Genesis
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/node_modules
|
||||||
|
/dist
|
||||||
14
LICENSE
Normal file
14
LICENSE
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
Zero-Clause BSD (0BSD)
|
||||||
|
|
||||||
|
Copyright (C) 2026 Automat Authors
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
PERFORMANCE OF THIS SOFTWARE.
|
||||||
172
README.md
Normal file
172
README.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# ⚙ 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.
|
||||||
|
|
||||||
|
[](https://sebgreen.net/automat/)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install react-automat
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Complete Type Signatures
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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
|
||||||
|
url?: string | null; // Auto-triggers fetcher(url) on init
|
||||||
|
fetcher?: (url: string, automat?: Automat<T, any>) => Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Selector<T, S = any> = keyof T | (keyof T)[] | ((state: T) => S | null | undefined);
|
||||||
|
|
||||||
|
export interface ScopedSlice<S> {
|
||||||
|
readonly state: S;
|
||||||
|
subscribe(target: PureComponent | ((slice: S) => void)): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlobResource {
|
||||||
|
blob: Blob; url: string; size: number; type: string; loadedAt: string;
|
||||||
|
revoke: () => void; // Auto-called on setDirty() / dispose() to release memory
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSEOptions<T = any, A = any> {
|
||||||
|
onMessage?: (data: any, event: string, automat: Automat<T, A>) => void;
|
||||||
|
events?: Record<string, (data: any, automat: Automat<T, A>) => void>;
|
||||||
|
onError?: (err: any, automat: Automat<T, A>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Automat<T = any, A = Record<string, Function>> {
|
||||||
|
constructor(initialState?: T | null, actions?: A, options?: AutomatOptions<T>);
|
||||||
|
|
||||||
|
readonly state: T;
|
||||||
|
getState(): T;
|
||||||
|
setState(partial: Partial<T> | ((prev: T) => Partial<T>)): T;
|
||||||
|
readonly actions: A;
|
||||||
|
|
||||||
|
readonly isReady: boolean;
|
||||||
|
readonly isDirty: boolean;
|
||||||
|
readonly ready: Promise<T>; // Re-awaitable after setDirty()
|
||||||
|
readonly name: string | null;
|
||||||
|
readonly persist: boolean;
|
||||||
|
readonly url: string | null;
|
||||||
|
readonly subscriberCount: number;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
subscribeTo<U = any>(
|
||||||
|
upstream: Automat<U, any>,
|
||||||
|
transform: (upstreamState: U, currentState: T) => Partial<T> | null | undefined,
|
||||||
|
options?: { cascadeDirty?: boolean | ((upstream: U, current: T) => boolean) }
|
||||||
|
): this;
|
||||||
|
|
||||||
|
invalidateWith<U = any>(upstream: Automat<U, any>, filterFn?: (upstream: U, current: T) => boolean): this;
|
||||||
|
onDirty(fn: (automat: this) => void): () => void;
|
||||||
|
|
||||||
|
setDirty(): Promise<T>; // Resets state to null/default, revokes blobs, cascades, defers if unmounted
|
||||||
|
reload(): Promise<T>; // Silent background re-fetch; preserves state & isReady=true
|
||||||
|
refresh(): Promise<T>; // Alias for reload()
|
||||||
|
read(): T; // For Suspense: throws ready promise if unready; triggers deferred load if dirty
|
||||||
|
|
||||||
|
clearPersistence(): Promise<void>;
|
||||||
|
dispose(): void; // Unsubs all, revokes blobs, deletes from window registry
|
||||||
|
|
||||||
|
static get<T = any, A = any>(name: string): Automat<T, A> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetchers & SSE
|
||||||
|
export function fetchJson<T = any>(url: string, options?: RequestInit): Promise<T>;
|
||||||
|
export function fetchBlob(url: string, options?: RequestInit): Promise<BlobResource>;
|
||||||
|
export const fetchImageBlob: typeof fetchBlob;
|
||||||
|
export const fetchPdfBlob: typeof fetchBlob;
|
||||||
|
export function connectSSE<T, A>(automat: Automat<T, A>, url: string, options?: SSEOptions<T, A>): { eventSource: EventSource | null; close: () => void };
|
||||||
|
export function createSSEFetcher(sseUrl: string, baseFetcher?: Function, sseOptions?: SSEOptions): (url: string, automat?: any) => Promise<any>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Canonical Usage Patterns
|
||||||
|
|
||||||
|
### A. PureComponent Subscription
|
||||||
|
```jsx
|
||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { Automat } from 'react-automat';
|
||||||
|
|
||||||
|
const store = new Automat({ count: 0 }, { inc: () => store.setState({ count: store.state.count + 1 }) });
|
||||||
|
|
||||||
|
class Counter extends PureComponent {
|
||||||
|
constructor(props) { super(props); this.state = store.state; } // Sync read, never stale
|
||||||
|
componentDidMount() { this.unsub = store.subscribe(this); } // Auto-calls this.setState(partial)
|
||||||
|
componentWillUnmount() { this.unsub(); }
|
||||||
|
render() { return <button onClick={store.actions.inc}>{this.state.count}</button>; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### B. Fine-Grained Selectors (Shallow-guarded, 0 renders if slice unchanged)
|
||||||
|
```js
|
||||||
|
store.subscribe(this, 'count'); // Single key
|
||||||
|
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 }));
|
||||||
|
```
|
||||||
|
|
||||||
|
### D. Backend API: React Suspense vs Standard
|
||||||
|
```jsx
|
||||||
|
const user = new Automat(null, {}, { url: '/api/user' });
|
||||||
|
|
||||||
|
// Suspense: throws promise while unready
|
||||||
|
const Profile = () => <div>{user.read().name}</div>;
|
||||||
|
|
||||||
|
// Standard: check isReady
|
||||||
|
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>; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### E. Cascading Invalidation, Blobs & Memory Freeing
|
||||||
|
```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
|
||||||
|
|
||||||
|
all.setDirty(); // p1 auto-revokes held Blob URL, resets state=null, defers reload if unmounted
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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');
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Decision Matrix: `setDirty()` vs `reload()`
|
||||||
|
|
||||||
|
| 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.
|
||||||
22
index.html
Normal file
22
index.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>react-automat — React PureComponent State Manager</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Higher-order state container for React PureComponents. State lives independently of mounting — perfect for render minimization."
|
||||||
|
/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
900
package-lock.json
generated
Normal file
900
package-lock.json
generated
Normal file
@@ -0,0 +1,900 @@
|
|||||||
|
{
|
||||||
|
"name": "automat",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "automat",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "0BSD",
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0",
|
||||||
|
"vite": "^8.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0 || >=17.0.0 || >=18.0.0 || >=19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxc-project/types": {
|
||||||
|
"version": "0.150.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
|
||||||
|
"integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/oxc-project"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/pluginutils": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@vitejs/plugin-react": {
|
||||||
|
"version": "6.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
|
||||||
|
"integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rolldown/pluginutils": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"oxc-transform-react": "^0.145.0",
|
||||||
|
"vite": "^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@rolldown/plugin-babel": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"babel-plugin-react-compiler": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"oxc-transform-react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fdir": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"picomatch": "^3 || ^4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"picomatch": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"lightningcss-android-arm64": "1.33.0",
|
||||||
|
"lightningcss-darwin-arm64": "1.33.0",
|
||||||
|
"lightningcss-darwin-x64": "1.33.0",
|
||||||
|
"lightningcss-freebsd-x64": "1.33.0",
|
||||||
|
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||||
|
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||||
|
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||||
|
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||||
|
"lightningcss-linux-x64-musl": "1.33.0",
|
||||||
|
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||||
|
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-android-arm64": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nanoid": {
|
||||||
|
"version": "3.3.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||||
|
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/picocolors": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/picomatch": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postcss": {
|
||||||
|
"version": "8.5.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||||
|
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "^3.3.18",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react": {
|
||||||
|
"version": "19.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
|
||||||
|
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-dom": {
|
||||||
|
"version": "19.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
|
||||||
|
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"scheduler": "^0.28.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rolldown": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@oxc-project/types": "=0.150.0",
|
||||||
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rolldown": "bin/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@rolldown/binding-android-arm-eabi": "1.2.9",
|
||||||
|
"@rolldown/binding-android-arm64": "1.2.9",
|
||||||
|
"@rolldown/binding-darwin-arm64": "1.2.9",
|
||||||
|
"@rolldown/binding-darwin-x64": "1.2.9",
|
||||||
|
"@rolldown/binding-freebsd-x64": "1.2.9",
|
||||||
|
"@rolldown/binding-linux-arm-gnueabihf": "1.2.9",
|
||||||
|
"@rolldown/binding-linux-arm64-gnu": "1.2.9",
|
||||||
|
"@rolldown/binding-linux-arm64-musl": "1.2.9",
|
||||||
|
"@rolldown/binding-linux-ppc64-gnu": "1.2.9",
|
||||||
|
"@rolldown/binding-linux-s390x-gnu": "1.2.9",
|
||||||
|
"@rolldown/binding-linux-x64-gnu": "1.2.9",
|
||||||
|
"@rolldown/binding-linux-x64-musl": "1.2.9",
|
||||||
|
"@rolldown/binding-openharmony-arm64": "1.2.9",
|
||||||
|
"@rolldown/binding-win32-arm64-msvc": "1.2.9",
|
||||||
|
"@rolldown/binding-win32-x64-msvc": "1.2.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/scheduler": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/source-map-js": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyglobby": {
|
||||||
|
"version": "0.2.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
|
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fdir": "^6.5.0",
|
||||||
|
"picomatch": "^4.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lightningcss": "^1.33.0",
|
||||||
|
"picomatch": "^4.0.7",
|
||||||
|
"postcss": "^8.5.28",
|
||||||
|
"rolldown": "~1.2.6",
|
||||||
|
"tinyglobby": "^0.2.17"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vite": "bin/vite.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
|
"@vitejs/devtools": "^0.7.1",
|
||||||
|
"esbuild": "^0.27.0 || ^0.28.0",
|
||||||
|
"jiti": ">=1.21.0",
|
||||||
|
"less": "^4.0.0",
|
||||||
|
"sass": "^1.70.0",
|
||||||
|
"sass-embedded": "^1.70.0",
|
||||||
|
"stylus": ">=0.54.8",
|
||||||
|
"sugarss": "^5.0.0",
|
||||||
|
"terser": "^5.16.0",
|
||||||
|
"tsx": "^4.8.1",
|
||||||
|
"yaml": "^2.4.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitejs/devtools": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"esbuild": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jiti": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"less": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sass": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sass-embedded": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"stylus": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sugarss": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"terser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"tsx": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"yaml": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
package.json
Normal file
68
package.json
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"name": "react-automat",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Higher-order state container for React PureComponents — state independent of component lifecycle",
|
||||||
|
"license": "0BSD",
|
||||||
|
"main": "./dist/react-automat.umd.js",
|
||||||
|
"module": "./dist/react-automat.es.js",
|
||||||
|
"types": "./src/lib/index.d.ts",
|
||||||
|
"sideEffects": false,
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/sebseb7/react-automat.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/sebseb7/react-automat/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://sebgreen.net/automat/",
|
||||||
|
"files": [
|
||||||
|
"dist/react-automat.es.js",
|
||||||
|
"dist/react-automat.umd.js",
|
||||||
|
"src/lib",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/lib/index.d.ts",
|
||||||
|
"import": "./dist/react-automat.es.js",
|
||||||
|
"require": "./dist/react-automat.umd.js",
|
||||||
|
"default": "./dist/react-automat.es.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"build:lib": "vite build --config vite.config.lib.js",
|
||||||
|
"prepublishOnly": "npm run build:lib",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"react",
|
||||||
|
"react-automat",
|
||||||
|
"state",
|
||||||
|
"purecomponent",
|
||||||
|
"higher-order-state",
|
||||||
|
"state-management",
|
||||||
|
"suspense",
|
||||||
|
"indexeddb",
|
||||||
|
"sse",
|
||||||
|
"offline",
|
||||||
|
"lightweight"
|
||||||
|
],
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0 || >=17.0.0 || >=18.0.0 || >=19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0",
|
||||||
|
"vite": "^8.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
307
src/App.jsx
Normal file
307
src/App.jsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import CounterButton from './examples/components/CounterButton.jsx';
|
||||||
|
import CounterDisplay from './examples/components/CounterDisplay.jsx';
|
||||||
|
import CascadeControls from './examples/components/CascadeControls.jsx';
|
||||||
|
import NotificationBar from './examples/components/NotificationBar.jsx';
|
||||||
|
import SyncCounterControls from './examples/components/SyncCounterControls.jsx';
|
||||||
|
import SyncBackendMonitor from './examples/components/SyncBackendMonitor.jsx';
|
||||||
|
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 UnconnectedPartialSubscriber from './examples/components/UnconnectedPartialSubscriber.jsx';
|
||||||
|
import ApiBoundExample from './examples/components/ApiBoundExample.jsx';
|
||||||
|
import CascadingPhotosExample from './examples/components/CascadingPhotosExample.jsx';
|
||||||
|
|
||||||
|
class App extends PureComponent {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
{/* ── Header ─────────────────────────────────────────────── */}
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="logo">
|
||||||
|
<span className="logo-icon">⚙</span>
|
||||||
|
<span className="logo-text">react-automat</span>
|
||||||
|
<span className="logo-version">v0.1.0</span>
|
||||||
|
</div>
|
||||||
|
<p className="tagline">
|
||||||
|
Higher-order 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 ────────────────────────────────── */}
|
||||||
|
<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>
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
<div className="flow-diagram" aria-label="Data flow">
|
||||||
|
<span className="flow-node">counterAutomat</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>
|
||||||
|
|
||||||
|
<div className="two-columns">
|
||||||
|
<CounterButton />
|
||||||
|
<CounterDisplay />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="section-divider" />
|
||||||
|
|
||||||
|
{/* ── Example 2: Cascade ───────────────────────────────── */}
|
||||||
|
<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>
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
<div className="flow-diagram" aria-label="Cascade data flow">
|
||||||
|
<span className="flow-node">User Click</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">counterAutomat.actions.increment()</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="two-columns">
|
||||||
|
<CascadeControls />
|
||||||
|
<NotificationBar />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="section-divider" />
|
||||||
|
|
||||||
|
{/* ── Example 3: API Auto-Sync ─────────────────────────── */}
|
||||||
|
<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>
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
<div className="flow-diagram" aria-label="API auto-sync flow">
|
||||||
|
<span className="flow-node">User Click</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">Optimistic setState()</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="two-columns">
|
||||||
|
<SyncCounterControls />
|
||||||
|
<SyncBackendMonitor />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="section-divider" />
|
||||||
|
|
||||||
|
{/* ── Example 4: Dynamic Map Resubscription ──────────── */}
|
||||||
|
<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>
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
<div className="flow-diagram" aria-label="Index-driven resubscription flow">
|
||||||
|
<span className="flow-node">Shared Index: N</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">window.automats.get(N)</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">Unsub Old / Sub New</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node flow-node-component">Recall State for Slot #N</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="info-box">
|
||||||
|
<div className="info-box-title">
|
||||||
|
<span>💾</span> Built-in Persistence: <code>persist: false</code> (Window) & <code>persist: true</code> (IndexedDB)
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Window Object (<code>persist: false</code>):</strong> While any Automat is unmount-safe by default, setting a <code>name</code> registers it in the <code>window</code> object. State persists across module re-evaluations and HMR, and enables cross-module lookup via <code>Automat.get(name)</code>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>IndexedDB (<code>persist: true</code>):</strong> Setting <code>persist: true</code> automatically synchronizes state snapshots to IndexedDB under the automat's <code>name</code>, surviving full page reloads and browser restarts.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="two-columns">
|
||||||
|
<IndexSelector />
|
||||||
|
<DynamicAutomatSubscriber />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="section-divider" />
|
||||||
|
|
||||||
|
{/* ── Example 5: Partial State Subscription ────────────── */}
|
||||||
|
<section className="example-section" aria-labelledby="ex5-title">
|
||||||
|
<div className="section-header">
|
||||||
|
<div className="section-title-row">
|
||||||
|
<span className="section-number">05</span>
|
||||||
|
<h2 id="ex5-title">Partial State Subscription & Unconnected Objects</h2>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
A subscriber can subscribe to only part of an automat’s state. On the right, two different partial subscribers demonstrate this power:
|
||||||
|
<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>.
|
||||||
|
</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-arrow">→</span>
|
||||||
|
<span className="flow-node">shallowEqual(lastSlice, nextSlice)</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node flow-node-component">Render ONLY if Selected Slice Changed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="two-columns">
|
||||||
|
<CartManager />
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<CartBadgeDisplay />
|
||||||
|
<UnconnectedPartialSubscriber />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="section-divider" />
|
||||||
|
|
||||||
|
{/* ── Example 6: Backend API Binding & Suspense ─────── */}
|
||||||
|
<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>
|
||||||
|
</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,
|
||||||
|
the reload is deferred until a component mounts or <code className="inline-code">read()</code> is invoked!
|
||||||
|
</p>
|
||||||
|
<div className="flow-diagram" aria-label="Backend binding and dirty flow">
|
||||||
|
<span className="flow-node">new Automat(null, {}, { url })</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">await ready / read() (Suspense)</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">setDirty()</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node flow-node-component">Immediate if Mounted / Deferred if Unmounted</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ApiBoundExample />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="section-divider" />
|
||||||
|
|
||||||
|
{/* ── Example 7: Cascading setDirty, Blobs & SSE ────── */}
|
||||||
|
<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">Cascading setDirty, Binary Blobs & Server-Sent Events (SSE)</h2>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Demonstrates <strong>cascading invalidation</strong>, <strong>automatic Blob memory revoking</strong>,
|
||||||
|
and <strong>Server-Sent Events (SSE)</strong> integration:
|
||||||
|
<br />
|
||||||
|
• Multiple photo automats (<code className="inline-code">photo/1</code>, <code className="inline-code">photo/2</code>, <code className="inline-code">photo/3</code>)
|
||||||
|
subscribe to a central hub (<code className="inline-code">photos/all</code>) via <code className="inline-code">invalidateWith(photosAllAutomat)</code>.
|
||||||
|
<br />
|
||||||
|
• When <code className="inline-code">photos/all.setDirty()</code> is called (or an SSE remote invalidation message arrives),
|
||||||
|
all child automats cascade <code className="inline-code">setDirty()</code>, <strong>automatically revoking their image Blob object URLs</strong> and releasing memory.
|
||||||
|
<br />
|
||||||
|
• Unmounted automats remain in freed/null state until re-mounted, deferring network fetches and saving client memory!
|
||||||
|
</p>
|
||||||
|
<div className="flow-diagram" aria-label="Cascading invalidation and SSE flow">
|
||||||
|
<span className="flow-node">SSE Remote Event / User Click</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">photos/all.setDirty()</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">cascade invalidateWith()</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">URL.revokeObjectURL() & Free Memory</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node flow-node-component">photo/1, photo/2, photo/3</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CascadingPhotosExample />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
78
src/examples/automats/cartAutomat.js
Normal file
78
src/examples/automats/cartAutomat.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Automat } from '../../lib/index.js';
|
||||||
|
|
||||||
|
let nextId = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cart Automat managing a list of items and checkout metadata.
|
||||||
|
*
|
||||||
|
* Demonstrates:
|
||||||
|
* 1. Partial state subscription: Subscribers who only care about cart badge quantity
|
||||||
|
* (list length) can subscribe to `state.items.length` without re-rendering when
|
||||||
|
* item quantities, prices, names, or coupon codes mutate.
|
||||||
|
* 2. IndexedDB persistence (`persist: true`): Cart state persists across full browser reloads.
|
||||||
|
*/
|
||||||
|
export const cartAutomat = new Automat(
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{ id: 1, name: 'Mechanical Keyboard', price: 129, quantity: 1 },
|
||||||
|
{ id: 2, name: 'Wireless Mouse', price: 79, quantity: 2 },
|
||||||
|
{ id: 3, name: 'Desk Mat (Gruvbox)', price: 29, quantity: 1 },
|
||||||
|
],
|
||||||
|
couponCode: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
addItem(name = 'USB Hub', price = 35) {
|
||||||
|
const { items } = cartAutomat.state;
|
||||||
|
cartAutomat.setState({
|
||||||
|
items: [
|
||||||
|
...items,
|
||||||
|
{ id: nextId++, name, price, quantity: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeItem(id) {
|
||||||
|
const { items } = cartAutomat.state;
|
||||||
|
cartAutomat.setState({
|
||||||
|
items: items.filter((item) => item.id !== id),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateQuantity(id, delta) {
|
||||||
|
const { items } = cartAutomat.state;
|
||||||
|
cartAutomat.setState({
|
||||||
|
items: items.map((item) =>
|
||||||
|
item.id === id
|
||||||
|
? { ...item, quantity: Math.max(1, item.quantity + delta) }
|
||||||
|
: item
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
renameItem(id, newName) {
|
||||||
|
const { items } = cartAutomat.state;
|
||||||
|
cartAutomat.setState({
|
||||||
|
items: items.map((item) =>
|
||||||
|
item.id === id ? { ...item, name: newName } : item
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setCouponCode(couponCode) {
|
||||||
|
cartAutomat.setState({ couponCode });
|
||||||
|
},
|
||||||
|
|
||||||
|
resetCart() {
|
||||||
|
nextId = 4;
|
||||||
|
cartAutomat.setState({
|
||||||
|
items: [
|
||||||
|
{ id: 1, name: 'Mechanical Keyboard', price: 129, quantity: 1 },
|
||||||
|
{ id: 2, name: 'Wireless Mouse', price: 79, quantity: 2 },
|
||||||
|
{ id: 3, name: 'Desk Mat (Gruvbox)', price: 29, quantity: 1 },
|
||||||
|
],
|
||||||
|
couponCode: '',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: 'cart', persist: true }
|
||||||
|
);
|
||||||
31
src/examples/automats/counterAutomat.js
Normal file
31
src/examples/automats/counterAutomat.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Automat } from '../../lib/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A local-memory automat with no API backend.
|
||||||
|
*
|
||||||
|
* State: { count: number }
|
||||||
|
*
|
||||||
|
* Actions close over the automat instance and drive all state transitions.
|
||||||
|
* Multiple React components can subscribe — they all stay in sync through
|
||||||
|
* the automat's subscriber set, never through React's prop drilling.
|
||||||
|
*/
|
||||||
|
const counterAutomat = new Automat(
|
||||||
|
{ count: 0 },
|
||||||
|
{
|
||||||
|
increment(amount = 1) {
|
||||||
|
const { count } = counterAutomat.getState();
|
||||||
|
const step = typeof amount === 'number' ? amount : 1;
|
||||||
|
counterAutomat.setState({ count: count + step });
|
||||||
|
},
|
||||||
|
decrement(amount = 1) {
|
||||||
|
const { count } = counterAutomat.getState();
|
||||||
|
const step = typeof amount === 'number' ? amount : 1;
|
||||||
|
counterAutomat.setState({ count: count - step });
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
counterAutomat.setState({ count: 0 });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default counterAutomat;
|
||||||
78
src/examples/automats/indexAutomat.js
Normal file
78
src/examples/automats/indexAutomat.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Automat } from '../../lib/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normal shared Automat (like Example 1 counter), holding an index value.
|
||||||
|
* The right-side component uses this value to lookup, dynamically instantiate,
|
||||||
|
* and resubscribe to a different Automat stored in window.automats Map.
|
||||||
|
*/
|
||||||
|
export const indexAutomat = new Automat(
|
||||||
|
{ index: 0 },
|
||||||
|
{
|
||||||
|
increment(step = 1) {
|
||||||
|
indexAutomat.setState({ index: indexAutomat.state.index + step });
|
||||||
|
},
|
||||||
|
decrement(step = 1) {
|
||||||
|
const next = Math.max(0, indexAutomat.state.index - step);
|
||||||
|
indexAutomat.setState({ index: next });
|
||||||
|
},
|
||||||
|
setIndex(index) {
|
||||||
|
indexAutomat.setState({ index: Math.max(0, Number(index) || 0) });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global Map on window holding dynamically instantiated slot Automats per index.
|
||||||
|
*/
|
||||||
|
if (typeof window !== 'undefined' && !window.automats) {
|
||||||
|
window.automats = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accesses or dynamically instantiates a named Automat in the window object for the given index.
|
||||||
|
* Demonstrates named automats and window-level persistence: `persist: false`.
|
||||||
|
*
|
||||||
|
* @param {number} index
|
||||||
|
* @returns {Automat}
|
||||||
|
*/
|
||||||
|
export function getOrCreateSlotAutomat(index) {
|
||||||
|
const name = `slot_${index}`;
|
||||||
|
let slotAutomat = Automat.get(name);
|
||||||
|
|
||||||
|
if (!slotAutomat) {
|
||||||
|
slotAutomat = new Automat(
|
||||||
|
{
|
||||||
|
index,
|
||||||
|
clicks: 0,
|
||||||
|
notes: `Notes for slot #${index}`,
|
||||||
|
lastModified: new Date().toLocaleTimeString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
click() {
|
||||||
|
slotAutomat.setState({
|
||||||
|
clicks: slotAutomat.state.clicks + 1,
|
||||||
|
lastModified: new Date().toLocaleTimeString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setNotes(notes) {
|
||||||
|
slotAutomat.setState({
|
||||||
|
notes,
|
||||||
|
lastModified: new Date().toLocaleTimeString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
slotAutomat.setState({
|
||||||
|
clicks: 0,
|
||||||
|
lastModified: new Date().toLocaleTimeString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name, persist: false } // Named automat persisted to window object
|
||||||
|
);
|
||||||
|
if (typeof window !== 'undefined' && window.automats) {
|
||||||
|
window.automats.set(index, slotAutomat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slotAutomat;
|
||||||
|
}
|
||||||
52
src/examples/automats/notificationAutomat.js
Normal file
52
src/examples/automats/notificationAutomat.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Automat } from '../../lib/index.js';
|
||||||
|
import counterAutomat from './counterAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cascade example — notificationAutomat subscribes to counterAutomat.
|
||||||
|
*
|
||||||
|
* Every time the counter's state changes, the transform callback fires and
|
||||||
|
* prepends a new notification message. This in turn calls notificationAutomat.setState,
|
||||||
|
* which notifies all of notificationAutomat's own subscribers (e.g. <NotificationBar>).
|
||||||
|
*
|
||||||
|
* State: { messages: Array<{ id, text, time, count }> }
|
||||||
|
*/
|
||||||
|
const notificationAutomat = new Automat(
|
||||||
|
{ messages: [] },
|
||||||
|
{
|
||||||
|
clear() {
|
||||||
|
notificationAutomat.setState({ messages: [] });
|
||||||
|
},
|
||||||
|
addNotice(text, count = 0) {
|
||||||
|
const { messages } = notificationAutomat.getState();
|
||||||
|
notificationAutomat.setState({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: Date.now() + Math.random(),
|
||||||
|
text,
|
||||||
|
time: new Date().toLocaleTimeString(),
|
||||||
|
count,
|
||||||
|
},
|
||||||
|
...messages.slice(0, 9),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wire the cascade: notificationAutomat derives state from counterAutomat:
|
||||||
|
notificationAutomat.subscribeTo(
|
||||||
|
counterAutomat,
|
||||||
|
(upstream, my) => ({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: Date.now() + Math.random(),
|
||||||
|
text: `Cascade trigger: counter changed to ${upstream.count}`,
|
||||||
|
time: new Date().toLocaleTimeString(),
|
||||||
|
count: upstream.count,
|
||||||
|
},
|
||||||
|
...my.messages.slice(0, 9),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default notificationAutomat;
|
||||||
73
src/examples/automats/photosAutomat.js
Normal file
73
src/examples/automats/photosAutomat.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Automat, fetchJson, fetchImageBlob, connectSSE } from '../../lib/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parent Automat: "photos/all"
|
||||||
|
*
|
||||||
|
* Manages the collection metadata and acts as the central invalidation hub.
|
||||||
|
* Listens to Server-Sent Events (SSE) via connectSSE to handle remote invalidation signals.
|
||||||
|
*/
|
||||||
|
export const photosAllAutomat = new Automat(
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
onSSETrigger(data, event, automat) {
|
||||||
|
console.log('[SSE Trigger Received]:', event, data);
|
||||||
|
if (data?.target === 'photos' || event === 'invalidate') {
|
||||||
|
automat.setDirty();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'photos/all',
|
||||||
|
url: '/api/photos',
|
||||||
|
fetcher: fetchJson,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Connect parent automat to Server-Sent Events stream:
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
connectSSE(photosAllAutomat, '/api/events/sse', {
|
||||||
|
onMessage: (data, event) => {
|
||||||
|
console.log('[SSE Stream Message]:', event, data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory / Registry for individual photo automats: "photo/:id"
|
||||||
|
*
|
||||||
|
* Each photo automat:
|
||||||
|
* 1. Fetches an Image Blob via `fetchImageBlob`
|
||||||
|
* 2. Cascades invalidation from `photosAllAutomat` via `invalidateWith(photosAllAutomat)`
|
||||||
|
* 3. On dirty, its `revoke()` method is called automatically, freeing the Blob URL from memory!
|
||||||
|
* 4. Bound to window under `photo/${id}` for global discovery
|
||||||
|
*/
|
||||||
|
const photoInstances = new Map();
|
||||||
|
|
||||||
|
export const getOrCreatePhotoAutomat = (id) => {
|
||||||
|
const name = `photo/${id}`;
|
||||||
|
if (photoInstances.has(id)) {
|
||||||
|
return photoInstances.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const automat = new Automat(
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
url: `/api/photos/${id}`,
|
||||||
|
fetcher: fetchImageBlob,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 💡 Cascading setDirty: Whenever photosAllAutomat is dirtied, this photo automat
|
||||||
|
// cascades setDirty(), revoking its blob URL and freeing heap memory!
|
||||||
|
automat.invalidateWith(photosAllAutomat);
|
||||||
|
|
||||||
|
photoInstances.set(id, automat);
|
||||||
|
return automat;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize photo automats 1, 2, 3
|
||||||
|
export const photo1 = getOrCreatePhotoAutomat(1);
|
||||||
|
export const photo2 = getOrCreatePhotoAutomat(2);
|
||||||
|
export const photo3 = getOrCreatePhotoAutomat(3);
|
||||||
27
src/examples/automats/profileAutomat.js
Normal file
27
src/examples/automats/profileAutomat.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Automat } from '../../lib/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend API-bound Automat.
|
||||||
|
*
|
||||||
|
* Demonstrates:
|
||||||
|
* 1. Binding to backend API URL via `url: '/api/profile'`.
|
||||||
|
* 2. Awaiting ready state via `await profileAutomat.ready`.
|
||||||
|
* 3. React Suspense support via `profileAutomat.read()`.
|
||||||
|
* 4. Standard subscriber support via `subscribe(this)`.
|
||||||
|
* 5. Invalidation via `profileAutomat.setDirty()`:
|
||||||
|
* - Resets state to null (since no defaults were given) or default state.
|
||||||
|
* - When mounted (subscribers > 0): reloads immediately.
|
||||||
|
* - When unmounted (subscribers == 0): defers reload until read() or subscribe().
|
||||||
|
*/
|
||||||
|
export const profileAutomat = new Automat(
|
||||||
|
null, // No defaults passed -> resets to null state when set dirty
|
||||||
|
{
|
||||||
|
setDirty() {
|
||||||
|
profileAutomat.setDirty();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'profile',
|
||||||
|
url: '/api/profile',
|
||||||
|
}
|
||||||
|
);
|
||||||
166
src/examples/automats/syncCounterAutomat.js
Normal file
166
src/examples/automats/syncCounterAutomat.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { Automat } from '../../lib/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to sync the indexed counter to the backend via POST /api/counter.
|
||||||
|
* Includes offline/fallback logic so it gracefully handles production previews.
|
||||||
|
*/
|
||||||
|
async function postCounterSync(index, count) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/counter', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ index, count, timestamp: Date.now() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Server returned ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
// Simulated fallback if running in an environment without the dev server backend:
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
index,
|
||||||
|
count,
|
||||||
|
savedAt: new Date().toLocaleTimeString(),
|
||||||
|
allRecords: [
|
||||||
|
{ index, count, updatedAt: new Date().toLocaleTimeString() },
|
||||||
|
],
|
||||||
|
fallback: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to fetch a specific index's state from backend via GET /api/counter?index=...
|
||||||
|
*/
|
||||||
|
async function fetchCounterIndex(index) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/counter?index=${index}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return { index, count: 0, updatedAt: 'Local' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API-backed indexed counter automat.
|
||||||
|
*
|
||||||
|
* Automatically syncs every mutation to the backend via HTTP POST /api/counter.
|
||||||
|
* Uses optimistic updates for instant UI response, followed by background POST synchronization.
|
||||||
|
*/
|
||||||
|
export const syncCounterAutomat = new Automat(
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
count: 0,
|
||||||
|
syncStatus: 'synced', // 'idle' | 'syncing' | 'synced' | 'error'
|
||||||
|
lastSyncedAt: 'Initial',
|
||||||
|
error: null,
|
||||||
|
backendRecords: [
|
||||||
|
{ index: 0, count: 0, updatedAt: 'Initial' },
|
||||||
|
{ index: 1, count: 5, updatedAt: 'Initial' },
|
||||||
|
{ index: 2, count: 10, updatedAt: 'Initial' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Increment count for current index & auto-sync to backend via POST
|
||||||
|
*/
|
||||||
|
async increment(step = 1) {
|
||||||
|
const { index, count } = syncCounterAutomat.state;
|
||||||
|
const nextCount = count + step;
|
||||||
|
|
||||||
|
// 1. Optimistic update:
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
count: nextCount,
|
||||||
|
syncStatus: 'syncing',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Auto-sync via POST /api/counter:
|
||||||
|
try {
|
||||||
|
const result = await postCounterSync(index, nextCount);
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
syncStatus: 'synced',
|
||||||
|
lastSyncedAt: result.savedAt,
|
||||||
|
backendRecords: result.allRecords || syncCounterAutomat.state.backendRecords,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
syncStatus: 'error',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrement count for current index & auto-sync to backend via POST
|
||||||
|
*/
|
||||||
|
async decrement(step = 1) {
|
||||||
|
const { index, count } = syncCounterAutomat.state;
|
||||||
|
const nextCount = Math.max(0, count - step);
|
||||||
|
|
||||||
|
// 1. Optimistic update:
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
count: nextCount,
|
||||||
|
syncStatus: 'syncing',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Auto-sync via POST /api/counter:
|
||||||
|
try {
|
||||||
|
const result = await postCounterSync(index, nextCount);
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
syncStatus: 'synced',
|
||||||
|
lastSyncedAt: result.savedAt,
|
||||||
|
backendRecords: result.allRecords || syncCounterAutomat.state.backendRecords,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
syncStatus: 'error',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a different counter index and load its backend state
|
||||||
|
*/
|
||||||
|
async setIndex(newIndex) {
|
||||||
|
const targetIndex = Number(newIndex);
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
index: targetIndex,
|
||||||
|
syncStatus: 'syncing',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const serverData = await fetchCounterIndex(targetIndex);
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
count: serverData.count ?? 0,
|
||||||
|
syncStatus: 'synced',
|
||||||
|
lastSyncedAt: serverData.updatedAt || new Date().toLocaleTimeString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset current counter to 0 & auto-sync via POST
|
||||||
|
*/
|
||||||
|
async reset() {
|
||||||
|
const { index } = syncCounterAutomat.state;
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
count: 0,
|
||||||
|
syncStatus: 'syncing',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await postCounterSync(index, 0);
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
syncStatus: 'synced',
|
||||||
|
lastSyncedAt: result.savedAt,
|
||||||
|
backendRecords: result.allRecords || syncCounterAutomat.state.backendRecords,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
439
src/examples/components/ApiBoundExample.jsx
Normal file
439
src/examples/components/ApiBoundExample.jsx
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
import { PureComponent, Suspense } from 'react';
|
||||||
|
import { profileAutomat } from '../automats/profileAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component 1: Standard PureComponent without React Suspense.
|
||||||
|
* Checks `profileAutomat.isReady` and handles loading state manually.
|
||||||
|
*/
|
||||||
|
class ProfileWithoutSuspense extends PureComponent {
|
||||||
|
_renderCount = 0;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
profile: profileAutomat.state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsubscribe = profileAutomat.subscribe(this, (state) => ({
|
||||||
|
profile: state,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubscribe) this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this._renderCount++;
|
||||||
|
const { profile } = this.state;
|
||||||
|
const isReady = profileAutomat.isReady && profile !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-body" style={{ minHeight: 220 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<span className="badge badge-api">Mode: Without Suspense</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
Renders: <strong style={{ color: 'var(--accent)' }}>#{this._renderCount}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isReady ? (
|
||||||
|
<div style={{ padding: '24px 0', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 24, marginBottom: 8, animation: 'spin 1s linear infinite' }}>⏳</div>
|
||||||
|
<div style={{ fontWeight: 600, color: 'var(--text-2)' }}>Fetching from /api/profile...</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>
|
||||||
|
(isReady is false; state is in null/default state)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, #10b981, #059669)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 20,
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 700
|
||||||
|
}}>
|
||||||
|
{profile.name?.charAt(0) || 'U'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ margin: 0, fontSize: 17, color: 'var(--text-1)' }}>{profile.name}</h4>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{profile.role} · {profile.department}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.03)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '10px 14px',
|
||||||
|
fontSize: 13,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between'
|
||||||
|
}}>
|
||||||
|
<span>API Fetch Sequence: <strong>#{profile.fetchCount}</strong></span>
|
||||||
|
<span style={{ color: 'var(--text-3)' }}>Loaded at: {profile.loadedAt}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component 2: PureComponent utilizing React Suspense via `profileAutomat.read()`.
|
||||||
|
*/
|
||||||
|
class ProfileSuspenseConsumer extends PureComponent {
|
||||||
|
_renderCount = 0;
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Subscribe to re-render when future mutations occur
|
||||||
|
this.unsubscribe = profileAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubscribe) this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this._renderCount++;
|
||||||
|
// Reads from automat — throws profileAutomat.ready if not ready!
|
||||||
|
const profile = profileAutomat.read();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-body" style={{ minHeight: 220 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<span className="badge badge-cascade">Mode: With React Suspense</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
Renders: <strong style={{ color: 'var(--accent)' }}>#{this._renderCount}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, #6366f1, #4f46e5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 20,
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 700
|
||||||
|
}}>
|
||||||
|
{profile?.name?.charAt(0) || 'S'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ margin: 0, fontSize: 17, color: 'var(--text-1)' }}>{profile?.name}</h4>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{profile?.role} · {profile?.department}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.03)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '10px 14px',
|
||||||
|
fontSize: 13,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between'
|
||||||
|
}}>
|
||||||
|
<span>API Fetch Sequence: <strong>#{profile?.fetchCount}</strong></span>
|
||||||
|
<span style={{ color: 'var(--text-3)' }}>Loaded at: {profile?.loadedAt}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback shown by <Suspense> while profileAutomat is unready.
|
||||||
|
*/
|
||||||
|
function SuspenseFallback() {
|
||||||
|
return (
|
||||||
|
<div className="card-body" style={{ minHeight: 220, textAlign: 'center', padding: '36px 0' }}>
|
||||||
|
<div style={{ fontSize: 24, marginBottom: 8, animation: 'pulse 1.2s ease-in-out infinite' }}>⚡</div>
|
||||||
|
<div style={{ fontWeight: 600, color: 'var(--accent-light)' }}>
|
||||||
|
Suspended by React Suspense Fallback
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>
|
||||||
|
profileAutomat.read() threw the ready Promise; Suspense caught it!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Controller Component managing Tabs and Demonstrating setDirty / Unmount behaviors.
|
||||||
|
*/
|
||||||
|
export default class ApiBoundExample extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
activeTab: 'without-suspense', // 'without-suspense' | 'with-suspense' | 'unmounted'
|
||||||
|
logMessage: null,
|
||||||
|
tick: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Monitor status changes on automat
|
||||||
|
this.unsubMonitor = profileAutomat.subscribe(() => {
|
||||||
|
this.forceUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubMonitor) this.unsubMonitor();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSetDirty = () => {
|
||||||
|
profileAutomat.setDirty();
|
||||||
|
const subs = profileAutomat.subscriberCount;
|
||||||
|
if (subs > 0) {
|
||||||
|
this.setState({
|
||||||
|
logMessage: `setDirty() called with ${subs} subscriber(s) mounted → Immediate reload started!`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.setState({
|
||||||
|
logMessage: `setDirty() called with 0 subscribers → Reload deferred until read() or subscribe()!`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleManualRead = () => {
|
||||||
|
try {
|
||||||
|
const data = profileAutomat.read();
|
||||||
|
this.setState({
|
||||||
|
logMessage: `Manual read() called! State was ready: ${JSON.stringify(data)}`,
|
||||||
|
});
|
||||||
|
} catch (promiseOrErr) {
|
||||||
|
if (typeof promiseOrErr?.then === 'function') {
|
||||||
|
this.setState({
|
||||||
|
logMessage: `Manual read() triggered deferred reload! Awaiting ready promise...`,
|
||||||
|
});
|
||||||
|
promiseOrErr.then((res) => {
|
||||||
|
this.setState({
|
||||||
|
logMessage: `Deferred reload finished! Data: ${res.name} (Fetch #${res.fetchCount})`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.setState({ logMessage: `Error: ${promiseOrErr.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAwaitReady = async () => {
|
||||||
|
this.setState({ logMessage: 'Awaiting profileAutomat.ready...' });
|
||||||
|
const data = await profileAutomat.ready;
|
||||||
|
this.setState({
|
||||||
|
logMessage: `profileAutomat.ready resolved with: ${data ? data.name : 'null'} (Fetch #${data?.fetchCount})`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSilentReload = async () => {
|
||||||
|
this.setState({ logMessage: 'Silent reload() in progress (isReady remains true, no flash)...' });
|
||||||
|
try {
|
||||||
|
const fresh = await profileAutomat.reload();
|
||||||
|
this.setState({
|
||||||
|
logMessage: `Silent reload() finished! Fresh data: ${fresh.name} (Fetch #${fresh.fetchCount}) without unready flash!`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ logMessage: `Reload failed: ${err.message}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { activeTab, logMessage } = this.state;
|
||||||
|
const isReady = profileAutomat.isReady;
|
||||||
|
const isDirty = profileAutomat.isDirty;
|
||||||
|
const subsCount = profileAutomat.subscriberCount;
|
||||||
|
const currentState = profileAutomat.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="two-columns" style={{ alignItems: 'flex-start' }}>
|
||||||
|
{/* Left Column: Tabbed Subscriber Interface */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header" style={{ paddingBottom: 0, borderBottom: 'none' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className="card-icon">🌐</span>
|
||||||
|
<h3>Backend-Bound Automat</h3>
|
||||||
|
</div>
|
||||||
|
<span className="badge badge-api">url: "/api/profile"</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginTop: 14, width: '100%', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${activeTab === 'without-suspense' ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
|
style={{ borderRadius: '6px 6px 0 0', borderBottom: 'none' }}
|
||||||
|
onClick={() => this.setState({ activeTab: 'without-suspense' })}
|
||||||
|
>
|
||||||
|
1. Without Suspense
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${activeTab === 'with-suspense' ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
|
style={{ borderRadius: '6px 6px 0 0', borderBottom: 'none' }}
|
||||||
|
onClick={() => this.setState({ activeTab: 'with-suspense' })}
|
||||||
|
>
|
||||||
|
2. With Suspense
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${activeTab === 'unmounted' ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
|
style={{ borderRadius: '6px 6px 0 0', borderBottom: 'none' }}
|
||||||
|
onClick={() => this.setState({ activeTab: 'unmounted' })}
|
||||||
|
>
|
||||||
|
3. Unmounted Tab
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
{activeTab === 'without-suspense' && <ProfileWithoutSuspense />}
|
||||||
|
|
||||||
|
{activeTab === 'with-suspense' && (
|
||||||
|
<Suspense fallback={<SuspenseFallback />}>
|
||||||
|
<ProfileSuspenseConsumer />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'unmounted' && (
|
||||||
|
<div className="card-body" style={{ minHeight: 220, textAlign: 'center', padding: '28px 20px' }}>
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 8 }}>📴</div>
|
||||||
|
<h4 style={{ margin: '0 0 6px', color: 'var(--text-1)' }}>Subscriber Unmounted</h4>
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-3)', maxWidth: 360, marginInline: 'auto' }}>
|
||||||
|
Neither subscriber is mounted. If you click <strong>Set Dirty</strong> now, state resets to null/default,
|
||||||
|
but the backend fetch is <strong>deferred</strong> until a subscriber mounts or <code>read()</code> is called.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Invalidation & Deferred Reload Controls */}
|
||||||
|
<div className="card card-display">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">⚡</span>
|
||||||
|
<h3>Dirty State & Reload Inspector</h3>
|
||||||
|
<span className={`badge ${isReady ? 'badge-success' : 'badge-warning'}`}>
|
||||||
|
{isReady ? 'Ready' : 'Not Ready'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Realtime Metrics */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10, marginBottom: 16 }}>
|
||||||
|
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border-subtle)', borderRadius: 8, padding: '8px 10px', textAlign: 'center' }}>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', display: 'block' }}>isReady</span>
|
||||||
|
<strong style={{ fontSize: 16, color: isReady ? 'var(--accent)' : '#f59e0b' }}>
|
||||||
|
{isReady ? 'true' : 'false'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border-subtle)', borderRadius: 8, padding: '8px 10px', textAlign: 'center' }}>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', display: 'block' }}>isDirty</span>
|
||||||
|
<strong style={{ fontSize: 16, color: isDirty ? '#ef4444' : 'var(--text-2)' }}>
|
||||||
|
{isDirty ? 'true' : 'false'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border-subtle)', borderRadius: 8, padding: '8px 10px', textAlign: 'center' }}>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', display: 'block' }}>Subscribers</span>
|
||||||
|
<strong style={{ fontSize: 16, color: subsCount > 0 ? 'var(--accent-light)' : 'var(--text-3)' }}>
|
||||||
|
{subsCount}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current State Snapshot */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
Current automat.state (resets to null when dirty):
|
||||||
|
</span>
|
||||||
|
<pre style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(0, 0, 0, 0.3)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
overflowX: 'auto',
|
||||||
|
color: currentState ? '#6ee7b7' : '#f87171'
|
||||||
|
}}>
|
||||||
|
{currentState ? JSON.stringify(currentState, null, 2) : 'null (unready / dirty)'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={this.handleSetDirty}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
⚡ Call profileAutomat.setDirty() (Resets state, causes reload)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={this.handleSilentReload}
|
||||||
|
style={{ width: '100%', borderColor: 'var(--accent)', color: 'var(--accent-light)' }}
|
||||||
|
title="Reloads data without resetting state or marking unready"
|
||||||
|
>
|
||||||
|
🔄 Call profileAutomat.reload() (Silent refresh, no flash)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={this.handleManualRead}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
title="Demonstrates triggering reload via read() when unmounted"
|
||||||
|
>
|
||||||
|
📖 Call read()
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={this.handleAwaitReady}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
title="Awaits profileAutomat.ready promise"
|
||||||
|
>
|
||||||
|
⏳ await ready
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Log Box */}
|
||||||
|
{logMessage && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: 12,
|
||||||
|
padding: '8px 10px',
|
||||||
|
background: 'rgba(99, 102, 241, 0.1)',
|
||||||
|
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||||
|
borderRadius: 6,
|
||||||
|
color: 'var(--text-2)'
|
||||||
|
}}>
|
||||||
|
💡 {logMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/examples/components/CartBadgeDisplay.jsx
Normal file
200
src/examples/components/CartBadgeDisplay.jsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { cartAutomat } from '../automats/cartAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CartBadgeDisplay — right column of the Partial Subscription example.
|
||||||
|
*
|
||||||
|
* Demonstrates partial state subscription:
|
||||||
|
* Subscribes ONLY to `state.items.length`, regardless of list content!
|
||||||
|
*
|
||||||
|
* When item quantities change or coupon codes are typed, `items.length` remains
|
||||||
|
* unchanged. The internal shallow equality check skips `setState()`, resulting in
|
||||||
|
* ZERO re-renders for this component.
|
||||||
|
*/
|
||||||
|
class CartBadgeDisplay extends PureComponent {
|
||||||
|
_renderCount = 0;
|
||||||
|
_badgeRef = null;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
// 1. Initial read: extract only what this component needs
|
||||||
|
this.state = {
|
||||||
|
badgeCount: cartAutomat.state.items.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// 2. 💡 PARTIAL SUBSCRIPTION:
|
||||||
|
// Selector maps to { badgeCount: state.items.length }.
|
||||||
|
// When items are added/removed, badgeCount changes -> setState() is called.
|
||||||
|
// When item quantities/names change or coupon is typed, badgeCount is unchanged -> setState() is SKIPPED!
|
||||||
|
this.unsubscribe = cartAutomat.subscribe(this, (state) => ({
|
||||||
|
badgeCount: state.items.length,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubscribe) this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
if (prevState.badgeCount !== this.state.badgeCount && this._badgeRef) {
|
||||||
|
this._badgeRef.classList.remove('count-animate');
|
||||||
|
void this._badgeRef.offsetWidth;
|
||||||
|
this._badgeRef.classList.add('count-animate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this._renderCount++;
|
||||||
|
const { badgeCount } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-display">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">🏷️</span>
|
||||||
|
<h3>Cart Badge (Partial Subscriber)</h3>
|
||||||
|
<span className="badge badge-api">Selector: items.length</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Render count & efficiency indicator */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||||
|
Badge Renders: <strong style={{ color: 'var(--green)' }}>#{this._renderCount}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="state-tag-automat" style={{ fontSize: 11 }}>
|
||||||
|
shallowEqual guarded
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Navbar / Cart Badge Mockup */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface-0)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-lg)',
|
||||||
|
padding: '24px 20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 12 }}>
|
||||||
|
Simulated App Navigation Bar
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
background: 'var(--surface-1)',
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: 'var(--r-xl)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-2)' }}>
|
||||||
|
Store Header
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div style={{ width: 1, height: 20, background: 'var(--border)' }} />
|
||||||
|
|
||||||
|
{/* Shopping Cart Icon with Badge */}
|
||||||
|
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
<span style={{ fontSize: 26, lineHeight: 1 }} role="img" aria-label="cart">
|
||||||
|
🛒
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Animated Badge Count */}
|
||||||
|
<span
|
||||||
|
ref={(el) => { this._badgeRef = el; }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -6,
|
||||||
|
right: -10,
|
||||||
|
background: 'var(--orange)',
|
||||||
|
color: 'var(--surface-0)',
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 12,
|
||||||
|
minWidth: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '0 5px',
|
||||||
|
boxShadow: '0 2px 5px rgba(0,0,0,0.5)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{badgeCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 14, fontSize: 13, color: 'var(--text-1)' }}>
|
||||||
|
Badge displays <strong style={{ color: 'var(--orange)' }}>{badgeCount} unique items</strong> in cart
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Explanation Callout */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface-1)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: 16,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, color: 'var(--text-bright)', marginBottom: 6 }}>
|
||||||
|
🔍 Why Partial State Subscription Matters:
|
||||||
|
</div>
|
||||||
|
<ul style={{ paddingLeft: 16, color: 'var(--text-2)' }}>
|
||||||
|
<li>
|
||||||
|
<strong>Quantity changes (+ / −)</strong> modify item content, but <code>items.length</code> remains {badgeCount}. <code>Automat</code> detects that <code>lastSlice</code> is shallow-equal to <code>nextSlice</code> and <strong>skips <code>this.setState()</code> completely</strong>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Typing coupon codes</strong> updates unrelated state, also triggering <strong>0 renders</strong> here.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Adding or removing an item</strong> alters <code>items.length</code>, immediately updating the badge number!
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Canonical Code Snippet */}
|
||||||
|
<div className="code-snippet">
|
||||||
|
<pre>{`// Partial subscription to ONLY the list length:
|
||||||
|
class CartBadgeDisplay extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { badgeCount: cartAutomat.state.items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// 💡 Regardless of item content/quantity/price changes,
|
||||||
|
// only re-renders when items.length changes:
|
||||||
|
this.unsubscribe = cartAutomat.subscribe(this, (state) => ({
|
||||||
|
badgeCount: state.items.length,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <span>Cart: {this.state.badgeCount}</span>;
|
||||||
|
}
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CartBadgeDisplay;
|
||||||
227
src/examples/components/CartManager.jsx
Normal file
227
src/examples/components/CartManager.jsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { cartAutomat } from '../automats/cartAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CartManager — left column of the Partial Subscription example.
|
||||||
|
*
|
||||||
|
* Full subscriber to cartAutomat.
|
||||||
|
* Allows adding/removing items (changing list length) AND modifying
|
||||||
|
* existing item content (quantity, name, coupon) without changing list length.
|
||||||
|
*/
|
||||||
|
class CartManager extends PureComponent {
|
||||||
|
_renderCount = 0;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = cartAutomat.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsubscribe = cartAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubscribe) this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAddItem = (name, price) => {
|
||||||
|
cartAutomat.actions.addItem(name, price);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleRemoveItem = (id) => {
|
||||||
|
cartAutomat.actions.removeItem(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleQuantityChange = (id, delta) => {
|
||||||
|
cartAutomat.actions.updateQuantity(id, delta);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCouponChange = (e) => {
|
||||||
|
cartAutomat.actions.setCouponCode(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
cartAutomat.actions.resetCart();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this._renderCount++;
|
||||||
|
const { items, couponCode } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">🛒</span>
|
||||||
|
<h3>Shopping Cart Manager</h3>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<span className="badge badge-success" title="State persists across full page reloads">💾 IndexedDB</span>
|
||||||
|
<span className="badge badge-local">Full Subscriber</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||||
|
Component Renders: <strong style={{ color: 'var(--accent)' }}>#{this._renderCount}</strong>
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={this.handleReset}
|
||||||
|
title="Reset items to initial state"
|
||||||
|
>
|
||||||
|
Reset List
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={async () => {
|
||||||
|
await cartAutomat.clearPersistence();
|
||||||
|
this.handleReset();
|
||||||
|
}}
|
||||||
|
title="Clear persisted state from IndexedDB"
|
||||||
|
>
|
||||||
|
Clear IndexedDB
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Add Buttons (Changes length) */}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
Add Item (Changes List Length):
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={() => this.handleAddItem('Keycaps Set', 45)}
|
||||||
|
>
|
||||||
|
+ Keycaps ($45)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={() => this.handleAddItem('Wrist Rest', 25)}
|
||||||
|
>
|
||||||
|
+ Wrist Rest ($25)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={() => this.handleAddItem('Coiled Cable', 32)}
|
||||||
|
>
|
||||||
|
+ Cable ($32)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items List */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
Cart Items ({items.length}):
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div style={{ padding: '16px', textAlign: 'center', color: 'var(--text-3)', background: 'var(--surface-1)', borderRadius: 'var(--r-md)' }}>
|
||||||
|
Cart is empty. Click an add button above.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
background: 'var(--surface-1)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: '8px 12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, marginRight: 8 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-1)' }}>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>
|
||||||
|
${item.price} each
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quantity controls (Changes content, NOT length!) */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-3)', marginRight: 2 }}>Qty:</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
style={{ padding: '2px 8px', fontSize: 12, minWidth: 26 }}
|
||||||
|
onClick={() => this.handleQuantityChange(item.id, -1)}
|
||||||
|
title="Decrease quantity (content changed, length unchanged)"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span style={{ fontWeight: 700, minWidth: 20, textAlign: 'center', fontSize: 13 }}>
|
||||||
|
{item.quantity}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
style={{ padding: '2px 8px', fontSize: 12, minWidth: 26 }}
|
||||||
|
onClick={() => this.handleQuantityChange(item.id, 1)}
|
||||||
|
title="Increase quantity (content changed, length unchanged)"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Remove item (Changes length!) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-danger"
|
||||||
|
style={{ padding: '2px 8px', fontSize: 12, marginLeft: 6 }}
|
||||||
|
onClick={() => this.handleRemoveItem(item.id)}
|
||||||
|
title="Remove item (changes length)"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unrelated field: Coupon Code */}
|
||||||
|
<div style={{ background: 'var(--surface-1)', padding: '10px 12px', borderRadius: 'var(--r-md)', border: '1px solid var(--border)' }}>
|
||||||
|
<label style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
Unrelated State Field (Coupon Code):
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={couponCode}
|
||||||
|
onChange={this.handleCouponChange}
|
||||||
|
placeholder="Type coupon (e.g. GRUVBOX20)..."
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '6px 10px',
|
||||||
|
background: 'var(--surface-0)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
color: 'var(--text-1)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4, display: 'block' }}>
|
||||||
|
Typing here calls <code>cartAutomat.setState({`{ couponCode }`})</code>. Notice how the badge on the right ignores this completely!
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CartManager;
|
||||||
163
src/examples/components/CascadeControls.jsx
Normal file
163
src/examples/components/CascadeControls.jsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import counterAutomat from '../automats/counterAutomat.js';
|
||||||
|
import notificationAutomat from '../automats/notificationAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CascadeControls — left column of Section 02 (Notification Cascade).
|
||||||
|
*
|
||||||
|
* Demonstrates:
|
||||||
|
* 1. Driving upstream transitions that cascade automatically into notificationAutomat.
|
||||||
|
* 2. Hybrid state: upstream count from counterAutomat + local cascade triggers counter.
|
||||||
|
* 3. Shows the complete wiring of `subscribeTo()`.
|
||||||
|
*/
|
||||||
|
class CascadeControls extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
// Hybrid initialization:
|
||||||
|
this.state = {
|
||||||
|
// 1. From upstream automat:
|
||||||
|
upstreamCount: counterAutomat.state.count,
|
||||||
|
// 2. Component-local state:
|
||||||
|
customText: 'System heartbeat',
|
||||||
|
cascadeFiredCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Subscribe to upstream automat to display its current state
|
||||||
|
this.unsubscribe = counterAutomat.subscribe(this, (state) => ({
|
||||||
|
upstreamCount: state.count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFireTrigger = (delta) => {
|
||||||
|
this.setState((prev) => ({ cascadeFiredCount: prev.cascadeFiredCount + 1 }));
|
||||||
|
counterAutomat.actions.increment(delta);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCustomNotice = () => {
|
||||||
|
const { customText, upstreamCount } = this.state;
|
||||||
|
this.setState((prev) => ({ cascadeFiredCount: prev.cascadeFiredCount + 1 }));
|
||||||
|
notificationAutomat.actions.addNotice(customText, upstreamCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClear = () => {
|
||||||
|
notificationAutomat.actions.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { upstreamCount, customText, cascadeFiredCount } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">🌊</span>
|
||||||
|
<h3>Cascade Trigger</h3>
|
||||||
|
<span className="badge badge-cascade">upstream</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Hybrid state display */}
|
||||||
|
<div className="hybrid-state-bar">
|
||||||
|
<div className="state-item">
|
||||||
|
<div className="state-item-header">
|
||||||
|
<span className="state-tag-automat">upstream</span>
|
||||||
|
<span>counter count</span>
|
||||||
|
</div>
|
||||||
|
<span className="state-item-val">{upstreamCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="state-item">
|
||||||
|
<div className="state-item-header">
|
||||||
|
<span className="state-tag-local">local</span>
|
||||||
|
<span>triggers fired</span>
|
||||||
|
</div>
|
||||||
|
<span className="state-item-val">{cascadeFiredCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-2)', marginBottom: 14 }}>
|
||||||
|
Clicking a trigger button invokes <code className="inline-code">counterAutomat.actions.increment()</code>.
|
||||||
|
Because <code className="inline-code">notificationAutomat</code> observes it with{' '}
|
||||||
|
<code className="inline-code">subscribeTo()</code>, the downstream log derives a new
|
||||||
|
message automatically without any component glue.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Trigger buttons */}
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginBottom: 14 }}>
|
||||||
|
<button
|
||||||
|
id="btn-cascade-trigger-1"
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={() => this.handleFireTrigger(1)}
|
||||||
|
>
|
||||||
|
Trigger (+1)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="btn-cascade-trigger-5"
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={() => this.handleFireTrigger(5)}
|
||||||
|
>
|
||||||
|
Trigger (+5)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-field"
|
||||||
|
value={customText}
|
||||||
|
onChange={(e) => this.setState({ customText: e.target.value })}
|
||||||
|
placeholder="Custom notification message..."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={this.handleCustomNotice}
|
||||||
|
style={{ whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
Post Notice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-full"
|
||||||
|
onClick={this.handleClear}
|
||||||
|
>
|
||||||
|
Clear Stream
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="code-snippet" style={{ marginTop: 16 }}>
|
||||||
|
<pre>{`// Concise reactive pipeline (ternary returns null to skip, or object to update):
|
||||||
|
notificationAutomat.subscribeTo(
|
||||||
|
counterAutomat,
|
||||||
|
(upstream, my) =>
|
||||||
|
upstream.count === 0
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: Date.now(),
|
||||||
|
text: \`Cascade: counter shifted to \${upstream.count}\`,
|
||||||
|
time: new Date().toLocaleTimeString(),
|
||||||
|
count: upstream.count,
|
||||||
|
},
|
||||||
|
...my.messages.slice(0, 9),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CascadeControls;
|
||||||
428
src/examples/components/CascadingPhotosExample.jsx
Normal file
428
src/examples/components/CascadingPhotosExample.jsx
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { Automat, fetchPdfBlob } from '../../lib/index.js';
|
||||||
|
import { photosAllAutomat, getOrCreatePhotoAutomat } from '../automats/photosAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PDF Document Automat demonstrating `fetchPdfBlob` and memory cleanup.
|
||||||
|
*/
|
||||||
|
const pdfDocAutomat = new Automat(
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
name: 'docs/spec',
|
||||||
|
url: '/api/docs/spec',
|
||||||
|
fetcher: fetchPdfBlob,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual Photo Card Component.
|
||||||
|
* Can be independently mounted/unmounted to demonstrate deferred reload on unmounted automats.
|
||||||
|
*/
|
||||||
|
class PhotoCard extends PureComponent {
|
||||||
|
_renderCount = 0;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const automat = getOrCreatePhotoAutomat(props.id);
|
||||||
|
this.state = {
|
||||||
|
data: automat.state,
|
||||||
|
isReady: automat.isReady,
|
||||||
|
isDirty: automat.isDirty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const automat = getOrCreatePhotoAutomat(this.props.id);
|
||||||
|
this.unsubscribe = automat.subscribe((state) => {
|
||||||
|
this.setState({
|
||||||
|
data: state,
|
||||||
|
isReady: automat.isReady,
|
||||||
|
isDirty: automat.isDirty,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubscribe) {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSingleDirty = () => {
|
||||||
|
const automat = getOrCreatePhotoAutomat(this.props.id);
|
||||||
|
automat.setDirty();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this._renderCount++;
|
||||||
|
const { id, title } = this.props;
|
||||||
|
const automat = getOrCreatePhotoAutomat(id);
|
||||||
|
const { data, isReady, isDirty } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface-2)',
|
||||||
|
border: isDirty ? '1px dashed #ef4444' : '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'relative', height: 160, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{isReady && data?.url ? (
|
||||||
|
<img
|
||||||
|
src={data.url}
|
||||||
|
alt={title}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ textAlign: 'center', padding: 12 }}>
|
||||||
|
<div style={{ fontSize: 24, marginBottom: 4 }}>{isDirty ? '🗑️' : '⏳'}</div>
|
||||||
|
<div style={{ fontSize: 12, color: isDirty ? '#ef4444' : 'var(--accent-light)', fontWeight: 600 }}>
|
||||||
|
{isDirty ? 'Memory Freed (Blob Revoked)' : 'Loading Blob...'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>State: null</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`badge ${isReady ? 'badge-success' : 'badge-warning'}`}
|
||||||
|
style={{ position: 'absolute', top: 8, right: 8, backdropFilter: 'blur(4px)' }}
|
||||||
|
>
|
||||||
|
photo/{id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8, flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h4 style={{ margin: 0, fontSize: 14, color: 'var(--text-1)' }}>{title}</h4>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>Renders: #{this._renderCount}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-2)', background: 'var(--surface-1)', padding: '6px 8px', borderRadius: 4, fontFamily: 'monospace' }}>
|
||||||
|
{data?.url ? (
|
||||||
|
<div>
|
||||||
|
<span style={{ color: '#10b981' }}>● Active Blob:</span> {(data.size / 1024).toFixed(1)} KB
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: '#ef4444' }}>● Memory Released (0 bytes)</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 'auto', display: 'flex', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={this.handleSingleDirty}
|
||||||
|
style={{ flex: 1, fontSize: 11, padding: '4px 6px' }}
|
||||||
|
>
|
||||||
|
Single Invalidate
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => automat.reload()}
|
||||||
|
style={{ flex: 1, fontSize: 11, padding: '4px 6px' }}
|
||||||
|
>
|
||||||
|
Silent Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main CascadingPhotosExample Component
|
||||||
|
*/
|
||||||
|
export default class CascadingPhotosExample extends PureComponent {
|
||||||
|
state = {
|
||||||
|
parentData: photosAllAutomat.state,
|
||||||
|
parentReady: photosAllAutomat.isReady,
|
||||||
|
parentDirty: photosAllAutomat.isDirty,
|
||||||
|
mountedPhotos: { 1: true, 2: true, 3: false },
|
||||||
|
sseLog: [],
|
||||||
|
showPdf: false,
|
||||||
|
pdfData: pdfDocAutomat.state,
|
||||||
|
pdfReady: pdfDocAutomat.isReady,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsubParent = photosAllAutomat.subscribe((state) => {
|
||||||
|
this.setState({
|
||||||
|
parentData: state,
|
||||||
|
parentReady: photosAllAutomat.isReady,
|
||||||
|
parentDirty: photosAllAutomat.isDirty,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.unsubPdf = pdfDocAutomat.subscribe((state) => {
|
||||||
|
this.setState({
|
||||||
|
pdfData: state,
|
||||||
|
pdfReady: pdfDocAutomat.isReady,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Intercept SSE trigger for interactive UI log
|
||||||
|
const origTrigger = photosAllAutomat.actions.onSSETrigger;
|
||||||
|
photosAllAutomat.actions.onSSETrigger = (data, event, automat) => {
|
||||||
|
origTrigger(data, event, automat);
|
||||||
|
this.setState((prev) => ({
|
||||||
|
sseLog: [
|
||||||
|
`[${new Date().toLocaleTimeString()}] SSE ${event}: ${JSON.stringify(data)} -> photosAllAutomat.setDirty()`,
|
||||||
|
...prev.sseLog.slice(0, 4),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubParent) this.unsubParent();
|
||||||
|
if (this.unsubPdf) this.unsubPdf();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleParentDirty = () => {
|
||||||
|
// Calling setDirty on photosAllAutomat cascades setDirty to ALL photo/* automats!
|
||||||
|
photosAllAutomat.setDirty();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleParentReload = () => {
|
||||||
|
photosAllAutomat.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleBroadcastSSE = async (eventName = 'invalidate') => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/events/trigger', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
event: eventName,
|
||||||
|
data: { target: 'photos', action: eventName, timestamp: Date.now() },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('SSE trigger failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
togglePhotoMount = (id) => {
|
||||||
|
this.setState((prev) => ({
|
||||||
|
mountedPhotos: {
|
||||||
|
...prev.mountedPhotos,
|
||||||
|
[id]: !prev.mountedPhotos[id],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { parentData, parentReady, parentDirty, mountedPhotos, sseLog, showPdf, pdfData, pdfReady } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ borderTop: '3px solid var(--accent)' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="card-header" style={{ flexWrap: 'wrap', gap: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span className="card-icon">🖼️</span>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: 0 }}>Cascading Invalidation & Binary Fetchers</h3>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
Hub: <code>photos/all</code> ➔ Dependents: <code>photo/1</code>, <code>photo/2</code>, <code>photo/3</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<span className={`badge ${parentReady ? 'badge-success' : 'badge-warning'}`}>
|
||||||
|
Hub: {parentReady ? 'Ready' : 'Dirty / Unready'}
|
||||||
|
</span>
|
||||||
|
<span className="badge badge-api">SSE: Connected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Controls Bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface-0)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: '14px 16px',
|
||||||
|
marginBottom: 20,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 10 }}>
|
||||||
|
<div>
|
||||||
|
<strong style={{ fontSize: 13, color: 'var(--text-1)' }}>Parent "photos/all" Controls:</strong>
|
||||||
|
<p style={{ margin: 0, fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
Setting parent dirty cascades to all photo automats, revoking Blob memory immediately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={this.handleParentDirty}
|
||||||
|
title="Calls photosAllAutomat.setDirty() which cascades to all children"
|
||||||
|
>
|
||||||
|
⚡ Cascade setDirty() (Free Memory)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => this.handleBroadcastSSE('invalidate')}
|
||||||
|
style={{ borderColor: '#ec4899', color: '#ec4899' }}
|
||||||
|
title="Sends SSE message to backend which triggers internal onSSETrigger"
|
||||||
|
>
|
||||||
|
📡 Send SSE Invalidation
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={this.handleParentReload}
|
||||||
|
title="Reloads parent collection silently"
|
||||||
|
>
|
||||||
|
🔄 Reload photos/all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SSE Live Log */}
|
||||||
|
{sseLog.length > 0 && (
|
||||||
|
<div style={{ background: 'rgba(0,0,0,0.3)', padding: '8px 10px', borderRadius: 6, fontSize: 11, fontFamily: 'monospace' }}>
|
||||||
|
<span style={{ color: '#ec4899', fontWeight: 600 }}>📡 SSE Activity Log:</span>
|
||||||
|
{sseLog.map((log, idx) => (
|
||||||
|
<div key={idx} style={{ color: 'var(--text-2)', marginTop: 2 }}>{log}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mount / Unmount Toggles for Memory Verification */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 8 }}>
|
||||||
|
Subscriber Mount States (Verify Deferred Reload & Memory Savings):
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
{[1, 2, 3].map((id) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${mountedPhotos[id] ? 'btn-ghost' : 'btn-primary'}`}
|
||||||
|
onClick={() => this.togglePhotoMount(id)}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{mountedPhotos[id] ? `📴 Unmount photo/${id}` : `📦 Mount photo/${id}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => this.setState((prev) => ({ showPdf: !prev.showPdf }))}
|
||||||
|
style={{ marginLeft: 'auto', borderColor: '#10b981', color: '#10b981' }}
|
||||||
|
>
|
||||||
|
📄 {showPdf ? 'Hide PDF Viewer' : 'Test fetchPdfBlob Viewer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Photos Grid */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16, marginBottom: 20 }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-2)', marginBottom: 6 }}>
|
||||||
|
Photo #1 (Alps) {mountedPhotos[1] ? '🟢 Mounted' : '⚪ Unmounted'}
|
||||||
|
</span>
|
||||||
|
{mountedPhotos[1] ? (
|
||||||
|
<PhotoCard id={1} title="Alps Panorama" />
|
||||||
|
) : (
|
||||||
|
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 12, background: 'var(--surface-0)' }}>
|
||||||
|
photo/1 is unmounted.<br />
|
||||||
|
Cascading dirty releases its blob and defers reload until re-mounted!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-2)', marginBottom: 6 }}>
|
||||||
|
Photo #2 (Tokyo) {mountedPhotos[2] ? '🟢 Mounted' : '⚪ Unmounted'}
|
||||||
|
</span>
|
||||||
|
{mountedPhotos[2] ? (
|
||||||
|
<PhotoCard id={2} title="Tokyo Neon Night" />
|
||||||
|
) : (
|
||||||
|
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 12, background: 'var(--surface-0)' }}>
|
||||||
|
photo/2 is unmounted.<br />
|
||||||
|
Cascading dirty releases its blob and defers reload until re-mounted!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-2)', marginBottom: 6 }}>
|
||||||
|
Photo #3 (Rainforest) {mountedPhotos[3] ? '🟢 Mounted' : '⚪ Unmounted'}
|
||||||
|
</span>
|
||||||
|
{mountedPhotos[3] ? (
|
||||||
|
<PhotoCard id={3} title="Emerald Rainforest" />
|
||||||
|
) : (
|
||||||
|
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 12, background: 'var(--surface-0)' }}>
|
||||||
|
photo/3 is unmounted.<br />
|
||||||
|
Click "Mount photo/3" above to subscribe and trigger initial fetch!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PDF Blob Viewer Section */}
|
||||||
|
{showPdf && (
|
||||||
|
<div style={{ background: 'var(--surface-0)', border: '1px solid var(--border)', borderRadius: 8, padding: 16, marginTop: 16 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ margin: 0, fontSize: 14 }}>PDF Blob Fetcher (`fetchPdfBlob`)</h4>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
Endpoint: <code>/api/docs/spec</code> · Blob Size: {pdfData?.size || 0} bytes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => pdfDocAutomat.setDirty()}
|
||||||
|
>
|
||||||
|
Set Dirty (Revoke PDF Blob)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => pdfDocAutomat.reload()}
|
||||||
|
>
|
||||||
|
Reload PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pdfReady && pdfData?.url ? (
|
||||||
|
<iframe
|
||||||
|
src={pdfData.url}
|
||||||
|
title="PDF Spec Viewer"
|
||||||
|
style={{ width: '100%', height: 200, border: '1px solid var(--border-subtle)', borderRadius: 6 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text-3)', fontSize: 12 }}>
|
||||||
|
PDF Blob Unready or Dirtied (Memory Freed)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/examples/components/CounterButton.jsx
Normal file
182
src/examples/components/CounterButton.jsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import counterAutomat from '../automats/counterAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CounterButton — left column.
|
||||||
|
*
|
||||||
|
* HYBRID STATE PATTERN:
|
||||||
|
* - Shared state: `count` comes from counterAutomat.
|
||||||
|
* - Local state: `step` (increment size) and `localClicks` (click counter)
|
||||||
|
* are private to this component.
|
||||||
|
*
|
||||||
|
* 1. Constructor combines direct automat access with local component state.
|
||||||
|
* 2. componentDidMount registers with counterAutomat.subscribe(this).
|
||||||
|
* 3. React's setState shallow-merges updates, so local fields remain untouched
|
||||||
|
* when the automat notifies this component of count updates.
|
||||||
|
*/
|
||||||
|
class CounterButton extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
// Hybrid initialization:
|
||||||
|
this.state = {
|
||||||
|
// 1. Initialized directly from the automat:
|
||||||
|
count: counterAutomat.state.count,
|
||||||
|
// 2. Component-local state:
|
||||||
|
step: 1,
|
||||||
|
localClicks: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Subscriber handling for React lifecycle management
|
||||||
|
this.unsubscribe = counterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSetStep = (step) => {
|
||||||
|
// Purely local state update
|
||||||
|
this.setState({ step });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleIncrement = () => {
|
||||||
|
const { step, localClicks } = this.state;
|
||||||
|
// Update local clicks counter
|
||||||
|
this.setState({ localClicks: localClicks + 1 });
|
||||||
|
// Trigger automat action with local step
|
||||||
|
counterAutomat.actions.increment(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDecrement = () => {
|
||||||
|
const { step, localClicks } = this.state;
|
||||||
|
this.setState({ localClicks: localClicks + 1 });
|
||||||
|
counterAutomat.actions.decrement(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
const { localClicks } = this.state;
|
||||||
|
this.setState({ localClicks: localClicks + 1 });
|
||||||
|
counterAutomat.actions.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { count, step, localClicks } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">⚡</span>
|
||||||
|
<h3>Counter Controls</h3>
|
||||||
|
<span className="badge badge-local">hybrid state</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Hybrid State breakdown */}
|
||||||
|
<div className="hybrid-state-bar">
|
||||||
|
<div className="state-item">
|
||||||
|
<div className="state-item-header">
|
||||||
|
<span className="state-tag-automat">automat</span>
|
||||||
|
<span>shared</span>
|
||||||
|
</div>
|
||||||
|
<span className="state-item-val">{count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="state-item">
|
||||||
|
<div className="state-item-header">
|
||||||
|
<span className="state-tag-local">local</span>
|
||||||
|
<span>button clicks</span>
|
||||||
|
</div>
|
||||||
|
<span className="state-item-val">{localClicks}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Local step selector */}
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
Step size <span className="state-tag-local">local</span>
|
||||||
|
</span>
|
||||||
|
<div className="segmented-control" role="group" aria-label="Step size">
|
||||||
|
{[1, 5, 10].map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
className={`seg-btn ${step === s ? 'active' : ''}`}
|
||||||
|
onClick={() => this.handleSetStep(s)}
|
||||||
|
>
|
||||||
|
±{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* +/- controls */}
|
||||||
|
<div className="counter-controls">
|
||||||
|
<button
|
||||||
|
id="btn-decrement"
|
||||||
|
className="btn btn-circle btn-secondary"
|
||||||
|
onClick={this.handleDecrement}
|
||||||
|
aria-label={`Decrement by ${step}`}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="btn-increment"
|
||||||
|
className="btn btn-circle btn-primary"
|
||||||
|
onClick={this.handleIncrement}
|
||||||
|
aria-label={`Increment by ${step}`}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="btn-reset"
|
||||||
|
className="btn btn-ghost btn-full"
|
||||||
|
onClick={this.handleReset}
|
||||||
|
>
|
||||||
|
Reset Count to 0
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="code-snippet">
|
||||||
|
<pre>{`// 1. Instantiate the automat outside React:
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. PureComponent with hybrid state:
|
||||||
|
class CounterButton extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
count: counterAutomat.state.count, // ← from automat
|
||||||
|
step: 1, // ← component-local
|
||||||
|
localClicks: 0, // ← component-local
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsub = counterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsub();
|
||||||
|
}
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CounterButton;
|
||||||
172
src/examples/components/CounterDisplay.jsx
Normal file
172
src/examples/components/CounterDisplay.jsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import counterAutomat from '../automats/counterAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CounterDisplay — right column.
|
||||||
|
*
|
||||||
|
* HYBRID STATE PATTERN:
|
||||||
|
* - Shared state: `count` comes from counterAutomat.
|
||||||
|
* - Local state: `format` ('dec' | 'hex') is private to this component.
|
||||||
|
*
|
||||||
|
* Demonstrates that CounterDisplay can maintain its own presentation preferences
|
||||||
|
* while staying in sync with the shared automat state.
|
||||||
|
*/
|
||||||
|
class CounterDisplay extends PureComponent {
|
||||||
|
/** @type {HTMLElement|null} */
|
||||||
|
_numRef = null;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
// Hybrid initialization:
|
||||||
|
this.state = {
|
||||||
|
// 1. Initialized directly from automat:
|
||||||
|
count: counterAutomat.state.count,
|
||||||
|
// 2. Component-local presentation state:
|
||||||
|
format: 'dec',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Subscriber handling for React lifecycle
|
||||||
|
this.unsubscribe = counterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
// Pop animation whenever shared automat count changes
|
||||||
|
if (prevState.count !== this.state.count) {
|
||||||
|
if (this._numRef) {
|
||||||
|
this._numRef.classList.remove('count-animate');
|
||||||
|
void this._numRef.offsetWidth;
|
||||||
|
this._numRef.classList.add('count-animate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFormatChange = (format) => {
|
||||||
|
// Purely local state update
|
||||||
|
this.setState({ format });
|
||||||
|
};
|
||||||
|
|
||||||
|
formatValue(count, format) {
|
||||||
|
if (format === 'hex') {
|
||||||
|
return (count < 0 ? '-' : '') + '0x' + Math.abs(count).toString(16).toUpperCase();
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { count, format } = this.state;
|
||||||
|
const formatted = this.formatValue(count, format);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-display">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">📊</span>
|
||||||
|
<h3>Counter Display</h3>
|
||||||
|
<span className="badge badge-local">hybrid state</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Hybrid State breakdown */}
|
||||||
|
<div className="hybrid-state-bar">
|
||||||
|
<div className="state-item">
|
||||||
|
<div className="state-item-header">
|
||||||
|
<span className="state-tag-automat">automat</span>
|
||||||
|
<span>shared count</span>
|
||||||
|
</div>
|
||||||
|
<span className="state-item-val">{count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="state-item">
|
||||||
|
<div className="state-item-header">
|
||||||
|
<span className="state-tag-local">local</span>
|
||||||
|
<span>format preference</span>
|
||||||
|
</div>
|
||||||
|
<span className="state-item-val">{format.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Local format selector */}
|
||||||
|
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
Display format <span className="state-tag-local">local</span>
|
||||||
|
</span>
|
||||||
|
<div className="segmented-control" role="group" aria-label="Display format">
|
||||||
|
{['dec', 'hex'].map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
type="button"
|
||||||
|
className={`seg-btn ${format === f ? 'active' : ''}`}
|
||||||
|
onClick={() => this.handleFormatChange(f)}
|
||||||
|
>
|
||||||
|
{f.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="count-display-container">
|
||||||
|
{/* Gradient number with pop animation on change */}
|
||||||
|
<span
|
||||||
|
className="count-number"
|
||||||
|
ref={(el) => { this._numRef = el; }}
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
{formatted}
|
||||||
|
</span>
|
||||||
|
<span className="count-label">{format.toUpperCase()} format</span>
|
||||||
|
<span className="count-hint">
|
||||||
|
Independent PureComponent — no shared parent
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="code-snippet">
|
||||||
|
<pre>{`// 1. Instantiate automat outside React:
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. PureComponent with hybrid state:
|
||||||
|
class CounterDisplay extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
count: counterAutomat.state.count, // ← from automat
|
||||||
|
format: 'dec', // ← component-local state
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsub = counterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsub();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { count, format } = this.state;
|
||||||
|
const value = format === 'hex' ? '0x' + count.toString(16) : count;
|
||||||
|
return <span>{value}</span>;
|
||||||
|
}
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CounterDisplay;
|
||||||
199
src/examples/components/DynamicAutomatSubscriber.jsx
Normal file
199
src/examples/components/DynamicAutomatSubscriber.jsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { indexAutomat, getOrCreateSlotAutomat } from '../automats/indexAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DynamicAutomatSubscriber — right column.
|
||||||
|
*
|
||||||
|
* 1. Subscribes to the shared `indexAutomat`.
|
||||||
|
* 2. Whenever `index` changes, it:
|
||||||
|
* - Unsubscribes from the previous Automat instance
|
||||||
|
* - Uses `index` to lookup or dynamically instantiate an Automat in `window.automats` Map
|
||||||
|
* - Immediately recalls its preserved state
|
||||||
|
* - Subscribes to the new Automat instance
|
||||||
|
*/
|
||||||
|
class DynamicAutomatSubscriber extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const initialIndex = indexAutomat.state.index;
|
||||||
|
const initialSlot = getOrCreateSlotAutomat(initialIndex);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
currentIndex: initialIndex,
|
||||||
|
slotState: initialSlot.state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// 1. Subscribe to the shared index automat
|
||||||
|
this.unsubIndex = indexAutomat.subscribe((indexState) => {
|
||||||
|
this.handleIndexChange(indexState.index);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Subscribe to the initial slot automat in window.automats
|
||||||
|
this.subscribeToSlot(this.state.currentIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubIndex) this.unsubIndex();
|
||||||
|
if (this.unsubSlot) this.unsubSlot();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleIndexChange(newIndex) {
|
||||||
|
if (newIndex === this.state.currentIndex) return;
|
||||||
|
|
||||||
|
// 💡 DYNAMIC RESUBSCRIPTION WORKFLOW:
|
||||||
|
// 1. Unsubscribe from the previous Automat
|
||||||
|
if (this.unsubSlot) {
|
||||||
|
this.unsubSlot();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Lookup or dynamically instantiate the Automat for this index in window.automats:
|
||||||
|
const slotAutomat = getOrCreateSlotAutomat(newIndex);
|
||||||
|
|
||||||
|
// 3. Immediately recall the stored state:
|
||||||
|
this.setState({
|
||||||
|
currentIndex: newIndex,
|
||||||
|
slotState: slotAutomat.state,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Resubscribe to the new instance:
|
||||||
|
this.unsubSlot = slotAutomat.subscribe((slotState) => {
|
||||||
|
this.setState({ slotState });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeToSlot(index) {
|
||||||
|
const slotAutomat = getOrCreateSlotAutomat(index);
|
||||||
|
this.unsubSlot = slotAutomat.subscribe((slotState) => {
|
||||||
|
this.setState({ slotState });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClickSlot = () => {
|
||||||
|
const slotAutomat = getOrCreateSlotAutomat(this.state.currentIndex);
|
||||||
|
slotAutomat.actions.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleNotesChange = (e) => {
|
||||||
|
const slotAutomat = getOrCreateSlotAutomat(this.state.currentIndex);
|
||||||
|
slotAutomat.actions.setNotes(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleResetSlot = () => {
|
||||||
|
const slotAutomat = getOrCreateSlotAutomat(this.state.currentIndex);
|
||||||
|
slotAutomat.actions.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { currentIndex, slotState } = this.state;
|
||||||
|
const map = typeof window !== 'undefined' ? window.automats : null;
|
||||||
|
const allIndexes = map
|
||||||
|
? Array.from(map.keys())
|
||||||
|
.filter((k) => typeof k === 'number' || !isNaN(Number(k)))
|
||||||
|
.sort((a, b) => Number(a) - Number(b))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-display">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">⚡</span>
|
||||||
|
<h3>Dynamic Slot Automat (Resubscribed by Index)</h3>
|
||||||
|
<span className="badge badge-cascade">Automat.get('slot_{currentIndex}') (window)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Active slot recalled state */}
|
||||||
|
<div className="count-display-container" style={{ padding: '4px 0 14px' }}>
|
||||||
|
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '.1em', color: 'var(--text-3)', fontWeight: 700 }}>
|
||||||
|
Recalled State for Automat #{currentIndex}
|
||||||
|
</span>
|
||||||
|
<span className="count-number" style={{ fontSize: 68, margin: '2px 0' }}>
|
||||||
|
{slotState?.clicks ?? 0}
|
||||||
|
</span>
|
||||||
|
<span className="count-label">Clicks recorded on Slot #{currentIndex}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slot mutation controls */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={this.handleClickSlot}
|
||||||
|
>
|
||||||
|
+ Click Slot #{currentIndex}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={this.handleResetSlot}
|
||||||
|
>
|
||||||
|
Reset Slot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-field"
|
||||||
|
value={slotState?.notes || ''}
|
||||||
|
onChange={this.handleNotesChange}
|
||||||
|
placeholder={`Notes for Slot #${currentIndex}...`}
|
||||||
|
style={{ fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table showing all entries in window.automats */}
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
All Instantiated Automats in window.automats Map:
|
||||||
|
</span>
|
||||||
|
<div style={{ background: 'var(--surface-0)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: '6px 10px', maxHeight: 96, overflowY: 'auto' }}>
|
||||||
|
{allIndexes.map((idx) => {
|
||||||
|
const aut = map.get(idx);
|
||||||
|
const st = aut?.state || {};
|
||||||
|
const isActive = idx === currentIndex;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '2px 0',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
color: isActive ? 'var(--accent)' : 'var(--text-2)',
|
||||||
|
fontWeight: isActive ? 700 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{isActive ? '▸ ' : ' '}window.automats.get({idx})
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
clicks: <strong>{st.clicks ?? 0}</strong> · "{st.notes ?? ''}"
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Code snippet showing resubscription logic */}
|
||||||
|
<div className="code-snippet">
|
||||||
|
<pre>{`// Resubscribing to different Automat driven by shared index:
|
||||||
|
handleIndexChange(newIndex) {
|
||||||
|
this.unsubSlot?.(); // 1. Unsub old
|
||||||
|
const slotAutomat = window.automats.get(newIndex)// 2. Access / create in Map
|
||||||
|
?? getOrCreateSlotAutomat(newIndex);
|
||||||
|
this.setState({ slotState: slotAutomat.state }); // 3. Recall state!
|
||||||
|
this.unsubSlot = slotAutomat.subscribe(this); // 4. Resubscribe!
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DynamicAutomatSubscriber;
|
||||||
118
src/examples/components/IndexSelector.jsx
Normal file
118
src/examples/components/IndexSelector.jsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { indexAutomat } from '../automats/indexAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IndexSelector — left column.
|
||||||
|
*
|
||||||
|
* A standard PureComponent driving indexAutomat (like Example 1).
|
||||||
|
* Clicking +/- changes the shared `index` value.
|
||||||
|
*/
|
||||||
|
class IndexSelector extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = indexAutomat.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsubscribe = indexAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubscribe) this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleIncrement = () => {
|
||||||
|
indexAutomat.actions.increment(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDecrement = () => {
|
||||||
|
indexAutomat.actions.decrement(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSelect = (i) => {
|
||||||
|
indexAutomat.actions.setIndex(i);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { index } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">🔢</span>
|
||||||
|
<h3>Shared Index Counter</h3>
|
||||||
|
<span className="badge badge-local">shared index</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Current Index Display */}
|
||||||
|
<div className="ctrl-current-value">
|
||||||
|
<span className="ctrl-current-label">Shared Automat Index</span>
|
||||||
|
<span className="ctrl-current-num">{index}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* +/- Controls */}
|
||||||
|
<div className="counter-controls">
|
||||||
|
<button
|
||||||
|
id="btn-index-dec"
|
||||||
|
type="button"
|
||||||
|
className="btn btn-circle btn-secondary"
|
||||||
|
onClick={this.handleDecrement}
|
||||||
|
disabled={index <= 0}
|
||||||
|
aria-label="Previous index"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="btn-index-inc"
|
||||||
|
type="button"
|
||||||
|
className="btn btn-circle btn-primary"
|
||||||
|
onClick={this.handleIncrement}
|
||||||
|
aria-label="Next index"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick jump pills */}
|
||||||
|
<div style={{ margin: '14px 0 10px', textAlign: 'center' }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
Quick jump to index:
|
||||||
|
</span>
|
||||||
|
<div className="segmented-control">
|
||||||
|
{[0, 1, 2, 3, 4].map((i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className={`seg-btn ${index === i ? 'active' : ''}`}
|
||||||
|
onClick={() => this.handleSelect(i)}
|
||||||
|
>
|
||||||
|
#{i}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-2)', textAlign: 'center', margin: '8px 0 14px' }}>
|
||||||
|
Changing this index causes the right side to dynamically lookup, instantiate, and resubscribe to{' '}
|
||||||
|
<code className="inline-code">{`window.automats.get(${index})`}</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="code-snippet">
|
||||||
|
<pre>{`// 1. Shared index automat:
|
||||||
|
const indexAutomat = new Automat({ index: 0 }, {
|
||||||
|
increment() { indexAutomat.setState({ index: indexAutomat.state.index + 1 }); },
|
||||||
|
decrement() { indexAutomat.setState({ index: indexAutomat.state.index - 1 }); },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Left component triggers the index change:
|
||||||
|
indexAutomat.actions.increment();`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IndexSelector;
|
||||||
155
src/examples/components/NotificationBar.jsx
Normal file
155
src/examples/components/NotificationBar.jsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import notificationAutomat from '../automats/notificationAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NotificationBar — right column of Section 02 (Notification Cascade).
|
||||||
|
*
|
||||||
|
* HYBRID STATE PATTERN:
|
||||||
|
* - Shared state: `messages` from notificationAutomat (derived from counterAutomat via cascade).
|
||||||
|
* - Local state: `filter` ('all' | 'positive' | 'negative') private to this component.
|
||||||
|
*
|
||||||
|
* Displays the resulting notifications in real-time.
|
||||||
|
*/
|
||||||
|
class NotificationBar extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
// Hybrid initialization:
|
||||||
|
this.state = {
|
||||||
|
// 1. Initialized directly from automat:
|
||||||
|
messages: notificationAutomat.state.messages,
|
||||||
|
// 2. Component-local filter state:
|
||||||
|
filter: 'all',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Subscriber handling for React lifecycle
|
||||||
|
this.unsubscribe = notificationAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFilterChange = (filter) => {
|
||||||
|
// Purely local state update
|
||||||
|
this.setState({ filter });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { messages, filter } = this.state;
|
||||||
|
|
||||||
|
const filteredMessages = messages.filter((m) => {
|
||||||
|
if (filter === 'positive') return m.count >= 0;
|
||||||
|
if (filter === 'negative') return m.count < 0;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">🔔</span>
|
||||||
|
<h3>Notification Stream</h3>
|
||||||
|
<span className="badge badge-cascade">downstream</span>
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<span className="notif-count-badge">{messages.length} total</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Hybrid state display */}
|
||||||
|
<div className="hybrid-state-bar">
|
||||||
|
<div className="state-item">
|
||||||
|
<div className="state-item-header">
|
||||||
|
<span className="state-tag-automat">cascade</span>
|
||||||
|
<span>items in stream</span>
|
||||||
|
</div>
|
||||||
|
<span className="state-item-val">{messages.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="state-item">
|
||||||
|
<div className="state-item-header">
|
||||||
|
<span className="state-tag-local">local</span>
|
||||||
|
<span>active filter</span>
|
||||||
|
</div>
|
||||||
|
<span className="state-item-val">{filter.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Local filter control */}
|
||||||
|
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
Filter stream <span className="state-tag-local">local</span>
|
||||||
|
</span>
|
||||||
|
<div className="segmented-control" role="group" aria-label="Notification filter">
|
||||||
|
{['all', 'positive', 'negative'].map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
type="button"
|
||||||
|
className={`seg-btn ${filter === f ? 'active' : ''}`}
|
||||||
|
onClick={() => this.handleFilterChange(f)}
|
||||||
|
>
|
||||||
|
{f.charAt(0).toUpperCase() + f.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredMessages.length === 0 ? (
|
||||||
|
<div className="empty-state" style={{ minHeight: 140 }}>
|
||||||
|
<span className="empty-state-icon">💤</span>
|
||||||
|
<p>No notifications matching "{filter}".</p>
|
||||||
|
<p className="empty-state-sub">Trigger counter changes to see cascade notifications.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="notification-list" aria-live="polite" style={{ maxHeight: 220, overflowY: 'auto' }}>
|
||||||
|
{filteredMessages.map((msg) => (
|
||||||
|
<li key={msg.id} className="notification-item">
|
||||||
|
<span className="notif-time">{msg.time}</span>
|
||||||
|
<span className="notif-text">{msg.text}</span>
|
||||||
|
<span
|
||||||
|
className="notif-count-pill"
|
||||||
|
style={{
|
||||||
|
background: msg.count >= 0 ? 'var(--green)' : 'var(--red)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg.count}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="code-snippet" style={{ marginTop: 16 }}>
|
||||||
|
<pre>{`// 4. Downstream component consumes the cascade:
|
||||||
|
class NotificationBar extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
// Reads directly from downstream notificationAutomat:
|
||||||
|
this.state = {
|
||||||
|
messages: notificationAutomat.state.messages, // ← derived from cascade
|
||||||
|
filter: 'all', // ← component-local state
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Re-renders automatically whenever the cascade emits new messages:
|
||||||
|
this.unsub = notificationAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsub();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { messages, filter } = this.state;
|
||||||
|
return <ul>{messages.map((m) => <li key={m.id}>{m.text}</li>)}</ul>;
|
||||||
|
}
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationBar;
|
||||||
115
src/examples/components/SyncBackendMonitor.jsx
Normal file
115
src/examples/components/SyncBackendMonitor.jsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { syncCounterAutomat } from '../automats/syncCounterAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SyncBackendMonitor — right column.
|
||||||
|
*
|
||||||
|
* Observes syncCounterAutomat and inspects the real-time server database state
|
||||||
|
* and HTTP POST payloads transmitted during auto-sync.
|
||||||
|
*/
|
||||||
|
class SyncBackendMonitor extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = syncCounterAutomat.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsubscribe = syncCounterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubscribe) this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { index, count, syncStatus, lastSyncedAt, backendRecords } = this.state;
|
||||||
|
const isSyncing = syncStatus === 'syncing';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-display">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">📡</span>
|
||||||
|
<h3>Backend Server Database Monitor</h3>
|
||||||
|
<span className="badge badge-api">POST /api/counter</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Last Transmitted Payload */}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
HTTP POST Payload (Sent Automatically on Change):
|
||||||
|
</span>
|
||||||
|
<div style={{ background: 'var(--surface-0)', border: '1px solid var(--border)', borderRadius: 'var(--r-md)', padding: '10px 12px', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||||
|
<div style={{ color: 'var(--text-3)', marginBottom: 4 }}>
|
||||||
|
<span style={{ color: 'var(--accent)', fontWeight: 700 }}>POST</span> /api/counter
|
||||||
|
</div>
|
||||||
|
<div style={{ color: isSyncing ? 'var(--orange)' : 'var(--aqua)' }}>
|
||||||
|
{JSON.stringify({ index, count }, null, 2)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6, fontSize: 11, color: isSyncing ? 'var(--orange)' : 'var(--green)', borderTop: '1px solid var(--surface-2)', paddingTop: 4 }}>
|
||||||
|
{isSyncing ? '⏳ Transmitting request to server…' : `✓ 200 OK — Saved at ${lastSyncedAt}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server-Side Database Records across all indexes */}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
Backend Server Database (Persisted Records):
|
||||||
|
</span>
|
||||||
|
<div style={{ background: 'var(--surface-0)', border: '1px solid var(--border)', borderRadius: 'var(--r-md)', padding: '8px 12px', maxHeight: 120, overflowY: 'auto' }}>
|
||||||
|
{backendRecords && backendRecords.length > 0 ? (
|
||||||
|
backendRecords.map((rec) => {
|
||||||
|
const isActive = rec.index === index;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rec.index}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '4px 0',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
borderBottom: '1px solid var(--surface-2)',
|
||||||
|
color: isActive ? 'var(--accent)' : 'var(--text-2)',
|
||||||
|
fontWeight: isActive ? 700 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{isActive ? '▸ ' : ' '}Index #{rec.index}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Server Count: <strong>{rec.count}</strong>
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-3)', fontSize: 11 }}>
|
||||||
|
{rec.updatedAt}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div style={{ color: 'var(--text-3)', fontSize: 12, padding: '4px 0' }}>
|
||||||
|
No backend records yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Code Snippet */}
|
||||||
|
<div className="code-snippet">
|
||||||
|
<pre>{`// Component rendering API-backed automat state:
|
||||||
|
class SyncBackendMonitor extends PureComponent {
|
||||||
|
componentDidMount() {
|
||||||
|
// Re-renders automatically on both optimistic update and POST resolution!
|
||||||
|
this.unsubscribe = syncCounterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SyncBackendMonitor;
|
||||||
178
src/examples/components/SyncCounterControls.jsx
Normal file
178
src/examples/components/SyncCounterControls.jsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { syncCounterAutomat } from '../automats/syncCounterAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SyncCounterControls — left column.
|
||||||
|
*
|
||||||
|
* Controls the indexed counter. Every mutation optimistically updates the UI
|
||||||
|
* and automatically dispatches an HTTP POST /api/counter in the background.
|
||||||
|
*/
|
||||||
|
class SyncCounterControls extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = syncCounterAutomat.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsubscribe = syncCounterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubscribe) this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleIncrement = (step = 1) => {
|
||||||
|
syncCounterAutomat.actions.increment(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDecrement = (step = 1) => {
|
||||||
|
syncCounterAutomat.actions.decrement(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
syncCounterAutomat.actions.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleIndexChange = (idx) => {
|
||||||
|
syncCounterAutomat.actions.setIndex(idx);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { index, count, syncStatus, lastSyncedAt, error } = this.state;
|
||||||
|
const isSyncing = syncStatus === 'syncing';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">🔄</span>
|
||||||
|
<h3>Auto-Sync Indexed Counter</h3>
|
||||||
|
<span className={`badge ${isSyncing ? 'badge-cascade' : 'badge-local'}`}>
|
||||||
|
{isSyncing ? 'POST syncing…' : 'POST synced'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Index Selector */}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
Select Counter Index:
|
||||||
|
</span>
|
||||||
|
<div className="segmented-control">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className={`seg-btn ${index === i ? 'active' : ''}`}
|
||||||
|
onClick={() => this.handleIndexChange(i)}
|
||||||
|
disabled={isSyncing}
|
||||||
|
>
|
||||||
|
Counter #{i}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Value Display */}
|
||||||
|
<div className="ctrl-current-value" style={{ marginBottom: 14 }}>
|
||||||
|
<span className="ctrl-current-label">Counter #{index} (Local UI Value)</span>
|
||||||
|
<span className="ctrl-current-num">{count}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="counter-controls" style={{ gap: 14, marginBottom: 12 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-circle btn-secondary"
|
||||||
|
onClick={() => this.handleDecrement(1)}
|
||||||
|
aria-label="Decrement"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-circle btn-primary"
|
||||||
|
onClick={() => this.handleIncrement(1)}
|
||||||
|
aria-label="Increment"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={() => this.handleIncrement(5)}
|
||||||
|
>
|
||||||
|
+5 Quick Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={this.handleReset}
|
||||||
|
>
|
||||||
|
Reset to 0
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sync Status Banner */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: 'var(--surface-0)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
fontSize: 12,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{isSyncing ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-sm" />
|
||||||
|
<span style={{ color: 'var(--orange)' }}>POST /api/counter in flight…</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span style={{ color: 'var(--green)' }}>✓</span>
|
||||||
|
<span style={{ color: 'var(--text-2)' }}>Backend in sync</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--text-3)', fontSize: 11 }}>
|
||||||
|
{lastSyncedAt}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="weather-error" style={{ marginBottom: 12 }}>
|
||||||
|
<span>⚠️ {error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Code Snippet */}
|
||||||
|
<div className="code-snippet">
|
||||||
|
<pre>{`// Optimistic local update + automatic background POST sync:
|
||||||
|
async increment(step = 1) {
|
||||||
|
const next = syncCounterAutomat.state.count + step;
|
||||||
|
syncCounterAutomat.setState({ count: next, syncStatus: 'syncing' });
|
||||||
|
|
||||||
|
// Auto-sync via HTTP POST /api/counter:
|
||||||
|
const res = await fetch('/api/counter', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ index, count: next }),
|
||||||
|
});
|
||||||
|
syncCounterAutomat.setState({ syncStatus: 'synced' });
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SyncCounterControls;
|
||||||
169
src/examples/components/UnconnectedPartialSubscriber.jsx
Normal file
169
src/examples/components/UnconnectedPartialSubscriber.jsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { Automat } from '../../lib/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnconnectedPartialSubscriber
|
||||||
|
*
|
||||||
|
* Demonstrates:
|
||||||
|
* 1. UNCONNECTED OBJECT: Absolutely NO import of `cartAutomat.js`!
|
||||||
|
* The component has no module-level dependency on the cart store.
|
||||||
|
* Instead, it discovers and binds to it strictly via the named window registry:
|
||||||
|
* `Automat.get('cart')` (or `window.__AUTOMATS__.get('cart')`).
|
||||||
|
*
|
||||||
|
* 2. PARTIAL SUBSCRIBER:
|
||||||
|
* Subscribes ONLY to a slice: `couponCode` via array selector `['couponCode']`.
|
||||||
|
* - Changes to items, prices, or quantities produce 0 re-renders.
|
||||||
|
* - Changes to couponCode trigger immediate updates.
|
||||||
|
*
|
||||||
|
* 3. UNCONNECTED ACTION DISPATCH:
|
||||||
|
* Calls actions directly on the resolved instance:
|
||||||
|
* `Automat.get('cart')?.actions.setCouponCode(...)` without importing anything.
|
||||||
|
*/
|
||||||
|
export default class UnconnectedPartialSubscriber extends PureComponent {
|
||||||
|
_renderCount = 0;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
// 1. Resolve store solely via named window object (no imports of cartAutomat!)
|
||||||
|
const store = typeof window !== 'undefined' ? (Automat.get('cart') || window.__AUTOMATS__?.get('cart')) : null;
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
// Extract only partial slice: couponCode
|
||||||
|
couponCode: store?.state?.couponCode || '',
|
||||||
|
connected: Boolean(store),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// 2. Discover store from named window object
|
||||||
|
const store = Automat.get('cart') || (typeof window !== 'undefined' && window.__AUTOMATS__?.get('cart'));
|
||||||
|
|
||||||
|
if (store) {
|
||||||
|
// 3. Partial subscription using key array selector: ['couponCode']
|
||||||
|
// Automat performs shallow-equality on { couponCode } and skips setState
|
||||||
|
// when items, quantities, or prices change!
|
||||||
|
this.unsubscribe = store.subscribe(this, ['couponCode']);
|
||||||
|
this.setState({ connected: true, couponCode: store.state?.couponCode || '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubscribe) {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleQuickCoupon = (code) => {
|
||||||
|
// Dispatch action through unconnected named window reference
|
||||||
|
const store = Automat.get('cart') || window.__AUTOMATS__?.get('cart');
|
||||||
|
store?.actions.setCouponCode(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this._renderCount++;
|
||||||
|
const { couponCode, connected } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-display" style={{ borderLeft: '3px solid var(--accent-light)' }}>
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">🔌</span>
|
||||||
|
<h3>Unconnected Partial Subscriber</h3>
|
||||||
|
<span className="badge badge-cascade">Zero Store Imports</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Status & Efficiency stats */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||||
|
Component Renders: <strong style={{ color: 'var(--accent-light)' }}>#{this._renderCount}</strong>
|
||||||
|
</span>
|
||||||
|
<span className={`badge ${connected ? 'badge-success' : 'badge-warning'}`}>
|
||||||
|
{connected ? 'window.__AUTOMATS__.get("cart")' : 'Disconnected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection Architecture Badge */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'rgba(99, 102, 241, 0.08)',
|
||||||
|
border: '1px solid rgba(99, 102, 241, 0.25)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '12px 14px',
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, color: 'var(--accent-light)', marginBottom: 4 }}>
|
||||||
|
🌐 Unconnected Architecture:
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
• <strong>No Store Import:</strong> Does not import <code>cartAutomat</code>. Resolves solely via <code>Automat.get('cart')</code>.<br />
|
||||||
|
• <strong>Partial Selector:</strong> Subscribed strictly via <code>store.subscribe(this, ['couponCode'])</code>.<br />
|
||||||
|
• <strong>Render Guard:</strong> Adding/deleting items or changing quantities triggers <strong>0 re-renders</strong> here.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Observed Partial Value */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
Observed Partial Slice (couponCode):
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(0, 0, 0, 0.3)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 14,
|
||||||
|
color: couponCode ? 'var(--green)' : 'var(--text-3)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{couponCode ? `Active Coupon: "${couponCode}"` : '(No coupon applied)'}</span>
|
||||||
|
{couponCode && (
|
||||||
|
<span className="badge badge-success" style={{ fontSize: 11 }}>
|
||||||
|
Applied
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Dispatch via Unconnected Reference */}
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
Dispatch Action via Named Window Reference:
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => this.handleQuickCoupon('AUTOMAT25')}
|
||||||
|
>
|
||||||
|
Apply "AUTOMAT25"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => this.handleQuickCoupon('FREESHIP')}
|
||||||
|
>
|
||||||
|
Apply "FREESHIP"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => this.handleQuickCoupon('')}
|
||||||
|
>
|
||||||
|
Clear Coupon
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1037
src/index.css
Normal file
1037
src/index.css
Normal file
File diff suppressed because it is too large
Load Diff
431
src/lib/Automat.js
Normal file
431
src/lib/Automat.js
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
/**
|
||||||
|
* @module Automat
|
||||||
|
*
|
||||||
|
* Higher-order state container designed for React PureComponent with optional
|
||||||
|
* persistence (Window or IndexedDB) and backend API binding.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shallow equality check between two values or objects.
|
||||||
|
*/
|
||||||
|
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]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Global Window & Registry Aliases ──────────────────────────────
|
||||||
|
const W = typeof window !== 'undefined' ? window : null;
|
||||||
|
const getGlobalMap = (key) => W && (W[key] ||= new Map());
|
||||||
|
|
||||||
|
// ── Unified IndexedDB Runner ──────────────────────────────────────
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
|
||||||
|
export class Automat {
|
||||||
|
constructor(initialState = null, callbacks = {}, options = {}) {
|
||||||
|
const hasInitial = initialState !== null && initialState !== undefined;
|
||||||
|
this._initial = hasInitial
|
||||||
|
? typeof initialState === 'object'
|
||||||
|
? { ...initialState }
|
||||||
|
: initialState
|
||||||
|
: null;
|
||||||
|
this._state = this._initial ? { ...this._initial } : null;
|
||||||
|
|
||||||
|
this._cbs = callbacks;
|
||||||
|
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._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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal data fetcher for backend API binding.
|
||||||
|
* @param {boolean} [silent=false] If true, preserves isReady without unready flash.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_fetchData(silent = false) {
|
||||||
|
if (!silent) this._isReady = false;
|
||||||
|
this._isDirty = false;
|
||||||
|
this._error = null;
|
||||||
|
|
||||||
|
const promise = this._fetcher(this._url, this)
|
||||||
|
.then((data) => {
|
||||||
|
this._state = data;
|
||||||
|
this._isReady = true;
|
||||||
|
this._isDirty = false;
|
||||||
|
this._error = null;
|
||||||
|
if (this._name) {
|
||||||
|
this._persist
|
||||||
|
? idbRun('put', this._state, this._name)
|
||||||
|
: getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state);
|
||||||
|
}
|
||||||
|
this._notify(this._state);
|
||||||
|
return this._state;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this._error = err;
|
||||||
|
this._isReady = false;
|
||||||
|
this._notify(this._state);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
this._ready = promise;
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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().
|
||||||
|
*/
|
||||||
|
setDirty() {
|
||||||
|
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 (this._dirtySubs) {
|
||||||
|
for (const fn of this._dirtySubs) {
|
||||||
|
try {
|
||||||
|
fn(this);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._subs.size > 0 && this._url) {
|
||||||
|
return this._fetchData();
|
||||||
|
}
|
||||||
|
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 unready, throws the ready promise for React Suspense.
|
||||||
|
*/
|
||||||
|
read() {
|
||||||
|
if (this._url) {
|
||||||
|
if (this._isDirty) {
|
||||||
|
this._fetchData();
|
||||||
|
}
|
||||||
|
if (!this._isReady) {
|
||||||
|
if (this._error) throw this._error;
|
||||||
|
throw this._ready;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this._state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves an Automat instance registered by name.
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
|
static get(name) {
|
||||||
|
return getGlobalMap('__AUTOMATS__')?.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return this._name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get persist() {
|
||||||
|
return this._persist;
|
||||||
|
}
|
||||||
|
|
||||||
|
get url() {
|
||||||
|
return this._url;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isReady() {
|
||||||
|
return this._isReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isDirty() {
|
||||||
|
return this._isDirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
get error() {
|
||||||
|
return this._error;
|
||||||
|
}
|
||||||
|
|
||||||
|
get subscriberCount() {
|
||||||
|
return this._subs.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
get ready() {
|
||||||
|
if (this._url && this._isDirty) {
|
||||||
|
return this._fetchData();
|
||||||
|
}
|
||||||
|
return this._ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearPersistence() {
|
||||||
|
if (!this._name) return;
|
||||||
|
this._persist
|
||||||
|
? await idbRun('delete', this._name)
|
||||||
|
: getGlobalMap('__AUTOMAT_STATE__')?.delete(this._name);
|
||||||
|
}
|
||||||
|
|
||||||
|
get state() {
|
||||||
|
return this._state;
|
||||||
|
}
|
||||||
|
|
||||||
|
getState() {
|
||||||
|
return this._state;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(partial) {
|
||||||
|
this._state = { ...this._state, ...partial };
|
||||||
|
if (this._name) {
|
||||||
|
this._persist
|
||||||
|
? idbRun('put', this._state, this._name)
|
||||||
|
: getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state);
|
||||||
|
}
|
||||||
|
this._notify(partial);
|
||||||
|
return this._state;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getSlice = selector ? resolveSelector(selector) : 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 (shallowEqual(lastSlice, nextSlice)) return;
|
||||||
|
lastSlice = nextSlice;
|
||||||
|
|
||||||
|
if (isComponent) {
|
||||||
|
if (nextSlice !== null && typeof nextSlice !== 'object') {
|
||||||
|
throw new TypeError('Automat: selector must return an object.');
|
||||||
|
}
|
||||||
|
nextSlice ? target.setState(nextSlice) : target.forceUpdate();
|
||||||
|
} else {
|
||||||
|
target(nextSlice);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isComponent) {
|
||||||
|
const update = partial ?? state;
|
||||||
|
update && typeof update === 'object'
|
||||||
|
? target.setState(update)
|
||||||
|
: target.forceUpdate();
|
||||||
|
} else {
|
||||||
|
target(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._subs.set(target, notifyFn);
|
||||||
|
|
||||||
|
if (this._url && this._isDirty) {
|
||||||
|
this._fetchData();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
this.unsubscribe(target);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
select(selector) {
|
||||||
|
const slice = resolveSelector(selector);
|
||||||
|
const self = this;
|
||||||
|
return {
|
||||||
|
get state() {
|
||||||
|
return slice(self._state);
|
||||||
|
},
|
||||||
|
subscribe: (target) => self.subscribe(target, selector),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe(target) {
|
||||||
|
this._subs.delete(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a listener called whenever this automat becomes dirty.
|
||||||
|
* @param {(automat: Automat) => void} fn
|
||||||
|
* @returns {() => void} Unsubscribe function
|
||||||
|
*/
|
||||||
|
onDirty(fn) {
|
||||||
|
this._dirtySubs.add(fn);
|
||||||
|
return () => this._dirtySubs.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cascading invalidation: automatically sets this automat dirty whenever
|
||||||
|
* upstreamAutomat is set dirty. If filterFn is provided, only invalidates when it returns true.
|
||||||
|
*
|
||||||
|
* @param {Automat} upstreamAutomat
|
||||||
|
* @param {(upstreamState: any, myState: any) => boolean} [filterFn]
|
||||||
|
* @returns {this}
|
||||||
|
*/
|
||||||
|
invalidateWith(upstreamAutomat, filterFn) {
|
||||||
|
const unsub = upstreamAutomat.onDirty((upstream) => {
|
||||||
|
if (!filterFn || filterFn(upstream.state, this._state)) {
|
||||||
|
this.setDirty();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._unsubs.push(unsub);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects this automat to an upstream automat.
|
||||||
|
* Whenever upstream emits new state, transform(upstreamState, myState) runs.
|
||||||
|
*
|
||||||
|
* @param {Automat} upstreamAutomat
|
||||||
|
* @param {(upstreamState: any, myState: any) => any} transform
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {boolean | ((upstreamState: any, myState: any) => boolean)} [options.cascadeDirty]
|
||||||
|
* @returns {this}
|
||||||
|
*/
|
||||||
|
subscribeTo(upstreamAutomat, transform, options = {}) {
|
||||||
|
const unsub = upstreamAutomat.subscribe((upstreamState) => {
|
||||||
|
const partial = transform(upstreamState, this._state);
|
||||||
|
if (partial != null) {
|
||||||
|
this.setState(partial);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._unsubs.push(unsub);
|
||||||
|
|
||||||
|
if (options?.cascadeDirty) {
|
||||||
|
this.invalidateWith(
|
||||||
|
upstreamAutomat,
|
||||||
|
typeof options.cascadeDirty === 'function' ? options.cascadeDirty : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
get actions() {
|
||||||
|
return this._cbs;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (typeof this._state?.revoke === 'function') {
|
||||||
|
try {
|
||||||
|
this._state.revoke();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
this._unsubs.forEach((fn) => fn());
|
||||||
|
this._unsubs = [];
|
||||||
|
this._subs.clear();
|
||||||
|
this._dirtySubs?.clear();
|
||||||
|
if (this._name) {
|
||||||
|
getGlobalMap('__AUTOMATS__')?.delete(this._name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_notify(partial) {
|
||||||
|
for (const notifyFn of this._subs.values()) {
|
||||||
|
notifyFn(this._state, partial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
src/lib/fetchers.js
Normal file
148
src/lib/fetchers.js
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Built-in Fetchers & SSE Connector for Automat.
|
||||||
|
*
|
||||||
|
* Provides specialized fetchers for JSON, Image Blobs, PDF Blobs,
|
||||||
|
* and Server-Sent Events (SSE) with automatic trigger action dispatch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard JSON fetcher.
|
||||||
|
* @param {string} url
|
||||||
|
* @param {RequestInit} [options]
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
export const fetchJson = (url, options = {}) =>
|
||||||
|
fetch(url, options).then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binary Blob fetcher (for images, PDFs, etc.).
|
||||||
|
* Creates a local object URL and provides a memory-freeing revoke() method.
|
||||||
|
* Automatically invoked when an Automat is setDirty() or disposed.
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @param {RequestInit} [options]
|
||||||
|
* @returns {Promise<{ blob: Blob, url: string, size: number, type: string, loadedAt: string, revoke: () => void }>}
|
||||||
|
*/
|
||||||
|
export const fetchBlob = (url, options = {}) =>
|
||||||
|
fetch(url, options).then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
return {
|
||||||
|
blob,
|
||||||
|
url: objectUrl,
|
||||||
|
size: blob.size,
|
||||||
|
type: blob.type,
|
||||||
|
loadedAt: new Date().toLocaleTimeString(),
|
||||||
|
revoke: () => {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchImageBlob = fetchBlob;
|
||||||
|
export const fetchPdfBlob = fetchBlob;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects an Automat to a Server-Sent Events (SSE) stream.
|
||||||
|
* Automatically forwards events, handles invalidation, and dispatches to
|
||||||
|
* the automat's internal `actions.onSSETrigger` or `onSSETrigger` method.
|
||||||
|
*
|
||||||
|
* @param {import('./Automat.js').Automat} automat - Target automat instance
|
||||||
|
* @param {string} url - SSE endpoint URL
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {(data: any, event: string, automat: any) => void} [options.onMessage]
|
||||||
|
* @param {Record<string, (data: any, automat: any) => void>} [options.events]
|
||||||
|
* @param {(err: any, automat: any) => void} [options.onError]
|
||||||
|
* @returns {{ eventSource: EventSource | null, close: () => void }}
|
||||||
|
*/
|
||||||
|
export const connectSSE = (automat, url, options = {}) => {
|
||||||
|
if (typeof window === 'undefined' || typeof EventSource === 'undefined') {
|
||||||
|
return { eventSource: null, close() {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const es = new EventSource(url);
|
||||||
|
|
||||||
|
const handleData = (eventName, rawData) => {
|
||||||
|
let data = rawData;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(rawData);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 1. Explicit event callback in options.events:
|
||||||
|
if (options.events?.[eventName]) {
|
||||||
|
options.events[eventName](data, automat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. User-provided general message listener:
|
||||||
|
if (options.onMessage) {
|
||||||
|
options.onMessage(data, eventName, automat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Call internal SSE trigger function on the automat (actions or direct method):
|
||||||
|
if (typeof automat.actions?.onSSETrigger === 'function') {
|
||||||
|
automat.actions.onSSETrigger(data, eventName, automat);
|
||||||
|
}
|
||||||
|
if (typeof automat.onSSETrigger === 'function') {
|
||||||
|
automat.onSSETrigger(data, eventName, automat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Standard SSE conventions:
|
||||||
|
if (eventName === 'invalidate' || eventName === 'dirty' || data?.action === 'invalidate') {
|
||||||
|
automat.setDirty();
|
||||||
|
} else if (eventName === 'reload' || data?.action === 'reload') {
|
||||||
|
automat.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onmessage = (e) => handleData('message', e.data);
|
||||||
|
|
||||||
|
const events = ['invalidate', 'reload', ...Object.keys(options.events || {})];
|
||||||
|
events.forEach((name) => {
|
||||||
|
es.addEventListener(name, (e) => handleData(name, e.data));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.onError) {
|
||||||
|
es.onerror = (err) => options.onError(err, automat);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
try {
|
||||||
|
es.close();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (automat._unsubs) {
|
||||||
|
automat._unsubs.push(cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventSource: es,
|
||||||
|
close: cleanup,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an SSE-enabled fetcher that automatically establishes an SSE connection
|
||||||
|
* on initial load and delegates data fetching to baseFetcher.
|
||||||
|
*
|
||||||
|
* @param {string} sseUrl
|
||||||
|
* @param {Function} [baseFetcher=fetchJson]
|
||||||
|
* @param {object} [sseOptions={}]
|
||||||
|
* @returns {(url: string, automat?: any) => Promise<any>}
|
||||||
|
*/
|
||||||
|
export const createSSEFetcher = (sseUrl, baseFetcher = fetchJson, sseOptions = {}) => {
|
||||||
|
let connected = false;
|
||||||
|
return (url, automat) => {
|
||||||
|
if (!connected && automat) {
|
||||||
|
connectSSE(automat, sseUrl, sseOptions);
|
||||||
|
connected = true;
|
||||||
|
}
|
||||||
|
return baseFetcher(url);
|
||||||
|
};
|
||||||
|
};
|
||||||
98
src/lib/index.d.ts
vendored
Normal file
98
src/lib/index.d.ts
vendored
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Selector<T, S> =
|
||||||
|
| keyof T
|
||||||
|
| (keyof T)[]
|
||||||
|
| ((state: T) => S | null | undefined);
|
||||||
|
|
||||||
|
export interface ScopedSlice<S> {
|
||||||
|
readonly state: S;
|
||||||
|
subscribe(target: PureComponent | ((slice: S) => void)): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlobResource {
|
||||||
|
blob: Blob;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
type: string;
|
||||||
|
loadedAt: string;
|
||||||
|
revoke: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSEOptions<T = any, A = any> {
|
||||||
|
onMessage?: (data: any, event: string, automat: Automat<T, A>) => void;
|
||||||
|
events?: Record<string, (data: any, automat: Automat<T, A>) => void>;
|
||||||
|
onError?: (err: any, automat: Automat<T, A>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Automat<T = any, A = Record<string, Function>> {
|
||||||
|
constructor(initialState?: T | null, actions?: 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(
|
||||||
|
target: PureComponent | ((state: T, partial?: Partial<T>) => void),
|
||||||
|
selector?: Selector<T, any>
|
||||||
|
): () => void;
|
||||||
|
|
||||||
|
select<S = any>(selector: Selector<T, S>): ScopedSlice<S>;
|
||||||
|
unsubscribe(target: any): void;
|
||||||
|
|
||||||
|
subscribeTo<U = any>(
|
||||||
|
upstreamAutomat: Automat<U, any>,
|
||||||
|
transform: (upstreamState: U, currentState: T) => Partial<T> | null | undefined,
|
||||||
|
options?: { cascadeDirty?: boolean | ((upstream: U, current: T) => boolean) }
|
||||||
|
): this;
|
||||||
|
|
||||||
|
invalidateWith<U = any>(
|
||||||
|
upstreamAutomat: Automat<U, any>,
|
||||||
|
filterFn?: (upstreamState: U, currentState: T) => boolean
|
||||||
|
): this;
|
||||||
|
|
||||||
|
onDirty(fn: (automat: this) => void): () => void;
|
||||||
|
|
||||||
|
setDirty(): Promise<T>;
|
||||||
|
reload(): Promise<T>;
|
||||||
|
refresh(): Promise<T>;
|
||||||
|
read(): T;
|
||||||
|
|
||||||
|
clearPersistence(): Promise<void>;
|
||||||
|
dispose(): void;
|
||||||
|
|
||||||
|
static get<T = any, A = any>(name: string): Automat<T, A> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchJson<T = any>(url: string, options?: RequestInit): Promise<T>;
|
||||||
|
export function fetchBlob(url: string, options?: RequestInit): Promise<BlobResource>;
|
||||||
|
export const fetchImageBlob: typeof fetchBlob;
|
||||||
|
export const fetchPdfBlob: typeof fetchBlob;
|
||||||
|
|
||||||
|
export function connectSSE<T = any, A = any>(
|
||||||
|
automat: Automat<T, A>,
|
||||||
|
url: string,
|
||||||
|
options?: SSEOptions<T, A>
|
||||||
|
): { eventSource: EventSource | null; close: () => void };
|
||||||
|
|
||||||
|
export function createSSEFetcher(
|
||||||
|
sseUrl: string,
|
||||||
|
baseFetcher?: (url: string, automat?: any) => Promise<any>,
|
||||||
|
sseOptions?: SSEOptions
|
||||||
|
): (url: string, automat?: any) => Promise<any>;
|
||||||
43
src/lib/index.js
Normal file
43
src/lib/index.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* @module react-automat
|
||||||
|
*
|
||||||
|
* Higher-order state container for React PureComponents with optional persistence.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* import { Automat } from './lib/index.js';
|
||||||
|
* import { PureComponent } from 'react';
|
||||||
|
*
|
||||||
|
* // Create an automat with optional persistence:
|
||||||
|
* // persist: false (default) = window object; persist: true = IndexedDB
|
||||||
|
* const counterAutomat = new Automat(
|
||||||
|
* { count: 0 },
|
||||||
|
* {
|
||||||
|
* increment() { counterAutomat.setState({ count: counterAutomat.state.count + 1 }); },
|
||||||
|
* },
|
||||||
|
* { name: 'counter', persist: false }
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* class Display extends PureComponent {
|
||||||
|
* constructor(props) {
|
||||||
|
* super(props);
|
||||||
|
* this.state = counterAutomat.state;
|
||||||
|
* }
|
||||||
|
* componentDidMount() {
|
||||||
|
* this.unsubscribe = counterAutomat.subscribe(this);
|
||||||
|
* }
|
||||||
|
* componentWillUnmount() {
|
||||||
|
* this.unsubscribe();
|
||||||
|
* }
|
||||||
|
* render() {
|
||||||
|
* return <span>{this.state.count}</span>;
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export { Automat } from './Automat.js';
|
||||||
|
export {
|
||||||
|
fetchJson,
|
||||||
|
fetchImageBlob,
|
||||||
|
fetchPdfBlob,
|
||||||
|
connectSSE,
|
||||||
|
createSSEFetcher,
|
||||||
|
} from './fetchers.js';
|
||||||
33
src/main.jsx
Normal file
33
src/main.jsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Ensure scroll-blocking events (wheel, touchstart, touchmove) default to passive: true
|
||||||
|
// Eliminates Chrome "[Violation] Added non-passive event listener to a scroll-blocking 'wheel' event"
|
||||||
|
if (typeof window !== 'undefined' && typeof EventTarget !== 'undefined') {
|
||||||
|
const originalAddEventListener = EventTarget.prototype.addEventListener;
|
||||||
|
const scrollEvents = new Set(['wheel', 'mousewheel', 'touchstart', 'touchmove']);
|
||||||
|
|
||||||
|
EventTarget.prototype.addEventListener = function (type, listener, options) {
|
||||||
|
let opts = options;
|
||||||
|
if (scrollEvents.has(type)) {
|
||||||
|
if (typeof options === 'boolean') {
|
||||||
|
opts = { capture: options, passive: true };
|
||||||
|
} else if (typeof options === 'object' && options !== null) {
|
||||||
|
if (options.passive === undefined) {
|
||||||
|
opts = { ...options, passive: true };
|
||||||
|
}
|
||||||
|
} else if (options === undefined) {
|
||||||
|
opts = { passive: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalAddEventListener.call(this, type, listener, opts);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App.jsx';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
258
vite.config.js
Normal file
258
vite.config.js
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embedded in-memory backend plugin for Vite dev server.
|
||||||
|
* Handles POST /api/counter and GET /api/counter to support automatic
|
||||||
|
* backend synchronization for the indexed counter demo without external APIs.
|
||||||
|
*/
|
||||||
|
function counterBackendPlugin() {
|
||||||
|
const serverStore = new Map([
|
||||||
|
[0, { count: 0, updatedAt: 'Initial' }],
|
||||||
|
[1, { count: 5, updatedAt: 'Initial' }],
|
||||||
|
[2, { count: 10, updatedAt: 'Initial' }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'counter-backend-plugin',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use('/api/counter', (req, res, next) => {
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body || '{}');
|
||||||
|
const index = Number(data.index) || 0;
|
||||||
|
const count = Number(data.count) || 0;
|
||||||
|
const updatedAt = new Date().toLocaleTimeString();
|
||||||
|
|
||||||
|
serverStore.set(index, { count, updatedAt });
|
||||||
|
|
||||||
|
// 180ms simulated server latency to clearly illustrate syncing status
|
||||||
|
setTimeout(() => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
index,
|
||||||
|
count,
|
||||||
|
savedAt: updatedAt,
|
||||||
|
allRecords: Array.from(serverStore.entries()).map(([idx, val]) => ({
|
||||||
|
index: idx,
|
||||||
|
...val,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, 180);
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
const url = new URL(req.url, 'http://localhost');
|
||||||
|
const indexParam = url.searchParams.get('index');
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
if (indexParam !== null) {
|
||||||
|
const index = Number(indexParam);
|
||||||
|
const item = serverStore.get(index) || { count: 0, updatedAt: 'Unsynced' };
|
||||||
|
return res.end(JSON.stringify({ index, ...item }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
allRecords: Array.from(serverStore.entries()).map(([idx, val]) => ({
|
||||||
|
index: idx,
|
||||||
|
...val,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
let profileFetchCount = 0;
|
||||||
|
server.middlewares.use('/api/profile', (req, res, next) => {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
profileFetchCount++;
|
||||||
|
// 350ms simulated latency to illustrate unready state and Suspense fallback
|
||||||
|
setTimeout(() => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 101,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
role: 'Staff Platform Engineer',
|
||||||
|
department: 'Distributed Systems',
|
||||||
|
fetchCount: profileFetchCount,
|
||||||
|
loadedAt: new Date().toLocaleTimeString(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, 350);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Photos Collection & Blob Endpoints ──────────────────────
|
||||||
|
server.middlewares.use('/api/photos', (req, res, next) => {
|
||||||
|
const url = new URL(req.url, 'http://localhost');
|
||||||
|
const match = url.pathname.match(/^\/(\d+)$/);
|
||||||
|
|
||||||
|
// Individual Photo SVG/Image Blob: /api/photos/:id
|
||||||
|
if (match) {
|
||||||
|
const id = Number(match[1]);
|
||||||
|
const colors = [
|
||||||
|
['#6366f1', '#4338ca', 'Alps Panorama'],
|
||||||
|
['#ec4899', '#be185d', 'Tokyo Neon Night'],
|
||||||
|
['#10b981', '#047857', 'Emerald Rainforest'],
|
||||||
|
['#f59e0b', '#b45309', 'Golden Dunes'],
|
||||||
|
];
|
||||||
|
const [c1, c2, title] = colors[(id - 1) % colors.length];
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 400" width="600" height="400">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${c1}" />
|
||||||
|
<stop offset="100%" stop-color="${c2}" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="600" height="400" fill="url(#g)" rx="16"/>
|
||||||
|
<circle cx="300" cy="180" r="80" fill="rgba(255,255,255,0.15)" />
|
||||||
|
<path d="M 120 340 L 260 210 L 360 290 L 480 160 L 580 340 Z" fill="rgba(255,255,255,0.22)" />
|
||||||
|
<text x="300" y="360" font-size="24" font-weight="bold" fill="#ffffff" text-anchor="middle" font-family="system-ui, sans-serif">
|
||||||
|
Photo #${id}: ${title}
|
||||||
|
</text>
|
||||||
|
</svg>`;
|
||||||
|
setTimeout(() => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'image/svg+xml',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(svg);
|
||||||
|
}, 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photos collection metadata
|
||||||
|
if (req.method === 'GET' && (url.pathname === '' || url.pathname === '/')) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
return res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
count: 3,
|
||||||
|
updatedAt: new Date().toLocaleTimeString(),
|
||||||
|
photos: [
|
||||||
|
{ id: 1, title: 'Alps Panorama', url: '/api/photos/1', memoryEst: '2.4 MB' },
|
||||||
|
{ id: 2, title: 'Tokyo Neon Night', url: '/api/photos/2', memoryEst: '3.1 MB' },
|
||||||
|
{ id: 3, title: 'Emerald Rainforest', url: '/api/photos/3', memoryEst: '1.9 MB' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── PDF Blob Endpoint ───────────────────────────────────────
|
||||||
|
server.middlewares.use('/api/docs/spec', (req, res, next) => {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
// Minimal valid PDF binary
|
||||||
|
const minimalPdf = `%PDF-1.4
|
||||||
|
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||||
|
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||||
|
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
|
||||||
|
4 0 obj << /Length 55 >> stream
|
||||||
|
BT /F1 24 Tf 100 700 Td (Automat PDF Stream Loaded) Tj ET
|
||||||
|
endstream endobj
|
||||||
|
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
|
||||||
|
xref
|
||||||
|
0 6
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000010 00000 n
|
||||||
|
0000000060 00000 n
|
||||||
|
0000000117 00000 n
|
||||||
|
0000000227 00000 n
|
||||||
|
0000000334 00000 n
|
||||||
|
trailer << /Root 1 0 R /Size 6 >>
|
||||||
|
startxref
|
||||||
|
407
|
||||||
|
%%EOF`;
|
||||||
|
setTimeout(() => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': 'inline; filename="automat-spec.pdf"',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(minimalPdf);
|
||||||
|
}, 250);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Server-Sent Events (SSE) Stream ─────────────────────────
|
||||||
|
const sseClients = new Set();
|
||||||
|
|
||||||
|
server.middlewares.use('/api/events/sse', (req, res, next) => {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.write(`event: connected\ndata: ${JSON.stringify({ time: new Date().toLocaleTimeString() })}\n\n`);
|
||||||
|
sseClients.add(res);
|
||||||
|
|
||||||
|
const keepAlive = setInterval(() => {
|
||||||
|
res.write(`: heartbeat\n\n`);
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
clearInterval(keepAlive);
|
||||||
|
sseClients.delete(res);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Endpoint to broadcast SSE events from the demo UI:
|
||||||
|
server.middlewares.use('/api/events/trigger', (req, res, next) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(body || '{}');
|
||||||
|
const eventName = payload.event || 'invalidate';
|
||||||
|
const data = payload.data || { reason: 'manual trigger', timestamp: Date.now() };
|
||||||
|
|
||||||
|
const ssePayload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
|
sseClients.forEach((client) => {
|
||||||
|
client.write(ssePayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, clients: sseClients.size, event: eventName }));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), counterBackendPlugin()],
|
||||||
|
});
|
||||||
24
vite.config.lib.js
Normal file
24
vite.config.lib.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { resolve } from 'path';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: false,
|
||||||
|
lib: {
|
||||||
|
entry: resolve(import.meta.dirname, 'src/lib/index.js'),
|
||||||
|
name: 'ReactAutomat',
|
||||||
|
fileName: (format) => `react-automat.${format}.js`,
|
||||||
|
formats: ['es', 'umd'],
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['react', 'react-dom'],
|
||||||
|
output: {
|
||||||
|
globals: {
|
||||||
|
react: 'React',
|
||||||
|
'react-dom': 'ReactDOM',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user