This commit is contained in:
seb
2026-09-19 04:25:01 +02:00
commit 99415ce835
35 changed files with 6807 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/node_modules
/dist

14
LICENSE Normal file
View 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
View 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.
[![Live Interactive Demo](https://img.shields.io/badge/Live%20Demo-sebgreen.net%2Fautomat-6366f1?style=flat-square)](https://sebgreen.net/automat/)
[![License: 0BSD](https://img.shields.io/badge/License-0BSD-blue.svg?style=flat-square)](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
View 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
View 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
View 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
View 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">&amp;</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) &amp; <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 &amp; 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(&apos;cart&apos;)</code>), and subscribes only to <code className="inline-code">[&apos;couponCode&apos;]</code>.
</p>
<div className="flow-diagram" aria-label="Partial subscription data flow">
<span className="flow-node">cartAutomat.setState()</span>
<span className="flow-arrow">→</span>
<span className="flow-node">window.__AUTOMATS__.get(&apos;cart&apos;)</span>
<span className="flow-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 &amp; Dirty Invalidation</h2>
</div>
<p>
An automat can bind directly to a backend URL (<code className="inline-code">url: &apos;/api/profile&apos;</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, &#123;&#125;, &#123; url &#125;)</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 &amp; 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() &amp; 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;

View 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 }
);

View 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;

View 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;
}

View 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;

View 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);

View 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',
}
);

View 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,
});
},
}
);

View 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: &quot;/api/profile&quot;</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 &amp; 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>
);
}
}

View 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;

View 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;

View 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;

View 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 &amp; 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 &quot;photos/all&quot; 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 &amp; 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 &quot;Mount photo/3&quot; 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>
);
}
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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(&apos;cart&apos;)</code>.<br />
• <strong>Partial Selector:</strong> Subscribed strictly via <code>store.subscribe(this, [&apos;couponCode&apos;])</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 &quot;AUTOMAT25&quot;
</button>
<button
type="button"
className="btn btn-sm btn-ghost"
onClick={() => this.handleQuickCoupon('FREESHIP')}
>
Apply &quot;FREESHIP&quot;
</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

File diff suppressed because it is too large Load Diff

431
src/lib/Automat.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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',
},
},
},
},
});