Merge remote-tracking branch 'sebgreen/main'

# Conflicts:
#	README.md
#	index.html
#	package-lock.json
#	package.json
#	src/App.jsx
#	src/examples/automats/indexAutomat.js
#	src/examples/components/DynamicAutomatSubscriber.jsx
#	src/index.css
#	src/lib/Automat.js
#	src/lib/index.js
#	src/main.jsx
#	vite.config.js
#	vite.config.lib.js
This commit is contained in:
seb
2026-09-23 16:21:20 +02:00
15 changed files with 2099 additions and 323 deletions

226
README.md
View File

@@ -1,6 +1,6 @@
# ⚙ react-automat # ⚙ 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) [![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) [![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 (
<div>
<span>{this.state.count}</span>
<button onClick={() => counterAutomat.actions.increment()}>+1</button>
<button onClick={() => counterAutomat.actions.decrement()}>-1</button>
</div>
);
}
}
```
---
## 1. Complete Type Signatures ## 1. Complete Type Signatures
```ts ```ts
@@ -22,6 +77,7 @@ import type { PureComponent } from 'react';
export interface AutomatOptions<T> { export interface AutomatOptions<T> {
name?: string | null; // window.__AUTOMATS__ key & Automat.get(name) name?: string | null; // window.__AUTOMATS__ key & Automat.get(name)
persist?: boolean; // false = window (default), true = IndexedDB persist?: boolean; // false = window (default), true = IndexedDB
loader?: (automat: Automat<T, any>) => Promise<Partial<T> | void>;
url?: string | null; // Auto-triggers fetcher(url) on init url?: string | null; // Auto-triggers fetcher(url) on init
fetcher?: (url: string, automat?: Automat<T, any>) => Promise<T>; fetcher?: (url: string, automat?: Automat<T, any>) => Promise<T>;
} }
@@ -44,6 +100,14 @@ export interface SSEOptions<T = any, A = any> {
onError?: (err: any, automat: Automat<T, A>) => void; onError?: (err: any, automat: Automat<T, A>) => void;
} }
export type CombinedState<M extends Record<string, Automat<any, any>>> = {
[K in keyof M]: M[K] extends Automat<infer T, any> ? T : never;
};
export type CombinedActions<M extends Record<string, Automat<any, any>>> = {
[K in keyof M]: M[K] extends Automat<any, infer A> ? A : never;
};
export class Automat<T = any, A = Record<string, Function>> { export class Automat<T = any, A = Record<string, Function>> {
constructor(initialState?: T | null, actions?: A, options?: AutomatOptions<T>); constructor(initialState?: T | null, actions?: A, options?: AutomatOptions<T>);
@@ -60,6 +124,9 @@ export class Automat<T = any, A = Record<string, Function>> {
readonly url: string | null; readonly url: string | null;
readonly subscriberCount: number; readonly subscriberCount: number;
getData(options?: { reload?: boolean }): T;
load(options?: { reload?: boolean }): T;
subscribe(target: PureComponent | ((state: T, partial?: Partial<T>) => void), selector?: Selector<T>): () => void; subscribe(target: PureComponent | ((state: T, partial?: Partial<T>) => void), selector?: Selector<T>): () => void;
select<S = any>(selector: Selector<T, S>): ScopedSlice<S>; select<S = any>(selector: Selector<T, S>): ScopedSlice<S>;
unsubscribe(target: any): void; unsubscribe(target: any): void;
@@ -82,7 +149,16 @@ export class Automat<T = any, A = Record<string, Function>> {
dispose(): void; // Unsubs all, revokes blobs, deletes from window registry dispose(): void; // Unsubs all, revokes blobs, deletes from window registry
static get<T = any, A = any>(name: string): Automat<T, A> | undefined; static get<T = any, A = any>(name: string): Automat<T, A> | undefined;
// Dictionary Combination:
static combine<M extends Record<string, Automat<any, any>>>(automats: M): Automat<CombinedState<M>, CombinedActions<M>>; static combine<M extends Record<string, Automat<any, any>>>(automats: M): Automat<CombinedState<M>, CombinedActions<M>>;
// Array Combiner Function Combination:
static combine<R = any>(
upstreamAutomats: Automat<any, any>[],
combiner: (...states: any[]) => R,
options?: AutomatOptions<R>
): Automat<R, Record<string, Function>>;
} }
// Fetchers & SSE // Fetchers & SSE
@@ -94,6 +170,8 @@ export function connectSSE<T, A>(automat: Automat<T, A>, url: string, options?:
export function createSSEFetcher(sseUrl: string, baseFetcher?: Function, sseOptions?: SSEOptions): (url: string, automat?: any) => Promise<any>; export function createSSEFetcher(sseUrl: string, baseFetcher?: Function, sseOptions?: SSEOptions): (url: string, automat?: any) => Promise<any>;
``` ```
---
## 2. Canonical Usage Patterns ## 2. Canonical Usage Patterns
### A. PureComponent Subscription ### A. PureComponent Subscription
@@ -118,14 +196,65 @@ store.subscribe(this, ['theme', 'locale']); // Multi key
store.subscribe(this, (s) => ({ items: s.list.length }));// Projection function store.subscribe(this, (s) => ({ items: s.list.length }));// Projection function
``` ```
### C. Unconnected Component via Window (Zero imports of store file) ### C. Async Fetch & Constructor Cache (Instant Re-Mount)
```js ```jsx
const store = Automat.get('cart'); // or window.__AUTOMATS__?.get('cart') class UserCard extends PureComponent {
this.state = { count: store?.state?.items?.length || 0 }; constructor(props) {
this.unsub = store?.subscribe(this, (s) => ({ count: s.items.length })); 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 <div className="spinner" />;
return <div>{data.name}</div>;
}
}
``` ```
### 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 <div>Loading dual sources in parallel...</div>;
return (
<div>
<h1>{stats.name}</h1>
<div>Cluster: {metrics.clusterHealth}</div>
</div>
);
}
}
```
### E. Backend API: React Suspense vs Standard
```jsx ```jsx
const user = new Automat(null, {}, { url: '/api/user' }); 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 }; } constructor(props) { super(props); this.state = { u: user.state }; }
componentDidMount() { this.unsub = user.subscribe(this, (u) => ({ u })); } componentDidMount() { this.unsub = user.subscribe(this, (u) => ({ u })); }
componentWillUnmount() { this.unsub(); } componentWillUnmount() { this.unsub(); }
render() { return !user.isReady ? <Spinner /> : <div>{this.state.u.name}</div>; } render() {
if (!user.isReady) return <div>Loading...</div>;
return <div>{this.state.u.name}</div>;
}
} }
``` ```
### E. Cascading Invalidation, Blobs & Memory Freeing ### F. Cascading Invalidation & Memory Freeing with Blobs
```js ```js
const all = new Automat(null, {}, { name: 'photos/all', url: '/api/photos' }); const photoHub = new Automat(null, {}, { url: '/api/photos' });
const p1 = new Automat(null, {}, { name: 'photo/1', url: '/api/photos/1', fetcher: fetchImageBlob }); const photo1 = new Automat(null, {}, { url: '/api/photos/1', fetcher: fetchImageBlob });
p1.invalidateWith(all); // p1 cascades setDirty() when all.setDirty() is called
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 ## 3. Core Invariants
```js
const app = Automat.combine({
auth: authAutomat,
company: companyAutomat,
});
app.state.auth; // authAutomat.state 1. **Lifecycle-Independent**: `Automat` instances live outside the React tree (typically in module scope), persisting state across component mounts and unmounts.
app.state.company; // companyAutomat.state 2. **Synchronous Constructor Reads**: Components initialize with `this.state = myAutomat.state;` directly in `constructor(props)`.
app.actions.auth.login(); // authAutomat.actions.login() 3. **Automatic Shallow Merges**: When subscribed with `subscribe(this)`, `automat.setState(partial)` calls `component.setState(partial)`, preserving any component-local state.
app.actions.company.rename('Acme'); 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 ## Best Practices
`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.
## 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 | ### ❌ DON'T
|---|---|---|---|---|---| - Do not instantiate `Automat` inside React component lifecycle or render methods.
| `setDirty()` | `false` | Resets to `null`/init | Throws fallback | Deferred until mount/read | Cache bust, logout, hard reset | - Do not mutate state directly (`automat.state.count = 1`); use `setState()` or actions.
| `reload()` | `true` | Preserved | Never suspends | Immediate fetch | Polling, pull-to-refresh, sync | - Do not wrap components in React Context providers or HOCs.
- Do not forget to unsubscribe in `componentWillUnmount()`.
## 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.

View File

@@ -10,6 +10,10 @@ import DynamicAutomatSubscriber from './examples/components/DynamicAutomatSubscr
import CartManager from './examples/components/CartManager.jsx'; import CartManager from './examples/components/CartManager.jsx';
import CartBadgeDisplay from './examples/components/CartBadgeDisplay.jsx'; import CartBadgeDisplay from './examples/components/CartBadgeDisplay.jsx';
import UnconnectedPartialSubscriber from './examples/components/UnconnectedPartialSubscriber.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 ApiBoundExample from './examples/components/ApiBoundExample.jsx';
import CascadingPhotosExample from './examples/components/CascadingPhotosExample.jsx'; import CascadingPhotosExample from './examples/components/CascadingPhotosExample.jsx';
import CombinedAutomatExample from './examples/components/CombinedAutomatExample.jsx'; import CombinedAutomatExample from './examples/components/CombinedAutomatExample.jsx';
@@ -26,31 +30,35 @@ class App extends PureComponent {
<span className="logo-version">v0.1.0</span> <span className="logo-version">v0.1.0</span>
</div> </div>
<p className="tagline"> <p className="tagline">
Higher-order state container for React{' '} Higher-order observable state container for React{' '}
<code className="inline-code">PureComponent</code> <code className="inline-code">PureComponent</code>
<br /> <br />
State lives independently of mounting — zero prop drilling · Optional Window and IndexedDB persistence. State lives independently of mounting — zero prop drilling · Optional Window and IndexedDB persistence.
</p> </p>
</header> </header>
<main className="app-main"> {/* ── Notification Bar ───────────────────────────────────── */}
{/* ── Example 1: Counter ────────────────────────────────── */} <NotificationBar />
{/* ── Main Content ───────────────────────────────────────── */}
<main className="examples-container">
{/* ── Example 1: Basic Counter ──────────────────────────── */}
<section className="example-section" aria-labelledby="ex1-title"> <section className="example-section" aria-labelledby="ex1-title">
<div className="section-header"> <div className="section-header">
<div className="section-title-row"> <div className="section-title-row">
<span className="section-number">01</span> <span className="section-number">01</span>
<h2 id="ex1-title">Counter</h2> <h2 id="ex1-title">Multi-Subscriber Sync &amp; Local State Isolation</h2>
</div> </div>
<p> <p>
Two independent <code className="inline-code">PureComponent</code>s Two completely separate components subscribe to the same <code className="inline-code">counterAutomat</code>.
synchronized by a single <code className="inline-code">counterAutomat</code>. The controller button also maintains local UI state (<code className="inline-code">lastPressed</code>, animation classes).
Neither knows the other exists — they both subscribe to the same state container. Neither component passes props to the other.
</p> </p>
<div className="flow-diagram" aria-label="Data flow"> <div className="flow-diagram" aria-label="Component and Automat data flow">
<span className="flow-node">counterAutomat</span> <span className="flow-node">CounterButton</span>
<span className="flow-arrow">→</span>
<span className="flow-node">counterAutomat.actions.increment()</span>
<span className="flow-arrow">→</span> <span className="flow-arrow">→</span>
<span className="flow-node flow-node-component">CounterButton</span>
<span className="flow-sep">&amp;</span>
<span className="flow-node flow-node-component">CounterDisplay</span> <span className="flow-node flow-node-component">CounterDisplay</span>
</div> </div>
</div> </div>
@@ -63,85 +71,51 @@ class App extends PureComponent {
<div className="section-divider" /> <div className="section-divider" />
{/* ── Example 2: Cascade ───────────────────────────────── */} {/* ── Example 2: Cascading State ────────────────────────── */}
<section className="example-section" aria-labelledby="ex2-title"> <section className="example-section" aria-labelledby="ex2-title">
<div className="section-header"> <div className="section-header">
<div className="section-title-row"> <div className="section-title-row">
<span className="section-number">02</span> <span className="section-number">02</span>
<h2 id="ex2-title">Notification Cascade</h2> <h2 id="ex2-title">Cascading Automats (Reactive Transformation)</h2>
</div> </div>
<p> <p>
<code className="inline-code">subscribeTo()</code> connects two automats into a <code className="inline-code">notificationAutomat</code> derives its state from{' '}
<strong> reactive pipeline</strong>. Clicking triggers in <code className="inline-code">CascadeControls</code> mutates{' '} <code className="inline-code">counterAutomat</code> using <code className="inline-code">subscribeTo</code>.
<code className="inline-code">counterAutomat</code> (upstream). This automatically invokes the{' '} Every state change cascades automatically without manual event dispatching.
<code className="inline-code">subscribeTo()</code> transform callback, updating{' '}
<code className="inline-code">notificationAutomat</code> (downstream), which then re-renders{' '}
<code className="inline-code">NotificationBar</code> without any prop drilling or shared component parent.
</p> </p>
<div className="flow-diagram" aria-label="Cascade data flow"> <div className="flow-diagram" aria-label="Cascade data flow">
<span className="flow-node">User Click</span> <span className="flow-node">counterAutomat</span>
<span className="flow-arrow">→</span> <span className="flow-arrow">→</span>
<span className="flow-node">counterAutomat.actions.increment()</span> <span className="flow-node">transform(counterState)</span>
<span className="flow-arrow">→</span> <span className="flow-arrow">→</span>
<span className="flow-node">subscribeTo(upstream, transform)</span> <span className="flow-node flow-node-component">notificationAutomat</span>
<span className="flow-arrow">→</span>
<span className="flow-node">notificationAutomat.setState()</span>
<span className="flow-arrow">→</span>
<span className="flow-node flow-node-component">NotificationBar</span>
</div>
<div className="info-box">
<div className="info-box-title">
<span>ℹ️</span> How <code>subscribeTo(upstreamAutomat, transform)</code> Works
</div>
<ul>
<li>
<strong><code>upstreamState</code>:</strong> The newly emitted state from the upstream automat (e.g. <code>{`{ count: 1 }`}</code>).
</li>
<li>
<strong><code>myState</code>:</strong> The current state of this downstream automat right before updating (e.g. <code>{`{ messages: [...] }`}</code>). Acts as an accumulator to prepend events and cap history.
</li>
<li>
<strong>Return Value:</strong> The returned partial state object is automatically applied via <code>this.setState(partial)</code>, notifying downstream UI subscribers.
</li>
<li>
<strong>Conditional Filtering:</strong> Return <code>null</code> or <code>undefined</code> to selectively skip updates and avoid triggering renders.
</li>
</ul>
</div> </div>
</div> </div>
<div className="two-columns"> <CascadeControls />
<CascadeControls />
<NotificationBar />
</div>
</section> </section>
<div className="section-divider" /> <div className="section-divider" />
{/* ── Example 3: API Auto-Sync ─────────────────────────── */} {/* ── Example 3: Backend Synchronization ────────────────── */}
<section className="example-section" aria-labelledby="ex3-title"> <section className="example-section" aria-labelledby="ex3-title">
<div className="section-header"> <div className="section-header">
<div className="section-title-row"> <div className="section-title-row">
<span className="section-number">03</span> <span className="section-number">03</span>
<h2 id="ex3-title">API-Backed Auto-Sync Counter (POST)</h2> <h2 id="ex3-title">Backend Synchronization &amp; Optimistic Updates</h2>
</div> </div>
<p> <p>
<code className="inline-code">syncCounterAutomat</code> manages an indexed counter that automatically State changes trigger asynchronous backend persistence via a custom fetch handler in the Automat’s actions.
synchronizes its mutations to the backend via HTTP <code className="inline-code">POST /api/counter</code>. A polling subscriber independently monitors the server-side state without polling the Automat itself.
State updates are applied <strong>optimistically</strong> for instant UI response, followed by asynchronous
background synchronization and status tracking without any component-level <code className="inline-code">fetch</code> or lifecycle glue.
</p> </p>
<div className="flow-diagram" aria-label="API auto-sync flow"> <div className="flow-diagram" aria-label="Backend sync flow">
<span className="flow-node">User Click</span> <span className="flow-node">SyncCounterControls</span>
<span className="flow-arrow">→</span> <span className="flow-arrow">→</span>
<span className="flow-node">Optimistic setState()</span> <span className="flow-node">syncCounterAutomat</span>
<span className="flow-arrow">⇄</span>
<span className="flow-node">Backend API</span>
<span className="flow-arrow">→</span> <span className="flow-arrow">→</span>
<span className="flow-node">POST /api/counter</span> <span className="flow-node flow-node-component">SyncBackendMonitor</span>
<span className="flow-arrow">→</span>
<span className="flow-node">Backend Database Updated</span>
<span className="flow-arrow">→</span>
<span className="flow-node flow-node-component">Sync Status 200 OK</span>
</div> </div>
</div> </div>
@@ -153,25 +127,24 @@ class App extends PureComponent {
<div className="section-divider" /> <div className="section-divider" />
{/* ── Example 4: Dynamic Map Resubscription ──────────── */} {/* ── Example 4: Dynamic Slot Allocation & Persistence ──── */}
<section className="example-section" aria-labelledby="ex4-title"> <section className="example-section" aria-labelledby="ex4-title">
<div className="section-header"> <div className="section-header">
<div className="section-title-row"> <div className="section-title-row">
<span className="section-number">04</span> <span className="section-number">04</span>
<h2 id="ex4-title">Dynamic Window Map (Index-Driven Resubscription)</h2> <h2 id="ex4-title">Dynamic Slot Allocation &amp; Unmount-Safe State Recall</h2>
</div> </div>
<p> <p>
The left side is a standard <code className="inline-code">indexAutomat</code> counter (like Example 1). Demonstrates how state lives <em>outside</em> the React component tree.
On the right side, that counter’s value is used to dynamically lookup, instantiate, and resubscribe to Changing the active index resubscribes the display component to a different named Automat.
a different <code className="inline-code">Automat</code> stored in <code className="inline-code">window.automats = new Map()</code>. Previous slot state is never destroyed on unmount — return to any slot at any time to find its clicks and notes intact.
Stepping through indexes unhooks from the old Automat, subscribes to the new one, and instantly recalls its preserved state.
</p> </p>
<div className="flow-diagram" aria-label="Index-driven resubscription flow"> <div className="flow-diagram" aria-label="Dynamic slot data flow">
<span className="flow-node">Shared Index: N</span> <span className="flow-node">IndexSelector</span>
<span className="flow-arrow">→</span> <span className="flow-arrow">→</span>
<span className="flow-node">window.automats.get(N)</span> <span className="flow-node">indexAutomat.state.index</span>
<span className="flow-arrow">→</span> <span className="flow-arrow">→</span>
<span className="flow-node">Unsub Old / Sub New</span> <span className="flow-node">Unsub Old Slot / Sub New Slot</span>
<span className="flow-arrow">→</span> <span className="flow-arrow">→</span>
<span className="flow-node flow-node-component">Recall State for Slot #N</span> <span className="flow-node flow-node-component">Recall State for Slot #N</span>
</div> </div>
@@ -211,12 +184,12 @@ class App extends PureComponent {
<br /> <br />
1. <code className="inline-code">CartBadgeDisplay</code> subscribes <strong>only to list length</strong> (<code className="inline-code">items.length</code>) — item quantity and coupon edits cause <strong>zero re-renders</strong>. 1. <code className="inline-code">CartBadgeDisplay</code> subscribes <strong>only to list length</strong> (<code className="inline-code">items.length</code>) — item quantity and coupon edits cause <strong>zero re-renders</strong>.
<br /> <br />
2. <code className="inline-code">UnconnectedPartialSubscriber</code> has <strong>zero imports</strong> of the cart store, resolving it strictly via the named window object (<code className="inline-code">Automat.get(&apos;cart&apos;)</code>), and subscribes only to <code className="inline-code">[&apos;couponCode&apos;]</code>. 2. <code className="inline-code">UnconnectedPartialSubscriber</code> has <strong>zero imports</strong> of the cart store, resolving it strictly via the named window object (<code className="inline-code">Automat.get('cart')</code>), and subscribes only to <code className="inline-code">['couponCode']</code>.
</p> </p>
<div className="flow-diagram" aria-label="Partial subscription data flow"> <div className="flow-diagram" aria-label="Partial subscription data flow">
<span className="flow-node">cartAutomat.setState()</span> <span className="flow-node">cartAutomat.setState()</span>
<span className="flow-arrow">→</span> <span className="flow-arrow">→</span>
<span className="flow-node">window.__AUTOMATS__.get(&apos;cart&apos;)</span> <span className="flow-node">window.__AUTOMATS__.get('cart')</span>
<span className="flow-arrow">→</span> <span className="flow-arrow">→</span>
<span className="flow-node">shallowEqual(lastSlice, nextSlice)</span> <span className="flow-node">shallowEqual(lastSlice, nextSlice)</span>
<span className="flow-arrow">→</span> <span className="flow-arrow">→</span>
@@ -235,15 +208,86 @@ class App extends PureComponent {
<div className="section-divider" /> <div className="section-divider" />
{/* ── Example 6: Backend API Binding & Suspense ─────── */} {/* ── Example 6: Async Fetch & Constructor Cache ──────── */}
<section className="example-section" aria-labelledby="ex6-title"> <section className="example-section" aria-labelledby="ex6-title">
<div className="section-header"> <div className="section-header">
<div className="section-title-row"> <div className="section-title-row">
<span className="section-number">06</span> <span className="section-number">06</span>
<h2 id="ex6-title">Backend API Binding, React Suspense &amp; Dirty Invalidation</h2> <h2 id="ex6-title">Async API Fetch &amp; Constructor Cache (Instant Re-Mount)</h2>
</div> </div>
<p> <p>
An automat can bind directly to a backend URL (<code className="inline-code">url: &apos;/api/profile&apos;</code>). An automat fetching asynchronous data via a simulated delayed API. When the primary component mounts,
calling <code className="inline-code">profileAsyncAutomat.getData()</code> in its constructor returns{' '}
<code className="inline-code">{`{ status: 'pending' }`}</code>, 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 <strong>already loaded in memory</strong> — the constructor receives the resolved payload synchronously, rendering in{' '}
<strong>1 render with zero 2nd render</strong>.
</p>
<div className="flow-diagram" aria-label="Async fetch and constructor cache flow">
<span className="flow-node">Mount 1 Constructor</span>
<span className="flow-arrow">→</span>
<span className="flow-node">status: 'pending' (Render #1)</span>
<span className="flow-arrow">→</span>
<span className="flow-node">API Resolves</span>
<span className="flow-arrow">→</span>
<span className="flow-node flow-node-component">Render #2 (Data)</span>
<span className="flow-sep">|</span>
<span className="flow-node">Mount 2 (Collapsible)</span>
<span className="flow-arrow">→</span>
<span className="flow-node flow-node-component">Constructor Has Data (1 Render Only)</span>
</div>
</div>
<div className="two-columns">
<AsyncApiPrimaryView />
<AsyncApiCollapsibleView />
</div>
</section>
<div className="section-divider" />
{/* ── Example 7: Combined Multi-API Automats ────────────── */}
<section className="example-section" aria-labelledby="ex7-title">
<div className="section-header">
<div className="section-title-row">
<span className="section-number">07</span>
<h2 id="ex7-title">Combined Multi-API Automats (Concurrent Orchestration)</h2>
</div>
<p>
Three automats in an orchestrated graph: two with delayed APIs (<code className="inline-code">userStatsAutomat</code> at ~700ms and{' '}
<code className="inline-code">systemMetricsAutomat</code> at ~1300ms), and one combined automat created with{' '}
<code className="inline-code">Automat.combine()</code>. When accessed while upstreams are idle, it triggers both to load in parallel,
merging results when both finish. However, if both have <strong>already been accessed earlier</strong>, the combined automat resolves{' '}
<strong>instantly in the component constructor</strong> with zero delay and a single render.
</p>
<div className="flow-diagram" aria-label="Combined multi-API flow">
<span className="flow-node">combinedDashboardAutomat.getData()</span>
<span className="flow-arrow">→</span>
<span className="flow-node">Check Upstreams</span>
<span className="flow-arrow">→</span>
<span className="flow-node">If Idle: Fetch Concurrently (~1300ms parallel)</span>
<span className="flow-sep">|</span>
<span className="flow-node">If Cached: Instant Constructor Return (1 Render)</span>
</div>
</div>
<div className="two-columns">
<MultiApiIndividualControls />
<MultiApiCombinedDashboard />
</div>
</section>
<div className="section-divider" />
{/* ── Example 8: Backend API Binding & Suspense ─────── */}
<section className="example-section" aria-labelledby="ex8-title">
<div className="section-header">
<div className="section-title-row">
<span className="section-number">08</span>
<h2 id="ex8-title">Backend API Binding, React Suspense &amp; Dirty Invalidation</h2>
</div>
<p>
An automat can bind directly to a backend URL (<code className="inline-code">url: '/api/profile'</code>).
Supports <code className="inline-code">await automat.ready</code>, React Suspense via <code className="inline-code">automat.read()</code>, Supports <code className="inline-code">await automat.ready</code>, React Suspense via <code className="inline-code">automat.read()</code>,
and manual invalidation via <code className="inline-code">automat.setDirty()</code>. and manual invalidation via <code className="inline-code">automat.setDirty()</code>.
When dirty, state resets to default or null. If mounted, it reloads immediately. If all subscribers are unmounted, 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 {
<div className="section-divider" /> <div className="section-divider" />
{/* ── Example 7: Cascading setDirty, Blobs & SSE ────── */} {/* ── Example 9: Cascading setDirty, Blobs & SSE ────── */}
<section className="example-section" aria-labelledby="ex7-title"> <section className="example-section" aria-labelledby="ex9-title">
<div className="section-header"> <div className="section-header">
<div className="section-title-row"> <div className="section-title-row">
<span className="section-number">07</span> <span className="section-number">09</span>
<h2 id="ex7-title">Cascading setDirty, Binary Blobs &amp; Server-Sent Events (SSE)</h2> <h2 id="ex9-title">Cascading setDirty, Binary Blobs &amp; Server-Sent Events (SSE)</h2>
</div> </div>
<p> <p>
Demonstrates <strong>cascading invalidation</strong>, <strong>automatic Blob memory revoking</strong>, Demonstrates <strong>cascading invalidation</strong>, <strong>automatic Blob memory revoking</strong>,
@@ -302,15 +346,15 @@ class App extends PureComponent {
<div className="section-divider" /> <div className="section-divider" />
{/* ── Example 8: Combined Automats ──────────────────── */} {/* ── Example 10: Dictionary Combined Automats ───────── */}
<section className="example-section" aria-labelledby="ex8-title"> <section className="example-section" aria-labelledby="ex10-title">
<div className="section-header"> <div className="section-header">
<div className="section-title-row"> <div className="section-title-row">
<span className="section-number">08</span> <span className="section-number">10</span>
<h2 id="ex8-title">Combined Automats</h2> <h2 id="ex10-title">Dictionary Combined Automats</h2>
</div> </div>
<p> <p>
<code className="inline-code">Automat.combine()</code> creates one derived application state from independent automats. <code className="inline-code">Automat.combine()</code> 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. Updates retain their namespace, actions remain grouped by child, and readiness operations cover every child.
</p> </p>
<div className="flow-diagram" aria-label="Combined automat flow"> <div className="flow-diagram" aria-label="Combined automat flow">
@@ -327,6 +371,13 @@ class App extends PureComponent {
<CombinedAutomatExample /> <CombinedAutomatExample />
</section> </section>
</main> </main>
<footer className="app-footer">
<p>
<code className="inline-code">react-automat</code> · MIT ·{' '}
State is independent of component lifecycle
</p>
</footer>
</div> </div>
); );
} }

View File

@@ -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',
});
}

View File

@@ -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) { if (typeof window !== 'undefined') {
window.automats = new Map(); window.automats = window.__AUTOMATS__ || (window.__AUTOMATS__ = new Map());
} }
/** /**

View File

@@ -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<object>}
*/
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,
};
},
}
);

View File

@@ -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 (
<div
style={{
background: 'var(--surface-0)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
padding: '18px',
marginTop: 12,
}}
>
{/* Mount Diagnostic Bar */}
<div className="hybrid-state-bar" style={{ marginBottom: 14 }}>
<div className="state-item">
<div className="state-item-header">
<span className="state-tag-automat">Lifecycle Stats</span>
<span>Session Mount</span>
</div>
<span className="state-item-val" style={{ color: 'var(--accent)' }}>
Mount #{this._mountId}
</span>
</div>
<div className="state-item">
<div className="state-item-header">
<span className="state-tag-local">Render Efficiency</span>
<span>Renders this Mount</span>
</div>
<span className="state-item-val" style={{ color: this._renderCount === 1 ? 'var(--green)' : 'var(--orange)' }}>
#{this._renderCount}
<span style={{ fontSize: 11, fontWeight: 400, marginLeft: 6, color: 'var(--text-3)' }}>
{this._renderCount === 1 ? '(Single render!)' : '(Pending + Complete)'}
</span>
</span>
</div>
</div>
{/* Big visual banner when 0 second renders occurred */}
{isSingleRenderSuccess && (
<div
style={{
background: 'rgba(184, 187, 38, 0.15)',
border: '1px solid rgba(184, 187, 38, 0.4)',
borderRadius: 'var(--r-md)',
padding: '10px 14px',
fontSize: 12,
color: 'var(--green)',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 14,
}}
>
<span>⚡</span>
<span>
Zero 2nd render! Data was resolved in the constructor (<code className="inline-code">status: &apos;success&apos;</code>).
</span>
</div>
)}
{/* Content View */}
{status === 'pending' && (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<div className="spinner-gruvbox" style={{ margin: '0 auto 10px' }} />
<div style={{ fontSize: 13, color: 'var(--orange)' }}>Loading API in background...</div>
</div>
)}
{status === 'idle' && (
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-3)', fontSize: 13 }}>
Automat is currently idle. (Open primary view or click refetch).
</div>
)}
{status === 'success' && data && (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<span style={{ fontSize: 26 }}>{data.avatar}</span>
<div>
<div style={{ fontWeight: 600, color: 'var(--text-1)', fontSize: 14 }}>
{data.name} <span style={{ fontSize: 12, color: 'var(--text-3)' }}>({data.handle})</span>
</div>
<div style={{ fontSize: 11, color: 'var(--aqua)' }}>{data.role}</div>
</div>
</div>
<div style={{ fontSize: 12, color: 'var(--text-2)', background: 'var(--surface-1)', padding: '8px 12px', borderRadius: 'var(--r-sm)' }}>
Connected to: <code className="inline-code">{data.endpoint}</code> (Ping: <strong style={{ color: 'var(--green)' }}>{data.pingMs}ms</strong>)
</div>
</div>
)}
</div>
);
}
}
/**
* 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 (
<div className="card">
<div className="card-header">
<span className="card-icon">📂</span>
<h3>Collapsible Consumer (Unmount Test)</h3>
<span className="badge badge-cascade">No 2nd Render</span>
</div>
<div className="card-body">
<p style={{ fontSize: 13, color: 'var(--text-2)', marginBottom: 14 }}>
Toggle the collapsible below to <strong>completely unmount and remount</strong> this
component. When data is already loaded in <code className="inline-code">profileAsyncAutomat</code>,
its constructor immediately receives the cached payload — rendering in <strong>1 render with no 2nd render</strong>.
</p>
{/* Current Automat Status Hint */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: 'var(--surface-0)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: '8px 12px',
fontSize: 12,
marginBottom: 14,
}}
>
<span style={{ color: 'var(--text-3)' }}>
Automat Cache State:
</span>
<span
className={`badge ${
automatState.status === 'success'
? 'badge-success'
: automatState.status === 'pending'
? 'badge-api'
: 'badge-cascade'
}`}
>
{automatState.status.toUpperCase()}
{automatState.status === 'success' ? ' (Data Ready)' : ''}
</span>
</div>
{/* Collapsible Toggle Button */}
<button
id="btn-toggle-collapsible-consumer"
type="button"
className="btn"
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: '10px 16px',
background: isMounted ? 'var(--surface-2)' : 'var(--accent)',
color: isMounted ? 'var(--text-1)' : 'var(--text-dark)',
fontWeight: 700,
fontSize: 13,
borderRadius: 'var(--r-md)',
border: '1px solid var(--border)',
}}
onClick={this.toggleMount}
>
<span>{isMounted ? '🔼' : '🔽'}</span>
<span>
{isMounted
? 'Unmount Secondary Component (Collapse)'
: 'Mount Secondary Component (Expand)'}
</span>
</button>
{/* Dynamically Mounted Child Component */}
{isMounted ? (
<SecondaryProfileConsumer />
) : (
<div
style={{
textAlign: 'center',
padding: '28px 16px',
color: 'var(--text-3)',
fontSize: 12,
fontStyle: 'italic',
border: '1px dashed var(--border)',
borderRadius: 'var(--r-lg)',
marginTop: 12,
}}
>
Component is currently <strong>UNMOUNTED</strong> from the React tree.
<br />
Click the button above to mount it and observe the constructor read.
</div>
)}
{/* Session Mount History Log */}
{mountHistoryLog.length > 0 && (
<div style={{ marginTop: 16 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-3)', textTransform: 'uppercase', marginBottom: 6 }}>
Session Mount History:
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 110, overflowY: 'auto' }}>
{mountHistoryLog.slice(0, 5).map((entry) => (
<div
key={entry.mountId}
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 11,
fontFamily: 'var(--font-mono)',
background: 'var(--surface-0)',
padding: '4px 8px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
}}
>
<span>Mount #{entry.mountId} ({entry.time})</span>
<span style={{ color: entry.hadDataImmediately ? 'var(--green)' : 'var(--orange)' }}>
{entry.hadDataImmediately ? '⚡ 1 render (instant)' : '⏳ 2 renders (pending)'}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
}

View File

@@ -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 (
<div className="card">
<div className="card-header">
<span className="card-icon">⚡</span>
<h3>Primary API Consumer</h3>
<span className="badge badge-api">Constructor getData()</span>
</div>
<div className="card-body">
{/* Diagnostic render & constructor status bar */}
<div className="hybrid-state-bar" style={{ marginBottom: 14 }}>
<div className="state-item">
<div className="state-item-header">
<span className="state-tag-automat">Mount Renders</span>
<span>Render Count</span>
</div>
<span className="state-item-val" style={{ color: this._renderCount === 2 ? 'var(--green)' : 'var(--accent)' }}>
#{this._renderCount}
<span style={{ fontSize: 11, fontWeight: 400, marginLeft: 6, color: 'var(--text-3)' }}>
({this._renderCount === 1 ? '1st: Pending' : '2nd: Completed'})
</span>
</span>
</div>
<div className="state-item">
<div className="state-item-header">
<span className="state-tag-local">Constructor Read</span>
<span>Initial Status</span>
</div>
<span className="state-item-val" style={{ color: this._constructorStatus === 'pending' ? 'var(--orange)' : 'var(--green)' }}>
{this._constructorStatus}
</span>
</div>
</div>
{/* Main Visual Display Area */}
<div
style={{
background: 'var(--surface-0)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
padding: '20px',
minHeight: 200,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
marginBottom: 16,
}}
>
{/* Status: IDLE */}
{status === 'idle' && (
<div style={{ textAlign: 'center', padding: '24px 0' }}>
<span style={{ fontSize: 32, display: 'block', marginBottom: 8 }}>💤</span>
<div style={{ fontWeight: 600, color: 'var(--text-1)', marginBottom: 4 }}>
Cache Cleared (State: Idle)
</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 14 }}>
Calling <code className="inline-code">getData()</code> will launch the delayed API request.
</div>
<button
type="button"
className="btn btn-primary"
onClick={this.handleReload}
>
Fetch Profile Now ({delayMs}ms delay)
</button>
</div>
)}
{/* Status: PENDING */}
{status === 'pending' && (
<div style={{ padding: '10px 0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div className="spinner-gruvbox" />
<div>
<div style={{ fontWeight: 600, color: 'var(--orange)', fontSize: 14 }}>
Simulating API fetch... ({delayMs}ms delay)
</div>
<div style={{ fontSize: 12, color: 'var(--text-3)' }}>
Constructor received <code className="inline-code">status: 'pending'</code>. Rendering 1st pass.
</div>
</div>
</div>
{/* Animated Gruvbox skeleton loaders */}
<div className="skeleton-bar" style={{ width: '45%', height: 16, marginBottom: 10 }} />
<div className="skeleton-bar" style={{ width: '85%', height: 12, marginBottom: 8 }} />
<div className="skeleton-bar" style={{ width: '65%', height: 12, marginBottom: 14 }} />
<div style={{ display: 'flex', gap: 8 }}>
<div className="skeleton-bar" style={{ width: 60, height: 20, borderRadius: 'var(--r-sm)' }} />
<div className="skeleton-bar" style={{ width: 80, height: 20, borderRadius: 'var(--r-sm)' }} />
<div className="skeleton-bar" style={{ width: 70, height: 20, borderRadius: 'var(--r-sm)' }} />
</div>
</div>
)}
{/* Status: ERROR */}
{status === 'error' && (
<div style={{ textAlign: 'center', padding: '16px 0' }}>
<span style={{ fontSize: 32, display: 'block', marginBottom: 8 }}>⚠️</span>
<div style={{ fontWeight: 600, color: 'var(--red)', marginBottom: 6 }}>
API Fetch Failed
</div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 14 }}>
{error}
</div>
<button
type="button"
className="btn btn-secondary"
onClick={this.handleReload}
>
Retry Fetch
</button>
</div>
)}
{/* Status: SUCCESS */}
{status === 'success' && data && (
<div>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 34, lineHeight: 1 }}>{data.avatar}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 16, color: 'var(--text-bright)' }}>
{data.name}
</div>
<div style={{ fontSize: 12, color: 'var(--text-3)' }}>
{data.handle} · <span style={{ color: 'var(--aqua)' }}>{data.role}</span>
</div>
</div>
</div>
<span className="badge badge-success" style={{ fontSize: 10 }}>
200 OK
</span>
</div>
<div
style={{
background: 'var(--surface-1)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: '10px 12px',
fontSize: 12,
color: 'var(--text-2)',
marginBottom: 12,
fontStyle: 'italic',
}}
>
&ldquo;{data.statusMessage}&rdquo;
</div>
{/* Quota bar & latency ping */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 12 }}>
<div style={{ background: 'var(--surface-1)', padding: '8px 10px', borderRadius: 'var(--r-sm)' }}>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 3 }}>
Cloud Quota ({data.quota.used}/{data.quota.total} {data.quota.unit})
</div>
<div style={{ height: 6, background: 'var(--surface-2)', borderRadius: 3, overflow: 'hidden' }}>
<div
style={{
height: '100%',
width: `${(data.quota.used / data.quota.total) * 100}%`,
background: 'var(--accent)',
borderRadius: 3,
}}
/>
</div>
</div>
<div style={{ background: 'var(--surface-1)', padding: '8px 10px', borderRadius: 'var(--r-sm)' }}>
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>Cluster Latency</div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--green)' }}>
⚡ {data.pingMs}ms <span style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-3)' }}>to internal VPC</span>
</div>
</div>
</div>
{/* Tags */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{data.tags.map((tag) => (
<span
key={tag}
style={{
fontSize: 10,
padding: '2px 7px',
background: 'var(--surface-2)',
borderRadius: 'var(--r-sm)',
color: 'var(--text-2)',
border: '1px solid var(--border)',
}}
>
{tag}
</span>
))}
</div>
</div>
)}
</div>
{/* Latency Simulator & Control Actions */}
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 6, display: 'flex', justifyContent: 'space-between' }}>
<span>Simulated Network Latency:</span>
<strong style={{ color: 'var(--accent)' }}>{delayMs}ms</strong>
</div>
<div className="segmented-control" style={{ width: '100%', display: 'flex' }}>
<button
type="button"
className={`seg-btn ${delayMs === 300 ? 'active' : ''}`}
style={{ flex: 1 }}
onClick={() => this.handleDelayChange(300)}
>
Fast (300ms)
</button>
<button
type="button"
className={`seg-btn ${delayMs === 800 ? 'active' : ''}`}
style={{ flex: 1 }}
onClick={() => this.handleDelayChange(800)}
>
Normal (800ms)
</button>
<button
type="button"
className={`seg-btn ${delayMs === 1800 ? 'active' : ''}`}
style={{ flex: 1 }}
onClick={() => this.handleDelayChange(1800)}
>
Slow (1.8s)
</button>
</div>
</div>
{/* Action Buttons */}
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
<button
id="btn-primary-refetch"
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
onClick={this.handleReload}
>
🔄 Force Reload
</button>
<button
id="btn-primary-reset"
type="button"
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={this.handleReset}
>
🗑️ Clear Cache (Idle)
</button>
<button
id="btn-primary-error"
type="button"
className="btn btn-secondary"
title="Simulate 504 Timeout"
onClick={this.handleSimulateError}
>
💥 Timeout
</button>
</div>
{/* Footer Metadata */}
<div style={{ fontSize: 11, color: 'var(--text-3)', display: 'flex', justifyContent: 'space-between' }}>
<span>API Fetch Calls: <strong style={{ color: 'var(--text-1)' }}>{fetchCount}</strong></span>
<span>Last Sync: <strong style={{ color: 'var(--text-1)' }}>{lastFetchedAt || 'Never'}</strong></span>
</div>
</div>
</div>
);
}
}

View File

@@ -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 (
<div
style={{
background: 'var(--surface-0)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
padding: '18px',
marginTop: 12,
}}
>
{/* Diagnostic Bar */}
<div className="hybrid-state-bar" style={{ marginBottom: 14 }}>
<div className="state-item">
<div className="state-item-header">
<span className="state-tag-automat">Dashboard Mount</span>
<span>Mount Number</span>
</div>
<span className="state-item-val" style={{ color: 'var(--accent)' }}>
Mount #{this._mountId}
</span>
</div>
<div className="state-item">
<div className="state-item-header">
<span className="state-tag-local">Render Efficiency</span>
<span>Renders this Mount</span>
</div>
<span
className="state-item-val"
style={{ color: this._renderCount === 1 ? 'var(--green)' : 'var(--orange)' }}
>
#{this._renderCount}
<span style={{ fontSize: 11, fontWeight: 400, marginLeft: 6, color: 'var(--text-3)' }}>
{this._renderCount === 1 ? '(Single render!)' : `(Progressive update)`}
</span>
</span>
</div>
</div>
{/* Instant Constructor Resolution Callout */}
{isInstantSuccess && (
<div
style={{
background: 'rgba(184, 187, 38, 0.15)',
border: '1px solid rgba(184, 187, 38, 0.4)',
borderRadius: 'var(--r-md)',
padding: '10px 14px',
fontSize: 12,
color: 'var(--green)',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 14,
}}
>
<span>⚡</span>
<span>
<strong>Instant Resolution!</strong> Both upstreams were already in memory. Constructor received full payload immediately — <strong>1 render total</strong>!
</span>
</div>
)}
{/* Pending / Concurrent Loading Dual Progress View */}
{status === 'pending' && (
<div style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="spinner-gruvbox" style={{ width: 14, height: 14 }} />
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--orange)' }}>
Loading dual sources in parallel...
</span>
</div>
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>
{progressText || `${doneCount || 0}/${totalCount || 2} loaded`}
</span>
</div>
{/* Dual Source Progress Cards */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 10 }}>
<div
style={{
background: 'var(--surface-1)',
padding: '8px 10px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
}}
>
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>Source A (Stats, ~700ms)</div>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: statsStatus === 'success' ? 'var(--green)' : 'var(--orange)',
}}
>
{statsStatus === 'success' ? '✅ Loaded' : '⏳ Fetching...'}
</div>
</div>
<div
style={{
background: 'var(--surface-1)',
padding: '8px 10px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
}}
>
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>Source B (Metrics, ~1300ms)</div>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: metricsStatus === 'success' ? 'var(--green)' : 'var(--orange)',
}}
>
{metricsStatus === 'success' ? '✅ Loaded' : '⏳ Fetching...'}
</div>
</div>
</div>
{/* Overall Progress Bar */}
<div style={{ height: 6, background: 'var(--surface-2)', borderRadius: 3, overflow: 'hidden' }}>
<div
style={{
height: '100%',
width: `${((doneCount || 0) / (totalCount || 2)) * 100}%`,
background: 'var(--accent)',
transition: 'width 0.25s ease',
borderRadius: 3,
}}
/>
</div>
</div>
)}
{/* Combined Dashboard View (Success) */}
{status === 'success' && stats && metrics && (
<div>
{/* Merged Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: 'var(--surface-1)',
padding: '10px 14px',
borderRadius: 'var(--r-md)',
marginBottom: 12,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 24 }}>{stats.avatar}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 14, color: 'var(--text-bright)' }}>
{stats.name}
</div>
<div style={{ fontSize: 11, color: 'var(--aqua)' }}>{stats.level}</div>
</div>
</div>
<span className="badge badge-success" style={{ fontSize: 10 }}>
Combined 200 OK
</span>
</div>
{/* Quick Metrics Grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8, marginBottom: 12 }}>
<div style={{ background: 'var(--surface-1)', padding: '8px', borderRadius: 'var(--r-sm)', textAlign: 'center' }}>
<div style={{ fontSize: 10, color: 'var(--text-3)' }}>Deployments</div>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-1)' }}>{stats.deploymentsCount}</div>
</div>
<div style={{ background: 'var(--surface-1)', padding: '8px', borderRadius: 'var(--r-sm)', textAlign: 'center' }}>
<div style={{ fontSize: 10, color: 'var(--text-3)' }}>Uptime</div>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--green)' }}>{stats.uptimeRating}</div>
</div>
<div style={{ background: 'var(--surface-1)', padding: '8px', borderRadius: 'var(--r-sm)', textAlign: 'center' }}>
<div style={{ fontSize: 10, color: 'var(--text-3)' }}>Active Nodes</div>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--accent)' }}>{metrics.activeNodes}</div>
</div>
</div>
{/* Recent Events from Source B */}
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-3)', textTransform: 'uppercase', marginBottom: 6 }}>
Live Infrastructure Events (from Source B):
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{metrics.events.map((evt) => (
<div
key={evt.id}
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 11,
background: 'var(--surface-1)',
padding: '5px 8px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
}}
>
<span style={{ color: 'var(--text-2)' }}>{evt.text}</span>
<span style={{ color: 'var(--text-3)', fontFamily: 'var(--font-mono)' }}>{evt.time}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
}
/**
* 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 (
<div className="card">
<div className="card-header">
<span className="card-icon">⚡</span>
<h3>Combined Dashboard Automat</h3>
<span className="badge badge-success">Automat.combine()</span>
</div>
<div className="card-body">
<p style={{ fontSize: 13, color: 'var(--text-2)', marginBottom: 14 }}>
Combines two upstream automats. If both are already loaded, accessing this
automat resolves <strong>instantly in constructor with 1 render</strong>. If not, it triggers
both to load concurrently in parallel.
</p>
{/* Current Upstream Status Indicator */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: 'var(--surface-0)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: '8px 12px',
fontSize: 12,
marginBottom: 14,
}}
>
<span style={{ color: 'var(--text-3)' }}>
Upstream Readiness:
</span>
<span
className={`badge ${
combinedState.status === 'success'
? 'badge-success'
: combinedState.status === 'pending'
? 'badge-api'
: 'badge-cascade'
}`}
>
{combinedState.status === 'success'
? 'BOTH READY (Instant Constructor)'
: combinedState.status === 'pending'
? 'SYNCING UPSTREAMS'
: 'IDLE (Will trigger parallel fetch)'}
</span>
</div>
{/* Mount / Unmount Toggle Button */}
<button
id="btn-toggle-combined-dashboard"
type="button"
className="btn"
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: '10px 16px',
background: isMounted ? 'var(--surface-2)' : 'var(--accent)',
color: isMounted ? 'var(--text-1)' : 'var(--text-dark)',
fontWeight: 700,
fontSize: 13,
borderRadius: 'var(--r-md)',
border: '1px solid var(--border)',
}}
onClick={this.toggleMount}
>
<span>{isMounted ? '🔼' : '🔽'}</span>
<span>
{isMounted
? 'Unmount Combined Dashboard (Collapse)'
: 'Mount Combined Dashboard (Expand)'}
</span>
</button>
{/* Mounted View or Unmounted Hint */}
{isMounted ? (
<CombinedDashboardView />
) : (
<div
style={{
textAlign: 'center',
padding: '28px 16px',
color: 'var(--text-3)',
fontSize: 12,
fontStyle: 'italic',
border: '1px dashed var(--border)',
borderRadius: 'var(--r-lg)',
marginTop: 12,
}}
>
Dashboard component is currently <strong>UNMOUNTED</strong>.
<br />
Click the button above to mount and inspect constructor resolution.
</div>
)}
</div>
</div>
);
}
}

View File

@@ -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 (
<div className="card">
<div className="card-header">
<span className="card-icon">🧩</span>
<h3>Individual Delayed Automats</h3>
<span className="badge badge-api">2 Upstream APIs</span>
</div>
<div className="card-body">
<p style={{ fontSize: 13, color: 'var(--text-2)', marginBottom: 14 }}>
Two independent automats with simulated delayed APIs. You can trigger each individually,
or let the combined automat trigger both concurrently.
</p>
{/* Upstream A: User Stats */}
<div
style={{
background: 'var(--surface-0)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: '12px 14px',
marginBottom: 12,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 16 }}>🛡️</span>
<strong style={{ fontSize: 13, color: 'var(--text-1)' }}>Automat A: User Stats</strong>
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>(~700ms)</span>
</div>
<span
className={`badge ${
stats.status === 'success'
? 'badge-success'
: stats.status === 'pending'
? 'badge-api'
: 'badge-cascade'
}`}
style={{ fontSize: 10 }}
>
{stats.status.toUpperCase()}
</span>
</div>
{stats.status === 'pending' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0', fontSize: 12, color: 'var(--orange)' }}>
<div className="spinner-gruvbox" style={{ width: 14, height: 14 }} />
<span>Fetching User Stats API...</span>
</div>
)}
{stats.status === 'success' && stats.data && (
<div style={{ fontSize: 12, color: 'var(--text-2)', background: 'var(--surface-1)', padding: '6px 10px', borderRadius: 'var(--r-sm)', marginBottom: 8 }}>
<strong style={{ color: 'var(--accent)' }}>{stats.data.name}</strong> · {stats.data.level} (Uptime: {stats.data.uptimeRating})
</div>
)}
<button
id="btn-fetch-stats"
type="button"
className="btn btn-secondary"
style={{ width: '100%', fontSize: 12, padding: '5px 10px' }}
onClick={this.handleFetchStats}
>
{stats.status === 'success' ? '🔄 Reload Stats (~700ms)' : '▶️ Fetch Stats API (~700ms)'}
</button>
</div>
{/* Upstream B: System Metrics */}
<div
style={{
background: 'var(--surface-0)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: '12px 14px',
marginBottom: 16,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 16 }}>📊</span>
<strong style={{ fontSize: 13, color: 'var(--text-1)' }}>Automat B: System Metrics</strong>
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>(~1300ms)</span>
</div>
<span
className={`badge ${
metrics.status === 'success'
? 'badge-success'
: metrics.status === 'pending'
? 'badge-api'
: 'badge-cascade'
}`}
style={{ fontSize: 10 }}
>
{metrics.status.toUpperCase()}
</span>
</div>
{metrics.status === 'pending' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0', fontSize: 12, color: 'var(--orange)' }}>
<div className="spinner-gruvbox" style={{ width: 14, height: 14 }} />
<span>Fetching System Metrics API...</span>
</div>
)}
{metrics.status === 'success' && metrics.data && (
<div style={{ fontSize: 12, color: 'var(--text-2)', background: 'var(--surface-1)', padding: '6px 10px', borderRadius: 'var(--r-sm)', marginBottom: 8 }}>
Cluster: <strong style={{ color: 'var(--green)' }}>{metrics.data.clusterHealth}</strong> · {metrics.data.activeNodes} Nodes · {metrics.data.events.length} Events
</div>
)}
<button
id="btn-fetch-metrics"
type="button"
className="btn btn-secondary"
style={{ width: '100%', fontSize: 12, padding: '5px 10px' }}
onClick={this.handleFetchMetrics}
>
{metrics.status === 'success' ? '🔄 Reload Metrics (~1300ms)' : '▶️ Fetch Metrics API (~1300ms)'}
</button>
</div>
{/* Reset All Button */}
<button
id="btn-reset-multi-demo"
type="button"
className="btn btn-secondary"
style={{ width: '100%', marginBottom: 14 }}
onClick={this.handleResetAll}
>
🗑️ Reset Both to Idle (Clear Cache)
</button>
{/* Guided Testing Flow Hints */}
<div className="info-box" style={{ marginTop: 0 }}>
<div className="info-box-title">
<span>💡</span> Two Verification Flows:
</div>
<ul style={{ fontSize: 12 }}>
<li>
<strong>Flow 1 (Instant Constructor Resolution):</strong> Click both fetch buttons above. Once both show <code className="inline-code">SUCCESS</code>, expand the Combined Dashboard on the right. Notice it resolves <strong>instantly in constructor with 1 render</strong>!
</li>
<li>
<strong>Flow 2 (Concurrent Orchestration):</strong> Click <em>Reset Both to Idle</em>, then expand the Combined Dashboard directly. It triggers both APIs in parallel, finishing in ~1300ms total.
</li>
</ul>
</div>
</div>
</div>
);
}
}

View File

@@ -411,6 +411,40 @@ body {
border: 1px solid rgba(131, 165, 152, 0.3); 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 ───────────────────────────────────────── */ /* ── Buttons ───────────────────────────────────────── */
.btn { .btn {

View File

@@ -1,73 +1,131 @@
/** /**
* @module Automat * @module Automat
* *
* Higher-order state container designed for React PureComponent with optional * Higher-order observable state container for React PureComponents with optional
* persistence (Window or IndexedDB) and backend API binding. * 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 (Object.is(a, b)) return true;
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false; if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
const kA = Object.keys(a); const keysA = Object.keys(a);
return kA.length === Object.keys(b).length && kA.every((k) => Object.is(a[k], b[k])); 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 ────────────────────────────── // ── Global Window & Registry Aliases ──────────────────────────────
const W = typeof window !== 'undefined' ? window : null; const W = typeof window !== 'undefined' ? window : null;
const getGlobalMap = (key) => W && (W[key] ||= new Map()); const getGlobalMap = (key) => W && (W[key] ||= new Map());
// ── Unified IndexedDB Runner ────────────────────────────────────── // ── Unified IndexedDB Storage ─────────────────────────────────────
const IDB_NAME = 'automat_db'; const IDB_NAME = 'automat_db';
const IDB_STORE = 'states'; const IDB_STORE = 'states';
let idbPromise = null; let idbPromise = null;
const getIdb = () => function getIdb() {
typeof indexedDB === 'undefined' if (typeof indexedDB === 'undefined') return Promise.resolve(null);
? Promise.resolve(null) if (!idbPromise) {
: (idbPromise ||= new Promise((resolve) => { idbPromise = new Promise((resolve) => {
try { try {
const req = indexedDB.open(IDB_NAME, 1); const req = indexedDB.open(IDB_NAME, 1);
req.onupgradeneeded = () => { req.onupgradeneeded = () => {
if (!req.result.objectStoreNames.contains(IDB_STORE)) { const db = req.result;
req.result.createObjectStore(IDB_STORE); if (!db.objectStoreNames.contains(IDB_STORE)) {
} db.createObjectStore(IDB_STORE);
}; }
req.onsuccess = () => resolve(req.result); };
req.onerror = () => resolve(null); req.onsuccess = () => resolve(req.result);
} catch { req.onerror = () => resolve(null);
resolve(null); } catch {
} resolve(null);
})); }
});
}
return idbPromise;
}
const idbRun = (method, ...args) => function idbGet(key) {
getIdb().then( return getIdb().then((db) => {
(db) => if (!db) return undefined;
db && return new Promise((resolve) => {
new Promise((resolve) => { try {
try { const tx = db.transaction(IDB_STORE, 'readonly');
const req = db const req = tx.objectStore(IDB_STORE).get(key);
.transaction(IDB_STORE, method === 'get' ? 'readonly' : 'readwrite') req.onsuccess = () => resolve(req.result);
.objectStore(IDB_STORE)[method](...args); req.onerror = () => resolve(undefined);
req.onsuccess = req.oncomplete = () => resolve(req.result); } catch {
req.onerror = () => resolve(); resolve(undefined);
} catch { }
resolve(); });
} });
}) }
);
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 ─────────────────────────────────────────── // ── Selector Normalizer ───────────────────────────────────────────
const resolveSelector = (s) => function resolveSelector(s, isComponent = false) {
typeof s === 'function' if (typeof s === 'function') {
? s return s;
: Array.isArray(s) }
? (x) => x && Object.fromEntries(s.map((k) => [k, x[k]])) if (typeof s === 'string') {
: s return isComponent ? (state) => (state ? { [s]: state[s] } : state) : (state) => state?.[s];
? (x) => x && { [s]: x[s] } }
: (x) => x; 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 { export class Automat {
constructor(initialState = null, callbacks = {}, options = {}) { constructor(initialState = null, callbacks = {}, options = {}) {
@@ -79,69 +137,73 @@ export class Automat {
: null; : null;
this._state = this._initial ? { ...this._initial } : 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._subs = new Map();
this._dirtySubs = new Set(); this._dirtySubs = new Set();
this._unsubs = []; this._unsubs = [];
this._name = options.name ?? null; this._name = options.name ?? null;
this._persist = Boolean(options.persist); this._persist = Boolean(options.persist);
this._url = options.url ?? null; this._loader = options.loader ?? null;
this._fetcher = options.fetcher || ((url) => fetch(url).then((r) => r.json()));
this._error = null; this._error = null;
if (this._name) { if (this._name) {
getGlobalMap('__AUTOMATS__')?.set(this._name, this); getGlobalMap('__AUTOMATS__')?.set(this._name, this);
} }
this._isReady = !this._url && !(this._name && this._persist);
this._isDirty = false; this._isDirty = false;
this._ready = this._url
? this._fetchData() if (this._loader) {
: this._name && this._persist this._ready = this._fetchLoader();
? idbRun('get', this._name).then((saved) => { } else if (this._name && this._persist) {
if (saved && typeof saved === 'object') this.setState(saved); this._ready = idbGet(this._name).then((saved) => {
this._isReady = true; if (saved && typeof saved === 'object') {
return this._state; this.setState(saved);
}) }
: Promise.resolve( return this._state;
this._name && !this._persist });
? ((this._state = { } else if (this._name && !this._persist) {
...this._state, const saved = getGlobalMap('__AUTOMAT_STATE__')?.get(this._name);
...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._state) getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state);
: this._state this._ready = Promise.resolve(this._state);
); } else {
this._ready = Promise.resolve(this._state);
}
} }
/** /**
* Internal data fetcher for backend API binding. * Internal data loader for function-backed automats.
* @param {boolean} [silent=false] If true, preserves isReady without unready flash.
* @private * @private
*/ */
_fetchData(silent = false) { _fetchLoader() {
if (!silent) this._isReady = false;
this._isDirty = false; this._isDirty = false;
this._error = null; this._error = null;
const promise = this._fetcher(this._url, this) const promise = Promise.resolve(this._loader(this))
.then((data) => { .then((data) => {
this._state = data; if (data && typeof data === 'object') {
this._isReady = true; this._state = { ...this._state, ...data };
} else if (data !== undefined) {
this._state = data;
}
this._isDirty = false; this._isDirty = false;
this._error = null; this._error = null;
if (this._name) { if (this._name) {
this._persist this._persist
? idbRun('put', this._state, this._name) ? idbSet(this._name, this._state)
: getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state); : getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state);
} }
this._notify(this._state); this._notify(this._state);
return this._state; return this._state;
}) })
.catch((err) => { .catch((err) => {
this._error = err; this._error = err?.message || String(err);
this._isReady = false;
this._notify(this._state); this._notify(this._state);
throw err; throw err;
}); });
@@ -152,21 +214,26 @@ export class Automat {
/** /**
* Marks the automat as dirty and unready. * Marks the automat as dirty and unready.
* State resets to initial default or null (if no defaults were given). * State resets to initial default or null.
* * If subscribed anywhere and configured with a loader, triggers loader fetch immediately.
* If subscribed anywhere, reloads immediately from backend API.
* If not subscribed anywhere, defers reload until read() or subscribe().
*/ */
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') { if (typeof this._state?.revoke === 'function') {
try { try {
this._state.revoke(); this._state.revoke();
} catch {} } catch {}
} }
this._isDirty = true; this._isDirty = true;
this._isReady = false; if (!shouldKeep) {
this._state = this._initial ? { ...this._initial } : null; this._state = this._initial ? { ...this._initial } : null;
this._notify(this._state); this._notify(this._state);
}
if (this._dirtySubs) { if (this._dirtySubs) {
for (const fn of this._dirtySubs) { for (const fn of this._dirtySubs) {
@@ -176,41 +243,24 @@ export class Automat {
} }
} }
if (this._subs.size > 0 && this._url) { if (this._loader && (shouldNow || this._subs.size > 0)) {
return this._fetchData(); return this._fetchLoader();
} }
return Promise.resolve(this._state); 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<any>} 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. * 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. * If unready, throws the ready promise for React Suspense.
*/ */
read() { read() {
if (this._url) { if (this._loader) {
if (this._isDirty) { 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; throw this._ready;
} }
} }
@@ -227,14 +277,31 @@ export class Automat {
/** /**
* Combines multiple automats into one derived 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<string, Automat>} 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<string, Automat>} upstream
* @param {Function} [combiner]
* @param {object} [options]
* @returns {Automat} * @returns {Automat}
*/ */
static combine(automats) { static combine(upstream, combiner, options = {}) {
return new CombinedAutomat(automats); 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() { get name() {
@@ -245,12 +312,8 @@ export class Automat {
return this._persist; return this._persist;
} }
get url() { get isPersisted() {
return this._url; return this._persist;
}
get isReady() {
return this._isReady;
} }
get isDirty() { get isDirty() {
@@ -266,8 +329,8 @@ export class Automat {
} }
get ready() { get ready() {
if (this._url && this._isDirty) { if (this._loader && this._isDirty) {
return this._fetchData(); return this._fetchLoader();
} }
return this._ready; return this._ready;
} }
@@ -275,50 +338,73 @@ export class Automat {
async clearPersistence() { async clearPersistence() {
if (!this._name) return; if (!this._name) return;
this._persist this._persist
? await idbRun('delete', this._name) ? await idbDelete(this._name)
: getGlobalMap('__AUTOMAT_STATE__')?.delete(this._name); : getGlobalMap('__AUTOMAT_STATE__')?.delete(this._name);
} }
get state() { get state() {
if (this._loader && this._isDirty) {
this._fetchLoader();
}
return this._state; return this._state;
} }
getState() { getState() {
return this._state; return this.state;
} }
/**
* Shallow-merges `partial` into current state and notifies all subscribers.
*/
setState(partial) { setState(partial) {
this._state = { ...this._state, ...partial }; const update = typeof partial === 'function' ? partial(this._state) : partial;
this._state = { ...this._state, ...update };
if (this._name) { if (this._name) {
this._persist this._persist
? idbRun('put', this._state, this._name) ? idbSet(this._name, this._state)
: getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state); : getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state);
} }
this._notify(partial); this._notify(update);
return this._state; 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) { subscribe(target, selector) {
const isComponent = target && typeof target.setState === 'function'; const isComponent = target && typeof target.setState === 'function';
if (!isComponent && typeof target !== 'function') { const isFunction = typeof target === 'function';
throw new TypeError('Automat.subscribe expects a component or 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; let lastSlice = getSlice ? getSlice(this._state) : undefined;
const notifyFn = (state, partial) => { const notifyFn = (state, partial) => {
if (getSlice) { if (getSlice) {
const nextSlice = getSlice(state); const nextSlice = getSlice(state);
if (nextSlice == null && lastSlice == null) return; if (nextSlice == null) return;
if (shallowEqual(lastSlice, nextSlice)) return; if (shallowEqual(lastSlice, nextSlice)) return;
lastSlice = nextSlice; lastSlice = nextSlice;
if (isComponent) { if (isComponent) {
if (nextSlice !== null && typeof nextSlice !== 'object') { if (typeof nextSlice !== 'object') {
throw new TypeError('Automat: selector must return an 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 { } else {
target(nextSlice); target(nextSlice);
} }
@@ -336,8 +422,8 @@ export class Automat {
this._subs.set(target, notifyFn); this._subs.set(target, notifyFn);
if (this._url && this._isDirty) { if (this._loader && this._isDirty) {
this._fetchData(); this._fetchLoader();
} }
return () => { 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) { select(selector) {
const slice = resolveSelector(selector); const getSlice = resolveSelector(selector, false);
const self = this; const self = this;
return { return {
get state() { get state() {
return slice(self._state); return getSlice(self._state);
}, },
subscribe: (target) => self.subscribe(target, selector), subscribe: (target) => self.subscribe(target, selector),
}; };
@@ -363,7 +455,7 @@ export class Automat {
/** /**
* Registers a listener called whenever this automat becomes dirty. * Registers a listener called whenever this automat becomes dirty.
* @param {(automat: Automat) => void} fn * @param {(automat: Automat) => void} fn
* @returns {() => void} Unsubscribe function * @returns {() => void}
*/ */
onDirty(fn) { onDirty(fn) {
this._dirtySubs.add(fn); this._dirtySubs.add(fn);
@@ -372,7 +464,7 @@ export class Automat {
/** /**
* Cascading invalidation: automatically sets this automat dirty whenever * 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 {Automat} upstreamAutomat
* @param {(upstreamState: any, myState: any) => boolean} [filterFn] * @param {(upstreamState: any, myState: any) => boolean} [filterFn]
@@ -389,14 +481,13 @@ export class Automat {
} }
/** /**
* Connects this automat to an upstream automat. * Connects this automat to derive state from an upstream automat.
* Whenever upstream emits new state, transform(upstreamState, myState) runs.
* *
* @param {Automat} upstreamAutomat * @param {Automat} upstreamAutomat
* @param {(upstreamState: any, myState: any) => any} transform * @param {(upstreamState: any, myState: any) => any} transform
* @param {object} [options] * @param {object} [options]
* @param {boolean | ((upstreamState: any, myState: any) => boolean)} [options.cascadeDirty] * @param {boolean | ((upstreamState: any, myState: any) => boolean)} [options.cascadeDirty]
* @returns {this} * @returns {this} Chainable.
*/ */
subscribeTo(upstreamAutomat, transform, options = {}) { subscribeTo(upstreamAutomat, transform, options = {}) {
const unsub = upstreamAutomat.subscribe((upstreamState) => { 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. * Disposing it removes aggregate subscriptions but leaves its children alive.
*/ */
class CombinedAutomat extends Automat { class CombinedAutomat extends Automat {
@@ -469,7 +588,7 @@ class CombinedAutomat extends Automat {
for (const [key, automat] of entries) { for (const [key, automat] of entries) {
this._unsubs.push( this._unsubs.push(
automat.subscribe((state) => this.setState({ [key]: state })), automat.subscribe((state) => this.setState({ [key]: state })),
automat.onDirty(() => { automat.onDirty?.(() => {
for (const fn of this._dirtySubs) { for (const fn of this._dirtySubs) {
try { try {
fn(this); fn(this);
@@ -480,10 +599,6 @@ class CombinedAutomat extends Automat {
} }
} }
get isReady() {
return Object.values(this._children).every((automat) => automat.isReady);
}
get isDirty() { get isDirty() {
return Object.values(this._children).some((automat) => automat.isDirty); return Object.values(this._children).some((automat) => automat.isDirty);
} }
@@ -501,29 +616,18 @@ class CombinedAutomat extends Automat {
read() { read() {
this._state = Object.fromEntries( 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; return this._state;
} }
setDirty() { setDirty(keep = false, now = false) {
return Promise.all(Object.values(this._children).map((automat) => automat.setDirty())).then(() => { return Promise.all(Object.values(this._children).map((automat) => automat.setDirty?.(keep, now))).then(() => {
this._syncState(); this._syncState();
return this._state; 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() { _syncState() {
this._state = Object.fromEntries( this._state = Object.fromEntries(
Object.entries(this._children).map(([key, automat]) => [key, automat.state]) Object.entries(this._children).map(([key, automat]) => [key, automat.state])

View File

@@ -96,7 +96,11 @@ export const connectSSE = (automat, url, options = {}) => {
if (eventName === 'invalidate' || eventName === 'dirty' || data?.action === 'invalidate') { if (eventName === 'invalidate' || eventName === 'dirty' || data?.action === 'invalidate') {
automat.setDirty(); automat.setDirty();
} else if (eventName === 'reload' || data?.action === 'reload') { } 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();
}
} }
}; };

20
src/lib/index.d.ts vendored
View File

@@ -3,8 +3,7 @@ import type { PureComponent } from 'react';
export interface AutomatOptions<T = any> { export interface AutomatOptions<T = any> {
name?: string | null; name?: string | null;
persist?: boolean; persist?: boolean;
url?: string | null; loader?: (automat: Automat<T, any>) => Promise<T | Partial<T>> | T | Partial<T>;
fetcher?: (url: string, automat?: Automat<T, any>) => Promise<T>;
} }
export type Selector<T, S> = export type Selector<T, S> =
@@ -41,19 +40,21 @@ export type CombinedActions<M extends Record<string, Automat<any, any>>> = {
}; };
export class Automat<T = any, A = Record<string, Function>> { export class Automat<T = any, A = Record<string, Function>> {
constructor(initialState?: T | null, actions?: A, options?: AutomatOptions<T>); constructor(
initialState?: T | null,
actions?: A | ((automat: Automat<T, A>) => A),
options?: AutomatOptions<T>
);
readonly state: T; readonly state: T;
getState(): T; getState(): T;
setState(partial: Partial<T> | ((prevState: T) => Partial<T>)): T; setState(partial: Partial<T> | ((prevState: T) => Partial<T>)): T;
readonly actions: A; readonly actions: A;
readonly isReady: boolean;
readonly isDirty: boolean; readonly isDirty: boolean;
readonly ready: Promise<T>; readonly ready: Promise<T>;
readonly name: string | null; readonly name: string | null;
readonly persist: boolean; readonly persist: boolean;
readonly url: string | null;
readonly subscriberCount: number; readonly subscriberCount: number;
subscribe( subscribe(
@@ -77,9 +78,7 @@ export class Automat<T = any, A = Record<string, Function>> {
onDirty(fn: (automat: this) => void): () => void; onDirty(fn: (automat: this) => void): () => void;
setDirty(): Promise<T>; setDirty(keep?: boolean, now?: boolean): Promise<T>;
reload(): Promise<T>;
refresh(): Promise<T>;
read(): T; read(): T;
clearPersistence(): Promise<void>; clearPersistence(): Promise<void>;
@@ -89,6 +88,11 @@ export class Automat<T = any, A = Record<string, Function>> {
static combine<M extends Record<string, Automat<any, any>>>( static combine<M extends Record<string, Automat<any, any>>>(
automats: M automats: M
): Automat<CombinedState<M>, CombinedActions<M>>; ): Automat<CombinedState<M>, CombinedActions<M>>;
static combine<R = any>(
upstreamAutomats: Automat<any, any>[],
combiner: (...states: any[]) => R,
options?: AutomatOptions<R>
): Automat<R, Record<string, Function>>;
} }
export function fetchJson<T = any>(url: string, options?: RequestInit): Promise<T>; export function fetchJson<T = any>(url: string, options?: RequestInit): Promise<T>;

View File

@@ -1,7 +1,7 @@
/** /**
* @module react-automat * @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 * @example
* import { Automat } from './lib/index.js'; * import { Automat } from './lib/index.js';

View File

@@ -20,7 +20,6 @@ if (typeof window !== 'undefined' && typeof EventTarget !== 'undefined') {
return originalAddEventListener.call(this, type, listener, opts); return originalAddEventListener.call(this, type, listener, opts);
}; };
} }
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import './index.css'; import './index.css';