This commit is contained in:
seb
2026-09-21 03:07:50 +02:00
parent 6f11b08e95
commit 4e646e8875
6 changed files with 258 additions and 0 deletions

View File

@@ -82,6 +82,7 @@ export class Automat<T = any, A = Record<string, Function>> {
dispose(): void; // Unsubs all, revokes blobs, deletes from window registry
static get<T = any, A = any>(name: string): Automat<T, A> | undefined;
static combine<M extends Record<string, Automat<any, any>>>(automats: M): Automat<CombinedState<M>, CombinedActions<M>>;
}
// 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 |

View File

@@ -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 {
<CascadingPhotosExample />
</section>
<div className="section-divider" />
{/* ── Example 8: Combined Automats ──────────────────── */}
<section className="example-section" aria-labelledby="ex8-title">
<div className="section-header">
<div className="section-title-row">
<span className="section-number">08</span>
<h2 id="ex8-title">Combined Automats</h2>
</div>
<p>
<code className="inline-code">Automat.combine()</code> creates one derived application state from independent automats.
Updates retain their namespace, actions remain grouped by child, and readiness operations cover every child.
</p>
<div className="flow-diagram" aria-label="Combined automat flow">
<span className="flow-node">authAutomat</span>
<span className="flow-sep">+</span>
<span className="flow-node">companyAutomat</span>
<span className="flow-arrow">→</span>
<span className="flow-node">Automat.combine()</span>
<span className="flow-arrow">→</span>
<span className="flow-node flow-node-component">appAutomat</span>
</div>
</div>
<CombinedAutomatExample />
</section>
</main>
</div>
);

View File

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

View File

@@ -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 (
<div className="card">
<div className="card-header">
<span className="card-icon">🧩</span>
<h3>Combined Application State</h3>
<span className="badge badge-local">2 automats</span>
</div>
<div className="card-body">
<div className="ctrl-current-value">
<span className="ctrl-current-label">Signed in as</span>
<span>{auth.user?.name ?? 'Guest'}</span>
</div>
<div className="ctrl-current-value">
<span className="ctrl-current-label">Company</span>
<span>{company.name} · {company.plan}</span>
</div>
<div className="counter-controls">
<button className="btn btn-secondary" type="button" onClick={() => auth.user ? appAutomat.actions.auth.logout() : appAutomat.actions.auth.login()}>
{auth.user ? 'Log out' : 'Log in'}
</button>
<button className="btn btn-primary" type="button" onClick={() => appAutomat.actions.company.togglePlan()}>
Toggle plan
</button>
</div>
<div className="code-snippet">
<pre>{`const app = Automat.combine({
auth: authAutomat,
company: companyAutomat,
});
app.subscribe(this);
app.actions.auth.login();
app.actions.company.togglePlan();`}</pre>
</div>
</div>
</div>
);
}
}
export default CombinedAutomatExample;

View File

@@ -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<string, Automat>} 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])
);
}
}

11
src/lib/index.d.ts vendored
View File

@@ -32,6 +32,14 @@ export interface SSEOptions<T = any, A = any> {
onError?: (err: any, automat: Automat<T, A>) => void;
}
export type CombinedState<M extends Record<string, Automat<any, any>>> = {
[K in keyof M]: M[K] extends Automat<infer T, any> ? T : never;
};
export type CombinedActions<M extends Record<string, Automat<any, any>>> = {
[K in keyof M]: M[K] extends Automat<any, infer A> ? A : never;
};
export class Automat<T = any, A = Record<string, Function>> {
constructor(initialState?: T | null, actions?: A, options?: AutomatOptions<T>);
@@ -78,6 +86,9 @@ export class Automat<T = any, A = Record<string, Function>> {
dispose(): void;
static get<T = any, A = any>(name: string): Automat<T, A> | undefined;
static combine<M extends Record<string, Automat<any, any>>>(
automats: M
): Automat<CombinedState<M>, CombinedActions<M>>;
}
export function fetchJson<T = any>(url: string, options?: RequestInit): Promise<T>;