From 99415ce83533fa73fa239dbb66a516cfc7b47313 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 19 Sep 2026 04:25:01 +0200 Subject: [PATCH] Genesis --- .gitignore | 2 + LICENSE | 14 + README.md | 172 +++ index.html | 22 + package-lock.json | 900 ++++++++++++++ package.json | 68 ++ src/App.jsx | 307 +++++ src/examples/automats/cartAutomat.js | 78 ++ src/examples/automats/counterAutomat.js | 31 + src/examples/automats/indexAutomat.js | 78 ++ src/examples/automats/notificationAutomat.js | 52 + src/examples/automats/photosAutomat.js | 73 ++ src/examples/automats/profileAutomat.js | 27 + src/examples/automats/syncCounterAutomat.js | 166 +++ src/examples/components/ApiBoundExample.jsx | 439 +++++++ src/examples/components/CartBadgeDisplay.jsx | 200 ++++ src/examples/components/CartManager.jsx | 227 ++++ src/examples/components/CascadeControls.jsx | 163 +++ .../components/CascadingPhotosExample.jsx | 428 +++++++ src/examples/components/CounterButton.jsx | 182 +++ src/examples/components/CounterDisplay.jsx | 172 +++ .../components/DynamicAutomatSubscriber.jsx | 199 ++++ src/examples/components/IndexSelector.jsx | 118 ++ src/examples/components/NotificationBar.jsx | 155 +++ .../components/SyncBackendMonitor.jsx | 115 ++ .../components/SyncCounterControls.jsx | 178 +++ .../UnconnectedPartialSubscriber.jsx | 169 +++ src/index.css | 1037 +++++++++++++++++ src/lib/Automat.js | 431 +++++++ src/lib/fetchers.js | 148 +++ src/lib/index.d.ts | 98 ++ src/lib/index.js | 43 + src/main.jsx | 33 + vite.config.js | 258 ++++ vite.config.lib.js | 24 + 35 files changed, 6807 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/App.jsx create mode 100644 src/examples/automats/cartAutomat.js create mode 100644 src/examples/automats/counterAutomat.js create mode 100644 src/examples/automats/indexAutomat.js create mode 100644 src/examples/automats/notificationAutomat.js create mode 100644 src/examples/automats/photosAutomat.js create mode 100644 src/examples/automats/profileAutomat.js create mode 100644 src/examples/automats/syncCounterAutomat.js create mode 100644 src/examples/components/ApiBoundExample.jsx create mode 100644 src/examples/components/CartBadgeDisplay.jsx create mode 100644 src/examples/components/CartManager.jsx create mode 100644 src/examples/components/CascadeControls.jsx create mode 100644 src/examples/components/CascadingPhotosExample.jsx create mode 100644 src/examples/components/CounterButton.jsx create mode 100644 src/examples/components/CounterDisplay.jsx create mode 100644 src/examples/components/DynamicAutomatSubscriber.jsx create mode 100644 src/examples/components/IndexSelector.jsx create mode 100644 src/examples/components/NotificationBar.jsx create mode 100644 src/examples/components/SyncBackendMonitor.jsx create mode 100644 src/examples/components/SyncCounterControls.jsx create mode 100644 src/examples/components/UnconnectedPartialSubscriber.jsx create mode 100644 src/index.css create mode 100644 src/lib/Automat.js create mode 100644 src/lib/fetchers.js create mode 100644 src/lib/index.d.ts create mode 100644 src/lib/index.js create mode 100644 src/main.jsx create mode 100644 vite.config.js create mode 100644 vite.config.lib.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2d59d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/node_modules +/dist \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..af2fab3 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6301aa6 --- /dev/null +++ b/README.md @@ -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 { + 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) => Promise; +} + +export type Selector = keyof T | (keyof T)[] | ((state: T) => S | null | undefined); + +export interface ScopedSlice { + 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 { + onMessage?: (data: any, event: string, automat: Automat) => void; + events?: Record) => void>; + onError?: (err: any, automat: Automat) => void; +} + +export class Automat> { + constructor(initialState?: T | null, actions?: A, options?: AutomatOptions); + + readonly state: T; + getState(): T; + setState(partial: Partial | ((prev: T) => Partial)): T; + readonly actions: A; + + readonly isReady: boolean; + readonly isDirty: boolean; + readonly ready: Promise; // 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) => void), selector?: Selector): () => void; + select(selector: Selector): ScopedSlice; + unsubscribe(target: any): void; + + subscribeTo( + upstream: Automat, + transform: (upstreamState: U, currentState: T) => Partial | null | undefined, + options?: { cascadeDirty?: boolean | ((upstream: U, current: T) => boolean) } + ): this; + + invalidateWith(upstream: Automat, filterFn?: (upstream: U, current: T) => boolean): this; + onDirty(fn: (automat: this) => void): () => void; + + setDirty(): Promise; // Resets state to null/default, revokes blobs, cascades, defers if unmounted + reload(): Promise; // Silent background re-fetch; preserves state & isReady=true + refresh(): Promise; // Alias for reload() + read(): T; // For Suspense: throws ready promise if unready; triggers deferred load if dirty + + clearPersistence(): Promise; + dispose(): void; // Unsubs all, revokes blobs, deletes from window registry + + static get(name: string): Automat | undefined; +} + +// Fetchers & SSE +export function fetchJson(url: string, options?: RequestInit): Promise; +export function fetchBlob(url: string, options?: RequestInit): Promise; +export const fetchImageBlob: typeof fetchBlob; +export const fetchPdfBlob: typeof fetchBlob; +export function connectSSE(automat: Automat, url: string, options?: SSEOptions): { eventSource: EventSource | null; close: () => void }; +export function createSSEFetcher(sseUrl: string, baseFetcher?: Function, sseOptions?: SSEOptions): (url: string, automat?: any) => Promise; +``` + +## 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 ; } +} +``` + +### 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 = () =>
{user.read().name}
; + +// 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 ? :
{this.state.u.name}
; } +} +``` + +### 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. diff --git a/index.html b/index.html new file mode 100644 index 0000000..1f0ffa2 --- /dev/null +++ b/index.html @@ -0,0 +1,22 @@ + + + + + + react-automat — React PureComponent State Manager + + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0d99ec0 --- /dev/null +++ b/package-lock.json @@ -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 + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7af3432 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..dbf694f --- /dev/null +++ b/src/App.jsx @@ -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 ( +
+ {/* ── Header ─────────────────────────────────────────────── */} +
+
+ ⚙ + react-automat + v0.1.0 +
+

+ Higher-order state container for React{' '} + PureComponent +
+ State lives independently of mounting — zero prop drilling · Optional Window and IndexedDB persistence. +

+
+ +
+ {/* ── Example 1: Counter ────────────────────────────────── */} +
+
+
+ 01 +

Counter

+
+

+ Two independent PureComponents + synchronized by a single counterAutomat. + Neither knows the other exists — they both subscribe to the same state container. +

+
+ counterAutomat + → + CounterButton + & + CounterDisplay +
+
+ +
+ + +
+
+ +
+ + {/* ── Example 2: Cascade ───────────────────────────────── */} +
+
+
+ 02 +

Notification Cascade

+
+

+ subscribeTo() connects two automats into a + reactive pipeline. Clicking triggers in CascadeControls mutates{' '} + counterAutomat (upstream). This automatically invokes the{' '} + subscribeTo() transform callback, updating{' '} + notificationAutomat (downstream), which then re-renders{' '} + NotificationBar without any prop drilling or shared component parent. +

+
+ User Click + → + counterAutomat.actions.increment() + → + subscribeTo(upstream, transform) + → + notificationAutomat.setState() + → + NotificationBar +
+ +
+
+ ℹ️ How subscribeTo(upstreamAutomat, transform) Works +
+
    +
  • + upstreamState: The newly emitted state from the upstream automat (e.g. {`{ count: 1 }`}). +
  • +
  • + myState: The current state of this downstream automat right before updating (e.g. {`{ messages: [...] }`}). Acts as an accumulator to prepend events and cap history. +
  • +
  • + Return Value: The returned partial state object is automatically applied via this.setState(partial), notifying downstream UI subscribers. +
  • +
  • + Conditional Filtering: Return null or undefined to selectively skip updates and avoid triggering renders. +
  • +
+
+
+ +
+ + +
+
+ +
+ + {/* ── Example 3: API Auto-Sync ─────────────────────────── */} +
+
+
+ 03 +

API-Backed Auto-Sync Counter (POST)

+
+

+ syncCounterAutomat manages an indexed counter that automatically + synchronizes its mutations to the backend via HTTP POST /api/counter. + State updates are applied optimistically for instant UI response, followed by asynchronous + background synchronization and status tracking without any component-level fetch or lifecycle glue. +

+
+ User Click + → + Optimistic setState() + → + POST /api/counter + → + Backend Database Updated + → + Sync Status 200 OK +
+
+ +
+ + +
+
+ +
+ + {/* ── Example 4: Dynamic Map Resubscription ──────────── */} +
+
+
+ 04 +

Dynamic Window Map (Index-Driven Resubscription)

+
+

+ The left side is a standard indexAutomat counter (like Example 1). + On the right side, that counter’s value is used to dynamically lookup, instantiate, and resubscribe to + a different Automat stored in window.automats = new Map(). + Stepping through indexes unhooks from the old Automat, subscribes to the new one, and instantly recalls its preserved state. +

+
+ Shared Index: N + → + window.automats.get(N) + → + Unsub Old / Sub New + → + Recall State for Slot #N +
+ +
+
+ 💾 Built-in Persistence: persist: false (Window) & persist: true (IndexedDB) +
+
    +
  • + Window Object (persist: false): While any Automat is unmount-safe by default, setting a name registers it in the window object. State persists across module re-evaluations and HMR, and enables cross-module lookup via Automat.get(name). +
  • +
  • + IndexedDB (persist: true): Setting persist: true automatically synchronizes state snapshots to IndexedDB under the automat's name, surviving full page reloads and browser restarts. +
  • +
+
+
+ +
+ + +
+
+ +
+ + {/* ── Example 5: Partial State Subscription ────────────── */} +
+
+
+ 05 +

Partial State Subscription & Unconnected Objects

+
+

+ A subscriber can subscribe to only part of an automat’s state. On the right, two different partial subscribers demonstrate this power: +
+ 1. CartBadgeDisplay subscribes only to list length (items.length) — item quantity and coupon edits cause zero re-renders. +
+ 2. UnconnectedPartialSubscriber has zero imports of the cart store, resolving it strictly via the named window object (Automat.get('cart')), and subscribes only to ['couponCode']. +

+
+ cartAutomat.setState() + → + window.__AUTOMATS__.get('cart') + → + shallowEqual(lastSlice, nextSlice) + → + Render ONLY if Selected Slice Changed +
+
+ +
+ +
+ + +
+
+
+ +
+ + {/* ── Example 6: Backend API Binding & Suspense ─────── */} +
+
+
+ 06 +

Backend API Binding, React Suspense & Dirty Invalidation

+
+

+ An automat can bind directly to a backend URL (url: '/api/profile'). + Supports await automat.ready, React Suspense via automat.read(), + and manual invalidation via automat.setDirty(). + 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 read() is invoked! +

+
+ new Automat(null, {}, { url }) + → + await ready / read() (Suspense) + → + setDirty() + → + Immediate if Mounted / Deferred if Unmounted +
+
+ + +
+ +
+ + {/* ── Example 7: Cascading setDirty, Blobs & SSE ────── */} +
+
+
+ 07 +

Cascading setDirty, Binary Blobs & Server-Sent Events (SSE)

+
+

+ Demonstrates cascading invalidation, automatic Blob memory revoking, + and Server-Sent Events (SSE) integration: +
+ • Multiple photo automats (photo/1, photo/2, photo/3) + subscribe to a central hub (photos/all) via invalidateWith(photosAllAutomat). +
+ • When photos/all.setDirty() is called (or an SSE remote invalidation message arrives), + all child automats cascade setDirty(), automatically revoking their image Blob object URLs and releasing memory. +
+ • Unmounted automats remain in freed/null state until re-mounted, deferring network fetches and saving client memory! +

+
+ SSE Remote Event / User Click + → + photos/all.setDirty() + → + cascade invalidateWith() + → + URL.revokeObjectURL() & Free Memory + → + photo/1, photo/2, photo/3 +
+
+ + +
+
+
+ ); + } +} + +export default App; diff --git a/src/examples/automats/cartAutomat.js b/src/examples/automats/cartAutomat.js new file mode 100644 index 0000000..0a1e743 --- /dev/null +++ b/src/examples/automats/cartAutomat.js @@ -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 } +); diff --git a/src/examples/automats/counterAutomat.js b/src/examples/automats/counterAutomat.js new file mode 100644 index 0000000..2e222da --- /dev/null +++ b/src/examples/automats/counterAutomat.js @@ -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; diff --git a/src/examples/automats/indexAutomat.js b/src/examples/automats/indexAutomat.js new file mode 100644 index 0000000..53a9947 --- /dev/null +++ b/src/examples/automats/indexAutomat.js @@ -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; +} diff --git a/src/examples/automats/notificationAutomat.js b/src/examples/automats/notificationAutomat.js new file mode 100644 index 0000000..da48131 --- /dev/null +++ b/src/examples/automats/notificationAutomat.js @@ -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. ). + * + * 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; diff --git a/src/examples/automats/photosAutomat.js b/src/examples/automats/photosAutomat.js new file mode 100644 index 0000000..c98e169 --- /dev/null +++ b/src/examples/automats/photosAutomat.js @@ -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); diff --git a/src/examples/automats/profileAutomat.js b/src/examples/automats/profileAutomat.js new file mode 100644 index 0000000..990acb4 --- /dev/null +++ b/src/examples/automats/profileAutomat.js @@ -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', + } +); diff --git a/src/examples/automats/syncCounterAutomat.js b/src/examples/automats/syncCounterAutomat.js new file mode 100644 index 0000000..c0bcceb --- /dev/null +++ b/src/examples/automats/syncCounterAutomat.js @@ -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, + }); + }, + } +); diff --git a/src/examples/components/ApiBoundExample.jsx b/src/examples/components/ApiBoundExample.jsx new file mode 100644 index 0000000..2e9ffeb --- /dev/null +++ b/src/examples/components/ApiBoundExample.jsx @@ -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 ( +
+
+ Mode: Without Suspense + + Renders: #{this._renderCount} + +
+ + {!isReady ? ( +
+
⏳
+
Fetching from /api/profile...
+
+ (isReady is false; state is in null/default state) +
+
+ ) : ( +
+
+
+ {profile.name?.charAt(0) || 'U'} +
+
+

{profile.name}

+ {profile.role} · {profile.department} +
+
+ +
+ API Fetch Sequence: #{profile.fetchCount} + Loaded at: {profile.loadedAt} +
+
+ )} +
+ ); + } +} + +/** + * 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 ( +
+
+ Mode: With React Suspense + + Renders: #{this._renderCount} + +
+ +
+
+ {profile?.name?.charAt(0) || 'S'} +
+
+

{profile?.name}

+ {profile?.role} · {profile?.department} +
+
+ +
+ API Fetch Sequence: #{profile?.fetchCount} + Loaded at: {profile?.loadedAt} +
+
+ ); + } +} + +/** + * Fallback shown by while profileAutomat is unready. + */ +function SuspenseFallback() { + return ( +
+
⚡
+
+ Suspended by React Suspense Fallback +
+
+ profileAutomat.read() threw the ready Promise; Suspense caught it! +
+
+ ); +} + +/** + * 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 ( +
+ {/* Left Column: Tabbed Subscriber Interface */} +
+
+
+
+ 🌐 +

Backend-Bound Automat

+
+ url: "/api/profile" +
+ + {/* Navigation Tabs */} +
+ + + +
+
+ + {/* Tab Content */} + {activeTab === 'without-suspense' && } + + {activeTab === 'with-suspense' && ( + }> + + + )} + + {activeTab === 'unmounted' && ( +
+
📴
+

Subscriber Unmounted

+

+ Neither subscriber is mounted. If you click Set Dirty now, state resets to null/default, + but the backend fetch is deferred until a subscriber mounts or read() is called. +

+
+ )} +
+ + {/* Right Column: Invalidation & Deferred Reload Controls */} +
+
+ ⚡ +

Dirty State & Reload Inspector

+ + {isReady ? 'Ready' : 'Not Ready'} + +
+ +
+ {/* Realtime Metrics */} +
+
+ isReady + + {isReady ? 'true' : 'false'} + +
+
+ isDirty + + {isDirty ? 'true' : 'false'} + +
+
+ Subscribers + 0 ? 'var(--accent-light)' : 'var(--text-3)' }}> + {subsCount} + +
+
+ + {/* Current State Snapshot */} +
+ + Current automat.state (resets to null when dirty): + +
+                {currentState ? JSON.stringify(currentState, null, 2) : 'null (unready / dirty)'}
+              
+
+ + {/* Actions */} +
+ + + + +
+ + +
+
+ + {/* Status Log Box */} + {logMessage && ( +
+ 💡 {logMessage} +
+ )} +
+
+
+ ); + } +} diff --git a/src/examples/components/CartBadgeDisplay.jsx b/src/examples/components/CartBadgeDisplay.jsx new file mode 100644 index 0000000..908d0b9 --- /dev/null +++ b/src/examples/components/CartBadgeDisplay.jsx @@ -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 ( +
+
+ 🏷️ +

Cart Badge (Partial Subscriber)

+ Selector: items.length +
+ +
+ {/* Render count & efficiency indicator */} +
+ + Badge Renders: #{this._renderCount} + + + shallowEqual guarded + +
+ + {/* Visual Navbar / Cart Badge Mockup */} +
+
+ Simulated App Navigation Bar +
+ +
+ + Store Header + + +
+ + {/* Shopping Cart Icon with Badge */} +
+ + 🛒 + + + {/* Animated Badge Count */} + { 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} + +
+
+ +
+ Badge displays {badgeCount} unique items in cart +
+
+ + {/* Explanation Callout */} +
+
+ 🔍 Why Partial State Subscription Matters: +
+
    +
  • + Quantity changes (+ / −) modify item content, but items.length remains {badgeCount}. Automat detects that lastSlice is shallow-equal to nextSlice and skips this.setState() completely. +
  • +
  • + Typing coupon codes updates unrelated state, also triggering 0 renders here. +
  • +
  • + Adding or removing an item alters items.length, immediately updating the badge number! +
  • +
+
+ + {/* Canonical Code Snippet */} +
+
{`// 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 Cart: {this.state.badgeCount};
+  }
+}`}
+
+
+
+ ); + } +} + +export default CartBadgeDisplay; diff --git a/src/examples/components/CartManager.jsx b/src/examples/components/CartManager.jsx new file mode 100644 index 0000000..7296483 --- /dev/null +++ b/src/examples/components/CartManager.jsx @@ -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 ( +
+
+ 🛒 +

Shopping Cart Manager

+
+ 💾 IndexedDB + Full Subscriber +
+
+ +
+
+ + Component Renders: #{this._renderCount} + +
+ + +
+
+ + {/* Quick Add Buttons (Changes length) */} +
+ + Add Item (Changes List Length): + +
+ + + +
+
+ + {/* Items List */} +
+ + Cart Items ({items.length}): + + + {items.length === 0 ? ( +
+ Cart is empty. Click an add button above. +
+ ) : ( +
+ {items.map((item) => ( +
+
+
+ {item.name} +
+
+ ${item.price} each +
+
+ + {/* Quantity controls (Changes content, NOT length!) */} +
+ Qty: + + + {item.quantity} + + + + {/* Remove item (Changes length!) */} + +
+
+ ))} +
+ )} +
+ + {/* Unrelated field: Coupon Code */} +
+ + + + Typing here calls cartAutomat.setState({`{ couponCode }`}). Notice how the badge on the right ignores this completely! + +
+
+
+ ); + } +} + +export default CartManager; diff --git a/src/examples/components/CascadeControls.jsx b/src/examples/components/CascadeControls.jsx new file mode 100644 index 0000000..72e59f7 --- /dev/null +++ b/src/examples/components/CascadeControls.jsx @@ -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 ( +
+
+ 🌊 +

Cascade Trigger

+ upstream +
+ +
+ {/* Hybrid state display */} +
+
+
+ upstream + counter count +
+ {upstreamCount} +
+
+
+ local + triggers fired +
+ {cascadeFiredCount} +
+
+ +

+ Clicking a trigger button invokes counterAutomat.actions.increment(). + Because notificationAutomat observes it with{' '} + subscribeTo(), the downstream log derives a new + message automatically without any component glue. +

+ + {/* Trigger buttons */} +
+ + +
+ +
+ this.setState({ customText: e.target.value })} + placeholder="Custom notification message..." + /> + +
+ + + +
+
{`// 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),
+          ],
+        }
+);`}
+
+
+
+ ); + } +} + +export default CascadeControls; diff --git a/src/examples/components/CascadingPhotosExample.jsx b/src/examples/components/CascadingPhotosExample.jsx new file mode 100644 index 0000000..d762e32 --- /dev/null +++ b/src/examples/components/CascadingPhotosExample.jsx @@ -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 ( +
+
+ {isReady && data?.url ? ( + {title} + ) : ( +
+
{isDirty ? '🗑️' : '⏳'}
+
+ {isDirty ? 'Memory Freed (Blob Revoked)' : 'Loading Blob...'} +
+
State: null
+
+ )} + + + photo/{id} + +
+ +
+
+

{title}

+ Renders: #{this._renderCount} +
+ +
+ {data?.url ? ( +
+ ● Active Blob: {(data.size / 1024).toFixed(1)} KB +
+ ) : ( +
● Memory Released (0 bytes)
+ )} +
+ +
+ + +
+
+
+ ); + } +} + +/** + * 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 ( +
+ {/* Header */} +
+
+ 🖼️ +
+

Cascading Invalidation & Binary Fetchers

+ + Hub: photos/all ➔ Dependents: photo/1, photo/2, photo/3 + +
+
+ +
+ + Hub: {parentReady ? 'Ready' : 'Dirty / Unready'} + + SSE: Connected +
+
+ +
+ {/* Controls Bar */} +
+
+
+ Parent "photos/all" Controls: +

+ Setting parent dirty cascades to all photo automats, revoking Blob memory immediately. +

+
+ +
+ + + + + +
+
+ + {/* SSE Live Log */} + {sseLog.length > 0 && ( +
+ 📡 SSE Activity Log: + {sseLog.map((log, idx) => ( +
{log}
+ ))} +
+ )} +
+ + {/* Mount / Unmount Toggles for Memory Verification */} +
+ + Subscriber Mount States (Verify Deferred Reload & Memory Savings): + +
+ {[1, 2, 3].map((id) => ( + + ))} + +
+
+ + {/* Photos Grid */} +
+
+ + Photo #1 (Alps) {mountedPhotos[1] ? '🟢 Mounted' : '⚪ Unmounted'} + + {mountedPhotos[1] ? ( + + ) : ( +
+ photo/1 is unmounted.
+ Cascading dirty releases its blob and defers reload until re-mounted! +
+ )} +
+ +
+ + Photo #2 (Tokyo) {mountedPhotos[2] ? '🟢 Mounted' : '⚪ Unmounted'} + + {mountedPhotos[2] ? ( + + ) : ( +
+ photo/2 is unmounted.
+ Cascading dirty releases its blob and defers reload until re-mounted! +
+ )} +
+ +
+ + Photo #3 (Rainforest) {mountedPhotos[3] ? '🟢 Mounted' : '⚪ Unmounted'} + + {mountedPhotos[3] ? ( + + ) : ( +
+ photo/3 is unmounted.
+ Click "Mount photo/3" above to subscribe and trigger initial fetch! +
+ )} +
+
+ + {/* PDF Blob Viewer Section */} + {showPdf && ( +
+
+
+

PDF Blob Fetcher (`fetchPdfBlob`)

+ + Endpoint: /api/docs/spec · Blob Size: {pdfData?.size || 0} bytes + +
+
+ + +
+
+ + {pdfReady && pdfData?.url ? ( +