Compare commits
5 Commits
0f9321ac34
...
59f16c0f0c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59f16c0f0c | ||
|
|
7c64dfdf29 | ||
|
|
4e646e8875 | ||
|
|
6f11b08e95 | ||
|
|
99415ce835 |
14
LICENSE
Normal file
14
LICENSE
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
Zero-Clause BSD (0BSD)
|
||||||
|
|
||||||
|
Copyright (C) 2026 Automat Authors
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
PERFORMANCE OF THIS SOFTWARE.
|
||||||
578
README.md
578
README.md
@@ -1,6 +1,16 @@
|
|||||||
# Automat
|
# ⚙ react-automat
|
||||||
|
|
||||||
Observable state management for React `PureComponent` — state independent of component lifecycle. Lightweight (~1.1 kB minified) and zero-dependency.
|
> Zero-dependency higher-order observable state container for React `PureComponent`. State lives in module scope (independent of component mounting). Components read state synchronously in `constructor` and subscribe in `componentDidMount`. No Context, hooks, or HOCs.
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/react-automat)
|
||||||
|
[](https://bundlephobia.com/package/react-automat)
|
||||||
|
[](https://sebgreen.net/automat/)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install react-automat
|
||||||
|
# or: pnpm add react-automat | yarn add react-automat | bun add react-automat
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -8,7 +18,7 @@ Observable state management for React `PureComponent` — state independent of c
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
// counterAutomat.js
|
// counterAutomat.js
|
||||||
import { Automat } from 'automat';
|
import { Automat } from 'react-automat';
|
||||||
|
|
||||||
export const counterAutomat = new Automat(
|
export const counterAutomat = new Automat(
|
||||||
{ count: 0 },
|
{ count: 0 },
|
||||||
@@ -59,382 +69,134 @@ export class Counter extends PureComponent {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Core Invariants
|
## 1. Complete Type Signatures
|
||||||
|
|
||||||
1. **Lifecycle-Independent**: `Automat` instances live outside the React tree (typically in module scope), persisting state across component mounts and unmounts.
|
|
||||||
2. **Synchronous Constructor Reads**: Components initialize with `this.state = myAutomat.state;` directly in `constructor(props)`.
|
|
||||||
3. **Automatic Shallow Merges**: When subscribed with `subscribe(this)`, `automat.setState(partial)` calls `component.setState(partial)`, preserving any component-local state.
|
|
||||||
4. **Fine-Grained Selectors**: Passing a selector to `subscribe(this, selector)` runs shallow equality checks; updates to unrelated fields skip `setState` and avoid re-renders.
|
|
||||||
5. **Zero Wrappers**: No hooks, HOCs, Context Providers, or `connect()`.
|
|
||||||
6. **Optional Persistence**: Automats can specify a `name` and persist in the `window` object (`persist: false`) for session memory across unmounts/HMR, or in `IndexedDB` (`persist: true`) to survive full page reloads.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### `new Automat(initialState, actions?, options?)`
|
|
||||||
|
|
||||||
Creates an observable state container with optional persistence.
|
|
||||||
|
|
||||||
- `initialState` *(object)*: Initial state snapshot (shallow copied).
|
|
||||||
- `actions` *(object, optional)*: Action methods stored on `automat.actions`.
|
|
||||||
- `options` *(object, optional)*:
|
|
||||||
- `name` *(string)*: Unique identifier used for persistence and `Automat.get(name)` registry lookup.
|
|
||||||
- `persist` *(boolean)*: Persistence strategy when `name` is provided:
|
|
||||||
- `false` (default): Persists in the `window` object (session memory, survives component unmounts and HMR).
|
|
||||||
- `true`: Persists in `IndexedDB` (survives page reloads and browser restarts).
|
|
||||||
- `loader` *(function, optional)*: Asynchronous loader `(automat) => Promise<object>`. Invoked when `getData()` is called while state is `'idle'`.
|
|
||||||
|
|
||||||
```js
|
|
||||||
// Window-persisted (session memory)
|
|
||||||
const sessionStore = new Automat(
|
|
||||||
{ filter: 'all' },
|
|
||||||
{ setFilter(filter) { sessionStore.setState({ filter }); } },
|
|
||||||
{ name: 'filter', persist: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Async API-backed with loader
|
|
||||||
const userStore = new Automat(
|
|
||||||
{ status: 'idle', data: null },
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
name: 'user',
|
|
||||||
loader: async (store) => {
|
|
||||||
const res = await fetch('/api/user');
|
|
||||||
return { data: await res.json() };
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `automat.getData(options?)` / `automat.load(options?)`
|
|
||||||
|
|
||||||
Reads state for PureComponent constructor initialization, triggering asynchronous loading if state is idle:
|
|
||||||
|
|
||||||
1. **First Mount (Data Not Loaded)**:
|
|
||||||
- Sets `{ status: 'pending' }` and kicks off the async loader.
|
|
||||||
- Synchronously returns the pending state for the first render.
|
|
||||||
- When the fetch completes, `setState()` notifies subscribers, causing a 2nd render with data.
|
|
||||||
2. **Subsequent Mounts / Remounts (Data Already Loaded)**:
|
|
||||||
- Directly returns `{ status: 'success', data }` synchronously in the constructor.
|
|
||||||
- Component renders completed data immediately. **Zero 2nd render occurs!**
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
class UserProfile extends PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
// 💡 Synchronous read in constructor:
|
|
||||||
this.state = userStore.getData();
|
|
||||||
}
|
|
||||||
componentDidMount() {
|
|
||||||
this.unsubscribe = userStore.subscribe(this);
|
|
||||||
}
|
|
||||||
componentWillUnmount() {
|
|
||||||
this.unsubscribe();
|
|
||||||
}
|
|
||||||
render() {
|
|
||||||
const { status, data } = this.state;
|
|
||||||
if (status === 'pending') return <Spinner />;
|
|
||||||
return <div>{data.name}</div>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `automat.state` / `automat.getState()`
|
|
||||||
|
|
||||||
Returns the current state snapshot.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const current = automat.state;
|
|
||||||
// or
|
|
||||||
const current = automat.getState();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `automat.setState(partial)`
|
|
||||||
|
|
||||||
Shallow-merges `partial` into current state and synchronously notifies subscribers. Returns the updated state.
|
|
||||||
|
|
||||||
```js
|
|
||||||
automat.setState({ count: 5 });
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `automat.actions`
|
|
||||||
|
|
||||||
Provides direct access to the actions object supplied in the constructor.
|
|
||||||
|
|
||||||
```js
|
|
||||||
automat.actions.reset();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `automat.subscribe(target, selector?)`
|
|
||||||
|
|
||||||
Subscribes a React `PureComponent` instance (`this`) or a callback function. Returns an unsubscribe function.
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
subscribe(
|
import type { PureComponent } from 'react';
|
||||||
target: PureComponent | ((state: T) => void),
|
|
||||||
selector?: string | string[] | ((state: T) => object | null)
|
|
||||||
): () => void
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Selector forms:
|
export interface AutomatOptions<T> {
|
||||||
|
name?: string | null; // window.__AUTOMATS__ key & Automat.get(name)
|
||||||
|
persist?: boolean; // false = window (default), true = IndexedDB
|
||||||
|
loader?: (automat: Automat<T, any>) => Promise<Partial<T> | void>;
|
||||||
|
url?: string | null; // Auto-triggers fetcher(url) on init
|
||||||
|
fetcher?: (url: string, automat?: Automat<T, any>) => Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
- **Single Key (string)**:
|
export type Selector<T, S = any> = keyof T | (keyof T)[] | ((state: T) => S | null | undefined);
|
||||||
```js
|
|
||||||
// Injects { count } into component setState only when count changes
|
|
||||||
this.unsubscribe = myAutomat.subscribe(this, 'count');
|
|
||||||
```
|
|
||||||
- **Multiple Keys (array)**:
|
|
||||||
```js
|
|
||||||
// Injects { count, text } only when either property changes
|
|
||||||
this.unsubscribe = myAutomat.subscribe(this, ['count', 'text']);
|
|
||||||
```
|
|
||||||
- **Selector Function**:
|
|
||||||
```js
|
|
||||||
// Custom slice with shallow equality check; return null/undefined to skip update
|
|
||||||
this.unsubscribe = myAutomat.subscribe(this, (state) => ({
|
|
||||||
badgeCount: state.items.length,
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
- **Callback Function (non-React)**:
|
|
||||||
```js
|
|
||||||
const unsub = myAutomat.subscribe((state) => console.log('State changed:', state));
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
export interface ScopedSlice<S> {
|
||||||
|
readonly state: S;
|
||||||
|
subscribe(target: PureComponent | ((slice: S) => void)): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
### `automat.select(selector)`
|
export interface BlobResource {
|
||||||
|
blob: Blob; url: string; size: number; type: string; loadedAt: string;
|
||||||
|
revoke: () => void; // Auto-called on setDirty() / dispose() to release memory
|
||||||
|
}
|
||||||
|
|
||||||
Creates a scoped slice containing a `.state` getter and a pre-scoped `.subscribe()` helper.
|
export interface SSEOptions<T = any, A = any> {
|
||||||
|
onMessage?: (data: any, event: string, automat: Automat<T, A>) => void;
|
||||||
|
events?: Record<string, (data: any, automat: Automat<T, A>) => void>;
|
||||||
|
onError?: (err: any, automat: Automat<T, A>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
```js
|
export type CombinedState<M extends Record<string, Automat<any, any>>> = {
|
||||||
const countSlice = myAutomat.select('count');
|
[K in keyof M]: M[K] extends Automat<infer T, any> ? T : never;
|
||||||
|
};
|
||||||
|
|
||||||
// In constructor:
|
export type CombinedActions<M extends Record<string, Automat<any, any>>> = {
|
||||||
this.state = countSlice.state; // { count: 0 }
|
[K in keyof M]: M[K] extends Automat<any, infer A> ? A : never;
|
||||||
|
};
|
||||||
|
|
||||||
// In componentDidMount:
|
export class Automat<T = any, A = Record<string, Function>> {
|
||||||
this.unsubscribe = countSlice.subscribe(this);
|
constructor(initialState?: T | null, actions?: A, options?: AutomatOptions<T>);
|
||||||
|
|
||||||
|
readonly state: T;
|
||||||
|
getState(): T;
|
||||||
|
setState(partial: Partial<T> | ((prev: T) => Partial<T>)): T;
|
||||||
|
readonly actions: A;
|
||||||
|
|
||||||
|
readonly isReady: boolean;
|
||||||
|
readonly isDirty: boolean;
|
||||||
|
readonly ready: Promise<T>; // Re-awaitable after setDirty()
|
||||||
|
readonly name: string | null;
|
||||||
|
readonly persist: boolean;
|
||||||
|
readonly url: string | null;
|
||||||
|
readonly subscriberCount: number;
|
||||||
|
|
||||||
|
getData(options?: { reload?: boolean }): T;
|
||||||
|
load(options?: { reload?: boolean }): T;
|
||||||
|
|
||||||
|
subscribe(target: PureComponent | ((state: T, partial?: Partial<T>) => void), selector?: Selector<T>): () => void;
|
||||||
|
select<S = any>(selector: Selector<T, S>): ScopedSlice<S>;
|
||||||
|
unsubscribe(target: any): void;
|
||||||
|
|
||||||
|
subscribeTo<U = any>(
|
||||||
|
upstream: Automat<U, any>,
|
||||||
|
transform: (upstreamState: U, currentState: T) => Partial<T> | null | undefined,
|
||||||
|
options?: { cascadeDirty?: boolean | ((upstream: U, current: T) => boolean) }
|
||||||
|
): this;
|
||||||
|
|
||||||
|
invalidateWith<U = any>(upstream: Automat<U, any>, filterFn?: (upstream: U, current: T) => boolean): this;
|
||||||
|
onDirty(fn: (automat: this) => void): () => void;
|
||||||
|
|
||||||
|
setDirty(): Promise<T>; // Resets state to null/default, revokes blobs, cascades, defers if unmounted
|
||||||
|
reload(): Promise<T>; // Silent background re-fetch; preserves state & isReady=true
|
||||||
|
refresh(): Promise<T>; // Alias for reload()
|
||||||
|
read(): T; // For Suspense: throws ready promise if unready; triggers deferred load if dirty
|
||||||
|
|
||||||
|
clearPersistence(): Promise<void>;
|
||||||
|
dispose(): void; // Unsubs all, revokes blobs, deletes from window registry
|
||||||
|
|
||||||
|
static get<T = any, A = any>(name: string): Automat<T, A> | undefined;
|
||||||
|
|
||||||
|
// Dictionary Combination:
|
||||||
|
static combine<M extends Record<string, Automat<any, any>>>(automats: M): Automat<CombinedState<M>, CombinedActions<M>>;
|
||||||
|
|
||||||
|
// Array Combiner Function Combination:
|
||||||
|
static combine<R = any>(
|
||||||
|
upstreamAutomats: Automat<any, any>[],
|
||||||
|
combiner: (...states: any[]) => R,
|
||||||
|
options?: AutomatOptions<R>
|
||||||
|
): Automat<R, Record<string, Function>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetchers & SSE
|
||||||
|
export function fetchJson<T = any>(url: string, options?: RequestInit): Promise<T>;
|
||||||
|
export function fetchBlob(url: string, options?: RequestInit): Promise<BlobResource>;
|
||||||
|
export const fetchImageBlob: typeof fetchBlob;
|
||||||
|
export const fetchPdfBlob: typeof fetchBlob;
|
||||||
|
export function connectSSE<T, A>(automat: Automat<T, A>, url: string, options?: SSEOptions<T, A>): { eventSource: EventSource | null; close: () => void };
|
||||||
|
export function createSSEFetcher(sseUrl: string, baseFetcher?: Function, sseOptions?: SSEOptions): (url: string, automat?: any) => Promise<any>;
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `automat.unsubscribe(target)`
|
## 2. Canonical Usage Patterns
|
||||||
|
|
||||||
Unregisters a subscriber. Prefer calling the function returned by `subscribe()`.
|
|
||||||
|
|
||||||
```js
|
|
||||||
automat.unsubscribe(this);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `automat.subscribeTo(upstreamAutomat, transform)`
|
|
||||||
|
|
||||||
Derives state reactively from an upstream automat. Whenever `upstreamAutomat` updates, `transform(upstreamState, myState)` runs. Return partial state to update, or `null` / `undefined` to skip. Returns `this` for chaining.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const auditAutomat = new Automat({ logs: [] });
|
|
||||||
|
|
||||||
auditAutomat.subscribeTo(counterAutomat, (upstream, my) => {
|
|
||||||
if (upstream.count === 0) return null; // skip update
|
|
||||||
return {
|
|
||||||
logs: [`Count changed to ${upstream.count}`, ...my.logs.slice(0, 19)],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `automat.ready`
|
|
||||||
|
|
||||||
A promise resolving with current state when initial rehydration finishes (resolves immediately for non-persisted and window-persisted instances; resolves once IndexedDB data loads).
|
|
||||||
|
|
||||||
```js
|
|
||||||
await cartStore.ready;
|
|
||||||
console.log('Cart rehydrated:', cartStore.state);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `automat.clearPersistence()`
|
|
||||||
|
|
||||||
Deletes the persisted state entry from `window` or `IndexedDB`.
|
|
||||||
|
|
||||||
```js
|
|
||||||
await cartStore.clearPersistence();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `Automat.get(name)`
|
|
||||||
|
|
||||||
Static registry method to retrieve any named `Automat` instance stored in the `window` object.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const filterStore = Automat.get('filter');
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `Automat.combine(upstreamAutomats, combiner, options?)`
|
|
||||||
|
|
||||||
Combines multiple upstream automats into a single derived container.
|
|
||||||
|
|
||||||
- **Concurrent Orchestration**: When accessed via `.getData()` while any upstream is idle, triggers all idle upstreams to fetch concurrently in parallel.
|
|
||||||
- **Instant Constructor Resolution**: If all upstream automats have already resolved (`status === 'success'`), `.getData()` computes the projection synchronously in the component constructor, returning complete combined data with zero delay and a single render.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const dashboardAutomat = Automat.combine(
|
|
||||||
[userStatsAutomat, systemMetricsAutomat],
|
|
||||||
(statsState, metricsState) => ({
|
|
||||||
stats: statsState.data,
|
|
||||||
metrics: metricsState.data,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// In component constructor:
|
|
||||||
this.state = dashboardAutomat.getData();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `automat.dispose()`
|
|
||||||
|
|
||||||
Tears down all upstream subscriptions set up via `subscribeTo()`, clears all subscribers, and removes named instance registrations from the window registry.
|
|
||||||
|
|
||||||
```js
|
|
||||||
automat.dispose();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Examples: Named Automats & Persistence
|
|
||||||
|
|
||||||
### 1. Named Automat in Window Object (`persist: false`)
|
|
||||||
|
|
||||||
Useful for shared app settings, tab management, or devtools inspection. State persists in memory across component unmounts and Hot Module Replacement (HMR) reloads:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// settingsAutomat.js
|
|
||||||
import { Automat } from 'automat';
|
|
||||||
|
|
||||||
export const settingsAutomat = new Automat(
|
|
||||||
{ theme: 'dark', soundEnabled: true },
|
|
||||||
{
|
|
||||||
setTheme(theme) {
|
|
||||||
settingsAutomat.setState({ theme });
|
|
||||||
},
|
|
||||||
toggleSound() {
|
|
||||||
settingsAutomat.setState({ soundEnabled: !settingsAutomat.state.soundEnabled });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ name: 'settings', persist: false } // Saved in window object
|
|
||||||
);
|
|
||||||
|
|
||||||
// Any other file or devtools console can lookup the instance by name:
|
|
||||||
const settings = Automat.get('settings');
|
|
||||||
settings?.actions.setTheme('light');
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Reload-Resilient Persistence with IndexedDB (`persist: true`)
|
|
||||||
|
|
||||||
Useful for shopping carts, drafts, and user form progress that must survive full page refreshes and browser restarts:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// cartAutomat.js
|
|
||||||
import { Automat } from 'automat';
|
|
||||||
|
|
||||||
export const cartAutomat = new Automat(
|
|
||||||
{ items: [], lastUpdated: null },
|
|
||||||
{
|
|
||||||
addItem(item) {
|
|
||||||
cartAutomat.setState({
|
|
||||||
items: [...cartAutomat.state.items, item],
|
|
||||||
lastUpdated: Date.now(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
clearCart() {
|
|
||||||
cartAutomat.setState({ items: [], lastUpdated: null });
|
|
||||||
// Optional: wipe stored record from IndexedDB
|
|
||||||
cartAutomat.clearPersistence();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ name: 'cart', persist: true } // Automatically syncs with IndexedDB
|
|
||||||
);
|
|
||||||
|
|
||||||
// Optional: wait for saved data to finish hydrating before proceeding
|
|
||||||
await cartAutomat.ready;
|
|
||||||
console.log('Hydrated cart items from IndexedDB:', cartAutomat.state.items);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. PureComponent Consuming an IndexedDB-Persisted Automat
|
|
||||||
|
|
||||||
Components mount immediately with initial state. When IndexedDB finishes loading persisted data in the background, subscribers are notified automatically:
|
|
||||||
|
|
||||||
|
### A. PureComponent Subscription
|
||||||
```jsx
|
```jsx
|
||||||
// CartView.jsx
|
|
||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
import { cartAutomat } from './cartAutomat.js';
|
import { Automat } from 'react-automat';
|
||||||
|
|
||||||
export class CartView extends PureComponent {
|
const store = new Automat({ count: 0 }, { inc: () => store.setState({ count: store.state.count + 1 }) });
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
// 1. Mount immediately with current/initial state
|
|
||||||
this.state = cartAutomat.state;
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
class Counter extends PureComponent {
|
||||||
// 2. Subscribe — receives automatic update once IndexedDB hydrates
|
constructor(props) { super(props); this.state = store.state; } // Sync read, never stale
|
||||||
this.unsubscribe = cartAutomat.subscribe(this);
|
componentDidMount() { this.unsub = store.subscribe(this); } // Auto-calls this.setState(partial)
|
||||||
}
|
componentWillUnmount() { this.unsub(); }
|
||||||
|
render() { return <button onClick={store.actions.inc}>{this.state.count}</button>; }
|
||||||
componentWillUnmount() {
|
|
||||||
// 3. Clean up subscription
|
|
||||||
this.unsubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { items } = this.state;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3>Shopping Cart ({items.length} items)</h3>
|
|
||||||
<ul>
|
|
||||||
{items.map((item) => (
|
|
||||||
<li key={item.id}>{item.name} - ${item.price}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<button onClick={() => cartAutomat.actions.addItem({ id: Date.now(), name: 'New Item', price: 20 })}>
|
|
||||||
Add Item (Persists on Reload)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### B. Fine-Grained Selectors (Shallow-guarded, 0 renders if slice unchanged)
|
||||||
|
```js
|
||||||
### 4. Async Fetch with Constructor Caching (Instant Re-Mount)
|
store.subscribe(this, 'count'); // Single key
|
||||||
|
store.subscribe(this, ['theme', 'locale']); // Multi key
|
||||||
When a component accesses `myAutomat.getData()` in its constructor while data is loading, it receives `{ status: 'pending' }` and renders the pending state. When the fetch completes, it renders again (2 renders total).
|
store.subscribe(this, (s) => ({ items: s.list.length }));// Projection function
|
||||||
|
```
|
||||||
When another component mounts (or the component remounts via a collapsible toggle) with data already loaded, `getData()` returns `{ status: 'success', data }` synchronously in the constructor — displaying final data in **1 render with zero 2nd render**:
|
|
||||||
|
|
||||||
|
### C. Async Fetch & Constructor Cache (Instant Re-Mount)
|
||||||
```jsx
|
```jsx
|
||||||
class UserCard extends PureComponent {
|
class UserCard extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -457,12 +219,7 @@ class UserCard extends PureComponent {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### D. Multi-API Orchestration (`Automat.combine`)
|
||||||
|
|
||||||
### 5. Combined Multi-API Automats (`Automat.combine`)
|
|
||||||
|
|
||||||
Combines two delayed APIs. If accessed while upstreams are idle, it triggers both to load concurrently in parallel. If both had already been accessed earlier, it resolves instantly in the constructor:
|
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const combinedDashboard = Automat.combine(
|
const combinedDashboard = Automat.combine(
|
||||||
[userStatsAutomat, systemMetricsAutomat],
|
[userStatsAutomat, systemMetricsAutomat],
|
||||||
@@ -475,7 +232,7 @@ const combinedDashboard = Automat.combine(
|
|||||||
class Dashboard extends PureComponent {
|
class Dashboard extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
// 💡 If both upstreams were loaded earlier, resolves instantly in constructor:
|
// Instant resolution in constructor if both upstreams loaded earlier:
|
||||||
this.state = combinedDashboard.getData();
|
this.state = combinedDashboard.getData();
|
||||||
}
|
}
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -497,13 +254,106 @@ class Dashboard extends PureComponent {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### E. Backend API: React Suspense vs Standard
|
||||||
|
```jsx
|
||||||
|
const user = new Automat(null, {}, { url: '/api/user' });
|
||||||
|
|
||||||
|
// Suspense: throws promise while unready
|
||||||
|
const Profile = () => <div>{user.read().name}</div>;
|
||||||
|
|
||||||
|
// Standard: check isReady
|
||||||
|
class ProfileStandard extends PureComponent {
|
||||||
|
constructor(props) { super(props); this.state = { u: user.state }; }
|
||||||
|
componentDidMount() { this.unsub = user.subscribe(this, (u) => ({ u })); }
|
||||||
|
componentWillUnmount() { this.unsub(); }
|
||||||
|
render() {
|
||||||
|
if (!user.isReady) return <div>Loading...</div>;
|
||||||
|
return <div>{this.state.u.name}</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### F. Cascading Invalidation & Memory Freeing with Blobs
|
||||||
|
```js
|
||||||
|
const photoHub = new Automat(null, {}, { url: '/api/photos' });
|
||||||
|
const photo1 = new Automat(null, {}, { url: '/api/photos/1', fetcher: fetchImageBlob });
|
||||||
|
|
||||||
|
// Automatically cascades setDirty when hub is dirtied
|
||||||
|
photo1.invalidateWith(photoHub);
|
||||||
|
|
||||||
|
// When photoHub.setDirty() is triggered:
|
||||||
|
// 1. photo1 calls URL.revokeObjectURL(photo1.state.url) to free browser image memory
|
||||||
|
// 2. photo1 resets to null/initial state
|
||||||
|
// 3. If photo1 is mounted, re-fetches immediately; if unmounted, defers fetch!
|
||||||
|
```
|
||||||
|
|
||||||
|
### G. Dynamic / Keyed Entities (`KeyedAutomat`)
|
||||||
|
|
||||||
|
For entity collections loaded by ID (e.g. categories, articles, users), `KeyedAutomat` provides per-ID reactive entries, concurrent in-flight deduplication, React Suspense, and fine-grained per-ID subscriptions:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { KeyedAutomat } from 'react-automat';
|
||||||
|
|
||||||
|
const categoryAutomat = new KeyedAutomat(null, {}, {
|
||||||
|
name: 'categories',
|
||||||
|
loader: async (id) => {
|
||||||
|
const res = await fetch(`/api/categories/${id}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Scoped handle for a specific ID:
|
||||||
|
const techHandle = categoryAutomat.key('tech');
|
||||||
|
|
||||||
|
// 2. React Suspense (auto-triggers load, throws in-flight promise):
|
||||||
|
const TechCategory = () => {
|
||||||
|
const category = categoryAutomat.read('tech'); // or techHandle.read()
|
||||||
|
return <h1>{category.name}</h1>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. PureComponent subscription (O(1) updates, 0 cross-talk between IDs):
|
||||||
|
class CategoryView extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.handle = categoryAutomat.key(props.id);
|
||||||
|
this.state = this.handle.state; // { data, loading, loaded, error }
|
||||||
|
}
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsub = this.handle.subscribe(this, (item) => ({
|
||||||
|
data: item.data,
|
||||||
|
loading: item.loading,
|
||||||
|
}));
|
||||||
|
this.handle.load(); // Deduplicated across concurrent mounts
|
||||||
|
}
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsub();
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
const { loading, data } = this.state;
|
||||||
|
if (loading) return <div>Loading category...</div>;
|
||||||
|
return <div>{data?.name}</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Core Invariants
|
||||||
|
|
||||||
|
1. **Lifecycle-Independent**: `Automat` instances live outside the React tree (typically in module scope), persisting state across component mounts and unmounts.
|
||||||
|
2. **Synchronous Constructor Reads**: Components initialize with `this.state = myAutomat.state;` directly in `constructor(props)`.
|
||||||
|
3. **Automatic Shallow Merges**: When subscribed with `subscribe(this)`, `automat.setState(partial)` calls `component.setState(partial)`, preserving any component-local state.
|
||||||
|
4. **Fine-Grained Selectors**: Passing a selector to `subscribe(this, selector)` runs shallow equality checks; updates to unrelated fields skip `setState` and avoid re-renders.
|
||||||
|
5. **Zero Wrappers**: No hooks, HOCs, Context Providers, or `connect()`.
|
||||||
|
6. **Optional Persistence**: Automats can specify a `name` and persist in the `window` object (`persist: false`) for session memory across unmounts/HMR, or in `IndexedDB` (`persist: true`) to survive full page reloads.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
### ✅ DO
|
### ✅ DO
|
||||||
- Define `Automat` instances in module scope.
|
- Define `Automat` instances in module scope.
|
||||||
- Read `automat.state` directly in `constructor(props)`.
|
- Read `automat.state` or `automat.getData()` directly in `constructor(props)`.
|
||||||
- Subscribe in `componentDidMount()` and unsubscribe in `componentWillUnmount()`.
|
- Subscribe in `componentDidMount()` and unsubscribe in `componentWillUnmount()`.
|
||||||
- Use selectors (`'key'`, `['keys']`, or function) on multi-field automats to avoid unnecessary re-renders.
|
- Use selectors (`'key'`, `['keys']`, or function) on multi-field automats to avoid unnecessary re-renders.
|
||||||
- Return `null` in `subscribeTo` transforms when an update should be skipped.
|
- Return `null` in `subscribeTo` transforms when an update should be skipped.
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Automat — React PureComponent State Manager</title>
|
<title>react-automat — React PureComponent State Manager</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Observable state management for React PureComponents. State lives independently of mounting — perfect for render minimization."
|
content="Higher-order state container for React PureComponents. State lives independently of mounting — perfect for render minimization."
|
||||||
/>
|
/>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -7,13 +7,20 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "automat",
|
"name": "automat",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"license": "0BSD",
|
||||||
"react": "^19.3.0",
|
|
||||||
"react-dom": "^19.3.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^6.1.1",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0",
|
||||||
"vite": "^8.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": {
|
"node_modules/@oxc-project/types": {
|
||||||
@@ -724,6 +731,7 @@
|
|||||||
"version": "19.3.0",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
|
||||||
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
|
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -733,6 +741,7 @@
|
|||||||
"version": "19.3.0",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
|
||||||
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
|
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.28.0"
|
"scheduler": "^0.28.0"
|
||||||
@@ -779,6 +788,7 @@
|
|||||||
"version": "0.28.0",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||||
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
|
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
|
|||||||
59
package.json
59
package.json
@@ -1,23 +1,68 @@
|
|||||||
{
|
{
|
||||||
"name": "automat",
|
"name": "react-automat",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Observable state management for React PureComponents — state independent of component lifecycle",
|
"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": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:lib": "vite build --config vite.config.lib.js",
|
"build:lib": "vite build --config vite.config.lib.js",
|
||||||
|
"prepublishOnly": "npm run build:lib",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"exports": {
|
"keywords": [
|
||||||
".": "./src/lib/index.js"
|
"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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"peerDependenciesMeta": {
|
||||||
"react": "^19.3.0",
|
"react-dom": {
|
||||||
"react-dom": "^19.3.0"
|
"optional": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^6.1.1",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0",
|
||||||
"vite": "^8.3.0"
|
"vite": "^8.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
244
src/App.jsx
244
src/App.jsx
@@ -9,10 +9,14 @@ import IndexSelector from './examples/components/IndexSelector.jsx';
|
|||||||
import DynamicAutomatSubscriber from './examples/components/DynamicAutomatSubscriber.jsx';
|
import DynamicAutomatSubscriber from './examples/components/DynamicAutomatSubscriber.jsx';
|
||||||
import CartManager from './examples/components/CartManager.jsx';
|
import CartManager from './examples/components/CartManager.jsx';
|
||||||
import CartBadgeDisplay from './examples/components/CartBadgeDisplay.jsx';
|
import CartBadgeDisplay from './examples/components/CartBadgeDisplay.jsx';
|
||||||
|
import UnconnectedPartialSubscriber from './examples/components/UnconnectedPartialSubscriber.jsx';
|
||||||
import AsyncApiPrimaryView from './examples/components/AsyncApiPrimaryView.jsx';
|
import AsyncApiPrimaryView from './examples/components/AsyncApiPrimaryView.jsx';
|
||||||
import AsyncApiCollapsibleView from './examples/components/AsyncApiCollapsibleView.jsx';
|
import AsyncApiCollapsibleView from './examples/components/AsyncApiCollapsibleView.jsx';
|
||||||
import MultiApiIndividualControls from './examples/components/MultiApiIndividualControls.jsx';
|
import MultiApiIndividualControls from './examples/components/MultiApiIndividualControls.jsx';
|
||||||
import MultiApiCombinedDashboard from './examples/components/MultiApiCombinedDashboard.jsx';
|
import MultiApiCombinedDashboard from './examples/components/MultiApiCombinedDashboard.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 {
|
class App extends PureComponent {
|
||||||
render() {
|
render() {
|
||||||
@@ -22,35 +26,39 @@ class App extends PureComponent {
|
|||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<div className="logo">
|
<div className="logo">
|
||||||
<span className="logo-icon">⚙</span>
|
<span className="logo-icon">⚙</span>
|
||||||
<span className="logo-text">automat</span>
|
<span className="logo-text">react-automat</span>
|
||||||
<span className="logo-version">v0.1.0</span>
|
<span className="logo-version">v0.1.0</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="tagline">
|
<p className="tagline">
|
||||||
Observable state management for React{' '}
|
Higher-order observable state container for React{' '}
|
||||||
<code className="inline-code">PureComponent</code>
|
<code className="inline-code">PureComponent</code>
|
||||||
<br />
|
<br />
|
||||||
State lives independently of mounting — zero prop drilling · Optional Window and IndexedDB persistence.
|
State lives independently of mounting — zero prop drilling · Optional Window and IndexedDB persistence.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="app-main">
|
{/* ── Notification Bar ───────────────────────────────────── */}
|
||||||
{/* ── Example 1: Counter ────────────────────────────────── */}
|
<NotificationBar />
|
||||||
|
|
||||||
|
{/* ── Main Content ───────────────────────────────────────── */}
|
||||||
|
<main className="examples-container">
|
||||||
|
{/* ── Example 1: Basic Counter ──────────────────────────── */}
|
||||||
<section className="example-section" aria-labelledby="ex1-title">
|
<section className="example-section" aria-labelledby="ex1-title">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div className="section-title-row">
|
<div className="section-title-row">
|
||||||
<span className="section-number">01</span>
|
<span className="section-number">01</span>
|
||||||
<h2 id="ex1-title">Counter</h2>
|
<h2 id="ex1-title">Multi-Subscriber Sync & Local State Isolation</h2>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
Two independent <code className="inline-code">PureComponent</code>s
|
Two completely separate components subscribe to the same <code className="inline-code">counterAutomat</code>.
|
||||||
synchronized by a single <code className="inline-code">counterAutomat</code>.
|
The controller button also maintains local UI state (<code className="inline-code">lastPressed</code>, animation classes).
|
||||||
Neither knows the other exists — they both subscribe to the same state container.
|
Neither component passes props to the other.
|
||||||
</p>
|
</p>
|
||||||
<div className="flow-diagram" aria-label="Data flow">
|
<div className="flow-diagram" aria-label="Component and Automat data flow">
|
||||||
<span className="flow-node">counterAutomat</span>
|
<span className="flow-node">CounterButton</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">counterAutomat.actions.increment()</span>
|
||||||
<span className="flow-arrow">→</span>
|
<span className="flow-arrow">→</span>
|
||||||
<span className="flow-node flow-node-component">CounterButton</span>
|
|
||||||
<span className="flow-sep">&</span>
|
|
||||||
<span className="flow-node flow-node-component">CounterDisplay</span>
|
<span className="flow-node flow-node-component">CounterDisplay</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,85 +71,51 @@ class App extends PureComponent {
|
|||||||
|
|
||||||
<div className="section-divider" />
|
<div className="section-divider" />
|
||||||
|
|
||||||
{/* ── Example 2: Cascade ───────────────────────────────── */}
|
{/* ── Example 2: Cascading State ────────────────────────── */}
|
||||||
<section className="example-section" aria-labelledby="ex2-title">
|
<section className="example-section" aria-labelledby="ex2-title">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div className="section-title-row">
|
<div className="section-title-row">
|
||||||
<span className="section-number">02</span>
|
<span className="section-number">02</span>
|
||||||
<h2 id="ex2-title">Notification Cascade</h2>
|
<h2 id="ex2-title">Cascading Automats (Reactive Transformation)</h2>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<code className="inline-code">subscribeTo()</code> connects two automats into a
|
<code className="inline-code">notificationAutomat</code> derives its state from{' '}
|
||||||
<strong> reactive pipeline</strong>. Clicking triggers in <code className="inline-code">CascadeControls</code> mutates{' '}
|
<code className="inline-code">counterAutomat</code> using <code className="inline-code">subscribeTo</code>.
|
||||||
<code className="inline-code">counterAutomat</code> (upstream). This automatically invokes the{' '}
|
Every state change cascades automatically without manual event dispatching.
|
||||||
<code className="inline-code">subscribeTo()</code> transform callback, updating{' '}
|
|
||||||
<code className="inline-code">notificationAutomat</code> (downstream), which then re-renders{' '}
|
|
||||||
<code className="inline-code">NotificationBar</code> without any prop drilling or shared component parent.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flow-diagram" aria-label="Cascade data flow">
|
<div className="flow-diagram" aria-label="Cascade data flow">
|
||||||
<span className="flow-node">User Click</span>
|
<span className="flow-node">counterAutomat</span>
|
||||||
<span className="flow-arrow">→</span>
|
<span className="flow-arrow">→</span>
|
||||||
<span className="flow-node">counterAutomat.actions.increment()</span>
|
<span className="flow-node">transform(counterState)</span>
|
||||||
<span className="flow-arrow">→</span>
|
<span className="flow-arrow">→</span>
|
||||||
<span className="flow-node">subscribeTo(upstream, transform)</span>
|
<span className="flow-node flow-node-component">notificationAutomat</span>
|
||||||
<span className="flow-arrow">→</span>
|
|
||||||
<span className="flow-node">notificationAutomat.setState()</span>
|
|
||||||
<span className="flow-arrow">→</span>
|
|
||||||
<span className="flow-node flow-node-component">NotificationBar</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="info-box">
|
|
||||||
<div className="info-box-title">
|
|
||||||
<span>ℹ️</span> How <code>subscribeTo(upstreamAutomat, transform)</code> Works
|
|
||||||
</div>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<strong><code>upstreamState</code>:</strong> The newly emitted state from the upstream automat (e.g. <code>{`{ count: 1 }`}</code>).
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong><code>myState</code>:</strong> The current state of this downstream automat right before updating (e.g. <code>{`{ messages: [...] }`}</code>). Acts as an accumulator to prepend events and cap history.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Return Value:</strong> The returned partial state object is automatically applied via <code>this.setState(partial)</code>, notifying downstream UI subscribers.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Conditional Filtering:</strong> Return <code>null</code> or <code>undefined</code> to selectively skip updates and avoid triggering renders.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="two-columns">
|
<CascadeControls />
|
||||||
<CascadeControls />
|
|
||||||
<NotificationBar />
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="section-divider" />
|
<div className="section-divider" />
|
||||||
|
|
||||||
{/* ── Example 3: API Auto-Sync ─────────────────────────── */}
|
{/* ── Example 3: Backend Synchronization ────────────────── */}
|
||||||
<section className="example-section" aria-labelledby="ex3-title">
|
<section className="example-section" aria-labelledby="ex3-title">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div className="section-title-row">
|
<div className="section-title-row">
|
||||||
<span className="section-number">03</span>
|
<span className="section-number">03</span>
|
||||||
<h2 id="ex3-title">API-Backed Auto-Sync Counter (POST)</h2>
|
<h2 id="ex3-title">Backend Synchronization & Optimistic Updates</h2>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<code className="inline-code">syncCounterAutomat</code> manages an indexed counter that automatically
|
State changes trigger asynchronous backend persistence via a custom fetch handler in the Automat’s actions.
|
||||||
synchronizes its mutations to the backend via HTTP <code className="inline-code">POST /api/counter</code>.
|
A polling subscriber independently monitors the server-side state without polling the Automat itself.
|
||||||
State updates are applied <strong>optimistically</strong> for instant UI response, followed by asynchronous
|
|
||||||
background synchronization and status tracking without any component-level <code className="inline-code">fetch</code> or lifecycle glue.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flow-diagram" aria-label="API auto-sync flow">
|
<div className="flow-diagram" aria-label="Backend sync flow">
|
||||||
<span className="flow-node">User Click</span>
|
<span className="flow-node">SyncCounterControls</span>
|
||||||
<span className="flow-arrow">→</span>
|
<span className="flow-arrow">→</span>
|
||||||
<span className="flow-node">Optimistic setState()</span>
|
<span className="flow-node">syncCounterAutomat</span>
|
||||||
|
<span className="flow-arrow">⇄</span>
|
||||||
|
<span className="flow-node">Backend API</span>
|
||||||
<span className="flow-arrow">→</span>
|
<span className="flow-arrow">→</span>
|
||||||
<span className="flow-node">POST /api/counter</span>
|
<span className="flow-node flow-node-component">SyncBackendMonitor</span>
|
||||||
<span className="flow-arrow">→</span>
|
|
||||||
<span className="flow-node">Backend Database Updated</span>
|
|
||||||
<span className="flow-arrow">→</span>
|
|
||||||
<span className="flow-node flow-node-component">Sync Status 200 OK</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -153,25 +127,24 @@ class App extends PureComponent {
|
|||||||
|
|
||||||
<div className="section-divider" />
|
<div className="section-divider" />
|
||||||
|
|
||||||
{/* ── Example 4: Dynamic Map Resubscription ──────────── */}
|
{/* ── Example 4: Dynamic Slot Allocation & Persistence ──── */}
|
||||||
<section className="example-section" aria-labelledby="ex4-title">
|
<section className="example-section" aria-labelledby="ex4-title">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div className="section-title-row">
|
<div className="section-title-row">
|
||||||
<span className="section-number">04</span>
|
<span className="section-number">04</span>
|
||||||
<h2 id="ex4-title">Dynamic Window Map (Index-Driven Resubscription)</h2>
|
<h2 id="ex4-title">Dynamic Slot Allocation & Unmount-Safe State Recall</h2>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
The left side is a standard <code className="inline-code">indexAutomat</code> counter (like Example 1).
|
Demonstrates how state lives <em>outside</em> the React component tree.
|
||||||
On the right side, that counter’s value is used to dynamically lookup, instantiate, and resubscribe to
|
Changing the active index resubscribes the display component to a different named Automat.
|
||||||
a different <code className="inline-code">Automat</code> stored in <code className="inline-code">window.automats = new Map()</code>.
|
Previous slot state is never destroyed on unmount — return to any slot at any time to find its clicks and notes intact.
|
||||||
Stepping through indexes unhooks from the old Automat, subscribes to the new one, and instantly recalls its preserved state.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flow-diagram" aria-label="Index-driven resubscription flow">
|
<div className="flow-diagram" aria-label="Dynamic slot data flow">
|
||||||
<span className="flow-node">Shared Index: N</span>
|
<span className="flow-node">IndexSelector</span>
|
||||||
<span className="flow-arrow">→</span>
|
<span className="flow-arrow">→</span>
|
||||||
<span className="flow-node">window.automats.get(N)</span>
|
<span className="flow-node">indexAutomat.state.index</span>
|
||||||
<span className="flow-arrow">→</span>
|
<span className="flow-arrow">→</span>
|
||||||
<span className="flow-node">Unsub Old / Sub New</span>
|
<span className="flow-node">Unsub Old Slot / Sub New Slot</span>
|
||||||
<span className="flow-arrow">→</span>
|
<span className="flow-arrow">→</span>
|
||||||
<span className="flow-node flow-node-component">Recall State for Slot #N</span>
|
<span className="flow-node flow-node-component">Recall State for Slot #N</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,7 +155,7 @@ class App extends PureComponent {
|
|||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<strong>Window Object (<code>persist: false</code>):</strong> When an automat is configured with <code>name</code>, it registers in the <code>window</code> object. Its state persists across component unmounts and HMR reloads. Global lookup is available via <code>Automat.get(name)</code>.
|
<strong>Window Object (<code>persist: false</code>):</strong> While any Automat is unmount-safe by default, setting a <code>name</code> registers it in the <code>window</code> object. State persists across module re-evaluations and HMR, and enables cross-module lookup via <code>Automat.get(name)</code>.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>IndexedDB (<code>persist: true</code>):</strong> Setting <code>persist: true</code> automatically synchronizes state snapshots to IndexedDB under the automat's <code>name</code>, surviving full page reloads and browser restarts.
|
<strong>IndexedDB (<code>persist: true</code>):</strong> Setting <code>persist: true</code> automatically synchronizes state snapshots to IndexedDB under the automat's <code>name</code>, surviving full page reloads and browser restarts.
|
||||||
@@ -204,29 +177,32 @@ class App extends PureComponent {
|
|||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div className="section-title-row">
|
<div className="section-title-row">
|
||||||
<span className="section-number">05</span>
|
<span className="section-number">05</span>
|
||||||
<h2 id="ex5-title">Partial State Subscription (Cart Length vs Content)</h2>
|
<h2 id="ex5-title">Partial State Subscription & Unconnected Objects</h2>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
A subscriber can subscribe to only part of an automat’s state. The <code className="inline-code">CartBadgeDisplay</code> on
|
A subscriber can subscribe to only part of an automat’s state. On the right, two different partial subscribers demonstrate this power:
|
||||||
the right subscribes <strong>only to list length</strong> (<code className="inline-code">items.length</code>) using a slice selector.
|
<br />
|
||||||
Modifying item quantities, changing names, or typing coupon codes mutates <code className="inline-code">cartAutomat</code> state,
|
1. <code className="inline-code">CartBadgeDisplay</code> subscribes <strong>only to list length</strong> (<code className="inline-code">items.length</code>) — item quantity and coupon edits cause <strong>zero re-renders</strong>.
|
||||||
but because the list length is unchanged, <code className="inline-code">Automat</code>’s internal shallow equality check skips <code className="inline-code">setState()</code> — producing <strong>zero badge re-renders</strong>.
|
<br />
|
||||||
With <code className="inline-code">{`{ name: 'cart', persist: true }`}</code>, this cart state also automatically survives full page reloads via IndexedDB.
|
2. <code className="inline-code">UnconnectedPartialSubscriber</code> has <strong>zero imports</strong> of the cart store, resolving it strictly via the named window object (<code className="inline-code">Automat.get('cart')</code>), and subscribes only to <code className="inline-code">['couponCode']</code>.
|
||||||
</p>
|
</p>
|
||||||
<div className="flow-diagram" aria-label="Partial subscription data flow">
|
<div className="flow-diagram" aria-label="Partial subscription data flow">
|
||||||
<span className="flow-node">cartAutomat.setState()</span>
|
<span className="flow-node">cartAutomat.setState()</span>
|
||||||
<span className="flow-arrow">→</span>
|
<span className="flow-arrow">→</span>
|
||||||
<span className="flow-node">selector(state)</span>
|
<span className="flow-node">window.__AUTOMATS__.get('cart')</span>
|
||||||
<span className="flow-arrow">→</span>
|
<span className="flow-arrow">→</span>
|
||||||
<span className="flow-node">shallowEqual(last, next)</span>
|
<span className="flow-node">shallowEqual(lastSlice, nextSlice)</span>
|
||||||
<span className="flow-arrow">→</span>
|
<span className="flow-arrow">→</span>
|
||||||
<span className="flow-node flow-node-component">Render ONLY on Length Change</span>
|
<span className="flow-node flow-node-component">Render ONLY if Selected Slice Changed</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="two-columns">
|
<div className="two-columns">
|
||||||
<CartManager />
|
<CartManager />
|
||||||
<CartBadgeDisplay />
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<CartBadgeDisplay />
|
||||||
|
<UnconnectedPartialSubscriber />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -300,11 +276,105 @@ class App extends PureComponent {
|
|||||||
<MultiApiCombinedDashboard />
|
<MultiApiCombinedDashboard />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div className="section-divider" />
|
||||||
|
|
||||||
|
{/* ── Example 8: Backend API Binding & Suspense ─────── */}
|
||||||
|
<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">Backend API Binding, React Suspense & Dirty Invalidation</h2>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
An automat can bind directly to a backend URL (<code className="inline-code">url: '/api/profile'</code>).
|
||||||
|
Supports <code className="inline-code">await automat.ready</code>, React Suspense via <code className="inline-code">automat.read()</code>,
|
||||||
|
and manual invalidation via <code className="inline-code">automat.setDirty()</code>.
|
||||||
|
When dirty, state resets to default or null. If mounted, it reloads immediately. If all subscribers are unmounted,
|
||||||
|
the reload is deferred until a component mounts or <code className="inline-code">read()</code> is invoked!
|
||||||
|
</p>
|
||||||
|
<div className="flow-diagram" aria-label="Backend binding and dirty flow">
|
||||||
|
<span className="flow-node">new Automat(null, {}, { url })</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">await ready / read() (Suspense)</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">setDirty()</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node flow-node-component">Immediate if Mounted / Deferred if Unmounted</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ApiBoundExample />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="section-divider" />
|
||||||
|
|
||||||
|
{/* ── Example 9: Cascading setDirty, Blobs & SSE ────── */}
|
||||||
|
<section className="example-section" aria-labelledby="ex9-title">
|
||||||
|
<div className="section-header">
|
||||||
|
<div className="section-title-row">
|
||||||
|
<span className="section-number">09</span>
|
||||||
|
<h2 id="ex9-title">Cascading setDirty, Binary Blobs & Server-Sent Events (SSE)</h2>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Demonstrates <strong>cascading invalidation</strong>, <strong>automatic Blob memory revoking</strong>,
|
||||||
|
and <strong>Server-Sent Events (SSE)</strong> integration:
|
||||||
|
<br />
|
||||||
|
• Multiple photo automats (<code className="inline-code">photo/1</code>, <code className="inline-code">photo/2</code>, <code className="inline-code">photo/3</code>)
|
||||||
|
subscribe to a central hub (<code className="inline-code">photos/all</code>) via <code className="inline-code">invalidateWith(photosAllAutomat)</code>.
|
||||||
|
<br />
|
||||||
|
• When <code className="inline-code">photos/all.setDirty()</code> is called (or an SSE remote invalidation message arrives),
|
||||||
|
all child automats cascade <code className="inline-code">setDirty()</code>, <strong>automatically revoking their image Blob object URLs</strong> and releasing memory.
|
||||||
|
<br />
|
||||||
|
• Unmounted automats remain in freed/null state until re-mounted, deferring network fetches and saving client memory!
|
||||||
|
</p>
|
||||||
|
<div className="flow-diagram" aria-label="Cascading invalidation and SSE flow">
|
||||||
|
<span className="flow-node">SSE Remote Event / User Click</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">photos/all.setDirty()</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">cascade invalidateWith()</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">URL.revokeObjectURL() & Free Memory</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node flow-node-component">photo/1, photo/2, photo/3</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CascadingPhotosExample />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="section-divider" />
|
||||||
|
|
||||||
|
{/* ── Example 10: Dictionary Combined Automats ───────── */}
|
||||||
|
<section className="example-section" aria-labelledby="ex10-title">
|
||||||
|
<div className="section-header">
|
||||||
|
<div className="section-title-row">
|
||||||
|
<span className="section-number">10</span>
|
||||||
|
<h2 id="ex10-title">Dictionary Combined Automats</h2>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<code className="inline-code">Automat.combine()</code> with an object map 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>
|
</main>
|
||||||
|
|
||||||
<footer className="app-footer">
|
<footer className="app-footer">
|
||||||
<p>
|
<p>
|
||||||
<code className="inline-code">automat</code> · MIT ·{' '}
|
<code className="inline-code">react-automat</code> · MIT ·{' '}
|
||||||
State is independent of component lifecycle
|
State is independent of component lifecycle
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
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,
|
||||||
|
});
|
||||||
73
src/examples/automats/photosAutomat.js
Normal file
73
src/examples/automats/photosAutomat.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Automat, fetchJson, fetchImageBlob, connectSSE } from '../../lib/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parent Automat: "photos/all"
|
||||||
|
*
|
||||||
|
* Manages the collection metadata and acts as the central invalidation hub.
|
||||||
|
* Listens to Server-Sent Events (SSE) via connectSSE to handle remote invalidation signals.
|
||||||
|
*/
|
||||||
|
export const photosAllAutomat = new Automat(
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
onSSETrigger(data, event, automat) {
|
||||||
|
console.log('[SSE Trigger Received]:', event, data);
|
||||||
|
if (data?.target === 'photos' || event === 'invalidate') {
|
||||||
|
automat.setDirty();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'photos/all',
|
||||||
|
url: '/api/photos',
|
||||||
|
fetcher: fetchJson,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Connect parent automat to Server-Sent Events stream:
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
connectSSE(photosAllAutomat, '/api/events/sse', {
|
||||||
|
onMessage: (data, event) => {
|
||||||
|
console.log('[SSE Stream Message]:', event, data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory / Registry for individual photo automats: "photo/:id"
|
||||||
|
*
|
||||||
|
* Each photo automat:
|
||||||
|
* 1. Fetches an Image Blob via `fetchImageBlob`
|
||||||
|
* 2. Cascades invalidation from `photosAllAutomat` via `invalidateWith(photosAllAutomat)`
|
||||||
|
* 3. On dirty, its `revoke()` method is called automatically, freeing the Blob URL from memory!
|
||||||
|
* 4. Bound to window under `photo/${id}` for global discovery
|
||||||
|
*/
|
||||||
|
const photoInstances = new Map();
|
||||||
|
|
||||||
|
export const getOrCreatePhotoAutomat = (id) => {
|
||||||
|
const name = `photo/${id}`;
|
||||||
|
if (photoInstances.has(id)) {
|
||||||
|
return photoInstances.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const automat = new Automat(
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
url: `/api/photos/${id}`,
|
||||||
|
fetcher: fetchImageBlob,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 💡 Cascading setDirty: Whenever photosAllAutomat is dirtied, this photo automat
|
||||||
|
// cascades setDirty(), revoking its blob URL and freeing heap memory!
|
||||||
|
automat.invalidateWith(photosAllAutomat);
|
||||||
|
|
||||||
|
photoInstances.set(id, automat);
|
||||||
|
return automat;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize photo automats 1, 2, 3
|
||||||
|
export const photo1 = getOrCreatePhotoAutomat(1);
|
||||||
|
export const photo2 = getOrCreatePhotoAutomat(2);
|
||||||
|
export const photo3 = getOrCreatePhotoAutomat(3);
|
||||||
27
src/examples/automats/profileAutomat.js
Normal file
27
src/examples/automats/profileAutomat.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Automat } from '../../lib/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend API-bound Automat.
|
||||||
|
*
|
||||||
|
* Demonstrates:
|
||||||
|
* 1. Binding to backend API URL via `url: '/api/profile'`.
|
||||||
|
* 2. Awaiting ready state via `await profileAutomat.ready`.
|
||||||
|
* 3. React Suspense support via `profileAutomat.read()`.
|
||||||
|
* 4. Standard subscriber support via `subscribe(this)`.
|
||||||
|
* 5. Invalidation via `profileAutomat.setDirty()`:
|
||||||
|
* - Resets state to null (since no defaults were given) or default state.
|
||||||
|
* - When mounted (subscribers > 0): reloads immediately.
|
||||||
|
* - When unmounted (subscribers == 0): defers reload until read() or subscribe().
|
||||||
|
*/
|
||||||
|
export const profileAutomat = new Automat(
|
||||||
|
null, // No defaults passed -> resets to null state when set dirty
|
||||||
|
{
|
||||||
|
setDirty() {
|
||||||
|
profileAutomat.setDirty();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'profile',
|
||||||
|
url: '/api/profile',
|
||||||
|
}
|
||||||
|
);
|
||||||
439
src/examples/components/ApiBoundExample.jsx
Normal file
439
src/examples/components/ApiBoundExample.jsx
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
import { PureComponent, Suspense } from 'react';
|
||||||
|
import { profileAutomat } from '../automats/profileAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component 1: Standard PureComponent without React Suspense.
|
||||||
|
* Checks `profileAutomat.isReady` and handles loading state manually.
|
||||||
|
*/
|
||||||
|
class ProfileWithoutSuspense extends PureComponent {
|
||||||
|
_renderCount = 0;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
profile: profileAutomat.state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsubscribe = profileAutomat.subscribe(this, (state) => ({
|
||||||
|
profile: state,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubscribe) this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this._renderCount++;
|
||||||
|
const { profile } = this.state;
|
||||||
|
const isReady = profileAutomat.isReady && profile !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-body" style={{ minHeight: 220 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<span className="badge badge-api">Mode: Without Suspense</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
Renders: <strong style={{ color: 'var(--accent)' }}>#{this._renderCount}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isReady ? (
|
||||||
|
<div style={{ padding: '24px 0', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 24, marginBottom: 8, animation: 'spin 1s linear infinite' }}>⏳</div>
|
||||||
|
<div style={{ fontWeight: 600, color: 'var(--text-2)' }}>Fetching from /api/profile...</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>
|
||||||
|
(isReady is false; state is in null/default state)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, #10b981, #059669)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 20,
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 700
|
||||||
|
}}>
|
||||||
|
{profile.name?.charAt(0) || 'U'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ margin: 0, fontSize: 17, color: 'var(--text-1)' }}>{profile.name}</h4>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{profile.role} · {profile.department}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.03)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '10px 14px',
|
||||||
|
fontSize: 13,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between'
|
||||||
|
}}>
|
||||||
|
<span>API Fetch Sequence: <strong>#{profile.fetchCount}</strong></span>
|
||||||
|
<span style={{ color: 'var(--text-3)' }}>Loaded at: {profile.loadedAt}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component 2: PureComponent utilizing React Suspense via `profileAutomat.read()`.
|
||||||
|
*/
|
||||||
|
class ProfileSuspenseConsumer extends PureComponent {
|
||||||
|
_renderCount = 0;
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Subscribe to re-render when future mutations occur
|
||||||
|
this.unsubscribe = profileAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubscribe) this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this._renderCount++;
|
||||||
|
// Reads from automat — throws profileAutomat.ready if not ready!
|
||||||
|
const profile = profileAutomat.read();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-body" style={{ minHeight: 220 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<span className="badge badge-cascade">Mode: With React Suspense</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
Renders: <strong style={{ color: 'var(--accent)' }}>#{this._renderCount}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, #6366f1, #4f46e5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 20,
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 700
|
||||||
|
}}>
|
||||||
|
{profile?.name?.charAt(0) || 'S'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ margin: 0, fontSize: 17, color: 'var(--text-1)' }}>{profile?.name}</h4>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{profile?.role} · {profile?.department}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.03)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '10px 14px',
|
||||||
|
fontSize: 13,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between'
|
||||||
|
}}>
|
||||||
|
<span>API Fetch Sequence: <strong>#{profile?.fetchCount}</strong></span>
|
||||||
|
<span style={{ color: 'var(--text-3)' }}>Loaded at: {profile?.loadedAt}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback shown by <Suspense> while profileAutomat is unready.
|
||||||
|
*/
|
||||||
|
function SuspenseFallback() {
|
||||||
|
return (
|
||||||
|
<div className="card-body" style={{ minHeight: 220, textAlign: 'center', padding: '36px 0' }}>
|
||||||
|
<div style={{ fontSize: 24, marginBottom: 8, animation: 'pulse 1.2s ease-in-out infinite' }}>⚡</div>
|
||||||
|
<div style={{ fontWeight: 600, color: 'var(--accent-light)' }}>
|
||||||
|
Suspended by React Suspense Fallback
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>
|
||||||
|
profileAutomat.read() threw the ready Promise; Suspense caught it!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Controller Component managing Tabs and Demonstrating setDirty / Unmount behaviors.
|
||||||
|
*/
|
||||||
|
export default class ApiBoundExample extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
activeTab: 'without-suspense', // 'without-suspense' | 'with-suspense' | 'unmounted'
|
||||||
|
logMessage: null,
|
||||||
|
tick: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Monitor status changes on automat
|
||||||
|
this.unsubMonitor = profileAutomat.subscribe(() => {
|
||||||
|
this.forceUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubMonitor) this.unsubMonitor();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSetDirty = () => {
|
||||||
|
profileAutomat.setDirty();
|
||||||
|
const subs = profileAutomat.subscriberCount;
|
||||||
|
if (subs > 0) {
|
||||||
|
this.setState({
|
||||||
|
logMessage: `setDirty() called with ${subs} subscriber(s) mounted → Immediate reload started!`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.setState({
|
||||||
|
logMessage: `setDirty() called with 0 subscribers → Reload deferred until read() or subscribe()!`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleManualRead = () => {
|
||||||
|
try {
|
||||||
|
const data = profileAutomat.read();
|
||||||
|
this.setState({
|
||||||
|
logMessage: `Manual read() called! State was ready: ${JSON.stringify(data)}`,
|
||||||
|
});
|
||||||
|
} catch (promiseOrErr) {
|
||||||
|
if (typeof promiseOrErr?.then === 'function') {
|
||||||
|
this.setState({
|
||||||
|
logMessage: `Manual read() triggered deferred reload! Awaiting ready promise...`,
|
||||||
|
});
|
||||||
|
promiseOrErr.then((res) => {
|
||||||
|
this.setState({
|
||||||
|
logMessage: `Deferred reload finished! Data: ${res.name} (Fetch #${res.fetchCount})`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.setState({ logMessage: `Error: ${promiseOrErr.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAwaitReady = async () => {
|
||||||
|
this.setState({ logMessage: 'Awaiting profileAutomat.ready...' });
|
||||||
|
const data = await profileAutomat.ready;
|
||||||
|
this.setState({
|
||||||
|
logMessage: `profileAutomat.ready resolved with: ${data ? data.name : 'null'} (Fetch #${data?.fetchCount})`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSilentReload = async () => {
|
||||||
|
this.setState({ logMessage: 'Silent reload() in progress (isReady remains true, no flash)...' });
|
||||||
|
try {
|
||||||
|
const fresh = await profileAutomat.reload();
|
||||||
|
this.setState({
|
||||||
|
logMessage: `Silent reload() finished! Fresh data: ${fresh.name} (Fetch #${fresh.fetchCount}) without unready flash!`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ logMessage: `Reload failed: ${err.message}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { activeTab, logMessage } = this.state;
|
||||||
|
const isReady = profileAutomat.isReady;
|
||||||
|
const isDirty = profileAutomat.isDirty;
|
||||||
|
const subsCount = profileAutomat.subscriberCount;
|
||||||
|
const currentState = profileAutomat.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="two-columns" style={{ alignItems: 'flex-start' }}>
|
||||||
|
{/* Left Column: Tabbed Subscriber Interface */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header" style={{ paddingBottom: 0, borderBottom: 'none' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className="card-icon">🌐</span>
|
||||||
|
<h3>Backend-Bound Automat</h3>
|
||||||
|
</div>
|
||||||
|
<span className="badge badge-api">url: "/api/profile"</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginTop: 14, width: '100%', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${activeTab === 'without-suspense' ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
|
style={{ borderRadius: '6px 6px 0 0', borderBottom: 'none' }}
|
||||||
|
onClick={() => this.setState({ activeTab: 'without-suspense' })}
|
||||||
|
>
|
||||||
|
1. Without Suspense
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${activeTab === 'with-suspense' ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
|
style={{ borderRadius: '6px 6px 0 0', borderBottom: 'none' }}
|
||||||
|
onClick={() => this.setState({ activeTab: 'with-suspense' })}
|
||||||
|
>
|
||||||
|
2. With Suspense
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${activeTab === 'unmounted' ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
|
style={{ borderRadius: '6px 6px 0 0', borderBottom: 'none' }}
|
||||||
|
onClick={() => this.setState({ activeTab: 'unmounted' })}
|
||||||
|
>
|
||||||
|
3. Unmounted Tab
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
{activeTab === 'without-suspense' && <ProfileWithoutSuspense />}
|
||||||
|
|
||||||
|
{activeTab === 'with-suspense' && (
|
||||||
|
<Suspense fallback={<SuspenseFallback />}>
|
||||||
|
<ProfileSuspenseConsumer />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'unmounted' && (
|
||||||
|
<div className="card-body" style={{ minHeight: 220, textAlign: 'center', padding: '28px 20px' }}>
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 8 }}>📴</div>
|
||||||
|
<h4 style={{ margin: '0 0 6px', color: 'var(--text-1)' }}>Subscriber Unmounted</h4>
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-3)', maxWidth: 360, marginInline: 'auto' }}>
|
||||||
|
Neither subscriber is mounted. If you click <strong>Set Dirty</strong> now, state resets to null/default,
|
||||||
|
but the backend fetch is <strong>deferred</strong> until a subscriber mounts or <code>read()</code> is called.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Invalidation & Deferred Reload Controls */}
|
||||||
|
<div className="card card-display">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">⚡</span>
|
||||||
|
<h3>Dirty State & Reload Inspector</h3>
|
||||||
|
<span className={`badge ${isReady ? 'badge-success' : 'badge-warning'}`}>
|
||||||
|
{isReady ? 'Ready' : 'Not Ready'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Realtime Metrics */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10, marginBottom: 16 }}>
|
||||||
|
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border-subtle)', borderRadius: 8, padding: '8px 10px', textAlign: 'center' }}>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', display: 'block' }}>isReady</span>
|
||||||
|
<strong style={{ fontSize: 16, color: isReady ? 'var(--accent)' : '#f59e0b' }}>
|
||||||
|
{isReady ? 'true' : 'false'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border-subtle)', borderRadius: 8, padding: '8px 10px', textAlign: 'center' }}>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', display: 'block' }}>isDirty</span>
|
||||||
|
<strong style={{ fontSize: 16, color: isDirty ? '#ef4444' : 'var(--text-2)' }}>
|
||||||
|
{isDirty ? 'true' : 'false'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border-subtle)', borderRadius: 8, padding: '8px 10px', textAlign: 'center' }}>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', display: 'block' }}>Subscribers</span>
|
||||||
|
<strong style={{ fontSize: 16, color: subsCount > 0 ? 'var(--accent-light)' : 'var(--text-3)' }}>
|
||||||
|
{subsCount}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current State Snapshot */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
Current automat.state (resets to null when dirty):
|
||||||
|
</span>
|
||||||
|
<pre style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(0, 0, 0, 0.3)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
overflowX: 'auto',
|
||||||
|
color: currentState ? '#6ee7b7' : '#f87171'
|
||||||
|
}}>
|
||||||
|
{currentState ? JSON.stringify(currentState, null, 2) : 'null (unready / dirty)'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={this.handleSetDirty}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
⚡ Call profileAutomat.setDirty() (Resets state, causes reload)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={this.handleSilentReload}
|
||||||
|
style={{ width: '100%', borderColor: 'var(--accent)', color: 'var(--accent-light)' }}
|
||||||
|
title="Reloads data without resetting state or marking unready"
|
||||||
|
>
|
||||||
|
🔄 Call profileAutomat.reload() (Silent refresh, no flash)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={this.handleManualRead}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
title="Demonstrates triggering reload via read() when unmounted"
|
||||||
|
>
|
||||||
|
📖 Call read()
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={this.handleAwaitReady}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
title="Awaits profileAutomat.ready promise"
|
||||||
|
>
|
||||||
|
⏳ await ready
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Log Box */}
|
||||||
|
{logMessage && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: 12,
|
||||||
|
padding: '8px 10px',
|
||||||
|
background: 'rgba(99, 102, 241, 0.1)',
|
||||||
|
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||||
|
borderRadius: 6,
|
||||||
|
color: 'var(--text-2)'
|
||||||
|
}}>
|
||||||
|
💡 {logMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
428
src/examples/components/CascadingPhotosExample.jsx
Normal file
428
src/examples/components/CascadingPhotosExample.jsx
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { Automat, fetchPdfBlob } from '../../lib/index.js';
|
||||||
|
import { photosAllAutomat, getOrCreatePhotoAutomat } from '../automats/photosAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PDF Document Automat demonstrating `fetchPdfBlob` and memory cleanup.
|
||||||
|
*/
|
||||||
|
const pdfDocAutomat = new Automat(
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
name: 'docs/spec',
|
||||||
|
url: '/api/docs/spec',
|
||||||
|
fetcher: fetchPdfBlob,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual Photo Card Component.
|
||||||
|
* Can be independently mounted/unmounted to demonstrate deferred reload on unmounted automats.
|
||||||
|
*/
|
||||||
|
class PhotoCard extends PureComponent {
|
||||||
|
_renderCount = 0;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const automat = getOrCreatePhotoAutomat(props.id);
|
||||||
|
this.state = {
|
||||||
|
data: automat.state,
|
||||||
|
isReady: automat.isReady,
|
||||||
|
isDirty: automat.isDirty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const automat = getOrCreatePhotoAutomat(this.props.id);
|
||||||
|
this.unsubscribe = automat.subscribe((state) => {
|
||||||
|
this.setState({
|
||||||
|
data: state,
|
||||||
|
isReady: automat.isReady,
|
||||||
|
isDirty: automat.isDirty,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubscribe) {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSingleDirty = () => {
|
||||||
|
const automat = getOrCreatePhotoAutomat(this.props.id);
|
||||||
|
automat.setDirty();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this._renderCount++;
|
||||||
|
const { id, title } = this.props;
|
||||||
|
const automat = getOrCreatePhotoAutomat(id);
|
||||||
|
const { data, isReady, isDirty } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface-2)',
|
||||||
|
border: isDirty ? '1px dashed #ef4444' : '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'relative', height: 160, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{isReady && data?.url ? (
|
||||||
|
<img
|
||||||
|
src={data.url}
|
||||||
|
alt={title}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ textAlign: 'center', padding: 12 }}>
|
||||||
|
<div style={{ fontSize: 24, marginBottom: 4 }}>{isDirty ? '🗑️' : '⏳'}</div>
|
||||||
|
<div style={{ fontSize: 12, color: isDirty ? '#ef4444' : 'var(--accent-light)', fontWeight: 600 }}>
|
||||||
|
{isDirty ? 'Memory Freed (Blob Revoked)' : 'Loading Blob...'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>State: null</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`badge ${isReady ? 'badge-success' : 'badge-warning'}`}
|
||||||
|
style={{ position: 'absolute', top: 8, right: 8, backdropFilter: 'blur(4px)' }}
|
||||||
|
>
|
||||||
|
photo/{id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8, flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h4 style={{ margin: 0, fontSize: 14, color: 'var(--text-1)' }}>{title}</h4>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>Renders: #{this._renderCount}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-2)', background: 'var(--surface-1)', padding: '6px 8px', borderRadius: 4, fontFamily: 'monospace' }}>
|
||||||
|
{data?.url ? (
|
||||||
|
<div>
|
||||||
|
<span style={{ color: '#10b981' }}>● Active Blob:</span> {(data.size / 1024).toFixed(1)} KB
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: '#ef4444' }}>● Memory Released (0 bytes)</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 'auto', display: 'flex', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={this.handleSingleDirty}
|
||||||
|
style={{ flex: 1, fontSize: 11, padding: '4px 6px' }}
|
||||||
|
>
|
||||||
|
Single Invalidate
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => automat.reload()}
|
||||||
|
style={{ flex: 1, fontSize: 11, padding: '4px 6px' }}
|
||||||
|
>
|
||||||
|
Silent Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main CascadingPhotosExample Component
|
||||||
|
*/
|
||||||
|
export default class CascadingPhotosExample extends PureComponent {
|
||||||
|
state = {
|
||||||
|
parentData: photosAllAutomat.state,
|
||||||
|
parentReady: photosAllAutomat.isReady,
|
||||||
|
parentDirty: photosAllAutomat.isDirty,
|
||||||
|
mountedPhotos: { 1: true, 2: true, 3: false },
|
||||||
|
sseLog: [],
|
||||||
|
showPdf: false,
|
||||||
|
pdfData: pdfDocAutomat.state,
|
||||||
|
pdfReady: pdfDocAutomat.isReady,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsubParent = photosAllAutomat.subscribe((state) => {
|
||||||
|
this.setState({
|
||||||
|
parentData: state,
|
||||||
|
parentReady: photosAllAutomat.isReady,
|
||||||
|
parentDirty: photosAllAutomat.isDirty,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.unsubPdf = pdfDocAutomat.subscribe((state) => {
|
||||||
|
this.setState({
|
||||||
|
pdfData: state,
|
||||||
|
pdfReady: pdfDocAutomat.isReady,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Intercept SSE trigger for interactive UI log
|
||||||
|
const origTrigger = photosAllAutomat.actions.onSSETrigger;
|
||||||
|
photosAllAutomat.actions.onSSETrigger = (data, event, automat) => {
|
||||||
|
origTrigger(data, event, automat);
|
||||||
|
this.setState((prev) => ({
|
||||||
|
sseLog: [
|
||||||
|
`[${new Date().toLocaleTimeString()}] SSE ${event}: ${JSON.stringify(data)} -> photosAllAutomat.setDirty()`,
|
||||||
|
...prev.sseLog.slice(0, 4),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubParent) this.unsubParent();
|
||||||
|
if (this.unsubPdf) this.unsubPdf();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleParentDirty = () => {
|
||||||
|
// Calling setDirty on photosAllAutomat cascades setDirty to ALL photo/* automats!
|
||||||
|
photosAllAutomat.setDirty();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleParentReload = () => {
|
||||||
|
photosAllAutomat.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleBroadcastSSE = async (eventName = 'invalidate') => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/events/trigger', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
event: eventName,
|
||||||
|
data: { target: 'photos', action: eventName, timestamp: Date.now() },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('SSE trigger failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
togglePhotoMount = (id) => {
|
||||||
|
this.setState((prev) => ({
|
||||||
|
mountedPhotos: {
|
||||||
|
...prev.mountedPhotos,
|
||||||
|
[id]: !prev.mountedPhotos[id],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { parentData, parentReady, parentDirty, mountedPhotos, sseLog, showPdf, pdfData, pdfReady } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ borderTop: '3px solid var(--accent)' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="card-header" style={{ flexWrap: 'wrap', gap: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span className="card-icon">🖼️</span>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: 0 }}>Cascading Invalidation & Binary Fetchers</h3>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
Hub: <code>photos/all</code> ➔ Dependents: <code>photo/1</code>, <code>photo/2</code>, <code>photo/3</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<span className={`badge ${parentReady ? 'badge-success' : 'badge-warning'}`}>
|
||||||
|
Hub: {parentReady ? 'Ready' : 'Dirty / Unready'}
|
||||||
|
</span>
|
||||||
|
<span className="badge badge-api">SSE: Connected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Controls Bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface-0)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: '14px 16px',
|
||||||
|
marginBottom: 20,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 10 }}>
|
||||||
|
<div>
|
||||||
|
<strong style={{ fontSize: 13, color: 'var(--text-1)' }}>Parent "photos/all" Controls:</strong>
|
||||||
|
<p style={{ margin: 0, fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
Setting parent dirty cascades to all photo automats, revoking Blob memory immediately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={this.handleParentDirty}
|
||||||
|
title="Calls photosAllAutomat.setDirty() which cascades to all children"
|
||||||
|
>
|
||||||
|
⚡ Cascade setDirty() (Free Memory)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => this.handleBroadcastSSE('invalidate')}
|
||||||
|
style={{ borderColor: '#ec4899', color: '#ec4899' }}
|
||||||
|
title="Sends SSE message to backend which triggers internal onSSETrigger"
|
||||||
|
>
|
||||||
|
📡 Send SSE Invalidation
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={this.handleParentReload}
|
||||||
|
title="Reloads parent collection silently"
|
||||||
|
>
|
||||||
|
🔄 Reload photos/all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SSE Live Log */}
|
||||||
|
{sseLog.length > 0 && (
|
||||||
|
<div style={{ background: 'rgba(0,0,0,0.3)', padding: '8px 10px', borderRadius: 6, fontSize: 11, fontFamily: 'monospace' }}>
|
||||||
|
<span style={{ color: '#ec4899', fontWeight: 600 }}>📡 SSE Activity Log:</span>
|
||||||
|
{sseLog.map((log, idx) => (
|
||||||
|
<div key={idx} style={{ color: 'var(--text-2)', marginTop: 2 }}>{log}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mount / Unmount Toggles for Memory Verification */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 8 }}>
|
||||||
|
Subscriber Mount States (Verify Deferred Reload & Memory Savings):
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
{[1, 2, 3].map((id) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${mountedPhotos[id] ? 'btn-ghost' : 'btn-primary'}`}
|
||||||
|
onClick={() => this.togglePhotoMount(id)}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{mountedPhotos[id] ? `📴 Unmount photo/${id}` : `📦 Mount photo/${id}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => this.setState((prev) => ({ showPdf: !prev.showPdf }))}
|
||||||
|
style={{ marginLeft: 'auto', borderColor: '#10b981', color: '#10b981' }}
|
||||||
|
>
|
||||||
|
📄 {showPdf ? 'Hide PDF Viewer' : 'Test fetchPdfBlob Viewer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Photos Grid */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16, marginBottom: 20 }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-2)', marginBottom: 6 }}>
|
||||||
|
Photo #1 (Alps) {mountedPhotos[1] ? '🟢 Mounted' : '⚪ Unmounted'}
|
||||||
|
</span>
|
||||||
|
{mountedPhotos[1] ? (
|
||||||
|
<PhotoCard id={1} title="Alps Panorama" />
|
||||||
|
) : (
|
||||||
|
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 12, background: 'var(--surface-0)' }}>
|
||||||
|
photo/1 is unmounted.<br />
|
||||||
|
Cascading dirty releases its blob and defers reload until re-mounted!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-2)', marginBottom: 6 }}>
|
||||||
|
Photo #2 (Tokyo) {mountedPhotos[2] ? '🟢 Mounted' : '⚪ Unmounted'}
|
||||||
|
</span>
|
||||||
|
{mountedPhotos[2] ? (
|
||||||
|
<PhotoCard id={2} title="Tokyo Neon Night" />
|
||||||
|
) : (
|
||||||
|
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 12, background: 'var(--surface-0)' }}>
|
||||||
|
photo/2 is unmounted.<br />
|
||||||
|
Cascading dirty releases its blob and defers reload until re-mounted!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-2)', marginBottom: 6 }}>
|
||||||
|
Photo #3 (Rainforest) {mountedPhotos[3] ? '🟢 Mounted' : '⚪ Unmounted'}
|
||||||
|
</span>
|
||||||
|
{mountedPhotos[3] ? (
|
||||||
|
<PhotoCard id={3} title="Emerald Rainforest" />
|
||||||
|
) : (
|
||||||
|
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 12, background: 'var(--surface-0)' }}>
|
||||||
|
photo/3 is unmounted.<br />
|
||||||
|
Click "Mount photo/3" above to subscribe and trigger initial fetch!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PDF Blob Viewer Section */}
|
||||||
|
{showPdf && (
|
||||||
|
<div style={{ background: 'var(--surface-0)', border: '1px solid var(--border)', borderRadius: 8, padding: 16, marginTop: 16 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ margin: 0, fontSize: 14 }}>PDF Blob Fetcher (`fetchPdfBlob`)</h4>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
Endpoint: <code>/api/docs/spec</code> · Blob Size: {pdfData?.size || 0} bytes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => pdfDocAutomat.setDirty()}
|
||||||
|
>
|
||||||
|
Set Dirty (Revoke PDF Blob)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => pdfDocAutomat.reload()}
|
||||||
|
>
|
||||||
|
Reload PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pdfReady && pdfData?.url ? (
|
||||||
|
<iframe
|
||||||
|
src={pdfData.url}
|
||||||
|
title="PDF Spec Viewer"
|
||||||
|
style={{ width: '100%', height: 200, border: '1px solid var(--border-subtle)', borderRadius: 6 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text-3)', fontSize: 12 }}>
|
||||||
|
PDF Blob Unready or Dirtied (Memory Freed)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
@@ -87,7 +87,11 @@ class DynamicAutomatSubscriber extends PureComponent {
|
|||||||
render() {
|
render() {
|
||||||
const { currentIndex, slotState } = this.state;
|
const { currentIndex, slotState } = this.state;
|
||||||
const map = typeof window !== 'undefined' ? window.automats : null;
|
const map = typeof window !== 'undefined' ? window.automats : null;
|
||||||
const allIndexes = map ? Array.from(map.keys()).sort((a, b) => a - b) : [];
|
const allIndexes = map
|
||||||
|
? Array.from(map.keys())
|
||||||
|
.filter((k) => typeof k === 'number' || !isNaN(Number(k)))
|
||||||
|
.sort((a, b) => Number(a) - Number(b))
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card card-display">
|
<div className="card card-display">
|
||||||
@@ -104,7 +108,7 @@ class DynamicAutomatSubscriber extends PureComponent {
|
|||||||
Recalled State for Automat #{currentIndex}
|
Recalled State for Automat #{currentIndex}
|
||||||
</span>
|
</span>
|
||||||
<span className="count-number" style={{ fontSize: 68, margin: '2px 0' }}>
|
<span className="count-number" style={{ fontSize: 68, margin: '2px 0' }}>
|
||||||
{slotState.clicks}
|
{slotState?.clicks ?? 0}
|
||||||
</span>
|
</span>
|
||||||
<span className="count-label">Clicks recorded on Slot #{currentIndex}</span>
|
<span className="count-label">Clicks recorded on Slot #{currentIndex}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,7 +136,7 @@ class DynamicAutomatSubscriber extends PureComponent {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input-field"
|
className="input-field"
|
||||||
value={slotState.notes || ''}
|
value={slotState?.notes || ''}
|
||||||
onChange={this.handleNotesChange}
|
onChange={this.handleNotesChange}
|
||||||
placeholder={`Notes for Slot #${currentIndex}...`}
|
placeholder={`Notes for Slot #${currentIndex}...`}
|
||||||
style={{ fontSize: 13 }}
|
style={{ fontSize: 13 }}
|
||||||
@@ -147,7 +151,7 @@ class DynamicAutomatSubscriber extends PureComponent {
|
|||||||
<div style={{ background: 'var(--surface-0)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: '6px 10px', maxHeight: 96, overflowY: 'auto' }}>
|
<div style={{ background: 'var(--surface-0)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: '6px 10px', maxHeight: 96, overflowY: 'auto' }}>
|
||||||
{allIndexes.map((idx) => {
|
{allIndexes.map((idx) => {
|
||||||
const aut = map.get(idx);
|
const aut = map.get(idx);
|
||||||
const st = aut ? aut.state : {};
|
const st = aut?.state || {};
|
||||||
const isActive = idx === currentIndex;
|
const isActive = idx === currentIndex;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -167,7 +171,7 @@ class DynamicAutomatSubscriber extends PureComponent {
|
|||||||
{isActive ? '▸ ' : ' '}window.automats.get({idx})
|
{isActive ? '▸ ' : ' '}window.automats.get({idx})
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
clicks: <strong>{st.clicks}</strong> · "{st.notes}"
|
clicks: <strong>{st.clicks ?? 0}</strong> · "{st.notes ?? ''}"
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
169
src/examples/components/UnconnectedPartialSubscriber.jsx
Normal file
169
src/examples/components/UnconnectedPartialSubscriber.jsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { Automat } from '../../lib/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnconnectedPartialSubscriber
|
||||||
|
*
|
||||||
|
* Demonstrates:
|
||||||
|
* 1. UNCONNECTED OBJECT: Absolutely NO import of `cartAutomat.js`!
|
||||||
|
* The component has no module-level dependency on the cart store.
|
||||||
|
* Instead, it discovers and binds to it strictly via the named window registry:
|
||||||
|
* `Automat.get('cart')` (or `window.__AUTOMATS__.get('cart')`).
|
||||||
|
*
|
||||||
|
* 2. PARTIAL SUBSCRIBER:
|
||||||
|
* Subscribes ONLY to a slice: `couponCode` via array selector `['couponCode']`.
|
||||||
|
* - Changes to items, prices, or quantities produce 0 re-renders.
|
||||||
|
* - Changes to couponCode trigger immediate updates.
|
||||||
|
*
|
||||||
|
* 3. UNCONNECTED ACTION DISPATCH:
|
||||||
|
* Calls actions directly on the resolved instance:
|
||||||
|
* `Automat.get('cart')?.actions.setCouponCode(...)` without importing anything.
|
||||||
|
*/
|
||||||
|
export default class UnconnectedPartialSubscriber extends PureComponent {
|
||||||
|
_renderCount = 0;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
// 1. Resolve store solely via named window object (no imports of cartAutomat!)
|
||||||
|
const store = typeof window !== 'undefined' ? (Automat.get('cart') || window.__AUTOMATS__?.get('cart')) : null;
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
// Extract only partial slice: couponCode
|
||||||
|
couponCode: store?.state?.couponCode || '',
|
||||||
|
connected: Boolean(store),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// 2. Discover store from named window object
|
||||||
|
const store = Automat.get('cart') || (typeof window !== 'undefined' && window.__AUTOMATS__?.get('cart'));
|
||||||
|
|
||||||
|
if (store) {
|
||||||
|
// 3. Partial subscription using key array selector: ['couponCode']
|
||||||
|
// Automat performs shallow-equality on { couponCode } and skips setState
|
||||||
|
// when items, quantities, or prices change!
|
||||||
|
this.unsubscribe = store.subscribe(this, ['couponCode']);
|
||||||
|
this.setState({ connected: true, couponCode: store.state?.couponCode || '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubscribe) {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleQuickCoupon = (code) => {
|
||||||
|
// Dispatch action through unconnected named window reference
|
||||||
|
const store = Automat.get('cart') || window.__AUTOMATS__?.get('cart');
|
||||||
|
store?.actions.setCouponCode(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this._renderCount++;
|
||||||
|
const { couponCode, connected } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-display" style={{ borderLeft: '3px solid var(--accent-light)' }}>
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">🔌</span>
|
||||||
|
<h3>Unconnected Partial Subscriber</h3>
|
||||||
|
<span className="badge badge-cascade">Zero Store Imports</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Status & Efficiency stats */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||||
|
Component Renders: <strong style={{ color: 'var(--accent-light)' }}>#{this._renderCount}</strong>
|
||||||
|
</span>
|
||||||
|
<span className={`badge ${connected ? 'badge-success' : 'badge-warning'}`}>
|
||||||
|
{connected ? 'window.__AUTOMATS__.get("cart")' : 'Disconnected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection Architecture Badge */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'rgba(99, 102, 241, 0.08)',
|
||||||
|
border: '1px solid rgba(99, 102, 241, 0.25)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '12px 14px',
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, color: 'var(--accent-light)', marginBottom: 4 }}>
|
||||||
|
🌐 Unconnected Architecture:
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
• <strong>No Store Import:</strong> Does not import <code>cartAutomat</code>. Resolves solely via <code>Automat.get('cart')</code>.<br />
|
||||||
|
• <strong>Partial Selector:</strong> Subscribed strictly via <code>store.subscribe(this, ['couponCode'])</code>.<br />
|
||||||
|
• <strong>Render Guard:</strong> Adding/deleting items or changing quantities triggers <strong>0 re-renders</strong> here.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Observed Partial Value */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
Observed Partial Slice (couponCode):
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(0, 0, 0, 0.3)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 14,
|
||||||
|
color: couponCode ? 'var(--green)' : 'var(--text-3)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{couponCode ? `Active Coupon: "${couponCode}"` : '(No coupon applied)'}</span>
|
||||||
|
{couponCode && (
|
||||||
|
<span className="badge badge-success" style={{ fontSize: 11 }}>
|
||||||
|
Applied
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Dispatch via Unconnected Reference */}
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
Dispatch Action via Named Window Reference:
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => this.handleQuickCoupon('AUTOMAT25')}
|
||||||
|
>
|
||||||
|
Apply "AUTOMAT25"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => this.handleQuickCoupon('FREESHIP')}
|
||||||
|
>
|
||||||
|
Apply "FREESHIP"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => this.handleQuickCoupon('')}
|
||||||
|
>
|
||||||
|
Clear Coupon
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -445,7 +445,6 @@ body {
|
|||||||
0% { background-position: 200% 0; }
|
0% { background-position: 200% 0; }
|
||||||
100% { background-position: -200% 0; }
|
100% { background-position: -200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Buttons ───────────────────────────────────────── */
|
/* ── Buttons ───────────────────────────────────────── */
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
|
|||||||
@@ -1,46 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* @module Automat
|
* @module Automat
|
||||||
*
|
*
|
||||||
* An observable state container whose lifetime is independent of any React component.
|
* Higher-order observable state container for React PureComponents with optional
|
||||||
* State persists across mounts and unmounts; components always see the latest value
|
* persistence (Window or IndexedDB), backend API binding, cascading invalidation,
|
||||||
* when they mount because they read `automat.state` or `automat.getState()` in their constructor.
|
* and multi-automat combination.
|
||||||
*
|
|
||||||
* Optional Persistence:
|
|
||||||
* - `persist: false` (default when `name` is provided): Persists state in `window` object
|
|
||||||
* (session memory; survives unmounts, dynamic imports, and HMR).
|
|
||||||
* - `persist: true`: Persists state in `IndexedDB` (survives page reloads and browser restarts).
|
|
||||||
*
|
|
||||||
* Direct PureComponent usage:
|
|
||||||
* ```jsx
|
|
||||||
* class CounterDisplay extends PureComponent {
|
|
||||||
* constructor(props) {
|
|
||||||
* super(props);
|
|
||||||
* // 1. Direct access in constructor:
|
|
||||||
* this.state = counterAutomat.state;
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* componentDidMount() {
|
|
||||||
* // 2. Subscriber handling for React lifecycle management:
|
|
||||||
* this.unsubscribe = counterAutomat.subscribe(this);
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* componentWillUnmount() {
|
|
||||||
* // 3. Clean up on unmount:
|
|
||||||
* this.unsubscribe(); // or counterAutomat.unsubscribe(this);
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* render() {
|
|
||||||
* return <span>{this.state.count}</span>;
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Performs a shallow equality check between two values or objects.
|
* Performs a shallow equality check between two values or objects.
|
||||||
* Used by Automat subscribers to avoid unnecessary setState calls when
|
* Used by Automat subscribers to avoid unnecessary setState calls when
|
||||||
* a subscribed slice of state has not changed.
|
* a subscribed slice of state has not changed.
|
||||||
*/
|
*/
|
||||||
function shallowEqual(a, b) {
|
export function shallowEqual(a, b) {
|
||||||
if (Object.is(a, b)) return true;
|
if (Object.is(a, b)) return true;
|
||||||
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
|
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
|
||||||
const keysA = Object.keys(a);
|
const keysA = Object.keys(a);
|
||||||
@@ -55,6 +26,13 @@ function shallowEqual(a, b) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { KeyedAutomat } from './KeyedAutomat.js';
|
||||||
|
|
||||||
|
// ── Global Window & Registry Aliases ──────────────────────────────
|
||||||
|
const W = typeof window !== 'undefined' ? window : null;
|
||||||
|
export const getGlobalMap = (key) => W && (W[key] ||= new Map());
|
||||||
|
|
||||||
|
// ── Unified IndexedDB Storage ─────────────────────────────────────
|
||||||
const IDB_NAME = 'automat_db';
|
const IDB_NAME = 'automat_db';
|
||||||
const IDB_STORE = 'states';
|
const IDB_STORE = 'states';
|
||||||
let idbPromise = null;
|
let idbPromise = null;
|
||||||
@@ -129,329 +107,288 @@ function idbDelete(key) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Selector Normalizer ───────────────────────────────────────────
|
||||||
|
export function resolveSelector(s, isComponent = false) {
|
||||||
|
if (typeof s === 'function') {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
if (typeof s === 'string') {
|
||||||
|
return isComponent ? (state) => (state ? { [s]: state[s] } : state) : (state) => state?.[s];
|
||||||
|
}
|
||||||
|
if (Array.isArray(s)) {
|
||||||
|
return (state) => {
|
||||||
|
if (!state) return state;
|
||||||
|
const slice = {};
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
const k = s[i];
|
||||||
|
slice[k] = state[k];
|
||||||
|
}
|
||||||
|
return slice;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return (state) => state;
|
||||||
|
}
|
||||||
|
|
||||||
export class Automat {
|
export class Automat {
|
||||||
/** @type {object} */
|
constructor(initialState = null, callbacks = {}, options = {}) {
|
||||||
#state;
|
const hasInitial = initialState !== null && initialState !== undefined;
|
||||||
/** @type {Map<any, function>} Map of subscriber target -> notification callback */
|
this._initial = hasInitial
|
||||||
#subscribers = new Map();
|
? typeof initialState === 'object'
|
||||||
/** @type {object} */
|
? { ...initialState }
|
||||||
#callbacks;
|
: initialState
|
||||||
/** @type {Array<function>} */
|
: null;
|
||||||
#upstreamUnsubscribers = [];
|
this._state = this._initial ? { ...this._initial } : null;
|
||||||
/** @type {string|null} */
|
|
||||||
#name = null;
|
|
||||||
/** @type {boolean} */
|
|
||||||
#persist = false;
|
|
||||||
/** @type {Promise<object>} */
|
|
||||||
#ready = Promise.resolve();
|
|
||||||
/** @type {function|null} */
|
|
||||||
#loader = null;
|
|
||||||
|
|
||||||
/**
|
const resolvedCallbacks = typeof callbacks === 'function' ? callbacks(this) : (callbacks || {});
|
||||||
* @param {object} initialState Initial state snapshot.
|
this._cbs = {};
|
||||||
* @param {object} [callbacks] Named action callbacks. Exposed via `.actions`.
|
for (const [key, fn] of Object.entries(resolvedCallbacks)) {
|
||||||
* @param {object} [options] Configuration options.
|
this._cbs[key] = typeof fn === 'function' ? fn.bind(this) : fn;
|
||||||
* @param {string} [options.name] Identifier for persistence and registry lookup.
|
}
|
||||||
* @param {boolean} [options.persist] true = IndexedDB (survives reload), false = window object (default).
|
this._subs = new Map();
|
||||||
* @param {function} [options.loader] Async loader function (automat) => Promise<object>.
|
this._dirtySubs = new Set();
|
||||||
*/
|
this._unsubs = [];
|
||||||
constructor(initialState = {}, callbacks = {}, options = {}) {
|
this._name = options.name ?? null;
|
||||||
this.#callbacks = callbacks;
|
this._persist = Boolean(options.persist);
|
||||||
this.#name = options.name ?? null;
|
this._loader = options.loader ?? null;
|
||||||
this.#persist = Boolean(options.persist);
|
this._error = null;
|
||||||
this.#loader = typeof options.loader === 'function' ? options.loader : null;
|
|
||||||
|
|
||||||
if (this.#name && typeof window !== 'undefined') {
|
if (this._name) {
|
||||||
window.__AUTOMATS__ = window.__AUTOMATS__ || new Map();
|
getGlobalMap('__AUTOMATS__')?.set(this._name, this);
|
||||||
window.__AUTOMATS__.set(this.#name, this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.#name && !this.#persist) {
|
this._isDirty = false;
|
||||||
// Window object persistence (session memory, persists across unmounts & HMR)
|
|
||||||
if (typeof window !== 'undefined') {
|
if (this._loader) {
|
||||||
window.__AUTOMAT_STATE__ = window.__AUTOMAT_STATE__ || new Map();
|
this._ready = this._fetchLoader();
|
||||||
if (window.__AUTOMAT_STATE__.has(this.#name)) {
|
} else if (this._name && this._persist) {
|
||||||
this.#state = { ...initialState, ...window.__AUTOMAT_STATE__.get(this.#name) };
|
this._ready = idbGet(this._name).then((saved) => {
|
||||||
} else {
|
|
||||||
this.#state = { ...initialState };
|
|
||||||
window.__AUTOMAT_STATE__.set(this.#name, this.#state);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.#state = { ...initialState };
|
|
||||||
}
|
|
||||||
this.#ready = Promise.resolve(this.#state);
|
|
||||||
} else if (this.#name && this.#persist) {
|
|
||||||
// IndexedDB persistence (survives page reloads)
|
|
||||||
this.#state = { ...initialState };
|
|
||||||
this.#ready = idbGet(this.#name).then((saved) => {
|
|
||||||
if (saved && typeof saved === 'object') {
|
if (saved && typeof saved === 'object') {
|
||||||
this.setState(saved);
|
this.setState(saved);
|
||||||
}
|
}
|
||||||
return this.#state;
|
return this._state;
|
||||||
});
|
});
|
||||||
|
} else if (this._name && !this._persist) {
|
||||||
|
const saved = getGlobalMap('__AUTOMAT_STATE__')?.get(this._name);
|
||||||
|
if (saved && typeof saved === 'object') {
|
||||||
|
this._state = { ...this._state, ...saved };
|
||||||
|
}
|
||||||
|
getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state);
|
||||||
|
this._ready = Promise.resolve(this._state);
|
||||||
} else {
|
} else {
|
||||||
this.#state = { ...initialState };
|
this._ready = Promise.resolve(this._state);
|
||||||
this.#ready = Promise.resolve(this.#state);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal data loader for function-backed automats.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_fetchLoader() {
|
||||||
|
this._isDirty = false;
|
||||||
|
this._error = null;
|
||||||
|
|
||||||
|
const promise = Promise.resolve(this._loader(this))
|
||||||
|
.then((data) => {
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
this._state = { ...this._state, ...data };
|
||||||
|
} else if (data !== undefined) {
|
||||||
|
this._state = data;
|
||||||
|
}
|
||||||
|
this._isDirty = false;
|
||||||
|
this._error = null;
|
||||||
|
if (this._name) {
|
||||||
|
this._persist
|
||||||
|
? idbSet(this._name, this._state)
|
||||||
|
: getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state);
|
||||||
|
}
|
||||||
|
this._notify(this._state);
|
||||||
|
return this._state;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this._error = err?.message || String(err);
|
||||||
|
this._notify(this._state);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
this._ready = promise;
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the automat as dirty and unready.
|
||||||
|
* State resets to initial default or null.
|
||||||
|
* If subscribed anywhere and configured with a loader, triggers loader fetch immediately.
|
||||||
|
*/
|
||||||
|
setDirty(keep = false, now = false) {
|
||||||
|
let shouldKeep = Boolean(keep);
|
||||||
|
let shouldNow = Boolean(now);
|
||||||
|
if (typeof keep === 'object' && keep !== null) {
|
||||||
|
shouldKeep = Boolean(keep.keep);
|
||||||
|
shouldNow = Boolean(keep.now);
|
||||||
|
}
|
||||||
|
if (typeof this._state?.revoke === 'function') {
|
||||||
|
try {
|
||||||
|
this._state.revoke();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
this._isDirty = true;
|
||||||
|
if (!shouldKeep) {
|
||||||
|
this._state = this._initial ? { ...this._initial } : null;
|
||||||
|
this._notify(this._state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._dirtySubs) {
|
||||||
|
for (const fn of this._dirtySubs) {
|
||||||
|
try {
|
||||||
|
fn(this);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._loader && (shouldNow || this._subs.size > 0)) {
|
||||||
|
return this._fetchLoader();
|
||||||
|
}
|
||||||
|
return Promise.resolve(this._state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads current state for React Suspense or imperative consumers.
|
||||||
|
* If dirty and unmounted, triggers deferred fetch.
|
||||||
|
* If unready, throws the ready promise for React Suspense.
|
||||||
|
*/
|
||||||
|
read() {
|
||||||
|
if (this._loader) {
|
||||||
|
if (this._isDirty) {
|
||||||
|
this._fetchLoader();
|
||||||
|
}
|
||||||
|
if (this._error) throw this._error;
|
||||||
|
if (this._state === null && this._ready) {
|
||||||
|
throw this._ready;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this._state;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves an Automat instance registered by name.
|
* Retrieves an Automat instance registered by name.
|
||||||
* @param {string} name
|
* @param {string} name
|
||||||
* @returns {Automat|undefined}
|
|
||||||
*/
|
*/
|
||||||
static get(name) {
|
static get(name) {
|
||||||
if (typeof window !== 'undefined' && window.__AUTOMATS__) {
|
return getGlobalMap('__AUTOMATS__')?.get(name);
|
||||||
return window.__AUTOMATS__.get(name);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a combined Automat derived from multiple upstream Automats.
|
* Combines multiple automats into one derived automat.
|
||||||
*
|
*
|
||||||
* When accessed via `.getData()`:
|
* Supports two signatures:
|
||||||
* - If all upstream automats are already resolved (`status === 'success'`),
|
* 1. Array + Combiner function:
|
||||||
* it immediately returns the combined state in the constructor (instant resolution).
|
* Automat.combine([automatA, automatB], (stateA, stateB) => ({ ... }), options)
|
||||||
* - If any upstream automat is idle, it triggers `.getData()` on them concurrently,
|
* 2. Object Dictionary:
|
||||||
* transitions itself to pending, and synchronizes automatically as each completes.
|
* Automat.combine({ auth: authAutomat, company: companyAutomat })
|
||||||
*
|
*
|
||||||
* @param {Automat[]} upstreamAutomats Array of upstream Automat instances.
|
* @param {Automat[] | Record<string, Automat>} upstream
|
||||||
* @param {function(...states: object[]): object} combiner Function that maps upstream states to combined state.
|
* @param {Function} [combiner]
|
||||||
* @param {object} [options] Optional Automat options (name, persist).
|
* @param {object} [options]
|
||||||
* @returns {Automat}
|
* @returns {Automat}
|
||||||
*/
|
*/
|
||||||
static combine(upstreamAutomats, combiner, options = {}) {
|
static combine(upstream, combiner, options = {}) {
|
||||||
const computeInitial = () => {
|
if (Array.isArray(upstream)) {
|
||||||
const upstreamStates = upstreamAutomats.map((a) => a.state);
|
if (typeof combiner !== 'function') {
|
||||||
return combiner(...upstreamStates);
|
throw new TypeError('Automat.combine with array expects a combiner function as second argument.');
|
||||||
};
|
}
|
||||||
|
return createOrchestratedCombinedAutomat(upstream, combiner, options);
|
||||||
|
}
|
||||||
|
|
||||||
const combined = new Automat(
|
if (upstream && typeof upstream === 'object') {
|
||||||
computeInitial(),
|
return new CombinedAutomat(upstream);
|
||||||
{
|
}
|
||||||
getData(opts = {}) {
|
|
||||||
const allResolved = upstreamAutomats.every(
|
|
||||||
(a) => a.state?.status === 'success'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (allResolved && !opts.reload) {
|
throw new TypeError('Automat.combine expects an array or dictionary object of Automat instances.');
|
||||||
const currentCombined = combiner(...upstreamAutomats.map((a) => a.state));
|
|
||||||
if (combined.state.status !== 'success') {
|
|
||||||
combined.setState({ ...currentCombined, status: 'success' });
|
|
||||||
}
|
|
||||||
return combined.state;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger loading on any uncompleted or reload-requested upstreams
|
|
||||||
upstreamAutomats.forEach((a) => {
|
|
||||||
if (typeof a.getData === 'function' && (a.state?.status === 'idle' || opts.reload)) {
|
|
||||||
a.getData(opts);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkAllResolved = upstreamAutomats.every(
|
|
||||||
(a) => a.state?.status === 'success'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (checkAllResolved) {
|
|
||||||
const res = combiner(...upstreamAutomats.map((a) => a.state));
|
|
||||||
combined.setState({ ...res, status: 'success' });
|
|
||||||
return combined.state;
|
|
||||||
}
|
|
||||||
|
|
||||||
const partialCombined = combiner(...upstreamAutomats.map((a) => a.state));
|
|
||||||
combined.setState({ ...partialCombined, status: 'pending' });
|
|
||||||
return combined.state;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options
|
|
||||||
);
|
|
||||||
|
|
||||||
// Subscribe to all upstreams
|
|
||||||
upstreamAutomats.forEach((upstream) => {
|
|
||||||
combined.subscribeTo(upstream, () => {
|
|
||||||
const upstreamStates = upstreamAutomats.map((a) => a.state);
|
|
||||||
const allDone = upstreamStates.every((s) => s?.status === 'success');
|
|
||||||
const anyError = upstreamStates.find((s) => s?.status === 'error');
|
|
||||||
const combinedSlice = combiner(...upstreamStates);
|
|
||||||
|
|
||||||
if (anyError) {
|
|
||||||
return { ...combinedSlice, status: 'error', error: anyError.error };
|
|
||||||
}
|
|
||||||
if (allDone) {
|
|
||||||
return { ...combinedSlice, status: 'success', error: null };
|
|
||||||
}
|
|
||||||
return { ...combinedSlice, status: 'pending' };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return combined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The registered name of the automat, or null if unnamed.
|
* Creates a KeyedAutomat instance for dynamic ID-based state.
|
||||||
* @returns {string|null}
|
* @param {any} [initialItemState=null]
|
||||||
|
* @param {any} [callbacks={}]
|
||||||
|
* @param {any} [options={}]
|
||||||
|
* @returns {KeyedAutomat}
|
||||||
*/
|
*/
|
||||||
|
static keyed(initialItemState = null, callbacks = {}, options = {}) {
|
||||||
|
return new KeyedAutomat(initialItemState, callbacks, options);
|
||||||
|
}
|
||||||
|
|
||||||
get name() {
|
get name() {
|
||||||
return this.#name;
|
return this._name;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this automat is persisted to IndexedDB (true) or window object (false).
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
get persist() {
|
get persist() {
|
||||||
return this.#persist;
|
return this._persist;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPersisted() {
|
||||||
|
return this._persist;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isDirty() {
|
||||||
|
return this._isDirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
get error() {
|
||||||
|
return this._error;
|
||||||
|
}
|
||||||
|
|
||||||
|
get subscriberCount() {
|
||||||
|
return this._subs.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Promise resolving when initial state rehydration is complete.
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
get ready() {
|
get ready() {
|
||||||
return this.#ready;
|
if (this._loader && this._isDirty) {
|
||||||
|
return this._fetchLoader();
|
||||||
|
}
|
||||||
|
return this._ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears persisted state from window or IndexedDB.
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async clearPersistence() {
|
async clearPersistence() {
|
||||||
if (!this.#name) return;
|
if (!this._name) return;
|
||||||
if (this.#persist) {
|
this._persist
|
||||||
await idbDelete(this.#name);
|
? await idbDelete(this._name)
|
||||||
} else if (typeof window !== 'undefined') {
|
: getGlobalMap('__AUTOMAT_STATE__')?.delete(this._name);
|
||||||
window.__AUTOMAT_STATE__?.delete(this.#name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Direct property access to the current state snapshot.
|
|
||||||
* Use in PureComponent constructor: `this.state = automat.state;`
|
|
||||||
* @returns {object}
|
|
||||||
*/
|
|
||||||
get state() {
|
get state() {
|
||||||
return this.#state;
|
if (this._loader && this._isDirty) {
|
||||||
|
this._fetchLoader();
|
||||||
|
}
|
||||||
|
return this._state;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the current state snapshot.
|
|
||||||
* Use in PureComponent constructor: `this.state = automat.getState();`
|
|
||||||
* @returns {object}
|
|
||||||
*/
|
|
||||||
getState() {
|
getState() {
|
||||||
return this.#state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads state for constructor initialization, optionally triggering an action or loader.
|
* Shallow-merges `partial` into current state and notifies all subscribers.
|
||||||
*
|
|
||||||
* Lifecycle & Caching Semantics:
|
|
||||||
* 1. If loader or load action is configured and state is 'idle' (or reload is true):
|
|
||||||
* - Transitions state to { status: 'pending', error: null }.
|
|
||||||
* - Launches loader asynchronously in the background.
|
|
||||||
* - Synchronously returns the pending state for the first render.
|
|
||||||
* 2. When the async fetch finishes, automat.setState notifies subscribers, triggering
|
|
||||||
* a second render with completed data.
|
|
||||||
* 3. When called again from another component or after remounting with data already loaded
|
|
||||||
* (status === 'success'), returns the resolved state immediately in the constructor.
|
|
||||||
* No secondary render is needed!
|
|
||||||
*
|
|
||||||
* @param {object} [options]
|
|
||||||
* @param {boolean} [options.reload=false]
|
|
||||||
* @returns {object}
|
|
||||||
*/
|
|
||||||
getData(options = {}) {
|
|
||||||
const shouldReload = Boolean(options.reload);
|
|
||||||
const isIdle = !this.#state.status || this.#state.status === 'idle' || shouldReload;
|
|
||||||
|
|
||||||
if (this.#loader && isIdle) {
|
|
||||||
this.setState({ status: 'pending', error: null });
|
|
||||||
Promise.resolve(this.#loader(this))
|
|
||||||
.then((result) => {
|
|
||||||
if (result && typeof result === 'object') {
|
|
||||||
this.setState({ status: 'success', ...result, error: null });
|
|
||||||
} else {
|
|
||||||
this.setState({ status: 'success', error: null });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
this.setState({ status: 'error', error: err?.message || String(err) });
|
|
||||||
});
|
|
||||||
return this.#state;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.#callbacks?.getData && typeof this.#callbacks.getData === 'function') {
|
|
||||||
return this.#callbacks.getData(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.#callbacks?.load && typeof this.#callbacks.load === 'function' && isIdle) {
|
|
||||||
this.#callbacks.load(options);
|
|
||||||
return this.#state;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.#state;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Alias for getData(options).
|
|
||||||
* @param {object} [options]
|
|
||||||
* @returns {object}
|
|
||||||
*/
|
|
||||||
load(options) {
|
|
||||||
return this.getData(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forces a reload via getData({ reload: true }).
|
|
||||||
* @returns {object}
|
|
||||||
*/
|
|
||||||
reload() {
|
|
||||||
return this.getData({ reload: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shallow-merges `partial` into the current state and notifies all subscribers.
|
|
||||||
*
|
|
||||||
* @param {object} partial Fields to update.
|
|
||||||
* @returns {object} The new full state.
|
|
||||||
*/
|
*/
|
||||||
setState(partial) {
|
setState(partial) {
|
||||||
this.#state = { ...this.#state, ...partial };
|
const update = typeof partial === 'function' ? partial(this._state) : partial;
|
||||||
if (this.#name) {
|
this._state = { ...this._state, ...update };
|
||||||
if (this.#persist) {
|
if (this._name) {
|
||||||
idbSet(this.#name, this.#state);
|
this._persist
|
||||||
} else if (typeof window !== 'undefined') {
|
? idbSet(this._name, this._state)
|
||||||
window.__AUTOMAT_STATE__?.set(this.#name, this.#state);
|
: getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.#notify(partial);
|
this._notify(update);
|
||||||
return this.#state;
|
return this._state;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribes a React PureComponent instance or a listener callback.
|
* Subscribes a React PureComponent instance or a listener callback.
|
||||||
|
* Supports slice subscriptions with string, array, or function selectors.
|
||||||
|
* Uses shallow equality to prevent re-renders when the slice has not changed.
|
||||||
*
|
*
|
||||||
* Partial / Slice Subscriptions:
|
* @param {object|function} target React component (`this`) or callback function.
|
||||||
* By default, subscribing to an automat triggers updates when any state changes.
|
* @param {string|string[]|function} [selector]
|
||||||
* Passing a `selector` allows subscribing to only a slice of state.
|
* @returns {function} Unsubscribe cleanup function.
|
||||||
* Changes to unrelated state fields will NOT trigger setState or re-renders.
|
|
||||||
*
|
|
||||||
* Selector forms:
|
|
||||||
* 1. Single property key (string):
|
|
||||||
* `automat.subscribe(this, 'count')`
|
|
||||||
* 2. Multiple property keys (array of strings):
|
|
||||||
* `automat.subscribe(this, ['count', 'step'])`
|
|
||||||
* 3. Selector function:
|
|
||||||
* `automat.subscribe(this, state => ({ count: state.count }))`
|
|
||||||
* (Return null/undefined to conditionally skip updates)
|
|
||||||
* 4. Full subscription (omitted):
|
|
||||||
* `automat.subscribe(this)`
|
|
||||||
*
|
|
||||||
* @param {object|function} target React component (`this`) or callback function.
|
|
||||||
* @param {string|string[]|function} [selector] Property key, key array, or selector function.
|
|
||||||
* @returns {function} Unsubscribe function for componentWillUnmount.
|
|
||||||
*/
|
*/
|
||||||
subscribe(target, selector) {
|
subscribe(target, selector) {
|
||||||
const isComponent = target && typeof target.setState === 'function';
|
const isComponent = target && typeof target.setState === 'function';
|
||||||
@@ -463,25 +400,9 @@ export class Automat {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let getSlice;
|
const hasSelector = selector !== undefined && selector !== null;
|
||||||
if (typeof selector === 'string') {
|
const getSlice = hasSelector ? resolveSelector(selector, isComponent) : null;
|
||||||
getSlice = isComponent
|
let lastSlice = getSlice ? getSlice(this._state) : undefined;
|
||||||
? (state) => ({ [selector]: state[selector] })
|
|
||||||
: (state) => state[selector];
|
|
||||||
} else if (Array.isArray(selector)) {
|
|
||||||
getSlice = (state) => {
|
|
||||||
const slice = {};
|
|
||||||
for (let i = 0; i < selector.length; i++) {
|
|
||||||
const key = selector[i];
|
|
||||||
slice[key] = state[key];
|
|
||||||
}
|
|
||||||
return slice;
|
|
||||||
};
|
|
||||||
} else if (typeof selector === 'function') {
|
|
||||||
getSlice = selector;
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastSlice = getSlice ? getSlice(this.#state) : undefined;
|
|
||||||
|
|
||||||
const notifyFn = (state, partial) => {
|
const notifyFn = (state, partial) => {
|
||||||
if (getSlice) {
|
if (getSlice) {
|
||||||
@@ -502,14 +423,21 @@ export class Automat {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (isComponent) {
|
if (isComponent) {
|
||||||
target.setState(partial ?? state);
|
const update = partial ?? state;
|
||||||
|
update && typeof update === 'object'
|
||||||
|
? target.setState(update)
|
||||||
|
: target.forceUpdate();
|
||||||
} else {
|
} else {
|
||||||
target(state);
|
target(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.#subscribers.set(target, notifyFn);
|
this._subs.set(target, notifyFn);
|
||||||
|
|
||||||
|
if (this._loader && this._isDirty) {
|
||||||
|
this._fetchLoader();
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
this.unsubscribe(target);
|
this.unsubscribe(target);
|
||||||
@@ -523,91 +451,199 @@ export class Automat {
|
|||||||
* @returns {{ readonly state: any, subscribe(target: object|function): function }}
|
* @returns {{ readonly state: any, subscribe(target: object|function): function }}
|
||||||
*/
|
*/
|
||||||
select(selector) {
|
select(selector) {
|
||||||
let getSlice;
|
const getSlice = resolveSelector(selector, false);
|
||||||
if (typeof selector === 'string') {
|
|
||||||
getSlice = (state) => ({ [selector]: state[selector] });
|
|
||||||
} else if (Array.isArray(selector)) {
|
|
||||||
getSlice = (state) => {
|
|
||||||
const slice = {};
|
|
||||||
for (let i = 0; i < selector.length; i++) {
|
|
||||||
const key = selector[i];
|
|
||||||
slice[key] = state[key];
|
|
||||||
}
|
|
||||||
return slice;
|
|
||||||
};
|
|
||||||
} else if (typeof selector === 'function') {
|
|
||||||
getSlice = selector;
|
|
||||||
} else {
|
|
||||||
getSlice = (state) => state;
|
|
||||||
}
|
|
||||||
|
|
||||||
const self = this;
|
const self = this;
|
||||||
return {
|
return {
|
||||||
get state() {
|
get state() {
|
||||||
return getSlice(self.state);
|
return getSlice(self._state);
|
||||||
},
|
},
|
||||||
subscribe: (target) => self.subscribe(target, selector),
|
subscribe: (target) => self.subscribe(target, selector),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Unsubscribes a component or listener function.
|
|
||||||
*
|
|
||||||
* Can be called directly in `componentWillUnmount`:
|
|
||||||
* `automat.unsubscribe(this);`
|
|
||||||
*
|
|
||||||
* @param {object|function} target
|
|
||||||
*/
|
|
||||||
unsubscribe(target) {
|
unsubscribe(target) {
|
||||||
this.#subscribers.delete(target);
|
this._subs.delete(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wires this automat to derive state from an upstream automat.
|
* Registers a listener called whenever this automat becomes dirty.
|
||||||
*
|
* @param {(automat: Automat) => void} fn
|
||||||
* Whenever `upstreamAutomat` changes, `transform` is called with the upstream state
|
* @returns {() => void}
|
||||||
* and this automat's current state. The returned partial object is applied via
|
|
||||||
* `setState`, notifying this automat's own subscribers.
|
|
||||||
*
|
|
||||||
* @param {Automat} upstreamAutomat
|
|
||||||
* @param {function(upstreamState: object, myState: object): object|null} transform
|
|
||||||
* @returns {this} Chainable.
|
|
||||||
*/
|
*/
|
||||||
subscribeTo(upstreamAutomat, transform) {
|
onDirty(fn) {
|
||||||
const unsub = upstreamAutomat.subscribe((upstreamState) => {
|
this._dirtySubs.add(fn);
|
||||||
const partial = transform(upstreamState, this.#state);
|
return () => this._dirtySubs.delete(fn);
|
||||||
if (partial != null) {
|
}
|
||||||
this.setState(partial);
|
|
||||||
|
/**
|
||||||
|
* Cascading invalidation: automatically sets this automat dirty whenever
|
||||||
|
* upstreamAutomat is set dirty.
|
||||||
|
*
|
||||||
|
* @param {Automat} upstreamAutomat
|
||||||
|
* @param {(upstreamState: any, myState: any) => boolean} [filterFn]
|
||||||
|
* @returns {this}
|
||||||
|
*/
|
||||||
|
invalidateWith(upstreamAutomat, filterFn) {
|
||||||
|
const unsub = upstreamAutomat.onDirty((upstream) => {
|
||||||
|
if (!filterFn || filterFn(upstream.state, this._state)) {
|
||||||
|
this.setDirty();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.#upstreamUnsubscribers.push(unsub);
|
this._unsubs.push(unsub);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The named action callbacks passed in the constructor.
|
* Connects this automat to derive state from an upstream automat.
|
||||||
* @type {object}
|
*
|
||||||
|
* @param {Automat} upstreamAutomat
|
||||||
|
* @param {(upstreamState: any, myState: any) => any} transform
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {boolean | ((upstreamState: any, myState: any) => boolean)} [options.cascadeDirty]
|
||||||
|
* @returns {this} Chainable.
|
||||||
*/
|
*/
|
||||||
get actions() {
|
subscribeTo(upstreamAutomat, transform, options = {}) {
|
||||||
return this.#callbacks;
|
const unsub = upstreamAutomat.subscribe((upstreamState) => {
|
||||||
|
const partial = transform(upstreamState, this._state);
|
||||||
|
if (partial != null) {
|
||||||
|
this.setState(partial);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._unsubs.push(unsub);
|
||||||
|
|
||||||
|
if (options?.cascadeDirty) {
|
||||||
|
this.invalidateWith(
|
||||||
|
upstreamAutomat,
|
||||||
|
typeof options.cascadeDirty === 'function' ? options.cascadeDirty : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
get actions() {
|
||||||
|
return this._cbs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Tears down all upstream subscriptions and clears the subscriber map.
|
|
||||||
*/
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this.#upstreamUnsubscribers.forEach((fn) => fn());
|
if (typeof this._state?.revoke === 'function') {
|
||||||
this.#upstreamUnsubscribers = [];
|
try {
|
||||||
this.#subscribers.clear();
|
this._state.revoke();
|
||||||
if (this.#name && typeof window !== 'undefined' && window.__AUTOMATS__) {
|
} catch {}
|
||||||
window.__AUTOMATS__.delete(this.#name);
|
}
|
||||||
|
this._unsubs.forEach((fn) => fn());
|
||||||
|
this._unsubs = [];
|
||||||
|
this._subs.clear();
|
||||||
|
this._dirtySubs?.clear();
|
||||||
|
if (this._name) {
|
||||||
|
getGlobalMap('__AUTOMATS__')?.delete(this._name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @private */
|
_notify(partial) {
|
||||||
#notify(partial) {
|
for (const notifyFn of this._subs.values()) {
|
||||||
for (const notifyFn of this.#subscribers.values()) {
|
notifyFn(this._state, partial);
|
||||||
notifyFn(this.#state, partial);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an orchestrated combined Automat from an array of upstreams and combiner function.
|
||||||
|
*/
|
||||||
|
function createOrchestratedCombinedAutomat(upstreamAutomats, combiner, options = {}) {
|
||||||
|
const computeInitial = () => {
|
||||||
|
const upstreamStates = upstreamAutomats.map((a) => a.state);
|
||||||
|
return combiner(...upstreamStates);
|
||||||
|
};
|
||||||
|
|
||||||
|
const combined = new Automat(
|
||||||
|
computeInitial(),
|
||||||
|
{},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subscribe to all upstreams
|
||||||
|
upstreamAutomats.forEach((upstream) => {
|
||||||
|
combined.subscribeTo(upstream, () => {
|
||||||
|
const upstreamStates = upstreamAutomats.map((a) => a.state);
|
||||||
|
const combinedSlice = combiner(...upstreamStates);
|
||||||
|
combined.setState(combinedSlice);
|
||||||
|
return combinedSlice;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived aggregate returned by Automat.combine({ auth: authAutomat, company: companyAutomat }).
|
||||||
|
* 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 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?.() ?? automat.state])
|
||||||
|
);
|
||||||
|
return this._state;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDirty(keep = false, now = false) {
|
||||||
|
return Promise.all(Object.values(this._children).map((automat) => automat.setDirty?.(keep, now))).then(() => {
|
||||||
|
this._syncState();
|
||||||
|
return this._state;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncState() {
|
||||||
|
this._state = Object.fromEntries(
|
||||||
|
Object.entries(this._children).map(([key, automat]) => [key, automat.state])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
603
src/lib/KeyedAutomat.js
Normal file
603
src/lib/KeyedAutomat.js
Normal file
@@ -0,0 +1,603 @@
|
|||||||
|
/**
|
||||||
|
* @module KeyedAutomat
|
||||||
|
*
|
||||||
|
* Observable dynamic/keyed entity state container for React PureComponents.
|
||||||
|
* Manages collections of items by ID with concurrent fetch deduplication,
|
||||||
|
* React Suspense support, and fine-grained per-ID subscriptions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { shallowEqual, resolveSelector, getGlobalMap } from './Automat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a blank item state container.
|
||||||
|
* @template T
|
||||||
|
* @param {T | null} [initialData=null]
|
||||||
|
* @returns {import('./index.d.ts').ItemState<T>}
|
||||||
|
*/
|
||||||
|
export function createDefaultItemState(initialData = null) {
|
||||||
|
return {
|
||||||
|
data: initialData,
|
||||||
|
loading: false,
|
||||||
|
loaded: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight scoped handle for a specific key/ID within a KeyedAutomat.
|
||||||
|
* Implements an interface consistent with standard Automat instances.
|
||||||
|
* @template T
|
||||||
|
*/
|
||||||
|
export class KeyHandle {
|
||||||
|
/**
|
||||||
|
* @param {KeyedAutomat<T>} automat
|
||||||
|
* @param {string | number} id
|
||||||
|
*/
|
||||||
|
constructor(automat, id) {
|
||||||
|
this._automat = automat;
|
||||||
|
this._id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get id() {
|
||||||
|
return this._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get automat() {
|
||||||
|
return this._automat;
|
||||||
|
}
|
||||||
|
|
||||||
|
get state() {
|
||||||
|
return this._automat.get(this._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
get data() {
|
||||||
|
return this._automat.getData(this._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
get loading() {
|
||||||
|
return this._automat.get(this._id).loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
get loaded() {
|
||||||
|
return this._automat.isLoaded(this._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
get error() {
|
||||||
|
return this._automat.get(this._id).error;
|
||||||
|
}
|
||||||
|
|
||||||
|
load(options) {
|
||||||
|
return this._automat.load(this._id, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
read() {
|
||||||
|
return this._automat.read(this._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
reload() {
|
||||||
|
return this._automat.reload(this._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDirty(keep, now) {
|
||||||
|
return this._automat.setDirty(this._id, keep, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(data) {
|
||||||
|
return this._automat.set(this._id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
evict() {
|
||||||
|
return this._automat.evict(this._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(target, selector) {
|
||||||
|
return this._automat.subscribeKey(this._id, target, selector);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KeyedAutomat manages a dynamic dictionary of reactive entity states by ID.
|
||||||
|
* @template T
|
||||||
|
* @template A
|
||||||
|
*/
|
||||||
|
export class KeyedAutomat {
|
||||||
|
/**
|
||||||
|
* @param {T | null} [initialItemState=null] Default data value for new items.
|
||||||
|
* @param {A | ((automat: KeyedAutomat<T, A>) => A)} [callbacks={}] Actions dictionary or factory.
|
||||||
|
* @param {object} [options={}]
|
||||||
|
* @param {string} [options.name] Identifier for global registry and devtools.
|
||||||
|
* @param {(id: string | number, automat: KeyedAutomat<T, A>) => Promise<T> | T} [options.loader]
|
||||||
|
*/
|
||||||
|
constructor(initialItemState = null, callbacks = {}, options = {}) {
|
||||||
|
this._initialItem = initialItemState !== undefined ? initialItemState : null;
|
||||||
|
this._options = options || {};
|
||||||
|
this._name = options.name ?? null;
|
||||||
|
this._loader = options.loader ?? null;
|
||||||
|
|
||||||
|
/** @type {Map<string | number, import('./index.d.ts').ItemState<T>>} */
|
||||||
|
this._items = new Map();
|
||||||
|
|
||||||
|
/** @type {Map<string | number, Promise<T>>} */
|
||||||
|
this._inFlight = new Map();
|
||||||
|
|
||||||
|
/** @type {Map<string | number, Map<object | function, function>>} */
|
||||||
|
this._keyedSubs = new Map();
|
||||||
|
|
||||||
|
/** @type {Map<object | function, function>} */
|
||||||
|
this._globalSubs = new Map();
|
||||||
|
|
||||||
|
/** @type {Set<function>} */
|
||||||
|
this._dirtySubs = new Set();
|
||||||
|
|
||||||
|
/** @type {Map<string | number, KeyHandle<T>>} */
|
||||||
|
this._keyHandles = new Map();
|
||||||
|
|
||||||
|
const resolvedCallbacks = typeof callbacks === 'function' ? callbacks(this) : (callbacks || {});
|
||||||
|
this._cbs = {};
|
||||||
|
for (const [key, fn] of Object.entries(resolvedCallbacks)) {
|
||||||
|
this._cbs[key] = typeof fn === 'function' ? fn.bind(this) : fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._name) {
|
||||||
|
getGlobalMap('__KEYED_AUTOMATS__')?.set(this._name, this);
|
||||||
|
getGlobalMap('__AUTOMATS__')?.set(this._name, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return this._name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get actions() {
|
||||||
|
return this._cbs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a snapshot object of all currently cached ID item states.
|
||||||
|
*/
|
||||||
|
get state() {
|
||||||
|
const snapshot = {};
|
||||||
|
for (const [id, item] of this._items.entries()) {
|
||||||
|
snapshot[id] = item;
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
getState() {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves or creates a scoped KeyHandle proxy for the specified ID.
|
||||||
|
* @param {string | number} id
|
||||||
|
* @returns {KeyHandle<T>}
|
||||||
|
*/
|
||||||
|
key(id) {
|
||||||
|
const keyStr = String(id);
|
||||||
|
let handle = this._keyHandles.get(keyStr);
|
||||||
|
if (!handle) {
|
||||||
|
handle = new KeyHandle(this, id);
|
||||||
|
this._keyHandles.set(keyStr, handle);
|
||||||
|
}
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether an entry exists for the given ID.
|
||||||
|
* @param {string | number} id
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
has(id) {
|
||||||
|
return this._items.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the given ID has been successfully loaded.
|
||||||
|
* @param {string | number} id
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isLoaded(id) {
|
||||||
|
return Boolean(this._items.get(id)?.loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the ItemState for an ID. Returns a default unready ItemState if not yet fetched.
|
||||||
|
* @param {string | number} id
|
||||||
|
* @returns {import('./index.d.ts').ItemState<T>}
|
||||||
|
*/
|
||||||
|
get(id) {
|
||||||
|
const item = this._items.get(id);
|
||||||
|
if (item) return item;
|
||||||
|
return createDefaultItemState(this._initialItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias for get(id).
|
||||||
|
* @param {string | number} id
|
||||||
|
*/
|
||||||
|
getItem(id) {
|
||||||
|
return this.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the data property for an ID, or null if unready.
|
||||||
|
* @param {string | number} id
|
||||||
|
* @returns {T | null}
|
||||||
|
*/
|
||||||
|
getData(id) {
|
||||||
|
return this.get(id).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads data for a specific ID with concurrent fetch deduplication.
|
||||||
|
* @param {string | number} id
|
||||||
|
* @param {{ force?: boolean }} [options]
|
||||||
|
* @returns {Promise<T>}
|
||||||
|
*/
|
||||||
|
load(id, options = {}) {
|
||||||
|
const keyStr = String(id);
|
||||||
|
if (!options.force && this._inFlight.has(keyStr)) {
|
||||||
|
return this._inFlight.get(keyStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.get(id);
|
||||||
|
if (!options.force && current.loaded && !current.error) {
|
||||||
|
return Promise.resolve(current.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._loader) {
|
||||||
|
return Promise.resolve(current.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set loading state
|
||||||
|
const loadingState = {
|
||||||
|
...current,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
this._items.set(id, loadingState);
|
||||||
|
this._notifyKey(id, loadingState);
|
||||||
|
|
||||||
|
const promise = Promise.resolve(this._loader(id, this))
|
||||||
|
.then((data) => {
|
||||||
|
this._inFlight.delete(keyStr);
|
||||||
|
const loadedState = {
|
||||||
|
data: data !== undefined ? data : null,
|
||||||
|
loading: false,
|
||||||
|
loaded: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
this._items.set(id, loadedState);
|
||||||
|
this._notifyKey(id, loadedState);
|
||||||
|
return loadedState.data;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this._inFlight.delete(keyStr);
|
||||||
|
const errorVal = err?.message || String(err);
|
||||||
|
const errorState = {
|
||||||
|
...this.get(id),
|
||||||
|
loading: false,
|
||||||
|
error: errorVal,
|
||||||
|
};
|
||||||
|
this._items.set(id, errorState);
|
||||||
|
this._notifyKey(id, errorState);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
this._inFlight.set(keyStr, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React Suspense-compatible read method for an ID.
|
||||||
|
* - If not loaded and not loading, triggers load(id) and throws the promise.
|
||||||
|
* - If loading, throws the in-flight promise.
|
||||||
|
* - If error, throws the error.
|
||||||
|
* - If loaded, returns data.
|
||||||
|
*
|
||||||
|
* @param {string | number} id
|
||||||
|
* @returns {T}
|
||||||
|
*/
|
||||||
|
read(id) {
|
||||||
|
const keyStr = String(id);
|
||||||
|
const item = this._items.get(id);
|
||||||
|
|
||||||
|
if (!item || (!item.loaded && !item.loading && !item.error)) {
|
||||||
|
const promise = this.load(id);
|
||||||
|
throw promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.loading) {
|
||||||
|
const inFlight = this._inFlight.get(keyStr);
|
||||||
|
if (inFlight) throw inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.error) {
|
||||||
|
throw typeof item.error === 'object' ? item.error : new Error(item.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directly sets/commits data for a specific ID into state.
|
||||||
|
* @param {string | number} id
|
||||||
|
* @param {T} data
|
||||||
|
* @returns {import('./index.d.ts').ItemState<T>}
|
||||||
|
*/
|
||||||
|
set(id, data) {
|
||||||
|
const next = {
|
||||||
|
data: data !== undefined ? data : null,
|
||||||
|
loading: false,
|
||||||
|
loaded: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
this._items.set(id, next);
|
||||||
|
this._notifyKey(id, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates a specific ID or all cached IDs.
|
||||||
|
* If subscribed and configured with a loader, re-fetches immediately when requested or subscribed.
|
||||||
|
*
|
||||||
|
* @param {string | number} [id] Target ID. If omitted, marks all keys dirty.
|
||||||
|
* @param {boolean | { keep?: boolean, now?: boolean }} [keep=false]
|
||||||
|
* @param {boolean} [now=false]
|
||||||
|
*/
|
||||||
|
setDirty(id, keep = false, now = false) {
|
||||||
|
if (id === undefined || id === null) {
|
||||||
|
// Invalidate all items
|
||||||
|
const promises = [];
|
||||||
|
for (const key of Array.from(this._items.keys())) {
|
||||||
|
promises.push(this.setDirty(key, keep, now));
|
||||||
|
}
|
||||||
|
return Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
let shouldKeep = Boolean(keep);
|
||||||
|
let shouldNow = Boolean(now);
|
||||||
|
if (typeof keep === 'object' && keep !== null) {
|
||||||
|
shouldKeep = Boolean(keep.keep);
|
||||||
|
shouldNow = Boolean(keep.now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this._items.get(id);
|
||||||
|
if (typeof current?.data?.revoke === 'function') {
|
||||||
|
try {
|
||||||
|
current.data.revoke();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldKeep) {
|
||||||
|
const resetState = createDefaultItemState(this._initialItem);
|
||||||
|
this._items.set(id, resetState);
|
||||||
|
this._notifyKey(id, resetState);
|
||||||
|
} else if (current) {
|
||||||
|
current.loaded = false;
|
||||||
|
this._notifyKey(id, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fn of this._dirtySubs) {
|
||||||
|
try {
|
||||||
|
fn(id, this);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keySubs = this._keyedSubs.get(id);
|
||||||
|
const hasSubscribers = (keySubs && keySubs.size > 0) || this._globalSubs.size > 0;
|
||||||
|
|
||||||
|
if (this._loader && (shouldNow || hasSubscribers)) {
|
||||||
|
return this.load(id, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(this.get(id).data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forces re-fetch of an ID (or all if omitted).
|
||||||
|
* @param {string | number} [id]
|
||||||
|
*/
|
||||||
|
reload(id) {
|
||||||
|
return this.setDirty(id, false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evicts an item from memory, revoking any blob URLs if present.
|
||||||
|
* @param {string | number} id
|
||||||
|
*/
|
||||||
|
evict(id) {
|
||||||
|
const current = this._items.get(id);
|
||||||
|
if (typeof current?.data?.revoke === 'function') {
|
||||||
|
try {
|
||||||
|
current.data.revoke();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._items.delete(id);
|
||||||
|
this._inFlight.delete(String(id));
|
||||||
|
|
||||||
|
const empty = createDefaultItemState(this._initialItem);
|
||||||
|
this._notifyKey(id, empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all cached items and in-flight requests.
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
for (const id of Array.from(this._items.keys())) {
|
||||||
|
this.evict(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a listener called whenever an item (or the automat) becomes dirty.
|
||||||
|
* @param {(id: string | number | undefined, automat: KeyedAutomat<T, A>) => void} fn
|
||||||
|
* @returns {() => void}
|
||||||
|
*/
|
||||||
|
onDirty(fn) {
|
||||||
|
this._dirtySubs.add(fn);
|
||||||
|
return () => this._dirtySubs.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes a React PureComponent instance or a listener callback to a specific ID.
|
||||||
|
* Updates only trigger when this specific ID's state changes.
|
||||||
|
*
|
||||||
|
* @param {string | number} id
|
||||||
|
* @param {object | function} target React component (`this`) or callback function.
|
||||||
|
* @param {string | string[] | function} [selector]
|
||||||
|
* @returns {() => void} Unsubscribe cleanup function.
|
||||||
|
*/
|
||||||
|
subscribeKey(id, target, selector) {
|
||||||
|
const isComponent = target && typeof target.setState === 'function';
|
||||||
|
const isFunction = typeof target === 'function';
|
||||||
|
|
||||||
|
if (!isComponent && !isFunction) {
|
||||||
|
throw new TypeError(
|
||||||
|
'KeyedAutomat.subscribeKey expects a callback function or a React component instance with a setState method.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let subsForId = this._keyedSubs.get(id);
|
||||||
|
if (!subsForId) {
|
||||||
|
subsForId = new Map();
|
||||||
|
this._keyedSubs.set(id, subsForId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSelector = selector !== undefined && selector !== null;
|
||||||
|
const getSlice = hasSelector ? resolveSelector(selector, isComponent) : null;
|
||||||
|
let lastSlice = getSlice ? getSlice(this.get(id)) : undefined;
|
||||||
|
|
||||||
|
const notifyFn = (itemState) => {
|
||||||
|
if (getSlice) {
|
||||||
|
const nextSlice = getSlice(itemState);
|
||||||
|
if (nextSlice == null) return;
|
||||||
|
if (shallowEqual(lastSlice, nextSlice)) return;
|
||||||
|
lastSlice = nextSlice;
|
||||||
|
|
||||||
|
if (isComponent) {
|
||||||
|
if (typeof nextSlice !== 'object') {
|
||||||
|
throw new TypeError(
|
||||||
|
'KeyedAutomat: selector for a React component must return a state object, e.g. s => ({ data: s.data }).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
target.setState(nextSlice);
|
||||||
|
} else {
|
||||||
|
target(nextSlice);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isComponent) {
|
||||||
|
target.setState({ [id]: itemState });
|
||||||
|
} else {
|
||||||
|
target(itemState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
subsForId.set(target, notifyFn);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subsForId.delete(target);
|
||||||
|
if (subsForId.size === 0) {
|
||||||
|
this._keyedSubs.delete(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to the entire dictionary state or multi-item slices.
|
||||||
|
*
|
||||||
|
* @param {object | function} target React component (`this`) or callback function.
|
||||||
|
* @param {string | string[] | function} [selector]
|
||||||
|
* @returns {() => void} Unsubscribe cleanup function.
|
||||||
|
*/
|
||||||
|
subscribe(target, selector) {
|
||||||
|
const isComponent = target && typeof target.setState === 'function';
|
||||||
|
const isFunction = typeof target === 'function';
|
||||||
|
|
||||||
|
if (!isComponent && !isFunction) {
|
||||||
|
throw new TypeError(
|
||||||
|
'KeyedAutomat.subscribe expects a callback function or a React component instance with a setState method.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSelector = selector !== undefined && selector !== null;
|
||||||
|
const getSlice = hasSelector ? resolveSelector(selector, isComponent) : null;
|
||||||
|
let lastSlice = getSlice ? getSlice(this.state) : undefined;
|
||||||
|
|
||||||
|
const notifyFn = (fullState, partial) => {
|
||||||
|
if (getSlice) {
|
||||||
|
const nextSlice = getSlice(fullState);
|
||||||
|
if (nextSlice == null) return;
|
||||||
|
if (shallowEqual(lastSlice, nextSlice)) return;
|
||||||
|
lastSlice = nextSlice;
|
||||||
|
|
||||||
|
if (isComponent) {
|
||||||
|
if (typeof nextSlice !== 'object') {
|
||||||
|
throw new TypeError(
|
||||||
|
'KeyedAutomat: selector for a React component must return a state object, e.g. state => ({ items: state }).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
target.setState(nextSlice);
|
||||||
|
} else {
|
||||||
|
target(nextSlice);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isComponent) {
|
||||||
|
target.setState(partial ?? fullState);
|
||||||
|
} else {
|
||||||
|
target(fullState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._globalSubs.set(target, notifyFn);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
this._globalSubs.delete(target);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disposes the KeyedAutomat, revoking all blob resources and removing all subscribers.
|
||||||
|
*/
|
||||||
|
dispose() {
|
||||||
|
this.clear();
|
||||||
|
this._keyedSubs.clear();
|
||||||
|
this._globalSubs.clear();
|
||||||
|
this._dirtySubs.clear();
|
||||||
|
this._keyHandles.clear();
|
||||||
|
|
||||||
|
if (this._name) {
|
||||||
|
getGlobalMap('__KEYED_AUTOMATS__')?.delete(this._name);
|
||||||
|
getGlobalMap('__AUTOMATS__')?.delete(this._name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_notifyKey(id, itemState) {
|
||||||
|
// 1. Notify fine-grained per-ID subscribers (O(1))
|
||||||
|
const subsForId = this._keyedSubs.get(id);
|
||||||
|
if (subsForId) {
|
||||||
|
for (const notifyFn of subsForId.values()) {
|
||||||
|
try {
|
||||||
|
notifyFn(itemState);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Notify global dictionary subscribers
|
||||||
|
if (this._globalSubs.size > 0) {
|
||||||
|
const fullState = this.state;
|
||||||
|
const partial = { [id]: itemState };
|
||||||
|
for (const notifyFn of this._globalSubs.values()) {
|
||||||
|
try {
|
||||||
|
notifyFn(fullState, partial);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/lib/fetchers.js
Normal file
152
src/lib/fetchers.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Built-in Fetchers & SSE Connector for Automat.
|
||||||
|
*
|
||||||
|
* Provides specialized fetchers for JSON, Image Blobs, PDF Blobs,
|
||||||
|
* and Server-Sent Events (SSE) with automatic trigger action dispatch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard JSON fetcher.
|
||||||
|
* @param {string} url
|
||||||
|
* @param {RequestInit} [options]
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
export const fetchJson = (url, options = {}) =>
|
||||||
|
fetch(url, options).then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binary Blob fetcher (for images, PDFs, etc.).
|
||||||
|
* Creates a local object URL and provides a memory-freeing revoke() method.
|
||||||
|
* Automatically invoked when an Automat is setDirty() or disposed.
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @param {RequestInit} [options]
|
||||||
|
* @returns {Promise<{ blob: Blob, url: string, size: number, type: string, loadedAt: string, revoke: () => void }>}
|
||||||
|
*/
|
||||||
|
export const fetchBlob = (url, options = {}) =>
|
||||||
|
fetch(url, options).then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
return {
|
||||||
|
blob,
|
||||||
|
url: objectUrl,
|
||||||
|
size: blob.size,
|
||||||
|
type: blob.type,
|
||||||
|
loadedAt: new Date().toLocaleTimeString(),
|
||||||
|
revoke: () => {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchImageBlob = fetchBlob;
|
||||||
|
export const fetchPdfBlob = fetchBlob;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects an Automat to a Server-Sent Events (SSE) stream.
|
||||||
|
* Automatically forwards events, handles invalidation, and dispatches to
|
||||||
|
* the automat's internal `actions.onSSETrigger` or `onSSETrigger` method.
|
||||||
|
*
|
||||||
|
* @param {import('./Automat.js').Automat} automat - Target automat instance
|
||||||
|
* @param {string} url - SSE endpoint URL
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {(data: any, event: string, automat: any) => void} [options.onMessage]
|
||||||
|
* @param {Record<string, (data: any, automat: any) => void>} [options.events]
|
||||||
|
* @param {(err: any, automat: any) => void} [options.onError]
|
||||||
|
* @returns {{ eventSource: EventSource | null, close: () => void }}
|
||||||
|
*/
|
||||||
|
export const connectSSE = (automat, url, options = {}) => {
|
||||||
|
if (typeof window === 'undefined' || typeof EventSource === 'undefined') {
|
||||||
|
return { eventSource: null, close() {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const es = new EventSource(url);
|
||||||
|
|
||||||
|
const handleData = (eventName, rawData) => {
|
||||||
|
let data = rawData;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(rawData);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 1. Explicit event callback in options.events:
|
||||||
|
if (options.events?.[eventName]) {
|
||||||
|
options.events[eventName](data, automat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. User-provided general message listener:
|
||||||
|
if (options.onMessage) {
|
||||||
|
options.onMessage(data, eventName, automat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Call internal SSE trigger function on the automat (actions or direct method):
|
||||||
|
if (typeof automat.actions?.onSSETrigger === 'function') {
|
||||||
|
automat.actions.onSSETrigger(data, eventName, automat);
|
||||||
|
}
|
||||||
|
if (typeof automat.onSSETrigger === 'function') {
|
||||||
|
automat.onSSETrigger(data, eventName, automat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Standard SSE conventions:
|
||||||
|
if (eventName === 'invalidate' || eventName === 'dirty' || data?.action === 'invalidate') {
|
||||||
|
automat.setDirty();
|
||||||
|
} else if (eventName === 'reload' || data?.action === 'reload') {
|
||||||
|
if (typeof automat.actions?.reload === 'function') {
|
||||||
|
automat.actions.reload();
|
||||||
|
} else if (typeof automat.reload === 'function') {
|
||||||
|
automat.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onmessage = (e) => handleData('message', e.data);
|
||||||
|
|
||||||
|
const events = ['invalidate', 'reload', ...Object.keys(options.events || {})];
|
||||||
|
events.forEach((name) => {
|
||||||
|
es.addEventListener(name, (e) => handleData(name, e.data));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.onError) {
|
||||||
|
es.onerror = (err) => options.onError(err, automat);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
try {
|
||||||
|
es.close();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (automat._unsubs) {
|
||||||
|
automat._unsubs.push(cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventSource: es,
|
||||||
|
close: cleanup,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an SSE-enabled fetcher that automatically establishes an SSE connection
|
||||||
|
* on initial load and delegates data fetching to baseFetcher.
|
||||||
|
*
|
||||||
|
* @param {string} sseUrl
|
||||||
|
* @param {Function} [baseFetcher=fetchJson]
|
||||||
|
* @param {object} [sseOptions={}]
|
||||||
|
* @returns {(url: string, automat?: any) => Promise<any>}
|
||||||
|
*/
|
||||||
|
export const createSSEFetcher = (sseUrl, baseFetcher = fetchJson, sseOptions = {}) => {
|
||||||
|
let connected = false;
|
||||||
|
return (url, automat) => {
|
||||||
|
if (!connected && automat) {
|
||||||
|
connectSSE(automat, sseUrl, sseOptions);
|
||||||
|
connected = true;
|
||||||
|
}
|
||||||
|
return baseFetcher(url);
|
||||||
|
};
|
||||||
|
};
|
||||||
194
src/lib/index.d.ts
vendored
Normal file
194
src/lib/index.d.ts
vendored
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import type { PureComponent } from 'react';
|
||||||
|
|
||||||
|
export interface AutomatOptions<T = any> {
|
||||||
|
name?: string | null;
|
||||||
|
persist?: boolean;
|
||||||
|
loader?: (automat: Automat<T, any>) => Promise<T | Partial<T>> | T | Partial<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Selector<T, S> =
|
||||||
|
| keyof T
|
||||||
|
| (keyof T)[]
|
||||||
|
| ((state: T) => S | null | undefined);
|
||||||
|
|
||||||
|
export interface ScopedSlice<S> {
|
||||||
|
readonly state: S;
|
||||||
|
subscribe(target: PureComponent | ((slice: S) => void)): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlobResource {
|
||||||
|
blob: Blob;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
type: string;
|
||||||
|
loadedAt: string;
|
||||||
|
revoke: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSEOptions<T = any, A = any> {
|
||||||
|
onMessage?: (data: any, event: string, automat: Automat<T, A>) => void;
|
||||||
|
events?: Record<string, (data: any, automat: Automat<T, A>) => void>;
|
||||||
|
onError?: (err: any, automat: Automat<T, A>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 | ((automat: Automat<T, A>) => A),
|
||||||
|
options?: AutomatOptions<T>
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly state: T;
|
||||||
|
getState(): T;
|
||||||
|
setState(partial: Partial<T> | ((prevState: T) => Partial<T>)): T;
|
||||||
|
|
||||||
|
readonly actions: A;
|
||||||
|
readonly isDirty: boolean;
|
||||||
|
readonly ready: Promise<T>;
|
||||||
|
readonly name: string | null;
|
||||||
|
readonly persist: boolean;
|
||||||
|
readonly subscriberCount: number;
|
||||||
|
|
||||||
|
subscribe(
|
||||||
|
target: PureComponent | ((state: T, partial?: Partial<T>) => void),
|
||||||
|
selector?: Selector<T, any>
|
||||||
|
): () => void;
|
||||||
|
|
||||||
|
select<S = any>(selector: Selector<T, S>): ScopedSlice<S>;
|
||||||
|
unsubscribe(target: any): void;
|
||||||
|
|
||||||
|
subscribeTo<U = any>(
|
||||||
|
upstreamAutomat: Automat<U, any>,
|
||||||
|
transform: (upstreamState: U, currentState: T) => Partial<T> | null | undefined,
|
||||||
|
options?: { cascadeDirty?: boolean | ((upstream: U, current: T) => boolean) }
|
||||||
|
): this;
|
||||||
|
|
||||||
|
invalidateWith<U = any>(
|
||||||
|
upstreamAutomat: Automat<U, any>,
|
||||||
|
filterFn?: (upstreamState: U, currentState: T) => boolean
|
||||||
|
): this;
|
||||||
|
|
||||||
|
onDirty(fn: (automat: this) => void): () => void;
|
||||||
|
|
||||||
|
setDirty(keep?: boolean, now?: boolean): Promise<T>;
|
||||||
|
read(): T;
|
||||||
|
|
||||||
|
clearPersistence(): Promise<void>;
|
||||||
|
dispose(): void;
|
||||||
|
|
||||||
|
static get<T = any, A = any>(name: string): Automat<T, A> | undefined;
|
||||||
|
static keyed<T = any, A = Record<string, Function>>(
|
||||||
|
initialItemState?: T | null,
|
||||||
|
actions?: A | ((automat: KeyedAutomat<T, A>) => A),
|
||||||
|
options?: KeyedAutomatOptions<T>
|
||||||
|
): KeyedAutomat<T, A>;
|
||||||
|
static combine<M extends Record<string, Automat<any, any>>>(
|
||||||
|
automats: M
|
||||||
|
): Automat<CombinedState<M>, CombinedActions<M>>;
|
||||||
|
static combine<R = any>(
|
||||||
|
upstreamAutomats: Automat<any, any>[],
|
||||||
|
combiner: (...states: any[]) => R,
|
||||||
|
options?: AutomatOptions<R>
|
||||||
|
): Automat<R, Record<string, Function>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemState<T> {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
loaded: boolean;
|
||||||
|
error: Error | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyedAutomatOptions<T = any> {
|
||||||
|
name?: string | null;
|
||||||
|
loader?: (id: string | number, automat: KeyedAutomat<T, any>) => Promise<T> | T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KeyHandle<T = any> {
|
||||||
|
readonly id: string | number;
|
||||||
|
readonly automat: KeyedAutomat<T, any>;
|
||||||
|
readonly state: ItemState<T>;
|
||||||
|
readonly data: T | null;
|
||||||
|
readonly loading: boolean;
|
||||||
|
readonly loaded: boolean;
|
||||||
|
readonly error: Error | string | null;
|
||||||
|
|
||||||
|
load(options?: { force?: boolean }): Promise<T>;
|
||||||
|
read(): T;
|
||||||
|
reload(): Promise<T>;
|
||||||
|
setDirty(keep?: boolean, now?: boolean): Promise<T>;
|
||||||
|
set(data: T): ItemState<T>;
|
||||||
|
evict(): void;
|
||||||
|
subscribe(
|
||||||
|
target: PureComponent | ((itemState: ItemState<T>) => void),
|
||||||
|
selector?: Selector<ItemState<T>, any>
|
||||||
|
): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KeyedAutomat<T = any, A = Record<string, Function>> {
|
||||||
|
constructor(
|
||||||
|
initialItemState?: T | null,
|
||||||
|
actions?: A | ((automat: KeyedAutomat<T, A>) => A),
|
||||||
|
options?: KeyedAutomatOptions<T>
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly name: string | null;
|
||||||
|
readonly actions: A;
|
||||||
|
readonly state: Record<string | number, ItemState<T>>;
|
||||||
|
getState(): Record<string | number, ItemState<T>>;
|
||||||
|
|
||||||
|
key(id: string | number): KeyHandle<T>;
|
||||||
|
has(id: string | number): boolean;
|
||||||
|
isLoaded(id: string | number): boolean;
|
||||||
|
get(id: string | number): ItemState<T>;
|
||||||
|
getItem(id: string | number): ItemState<T>;
|
||||||
|
getData(id: string | number): T | null;
|
||||||
|
|
||||||
|
load(id: string | number, options?: { force?: boolean }): Promise<T>;
|
||||||
|
read(id: string | number): T;
|
||||||
|
set(id: string | number, data: T): ItemState<T>;
|
||||||
|
setDirty(id?: string | number, keep?: boolean, now?: boolean): Promise<any>;
|
||||||
|
reload(id?: string | number): Promise<any>;
|
||||||
|
evict(id: string | number): void;
|
||||||
|
clear(): void;
|
||||||
|
|
||||||
|
onDirty(fn: (id: string | number | undefined, automat: this) => void): () => void;
|
||||||
|
|
||||||
|
subscribeKey(
|
||||||
|
id: string | number,
|
||||||
|
target: PureComponent | ((itemState: ItemState<T>) => void),
|
||||||
|
selector?: Selector<ItemState<T>, any>
|
||||||
|
): () => void;
|
||||||
|
|
||||||
|
subscribe(
|
||||||
|
target: PureComponent | ((state: Record<string | number, ItemState<T>>) => void),
|
||||||
|
selector?: Selector<Record<string | number, ItemState<T>>, any>
|
||||||
|
): () => void;
|
||||||
|
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchJson<T = any>(url: string, options?: RequestInit): Promise<T>;
|
||||||
|
export function fetchBlob(url: string, options?: RequestInit): Promise<BlobResource>;
|
||||||
|
export const fetchImageBlob: typeof fetchBlob;
|
||||||
|
export const fetchPdfBlob: typeof fetchBlob;
|
||||||
|
|
||||||
|
export function connectSSE<T = any, A = any>(
|
||||||
|
automat: Automat<T, A>,
|
||||||
|
url: string,
|
||||||
|
options?: SSEOptions<T, A>
|
||||||
|
): { eventSource: EventSource | null; close: () => void };
|
||||||
|
|
||||||
|
export function createSSEFetcher(
|
||||||
|
sseUrl: string,
|
||||||
|
baseFetcher?: (url: string, automat?: any) => Promise<any>,
|
||||||
|
sseOptions?: SSEOptions
|
||||||
|
): (url: string, automat?: any) => Promise<any>;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* @module automat
|
* @module react-automat
|
||||||
*
|
*
|
||||||
* Observable state management for React PureComponents with optional persistence.
|
* Observable higher-order state container for React PureComponents with optional persistence.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* import { Automat } from './lib/index.js';
|
* import { Automat } from './lib/index.js';
|
||||||
@@ -34,3 +34,11 @@
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export { Automat } from './Automat.js';
|
export { Automat } from './Automat.js';
|
||||||
|
export { KeyedAutomat, KeyHandle } from './KeyedAutomat.js';
|
||||||
|
export {
|
||||||
|
fetchJson,
|
||||||
|
fetchImageBlob,
|
||||||
|
fetchPdfBlob,
|
||||||
|
connectSSE,
|
||||||
|
createSSEFetcher,
|
||||||
|
} from './fetchers.js';
|
||||||
|
|||||||
22
src/main.jsx
22
src/main.jsx
@@ -1,3 +1,25 @@
|
|||||||
|
// Ensure scroll-blocking events (wheel, touchstart, touchmove) default to passive: true
|
||||||
|
// Eliminates Chrome "[Violation] Added non-passive event listener to a scroll-blocking 'wheel' event"
|
||||||
|
if (typeof window !== 'undefined' && typeof EventTarget !== 'undefined') {
|
||||||
|
const originalAddEventListener = EventTarget.prototype.addEventListener;
|
||||||
|
const scrollEvents = new Set(['wheel', 'mousewheel', 'touchstart', 'touchmove']);
|
||||||
|
|
||||||
|
EventTarget.prototype.addEventListener = function (type, listener, options) {
|
||||||
|
let opts = options;
|
||||||
|
if (scrollEvents.has(type)) {
|
||||||
|
if (typeof options === 'boolean') {
|
||||||
|
opts = { capture: options, passive: true };
|
||||||
|
} else if (typeof options === 'object' && options !== null) {
|
||||||
|
if (options.passive === undefined) {
|
||||||
|
opts = { ...options, passive: true };
|
||||||
|
}
|
||||||
|
} else if (options === undefined) {
|
||||||
|
opts = { passive: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalAddEventListener.call(this, type, listener, opts);
|
||||||
|
};
|
||||||
|
}
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|||||||
171
vite.config.js
171
vite.config.js
@@ -78,6 +78,177 @@ function counterBackendPlugin() {
|
|||||||
|
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let profileFetchCount = 0;
|
||||||
|
server.middlewares.use('/api/profile', (req, res, next) => {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
profileFetchCount++;
|
||||||
|
// 350ms simulated latency to illustrate unready state and Suspense fallback
|
||||||
|
setTimeout(() => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 101,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
role: 'Staff Platform Engineer',
|
||||||
|
department: 'Distributed Systems',
|
||||||
|
fetchCount: profileFetchCount,
|
||||||
|
loadedAt: new Date().toLocaleTimeString(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, 350);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Photos Collection & Blob Endpoints ──────────────────────
|
||||||
|
server.middlewares.use('/api/photos', (req, res, next) => {
|
||||||
|
const url = new URL(req.url, 'http://localhost');
|
||||||
|
const match = url.pathname.match(/^\/(\d+)$/);
|
||||||
|
|
||||||
|
// Individual Photo SVG/Image Blob: /api/photos/:id
|
||||||
|
if (match) {
|
||||||
|
const id = Number(match[1]);
|
||||||
|
const colors = [
|
||||||
|
['#6366f1', '#4338ca', 'Alps Panorama'],
|
||||||
|
['#ec4899', '#be185d', 'Tokyo Neon Night'],
|
||||||
|
['#10b981', '#047857', 'Emerald Rainforest'],
|
||||||
|
['#f59e0b', '#b45309', 'Golden Dunes'],
|
||||||
|
];
|
||||||
|
const [c1, c2, title] = colors[(id - 1) % colors.length];
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 400" width="600" height="400">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${c1}" />
|
||||||
|
<stop offset="100%" stop-color="${c2}" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="600" height="400" fill="url(#g)" rx="16"/>
|
||||||
|
<circle cx="300" cy="180" r="80" fill="rgba(255,255,255,0.15)" />
|
||||||
|
<path d="M 120 340 L 260 210 L 360 290 L 480 160 L 580 340 Z" fill="rgba(255,255,255,0.22)" />
|
||||||
|
<text x="300" y="360" font-size="24" font-weight="bold" fill="#ffffff" text-anchor="middle" font-family="system-ui, sans-serif">
|
||||||
|
Photo #${id}: ${title}
|
||||||
|
</text>
|
||||||
|
</svg>`;
|
||||||
|
setTimeout(() => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'image/svg+xml',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(svg);
|
||||||
|
}, 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photos collection metadata
|
||||||
|
if (req.method === 'GET' && (url.pathname === '' || url.pathname === '/')) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
return res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
count: 3,
|
||||||
|
updatedAt: new Date().toLocaleTimeString(),
|
||||||
|
photos: [
|
||||||
|
{ id: 1, title: 'Alps Panorama', url: '/api/photos/1', memoryEst: '2.4 MB' },
|
||||||
|
{ id: 2, title: 'Tokyo Neon Night', url: '/api/photos/2', memoryEst: '3.1 MB' },
|
||||||
|
{ id: 3, title: 'Emerald Rainforest', url: '/api/photos/3', memoryEst: '1.9 MB' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── PDF Blob Endpoint ───────────────────────────────────────
|
||||||
|
server.middlewares.use('/api/docs/spec', (req, res, next) => {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
// Minimal valid PDF binary
|
||||||
|
const minimalPdf = `%PDF-1.4
|
||||||
|
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||||
|
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||||
|
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
|
||||||
|
4 0 obj << /Length 55 >> stream
|
||||||
|
BT /F1 24 Tf 100 700 Td (Automat PDF Stream Loaded) Tj ET
|
||||||
|
endstream endobj
|
||||||
|
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
|
||||||
|
xref
|
||||||
|
0 6
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000010 00000 n
|
||||||
|
0000000060 00000 n
|
||||||
|
0000000117 00000 n
|
||||||
|
0000000227 00000 n
|
||||||
|
0000000334 00000 n
|
||||||
|
trailer << /Root 1 0 R /Size 6 >>
|
||||||
|
startxref
|
||||||
|
407
|
||||||
|
%%EOF`;
|
||||||
|
setTimeout(() => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': 'inline; filename="automat-spec.pdf"',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(minimalPdf);
|
||||||
|
}, 250);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Server-Sent Events (SSE) Stream ─────────────────────────
|
||||||
|
const sseClients = new Set();
|
||||||
|
|
||||||
|
server.middlewares.use('/api/events/sse', (req, res, next) => {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.write(`event: connected\ndata: ${JSON.stringify({ time: new Date().toLocaleTimeString() })}\n\n`);
|
||||||
|
sseClients.add(res);
|
||||||
|
|
||||||
|
const keepAlive = setInterval(() => {
|
||||||
|
res.write(`: heartbeat\n\n`);
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
clearInterval(keepAlive);
|
||||||
|
sseClients.delete(res);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Endpoint to broadcast SSE events from the demo UI:
|
||||||
|
server.middlewares.use('/api/events/trigger', (req, res, next) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(body || '{}');
|
||||||
|
const eventName = payload.event || 'invalidate';
|
||||||
|
const data = payload.data || { reason: 'manual trigger', timestamp: Date.now() };
|
||||||
|
|
||||||
|
const ssePayload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
|
sseClients.forEach((client) => {
|
||||||
|
client.write(ssePayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, clients: sseClients.size, event: eventName }));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export default defineConfig({
|
|||||||
emptyOutDir: false,
|
emptyOutDir: false,
|
||||||
lib: {
|
lib: {
|
||||||
entry: resolve(import.meta.dirname, 'src/lib/index.js'),
|
entry: resolve(import.meta.dirname, 'src/lib/index.js'),
|
||||||
name: 'Automat',
|
name: 'ReactAutomat',
|
||||||
fileName: (format) => `automat.${format}.js`,
|
fileName: (format) => `react-automat.${format}.js`,
|
||||||
formats: ['es', 'umd'],
|
formats: ['es', 'umd'],
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
|||||||
Reference in New Issue
Block a user