combine
This commit is contained in:
25
README.md
25
README.md
@@ -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 |
|
||||
|
||||
28
src/App.jsx
28
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 {
|
||||
|
||||
<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>
|
||||
);
|
||||
|
||||
32
src/examples/automats/combinedAutomat.js
Normal file
32
src/examples/automats/combinedAutomat.js
Normal 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,
|
||||
});
|
||||
61
src/examples/components/CombinedAutomatExample.jsx
Normal file
61
src/examples/components/CombinedAutomatExample.jsx
Normal 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;
|
||||
@@ -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
11
src/lib/index.d.ts
vendored
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user