diff --git a/README.md b/README.md index 42087b7..93c4dfc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # βš™ react-automat -> Zero-dependency higher-order state container for React `PureComponent`. State lives in module scope (independent of mounting). Components read state synchronously in `constructor` and subscribe in `componentDidMount`. No Context, hooks, or HOCs. +> Zero-dependency higher-order observable state container for React `PureComponent`. State lives in module scope (independent of component mounting). Components read state synchronously in `constructor` and subscribe in `componentDidMount`. No Context, hooks, or HOCs. [![npm version](https://img.shields.io/npm/v/react-automat?style=flat-square&color=crimson)](https://www.npmjs.com/package/react-automat) [![bundle size](https://img.shields.io/bundlephobia/minzip/react-automat?style=flat-square&color=22c55e&label=minzipped)](https://bundlephobia.com/package/react-automat) @@ -14,6 +14,61 @@ npm install react-automat --- +## Quick Start + +```js +// counterAutomat.js +import { Automat } from 'react-automat'; + +export const counterAutomat = new Automat( + { count: 0 }, + { + increment(step = 1) { + counterAutomat.setState({ count: counterAutomat.state.count + step }); + }, + decrement(step = 1) { + counterAutomat.setState({ count: counterAutomat.state.count - step }); + }, + } +); +``` + +```jsx +// Counter.jsx +import { PureComponent } from 'react'; +import { counterAutomat } from './counterAutomat.js'; + +export class Counter extends PureComponent { + constructor(props) { + super(props); + // 1. Read state directly in constructor (never stale) + this.state = counterAutomat.state; + } + + componentDidMount() { + // 2. Subscribe component to updates + this.unsubscribe = counterAutomat.subscribe(this); + } + + componentWillUnmount() { + // 3. Clean up on unmount + this.unsubscribe(); + } + + render() { + return ( +
+ {this.state.count} + + +
+ ); + } +} +``` + +--- + ## 1. Complete Type Signatures ```ts @@ -22,6 +77,7 @@ import type { PureComponent } from 'react'; export interface AutomatOptions { name?: string | null; // window.__AUTOMATS__ key & Automat.get(name) persist?: boolean; // false = window (default), true = IndexedDB + loader?: (automat: Automat) => Promise | void>; url?: string | null; // Auto-triggers fetcher(url) on init fetcher?: (url: string, automat?: Automat) => Promise; } @@ -44,6 +100,14 @@ export interface SSEOptions { onError?: (err: any, automat: Automat) => void; } +export type CombinedState>> = { + [K in keyof M]: M[K] extends Automat ? T : never; +}; + +export type CombinedActions>> = { + [K in keyof M]: M[K] extends Automat ? A : never; +}; + export class Automat> { constructor(initialState?: T | null, actions?: A, options?: AutomatOptions); @@ -60,6 +124,9 @@ export class Automat> { readonly url: string | null; readonly subscriberCount: number; + getData(options?: { reload?: boolean }): T; + load(options?: { reload?: boolean }): T; + subscribe(target: PureComponent | ((state: T, partial?: Partial) => void), selector?: Selector): () => void; select(selector: Selector): ScopedSlice; unsubscribe(target: any): void; @@ -82,7 +149,16 @@ export class Automat> { dispose(): void; // Unsubs all, revokes blobs, deletes from window registry static get(name: string): Automat | undefined; + + // Dictionary Combination: static combine>>(automats: M): Automat, CombinedActions>; + + // Array Combiner Function Combination: + static combine( + upstreamAutomats: Automat[], + combiner: (...states: any[]) => R, + options?: AutomatOptions + ): Automat>; } // Fetchers & SSE @@ -94,6 +170,8 @@ export function connectSSE(automat: Automat, url: string, options?: export function createSSEFetcher(sseUrl: string, baseFetcher?: Function, sseOptions?: SSEOptions): (url: string, automat?: any) => Promise; ``` +--- + ## 2. Canonical Usage Patterns ### A. PureComponent Subscription @@ -118,14 +196,65 @@ store.subscribe(this, ['theme', 'locale']); // Multi key store.subscribe(this, (s) => ({ items: s.list.length }));// Projection function ``` -### C. Unconnected Component via Window (Zero imports of store file) -```js -const store = Automat.get('cart'); // or window.__AUTOMATS__?.get('cart') -this.state = { count: store?.state?.items?.length || 0 }; -this.unsub = store?.subscribe(this, (s) => ({ count: s.items.length })); +### C. Async Fetch & Constructor Cache (Instant Re-Mount) +```jsx +class UserCard extends PureComponent { + constructor(props) { + super(props); + // πŸ’‘ Synchronous read in constructor: + // If already loaded, returns immediately -> 1 render only! + this.state = profileAsyncAutomat.getData(); + } + componentDidMount() { + this.unsubscribe = profileAsyncAutomat.subscribe(this); + } + componentWillUnmount() { + this.unsubscribe(); + } + render() { + const { status, data } = this.state; + if (status === 'pending') return
; + return
{data.name}
; + } +} ``` -### D. Backend API: React Suspense vs Standard +### D. Multi-API Orchestration (`Automat.combine`) +```js +const combinedDashboard = Automat.combine( + [userStatsAutomat, systemMetricsAutomat], + (stats, metrics) => ({ + stats: stats.data, + metrics: metrics.data, + }) +); + +class Dashboard extends PureComponent { + constructor(props) { + super(props); + // Instant resolution in constructor if both upstreams loaded earlier: + this.state = combinedDashboard.getData(); + } + componentDidMount() { + this.unsubscribe = combinedDashboard.subscribe(this); + } + componentWillUnmount() { + this.unsubscribe(); + } + render() { + const { status, stats, metrics } = this.state; + if (status === 'pending') return
Loading dual sources in parallel...
; + return ( +
+

{stats.name}

+
Cluster: {metrics.clusterHealth}
+
+ ); + } +} +``` + +### E. Backend API: React Suspense vs Standard ```jsx const user = new Automat(null, {}, { url: '/api/user' }); @@ -137,64 +266,51 @@ class ProfileStandard extends PureComponent { constructor(props) { super(props); this.state = { u: user.state }; } componentDidMount() { this.unsub = user.subscribe(this, (u) => ({ u })); } componentWillUnmount() { this.unsub(); } - render() { return !user.isReady ? :
{this.state.u.name}
; } + render() { + if (!user.isReady) return
Loading...
; + return
{this.state.u.name}
; + } } ``` -### E. Cascading Invalidation, Blobs & Memory Freeing +### F. Cascading Invalidation & Memory Freeing with Blobs ```js -const all = new Automat(null, {}, { name: 'photos/all', url: '/api/photos' }); -const p1 = new Automat(null, {}, { name: 'photo/1', url: '/api/photos/1', fetcher: fetchImageBlob }); -p1.invalidateWith(all); // p1 cascades setDirty() when all.setDirty() is called +const photoHub = new Automat(null, {}, { url: '/api/photos' }); +const photo1 = new Automat(null, {}, { url: '/api/photos/1', fetcher: fetchImageBlob }); -all.setDirty(); // p1 auto-revokes held Blob URL, resets state=null, defers reload if unmounted +// Automatically cascades setDirty when hub is dirtied +photo1.invalidateWith(photoHub); + +// When photoHub.setDirty() is triggered: +// 1. photo1 calls URL.revokeObjectURL(photo1.state.url) to free browser image memory +// 2. photo1 resets to null/initial state +// 3. If photo1 is mounted, re-fetches immediately; if unmounted, defers fetch! ``` -### F. SSE Remote Trigger -```js -const live = new Automat({}, { - onSSETrigger(data, event, store) { - if (event === 'invalidate') store.setDirty(); - if (event === 'reload') store.reload(); - } -}); -connectSSE(live, '/api/stream'); -``` +--- -### G. Combine Automats -```js -const app = Automat.combine({ - auth: authAutomat, - company: companyAutomat, -}); +## 3. Core Invariants -app.state.auth; // authAutomat.state -app.state.company; // companyAutomat.state -app.actions.auth.login(); // authAutomat.actions.login() -app.actions.company.rename('Acme'); +1. **Lifecycle-Independent**: `Automat` instances live outside the React tree (typically in module scope), persisting state across component mounts and unmounts. +2. **Synchronous Constructor Reads**: Components initialize with `this.state = myAutomat.state;` directly in `constructor(props)`. +3. **Automatic Shallow Merges**: When subscribed with `subscribe(this)`, `automat.setState(partial)` calls `component.setState(partial)`, preserving any component-local state. +4. **Fine-Grained Selectors**: Passing a selector to `subscribe(this, selector)` runs shallow equality checks; updates to unrelated fields skip `setState` and avoid re-renders. +5. **Zero Wrappers**: No hooks, HOCs, Context Providers, or `connect()`. +6. **Optional Persistence**: Automats can specify a `name` and persist in the `window` object (`persist: false`) for session memory across unmounts/HMR, or in `IndexedDB` (`persist: true`) to survive full page reloads. -// Notified when either child changes; selectors can isolate a branch. -const unsubscribe = app.subscribe(this, (state) => ({ - user: state.auth.user, - companyName: state.company.name, -})); -``` +--- -The aggregate is derived: child updates automatically update the matching key. Its -`isReady`, `isDirty`, `ready`, `read()`, `setDirty()`, and `reload()` operations cover -all children. Calling `app.dispose()` only removes aggregate subscriptions; it does -not dispose the child automats. +## Best Practices -## 3. Decision Matrix: `setDirty()` vs `reload()` +### βœ… DO +- Define `Automat` instances in module scope. +- Read `automat.state` or `automat.getData()` directly in `constructor(props)`. +- Subscribe in `componentDidMount()` and unsubscribe in `componentWillUnmount()`. +- Use selectors (`'key'`, `['keys']`, or function) on multi-field automats to avoid unnecessary re-renders. +- Return `null` in `subscribeTo` transforms when an update should be skipped. -| Method | `isReady` | State during fetch | Suspense | Unmounted | Use Case | -|---|---|---|---|---|---| -| `setDirty()` | `false` | Resets to `null`/init | Throws fallback | Deferred until mount/read | Cache bust, logout, hard reset | -| `reload()` | `true` | Preserved | Never suspends | Immediate fetch | Polling, pull-to-refresh, sync | - -## 4. Invariant Rules -- Read `store.state` synchronously in `constructor(props)` β€” never in render bodies. -- Subscribe in `componentDidMount()` & store the returned `unsub`; unsubscribe in `componentWillUnmount()`. -- Never mutate `store.state` directly (`store.state.x = 1` ❌); use `store.setState({ x })` or actions. -- Pass selectors to `subscribe(this, selector)` on multi-key stores to skip redundant renders. -- Never wrap components in Context Providers, HOCs, or custom hooks. +### ❌ DON'T +- Do not instantiate `Automat` inside React component lifecycle or render methods. +- Do not mutate state directly (`automat.state.count = 1`); use `setState()` or actions. +- Do not wrap components in React Context providers or HOCs. +- Do not forget to unsubscribe in `componentWillUnmount()`. diff --git a/src/App.jsx b/src/App.jsx index 63843c0..86dc36e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -10,6 +10,10 @@ import DynamicAutomatSubscriber from './examples/components/DynamicAutomatSubscr import CartManager from './examples/components/CartManager.jsx'; import CartBadgeDisplay from './examples/components/CartBadgeDisplay.jsx'; import UnconnectedPartialSubscriber from './examples/components/UnconnectedPartialSubscriber.jsx'; +import AsyncApiPrimaryView from './examples/components/AsyncApiPrimaryView.jsx'; +import AsyncApiCollapsibleView from './examples/components/AsyncApiCollapsibleView.jsx'; +import MultiApiIndividualControls from './examples/components/MultiApiIndividualControls.jsx'; +import MultiApiCombinedDashboard from './examples/components/MultiApiCombinedDashboard.jsx'; import ApiBoundExample from './examples/components/ApiBoundExample.jsx'; import CascadingPhotosExample from './examples/components/CascadingPhotosExample.jsx'; import CombinedAutomatExample from './examples/components/CombinedAutomatExample.jsx'; @@ -26,31 +30,35 @@ class App extends PureComponent { v0.1.0

- Higher-order state container for React{' '} + Higher-order observable state container for React{' '} PureComponent
State lives independently of mounting β€” zero prop drilling Β· Optional Window and IndexedDB persistence.

-
- {/* ── Example 1: Counter ────────────────────────────────── */} + {/* ── Notification Bar ───────────────────────────────────── */} + + + {/* ── Main Content ───────────────────────────────────────── */} +
+ {/* ── Example 1: Basic Counter ──────────────────────────── */}
01 -

Counter

+

Multi-Subscriber Sync & Local State Isolation

- Two independent PureComponents - synchronized by a single counterAutomat. - Neither knows the other exists β€” they both subscribe to the same state container. + Two completely separate components subscribe to the same counterAutomat. + The controller button also maintains local UI state (lastPressed, animation classes). + Neither component passes props to the other.

-
- counterAutomat +
+ CounterButton + β†’ + counterAutomat.actions.increment() β†’ - CounterButton - & CounterDisplay
@@ -63,85 +71,51 @@ class App extends PureComponent {
- {/* ── Example 2: Cascade ───────────────────────────────── */} + {/* ── Example 2: Cascading State ────────────────────────── */}
02 -

Notification Cascade

+

Cascading Automats (Reactive Transformation)

- subscribeTo() connects two automats into a - reactive pipeline. Clicking triggers in CascadeControls mutates{' '} - counterAutomat (upstream). This automatically invokes the{' '} - subscribeTo() transform callback, updating{' '} - notificationAutomat (downstream), which then re-renders{' '} - NotificationBar without any prop drilling or shared component parent. + notificationAutomat derives its state from{' '} + counterAutomat using subscribeTo. + Every state change cascades automatically without manual event dispatching.

- User Click + counterAutomat β†’ - counterAutomat.actions.increment() + transform(counterState) β†’ - subscribeTo(upstream, transform) - β†’ - notificationAutomat.setState() - β†’ - NotificationBar -
- -
-
- ℹ️ How subscribeTo(upstreamAutomat, transform) Works -
-
    -
  • - upstreamState: The newly emitted state from the upstream automat (e.g. {`{ count: 1 }`}). -
  • -
  • - myState: The current state of this downstream automat right before updating (e.g. {`{ messages: [...] }`}). Acts as an accumulator to prepend events and cap history. -
  • -
  • - Return Value: The returned partial state object is automatically applied via this.setState(partial), notifying downstream UI subscribers. -
  • -
  • - Conditional Filtering: Return null or undefined to selectively skip updates and avoid triggering renders. -
  • -
+ notificationAutomat
-
- - -
+
- {/* ── Example 3: API Auto-Sync ─────────────────────────── */} + {/* ── Example 3: Backend Synchronization ────────────────── */}
03 -

API-Backed Auto-Sync Counter (POST)

+

Backend Synchronization & Optimistic Updates

- syncCounterAutomat manages an indexed counter that automatically - synchronizes its mutations to the backend via HTTP POST /api/counter. - State updates are applied optimistically for instant UI response, followed by asynchronous - background synchronization and status tracking without any component-level fetch or lifecycle glue. + State changes trigger asynchronous backend persistence via a custom fetch handler in the Automat’s actions. + A polling subscriber independently monitors the server-side state without polling the Automat itself.

-
- User Click +
+ SyncCounterControls β†’ - Optimistic setState() + syncCounterAutomat + ⇄ + Backend API β†’ - POST /api/counter - β†’ - Backend Database Updated - β†’ - Sync Status 200 OK + SyncBackendMonitor
@@ -153,25 +127,24 @@ class App extends PureComponent {
- {/* ── Example 4: Dynamic Map Resubscription ──────────── */} + {/* ── Example 4: Dynamic Slot Allocation & Persistence ──── */}
04 -

Dynamic Window Map (Index-Driven Resubscription)

+

Dynamic Slot Allocation & Unmount-Safe State Recall

- The left side is a standard indexAutomat counter (like Example 1). - On the right side, that counter’s value is used to dynamically lookup, instantiate, and resubscribe to - a different Automat stored in window.automats = new Map(). - Stepping through indexes unhooks from the old Automat, subscribes to the new one, and instantly recalls its preserved state. + Demonstrates how state lives outside the React component tree. + Changing the active index resubscribes the display component to a different named Automat. + Previous slot state is never destroyed on unmount β€” return to any slot at any time to find its clicks and notes intact.

-
- Shared Index: N +
+ IndexSelector β†’ - window.automats.get(N) + indexAutomat.state.index β†’ - Unsub Old / Sub New + Unsub Old Slot / Sub New Slot β†’ Recall State for Slot #N
@@ -211,12 +184,12 @@ class App extends PureComponent {
1. CartBadgeDisplay subscribes only to list length (items.length) β€” item quantity and coupon edits cause zero re-renders.
- 2. UnconnectedPartialSubscriber has zero imports of the cart store, resolving it strictly via the named window object (Automat.get('cart')), and subscribes only to ['couponCode']. + 2. UnconnectedPartialSubscriber has zero imports of the cart store, resolving it strictly via the named window object (Automat.get('cart')), and subscribes only to ['couponCode'].

cartAutomat.setState() β†’ - window.__AUTOMATS__.get('cart') + window.__AUTOMATS__.get('cart') β†’ shallowEqual(lastSlice, nextSlice) β†’ @@ -235,15 +208,86 @@ class App extends PureComponent {
- {/* ── Example 6: Backend API Binding & Suspense ─────── */} + {/* ── Example 6: Async Fetch & Constructor Cache ──────── */}
06 -

Backend API Binding, React Suspense & Dirty Invalidation

+

Async API Fetch & Constructor Cache (Instant Re-Mount)

- An automat can bind directly to a backend URL (url: '/api/profile'). + An automat fetching asynchronous data via a simulated delayed API. When the primary component mounts, + calling profileAsyncAutomat.getData() in its constructor returns{' '} + {`{ status: 'pending' }`}, rendering a loading state (1st render), then re-rendering + when the fetch finishes (2nd render). When a second component mounts (or when unmounted and remounted via the collapsible toggle), + the data is already loaded in memory β€” the constructor receives the resolved payload synchronously, rendering in{' '} + 1 render with zero 2nd render. +

+
+ Mount 1 Constructor + β†’ + status: 'pending' (Render #1) + β†’ + API Resolves + β†’ + Render #2 (Data) + | + Mount 2 (Collapsible) + β†’ + Constructor Has Data (1 Render Only) +
+
+ +
+ + +
+
+ +
+ + {/* ── Example 7: Combined Multi-API Automats ────────────── */} +
+
+
+ 07 +

Combined Multi-API Automats (Concurrent Orchestration)

+
+

+ Three automats in an orchestrated graph: two with delayed APIs (userStatsAutomat at ~700ms and{' '} + systemMetricsAutomat at ~1300ms), and one combined automat created with{' '} + Automat.combine(). When accessed while upstreams are idle, it triggers both to load in parallel, + merging results when both finish. However, if both have already been accessed earlier, the combined automat resolves{' '} + instantly in the component constructor with zero delay and a single render. +

+
+ combinedDashboardAutomat.getData() + β†’ + Check Upstreams + β†’ + If Idle: Fetch Concurrently (~1300ms parallel) + | + If Cached: Instant Constructor Return (1 Render) +
+
+ +
+ + +
+
+ +
+ + {/* ── Example 8: Backend API Binding & Suspense ─────── */} +
+
+
+ 08 +

Backend API Binding, React Suspense & Dirty Invalidation

+
+

+ An automat can bind directly to a backend URL (url: '/api/profile'). Supports await automat.ready, React Suspense via automat.read(), and manual invalidation via automat.setDirty(). When dirty, state resets to default or null. If mounted, it reloads immediately. If all subscribers are unmounted, @@ -265,12 +309,12 @@ class App extends PureComponent {

- {/* ── Example 7: Cascading setDirty, Blobs & SSE ────── */} -
+ {/* ── Example 9: Cascading setDirty, Blobs & SSE ────── */} +
- 07 -

Cascading setDirty, Binary Blobs & Server-Sent Events (SSE)

+ 09 +

Cascading setDirty, Binary Blobs & Server-Sent Events (SSE)

Demonstrates cascading invalidation, automatic Blob memory revoking, @@ -302,15 +346,15 @@ class App extends PureComponent {

- {/* ── Example 8: Combined Automats ──────────────────── */} -
+ {/* ── Example 10: Dictionary Combined Automats ───────── */} +
- 08 -

Combined Automats

+ 10 +

Dictionary Combined Automats

- Automat.combine() creates one derived application state from independent automats. + Automat.combine() with an object map creates one derived application state from independent automats. Updates retain their namespace, actions remain grouped by child, and readiness operations cover every child.

@@ -327,6 +371,13 @@ class App extends PureComponent {
+ +
+

+ react-automat Β· MIT Β·{' '} + State is independent of component lifecycle +

+
); } diff --git a/src/examples/automats/combinedMultiAutomat.js b/src/examples/automats/combinedMultiAutomat.js new file mode 100644 index 0000000..45140eb --- /dev/null +++ b/src/examples/automats/combinedMultiAutomat.js @@ -0,0 +1,146 @@ +import { Automat } from '../../lib/index.js'; + +/** + * Simulated delayed API #1: User Stats (~700ms latency) + */ +export async function fetchUserStats(delayMs = 700) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return { + name: 'Sarah Connor', + handle: '@sconnor_ops', + avatar: 'πŸ›‘οΈ', + level: 'Tier-1 Defense Architect', + deploymentsCount: 94, + uptimeRating: '99.98%', + lastAuditScore: '98/100', + fetchedAt: new Date().toLocaleTimeString(), + }; +} + +/** + * Simulated delayed API #2: System Metrics & Events (~1300ms latency) + */ +export async function fetchSystemMetrics(delayMs = 1300) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return { + clusterHealth: 'Healthy', + activeNodes: 16, + avgResponseMs: 19, + events: [ + { id: 1, type: 'firewall', text: 'Firewall updated: VPC ingress port 8443 opened', time: '2m ago' }, + { id: 2, type: 'kernel', text: 'Kernel security patch automat_v0.1.0 verified', time: '14m ago' }, + { id: 3, type: 'backup', text: 'Automated disk snapshot created (3.2 GB)', time: '40m ago' }, + ], + fetchedAt: new Date().toLocaleTimeString(), + }; +} + +/** + * Automat A: User Stats (Delayed API) + */ +export const userStatsAutomat = new Automat( + { + status: 'idle', // 'idle' | 'pending' | 'success' | 'error' + data: null, + error: null, + delayMs: 700, + fetchCount: 0, + }, + { + reset() { + userStatsAutomat.setState({ status: 'idle', data: null, error: null }); + }, + }, + { + name: 'user_stats_multi', + persist: false, + loader: async (automat) => { + const { delayMs, fetchCount } = automat.state; + const data = await fetchUserStats(delayMs); + return { data, fetchCount: fetchCount + 1 }; + }, + } +); + +/** + * Automat B: System Metrics (Delayed API) + */ +export const systemMetricsAutomat = new Automat( + { + status: 'idle', // 'idle' | 'pending' | 'success' | 'error' + data: null, + error: null, + delayMs: 1300, + fetchCount: 0, + }, + { + reset() { + systemMetricsAutomat.setState({ status: 'idle', data: null, error: null }); + }, + }, + { + name: 'system_metrics_multi', + persist: false, + loader: async (automat) => { + const { delayMs, fetchCount } = automat.state; + const data = await fetchSystemMetrics(delayMs); + return { data, fetchCount: fetchCount + 1 }; + }, + } +); + +/** + * Automat C: Combined Dashboard Automat + * + * Combines `userStatsAutomat` and `systemMetricsAutomat`. + * + * Invariants: + * 1. If accessed when both upstreams are idle: + * Triggers BOTH upstreams to fetch concurrently in parallel. + * Tracks live progress (0/2 -> 1/2 -> 2/2) and transitions to 'success' + * when both finish (total time ~1300ms instead of 2000ms sequential). + * 2. If accessed when both upstreams were ALREADY loaded earlier: + * Resolves INSTANTLY in the component constructor! + * State has `status: 'success'` and complete data on the 1st render (no 2nd render). + */ +export const combinedDashboardAutomat = Automat.combine( + [userStatsAutomat, systemMetricsAutomat], + (statsState, metricsState) => { + const statsDone = statsState?.status === 'success'; + const metricsDone = metricsState?.status === 'success'; + const doneCount = (statsDone ? 1 : 0) + (metricsDone ? 1 : 0); + + return { + stats: statsState?.data || null, + metrics: metricsState?.data || null, + statsStatus: statsState?.status || 'idle', + metricsStatus: metricsState?.status || 'idle', + doneCount, + totalCount: 2, + progressText: `${doneCount}/2 sources resolved`, + lastCombinedAt: new Date().toLocaleTimeString(), + }; + }, + { + name: 'combined_dashboard', + persist: false, + } +); + +/** + * Helper to reset all three automats back to idle for testing. + */ +export function resetMultiApiDemo() { + userStatsAutomat.actions.reset(); + systemMetricsAutomat.actions.reset(); + combinedDashboardAutomat.setState({ + status: 'idle', + stats: null, + metrics: null, + statsStatus: 'idle', + metricsStatus: 'idle', + doneCount: 0, + totalCount: 2, + progressText: '0/2 sources resolved', + }); +} diff --git a/src/examples/automats/indexAutomat.js b/src/examples/automats/indexAutomat.js index 53a9947..00f83c9 100644 --- a/src/examples/automats/indexAutomat.js +++ b/src/examples/automats/indexAutomat.js @@ -22,10 +22,11 @@ export const indexAutomat = new Automat( ); /** - * Global Map on window holding dynamically instantiated slot Automats per index. + * Global Map on window holding dynamically instantiated Automats per index. + * Linked to window.__AUTOMATS__ for registry discovery. */ -if (typeof window !== 'undefined' && !window.automats) { - window.automats = new Map(); +if (typeof window !== 'undefined') { + window.automats = window.__AUTOMATS__ || (window.__AUTOMATS__ = new Map()); } /** diff --git a/src/examples/automats/profileAsyncAutomat.js b/src/examples/automats/profileAsyncAutomat.js new file mode 100644 index 0000000..ffd78c0 --- /dev/null +++ b/src/examples/automats/profileAsyncAutomat.js @@ -0,0 +1,93 @@ +import { Automat } from '../../lib/index.js'; + +/** + * Simulated dummy API with realistic asynchronous latency. + * + * @param {number} delayMs + * @param {boolean} shouldFail + * @returns {Promise} + */ +export async function fetchDummyProfile(delayMs = 800, shouldFail = false) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + + if (shouldFail) { + throw new Error('Simulated upstream gateway timeout (HTTP 504 Gateway Timeout)'); + } + + return { + id: 'usr_8492', + name: 'Elena Rostova', + handle: '@elena_dev', + role: 'Principal Systems Architect', + avatar: 'πŸ‘©β€πŸ’»', + statusMessage: 'Optimizing reactive state pipelines', + quota: { used: 74, total: 100, unit: 'GB' }, + endpoint: 'us-east-1.cloud.automat.internal', + pingMs: Math.floor(18 + Math.random() * 15), + tags: ['Production', 'Kubernetes', 'WebSockets', 'GraphQL'], + lastFetchedAt: new Date().toLocaleTimeString(), + }; +} + +/** + * Async API-backed Automat. + * + * Demonstrates: + * 1. An Automat that fetches an API with simulated latency. + * 2. When a component accesses `getData()` in its constructor while status is 'idle', + * it receives `{ status: 'pending' }` immediately and renders the pending state (1st render). + * 3. Once the API completes, `setState` notifies subscribers, re-rendering with data (2nd render). + * 4. When a different component (or the same component remounted via a collapsible) + * calls `getData()`, the data is ALREADY in memory (`status === 'success'`). + * It returns synchronously in constructor, rendering immediately with NO 2nd render needed! + */ +export const profileAsyncAutomat = new Automat( + { + status: 'idle', // 'idle' | 'pending' | 'success' | 'error' + data: null, + error: null, + delayMs: 800, + fetchCount: 0, + lastFetchedAt: null, + }, + { + setDelay(delayMs) { + profileAsyncAutomat.setState({ delayMs: Math.max(100, Number(delayMs) || 800) }); + }, + + reset() { + profileAsyncAutomat.setState({ + status: 'idle', + data: null, + error: null, + }); + }, + + async triggerError() { + const { delayMs, fetchCount } = profileAsyncAutomat.state; + profileAsyncAutomat.setState({ status: 'pending', error: null }); + try { + await fetchDummyProfile(delayMs, true); + } catch (err) { + profileAsyncAutomat.setState({ + status: 'error', + error: err.message, + fetchCount: fetchCount + 1, + }); + } + }, + }, + { + name: 'profile_async', + persist: false, + loader: async (automat) => { + const { delayMs, fetchCount } = automat.state; + const data = await fetchDummyProfile(delayMs, false); + return { + data, + fetchCount: fetchCount + 1, + lastFetchedAt: data.lastFetchedAt, + }; + }, + } +); diff --git a/src/examples/components/AsyncApiCollapsibleView.jsx b/src/examples/components/AsyncApiCollapsibleView.jsx new file mode 100644 index 0000000..f8cc181 --- /dev/null +++ b/src/examples/components/AsyncApiCollapsibleView.jsx @@ -0,0 +1,304 @@ +import { PureComponent } from 'react'; +import { profileAsyncAutomat } from '../automats/profileAsyncAutomat.js'; + +let secondaryMountSequence = 0; +const mountHistoryLog = []; + +/** + * Secondary Consumer Component. + * Unmounted and remounted by the parent collapsible toggle. + * + * Demonstrates: + * When this component mounts while data is already in memory: + * 1. `profileAsyncAutomat.getData()` returns { status: 'success', data } synchronously in `constructor()`. + * 2. 1st render displays final data immediately with ZERO delay. + * 3. `componentDidMount()` subscribes, but no state changes occur. + * 4. RENDER COUNT STAYS AT 1! No 2nd render is necessary! + */ +class SecondaryProfileConsumer extends PureComponent { + _renderCount = 0; + _constructorStatus = 'uninitialized'; + _mountId = 0; + + constructor(props) { + super(props); + this._mountId = ++secondaryMountSequence; + + // 1. πŸ’‘ Synchronous read in constructor: + this.state = profileAsyncAutomat.getData(); + this._constructorStatus = this.state.status; + + mountHistoryLog.unshift({ + mountId: this._mountId, + time: new Date().toLocaleTimeString(), + statusInConstructor: this._constructorStatus, + hadDataImmediately: this._constructorStatus === 'success', + }); + } + + componentDidMount() { + // 2. Subscribe for updates (e.g. if Primary triggers a refetch or reset): + this.unsubscribe = profileAsyncAutomat.subscribe(this); + } + + componentWillUnmount() { + if (this.unsubscribe) { + this.unsubscribe(); + } + } + + render() { + this._renderCount++; + const { status, data } = this.state; + const isSingleRenderSuccess = this._renderCount === 1 && status === 'success'; + + return ( +
+ {/* Mount Diagnostic Bar */} +
+
+
+ Lifecycle Stats + Session Mount +
+ + Mount #{this._mountId} + +
+ +
+
+ Render Efficiency + Renders this Mount +
+ + #{this._renderCount} + + {this._renderCount === 1 ? '(Single render!)' : '(Pending + Complete)'} + + +
+
+ + {/* Big visual banner when 0 second renders occurred */} + {isSingleRenderSuccess && ( +
+ ⚑ + + Zero 2nd render! Data was resolved in the constructor (status: 'success'). + +
+ )} + + {/* Content View */} + {status === 'pending' && ( +
+
+
Loading API in background...
+
+ )} + + {status === 'idle' && ( +
+ Automat is currently idle. (Open primary view or click refetch). +
+ )} + + {status === 'success' && data && ( +
+
+ {data.avatar} +
+
+ {data.name} ({data.handle}) +
+
{data.role}
+
+
+ +
+ Connected to: {data.endpoint} (Ping: {data.pingMs}ms) +
+
+ )} +
+ ); + } +} + +/** + * AsyncApiCollapsibleView β€” Right column of Section 06. + * + * Provides a collapsible panel that dynamically mounts and unmounts the + * `SecondaryProfileConsumer` component. + */ +export default class AsyncApiCollapsibleView extends PureComponent { + constructor(props) { + super(props); + this.state = { + isMounted: false, + }; + } + + toggleMount = () => { + this.setState((prev) => ({ isMounted: !prev.isMounted })); + }; + + render() { + const { isMounted } = this.state; + const automatState = profileAsyncAutomat.state; + + return ( +
+
+ πŸ“‚ +

Collapsible Consumer (Unmount Test)

+ No 2nd Render +
+ +
+

+ Toggle the collapsible below to completely unmount and remount this + component. When data is already loaded in profileAsyncAutomat, + its constructor immediately receives the cached payload β€” rendering in 1 render with no 2nd render. +

+ + {/* Current Automat Status Hint */} +
+ + Automat Cache State: + + + {automatState.status.toUpperCase()} + {automatState.status === 'success' ? ' (Data Ready)' : ''} + +
+ + {/* Collapsible Toggle Button */} + + + {/* Dynamically Mounted Child Component */} + {isMounted ? ( + + ) : ( +
+ Component is currently UNMOUNTED from the React tree. +
+ Click the button above to mount it and observe the constructor read. +
+ )} + + {/* Session Mount History Log */} + {mountHistoryLog.length > 0 && ( +
+
+ Session Mount History: +
+
+ {mountHistoryLog.slice(0, 5).map((entry) => ( +
+ Mount #{entry.mountId} ({entry.time}) + + {entry.hadDataImmediately ? '⚑ 1 render (instant)' : '⏳ 2 renders (pending)'} + +
+ ))} +
+
+ )} +
+
+ ); + } +} diff --git a/src/examples/components/AsyncApiPrimaryView.jsx b/src/examples/components/AsyncApiPrimaryView.jsx new file mode 100644 index 0000000..5d38ee6 --- /dev/null +++ b/src/examples/components/AsyncApiPrimaryView.jsx @@ -0,0 +1,327 @@ +import { PureComponent } from 'react'; +import { profileAsyncAutomat } from '../automats/profileAsyncAutomat.js'; + +/** + * AsyncApiPrimaryView β€” Left column of Section 06. + * + * Demonstrates: + * 1. Initial access to an asynchronous Automat: constructor calls `profileAsyncAutomat.getData()`. + * 2. On first mount when data is not yet loaded, status is 'pending'. + * 3. The component renders `pending` (Render #1). + * 4. In `componentDidMount()`, it subscribes to updates. + * 5. When the simulated API fetch completes, `setState` runs and the component renders again (Render #2). + */ +export default class AsyncApiPrimaryView extends PureComponent { + _renderCount = 0; + _constructorStatus = 'uninitialized'; + + constructor(props) { + super(props); + // 1. Initial read in constructor triggers the async loader if idle: + this.state = profileAsyncAutomat.getData(); + this._constructorStatus = this.state.status; + } + + componentDidMount() { + // 2. Subscribe to receive update when API fetch completes: + this.unsubscribe = profileAsyncAutomat.subscribe(this); + } + + componentWillUnmount() { + if (this.unsubscribe) this.unsubscribe(); + } + + handleReload = () => { + profileAsyncAutomat.reload(); + }; + + handleReset = () => { + profileAsyncAutomat.actions.reset(); + }; + + handleSimulateError = () => { + profileAsyncAutomat.actions.triggerError(); + }; + + handleDelayChange = (ms) => { + profileAsyncAutomat.actions.setDelay(ms); + }; + + render() { + this._renderCount++; + const { status, data, error, delayMs, fetchCount, lastFetchedAt } = this.state; + + return ( +
+
+ ⚑ +

Primary API Consumer

+ Constructor getData() +
+ +
+ {/* Diagnostic render & constructor status bar */} +
+
+
+ Mount Renders + Render Count +
+ + #{this._renderCount} + + ({this._renderCount === 1 ? '1st: Pending' : '2nd: Completed'}) + + +
+ +
+
+ Constructor Read + Initial Status +
+ + {this._constructorStatus} + +
+
+ + {/* Main Visual Display Area */} +
+ {/* Status: IDLE */} + {status === 'idle' && ( +
+ πŸ’€ +
+ Cache Cleared (State: Idle) +
+
+ Calling getData() will launch the delayed API request. +
+ +
+ )} + + {/* Status: PENDING */} + {status === 'pending' && ( +
+
+
+
+
+ Simulating API fetch... ({delayMs}ms delay) +
+
+ Constructor received status: 'pending'. Rendering 1st pass. +
+
+
+ + {/* Animated Gruvbox skeleton loaders */} +
+
+
+
+
+
+
+
+
+ )} + + {/* Status: ERROR */} + {status === 'error' && ( +
+ ⚠️ +
+ API Fetch Failed +
+
+ {error} +
+ +
+ )} + + {/* Status: SUCCESS */} + {status === 'success' && data && ( +
+
+
+ {data.avatar} +
+
+ {data.name} +
+
+ {data.handle} Β· {data.role} +
+
+
+ + 200 OK + +
+ +
+ “{data.statusMessage}” +
+ + {/* Quota bar & latency ping */} +
+
+
+ Cloud Quota ({data.quota.used}/{data.quota.total} {data.quota.unit}) +
+
+
+
+
+ +
+
Cluster Latency
+
+ ⚑ {data.pingMs}ms to internal VPC +
+
+
+ + {/* Tags */} +
+ {data.tags.map((tag) => ( + + {tag} + + ))} +
+
+ )} +
+ + {/* Latency Simulator & Control Actions */} +
+
+ Simulated Network Latency: + {delayMs}ms +
+
+ + + +
+
+ + {/* Action Buttons */} +
+ + + +
+ + {/* Footer Metadata */} +
+ API Fetch Calls: {fetchCount} + Last Sync: {lastFetchedAt || 'Never'} +
+
+
+ ); + } +} diff --git a/src/examples/components/MultiApiCombinedDashboard.jsx b/src/examples/components/MultiApiCombinedDashboard.jsx new file mode 100644 index 0000000..d399444 --- /dev/null +++ b/src/examples/components/MultiApiCombinedDashboard.jsx @@ -0,0 +1,389 @@ +import { PureComponent } from 'react'; +import { combinedDashboardAutomat } from '../automats/combinedMultiAutomat.js'; + +let combinedMountCounter = 0; + +/** + * CombinedDashboardView β€” dynamically mounted child component. + * + * Demonstrates: + * 1. If upstreams were already fetched earlier: + * Constructor immediately receives { status: 'success', stats, metrics }. + * Renders in 1 render with 0ms delay! + * 2. If upstreams were idle: + * Constructor receives { status: 'pending' }, triggering both in parallel. + * Renders progress (0/2 -> 1/2 -> 2/2) and then the complete dashboard. + */ +class CombinedDashboardView extends PureComponent { + _renderCount = 0; + _constructorStatus = 'uninitialized'; + _mountId = 0; + + constructor(props) { + super(props); + this._mountId = ++combinedMountCounter; + + // 1. Synchronous read in constructor: + this.state = combinedDashboardAutomat.getData(); + this._constructorStatus = this.state.status; + } + + componentDidMount() { + this.unsubscribe = combinedDashboardAutomat.subscribe(this); + } + + componentWillUnmount() { + if (this.unsubscribe) this.unsubscribe(); + } + + render() { + this._renderCount++; + const { + status, + stats, + metrics, + statsStatus, + metricsStatus, + doneCount, + totalCount, + progressText, + } = this.state; + + const isInstantSuccess = this._constructorStatus === 'success' && this._renderCount === 1; + + return ( +
+ {/* Diagnostic Bar */} +
+
+
+ Dashboard Mount + Mount Number +
+ + Mount #{this._mountId} + +
+ +
+
+ Render Efficiency + Renders this Mount +
+ + #{this._renderCount} + + {this._renderCount === 1 ? '(Single render!)' : `(Progressive update)`} + + +
+
+ + {/* Instant Constructor Resolution Callout */} + {isInstantSuccess && ( +
+ ⚑ + + Instant Resolution! Both upstreams were already in memory. Constructor received full payload immediately β€” 1 render total! + +
+ )} + + {/* Pending / Concurrent Loading Dual Progress View */} + {status === 'pending' && ( +
+
+
+
+ + Loading dual sources in parallel... + +
+ + {progressText || `${doneCount || 0}/${totalCount || 2} loaded`} + +
+ + {/* Dual Source Progress Cards */} +
+
+
Source A (Stats, ~700ms)
+
+ {statsStatus === 'success' ? 'βœ… Loaded' : '⏳ Fetching...'} +
+
+ +
+
Source B (Metrics, ~1300ms)
+
+ {metricsStatus === 'success' ? 'βœ… Loaded' : '⏳ Fetching...'} +
+
+
+ + {/* Overall Progress Bar */} +
+
+
+
+ )} + + {/* Combined Dashboard View (Success) */} + {status === 'success' && stats && metrics && ( +
+ {/* Merged Header */} +
+
+ {stats.avatar} +
+
+ {stats.name} +
+
{stats.level}
+
+
+ + Combined 200 OK + +
+ + {/* Quick Metrics Grid */} +
+
+
Deployments
+
{stats.deploymentsCount}
+
+
+
Uptime
+
{stats.uptimeRating}
+
+
+
Active Nodes
+
{metrics.activeNodes}
+
+
+ + {/* Recent Events from Source B */} +
+ Live Infrastructure Events (from Source B): +
+
+ {metrics.events.map((evt) => ( +
+ {evt.text} + {evt.time} +
+ ))} +
+
+ )} +
+ ); + } +} + +/** + * MultiApiCombinedDashboard β€” Right column of Section 07. + * + * Provides a collapsible panel to mount and unmount the combined dashboard. + */ +export default class MultiApiCombinedDashboard extends PureComponent { + constructor(props) { + super(props); + this.state = { + isMounted: false, + }; + } + + toggleMount = () => { + this.setState((prev) => ({ isMounted: !prev.isMounted })); + }; + + render() { + const { isMounted } = this.state; + const combinedState = combinedDashboardAutomat.state; + + return ( +
+
+ ⚑ +

Combined Dashboard Automat

+ Automat.combine() +
+ +
+

+ Combines two upstream automats. If both are already loaded, accessing this + automat resolves instantly in constructor with 1 render. If not, it triggers + both to load concurrently in parallel. +

+ + {/* Current Upstream Status Indicator */} +
+ + Upstream Readiness: + + + {combinedState.status === 'success' + ? 'BOTH READY (Instant Constructor)' + : combinedState.status === 'pending' + ? 'SYNCING UPSTREAMS' + : 'IDLE (Will trigger parallel fetch)'} + +
+ + {/* Mount / Unmount Toggle Button */} + + + {/* Mounted View or Unmounted Hint */} + {isMounted ? ( + + ) : ( +
+ Dashboard component is currently UNMOUNTED. +
+ Click the button above to mount and inspect constructor resolution. +
+ )} +
+
+ ); + } +} diff --git a/src/examples/components/MultiApiIndividualControls.jsx b/src/examples/components/MultiApiIndividualControls.jsx new file mode 100644 index 0000000..7c5ead0 --- /dev/null +++ b/src/examples/components/MultiApiIndividualControls.jsx @@ -0,0 +1,204 @@ +import { PureComponent } from 'react'; +import { + userStatsAutomat, + systemMetricsAutomat, + resetMultiApiDemo, +} from '../automats/combinedMultiAutomat.js'; + +/** + * MultiApiIndividualControls β€” Left column of Section 07. + * + * Controls and monitors the two individual upstream automats: + * 1. userStatsAutomat (delayed API, ~700ms) + * 2. systemMetricsAutomat (delayed API, ~1300ms) + */ +export default class MultiApiIndividualControls extends PureComponent { + constructor(props) { + super(props); + this.state = { + stats: userStatsAutomat.state, + metrics: systemMetricsAutomat.state, + }; + } + + componentDidMount() { + this.unsubStats = userStatsAutomat.subscribe((stats) => { + this.setState({ stats }); + }); + this.unsubMetrics = systemMetricsAutomat.subscribe((metrics) => { + this.setState({ metrics }); + }); + } + + componentWillUnmount() { + if (this.unsubStats) this.unsubStats(); + if (this.unsubMetrics) this.unsubMetrics(); + } + + handleFetchStats = () => { + userStatsAutomat.getData({ reload: true }); + }; + + handleFetchMetrics = () => { + systemMetricsAutomat.getData({ reload: true }); + }; + + handleResetAll = () => { + resetMultiApiDemo(); + }; + + render() { + const { stats, metrics } = this.state; + + return ( +
+
+ 🧩 +

Individual Delayed Automats

+ 2 Upstream APIs +
+ +
+

+ Two independent automats with simulated delayed APIs. You can trigger each individually, + or let the combined automat trigger both concurrently. +

+ + {/* Upstream A: User Stats */} +
+
+
+ πŸ›‘οΈ + Automat A: User Stats + (~700ms) +
+ + {stats.status.toUpperCase()} + +
+ + {stats.status === 'pending' && ( +
+
+ Fetching User Stats API... +
+ )} + + {stats.status === 'success' && stats.data && ( +
+ {stats.data.name} Β· {stats.data.level} (Uptime: {stats.data.uptimeRating}) +
+ )} + + +
+ + {/* Upstream B: System Metrics */} +
+
+
+ πŸ“Š + Automat B: System Metrics + (~1300ms) +
+ + {metrics.status.toUpperCase()} + +
+ + {metrics.status === 'pending' && ( +
+
+ Fetching System Metrics API... +
+ )} + + {metrics.status === 'success' && metrics.data && ( +
+ Cluster: {metrics.data.clusterHealth} Β· {metrics.data.activeNodes} Nodes Β· {metrics.data.events.length} Events +
+ )} + + +
+ + {/* Reset All Button */} + + + {/* Guided Testing Flow Hints */} +
+
+ πŸ’‘ Two Verification Flows: +
+
    +
  • + Flow 1 (Instant Constructor Resolution): Click both fetch buttons above. Once both show SUCCESS, expand the Combined Dashboard on the right. Notice it resolves instantly in constructor with 1 render! +
  • +
  • + Flow 2 (Concurrent Orchestration): Click Reset Both to Idle, then expand the Combined Dashboard directly. It triggers both APIs in parallel, finishing in ~1300ms total. +
  • +
+
+
+
+ ); + } +} diff --git a/src/index.css b/src/index.css index 0077794..a3f9a59 100644 --- a/src/index.css +++ b/src/index.css @@ -411,6 +411,40 @@ body { border: 1px solid rgba(131, 165, 152, 0.3); } +.badge-success { + background: rgba(184, 187, 38, 0.15); + color: var(--green); + border: 1px solid rgba(184, 187, 38, 0.3); +} + +/* ── Spinner & Skeleton Animations ─────────────────── */ + +.spinner-gruvbox { + width: 18px; + height: 18px; + border: 2px solid rgba(250, 189, 47, 0.25); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin-gruvbox 0.75s linear infinite; + display: inline-block; + flex-shrink: 0; +} + +@keyframes spin-gruvbox { + to { transform: rotate(360deg); } +} + +.skeleton-bar { + background: linear-gradient(90deg, var(--surface-1) 25%, var(--surface-2) 50%, var(--surface-1) 75%); + background-size: 200% 100%; + animation: skeleton-pulse 1.4s ease-in-out infinite; + border-radius: var(--r-sm); +} + +@keyframes skeleton-pulse { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} /* ── Buttons ───────────────────────────────────────── */ .btn { diff --git a/src/lib/Automat.js b/src/lib/Automat.js index 3606e84..b2ecb4f 100644 --- a/src/lib/Automat.js +++ b/src/lib/Automat.js @@ -1,73 +1,131 @@ /** * @module Automat * - * Higher-order state container designed for React PureComponent with optional - * persistence (Window or IndexedDB) and backend API binding. + * Higher-order observable state container for React PureComponents with optional + * persistence (Window or IndexedDB), backend API binding, cascading invalidation, + * and multi-automat combination. */ /** - * Shallow equality check between two values or objects. + * Performs a shallow equality check between two values or objects. + * Used by Automat subscribers to avoid unnecessary setState calls when + * a subscribed slice of state has not changed. */ -function shallowEqual(a, b) { +export function shallowEqual(a, b) { if (Object.is(a, b)) return true; if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false; - const kA = Object.keys(a); - return kA.length === Object.keys(b).length && kA.every((k) => Object.is(a[k], b[k])); + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + for (let i = 0; i < keysA.length; i++) { + const key = keysA[i]; + if (!Object.prototype.hasOwnProperty.call(b, key) || !Object.is(a[key], b[key])) { + return false; + } + } + return true; } // ── Global Window & Registry Aliases ────────────────────────────── const W = typeof window !== 'undefined' ? window : null; const getGlobalMap = (key) => W && (W[key] ||= new Map()); -// ── Unified IndexedDB Runner ────────────────────────────────────── +// ── Unified IndexedDB Storage ───────────────────────────────────── const IDB_NAME = 'automat_db'; const IDB_STORE = 'states'; let idbPromise = null; -const getIdb = () => - typeof indexedDB === 'undefined' - ? Promise.resolve(null) - : (idbPromise ||= new Promise((resolve) => { - try { - const req = indexedDB.open(IDB_NAME, 1); - req.onupgradeneeded = () => { - if (!req.result.objectStoreNames.contains(IDB_STORE)) { - req.result.createObjectStore(IDB_STORE); - } - }; - req.onsuccess = () => resolve(req.result); - req.onerror = () => resolve(null); - } catch { - resolve(null); - } - })); +function getIdb() { + if (typeof indexedDB === 'undefined') return Promise.resolve(null); + if (!idbPromise) { + idbPromise = new Promise((resolve) => { + try { + const req = indexedDB.open(IDB_NAME, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(IDB_STORE)) { + db.createObjectStore(IDB_STORE); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => resolve(null); + } catch { + resolve(null); + } + }); + } + return idbPromise; +} -const idbRun = (method, ...args) => - getIdb().then( - (db) => - db && - new Promise((resolve) => { - try { - const req = db - .transaction(IDB_STORE, method === 'get' ? 'readonly' : 'readwrite') - .objectStore(IDB_STORE)[method](...args); - req.onsuccess = req.oncomplete = () => resolve(req.result); - req.onerror = () => resolve(); - } catch { - resolve(); - } - }) - ); +function idbGet(key) { + return getIdb().then((db) => { + if (!db) return undefined; + return new Promise((resolve) => { + try { + const tx = db.transaction(IDB_STORE, 'readonly'); + const req = tx.objectStore(IDB_STORE).get(key); + req.onsuccess = () => resolve(req.result); + req.onerror = () => resolve(undefined); + } catch { + resolve(undefined); + } + }); + }); +} + +function idbSet(key, value) { + return getIdb().then((db) => { + if (!db) return; + return new Promise((resolve) => { + try { + const tx = db.transaction(IDB_STORE, 'readwrite'); + tx.objectStore(IDB_STORE).put(value, key); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + } catch { + resolve(); + } + }); + }); +} + +function idbDelete(key) { + return getIdb().then((db) => { + if (!db) return; + return new Promise((resolve) => { + try { + const tx = db.transaction(IDB_STORE, 'readwrite'); + tx.objectStore(IDB_STORE).delete(key); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + } catch { + resolve(); + } + }); + }); +} // ── Selector Normalizer ─────────────────────────────────────────── -const resolveSelector = (s) => - typeof s === 'function' - ? s - : Array.isArray(s) - ? (x) => x && Object.fromEntries(s.map((k) => [k, x[k]])) - : s - ? (x) => x && { [s]: x[s] } - : (x) => x; +function resolveSelector(s, isComponent = false) { + if (typeof s === 'function') { + return s; + } + if (typeof s === 'string') { + return isComponent ? (state) => (state ? { [s]: state[s] } : state) : (state) => state?.[s]; + } + if (Array.isArray(s)) { + return (state) => { + if (!state) return state; + const slice = {}; + for (let i = 0; i < s.length; i++) { + const k = s[i]; + slice[k] = state[k]; + } + return slice; + }; + } + return (state) => state; +} export class Automat { constructor(initialState = null, callbacks = {}, options = {}) { @@ -79,69 +137,73 @@ export class Automat { : null; this._state = this._initial ? { ...this._initial } : null; - this._cbs = callbacks; + const resolvedCallbacks = typeof callbacks === 'function' ? callbacks(this) : (callbacks || {}); + this._cbs = {}; + for (const [key, fn] of Object.entries(resolvedCallbacks)) { + this._cbs[key] = typeof fn === 'function' ? fn.bind(this) : fn; + } this._subs = new Map(); this._dirtySubs = new Set(); this._unsubs = []; this._name = options.name ?? null; this._persist = Boolean(options.persist); - this._url = options.url ?? null; - this._fetcher = options.fetcher || ((url) => fetch(url).then((r) => r.json())); + this._loader = options.loader ?? null; this._error = null; if (this._name) { getGlobalMap('__AUTOMATS__')?.set(this._name, this); } - this._isReady = !this._url && !(this._name && this._persist); this._isDirty = false; - this._ready = this._url - ? this._fetchData() - : this._name && this._persist - ? idbRun('get', this._name).then((saved) => { - if (saved && typeof saved === 'object') this.setState(saved); - this._isReady = true; - return this._state; - }) - : Promise.resolve( - this._name && !this._persist - ? ((this._state = { - ...this._state, - ...getGlobalMap('__AUTOMAT_STATE__')?.get(this._name), - }), - getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state), - this._state) - : this._state - ); + + if (this._loader) { + this._ready = this._fetchLoader(); + } else if (this._name && this._persist) { + this._ready = idbGet(this._name).then((saved) => { + if (saved && typeof saved === 'object') { + this.setState(saved); + } + return this._state; + }); + } else if (this._name && !this._persist) { + const saved = getGlobalMap('__AUTOMAT_STATE__')?.get(this._name); + if (saved && typeof saved === 'object') { + this._state = { ...this._state, ...saved }; + } + getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state); + this._ready = Promise.resolve(this._state); + } else { + this._ready = Promise.resolve(this._state); + } } /** - * Internal data fetcher for backend API binding. - * @param {boolean} [silent=false] If true, preserves isReady without unready flash. + * Internal data loader for function-backed automats. * @private */ - _fetchData(silent = false) { - if (!silent) this._isReady = false; + _fetchLoader() { this._isDirty = false; this._error = null; - const promise = this._fetcher(this._url, this) + const promise = Promise.resolve(this._loader(this)) .then((data) => { - this._state = data; - this._isReady = true; + if (data && typeof data === 'object') { + this._state = { ...this._state, ...data }; + } else if (data !== undefined) { + this._state = data; + } this._isDirty = false; this._error = null; if (this._name) { this._persist - ? idbRun('put', this._state, this._name) + ? idbSet(this._name, this._state) : getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state); } this._notify(this._state); return this._state; }) .catch((err) => { - this._error = err; - this._isReady = false; + this._error = err?.message || String(err); this._notify(this._state); throw err; }); @@ -152,21 +214,26 @@ export class Automat { /** * Marks the automat as dirty and unready. - * State resets to initial default or null (if no defaults were given). - * - * If subscribed anywhere, reloads immediately from backend API. - * If not subscribed anywhere, defers reload until read() or subscribe(). + * State resets to initial default or null. + * If subscribed anywhere and configured with a loader, triggers loader fetch immediately. */ - setDirty() { + setDirty(keep = false, now = false) { + let shouldKeep = Boolean(keep); + let shouldNow = Boolean(now); + if (typeof keep === 'object' && keep !== null) { + shouldKeep = Boolean(keep.keep); + shouldNow = Boolean(keep.now); + } if (typeof this._state?.revoke === 'function') { try { this._state.revoke(); } catch {} } this._isDirty = true; - this._isReady = false; - this._state = this._initial ? { ...this._initial } : null; - this._notify(this._state); + if (!shouldKeep) { + this._state = this._initial ? { ...this._initial } : null; + this._notify(this._state); + } if (this._dirtySubs) { for (const fn of this._dirtySubs) { @@ -176,41 +243,24 @@ export class Automat { } } - if (this._subs.size > 0 && this._url) { - return this._fetchData(); + if (this._loader && (shouldNow || this._subs.size > 0)) { + return this._fetchLoader(); } return Promise.resolve(this._state); } - /** - * Reloads data from the backend API without making the automat dirty or unready. - * Existing state remains intact and UI stays interactive until fresh data arrives. - * - * @returns {Promise} Resolves with the fresh state. - */ - reload() { - return this._url ? this._fetchData(true) : Promise.resolve(this._state); - } - - /** - * Alias for reload(). - */ - refresh() { - return this.reload(); - } - /** * Reads current state for React Suspense or imperative consumers. - * If dirty and unmounted, calling read() triggers deferred reload. + * If dirty and unmounted, triggers deferred fetch. * If unready, throws the ready promise for React Suspense. */ read() { - if (this._url) { + if (this._loader) { if (this._isDirty) { - this._fetchData(); + this._fetchLoader(); } - if (!this._isReady) { - if (this._error) throw this._error; + if (this._error) throw this._error; + if (this._state === null && this._ready) { throw this._ready; } } @@ -227,14 +277,31 @@ export class Automat { /** * Combines multiple automats into one derived automat. - * The combined state and actions use the same keys as the supplied map. - * Child updates are forwarded without taking ownership of the children. * - * @param {Record} automats + * Supports two signatures: + * 1. Array + Combiner function: + * Automat.combine([automatA, automatB], (stateA, stateB) => ({ ... }), options) + * 2. Object Dictionary: + * Automat.combine({ auth: authAutomat, company: companyAutomat }) + * + * @param {Automat[] | Record} upstream + * @param {Function} [combiner] + * @param {object} [options] * @returns {Automat} */ - static combine(automats) { - return new CombinedAutomat(automats); + static combine(upstream, combiner, options = {}) { + if (Array.isArray(upstream)) { + if (typeof combiner !== 'function') { + throw new TypeError('Automat.combine with array expects a combiner function as second argument.'); + } + return createOrchestratedCombinedAutomat(upstream, combiner, options); + } + + if (upstream && typeof upstream === 'object') { + return new CombinedAutomat(upstream); + } + + throw new TypeError('Automat.combine expects an array or dictionary object of Automat instances.'); } get name() { @@ -245,12 +312,8 @@ export class Automat { return this._persist; } - get url() { - return this._url; - } - - get isReady() { - return this._isReady; + get isPersisted() { + return this._persist; } get isDirty() { @@ -266,8 +329,8 @@ export class Automat { } get ready() { - if (this._url && this._isDirty) { - return this._fetchData(); + if (this._loader && this._isDirty) { + return this._fetchLoader(); } return this._ready; } @@ -275,50 +338,73 @@ export class Automat { async clearPersistence() { if (!this._name) return; this._persist - ? await idbRun('delete', this._name) + ? await idbDelete(this._name) : getGlobalMap('__AUTOMAT_STATE__')?.delete(this._name); } get state() { + if (this._loader && this._isDirty) { + this._fetchLoader(); + } return this._state; } getState() { - return this._state; + return this.state; } + /** + * Shallow-merges `partial` into current state and notifies all subscribers. + */ setState(partial) { - this._state = { ...this._state, ...partial }; + const update = typeof partial === 'function' ? partial(this._state) : partial; + this._state = { ...this._state, ...update }; if (this._name) { this._persist - ? idbRun('put', this._state, this._name) + ? idbSet(this._name, this._state) : getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state); } - this._notify(partial); + this._notify(update); return this._state; } + /** + * Subscribes a React PureComponent instance or a listener callback. + * Supports slice subscriptions with string, array, or function selectors. + * Uses shallow equality to prevent re-renders when the slice has not changed. + * + * @param {object|function} target React component (`this`) or callback function. + * @param {string|string[]|function} [selector] + * @returns {function} Unsubscribe cleanup function. + */ subscribe(target, selector) { const isComponent = target && typeof target.setState === 'function'; - if (!isComponent && typeof target !== 'function') { - throw new TypeError('Automat.subscribe expects a component or function.'); + const isFunction = typeof target === 'function'; + + if (!isComponent && !isFunction) { + throw new TypeError( + 'Automat.subscribe expects a callback function or a React component instance with a setState method.' + ); } - const getSlice = selector ? resolveSelector(selector) : null; + const hasSelector = selector !== undefined && selector !== null; + const getSlice = hasSelector ? resolveSelector(selector, isComponent) : null; let lastSlice = getSlice ? getSlice(this._state) : undefined; const notifyFn = (state, partial) => { if (getSlice) { const nextSlice = getSlice(state); - if (nextSlice == null && lastSlice == null) return; + if (nextSlice == null) return; if (shallowEqual(lastSlice, nextSlice)) return; lastSlice = nextSlice; if (isComponent) { - if (nextSlice !== null && typeof nextSlice !== 'object') { - throw new TypeError('Automat: selector must return an object.'); + if (typeof nextSlice !== 'object') { + throw new TypeError( + 'Automat: selector for a React component must return a state object, e.g. state => ({ count: state.count }).' + ); } - nextSlice ? target.setState(nextSlice) : target.forceUpdate(); + target.setState(nextSlice); } else { target(nextSlice); } @@ -336,8 +422,8 @@ export class Automat { this._subs.set(target, notifyFn); - if (this._url && this._isDirty) { - this._fetchData(); + if (this._loader && this._isDirty) { + this._fetchLoader(); } return () => { @@ -345,12 +431,18 @@ export class Automat { }; } + /** + * Slices this automat to a subset of state for reading and subscription. + * + * @param {string|string[]|function} selector + * @returns {{ readonly state: any, subscribe(target: object|function): function }} + */ select(selector) { - const slice = resolveSelector(selector); + const getSlice = resolveSelector(selector, false); const self = this; return { get state() { - return slice(self._state); + return getSlice(self._state); }, subscribe: (target) => self.subscribe(target, selector), }; @@ -363,7 +455,7 @@ export class Automat { /** * Registers a listener called whenever this automat becomes dirty. * @param {(automat: Automat) => void} fn - * @returns {() => void} Unsubscribe function + * @returns {() => void} */ onDirty(fn) { this._dirtySubs.add(fn); @@ -372,7 +464,7 @@ export class Automat { /** * Cascading invalidation: automatically sets this automat dirty whenever - * upstreamAutomat is set dirty. If filterFn is provided, only invalidates when it returns true. + * upstreamAutomat is set dirty. * * @param {Automat} upstreamAutomat * @param {(upstreamState: any, myState: any) => boolean} [filterFn] @@ -389,14 +481,13 @@ export class Automat { } /** - * Connects this automat to an upstream automat. - * Whenever upstream emits new state, transform(upstreamState, myState) runs. + * Connects this automat to derive state from an upstream automat. * * @param {Automat} upstreamAutomat * @param {(upstreamState: any, myState: any) => any} transform * @param {object} [options] * @param {boolean | ((upstreamState: any, myState: any) => boolean)} [options.cascadeDirty] - * @returns {this} + * @returns {this} Chainable. */ subscribeTo(upstreamAutomat, transform, options = {}) { const unsub = upstreamAutomat.subscribe((upstreamState) => { @@ -443,7 +534,35 @@ export class Automat { } /** - * Derived aggregate returned by Automat.combine(). + * Creates an orchestrated combined Automat from an array of upstreams and combiner function. + */ +function createOrchestratedCombinedAutomat(upstreamAutomats, combiner, options = {}) { + const computeInitial = () => { + const upstreamStates = upstreamAutomats.map((a) => a.state); + return combiner(...upstreamStates); + }; + + const combined = new Automat( + computeInitial(), + {}, + options + ); + + // Subscribe to all upstreams + upstreamAutomats.forEach((upstream) => { + combined.subscribeTo(upstream, () => { + const upstreamStates = upstreamAutomats.map((a) => a.state); + const combinedSlice = combiner(...upstreamStates); + combined.setState(combinedSlice); + return combinedSlice; + }); + }); + + return combined; +} + +/** + * Derived aggregate returned by Automat.combine({ auth: authAutomat, company: companyAutomat }). * Disposing it removes aggregate subscriptions but leaves its children alive. */ class CombinedAutomat extends Automat { @@ -469,7 +588,7 @@ class CombinedAutomat extends Automat { for (const [key, automat] of entries) { this._unsubs.push( automat.subscribe((state) => this.setState({ [key]: state })), - automat.onDirty(() => { + automat.onDirty?.(() => { for (const fn of this._dirtySubs) { try { fn(this); @@ -480,10 +599,6 @@ class CombinedAutomat extends Automat { } } - get isReady() { - return Object.values(this._children).every((automat) => automat.isReady); - } - get isDirty() { return Object.values(this._children).some((automat) => automat.isDirty); } @@ -501,29 +616,18 @@ class CombinedAutomat extends Automat { read() { this._state = Object.fromEntries( - Object.entries(this._children).map(([key, automat]) => [key, automat.read()]) + Object.entries(this._children).map(([key, automat]) => [key, automat.read?.() ?? automat.state]) ); return this._state; } - setDirty() { - return Promise.all(Object.values(this._children).map((automat) => automat.setDirty())).then(() => { + setDirty(keep = false, now = false) { + return Promise.all(Object.values(this._children).map((automat) => automat.setDirty?.(keep, now))).then(() => { this._syncState(); return this._state; }); } - reload() { - return Promise.all(Object.values(this._children).map((automat) => automat.reload())).then(() => { - this._syncState(); - return this._state; - }); - } - - refresh() { - return this.reload(); - } - _syncState() { this._state = Object.fromEntries( Object.entries(this._children).map(([key, automat]) => [key, automat.state]) diff --git a/src/lib/fetchers.js b/src/lib/fetchers.js index ffd07c6..ffd30d3 100644 --- a/src/lib/fetchers.js +++ b/src/lib/fetchers.js @@ -96,7 +96,11 @@ export const connectSSE = (automat, url, options = {}) => { if (eventName === 'invalidate' || eventName === 'dirty' || data?.action === 'invalidate') { automat.setDirty(); } else if (eventName === 'reload' || data?.action === 'reload') { - automat.reload(); + if (typeof automat.actions?.reload === 'function') { + automat.actions.reload(); + } else if (typeof automat.reload === 'function') { + automat.reload(); + } } }; diff --git a/src/lib/index.d.ts b/src/lib/index.d.ts index 101d65f..b4c739e 100644 --- a/src/lib/index.d.ts +++ b/src/lib/index.d.ts @@ -3,8 +3,7 @@ import type { PureComponent } from 'react'; export interface AutomatOptions { name?: string | null; persist?: boolean; - url?: string | null; - fetcher?: (url: string, automat?: Automat) => Promise; + loader?: (automat: Automat) => Promise> | T | Partial; } export type Selector = @@ -41,19 +40,21 @@ export type CombinedActions>> = { }; export class Automat> { - constructor(initialState?: T | null, actions?: A, options?: AutomatOptions); + constructor( + initialState?: T | null, + actions?: A | ((automat: Automat) => A), + options?: AutomatOptions + ); readonly state: T; getState(): T; setState(partial: Partial | ((prevState: T) => Partial)): T; readonly actions: A; - readonly isReady: boolean; readonly isDirty: boolean; readonly ready: Promise; readonly name: string | null; readonly persist: boolean; - readonly url: string | null; readonly subscriberCount: number; subscribe( @@ -77,9 +78,7 @@ export class Automat> { onDirty(fn: (automat: this) => void): () => void; - setDirty(): Promise; - reload(): Promise; - refresh(): Promise; + setDirty(keep?: boolean, now?: boolean): Promise; read(): T; clearPersistence(): Promise; @@ -89,6 +88,11 @@ export class Automat> { static combine>>( automats: M ): Automat, CombinedActions>; + static combine( + upstreamAutomats: Automat[], + combiner: (...states: any[]) => R, + options?: AutomatOptions + ): Automat>; } export function fetchJson(url: string, options?: RequestInit): Promise; diff --git a/src/lib/index.js b/src/lib/index.js index d0f5abc..288accd 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -1,7 +1,7 @@ /** * @module react-automat * - * Higher-order state container for React PureComponents with optional persistence. + * Observable higher-order state container for React PureComponents with optional persistence. * * @example * import { Automat } from './lib/index.js'; diff --git a/src/main.jsx b/src/main.jsx index ac0da59..3c1b45a 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -20,7 +20,6 @@ if (typeof window !== 'undefined' && typeof EventTarget !== 'undefined') { return originalAddEventListener.call(this, type, listener, opts); }; } - import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './index.css';