From 4e646e8875a572389b8a056d1d273dfbb69e9078 Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 21 Sep 2026 03:07:50 +0200 Subject: [PATCH] combine --- README.md | 25 +++++ src/App.jsx | 28 +++++ src/examples/automats/combinedAutomat.js | 32 ++++++ .../components/CombinedAutomatExample.jsx | 61 +++++++++++ src/lib/Automat.js | 101 ++++++++++++++++++ src/lib/index.d.ts | 11 ++ 6 files changed, 258 insertions(+) create mode 100644 src/examples/automats/combinedAutomat.js create mode 100644 src/examples/components/CombinedAutomatExample.jsx diff --git a/README.md b/README.md index b10c249..42087b7 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ export class Automat> { dispose(): void; // Unsubs all, revokes blobs, deletes from window registry static get(name: string): Automat | undefined; + static combine>>(automats: M): Automat, CombinedActions>; } // Fetchers & SSE @@ -160,6 +161,30 @@ const live = new Automat({}, { connectSSE(live, '/api/stream'); ``` +### G. Combine Automats +```js +const app = Automat.combine({ + auth: authAutomat, + company: companyAutomat, +}); + +app.state.auth; // authAutomat.state +app.state.company; // companyAutomat.state +app.actions.auth.login(); // authAutomat.actions.login() +app.actions.company.rename('Acme'); + +// Notified when either child changes; selectors can isolate a branch. +const unsubscribe = app.subscribe(this, (state) => ({ + user: state.auth.user, + companyName: state.company.name, +})); +``` + +The aggregate is derived: child updates automatically update the matching key. Its +`isReady`, `isDirty`, `ready`, `read()`, `setDirty()`, and `reload()` operations cover +all children. Calling `app.dispose()` only removes aggregate subscriptions; it does +not dispose the child automats. + ## 3. Decision Matrix: `setDirty()` vs `reload()` | Method | `isReady` | State during fetch | Suspense | Unmounted | Use Case | diff --git a/src/App.jsx b/src/App.jsx index dbf694f..63843c0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -12,6 +12,7 @@ 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'; +import CombinedAutomatExample from './examples/components/CombinedAutomatExample.jsx'; class App extends PureComponent { render() { @@ -298,6 +299,33 @@ class App extends PureComponent { + +
+ + {/* ── Example 8: Combined Automats ──────────────────── */} +
+
+
+ 08 +

Combined Automats

+
+

+ Automat.combine() creates one derived application state from independent automats. + Updates retain their namespace, actions remain grouped by child, and readiness operations cover every child. +

+
+ authAutomat + + + companyAutomat + → + Automat.combine() + → + appAutomat +
+
+ + +
); diff --git a/src/examples/automats/combinedAutomat.js b/src/examples/automats/combinedAutomat.js new file mode 100644 index 0000000..5807a98 --- /dev/null +++ b/src/examples/automats/combinedAutomat.js @@ -0,0 +1,32 @@ +import { Automat } from '../../lib/index.js'; + +export const authAutomat = new Automat( + { user: null }, + { + login() { + authAutomat.setState({ user: { name: 'Ada Lovelace' } }); + }, + logout() { + authAutomat.setState({ user: null }); + }, + } +); + +export const companyAutomat = new Automat( + { name: 'Analytical Engines Ltd.', plan: 'Starter' }, + { + rename(name) { + companyAutomat.setState({ name }); + }, + togglePlan() { + companyAutomat.setState({ + plan: companyAutomat.state.plan === 'Starter' ? 'Enterprise' : 'Starter', + }); + }, + } +); + +export const appAutomat = Automat.combine({ + auth: authAutomat, + company: companyAutomat, +}); \ No newline at end of file diff --git a/src/examples/components/CombinedAutomatExample.jsx b/src/examples/components/CombinedAutomatExample.jsx new file mode 100644 index 0000000..976b7a5 --- /dev/null +++ b/src/examples/components/CombinedAutomatExample.jsx @@ -0,0 +1,61 @@ +import { PureComponent } from 'react'; +import { appAutomat } from '../automats/combinedAutomat.js'; + +class CombinedAutomatExample extends PureComponent { + constructor(props) { + super(props); + this.state = appAutomat.state; + } + + componentDidMount() { + this.unsubscribe = appAutomat.subscribe(this); + } + + componentWillUnmount() { + this.unsubscribe?.(); + } + + render() { + const { auth, company } = this.state; + + return ( +
+
+ 🧩 +

Combined Application State

+ 2 automats +
+
+
+ Signed in as + {auth.user?.name ?? 'Guest'} +
+
+ Company + {company.name} · {company.plan} +
+
+ + +
+
+
{`const app = Automat.combine({
+  auth: authAutomat,
+  company: companyAutomat,
+});
+
+app.subscribe(this);
+app.actions.auth.login();
+app.actions.company.togglePlan();`}
+
+
+
+ ); + } +} + +export default CombinedAutomatExample; \ No newline at end of file diff --git a/src/lib/Automat.js b/src/lib/Automat.js index 03de3f9..3606e84 100644 --- a/src/lib/Automat.js +++ b/src/lib/Automat.js @@ -225,6 +225,18 @@ export class Automat { return getGlobalMap('__AUTOMATS__')?.get(name); } + /** + * Combines multiple automats into one derived automat. + * The combined state and actions use the same keys as the supplied map. + * Child updates are forwarded without taking ownership of the children. + * + * @param {Record} automats + * @returns {Automat} + */ + static combine(automats) { + return new CombinedAutomat(automats); + } + get name() { return this._name; } @@ -429,3 +441,92 @@ export class Automat { } } } + +/** + * Derived aggregate returned by Automat.combine(). + * Disposing it removes aggregate subscriptions but leaves its children alive. + */ +class CombinedAutomat extends Automat { + constructor(automats) { + if (!automats || typeof automats !== 'object' || Array.isArray(automats)) { + throw new TypeError('Automat.combine expects an object of Automat instances.'); + } + + const entries = Object.entries(automats); + for (const [key, automat] of entries) { + if (!automat || typeof automat.subscribe !== 'function') { + throw new TypeError(`Automat.combine: "${key}" is not an Automat instance.`); + } + } + + super( + Object.fromEntries(entries.map(([key, automat]) => [key, automat.state])), + Object.fromEntries(entries.map(([key, automat]) => [key, automat.actions])) + ); + + this._children = automats; + + for (const [key, automat] of entries) { + this._unsubs.push( + automat.subscribe((state) => this.setState({ [key]: state })), + automat.onDirty(() => { + for (const fn of this._dirtySubs) { + try { + fn(this); + } catch {} + } + }) + ); + } + } + + get isReady() { + return Object.values(this._children).every((automat) => automat.isReady); + } + + get isDirty() { + return Object.values(this._children).some((automat) => automat.isDirty); + } + + get error() { + return Object.values(this._children).find((automat) => automat.error)?.error ?? null; + } + + get ready() { + return Promise.all(Object.values(this._children).map((automat) => automat.ready)).then(() => { + this._syncState(); + return this._state; + }); + } + + read() { + this._state = Object.fromEntries( + Object.entries(this._children).map(([key, automat]) => [key, automat.read()]) + ); + return this._state; + } + + setDirty() { + return Promise.all(Object.values(this._children).map((automat) => automat.setDirty())).then(() => { + this._syncState(); + return this._state; + }); + } + + reload() { + return Promise.all(Object.values(this._children).map((automat) => automat.reload())).then(() => { + this._syncState(); + return this._state; + }); + } + + refresh() { + return this.reload(); + } + + _syncState() { + this._state = Object.fromEntries( + Object.entries(this._children).map(([key, automat]) => [key, automat.state]) + ); + } +} diff --git a/src/lib/index.d.ts b/src/lib/index.d.ts index c0578cf..101d65f 100644 --- a/src/lib/index.d.ts +++ b/src/lib/index.d.ts @@ -32,6 +32,14 @@ export interface SSEOptions { onError?: (err: any, automat: Automat) => void; } +export type CombinedState>> = { + [K in keyof M]: M[K] extends Automat ? T : never; +}; + +export type CombinedActions>> = { + [K in keyof M]: M[K] extends Automat ? A : never; +}; + export class Automat> { constructor(initialState?: T | null, actions?: A, options?: AutomatOptions); @@ -78,6 +86,9 @@ export class Automat> { dispose(): void; static get(name: string): Automat | undefined; + static combine>>( + automats: M + ): Automat, CombinedActions>; } export function fetchJson(url: string, options?: RequestInit): Promise;