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:
226
README.md
226
README.md
@@ -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.
|
||||||
|
|
||||||
[](https://www.npmjs.com/package/react-automat)
|
[](https://www.npmjs.com/package/react-automat)
|
||||||
[](https://bundlephobia.com/package/react-automat)
|
[](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.
|
|
||||||
|
|||||||
225
src/App.jsx
225
src/App.jsx
@@ -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 & 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">&</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 & 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 & 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('cart')</code>), and subscribes only to <code className="inline-code">['couponCode']</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('cart')</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 & Dirty Invalidation</h2>
|
<h2 id="ex6-title">Async API Fetch & Constructor Cache (Instant Re-Mount)</h2>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
An automat can bind directly to a backend URL (<code className="inline-code">url: '/api/profile'</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 & 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 & Server-Sent Events (SSE)</h2>
|
<h2 id="ex9-title">Cascading setDirty, Binary Blobs & 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
146
src/examples/automats/combinedMultiAutomat.js
Normal file
146
src/examples/automats/combinedMultiAutomat.js
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
93
src/examples/automats/profileAsyncAutomat.js
Normal file
93
src/examples/automats/profileAsyncAutomat.js
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
304
src/examples/components/AsyncApiCollapsibleView.jsx
Normal file
304
src/examples/components/AsyncApiCollapsibleView.jsx
Normal 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: 'success'</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
327
src/examples/components/AsyncApiPrimaryView.jsx
Normal file
327
src/examples/components/AsyncApiPrimaryView.jsx
Normal 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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
“{data.statusMessage}”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
389
src/examples/components/MultiApiCombinedDashboard.jsx
Normal file
389
src/examples/components/MultiApiCombinedDashboard.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
204
src/examples/components/MultiApiIndividualControls.jsx
Normal file
204
src/examples/components/MultiApiIndividualControls.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,38 +1,50 @@
|
|||||||
/**
|
/**
|
||||||
* @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.onsuccess = () => resolve(req.result);
|
||||||
@@ -40,34 +52,80 @@ const getIdb = () =>
|
|||||||
} catch {
|
} catch {
|
||||||
resolve(null);
|
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 req = db
|
const tx = db.transaction(IDB_STORE, 'readonly');
|
||||||
.transaction(IDB_STORE, method === 'get' ? 'readonly' : 'readwrite')
|
const req = tx.objectStore(IDB_STORE).get(key);
|
||||||
.objectStore(IDB_STORE)[method](...args);
|
req.onsuccess = () => resolve(req.result);
|
||||||
req.onsuccess = req.oncomplete = () => resolve(req.result);
|
req.onerror = () => resolve(undefined);
|
||||||
req.onerror = () => resolve();
|
} 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 {
|
} catch {
|
||||||
resolve();
|
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') {
|
||||||
|
this.setState(saved);
|
||||||
|
}
|
||||||
return this._state;
|
return this._state;
|
||||||
})
|
});
|
||||||
: Promise.resolve(
|
} else if (this._name && !this._persist) {
|
||||||
this._name && !this._persist
|
const saved = getGlobalMap('__AUTOMAT_STATE__')?.get(this._name);
|
||||||
? ((this._state = {
|
if (saved && typeof saved === 'object') {
|
||||||
...this._state,
|
this._state = { ...this._state, ...saved };
|
||||||
...getGlobalMap('__AUTOMAT_STATE__')?.get(this._name),
|
}
|
||||||
}),
|
getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state);
|
||||||
getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state),
|
this._ready = Promise.resolve(this._state);
|
||||||
this._state)
|
} else {
|
||||||
: this._state
|
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) => {
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
this._state = { ...this._state, ...data };
|
||||||
|
} else if (data !== undefined) {
|
||||||
this._state = data;
|
this._state = data;
|
||||||
this._isReady = true;
|
}
|
||||||
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])
|
||||||
|
|||||||
@@ -96,8 +96,12 @@ 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') {
|
||||||
|
if (typeof automat.actions?.reload === 'function') {
|
||||||
|
automat.actions.reload();
|
||||||
|
} else if (typeof automat.reload === 'function') {
|
||||||
automat.reload();
|
automat.reload();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
es.onmessage = (e) => handleData('message', e.data);
|
es.onmessage = (e) => handleData('message', e.data);
|
||||||
|
|||||||
20
src/lib/index.d.ts
vendored
20
src/lib/index.d.ts
vendored
@@ -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>;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user