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
|
||||
|
||||
> 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://bundlephobia.com/package/react-automat)
|
||||
@@ -14,6 +14,61 @@ npm install react-automat
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```js
|
||||
// counterAutomat.js
|
||||
import { Automat } from 'react-automat';
|
||||
|
||||
export const counterAutomat = new Automat(
|
||||
{ count: 0 },
|
||||
{
|
||||
increment(step = 1) {
|
||||
counterAutomat.setState({ count: counterAutomat.state.count + step });
|
||||
},
|
||||
decrement(step = 1) {
|
||||
counterAutomat.setState({ count: counterAutomat.state.count - step });
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
```jsx
|
||||
// Counter.jsx
|
||||
import { PureComponent } from 'react';
|
||||
import { counterAutomat } from './counterAutomat.js';
|
||||
|
||||
export class Counter extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
// 1. Read state directly in constructor (never stale)
|
||||
this.state = counterAutomat.state;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// 2. Subscribe component to updates
|
||||
this.unsubscribe = counterAutomat.subscribe(this);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// 3. Clean up on unmount
|
||||
this.unsubscribe();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<span>{this.state.count}</span>
|
||||
<button onClick={() => counterAutomat.actions.increment()}>+1</button>
|
||||
<button onClick={() => counterAutomat.actions.decrement()}>-1</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Complete Type Signatures
|
||||
|
||||
```ts
|
||||
@@ -22,6 +77,7 @@ import type { PureComponent } from 'react';
|
||||
export interface AutomatOptions<T> {
|
||||
name?: string | null; // window.__AUTOMATS__ key & Automat.get(name)
|
||||
persist?: boolean; // false = window (default), true = IndexedDB
|
||||
loader?: (automat: Automat<T, any>) => Promise<Partial<T> | void>;
|
||||
url?: string | null; // Auto-triggers fetcher(url) on init
|
||||
fetcher?: (url: string, automat?: Automat<T, any>) => Promise<T>;
|
||||
}
|
||||
@@ -44,6 +100,14 @@ export interface SSEOptions<T = any, A = any> {
|
||||
onError?: (err: any, automat: Automat<T, A>) => void;
|
||||
}
|
||||
|
||||
export type CombinedState<M extends Record<string, Automat<any, any>>> = {
|
||||
[K in keyof M]: M[K] extends Automat<infer T, any> ? T : never;
|
||||
};
|
||||
|
||||
export type CombinedActions<M extends Record<string, Automat<any, any>>> = {
|
||||
[K in keyof M]: M[K] extends Automat<any, infer A> ? A : never;
|
||||
};
|
||||
|
||||
export class Automat<T = any, A = Record<string, Function>> {
|
||||
constructor(initialState?: T | null, actions?: A, options?: AutomatOptions<T>);
|
||||
|
||||
@@ -60,6 +124,9 @@ export class Automat<T = any, A = Record<string, Function>> {
|
||||
readonly url: string | null;
|
||||
readonly subscriberCount: number;
|
||||
|
||||
getData(options?: { reload?: boolean }): T;
|
||||
load(options?: { reload?: boolean }): T;
|
||||
|
||||
subscribe(target: PureComponent | ((state: T, partial?: Partial<T>) => void), selector?: Selector<T>): () => void;
|
||||
select<S = any>(selector: Selector<T, S>): ScopedSlice<S>;
|
||||
unsubscribe(target: any): void;
|
||||
@@ -82,7 +149,16 @@ export class Automat<T = any, A = Record<string, Function>> {
|
||||
dispose(): void; // Unsubs all, revokes blobs, deletes from window registry
|
||||
|
||||
static get<T = any, A = any>(name: string): Automat<T, A> | undefined;
|
||||
|
||||
// Dictionary Combination:
|
||||
static combine<M extends Record<string, Automat<any, any>>>(automats: M): Automat<CombinedState<M>, CombinedActions<M>>;
|
||||
|
||||
// Array Combiner Function Combination:
|
||||
static combine<R = any>(
|
||||
upstreamAutomats: Automat<any, any>[],
|
||||
combiner: (...states: any[]) => R,
|
||||
options?: AutomatOptions<R>
|
||||
): Automat<R, Record<string, Function>>;
|
||||
}
|
||||
|
||||
// Fetchers & SSE
|
||||
@@ -94,6 +170,8 @@ export function connectSSE<T, A>(automat: Automat<T, A>, url: string, options?:
|
||||
export function createSSEFetcher(sseUrl: string, baseFetcher?: Function, sseOptions?: SSEOptions): (url: string, automat?: any) => Promise<any>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Canonical Usage Patterns
|
||||
|
||||
### A. PureComponent Subscription
|
||||
@@ -118,14 +196,65 @@ store.subscribe(this, ['theme', 'locale']); // Multi key
|
||||
store.subscribe(this, (s) => ({ items: s.list.length }));// Projection function
|
||||
```
|
||||
|
||||
### C. Unconnected Component via Window (Zero imports of store file)
|
||||
```js
|
||||
const store = Automat.get('cart'); // or window.__AUTOMATS__?.get('cart')
|
||||
this.state = { count: store?.state?.items?.length || 0 };
|
||||
this.unsub = store?.subscribe(this, (s) => ({ count: s.items.length }));
|
||||
### C. Async Fetch & Constructor Cache (Instant Re-Mount)
|
||||
```jsx
|
||||
class UserCard extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
// 💡 Synchronous read in constructor:
|
||||
// If already loaded, returns immediately -> 1 render only!
|
||||
this.state = profileAsyncAutomat.getData();
|
||||
}
|
||||
componentDidMount() {
|
||||
this.unsubscribe = profileAsyncAutomat.subscribe(this);
|
||||
}
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe();
|
||||
}
|
||||
render() {
|
||||
const { status, data } = this.state;
|
||||
if (status === 'pending') return <div className="spinner" />;
|
||||
return <div>{data.name}</div>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### D. Backend API: React Suspense vs Standard
|
||||
### D. Multi-API Orchestration (`Automat.combine`)
|
||||
```js
|
||||
const combinedDashboard = Automat.combine(
|
||||
[userStatsAutomat, systemMetricsAutomat],
|
||||
(stats, metrics) => ({
|
||||
stats: stats.data,
|
||||
metrics: metrics.data,
|
||||
})
|
||||
);
|
||||
|
||||
class Dashboard extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
// Instant resolution in constructor if both upstreams loaded earlier:
|
||||
this.state = combinedDashboard.getData();
|
||||
}
|
||||
componentDidMount() {
|
||||
this.unsubscribe = combinedDashboard.subscribe(this);
|
||||
}
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe();
|
||||
}
|
||||
render() {
|
||||
const { status, stats, metrics } = this.state;
|
||||
if (status === 'pending') return <div>Loading dual sources in parallel...</div>;
|
||||
return (
|
||||
<div>
|
||||
<h1>{stats.name}</h1>
|
||||
<div>Cluster: {metrics.clusterHealth}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### E. Backend API: React Suspense vs Standard
|
||||
```jsx
|
||||
const user = new Automat(null, {}, { url: '/api/user' });
|
||||
|
||||
@@ -137,64 +266,51 @@ class ProfileStandard extends PureComponent {
|
||||
constructor(props) { super(props); this.state = { u: user.state }; }
|
||||
componentDidMount() { this.unsub = user.subscribe(this, (u) => ({ u })); }
|
||||
componentWillUnmount() { this.unsub(); }
|
||||
render() { return !user.isReady ? <Spinner /> : <div>{this.state.u.name}</div>; }
|
||||
render() {
|
||||
if (!user.isReady) return <div>Loading...</div>;
|
||||
return <div>{this.state.u.name}</div>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### E. Cascading Invalidation, Blobs & Memory Freeing
|
||||
### F. Cascading Invalidation & Memory Freeing with Blobs
|
||||
```js
|
||||
const all = new Automat(null, {}, { name: 'photos/all', url: '/api/photos' });
|
||||
const p1 = new Automat(null, {}, { name: 'photo/1', url: '/api/photos/1', fetcher: fetchImageBlob });
|
||||
p1.invalidateWith(all); // p1 cascades setDirty() when all.setDirty() is called
|
||||
const photoHub = new Automat(null, {}, { url: '/api/photos' });
|
||||
const photo1 = new Automat(null, {}, { url: '/api/photos/1', fetcher: fetchImageBlob });
|
||||
|
||||
all.setDirty(); // p1 auto-revokes held Blob URL, resets state=null, defers reload if unmounted
|
||||
// Automatically cascades setDirty when hub is dirtied
|
||||
photo1.invalidateWith(photoHub);
|
||||
|
||||
// When photoHub.setDirty() is triggered:
|
||||
// 1. photo1 calls URL.revokeObjectURL(photo1.state.url) to free browser image memory
|
||||
// 2. photo1 resets to null/initial state
|
||||
// 3. If photo1 is mounted, re-fetches immediately; if unmounted, defers fetch!
|
||||
```
|
||||
|
||||
### F. SSE Remote Trigger
|
||||
```js
|
||||
const live = new Automat({}, {
|
||||
onSSETrigger(data, event, store) {
|
||||
if (event === 'invalidate') store.setDirty();
|
||||
if (event === 'reload') store.reload();
|
||||
}
|
||||
});
|
||||
connectSSE(live, '/api/stream');
|
||||
```
|
||||
---
|
||||
|
||||
### G. Combine Automats
|
||||
```js
|
||||
const app = Automat.combine({
|
||||
auth: authAutomat,
|
||||
company: companyAutomat,
|
||||
});
|
||||
## 3. Core Invariants
|
||||
|
||||
app.state.auth; // authAutomat.state
|
||||
app.state.company; // companyAutomat.state
|
||||
app.actions.auth.login(); // authAutomat.actions.login()
|
||||
app.actions.company.rename('Acme');
|
||||
1. **Lifecycle-Independent**: `Automat` instances live outside the React tree (typically in module scope), persisting state across component mounts and unmounts.
|
||||
2. **Synchronous Constructor Reads**: Components initialize with `this.state = myAutomat.state;` directly in `constructor(props)`.
|
||||
3. **Automatic Shallow Merges**: When subscribed with `subscribe(this)`, `automat.setState(partial)` calls `component.setState(partial)`, preserving any component-local state.
|
||||
4. **Fine-Grained Selectors**: Passing a selector to `subscribe(this, selector)` runs shallow equality checks; updates to unrelated fields skip `setState` and avoid re-renders.
|
||||
5. **Zero Wrappers**: No hooks, HOCs, Context Providers, or `connect()`.
|
||||
6. **Optional Persistence**: Automats can specify a `name` and persist in the `window` object (`persist: false`) for session memory across unmounts/HMR, or in `IndexedDB` (`persist: true`) to survive full page reloads.
|
||||
|
||||
// Notified when either child changes; selectors can isolate a branch.
|
||||
const unsubscribe = app.subscribe(this, (state) => ({
|
||||
user: state.auth.user,
|
||||
companyName: state.company.name,
|
||||
}));
|
||||
```
|
||||
---
|
||||
|
||||
The aggregate is derived: child updates automatically update the matching key. Its
|
||||
`isReady`, `isDirty`, `ready`, `read()`, `setDirty()`, and `reload()` operations cover
|
||||
all children. Calling `app.dispose()` only removes aggregate subscriptions; it does
|
||||
not dispose the child automats.
|
||||
## Best Practices
|
||||
|
||||
## 3. Decision Matrix: `setDirty()` vs `reload()`
|
||||
### ✅ DO
|
||||
- Define `Automat` instances in module scope.
|
||||
- Read `automat.state` or `automat.getData()` directly in `constructor(props)`.
|
||||
- Subscribe in `componentDidMount()` and unsubscribe in `componentWillUnmount()`.
|
||||
- Use selectors (`'key'`, `['keys']`, or function) on multi-field automats to avoid unnecessary re-renders.
|
||||
- Return `null` in `subscribeTo` transforms when an update should be skipped.
|
||||
|
||||
| Method | `isReady` | State during fetch | Suspense | Unmounted | Use Case |
|
||||
|---|---|---|---|---|---|
|
||||
| `setDirty()` | `false` | Resets to `null`/init | Throws fallback | Deferred until mount/read | Cache bust, logout, hard reset |
|
||||
| `reload()` | `true` | Preserved | Never suspends | Immediate fetch | Polling, pull-to-refresh, sync |
|
||||
|
||||
## 4. Invariant Rules
|
||||
- Read `store.state` synchronously in `constructor(props)` — never in render bodies.
|
||||
- Subscribe in `componentDidMount()` & store the returned `unsub`; unsubscribe in `componentWillUnmount()`.
|
||||
- Never mutate `store.state` directly (`store.state.x = 1` ❌); use `store.setState({ x })` or actions.
|
||||
- Pass selectors to `subscribe(this, selector)` on multi-key stores to skip redundant renders.
|
||||
- Never wrap components in Context Providers, HOCs, or custom hooks.
|
||||
### ❌ DON'T
|
||||
- Do not instantiate `Automat` inside React component lifecycle or render methods.
|
||||
- Do not mutate state directly (`automat.state.count = 1`); use `setState()` or actions.
|
||||
- Do not wrap components in React Context providers or HOCs.
|
||||
- Do not forget to unsubscribe in `componentWillUnmount()`.
|
||||
|
||||
227
src/App.jsx
227
src/App.jsx
@@ -10,6 +10,10 @@ import DynamicAutomatSubscriber from './examples/components/DynamicAutomatSubscr
|
||||
import CartManager from './examples/components/CartManager.jsx';
|
||||
import CartBadgeDisplay from './examples/components/CartBadgeDisplay.jsx';
|
||||
import UnconnectedPartialSubscriber from './examples/components/UnconnectedPartialSubscriber.jsx';
|
||||
import AsyncApiPrimaryView from './examples/components/AsyncApiPrimaryView.jsx';
|
||||
import AsyncApiCollapsibleView from './examples/components/AsyncApiCollapsibleView.jsx';
|
||||
import MultiApiIndividualControls from './examples/components/MultiApiIndividualControls.jsx';
|
||||
import MultiApiCombinedDashboard from './examples/components/MultiApiCombinedDashboard.jsx';
|
||||
import ApiBoundExample from './examples/components/ApiBoundExample.jsx';
|
||||
import CascadingPhotosExample from './examples/components/CascadingPhotosExample.jsx';
|
||||
import CombinedAutomatExample from './examples/components/CombinedAutomatExample.jsx';
|
||||
@@ -26,31 +30,35 @@ class App extends PureComponent {
|
||||
<span className="logo-version">v0.1.0</span>
|
||||
</div>
|
||||
<p className="tagline">
|
||||
Higher-order state container for React{' '}
|
||||
Higher-order observable state container for React{' '}
|
||||
<code className="inline-code">PureComponent</code>
|
||||
<br />
|
||||
State lives independently of mounting — zero prop drilling · Optional Window and IndexedDB persistence.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
{/* ── Example 1: Counter ────────────────────────────────── */}
|
||||
{/* ── Notification Bar ───────────────────────────────────── */}
|
||||
<NotificationBar />
|
||||
|
||||
{/* ── Main Content ───────────────────────────────────────── */}
|
||||
<main className="examples-container">
|
||||
{/* ── Example 1: Basic Counter ──────────────────────────── */}
|
||||
<section className="example-section" aria-labelledby="ex1-title">
|
||||
<div className="section-header">
|
||||
<div className="section-title-row">
|
||||
<span className="section-number">01</span>
|
||||
<h2 id="ex1-title">Counter</h2>
|
||||
<h2 id="ex1-title">Multi-Subscriber Sync & Local State Isolation</h2>
|
||||
</div>
|
||||
<p>
|
||||
Two independent <code className="inline-code">PureComponent</code>s
|
||||
synchronized by a single <code className="inline-code">counterAutomat</code>.
|
||||
Neither knows the other exists — they both subscribe to the same state container.
|
||||
Two completely separate components subscribe to the same <code className="inline-code">counterAutomat</code>.
|
||||
The controller button also maintains local UI state (<code className="inline-code">lastPressed</code>, animation classes).
|
||||
Neither component passes props to the other.
|
||||
</p>
|
||||
<div className="flow-diagram" aria-label="Data flow">
|
||||
<span className="flow-node">counterAutomat</span>
|
||||
<div className="flow-diagram" aria-label="Component and Automat data flow">
|
||||
<span className="flow-node">CounterButton</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node">counterAutomat.actions.increment()</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node flow-node-component">CounterButton</span>
|
||||
<span className="flow-sep">&</span>
|
||||
<span className="flow-node flow-node-component">CounterDisplay</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,85 +71,51 @@ class App extends PureComponent {
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* ── Example 2: Cascade ───────────────────────────────── */}
|
||||
{/* ── Example 2: Cascading State ────────────────────────── */}
|
||||
<section className="example-section" aria-labelledby="ex2-title">
|
||||
<div className="section-header">
|
||||
<div className="section-title-row">
|
||||
<span className="section-number">02</span>
|
||||
<h2 id="ex2-title">Notification Cascade</h2>
|
||||
<h2 id="ex2-title">Cascading Automats (Reactive Transformation)</h2>
|
||||
</div>
|
||||
<p>
|
||||
<code className="inline-code">subscribeTo()</code> connects two automats into a
|
||||
<strong> reactive pipeline</strong>. Clicking triggers in <code className="inline-code">CascadeControls</code> mutates{' '}
|
||||
<code className="inline-code">counterAutomat</code> (upstream). This automatically invokes the{' '}
|
||||
<code className="inline-code">subscribeTo()</code> transform callback, updating{' '}
|
||||
<code className="inline-code">notificationAutomat</code> (downstream), which then re-renders{' '}
|
||||
<code className="inline-code">NotificationBar</code> without any prop drilling or shared component parent.
|
||||
<code className="inline-code">notificationAutomat</code> derives its state from{' '}
|
||||
<code className="inline-code">counterAutomat</code> using <code className="inline-code">subscribeTo</code>.
|
||||
Every state change cascades automatically without manual event dispatching.
|
||||
</p>
|
||||
<div className="flow-diagram" aria-label="Cascade data flow">
|
||||
<span className="flow-node">User Click</span>
|
||||
<span className="flow-node">counterAutomat</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node">counterAutomat.actions.increment()</span>
|
||||
<span className="flow-node">transform(counterState)</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node">subscribeTo(upstream, transform)</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node">notificationAutomat.setState()</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node flow-node-component">NotificationBar</span>
|
||||
</div>
|
||||
|
||||
<div className="info-box">
|
||||
<div className="info-box-title">
|
||||
<span>ℹ️</span> How <code>subscribeTo(upstreamAutomat, transform)</code> Works
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
<strong><code>upstreamState</code>:</strong> The newly emitted state from the upstream automat (e.g. <code>{`{ count: 1 }`}</code>).
|
||||
</li>
|
||||
<li>
|
||||
<strong><code>myState</code>:</strong> The current state of this downstream automat right before updating (e.g. <code>{`{ messages: [...] }`}</code>). Acts as an accumulator to prepend events and cap history.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Return Value:</strong> The returned partial state object is automatically applied via <code>this.setState(partial)</code>, notifying downstream UI subscribers.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Conditional Filtering:</strong> Return <code>null</code> or <code>undefined</code> to selectively skip updates and avoid triggering renders.
|
||||
</li>
|
||||
</ul>
|
||||
<span className="flow-node flow-node-component">notificationAutomat</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="two-columns">
|
||||
<CascadeControls />
|
||||
<NotificationBar />
|
||||
</div>
|
||||
<CascadeControls />
|
||||
</section>
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* ── Example 3: API Auto-Sync ─────────────────────────── */}
|
||||
{/* ── Example 3: Backend Synchronization ────────────────── */}
|
||||
<section className="example-section" aria-labelledby="ex3-title">
|
||||
<div className="section-header">
|
||||
<div className="section-title-row">
|
||||
<span className="section-number">03</span>
|
||||
<h2 id="ex3-title">API-Backed Auto-Sync Counter (POST)</h2>
|
||||
<h2 id="ex3-title">Backend Synchronization & Optimistic Updates</h2>
|
||||
</div>
|
||||
<p>
|
||||
<code className="inline-code">syncCounterAutomat</code> manages an indexed counter that automatically
|
||||
synchronizes its mutations to the backend via HTTP <code className="inline-code">POST /api/counter</code>.
|
||||
State updates are applied <strong>optimistically</strong> for instant UI response, followed by asynchronous
|
||||
background synchronization and status tracking without any component-level <code className="inline-code">fetch</code> or lifecycle glue.
|
||||
State changes trigger asynchronous backend persistence via a custom fetch handler in the Automat’s actions.
|
||||
A polling subscriber independently monitors the server-side state without polling the Automat itself.
|
||||
</p>
|
||||
<div className="flow-diagram" aria-label="API auto-sync flow">
|
||||
<span className="flow-node">User Click</span>
|
||||
<div className="flow-diagram" aria-label="Backend sync flow">
|
||||
<span className="flow-node">SyncCounterControls</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node">Optimistic setState()</span>
|
||||
<span className="flow-node">syncCounterAutomat</span>
|
||||
<span className="flow-arrow">⇄</span>
|
||||
<span className="flow-node">Backend API</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node">POST /api/counter</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node">Backend Database Updated</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node flow-node-component">Sync Status 200 OK</span>
|
||||
<span className="flow-node flow-node-component">SyncBackendMonitor</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -153,25 +127,24 @@ class App extends PureComponent {
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* ── Example 4: Dynamic Map Resubscription ──────────── */}
|
||||
{/* ── Example 4: Dynamic Slot Allocation & Persistence ──── */}
|
||||
<section className="example-section" aria-labelledby="ex4-title">
|
||||
<div className="section-header">
|
||||
<div className="section-title-row">
|
||||
<span className="section-number">04</span>
|
||||
<h2 id="ex4-title">Dynamic Window Map (Index-Driven Resubscription)</h2>
|
||||
<h2 id="ex4-title">Dynamic Slot Allocation & Unmount-Safe State Recall</h2>
|
||||
</div>
|
||||
<p>
|
||||
The left side is a standard <code className="inline-code">indexAutomat</code> counter (like Example 1).
|
||||
On the right side, that counter’s value is used to dynamically lookup, instantiate, and resubscribe to
|
||||
a different <code className="inline-code">Automat</code> stored in <code className="inline-code">window.automats = new Map()</code>.
|
||||
Stepping through indexes unhooks from the old Automat, subscribes to the new one, and instantly recalls its preserved state.
|
||||
Demonstrates how state lives <em>outside</em> the React component tree.
|
||||
Changing the active index resubscribes the display component to a different named Automat.
|
||||
Previous slot state is never destroyed on unmount — return to any slot at any time to find its clicks and notes intact.
|
||||
</p>
|
||||
<div className="flow-diagram" aria-label="Index-driven resubscription flow">
|
||||
<span className="flow-node">Shared Index: N</span>
|
||||
<div className="flow-diagram" aria-label="Dynamic slot data flow">
|
||||
<span className="flow-node">IndexSelector</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node">window.automats.get(N)</span>
|
||||
<span className="flow-node">indexAutomat.state.index</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node">Unsub Old / Sub New</span>
|
||||
<span className="flow-node">Unsub Old Slot / Sub New Slot</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node flow-node-component">Recall State for Slot #N</span>
|
||||
</div>
|
||||
@@ -211,12 +184,12 @@ class App extends PureComponent {
|
||||
<br />
|
||||
1. <code className="inline-code">CartBadgeDisplay</code> subscribes <strong>only to list length</strong> (<code className="inline-code">items.length</code>) — item quantity and coupon edits cause <strong>zero re-renders</strong>.
|
||||
<br />
|
||||
2. <code className="inline-code">UnconnectedPartialSubscriber</code> has <strong>zero imports</strong> of the cart store, resolving it strictly via the named window object (<code className="inline-code">Automat.get('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>
|
||||
<div className="flow-diagram" aria-label="Partial subscription data flow">
|
||||
<span className="flow-node">cartAutomat.setState()</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node">window.__AUTOMATS__.get('cart')</span>
|
||||
<span className="flow-node">window.__AUTOMATS__.get('cart')</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node">shallowEqual(lastSlice, nextSlice)</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
@@ -235,15 +208,86 @@ class App extends PureComponent {
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* ── Example 6: Backend API Binding & Suspense ─────── */}
|
||||
{/* ── Example 6: Async Fetch & Constructor Cache ──────── */}
|
||||
<section className="example-section" aria-labelledby="ex6-title">
|
||||
<div className="section-header">
|
||||
<div className="section-title-row">
|
||||
<span className="section-number">06</span>
|
||||
<h2 id="ex6-title">Backend API Binding, React Suspense & Dirty Invalidation</h2>
|
||||
<h2 id="ex6-title">Async API Fetch & Constructor Cache (Instant Re-Mount)</h2>
|
||||
</div>
|
||||
<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>,
|
||||
and manual invalidation via <code className="inline-code">automat.setDirty()</code>.
|
||||
When dirty, state resets to default or null. If mounted, it reloads immediately. If all subscribers are unmounted,
|
||||
@@ -265,12 +309,12 @@ class App extends PureComponent {
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* ── Example 7: Cascading setDirty, Blobs & SSE ────── */}
|
||||
<section className="example-section" aria-labelledby="ex7-title">
|
||||
{/* ── Example 9: Cascading setDirty, Blobs & SSE ────── */}
|
||||
<section className="example-section" aria-labelledby="ex9-title">
|
||||
<div className="section-header">
|
||||
<div className="section-title-row">
|
||||
<span className="section-number">07</span>
|
||||
<h2 id="ex7-title">Cascading setDirty, Binary Blobs & Server-Sent Events (SSE)</h2>
|
||||
<span className="section-number">09</span>
|
||||
<h2 id="ex9-title">Cascading setDirty, Binary Blobs & Server-Sent Events (SSE)</h2>
|
||||
</div>
|
||||
<p>
|
||||
Demonstrates <strong>cascading invalidation</strong>, <strong>automatic Blob memory revoking</strong>,
|
||||
@@ -302,15 +346,15 @@ class App extends PureComponent {
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* ── Example 8: Combined Automats ──────────────────── */}
|
||||
<section className="example-section" aria-labelledby="ex8-title">
|
||||
{/* ── Example 10: Dictionary Combined Automats ───────── */}
|
||||
<section className="example-section" aria-labelledby="ex10-title">
|
||||
<div className="section-header">
|
||||
<div className="section-title-row">
|
||||
<span className="section-number">08</span>
|
||||
<h2 id="ex8-title">Combined Automats</h2>
|
||||
<span className="section-number">10</span>
|
||||
<h2 id="ex10-title">Dictionary Combined Automats</h2>
|
||||
</div>
|
||||
<p>
|
||||
<code className="inline-code">Automat.combine()</code> creates one derived application state from independent automats.
|
||||
<code className="inline-code">Automat.combine()</code> with an object map creates one derived application state from independent automats.
|
||||
Updates retain their namespace, actions remain grouped by child, and readiness operations cover every child.
|
||||
</p>
|
||||
<div className="flow-diagram" aria-label="Combined automat flow">
|
||||
@@ -327,6 +371,13 @@ class App extends PureComponent {
|
||||
<CombinedAutomatExample />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="app-footer">
|
||||
<p>
|
||||
<code className="inline-code">react-automat</code> · MIT ·{' '}
|
||||
State is independent of component lifecycle
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
window.automats = new Map();
|
||||
if (typeof window !== 'undefined') {
|
||||
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);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: rgba(184, 187, 38, 0.15);
|
||||
color: var(--green);
|
||||
border: 1px solid rgba(184, 187, 38, 0.3);
|
||||
}
|
||||
|
||||
/* ── Spinner & Skeleton Animations ─────────────────── */
|
||||
|
||||
.spinner-gruvbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(250, 189, 47, 0.25);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin-gruvbox 0.75s linear infinite;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin-gruvbox {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.skeleton-bar {
|
||||
background: linear-gradient(90deg, var(--surface-1) 25%, var(--surface-2) 50%, var(--surface-1) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-pulse 1.4s ease-in-out infinite;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
/* ── Buttons ───────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
|
||||
@@ -1,73 +1,131 @@
|
||||
/**
|
||||
* @module Automat
|
||||
*
|
||||
* Higher-order state container designed for React PureComponent with optional
|
||||
* persistence (Window or IndexedDB) and backend API binding.
|
||||
* Higher-order observable state container for React PureComponents with optional
|
||||
* persistence (Window or IndexedDB), backend API binding, cascading invalidation,
|
||||
* and multi-automat combination.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shallow equality check between two values or objects.
|
||||
* Performs a shallow equality check between two values or objects.
|
||||
* Used by Automat subscribers to avoid unnecessary setState calls when
|
||||
* a subscribed slice of state has not changed.
|
||||
*/
|
||||
function shallowEqual(a, b) {
|
||||
export function shallowEqual(a, b) {
|
||||
if (Object.is(a, b)) return true;
|
||||
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
|
||||
const kA = Object.keys(a);
|
||||
return kA.length === Object.keys(b).length && kA.every((k) => Object.is(a[k], b[k]));
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
for (let i = 0; i < keysA.length; i++) {
|
||||
const key = keysA[i];
|
||||
if (!Object.prototype.hasOwnProperty.call(b, key) || !Object.is(a[key], b[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Global Window & Registry Aliases ──────────────────────────────
|
||||
const W = typeof window !== 'undefined' ? window : null;
|
||||
const getGlobalMap = (key) => W && (W[key] ||= new Map());
|
||||
|
||||
// ── Unified IndexedDB Runner ──────────────────────────────────────
|
||||
// ── Unified IndexedDB Storage ─────────────────────────────────────
|
||||
const IDB_NAME = 'automat_db';
|
||||
const IDB_STORE = 'states';
|
||||
let idbPromise = null;
|
||||
|
||||
const getIdb = () =>
|
||||
typeof indexedDB === 'undefined'
|
||||
? Promise.resolve(null)
|
||||
: (idbPromise ||= new Promise((resolve) => {
|
||||
try {
|
||||
const req = indexedDB.open(IDB_NAME, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
if (!req.result.objectStoreNames.contains(IDB_STORE)) {
|
||||
req.result.createObjectStore(IDB_STORE);
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => resolve(null);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
}));
|
||||
function getIdb() {
|
||||
if (typeof indexedDB === 'undefined') return Promise.resolve(null);
|
||||
if (!idbPromise) {
|
||||
idbPromise = new Promise((resolve) => {
|
||||
try {
|
||||
const req = indexedDB.open(IDB_NAME, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(IDB_STORE)) {
|
||||
db.createObjectStore(IDB_STORE);
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => resolve(null);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
return idbPromise;
|
||||
}
|
||||
|
||||
const idbRun = (method, ...args) =>
|
||||
getIdb().then(
|
||||
(db) =>
|
||||
db &&
|
||||
new Promise((resolve) => {
|
||||
try {
|
||||
const req = db
|
||||
.transaction(IDB_STORE, method === 'get' ? 'readonly' : 'readwrite')
|
||||
.objectStore(IDB_STORE)[method](...args);
|
||||
req.onsuccess = req.oncomplete = () => resolve(req.result);
|
||||
req.onerror = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
);
|
||||
function idbGet(key) {
|
||||
return getIdb().then((db) => {
|
||||
if (!db) return undefined;
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = db.transaction(IDB_STORE, 'readonly');
|
||||
const req = tx.objectStore(IDB_STORE).get(key);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => resolve(undefined);
|
||||
} catch {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function idbSet(key, value) {
|
||||
return getIdb().then((db) => {
|
||||
if (!db) return;
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = db.transaction(IDB_STORE, 'readwrite');
|
||||
tx.objectStore(IDB_STORE).put(value, key);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function idbDelete(key) {
|
||||
return getIdb().then((db) => {
|
||||
if (!db) return;
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = db.transaction(IDB_STORE, 'readwrite');
|
||||
tx.objectStore(IDB_STORE).delete(key);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Selector Normalizer ───────────────────────────────────────────
|
||||
const resolveSelector = (s) =>
|
||||
typeof s === 'function'
|
||||
? s
|
||||
: Array.isArray(s)
|
||||
? (x) => x && Object.fromEntries(s.map((k) => [k, x[k]]))
|
||||
: s
|
||||
? (x) => x && { [s]: x[s] }
|
||||
: (x) => x;
|
||||
function resolveSelector(s, isComponent = false) {
|
||||
if (typeof s === 'function') {
|
||||
return s;
|
||||
}
|
||||
if (typeof s === 'string') {
|
||||
return isComponent ? (state) => (state ? { [s]: state[s] } : state) : (state) => state?.[s];
|
||||
}
|
||||
if (Array.isArray(s)) {
|
||||
return (state) => {
|
||||
if (!state) return state;
|
||||
const slice = {};
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const k = s[i];
|
||||
slice[k] = state[k];
|
||||
}
|
||||
return slice;
|
||||
};
|
||||
}
|
||||
return (state) => state;
|
||||
}
|
||||
|
||||
export class Automat {
|
||||
constructor(initialState = null, callbacks = {}, options = {}) {
|
||||
@@ -79,69 +137,73 @@ export class Automat {
|
||||
: null;
|
||||
this._state = this._initial ? { ...this._initial } : null;
|
||||
|
||||
this._cbs = callbacks;
|
||||
const resolvedCallbacks = typeof callbacks === 'function' ? callbacks(this) : (callbacks || {});
|
||||
this._cbs = {};
|
||||
for (const [key, fn] of Object.entries(resolvedCallbacks)) {
|
||||
this._cbs[key] = typeof fn === 'function' ? fn.bind(this) : fn;
|
||||
}
|
||||
this._subs = new Map();
|
||||
this._dirtySubs = new Set();
|
||||
this._unsubs = [];
|
||||
this._name = options.name ?? null;
|
||||
this._persist = Boolean(options.persist);
|
||||
this._url = options.url ?? null;
|
||||
this._fetcher = options.fetcher || ((url) => fetch(url).then((r) => r.json()));
|
||||
this._loader = options.loader ?? null;
|
||||
this._error = null;
|
||||
|
||||
if (this._name) {
|
||||
getGlobalMap('__AUTOMATS__')?.set(this._name, this);
|
||||
}
|
||||
|
||||
this._isReady = !this._url && !(this._name && this._persist);
|
||||
this._isDirty = false;
|
||||
this._ready = this._url
|
||||
? this._fetchData()
|
||||
: this._name && this._persist
|
||||
? idbRun('get', this._name).then((saved) => {
|
||||
if (saved && typeof saved === 'object') this.setState(saved);
|
||||
this._isReady = true;
|
||||
return this._state;
|
||||
})
|
||||
: Promise.resolve(
|
||||
this._name && !this._persist
|
||||
? ((this._state = {
|
||||
...this._state,
|
||||
...getGlobalMap('__AUTOMAT_STATE__')?.get(this._name),
|
||||
}),
|
||||
getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state),
|
||||
this._state)
|
||||
: this._state
|
||||
);
|
||||
|
||||
if (this._loader) {
|
||||
this._ready = this._fetchLoader();
|
||||
} else if (this._name && this._persist) {
|
||||
this._ready = idbGet(this._name).then((saved) => {
|
||||
if (saved && typeof saved === 'object') {
|
||||
this.setState(saved);
|
||||
}
|
||||
return this._state;
|
||||
});
|
||||
} else if (this._name && !this._persist) {
|
||||
const saved = getGlobalMap('__AUTOMAT_STATE__')?.get(this._name);
|
||||
if (saved && typeof saved === 'object') {
|
||||
this._state = { ...this._state, ...saved };
|
||||
}
|
||||
getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state);
|
||||
this._ready = Promise.resolve(this._state);
|
||||
} else {
|
||||
this._ready = Promise.resolve(this._state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal data fetcher for backend API binding.
|
||||
* @param {boolean} [silent=false] If true, preserves isReady without unready flash.
|
||||
* Internal data loader for function-backed automats.
|
||||
* @private
|
||||
*/
|
||||
_fetchData(silent = false) {
|
||||
if (!silent) this._isReady = false;
|
||||
_fetchLoader() {
|
||||
this._isDirty = false;
|
||||
this._error = null;
|
||||
|
||||
const promise = this._fetcher(this._url, this)
|
||||
const promise = Promise.resolve(this._loader(this))
|
||||
.then((data) => {
|
||||
this._state = data;
|
||||
this._isReady = true;
|
||||
if (data && typeof data === 'object') {
|
||||
this._state = { ...this._state, ...data };
|
||||
} else if (data !== undefined) {
|
||||
this._state = data;
|
||||
}
|
||||
this._isDirty = false;
|
||||
this._error = null;
|
||||
if (this._name) {
|
||||
this._persist
|
||||
? idbRun('put', this._state, this._name)
|
||||
? idbSet(this._name, this._state)
|
||||
: getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state);
|
||||
}
|
||||
this._notify(this._state);
|
||||
return this._state;
|
||||
})
|
||||
.catch((err) => {
|
||||
this._error = err;
|
||||
this._isReady = false;
|
||||
this._error = err?.message || String(err);
|
||||
this._notify(this._state);
|
||||
throw err;
|
||||
});
|
||||
@@ -152,21 +214,26 @@ export class Automat {
|
||||
|
||||
/**
|
||||
* Marks the automat as dirty and unready.
|
||||
* State resets to initial default or null (if no defaults were given).
|
||||
*
|
||||
* If subscribed anywhere, reloads immediately from backend API.
|
||||
* If not subscribed anywhere, defers reload until read() or subscribe().
|
||||
* State resets to initial default or null.
|
||||
* If subscribed anywhere and configured with a loader, triggers loader fetch immediately.
|
||||
*/
|
||||
setDirty() {
|
||||
setDirty(keep = false, now = false) {
|
||||
let shouldKeep = Boolean(keep);
|
||||
let shouldNow = Boolean(now);
|
||||
if (typeof keep === 'object' && keep !== null) {
|
||||
shouldKeep = Boolean(keep.keep);
|
||||
shouldNow = Boolean(keep.now);
|
||||
}
|
||||
if (typeof this._state?.revoke === 'function') {
|
||||
try {
|
||||
this._state.revoke();
|
||||
} catch {}
|
||||
}
|
||||
this._isDirty = true;
|
||||
this._isReady = false;
|
||||
this._state = this._initial ? { ...this._initial } : null;
|
||||
this._notify(this._state);
|
||||
if (!shouldKeep) {
|
||||
this._state = this._initial ? { ...this._initial } : null;
|
||||
this._notify(this._state);
|
||||
}
|
||||
|
||||
if (this._dirtySubs) {
|
||||
for (const fn of this._dirtySubs) {
|
||||
@@ -176,41 +243,24 @@ export class Automat {
|
||||
}
|
||||
}
|
||||
|
||||
if (this._subs.size > 0 && this._url) {
|
||||
return this._fetchData();
|
||||
if (this._loader && (shouldNow || this._subs.size > 0)) {
|
||||
return this._fetchLoader();
|
||||
}
|
||||
return Promise.resolve(this._state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads data from the backend API without making the automat dirty or unready.
|
||||
* Existing state remains intact and UI stays interactive until fresh data arrives.
|
||||
*
|
||||
* @returns {Promise<any>} Resolves with the fresh state.
|
||||
*/
|
||||
reload() {
|
||||
return this._url ? this._fetchData(true) : Promise.resolve(this._state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for reload().
|
||||
*/
|
||||
refresh() {
|
||||
return this.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads current state for React Suspense or imperative consumers.
|
||||
* If dirty and unmounted, calling read() triggers deferred reload.
|
||||
* If dirty and unmounted, triggers deferred fetch.
|
||||
* If unready, throws the ready promise for React Suspense.
|
||||
*/
|
||||
read() {
|
||||
if (this._url) {
|
||||
if (this._loader) {
|
||||
if (this._isDirty) {
|
||||
this._fetchData();
|
||||
this._fetchLoader();
|
||||
}
|
||||
if (!this._isReady) {
|
||||
if (this._error) throw this._error;
|
||||
if (this._error) throw this._error;
|
||||
if (this._state === null && this._ready) {
|
||||
throw this._ready;
|
||||
}
|
||||
}
|
||||
@@ -227,14 +277,31 @@ export class Automat {
|
||||
|
||||
/**
|
||||
* Combines multiple automats into one derived automat.
|
||||
* The combined state and actions use the same keys as the supplied map.
|
||||
* Child updates are forwarded without taking ownership of the children.
|
||||
*
|
||||
* @param {Record<string, Automat>} automats
|
||||
* Supports two signatures:
|
||||
* 1. Array + Combiner function:
|
||||
* Automat.combine([automatA, automatB], (stateA, stateB) => ({ ... }), options)
|
||||
* 2. Object Dictionary:
|
||||
* Automat.combine({ auth: authAutomat, company: companyAutomat })
|
||||
*
|
||||
* @param {Automat[] | Record<string, Automat>} upstream
|
||||
* @param {Function} [combiner]
|
||||
* @param {object} [options]
|
||||
* @returns {Automat}
|
||||
*/
|
||||
static combine(automats) {
|
||||
return new CombinedAutomat(automats);
|
||||
static combine(upstream, combiner, options = {}) {
|
||||
if (Array.isArray(upstream)) {
|
||||
if (typeof combiner !== 'function') {
|
||||
throw new TypeError('Automat.combine with array expects a combiner function as second argument.');
|
||||
}
|
||||
return createOrchestratedCombinedAutomat(upstream, combiner, options);
|
||||
}
|
||||
|
||||
if (upstream && typeof upstream === 'object') {
|
||||
return new CombinedAutomat(upstream);
|
||||
}
|
||||
|
||||
throw new TypeError('Automat.combine expects an array or dictionary object of Automat instances.');
|
||||
}
|
||||
|
||||
get name() {
|
||||
@@ -245,12 +312,8 @@ export class Automat {
|
||||
return this._persist;
|
||||
}
|
||||
|
||||
get url() {
|
||||
return this._url;
|
||||
}
|
||||
|
||||
get isReady() {
|
||||
return this._isReady;
|
||||
get isPersisted() {
|
||||
return this._persist;
|
||||
}
|
||||
|
||||
get isDirty() {
|
||||
@@ -266,8 +329,8 @@ export class Automat {
|
||||
}
|
||||
|
||||
get ready() {
|
||||
if (this._url && this._isDirty) {
|
||||
return this._fetchData();
|
||||
if (this._loader && this._isDirty) {
|
||||
return this._fetchLoader();
|
||||
}
|
||||
return this._ready;
|
||||
}
|
||||
@@ -275,50 +338,73 @@ export class Automat {
|
||||
async clearPersistence() {
|
||||
if (!this._name) return;
|
||||
this._persist
|
||||
? await idbRun('delete', this._name)
|
||||
? await idbDelete(this._name)
|
||||
: getGlobalMap('__AUTOMAT_STATE__')?.delete(this._name);
|
||||
}
|
||||
|
||||
get state() {
|
||||
if (this._loader && this._isDirty) {
|
||||
this._fetchLoader();
|
||||
}
|
||||
return this._state;
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this._state;
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow-merges `partial` into current state and notifies all subscribers.
|
||||
*/
|
||||
setState(partial) {
|
||||
this._state = { ...this._state, ...partial };
|
||||
const update = typeof partial === 'function' ? partial(this._state) : partial;
|
||||
this._state = { ...this._state, ...update };
|
||||
if (this._name) {
|
||||
this._persist
|
||||
? idbRun('put', this._state, this._name)
|
||||
? idbSet(this._name, this._state)
|
||||
: getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state);
|
||||
}
|
||||
this._notify(partial);
|
||||
this._notify(update);
|
||||
return this._state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes a React PureComponent instance or a listener callback.
|
||||
* Supports slice subscriptions with string, array, or function selectors.
|
||||
* Uses shallow equality to prevent re-renders when the slice has not changed.
|
||||
*
|
||||
* @param {object|function} target React component (`this`) or callback function.
|
||||
* @param {string|string[]|function} [selector]
|
||||
* @returns {function} Unsubscribe cleanup function.
|
||||
*/
|
||||
subscribe(target, selector) {
|
||||
const isComponent = target && typeof target.setState === 'function';
|
||||
if (!isComponent && typeof target !== 'function') {
|
||||
throw new TypeError('Automat.subscribe expects a component or function.');
|
||||
const isFunction = typeof target === 'function';
|
||||
|
||||
if (!isComponent && !isFunction) {
|
||||
throw new TypeError(
|
||||
'Automat.subscribe expects a callback function or a React component instance with a setState method.'
|
||||
);
|
||||
}
|
||||
|
||||
const getSlice = selector ? resolveSelector(selector) : null;
|
||||
const hasSelector = selector !== undefined && selector !== null;
|
||||
const getSlice = hasSelector ? resolveSelector(selector, isComponent) : null;
|
||||
let lastSlice = getSlice ? getSlice(this._state) : undefined;
|
||||
|
||||
const notifyFn = (state, partial) => {
|
||||
if (getSlice) {
|
||||
const nextSlice = getSlice(state);
|
||||
if (nextSlice == null && lastSlice == null) return;
|
||||
if (nextSlice == null) return;
|
||||
if (shallowEqual(lastSlice, nextSlice)) return;
|
||||
lastSlice = nextSlice;
|
||||
|
||||
if (isComponent) {
|
||||
if (nextSlice !== null && typeof nextSlice !== 'object') {
|
||||
throw new TypeError('Automat: selector must return an object.');
|
||||
if (typeof nextSlice !== 'object') {
|
||||
throw new TypeError(
|
||||
'Automat: selector for a React component must return a state object, e.g. state => ({ count: state.count }).'
|
||||
);
|
||||
}
|
||||
nextSlice ? target.setState(nextSlice) : target.forceUpdate();
|
||||
target.setState(nextSlice);
|
||||
} else {
|
||||
target(nextSlice);
|
||||
}
|
||||
@@ -336,8 +422,8 @@ export class Automat {
|
||||
|
||||
this._subs.set(target, notifyFn);
|
||||
|
||||
if (this._url && this._isDirty) {
|
||||
this._fetchData();
|
||||
if (this._loader && this._isDirty) {
|
||||
this._fetchLoader();
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -345,12 +431,18 @@ export class Automat {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Slices this automat to a subset of state for reading and subscription.
|
||||
*
|
||||
* @param {string|string[]|function} selector
|
||||
* @returns {{ readonly state: any, subscribe(target: object|function): function }}
|
||||
*/
|
||||
select(selector) {
|
||||
const slice = resolveSelector(selector);
|
||||
const getSlice = resolveSelector(selector, false);
|
||||
const self = this;
|
||||
return {
|
||||
get state() {
|
||||
return slice(self._state);
|
||||
return getSlice(self._state);
|
||||
},
|
||||
subscribe: (target) => self.subscribe(target, selector),
|
||||
};
|
||||
@@ -363,7 +455,7 @@ export class Automat {
|
||||
/**
|
||||
* Registers a listener called whenever this automat becomes dirty.
|
||||
* @param {(automat: Automat) => void} fn
|
||||
* @returns {() => void} Unsubscribe function
|
||||
* @returns {() => void}
|
||||
*/
|
||||
onDirty(fn) {
|
||||
this._dirtySubs.add(fn);
|
||||
@@ -372,7 +464,7 @@ export class Automat {
|
||||
|
||||
/**
|
||||
* Cascading invalidation: automatically sets this automat dirty whenever
|
||||
* upstreamAutomat is set dirty. If filterFn is provided, only invalidates when it returns true.
|
||||
* upstreamAutomat is set dirty.
|
||||
*
|
||||
* @param {Automat} upstreamAutomat
|
||||
* @param {(upstreamState: any, myState: any) => boolean} [filterFn]
|
||||
@@ -389,14 +481,13 @@ export class Automat {
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects this automat to an upstream automat.
|
||||
* Whenever upstream emits new state, transform(upstreamState, myState) runs.
|
||||
* Connects this automat to derive state from an upstream automat.
|
||||
*
|
||||
* @param {Automat} upstreamAutomat
|
||||
* @param {(upstreamState: any, myState: any) => any} transform
|
||||
* @param {object} [options]
|
||||
* @param {boolean | ((upstreamState: any, myState: any) => boolean)} [options.cascadeDirty]
|
||||
* @returns {this}
|
||||
* @returns {this} Chainable.
|
||||
*/
|
||||
subscribeTo(upstreamAutomat, transform, options = {}) {
|
||||
const unsub = upstreamAutomat.subscribe((upstreamState) => {
|
||||
@@ -443,7 +534,35 @@ export class Automat {
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived aggregate returned by Automat.combine().
|
||||
* Creates an orchestrated combined Automat from an array of upstreams and combiner function.
|
||||
*/
|
||||
function createOrchestratedCombinedAutomat(upstreamAutomats, combiner, options = {}) {
|
||||
const computeInitial = () => {
|
||||
const upstreamStates = upstreamAutomats.map((a) => a.state);
|
||||
return combiner(...upstreamStates);
|
||||
};
|
||||
|
||||
const combined = new Automat(
|
||||
computeInitial(),
|
||||
{},
|
||||
options
|
||||
);
|
||||
|
||||
// Subscribe to all upstreams
|
||||
upstreamAutomats.forEach((upstream) => {
|
||||
combined.subscribeTo(upstream, () => {
|
||||
const upstreamStates = upstreamAutomats.map((a) => a.state);
|
||||
const combinedSlice = combiner(...upstreamStates);
|
||||
combined.setState(combinedSlice);
|
||||
return combinedSlice;
|
||||
});
|
||||
});
|
||||
|
||||
return combined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived aggregate returned by Automat.combine({ auth: authAutomat, company: companyAutomat }).
|
||||
* Disposing it removes aggregate subscriptions but leaves its children alive.
|
||||
*/
|
||||
class CombinedAutomat extends Automat {
|
||||
@@ -469,7 +588,7 @@ class CombinedAutomat extends Automat {
|
||||
for (const [key, automat] of entries) {
|
||||
this._unsubs.push(
|
||||
automat.subscribe((state) => this.setState({ [key]: state })),
|
||||
automat.onDirty(() => {
|
||||
automat.onDirty?.(() => {
|
||||
for (const fn of this._dirtySubs) {
|
||||
try {
|
||||
fn(this);
|
||||
@@ -480,10 +599,6 @@ class CombinedAutomat extends Automat {
|
||||
}
|
||||
}
|
||||
|
||||
get isReady() {
|
||||
return Object.values(this._children).every((automat) => automat.isReady);
|
||||
}
|
||||
|
||||
get isDirty() {
|
||||
return Object.values(this._children).some((automat) => automat.isDirty);
|
||||
}
|
||||
@@ -501,29 +616,18 @@ class CombinedAutomat extends Automat {
|
||||
|
||||
read() {
|
||||
this._state = Object.fromEntries(
|
||||
Object.entries(this._children).map(([key, automat]) => [key, automat.read()])
|
||||
Object.entries(this._children).map(([key, automat]) => [key, automat.read?.() ?? automat.state])
|
||||
);
|
||||
return this._state;
|
||||
}
|
||||
|
||||
setDirty() {
|
||||
return Promise.all(Object.values(this._children).map((automat) => automat.setDirty())).then(() => {
|
||||
setDirty(keep = false, now = false) {
|
||||
return Promise.all(Object.values(this._children).map((automat) => automat.setDirty?.(keep, now))).then(() => {
|
||||
this._syncState();
|
||||
return this._state;
|
||||
});
|
||||
}
|
||||
|
||||
reload() {
|
||||
return Promise.all(Object.values(this._children).map((automat) => automat.reload())).then(() => {
|
||||
this._syncState();
|
||||
return this._state;
|
||||
});
|
||||
}
|
||||
|
||||
refresh() {
|
||||
return this.reload();
|
||||
}
|
||||
|
||||
_syncState() {
|
||||
this._state = Object.fromEntries(
|
||||
Object.entries(this._children).map(([key, automat]) => [key, automat.state])
|
||||
|
||||
@@ -96,7 +96,11 @@ export const connectSSE = (automat, url, options = {}) => {
|
||||
if (eventName === 'invalidate' || eventName === 'dirty' || data?.action === 'invalidate') {
|
||||
automat.setDirty();
|
||||
} else if (eventName === 'reload' || data?.action === 'reload') {
|
||||
automat.reload();
|
||||
if (typeof automat.actions?.reload === 'function') {
|
||||
automat.actions.reload();
|
||||
} else if (typeof automat.reload === 'function') {
|
||||
automat.reload();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
20
src/lib/index.d.ts
vendored
20
src/lib/index.d.ts
vendored
@@ -3,8 +3,7 @@ import type { PureComponent } from 'react';
|
||||
export interface AutomatOptions<T = any> {
|
||||
name?: string | null;
|
||||
persist?: boolean;
|
||||
url?: string | null;
|
||||
fetcher?: (url: string, automat?: Automat<T, any>) => Promise<T>;
|
||||
loader?: (automat: Automat<T, any>) => Promise<T | Partial<T>> | T | Partial<T>;
|
||||
}
|
||||
|
||||
export type Selector<T, S> =
|
||||
@@ -41,19 +40,21 @@ export type CombinedActions<M extends Record<string, Automat<any, any>>> = {
|
||||
};
|
||||
|
||||
export class Automat<T = any, A = Record<string, Function>> {
|
||||
constructor(initialState?: T | null, actions?: A, options?: AutomatOptions<T>);
|
||||
constructor(
|
||||
initialState?: T | null,
|
||||
actions?: A | ((automat: Automat<T, A>) => A),
|
||||
options?: AutomatOptions<T>
|
||||
);
|
||||
|
||||
readonly state: T;
|
||||
getState(): T;
|
||||
setState(partial: Partial<T> | ((prevState: T) => Partial<T>)): T;
|
||||
|
||||
readonly actions: A;
|
||||
readonly isReady: boolean;
|
||||
readonly isDirty: boolean;
|
||||
readonly ready: Promise<T>;
|
||||
readonly name: string | null;
|
||||
readonly persist: boolean;
|
||||
readonly url: string | null;
|
||||
readonly subscriberCount: number;
|
||||
|
||||
subscribe(
|
||||
@@ -77,9 +78,7 @@ export class Automat<T = any, A = Record<string, Function>> {
|
||||
|
||||
onDirty(fn: (automat: this) => void): () => void;
|
||||
|
||||
setDirty(): Promise<T>;
|
||||
reload(): Promise<T>;
|
||||
refresh(): Promise<T>;
|
||||
setDirty(keep?: boolean, now?: boolean): Promise<T>;
|
||||
read(): T;
|
||||
|
||||
clearPersistence(): Promise<void>;
|
||||
@@ -89,6 +88,11 @@ export class Automat<T = any, A = Record<string, Function>> {
|
||||
static combine<M extends Record<string, Automat<any, any>>>(
|
||||
automats: M
|
||||
): Automat<CombinedState<M>, CombinedActions<M>>;
|
||||
static combine<R = any>(
|
||||
upstreamAutomats: Automat<any, any>[],
|
||||
combiner: (...states: any[]) => R,
|
||||
options?: AutomatOptions<R>
|
||||
): Automat<R, Record<string, Function>>;
|
||||
}
|
||||
|
||||
export function fetchJson<T = any>(url: string, options?: RequestInit): Promise<T>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @module react-automat
|
||||
*
|
||||
* Higher-order state container for React PureComponents with optional persistence.
|
||||
* Observable higher-order state container for React PureComponents with optional persistence.
|
||||
*
|
||||
* @example
|
||||
* import { Automat } from './lib/index.js';
|
||||
|
||||
@@ -20,7 +20,6 @@ if (typeof window !== 'undefined' && typeof EventTarget !== 'undefined') {
|
||||
return originalAddEventListener.call(this, type, listener, opts);
|
||||
};
|
||||
}
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
|
||||
Reference in New Issue
Block a user