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
> Zero-dependency higher-order state container for React `PureComponent`. State lives in module scope (independent of mounting). Components read state synchronously in `constructor` and subscribe in `componentDidMount`. No Context, hooks, or HOCs.
> Zero-dependency higher-order observable state container for React `PureComponent`. State lives in module scope (independent of component mounting). Components read state synchronously in `constructor` and subscribe in `componentDidMount`. No Context, hooks, or HOCs.
[![npm version](https://img.shields.io/npm/v/react-automat?style=flat-square&color=crimson)](https://www.npmjs.com/package/react-automat)
[![bundle size](https://img.shields.io/bundlephobia/minzip/react-automat?style=flat-square&color=22c55e&label=minzipped)](https://bundlephobia.com/package/react-automat)
@@ -14,6 +14,61 @@ npm install react-automat
---
## Quick Start
```js
// counterAutomat.js
import { Automat } from 'react-automat';
export const counterAutomat = new Automat(
{ count: 0 },
{
increment(step = 1) {
counterAutomat.setState({ count: counterAutomat.state.count + step });
},
decrement(step = 1) {
counterAutomat.setState({ count: counterAutomat.state.count - step });
},
}
);
```
```jsx
// Counter.jsx
import { PureComponent } from 'react';
import { counterAutomat } from './counterAutomat.js';
export class Counter extends PureComponent {
constructor(props) {
super(props);
// 1. Read state directly in constructor (never stale)
this.state = counterAutomat.state;
}
componentDidMount() {
// 2. Subscribe component to updates
this.unsubscribe = counterAutomat.subscribe(this);
}
componentWillUnmount() {
// 3. Clean up on unmount
this.unsubscribe();
}
render() {
return (
<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
```ts
@@ -22,6 +77,7 @@ import type { PureComponent } from 'react';
export interface AutomatOptions<T> {
name?: string | null; // window.__AUTOMATS__ key & Automat.get(name)
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
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;
}
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>> {
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 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;
select<S = any>(selector: Selector<T, S>): ScopedSlice<S>;
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
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>>;
// 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
@@ -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>;
```
---
## 2. Canonical Usage Patterns
### A. PureComponent Subscription
@@ -118,14 +196,65 @@ store.subscribe(this, ['theme', 'locale']); // Multi key
store.subscribe(this, (s) => ({ items: s.list.length }));// Projection function
```
### C. Unconnected Component via Window (Zero imports of store file)
```js
const store = Automat.get('cart'); // or window.__AUTOMATS__?.get('cart')
this.state = { count: store?.state?.items?.length || 0 };
this.unsub = store?.subscribe(this, (s) => ({ count: s.items.length }));
### C. Async Fetch & Constructor Cache (Instant Re-Mount)
```jsx
class UserCard extends PureComponent {
constructor(props) {
super(props);
// 💡 Synchronous read in constructor:
// If already loaded, returns immediately -> 1 render only!
this.state = profileAsyncAutomat.getData();
}
componentDidMount() {
this.unsubscribe = profileAsyncAutomat.subscribe(this);
}
componentWillUnmount() {
this.unsubscribe();
}
render() {
const { status, data } = this.state;
if (status === 'pending') return <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
const user = new Automat(null, {}, { url: '/api/user' });
@@ -137,64 +266,51 @@ class ProfileStandard extends PureComponent {
constructor(props) { super(props); this.state = { u: user.state }; }
componentDidMount() { this.unsub = user.subscribe(this, (u) => ({ u })); }
componentWillUnmount() { this.unsub(); }
render() { return !user.isReady ? <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
const all = new Automat(null, {}, { name: 'photos/all', url: '/api/photos' });
const p1 = new Automat(null, {}, { name: 'photo/1', url: '/api/photos/1', fetcher: fetchImageBlob });
p1.invalidateWith(all); // p1 cascades setDirty() when all.setDirty() is called
const photoHub = new Automat(null, {}, { url: '/api/photos' });
const photo1 = new Automat(null, {}, { url: '/api/photos/1', fetcher: fetchImageBlob });
all.setDirty(); // p1 auto-revokes held Blob URL, resets state=null, defers reload if unmounted
// Automatically cascades setDirty when hub is dirtied
photo1.invalidateWith(photoHub);
// When photoHub.setDirty() is triggered:
// 1. photo1 calls URL.revokeObjectURL(photo1.state.url) to free browser image memory
// 2. photo1 resets to null/initial state
// 3. If photo1 is mounted, re-fetches immediately; if unmounted, defers fetch!
```
### F. SSE Remote Trigger
```js
const live = new Automat({}, {
onSSETrigger(data, event, store) {
if (event === 'invalidate') store.setDirty();
if (event === 'reload') store.reload();
}
});
connectSSE(live, '/api/stream');
```
---
### G. Combine Automats
```js
const app = Automat.combine({
auth: authAutomat,
company: companyAutomat,
});
## 3. Core Invariants
app.state.auth; // authAutomat.state
app.state.company; // companyAutomat.state
app.actions.auth.login(); // authAutomat.actions.login()
app.actions.company.rename('Acme');
1. **Lifecycle-Independent**: `Automat` instances live outside the React tree (typically in module scope), persisting state across component mounts and unmounts.
2. **Synchronous Constructor Reads**: Components initialize with `this.state = myAutomat.state;` directly in `constructor(props)`.
3. **Automatic Shallow Merges**: When subscribed with `subscribe(this)`, `automat.setState(partial)` calls `component.setState(partial)`, preserving any component-local state.
4. **Fine-Grained Selectors**: Passing a selector to `subscribe(this, selector)` runs shallow equality checks; updates to unrelated fields skip `setState` and avoid re-renders.
5. **Zero Wrappers**: No hooks, HOCs, Context Providers, or `connect()`.
6. **Optional Persistence**: Automats can specify a `name` and persist in the `window` object (`persist: false`) for session memory across unmounts/HMR, or in `IndexedDB` (`persist: true`) to survive full page reloads.
// Notified when either child changes; selectors can isolate a branch.
const unsubscribe = app.subscribe(this, (state) => ({
user: state.auth.user,
companyName: state.company.name,
}));
```
---
The aggregate is derived: child updates automatically update the matching key. Its
`isReady`, `isDirty`, `ready`, `read()`, `setDirty()`, and `reload()` operations cover
all children. Calling `app.dispose()` only removes aggregate subscriptions; it does
not dispose the child automats.
## Best Practices
## 3. Decision Matrix: `setDirty()` vs `reload()`
### ✅ DO
- Define `Automat` instances in module scope.
- Read `automat.state` or `automat.getData()` directly in `constructor(props)`.
- Subscribe in `componentDidMount()` and unsubscribe in `componentWillUnmount()`.
- Use selectors (`'key'`, `['keys']`, or function) on multi-field automats to avoid unnecessary re-renders.
- Return `null` in `subscribeTo` transforms when an update should be skipped.
| Method | `isReady` | State during fetch | Suspense | Unmounted | Use Case |
|---|---|---|---|---|---|
| `setDirty()` | `false` | Resets to `null`/init | Throws fallback | Deferred until mount/read | Cache bust, logout, hard reset |
| `reload()` | `true` | Preserved | Never suspends | Immediate fetch | Polling, pull-to-refresh, sync |
## 4. Invariant Rules
- Read `store.state` synchronously in `constructor(props)` — never in render bodies.
- Subscribe in `componentDidMount()` & store the returned `unsub`; unsubscribe in `componentWillUnmount()`.
- Never mutate `store.state` directly (`store.state.x = 1` ❌); use `store.setState({ x })` or actions.
- Pass selectors to `subscribe(this, selector)` on multi-key stores to skip redundant renders.
- Never wrap components in Context Providers, HOCs, or custom hooks.
### ❌ DON'T
- Do not instantiate `Automat` inside React component lifecycle or render methods.
- Do not mutate state directly (`automat.state.count = 1`); use `setState()` or actions.
- Do not wrap components in React Context providers or HOCs.
- Do not forget to unsubscribe in `componentWillUnmount()`.

View File

@@ -10,6 +10,10 @@ import DynamicAutomatSubscriber from './examples/components/DynamicAutomatSubscr
import CartManager from './examples/components/CartManager.jsx';
import CartBadgeDisplay from './examples/components/CartBadgeDisplay.jsx';
import UnconnectedPartialSubscriber from './examples/components/UnconnectedPartialSubscriber.jsx';
import AsyncApiPrimaryView from './examples/components/AsyncApiPrimaryView.jsx';
import AsyncApiCollapsibleView from './examples/components/AsyncApiCollapsibleView.jsx';
import MultiApiIndividualControls from './examples/components/MultiApiIndividualControls.jsx';
import MultiApiCombinedDashboard from './examples/components/MultiApiCombinedDashboard.jsx';
import ApiBoundExample from './examples/components/ApiBoundExample.jsx';
import CascadingPhotosExample from './examples/components/CascadingPhotosExample.jsx';
import CombinedAutomatExample from './examples/components/CombinedAutomatExample.jsx';
@@ -26,31 +30,35 @@ class App extends PureComponent {
<span className="logo-version">v0.1.0</span>
</div>
<p className="tagline">
Higher-order state container for React{' '}
Higher-order observable state container for React{' '}
<code className="inline-code">PureComponent</code>
<br />
State lives independently of mounting — zero prop drilling · Optional Window and IndexedDB persistence.
</p>
</header>
<main className="app-main">
{/* ── Example 1: Counter ────────────────────────────────── */}
{/* ── Notification Bar ───────────────────────────────────── */}
<NotificationBar />
{/* ── Main Content ───────────────────────────────────────── */}
<main className="examples-container">
{/* ── Example 1: Basic Counter ──────────────────────────── */}
<section className="example-section" aria-labelledby="ex1-title">
<div className="section-header">
<div className="section-title-row">
<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>
<p>
Two independent <code className="inline-code">PureComponent</code>s
synchronized by a single <code className="inline-code">counterAutomat</code>.
Neither knows the other exists — they both subscribe to the same state container.
Two completely separate components subscribe to the same <code className="inline-code">counterAutomat</code>.
The controller button also maintains local UI state (<code className="inline-code">lastPressed</code>, animation classes).
Neither component passes props to the other.
</p>
<div className="flow-diagram" aria-label="Data flow">
<span className="flow-node">counterAutomat</span>
<div className="flow-diagram" aria-label="Component and Automat data flow">
<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-node flow-node-component">CounterButton</span>
<span className="flow-sep">&amp;</span>
<span className="flow-node flow-node-component">CounterDisplay</span>
</div>
</div>
@@ -63,85 +71,51 @@ class App extends PureComponent {
<div className="section-divider" />
{/* ── Example 2: Cascade ───────────────────────────────── */}
{/* ── Example 2: Cascading State ────────────────────────── */}
<section className="example-section" aria-labelledby="ex2-title">
<div className="section-header">
<div className="section-title-row">
<span className="section-number">02</span>
<h2 id="ex2-title">Notification Cascade</h2>
<h2 id="ex2-title">Cascading Automats (Reactive Transformation)</h2>
</div>
<p>
<code className="inline-code">subscribeTo()</code> connects two automats into a
<strong> reactive pipeline</strong>. Clicking triggers in <code className="inline-code">CascadeControls</code> mutates{' '}
<code className="inline-code">counterAutomat</code> (upstream). This automatically invokes the{' '}
<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.
<code className="inline-code">notificationAutomat</code> derives its state from{' '}
<code className="inline-code">counterAutomat</code> using <code className="inline-code">subscribeTo</code>.
Every state change cascades automatically without manual event dispatching.
</p>
<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-node">counterAutomat.actions.increment()</span>
<span className="flow-node">transform(counterState)</span>
<span className="flow-arrow">→</span>
<span className="flow-node">subscribeTo(upstream, transform)</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>
<span className="flow-node flow-node-component">notificationAutomat</span>
</div>
</div>
<div className="two-columns">
<CascadeControls />
<NotificationBar />
</div>
<CascadeControls />
</section>
<div className="section-divider" />
{/* ── Example 3: API Auto-Sync ─────────────────────────── */}
{/* ── Example 3: Backend Synchronization ────────────────── */}
<section className="example-section" aria-labelledby="ex3-title">
<div className="section-header">
<div className="section-title-row">
<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>
<p>
<code className="inline-code">syncCounterAutomat</code> manages an indexed counter that automatically
synchronizes its mutations to the backend via HTTP <code className="inline-code">POST /api/counter</code>.
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.
State changes trigger asynchronous backend persistence via a custom fetch handler in the Automat’s actions.
A polling subscriber independently monitors the server-side state without polling the Automat itself.
</p>
<div className="flow-diagram" aria-label="API auto-sync flow">
<span className="flow-node">User Click</span>
<div className="flow-diagram" aria-label="Backend sync flow">
<span className="flow-node">SyncCounterControls</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-node">POST /api/counter</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>
<span className="flow-node flow-node-component">SyncBackendMonitor</span>
</div>
</div>
@@ -153,25 +127,24 @@ class App extends PureComponent {
<div className="section-divider" />
{/* ── Example 4: Dynamic Map Resubscription ──────────── */}
{/* ── Example 4: Dynamic Slot Allocation & Persistence ──── */}
<section className="example-section" aria-labelledby="ex4-title">
<div className="section-header">
<div className="section-title-row">
<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>
<p>
The left side is a standard <code className="inline-code">indexAutomat</code> counter (like Example 1).
On the right side, that counter’s value is used to dynamically lookup, instantiate, and resubscribe to
a different <code className="inline-code">Automat</code> stored in <code className="inline-code">window.automats = new Map()</code>.
Stepping through indexes unhooks from the old Automat, subscribes to the new one, and instantly recalls its preserved state.
Demonstrates how state lives <em>outside</em> the React component tree.
Changing the active index resubscribes the display component to a different named Automat.
Previous slot state is never destroyed on unmount — return to any slot at any time to find its clicks and notes intact.
</p>
<div className="flow-diagram" aria-label="Index-driven resubscription flow">
<span className="flow-node">Shared Index: N</span>
<div className="flow-diagram" aria-label="Dynamic slot data flow">
<span className="flow-node">IndexSelector</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-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-node flow-node-component">Recall State for Slot #N</span>
</div>
@@ -211,12 +184,12 @@ class App extends PureComponent {
<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>.
<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>
<div className="flow-diagram" aria-label="Partial subscription data flow">
<span className="flow-node">cartAutomat.setState()</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-node">shallowEqual(lastSlice, nextSlice)</span>
<span className="flow-arrow">→</span>
@@ -235,15 +208,86 @@ class App extends PureComponent {
<div className="section-divider" />
{/* ── Example 6: Backend API Binding & Suspense ─────── */}
{/* ── Example 6: Async Fetch & Constructor Cache ──────── */}
<section className="example-section" aria-labelledby="ex6-title">
<div className="section-header">
<div className="section-title-row">
<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>
<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>,
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,
@@ -265,12 +309,12 @@ class App extends PureComponent {
<div className="section-divider" />
{/* ── Example 7: Cascading setDirty, Blobs & SSE ────── */}
<section className="example-section" aria-labelledby="ex7-title">
{/* ── Example 9: Cascading setDirty, Blobs & SSE ────── */}
<section className="example-section" aria-labelledby="ex9-title">
<div className="section-header">
<div className="section-title-row">
<span className="section-number">07</span>
<h2 id="ex7-title">Cascading setDirty, Binary Blobs &amp; Server-Sent Events (SSE)</h2>
<span className="section-number">09</span>
<h2 id="ex9-title">Cascading setDirty, Binary Blobs &amp; Server-Sent Events (SSE)</h2>
</div>
<p>
Demonstrates <strong>cascading invalidation</strong>, <strong>automatic Blob memory revoking</strong>,
@@ -302,15 +346,15 @@ class App extends PureComponent {
<div className="section-divider" />
{/* ── Example 8: Combined Automats ──────────────────── */}
<section className="example-section" aria-labelledby="ex8-title">
{/* ── Example 10: Dictionary Combined Automats ───────── */}
<section className="example-section" aria-labelledby="ex10-title">
<div className="section-header">
<div className="section-title-row">
<span className="section-number">08</span>
<h2 id="ex8-title">Combined Automats</h2>
<span className="section-number">10</span>
<h2 id="ex10-title">Dictionary Combined Automats</h2>
</div>
<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.
</p>
<div className="flow-diagram" aria-label="Combined automat flow">
@@ -327,6 +371,13 @@ class App extends PureComponent {
<CombinedAutomatExample />
</section>
</main>
<footer className="app-footer">
<p>
<code className="inline-code">react-automat</code> · MIT ·{' '}
State is independent of component lifecycle
</p>
</footer>
</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) {
window.automats = new Map();
if (typeof window !== 'undefined') {
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);
}
.badge-success {
background: rgba(184, 187, 38, 0.15);
color: var(--green);
border: 1px solid rgba(184, 187, 38, 0.3);
}
/* ── Spinner & Skeleton Animations ─────────────────── */
.spinner-gruvbox {
width: 18px;
height: 18px;
border: 2px solid rgba(250, 189, 47, 0.25);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin-gruvbox 0.75s linear infinite;
display: inline-block;
flex-shrink: 0;
}
@keyframes spin-gruvbox {
to { transform: rotate(360deg); }
}
.skeleton-bar {
background: linear-gradient(90deg, var(--surface-1) 25%, var(--surface-2) 50%, var(--surface-1) 75%);
background-size: 200% 100%;
animation: skeleton-pulse 1.4s ease-in-out infinite;
border-radius: var(--r-sm);
}
@keyframes skeleton-pulse {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ── Buttons ───────────────────────────────────────── */
.btn {

View File

@@ -1,73 +1,131 @@
/**
* @module Automat
*
* Higher-order state container designed for React PureComponent with optional
* persistence (Window or IndexedDB) and backend API binding.
* Higher-order observable state container for React PureComponents with optional
* persistence (Window or IndexedDB), backend API binding, cascading invalidation,
* and multi-automat combination.
*/
/**
* Shallow equality check between two values or objects.
* Performs a shallow equality check between two values or objects.
* Used by Automat subscribers to avoid unnecessary setState calls when
* a subscribed slice of state has not changed.
*/
function shallowEqual(a, b) {
export function shallowEqual(a, b) {
if (Object.is(a, b)) return true;
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
const kA = Object.keys(a);
return kA.length === Object.keys(b).length && kA.every((k) => Object.is(a[k], b[k]));
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (let i = 0; i < keysA.length; i++) {
const key = keysA[i];
if (!Object.prototype.hasOwnProperty.call(b, key) || !Object.is(a[key], b[key])) {
return false;
}
}
return true;
}
// ── Global Window & Registry Aliases ──────────────────────────────
const W = typeof window !== 'undefined' ? window : null;
const getGlobalMap = (key) => W && (W[key] ||= new Map());
// ── Unified IndexedDB Runner ──────────────────────────────────────
// ── Unified IndexedDB Storage ─────────────────────────────────────
const IDB_NAME = 'automat_db';
const IDB_STORE = 'states';
let idbPromise = null;
const getIdb = () =>
typeof indexedDB === 'undefined'
? Promise.resolve(null)
: (idbPromise ||= new Promise((resolve) => {
try {
const req = indexedDB.open(IDB_NAME, 1);
req.onupgradeneeded = () => {
if (!req.result.objectStoreNames.contains(IDB_STORE)) {
req.result.createObjectStore(IDB_STORE);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
} catch {
resolve(null);
}
}));
function getIdb() {
if (typeof indexedDB === 'undefined') return Promise.resolve(null);
if (!idbPromise) {
idbPromise = new Promise((resolve) => {
try {
const req = indexedDB.open(IDB_NAME, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(IDB_STORE)) {
db.createObjectStore(IDB_STORE);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
} catch {
resolve(null);
}
});
}
return idbPromise;
}
const idbRun = (method, ...args) =>
getIdb().then(
(db) =>
db &&
new Promise((resolve) => {
try {
const req = db
.transaction(IDB_STORE, method === 'get' ? 'readonly' : 'readwrite')
.objectStore(IDB_STORE)[method](...args);
req.onsuccess = req.oncomplete = () => resolve(req.result);
req.onerror = () => resolve();
} catch {
resolve();
}
})
);
function idbGet(key) {
return getIdb().then((db) => {
if (!db) return undefined;
return new Promise((resolve) => {
try {
const tx = db.transaction(IDB_STORE, 'readonly');
const req = tx.objectStore(IDB_STORE).get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(undefined);
} catch {
resolve(undefined);
}
});
});
}
function idbSet(key, value) {
return getIdb().then((db) => {
if (!db) return;
return new Promise((resolve) => {
try {
const tx = db.transaction(IDB_STORE, 'readwrite');
tx.objectStore(IDB_STORE).put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
} catch {
resolve();
}
});
});
}
function idbDelete(key) {
return getIdb().then((db) => {
if (!db) return;
return new Promise((resolve) => {
try {
const tx = db.transaction(IDB_STORE, 'readwrite');
tx.objectStore(IDB_STORE).delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
} catch {
resolve();
}
});
});
}
// ── Selector Normalizer ───────────────────────────────────────────
const resolveSelector = (s) =>
typeof s === 'function'
? s
: Array.isArray(s)
? (x) => x && Object.fromEntries(s.map((k) => [k, x[k]]))
: s
? (x) => x && { [s]: x[s] }
: (x) => x;
function resolveSelector(s, isComponent = false) {
if (typeof s === 'function') {
return s;
}
if (typeof s === 'string') {
return isComponent ? (state) => (state ? { [s]: state[s] } : state) : (state) => state?.[s];
}
if (Array.isArray(s)) {
return (state) => {
if (!state) return state;
const slice = {};
for (let i = 0; i < s.length; i++) {
const k = s[i];
slice[k] = state[k];
}
return slice;
};
}
return (state) => state;
}
export class Automat {
constructor(initialState = null, callbacks = {}, options = {}) {
@@ -79,69 +137,73 @@ export class Automat {
: null;
this._state = this._initial ? { ...this._initial } : null;
this._cbs = callbacks;
const resolvedCallbacks = typeof callbacks === 'function' ? callbacks(this) : (callbacks || {});
this._cbs = {};
for (const [key, fn] of Object.entries(resolvedCallbacks)) {
this._cbs[key] = typeof fn === 'function' ? fn.bind(this) : fn;
}
this._subs = new Map();
this._dirtySubs = new Set();
this._unsubs = [];
this._name = options.name ?? null;
this._persist = Boolean(options.persist);
this._url = options.url ?? null;
this._fetcher = options.fetcher || ((url) => fetch(url).then((r) => r.json()));
this._loader = options.loader ?? null;
this._error = null;
if (this._name) {
getGlobalMap('__AUTOMATS__')?.set(this._name, this);
}
this._isReady = !this._url && !(this._name && this._persist);
this._isDirty = false;
this._ready = this._url
? this._fetchData()
: this._name && this._persist
? idbRun('get', this._name).then((saved) => {
if (saved && typeof saved === 'object') this.setState(saved);
this._isReady = true;
return this._state;
})
: Promise.resolve(
this._name && !this._persist
? ((this._state = {
...this._state,
...getGlobalMap('__AUTOMAT_STATE__')?.get(this._name),
}),
getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state),
this._state)
: this._state
);
if (this._loader) {
this._ready = this._fetchLoader();
} else if (this._name && this._persist) {
this._ready = idbGet(this._name).then((saved) => {
if (saved && typeof saved === 'object') {
this.setState(saved);
}
return this._state;
});
} else if (this._name && !this._persist) {
const saved = getGlobalMap('__AUTOMAT_STATE__')?.get(this._name);
if (saved && typeof saved === 'object') {
this._state = { ...this._state, ...saved };
}
getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state);
this._ready = Promise.resolve(this._state);
} else {
this._ready = Promise.resolve(this._state);
}
}
/**
* Internal data fetcher for backend API binding.
* @param {boolean} [silent=false] If true, preserves isReady without unready flash.
* Internal data loader for function-backed automats.
* @private
*/
_fetchData(silent = false) {
if (!silent) this._isReady = false;
_fetchLoader() {
this._isDirty = false;
this._error = null;
const promise = this._fetcher(this._url, this)
const promise = Promise.resolve(this._loader(this))
.then((data) => {
this._state = data;
this._isReady = true;
if (data && typeof data === 'object') {
this._state = { ...this._state, ...data };
} else if (data !== undefined) {
this._state = data;
}
this._isDirty = false;
this._error = null;
if (this._name) {
this._persist
? idbRun('put', this._state, this._name)
? idbSet(this._name, this._state)
: getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state);
}
this._notify(this._state);
return this._state;
})
.catch((err) => {
this._error = err;
this._isReady = false;
this._error = err?.message || String(err);
this._notify(this._state);
throw err;
});
@@ -152,21 +214,26 @@ export class Automat {
/**
* Marks the automat as dirty and unready.
* State resets to initial default or null (if no defaults were given).
*
* If subscribed anywhere, reloads immediately from backend API.
* If not subscribed anywhere, defers reload until read() or subscribe().
* State resets to initial default or null.
* If subscribed anywhere and configured with a loader, triggers loader fetch immediately.
*/
setDirty() {
setDirty(keep = false, now = false) {
let shouldKeep = Boolean(keep);
let shouldNow = Boolean(now);
if (typeof keep === 'object' && keep !== null) {
shouldKeep = Boolean(keep.keep);
shouldNow = Boolean(keep.now);
}
if (typeof this._state?.revoke === 'function') {
try {
this._state.revoke();
} catch {}
}
this._isDirty = true;
this._isReady = false;
this._state = this._initial ? { ...this._initial } : null;
this._notify(this._state);
if (!shouldKeep) {
this._state = this._initial ? { ...this._initial } : null;
this._notify(this._state);
}
if (this._dirtySubs) {
for (const fn of this._dirtySubs) {
@@ -176,41 +243,24 @@ export class Automat {
}
}
if (this._subs.size > 0 && this._url) {
return this._fetchData();
if (this._loader && (shouldNow || this._subs.size > 0)) {
return this._fetchLoader();
}
return Promise.resolve(this._state);
}
/**
* Reloads data from the backend API without making the automat dirty or unready.
* Existing state remains intact and UI stays interactive until fresh data arrives.
*
* @returns {Promise<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.
* If dirty and unmounted, calling read() triggers deferred reload.
* If dirty and unmounted, triggers deferred fetch.
* If unready, throws the ready promise for React Suspense.
*/
read() {
if (this._url) {
if (this._loader) {
if (this._isDirty) {
this._fetchData();
this._fetchLoader();
}
if (!this._isReady) {
if (this._error) throw this._error;
if (this._error) throw this._error;
if (this._state === null && this._ready) {
throw this._ready;
}
}
@@ -227,14 +277,31 @@ export class Automat {
/**
* Combines multiple automats into one derived automat.
* The combined state and actions use the same keys as the supplied map.
* Child updates are forwarded without taking ownership of the children.
*
* @param {Record<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}
*/
static combine(automats) {
return new CombinedAutomat(automats);
static combine(upstream, combiner, options = {}) {
if (Array.isArray(upstream)) {
if (typeof combiner !== 'function') {
throw new TypeError('Automat.combine with array expects a combiner function as second argument.');
}
return createOrchestratedCombinedAutomat(upstream, combiner, options);
}
if (upstream && typeof upstream === 'object') {
return new CombinedAutomat(upstream);
}
throw new TypeError('Automat.combine expects an array or dictionary object of Automat instances.');
}
get name() {
@@ -245,12 +312,8 @@ export class Automat {
return this._persist;
}
get url() {
return this._url;
}
get isReady() {
return this._isReady;
get isPersisted() {
return this._persist;
}
get isDirty() {
@@ -266,8 +329,8 @@ export class Automat {
}
get ready() {
if (this._url && this._isDirty) {
return this._fetchData();
if (this._loader && this._isDirty) {
return this._fetchLoader();
}
return this._ready;
}
@@ -275,50 +338,73 @@ export class Automat {
async clearPersistence() {
if (!this._name) return;
this._persist
? await idbRun('delete', this._name)
? await idbDelete(this._name)
: getGlobalMap('__AUTOMAT_STATE__')?.delete(this._name);
}
get state() {
if (this._loader && this._isDirty) {
this._fetchLoader();
}
return this._state;
}
getState() {
return this._state;
return this.state;
}
/**
* Shallow-merges `partial` into current state and notifies all subscribers.
*/
setState(partial) {
this._state = { ...this._state, ...partial };
const update = typeof partial === 'function' ? partial(this._state) : partial;
this._state = { ...this._state, ...update };
if (this._name) {
this._persist
? idbRun('put', this._state, this._name)
? idbSet(this._name, this._state)
: getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state);
}
this._notify(partial);
this._notify(update);
return this._state;
}
/**
* Subscribes a React PureComponent instance or a listener callback.
* Supports slice subscriptions with string, array, or function selectors.
* Uses shallow equality to prevent re-renders when the slice has not changed.
*
* @param {object|function} target React component (`this`) or callback function.
* @param {string|string[]|function} [selector]
* @returns {function} Unsubscribe cleanup function.
*/
subscribe(target, selector) {
const isComponent = target && typeof target.setState === 'function';
if (!isComponent && typeof target !== 'function') {
throw new TypeError('Automat.subscribe expects a component or function.');
const isFunction = typeof target === 'function';
if (!isComponent && !isFunction) {
throw new TypeError(
'Automat.subscribe expects a callback function or a React component instance with a setState method.'
);
}
const getSlice = selector ? resolveSelector(selector) : null;
const hasSelector = selector !== undefined && selector !== null;
const getSlice = hasSelector ? resolveSelector(selector, isComponent) : null;
let lastSlice = getSlice ? getSlice(this._state) : undefined;
const notifyFn = (state, partial) => {
if (getSlice) {
const nextSlice = getSlice(state);
if (nextSlice == null && lastSlice == null) return;
if (nextSlice == null) return;
if (shallowEqual(lastSlice, nextSlice)) return;
lastSlice = nextSlice;
if (isComponent) {
if (nextSlice !== null && typeof nextSlice !== 'object') {
throw new TypeError('Automat: selector must return an object.');
if (typeof nextSlice !== 'object') {
throw new TypeError(
'Automat: selector for a React component must return a state object, e.g. state => ({ count: state.count }).'
);
}
nextSlice ? target.setState(nextSlice) : target.forceUpdate();
target.setState(nextSlice);
} else {
target(nextSlice);
}
@@ -336,8 +422,8 @@ export class Automat {
this._subs.set(target, notifyFn);
if (this._url && this._isDirty) {
this._fetchData();
if (this._loader && this._isDirty) {
this._fetchLoader();
}
return () => {
@@ -345,12 +431,18 @@ export class Automat {
};
}
/**
* Slices this automat to a subset of state for reading and subscription.
*
* @param {string|string[]|function} selector
* @returns {{ readonly state: any, subscribe(target: object|function): function }}
*/
select(selector) {
const slice = resolveSelector(selector);
const getSlice = resolveSelector(selector, false);
const self = this;
return {
get state() {
return slice(self._state);
return getSlice(self._state);
},
subscribe: (target) => self.subscribe(target, selector),
};
@@ -363,7 +455,7 @@ export class Automat {
/**
* Registers a listener called whenever this automat becomes dirty.
* @param {(automat: Automat) => void} fn
* @returns {() => void} Unsubscribe function
* @returns {() => void}
*/
onDirty(fn) {
this._dirtySubs.add(fn);
@@ -372,7 +464,7 @@ export class Automat {
/**
* Cascading invalidation: automatically sets this automat dirty whenever
* upstreamAutomat is set dirty. If filterFn is provided, only invalidates when it returns true.
* upstreamAutomat is set dirty.
*
* @param {Automat} upstreamAutomat
* @param {(upstreamState: any, myState: any) => boolean} [filterFn]
@@ -389,14 +481,13 @@ export class Automat {
}
/**
* Connects this automat to an upstream automat.
* Whenever upstream emits new state, transform(upstreamState, myState) runs.
* Connects this automat to derive state from an upstream automat.
*
* @param {Automat} upstreamAutomat
* @param {(upstreamState: any, myState: any) => any} transform
* @param {object} [options]
* @param {boolean | ((upstreamState: any, myState: any) => boolean)} [options.cascadeDirty]
* @returns {this}
* @returns {this} Chainable.
*/
subscribeTo(upstreamAutomat, transform, options = {}) {
const unsub = upstreamAutomat.subscribe((upstreamState) => {
@@ -443,7 +534,35 @@ export class Automat {
}
/**
* Derived aggregate returned by Automat.combine().
* Creates an orchestrated combined Automat from an array of upstreams and combiner function.
*/
function createOrchestratedCombinedAutomat(upstreamAutomats, combiner, options = {}) {
const computeInitial = () => {
const upstreamStates = upstreamAutomats.map((a) => a.state);
return combiner(...upstreamStates);
};
const combined = new Automat(
computeInitial(),
{},
options
);
// Subscribe to all upstreams
upstreamAutomats.forEach((upstream) => {
combined.subscribeTo(upstream, () => {
const upstreamStates = upstreamAutomats.map((a) => a.state);
const combinedSlice = combiner(...upstreamStates);
combined.setState(combinedSlice);
return combinedSlice;
});
});
return combined;
}
/**
* Derived aggregate returned by Automat.combine({ auth: authAutomat, company: companyAutomat }).
* Disposing it removes aggregate subscriptions but leaves its children alive.
*/
class CombinedAutomat extends Automat {
@@ -469,7 +588,7 @@ class CombinedAutomat extends Automat {
for (const [key, automat] of entries) {
this._unsubs.push(
automat.subscribe((state) => this.setState({ [key]: state })),
automat.onDirty(() => {
automat.onDirty?.(() => {
for (const fn of this._dirtySubs) {
try {
fn(this);
@@ -480,10 +599,6 @@ class CombinedAutomat extends Automat {
}
}
get isReady() {
return Object.values(this._children).every((automat) => automat.isReady);
}
get isDirty() {
return Object.values(this._children).some((automat) => automat.isDirty);
}
@@ -501,29 +616,18 @@ class CombinedAutomat extends Automat {
read() {
this._state = Object.fromEntries(
Object.entries(this._children).map(([key, automat]) => [key, automat.read()])
Object.entries(this._children).map(([key, automat]) => [key, automat.read?.() ?? automat.state])
);
return this._state;
}
setDirty() {
return Promise.all(Object.values(this._children).map((automat) => automat.setDirty())).then(() => {
setDirty(keep = false, now = false) {
return Promise.all(Object.values(this._children).map((automat) => automat.setDirty?.(keep, now))).then(() => {
this._syncState();
return this._state;
});
}
reload() {
return Promise.all(Object.values(this._children).map((automat) => automat.reload())).then(() => {
this._syncState();
return this._state;
});
}
refresh() {
return this.reload();
}
_syncState() {
this._state = Object.fromEntries(
Object.entries(this._children).map(([key, automat]) => [key, automat.state])

View File

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

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

@@ -3,8 +3,7 @@ import type { PureComponent } from 'react';
export interface AutomatOptions<T = any> {
name?: string | null;
persist?: boolean;
url?: string | null;
fetcher?: (url: string, automat?: Automat<T, any>) => Promise<T>;
loader?: (automat: Automat<T, any>) => Promise<T | Partial<T>> | T | Partial<T>;
}
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>> {
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;
getState(): T;
setState(partial: Partial<T> | ((prevState: T) => Partial<T>)): T;
readonly actions: A;
readonly isReady: boolean;
readonly isDirty: boolean;
readonly ready: Promise<T>;
readonly name: string | null;
readonly persist: boolean;
readonly url: string | null;
readonly subscriberCount: number;
subscribe(
@@ -77,9 +78,7 @@ export class Automat<T = any, A = Record<string, Function>> {
onDirty(fn: (automat: this) => void): () => void;
setDirty(): Promise<T>;
reload(): Promise<T>;
refresh(): Promise<T>;
setDirty(keep?: boolean, now?: boolean): Promise<T>;
read(): T;
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>>>(
automats: 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>;

View File

@@ -1,7 +1,7 @@
/**
* @module react-automat
*
* Higher-order state container for React PureComponents with optional persistence.
* Observable higher-order state container for React PureComponents with optional persistence.
*
* @example
* import { Automat } from './lib/index.js';

View File

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