u
This commit is contained in:
144
README.md
144
README.md
@@ -83,6 +83,7 @@ Creates an observable state container with optional persistence.
|
||||
- `persist` *(boolean)*: Persistence strategy when `name` is provided:
|
||||
- `false` (default): Persists in the `window` object (session memory, survives component unmounts and HMR).
|
||||
- `true`: Persists in `IndexedDB` (survives page reloads and browser restarts).
|
||||
- `loader` *(function, optional)*: Asynchronous loader `(automat) => Promise<object>`. Invoked when `getData()` is called while state is `'idle'`.
|
||||
|
||||
```js
|
||||
// Window-persisted (session memory)
|
||||
@@ -92,16 +93,57 @@ const sessionStore = new Automat(
|
||||
{ name: 'filter', persist: false }
|
||||
);
|
||||
|
||||
// IndexedDB-persisted (survives browser reload)
|
||||
const cartStore = new Automat(
|
||||
{ items: [] },
|
||||
{ addItem(item) { cartStore.setState({ items: [...cartStore.state.items, item] }); } },
|
||||
{ name: 'cart', persist: true }
|
||||
// Async API-backed with loader
|
||||
const userStore = new Automat(
|
||||
{ status: 'idle', data: null },
|
||||
{},
|
||||
{
|
||||
name: 'user',
|
||||
loader: async (store) => {
|
||||
const res = await fetch('/api/user');
|
||||
return { data: await res.json() };
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `automat.getData(options?)` / `automat.load(options?)`
|
||||
|
||||
Reads state for PureComponent constructor initialization, triggering asynchronous loading if state is idle:
|
||||
|
||||
1. **First Mount (Data Not Loaded)**:
|
||||
- Sets `{ status: 'pending' }` and kicks off the async loader.
|
||||
- Synchronously returns the pending state for the first render.
|
||||
- When the fetch completes, `setState()` notifies subscribers, causing a 2nd render with data.
|
||||
2. **Subsequent Mounts / Remounts (Data Already Loaded)**:
|
||||
- Directly returns `{ status: 'success', data }` synchronously in the constructor.
|
||||
- Component renders completed data immediately. **Zero 2nd render occurs!**
|
||||
|
||||
```jsx
|
||||
class UserProfile extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
// 💡 Synchronous read in constructor:
|
||||
this.state = userStore.getData();
|
||||
}
|
||||
componentDidMount() {
|
||||
this.unsubscribe = userStore.subscribe(this);
|
||||
}
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe();
|
||||
}
|
||||
render() {
|
||||
const { status, data } = this.state;
|
||||
if (status === 'pending') return <Spinner />;
|
||||
return <div>{data.name}</div>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `automat.state` / `automat.getState()`
|
||||
|
||||
Returns the current state snapshot.
|
||||
@@ -245,6 +287,28 @@ const filterStore = Automat.get('filter');
|
||||
|
||||
---
|
||||
|
||||
### `Automat.combine(upstreamAutomats, combiner, options?)`
|
||||
|
||||
Combines multiple upstream automats into a single derived container.
|
||||
|
||||
- **Concurrent Orchestration**: When accessed via `.getData()` while any upstream is idle, triggers all idle upstreams to fetch concurrently in parallel.
|
||||
- **Instant Constructor Resolution**: If all upstream automats have already resolved (`status === 'success'`), `.getData()` computes the projection synchronously in the component constructor, returning complete combined data with zero delay and a single render.
|
||||
|
||||
```js
|
||||
const dashboardAutomat = Automat.combine(
|
||||
[userStatsAutomat, systemMetricsAutomat],
|
||||
(statsState, metricsState) => ({
|
||||
stats: statsState.data,
|
||||
metrics: metricsState.data,
|
||||
})
|
||||
);
|
||||
|
||||
// In component constructor:
|
||||
this.state = dashboardAutomat.getData();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `automat.dispose()`
|
||||
|
||||
Tears down all upstream subscriptions set up via `subscribeTo()`, clears all subscribers, and removes named instance registrations from the window registry.
|
||||
@@ -365,6 +429,76 @@ export class CartView extends PureComponent {
|
||||
|
||||
---
|
||||
|
||||
### 4. Async Fetch with Constructor Caching (Instant Re-Mount)
|
||||
|
||||
When a component accesses `myAutomat.getData()` in its constructor while data is loading, it receives `{ status: 'pending' }` and renders the pending state. When the fetch completes, it renders again (2 renders total).
|
||||
|
||||
When another component mounts (or the component remounts via a collapsible toggle) with data already loaded, `getData()` returns `{ status: 'success', data }` synchronously in the constructor — displaying final data in **1 render with zero 2nd render**:
|
||||
|
||||
```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>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Combined Multi-API Automats (`Automat.combine`)
|
||||
|
||||
Combines two delayed APIs. If accessed while upstreams are idle, it triggers both to load concurrently in parallel. If both had already been accessed earlier, it resolves instantly in the constructor:
|
||||
|
||||
```js
|
||||
const combinedDashboard = Automat.combine(
|
||||
[userStatsAutomat, systemMetricsAutomat],
|
||||
(stats, metrics) => ({
|
||||
stats: stats.data,
|
||||
metrics: metrics.data,
|
||||
})
|
||||
);
|
||||
|
||||
class Dashboard extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
// 💡 If both upstreams were loaded earlier, resolves instantly in constructor:
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ DO
|
||||
|
||||
75
src/App.jsx
75
src/App.jsx
@@ -9,6 +9,10 @@ import IndexSelector from './examples/components/IndexSelector.jsx';
|
||||
import DynamicAutomatSubscriber from './examples/components/DynamicAutomatSubscriber.jsx';
|
||||
import CartManager from './examples/components/CartManager.jsx';
|
||||
import CartBadgeDisplay from './examples/components/CartBadgeDisplay.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';
|
||||
|
||||
class App extends PureComponent {
|
||||
render() {
|
||||
@@ -225,6 +229,77 @@ class App extends PureComponent {
|
||||
<CartBadgeDisplay />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* ── 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">Async API Fetch & Constructor Cache (Instant Re-Mount)</h2>
|
||||
</div>
|
||||
<p>
|
||||
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>
|
||||
</main>
|
||||
|
||||
<footer className="app-footer">
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
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,41 @@ 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 {
|
||||
|
||||
@@ -144,6 +144,8 @@ export class Automat {
|
||||
#persist = false;
|
||||
/** @type {Promise<object>} */
|
||||
#ready = Promise.resolve();
|
||||
/** @type {function|null} */
|
||||
#loader = null;
|
||||
|
||||
/**
|
||||
* @param {object} initialState Initial state snapshot.
|
||||
@@ -151,11 +153,13 @@ export class Automat {
|
||||
* @param {object} [options] Configuration options.
|
||||
* @param {string} [options.name] Identifier for persistence and registry lookup.
|
||||
* @param {boolean} [options.persist] true = IndexedDB (survives reload), false = window object (default).
|
||||
* @param {function} [options.loader] Async loader function (automat) => Promise<object>.
|
||||
*/
|
||||
constructor(initialState = {}, callbacks = {}, options = {}) {
|
||||
this.#callbacks = callbacks;
|
||||
this.#name = options.name ?? null;
|
||||
this.#persist = Boolean(options.persist);
|
||||
this.#loader = typeof options.loader === 'function' ? options.loader : null;
|
||||
|
||||
if (this.#name && typeof window !== 'undefined') {
|
||||
window.__AUTOMATS__ = window.__AUTOMATS__ || new Map();
|
||||
@@ -203,6 +207,88 @@ export class Automat {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a combined Automat derived from multiple upstream Automats.
|
||||
*
|
||||
* When accessed via `.getData()`:
|
||||
* - If all upstream automats are already resolved (`status === 'success'`),
|
||||
* it immediately returns the combined state in the constructor (instant resolution).
|
||||
* - If any upstream automat is idle, it triggers `.getData()` on them concurrently,
|
||||
* transitions itself to pending, and synchronizes automatically as each completes.
|
||||
*
|
||||
* @param {Automat[]} upstreamAutomats Array of upstream Automat instances.
|
||||
* @param {function(...states: object[]): object} combiner Function that maps upstream states to combined state.
|
||||
* @param {object} [options] Optional Automat options (name, persist).
|
||||
* @returns {Automat}
|
||||
*/
|
||||
static combine(upstreamAutomats, combiner, options = {}) {
|
||||
const computeInitial = () => {
|
||||
const upstreamStates = upstreamAutomats.map((a) => a.state);
|
||||
return combiner(...upstreamStates);
|
||||
};
|
||||
|
||||
const combined = new Automat(
|
||||
computeInitial(),
|
||||
{
|
||||
getData(opts = {}) {
|
||||
const allResolved = upstreamAutomats.every(
|
||||
(a) => a.state?.status === 'success'
|
||||
);
|
||||
|
||||
if (allResolved && !opts.reload) {
|
||||
const currentCombined = combiner(...upstreamAutomats.map((a) => a.state));
|
||||
if (combined.state.status !== 'success') {
|
||||
combined.setState({ ...currentCombined, status: 'success' });
|
||||
}
|
||||
return combined.state;
|
||||
}
|
||||
|
||||
// Trigger loading on any uncompleted or reload-requested upstreams
|
||||
upstreamAutomats.forEach((a) => {
|
||||
if (typeof a.getData === 'function' && (a.state?.status === 'idle' || opts.reload)) {
|
||||
a.getData(opts);
|
||||
}
|
||||
});
|
||||
|
||||
const checkAllResolved = upstreamAutomats.every(
|
||||
(a) => a.state?.status === 'success'
|
||||
);
|
||||
|
||||
if (checkAllResolved) {
|
||||
const res = combiner(...upstreamAutomats.map((a) => a.state));
|
||||
combined.setState({ ...res, status: 'success' });
|
||||
return combined.state;
|
||||
}
|
||||
|
||||
const partialCombined = combiner(...upstreamAutomats.map((a) => a.state));
|
||||
combined.setState({ ...partialCombined, status: 'pending' });
|
||||
return combined.state;
|
||||
},
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
// Subscribe to all upstreams
|
||||
upstreamAutomats.forEach((upstream) => {
|
||||
combined.subscribeTo(upstream, () => {
|
||||
const upstreamStates = upstreamAutomats.map((a) => a.state);
|
||||
const allDone = upstreamStates.every((s) => s?.status === 'success');
|
||||
const anyError = upstreamStates.find((s) => s?.status === 'error');
|
||||
const combinedSlice = combiner(...upstreamStates);
|
||||
|
||||
if (anyError) {
|
||||
return { ...combinedSlice, status: 'error', error: anyError.error };
|
||||
}
|
||||
if (allDone) {
|
||||
return { ...combinedSlice, status: 'success', error: null };
|
||||
}
|
||||
return { ...combinedSlice, status: 'pending' };
|
||||
});
|
||||
});
|
||||
|
||||
return combined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The registered name of the automat, or null if unnamed.
|
||||
* @returns {string|null}
|
||||
@@ -258,6 +344,73 @@ export class Automat {
|
||||
return this.#state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads state for constructor initialization, optionally triggering an action or loader.
|
||||
*
|
||||
* Lifecycle & Caching Semantics:
|
||||
* 1. If loader or load action is configured and state is 'idle' (or reload is true):
|
||||
* - Transitions state to { status: 'pending', error: null }.
|
||||
* - Launches loader asynchronously in the background.
|
||||
* - Synchronously returns the pending state for the first render.
|
||||
* 2. When the async fetch finishes, automat.setState notifies subscribers, triggering
|
||||
* a second render with completed data.
|
||||
* 3. When called again from another component or after remounting with data already loaded
|
||||
* (status === 'success'), returns the resolved state immediately in the constructor.
|
||||
* No secondary render is needed!
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.reload=false]
|
||||
* @returns {object}
|
||||
*/
|
||||
getData(options = {}) {
|
||||
const shouldReload = Boolean(options.reload);
|
||||
const isIdle = !this.#state.status || this.#state.status === 'idle' || shouldReload;
|
||||
|
||||
if (this.#loader && isIdle) {
|
||||
this.setState({ status: 'pending', error: null });
|
||||
Promise.resolve(this.#loader(this))
|
||||
.then((result) => {
|
||||
if (result && typeof result === 'object') {
|
||||
this.setState({ status: 'success', ...result, error: null });
|
||||
} else {
|
||||
this.setState({ status: 'success', error: null });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
this.setState({ status: 'error', error: err?.message || String(err) });
|
||||
});
|
||||
return this.#state;
|
||||
}
|
||||
|
||||
if (this.#callbacks?.getData && typeof this.#callbacks.getData === 'function') {
|
||||
return this.#callbacks.getData(options);
|
||||
}
|
||||
|
||||
if (this.#callbacks?.load && typeof this.#callbacks.load === 'function' && isIdle) {
|
||||
this.#callbacks.load(options);
|
||||
return this.#state;
|
||||
}
|
||||
|
||||
return this.#state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for getData(options).
|
||||
* @param {object} [options]
|
||||
* @returns {object}
|
||||
*/
|
||||
load(options) {
|
||||
return this.getData(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces a reload via getData({ reload: true }).
|
||||
* @returns {object}
|
||||
*/
|
||||
reload() {
|
||||
return this.getData({ reload: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow-merges `partial` into the current state and notifies all subscribers.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user