Genesis
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/node_modules
|
||||||
|
/dist
|
||||||
371
README.LLM.md
Normal file
371
README.LLM.md
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
# Automat — LLM Agent API Reference & Integration Guide
|
||||||
|
|
||||||
|
This document is optimized for LLMs and AI coding assistants implementing or consuming the `automat` state management library.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. System Overview & Core Invariants
|
||||||
|
|
||||||
|
`automat` is a lightweight (~1.1 kB minified, zero-dependency) observable state container designed specifically for React `PureComponent`.
|
||||||
|
|
||||||
|
### Key Invariants
|
||||||
|
1. **Instance Lifetime (Module Singleton or Dynamic Map Registry)**: An `Automat` instance lives outside the React render tree. While commonly instantiated as module-level singletons, instances can also be stored dynamically in a `Map` (e.g. `window.automatRegistry = new Map()` or an in-memory entity cache) keyed by ID or index. State persists in memory across component mounts, unmounts, and subscription transfers.
|
||||||
|
2. **Direct Constructor Read**: Components read `automat.state` directly in their `constructor(props)`. State is never stale upon mounting.
|
||||||
|
3. **Lifecycle Subscription**: Components register with `automat.subscribe(this)` in `componentDidMount()` and call `this.unsubscribe()` in `componentWillUnmount()`.
|
||||||
|
4. **Hybrid State by Default**: When `automat.setState()` notifies a component, it calls `component.setState(partial)`. React's class component `setState` performs a shallow merge, preserving any component-local state fields.
|
||||||
|
5. **No Wrappers or Hooks**: No HOCs, no context providers, no hooks, no `connect()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. API Reference
|
||||||
|
|
||||||
|
### Constructor
|
||||||
|
|
||||||
|
```ts
|
||||||
|
new Automat<T extends object, A extends Record<string, Function>>(
|
||||||
|
initialState: T,
|
||||||
|
actions?: A
|
||||||
|
): Automat<T, A>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `initialState`: Object representing the initial state snapshot. Shallow-copied internally.
|
||||||
|
- `actions` *(optional)*: Object of action functions. Accessible via `automat.actions`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Instance Properties & Methods
|
||||||
|
|
||||||
|
| Member | Signature | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `state` | `get state(): T` | Direct getter returning current state snapshot. Ideal for component constructors: `this.state = myAutomat.state;`. |
|
||||||
|
| `getState()` | `getState(): T` | Method returning current state snapshot. |
|
||||||
|
| `actions` | `get actions(): A` | Getter returning the actions object passed into the constructor. |
|
||||||
|
| `setState()` | `setState(partial: Partial<T>): T` | Shallow-merges `partial` into current state and synchronously notifies all subscribers. Returns new state. |
|
||||||
|
| `subscribe()` | `subscribe(target: PureComponent \| ((state: T) => void), selector?: (state: T) => object \| null): () => void` | Subscribes either a React component instance (`this`) or a callback function. Returns an `unsubscribe` function. |
|
||||||
|
| `unsubscribe()` | `unsubscribe(target: PureComponent \| Function): void` | Manually unregisters a subscriber. |
|
||||||
|
| `subscribeTo()` | `subscribeTo<U>(upstream: Automat<U>, transform: (upstreamState: U, myState: T) => Partial<T> \| null): this` | Reactive pipeline: Derives state from an upstream automat. Returns `this` for chaining. |
|
||||||
|
| `dispose()` | `dispose(): void` | Unsubscribes all upstream listeners and clears all subscribers. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Canonical Patterns for Code Generation
|
||||||
|
|
||||||
|
### Pattern A: Defining an Automat Module
|
||||||
|
|
||||||
|
```js
|
||||||
|
// src/automats/counterAutomat.js
|
||||||
|
import { Automat } from 'automat'; // or relative path to lib/index.js
|
||||||
|
|
||||||
|
export const counterAutomat = new Automat(
|
||||||
|
{ count: 0 },
|
||||||
|
{
|
||||||
|
increment(step = 1) {
|
||||||
|
counterAutomat.setState({ count: counterAutomat.state.count + step });
|
||||||
|
},
|
||||||
|
decrement(step = 1) {
|
||||||
|
counterAutomat.setState({ count: counterAutomat.state.count - step });
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
counterAutomat.setState({ count: 0 });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pattern B: React `PureComponent` Integration & Click Wiring
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// src/components/CounterController.jsx
|
||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { counterAutomat } from '../automats/counterAutomat.js';
|
||||||
|
|
||||||
|
export class CounterController extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
// 1. Initialize hybrid state: shared automat fields + component-local fields
|
||||||
|
this.state = {
|
||||||
|
count: counterAutomat.state.count, // shared state from automat
|
||||||
|
step: 1, // local state private to component
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// 2. Subscribe component to automat updates:
|
||||||
|
this.unsubscribe = counterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
// 3. Clean up subscription on unmount:
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Click handlers: invoke automat action directly
|
||||||
|
handleIncrement = () => {
|
||||||
|
counterAutomat.actions.increment(this.state.step);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDecrement = () => {
|
||||||
|
counterAutomat.actions.decrement(this.state.step);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { count, step } = this.state;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>Count: {count}</p>
|
||||||
|
<button onClick={this.handleDecrement}>−{step}</button>
|
||||||
|
<button onClick={this.handleIncrement}>+{step}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pattern C: Passive Reader Component with State Selector
|
||||||
|
|
||||||
|
Use a selector function when a component only cares about a subset of the automat's state:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// src/components/CountDisplay.jsx
|
||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { counterAutomat } from '../automats/counterAutomat.js';
|
||||||
|
|
||||||
|
export class CountDisplay extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { count: counterAutomat.state.count };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Selector maps state to target object. Returning null skips setState.
|
||||||
|
this.unsubscribe = counterAutomat.subscribe(this, (state) => ({
|
||||||
|
count: state.count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <h1>Current Count: {this.state.count}</h1>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pattern D: Reactive Cascade with `subscribeTo()`
|
||||||
|
|
||||||
|
Use `subscribeTo()` to connect two automats into a reactive pipe. The `transform` function receives `(upstreamState, myState)`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// src/automats/auditAutomat.js
|
||||||
|
import { Automat } from 'automat';
|
||||||
|
import { counterAutomat } from './counterAutomat.js';
|
||||||
|
|
||||||
|
export const auditAutomat = new Automat({ logs: [] });
|
||||||
|
|
||||||
|
// Wire reactive pipeline:
|
||||||
|
auditAutomat.subscribeTo(
|
||||||
|
counterAutomat,
|
||||||
|
(upstream, my) =>
|
||||||
|
// Return null to conditionally skip updates; otherwise return partial state:
|
||||||
|
upstream.count === 0
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
logs: [
|
||||||
|
{ id: Date.now(), text: `Counter changed to ${upstream.count}` },
|
||||||
|
...my.logs.slice(0, 19), // Accumulate history up to 20 items
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Rules for `subscribeTo()`:
|
||||||
|
1. **`upstream`**: Snapshot of the observed automat after its update.
|
||||||
|
2. **`my`**: Snapshot of the current (downstream) automat *before* this update. Use this as an accumulator.
|
||||||
|
3. **Filtering (`return null`)**: Return `null` or `undefined` to bypass `setState()`, producing zero subscriber notifications and zero component re-renders.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pattern E: API-Backed Auto-Sync Indexed Counter (POST)
|
||||||
|
|
||||||
|
An automat can perform optimistic state updates immediately for responsive UI, while automatically synchronizing mutations to the backend via HTTP POST in the background:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// src/automats/syncCounterAutomat.js
|
||||||
|
import { Automat } from 'automat';
|
||||||
|
|
||||||
|
export const syncCounterAutomat = new Automat(
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
count: 0,
|
||||||
|
syncStatus: 'synced', // 'syncing' | 'synced' | 'error'
|
||||||
|
lastSyncedAt: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
async increment(step = 1) {
|
||||||
|
const { index, count } = syncCounterAutomat.state;
|
||||||
|
const nextCount = count + step;
|
||||||
|
|
||||||
|
// 1. Optimistic update (UI updates immediately):
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
count: nextCount,
|
||||||
|
syncStatus: 'syncing',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Automatic background sync via POST /api/counter:
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/counter', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ index, count: nextCount }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// 3. Mark in-sync once server responds:
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
syncStatus: 'synced',
|
||||||
|
lastSyncedAt: data.savedAt,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
syncStatus: 'error',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pattern F: Dynamic Window Map (Count-Driven Automat Resubscription)
|
||||||
|
|
||||||
|
In this pattern, a standard shared counter automat drives the index. A second component watches that counter and uses its value to access, dynamically instantiate, and resubscribe to a different `Automat` stored in `window.automats = new Map()`, instantly recalling that slot's state:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 1. Shared index/counter automat (like Example 1):
|
||||||
|
export const indexAutomat = new Automat({ index: 0 }, {
|
||||||
|
increment() { indexAutomat.setState({ index: indexAutomat.state.index + 1 }); },
|
||||||
|
decrement() { indexAutomat.setState({ index: Math.max(0, indexAutomat.state.index - 1) }); },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Map on window holding dynamically instantiated Automats per index:
|
||||||
|
if (typeof window !== 'undefined' && !window.automats) {
|
||||||
|
window.automats = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrCreateSlotAutomat(index) {
|
||||||
|
if (!window.automats.has(index)) {
|
||||||
|
const automat = new Automat(
|
||||||
|
{ index, clicks: 0, notes: `Slot #${index} initial notes` },
|
||||||
|
{
|
||||||
|
click() { automat.setState({ clicks: automat.state.clicks + 1 }); },
|
||||||
|
setNotes(notes) { automat.setState({ notes }); },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
window.automats.set(index, automat);
|
||||||
|
}
|
||||||
|
return window.automats.get(index);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Dynamic Resubscription Component:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// 3. Component dynamically resubscribing based on shared counter value:
|
||||||
|
export class DynamicSlotObserver extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const initialIndex = indexAutomat.state.index;
|
||||||
|
this.state = {
|
||||||
|
currentIndex: initialIndex,
|
||||||
|
slotState: getOrCreateSlotAutomat(initialIndex).state, // Recall state on mount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Watch shared index counter:
|
||||||
|
this.unsubIndex = indexAutomat.subscribe((indexState) => {
|
||||||
|
this.handleIndexChange(indexState.index);
|
||||||
|
});
|
||||||
|
// Subscribe to initial slot automat:
|
||||||
|
this.subscribeToSlot(this.state.currentIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubIndex?.();
|
||||||
|
this.unsubSlot?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleIndexChange(newIndex) {
|
||||||
|
if (newIndex === this.state.currentIndex) return;
|
||||||
|
|
||||||
|
// 💡 DYNAMIC RESUBSCRIPTION WORKFLOW:
|
||||||
|
// 1. Unhook old subscription:
|
||||||
|
this.unsubSlot?.();
|
||||||
|
|
||||||
|
// 2. Lookup or dynamically instantiate in window.automats:
|
||||||
|
const slotAutomat = getOrCreateSlotAutomat(newIndex);
|
||||||
|
|
||||||
|
// 3. Recall preserved state immediately:
|
||||||
|
this.setState({
|
||||||
|
currentIndex: newIndex,
|
||||||
|
slotState: slotAutomat.state,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Resubscribe to the newly selected instance:
|
||||||
|
this.unsubSlot = slotAutomat.subscribe((slotState) => {
|
||||||
|
this.setState({ slotState });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeToSlot(index) {
|
||||||
|
this.unsubSlot = getOrCreateSlotAutomat(index).subscribe((slotState) => {
|
||||||
|
this.setState({ slotState });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { currentIndex, slotState } = this.state;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3>Slot #{currentIndex}</h3>
|
||||||
|
<p>Recalled Clicks: {slotState.clicks}</p>
|
||||||
|
<button onClick={() => getOrCreateSlotAutomat(currentIndex).actions.click()}>
|
||||||
|
Click Slot #{currentIndex}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Strict Agent Guidelines (DOs and DON'Ts)
|
||||||
|
|
||||||
|
### ✅ DOs
|
||||||
|
- **DO** create `Automat` instances in module scope or outside React components.
|
||||||
|
- **DO** initialize component state synchronously in `constructor(props)` using `this.state = myAutomat.state;` or `{ ...myAutomat.state, localField: 'val' }`.
|
||||||
|
- **DO** register subscriptions in `componentDidMount()` via `this.unsubscribe = myAutomat.subscribe(this);`.
|
||||||
|
- **DO** clean up subscriptions in `componentWillUnmount()` via `this.unsubscribe();`.
|
||||||
|
- **DO** invoke actions directly from event handlers (e.g. `onClick={() => myAutomat.actions.doSomething()}`).
|
||||||
|
- **DO** return `null` in `subscribeTo` transforms when an update should be filtered out.
|
||||||
|
|
||||||
|
### ❌ DON'Ts
|
||||||
|
- **DON'T** instantiate `new Automat()` inside a React component's `render()`, `constructor()`, or lifecycle method.
|
||||||
|
- **DON'T** mutate state directly (e.g. `myAutomat.state.count = 5` is forbidden). Always call `myAutomat.setState({ count: 5 })` or an action.
|
||||||
|
- **DON'T** wrap components in React Context providers, HOCs, or `connect()`.
|
||||||
|
- **DON'T** use React Hooks (`useState`, `useEffect`) when targeting the `automat` class component architecture. Use `PureComponent`.
|
||||||
|
- **DON'T** forget to store the return value of `subscribe(this)` and call it in `componentWillUnmount()`, as this will cause memory leaks.
|
||||||
500
README.md
Normal file
500
README.md
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
# ⚙ automat
|
||||||
|
|
||||||
|
> Observable state management for React `PureComponent`.
|
||||||
|
> State lives **independently of component lifecycle** — mounts and unmounts freely without losing state.
|
||||||
|
> **No wrappers, no HOCs, no `connect()` — purely direct access and lifecycle subscriber handling.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Redux separates state from UI, but brings boilerplate and pushes toward hooks. Higher-order wrappers and `connect()` introduce indirection, wrapper nesting, and tricky state hydration.
|
||||||
|
|
||||||
|
`automat` provides a clean, direct approach centered on standard `React.PureComponent`:
|
||||||
|
|
||||||
|
1. **Instantiate first**: The Automat instance is created outside React's render tree.
|
||||||
|
2. **Direct constructor access**: Components initialize directly from `automat.state` (or `getState()`) — never stale, even after transitions prior to mounting.
|
||||||
|
3. **Lifecycle subscriber handling**: In `componentDidMount`, register the component with `automat.subscribe(this)`. In `componentWillUnmount`, call `this.unsubscribe()` or `automat.unsubscribe(this)`.
|
||||||
|
4. **Direct event wiring**: Call `automat.actions.actionName()` or `automat.setState(...)` directly in `onClick` handlers. No dispatchers, actions creators, or prop drilling.
|
||||||
|
5. **Render minimization**: Standard `PureComponent` shallow state comparison prevents unnecessary re-renders automatically without extra layers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Direct PureComponent Pattern (Wired Click Example)
|
||||||
|
|
||||||
|
Here is a complete, two-component example showing how clicks trigger actions and synchronize independent components:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { Automat } from 'automat';
|
||||||
|
|
||||||
|
// 1. Instantiate the automat outside React:
|
||||||
|
const counterAutomat = new Automat(
|
||||||
|
{ count: 0 },
|
||||||
|
{
|
||||||
|
increment(step = 1) {
|
||||||
|
counterAutomat.setState({ count: counterAutomat.state.count + step });
|
||||||
|
},
|
||||||
|
decrement(step = 1) {
|
||||||
|
counterAutomat.setState({ count: counterAutomat.state.count - step });
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
counterAutomat.setState({ count: 0 });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Controller component: buttons trigger actions, hybrid state tracks local clicks
|
||||||
|
class CounterButton extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
// 💡 HYBRID STATE:
|
||||||
|
// Shared count comes from the automat; step & localClicks are local
|
||||||
|
this.state = {
|
||||||
|
count: counterAutomat.state.count, // ← from automat
|
||||||
|
step: 1, // ← component-local state
|
||||||
|
localClicks: 0, // ← component-local state
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Subscribe component to automat updates
|
||||||
|
this.unsubscribe = counterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 💡 CLICK HANDLERS: update local state AND trigger automat actions
|
||||||
|
handleIncrement = () => {
|
||||||
|
const { step, localClicks } = this.state;
|
||||||
|
this.setState({ localClicks: localClicks + 1 });
|
||||||
|
counterAutomat.actions.increment(step); // ← Triggers automat!
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDecrement = () => {
|
||||||
|
const { step, localClicks } = this.state;
|
||||||
|
this.setState({ localClicks: localClicks + 1 });
|
||||||
|
counterAutomat.actions.decrement(step); // ← Triggers automat!
|
||||||
|
};
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
this.setState({ localClicks: 0 });
|
||||||
|
counterAutomat.actions.reset(); // ← Triggers automat!
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { count, step, localClicks } = this.state;
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<p>Count: {count} · Local Clicks: {localClicks}</p>
|
||||||
|
|
||||||
|
{/* 💡 WIRED ONCLICK: calls handlers directly */}
|
||||||
|
<button onClick={this.handleDecrement}>−{step}</button>
|
||||||
|
<button onClick={this.handleIncrement}>+{step}</button>
|
||||||
|
<button onClick={this.handleReset}>Reset</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Independent Display component: reads same automat with ZERO props passed
|
||||||
|
class CounterDisplay extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
// Reads directly from automat in constructor:
|
||||||
|
this.state = counterAutomat.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Automatically re-renders when CounterButton triggers an increment/decrement
|
||||||
|
this.unsubscribe = counterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <h1>Display: {this.state.count}</h1>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Why Hybrid State works seamlessly with React PureComponent
|
||||||
|
|
||||||
|
When `counterAutomat.setState({ count: 42 })` notifies the subscriber:
|
||||||
|
1. It calls `this.setState({ count: 42 })` on the component instance.
|
||||||
|
2. React's class component `setState` performs a **shallow merge** into `this.state`.
|
||||||
|
3. Local fields (`step`, `localClicks`, `inputValue`) remain untouched.
|
||||||
|
4. `PureComponent`'s shallow comparison ensures renders happen only when values change.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
class SearchBox extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
...searchAutomat.state, // results, loading, etc.
|
||||||
|
inputValue: '', // component-local input
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsubscribe = searchAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInput = (e) => {
|
||||||
|
this.setState({ inputValue: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 💡 Wired form submission / click:
|
||||||
|
handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const query = this.state.inputValue.trim();
|
||||||
|
if (query) {
|
||||||
|
searchAutomat.actions.search(query); // ← Triggers async search action
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { loading, results, inputValue } = this.state;
|
||||||
|
return (
|
||||||
|
<form onSubmit={this.handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={this.handleInput}
|
||||||
|
placeholder="Search items…"
|
||||||
|
/>
|
||||||
|
{/* 💡 Click triggers handleSubmit → searchAutomat.actions.search() */}
|
||||||
|
<button type="submit" disabled={loading}>
|
||||||
|
{loading ? 'Searching…' : 'Search'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{results?.map((item) => (
|
||||||
|
<li key={item.id}>{item.title}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core API
|
||||||
|
|
||||||
|
### `new Automat(initialState, actions?)`
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { Automat } from './src/lib/index.js';
|
||||||
|
|
||||||
|
const counterAutomat = new Automat(
|
||||||
|
{ count: 0 },
|
||||||
|
{
|
||||||
|
increment(step = 1) {
|
||||||
|
counterAutomat.setState({ count: counterAutomat.state.count + step });
|
||||||
|
},
|
||||||
|
decrement(step = 1) {
|
||||||
|
counterAutomat.setState({ count: counterAutomat.state.count - step });
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
counterAutomat.setState({ count: 0 });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
| Member | Description |
|
||||||
|
|---|---|
|
||||||
|
| `automat.state` | Direct getter for current state snapshot (ideal for `constructor`) |
|
||||||
|
| `automat.getState()` | Returns current state snapshot |
|
||||||
|
| `automat.setState(partial)` | Merges partial into state and notifies all subscribers |
|
||||||
|
| `automat.subscribe(target, selector?)` | Subscribes either a component instance (`this`) or a callback `(state) => ...`. Returns unsub function. |
|
||||||
|
| `automat.unsubscribe(target)` | Unsubscribes a component instance or callback function |
|
||||||
|
| `automat.subscribeTo(upstream, transform)` | Notification cascade: derives state from an upstream automat |
|
||||||
|
| `automat.actions` | Named action callbacks passed to constructor — call directly from `onClick` |
|
||||||
|
| `automat.dispose()` | Tears down all upstream subscriptions and clears all subscribers |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notification Cascade (Wired Example)
|
||||||
|
|
||||||
|
### How `subscribeTo()` Works (Reactive Pipeline)
|
||||||
|
|
||||||
|
`subscribeTo()` establishes a **reactive pipeline between two automats** without React components in the middle. Think of it like a database trigger or spreadsheet formula: when the upstream changes, the downstream automatically derives new state.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ setState() ┌─────────────────────────┐
|
||||||
|
│ counterAutomat │ ───────────────────> │ notificationAutomat │
|
||||||
|
│ (Upstream) │ │ (Downstream) │
|
||||||
|
└─────────────────┘ └────────────┬────────────┘
|
||||||
|
│ notifies
|
||||||
|
▼
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ NotificationBar │
|
||||||
|
│ (PureComponent UI) │
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Code Anatomy:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 1. Upstream automat (e.g. holds raw counter)
|
||||||
|
const counterAutomat = new Automat({ count: 0 }, {
|
||||||
|
increment(step = 1) {
|
||||||
|
counterAutomat.setState({ count: counterAutomat.state.count + step });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Downstream automat (e.g. maintains an event/audit log)
|
||||||
|
const notificationAutomat = new Automat(
|
||||||
|
{ messages: [] },
|
||||||
|
{
|
||||||
|
clear() { notificationAutomat.setState({ messages: [] }); },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Connect downstream to upstream (returns null to filter, or state object):
|
||||||
|
notificationAutomat.subscribeTo(
|
||||||
|
counterAutomat,
|
||||||
|
(upstream, my) =>
|
||||||
|
upstream.count === 0
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: Date.now(),
|
||||||
|
text: `Counter changed to ${upstream.count}`,
|
||||||
|
time: new Date().toLocaleTimeString(),
|
||||||
|
count: upstream.count,
|
||||||
|
},
|
||||||
|
...my.messages.slice(0, 9), // Caps list at 10 items
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameter Breakdown:
|
||||||
|
|
||||||
|
| Parameter | What it receives | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `upstreamAutomat` | `counterAutomat` | The automat to watch. Any time it calls `setState()`, the transform runs. |
|
||||||
|
| `upstreamState` | `{ count: 42 }` | The **new state snapshot** of the upstream automat. |
|
||||||
|
| `myState` | `{ messages: [...] }` | The **current state snapshot** of *this* downstream automat right before updating. Essential for accumulating history, comparing previous values, or merging. |
|
||||||
|
| **Return value** | `{ messages: [...] }` | A **partial state object** passed to `this.setState(partial)`. Returning `null` skips the update. |
|
||||||
|
|
||||||
|
#### Filtering Updates (Conditional Derivation):
|
||||||
|
|
||||||
|
You can selectively ignore upstream events by returning `null`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Only log notifications when count exceeds 10:
|
||||||
|
notificationAutomat.subscribeTo(counterAutomat, (upstreamState, myState) => {
|
||||||
|
if (upstreamState.count < 10) {
|
||||||
|
return null; // 💡 Returning null skips setState — no re-renders!
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
messages: [{ id: Date.now(), text: `High value reached: ${upstreamState.count}` }, ...myState.messages],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Multiple Upstream Sources & Chaining:
|
||||||
|
|
||||||
|
`subscribeTo()` returns `this`, so an automat can aggregate from multiple independent sources:
|
||||||
|
|
||||||
|
```js
|
||||||
|
dashboardAutomat
|
||||||
|
.subscribeTo(userAutomat, (user) => ({ username: user.name }))
|
||||||
|
.subscribeTo(cartAutomat, (cart) => ({ cartItemCount: cart.items.length }));
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Teardown:
|
||||||
|
|
||||||
|
Calling `notificationAutomat.dispose()` unsubscribes all upstream listeners automatically to prevent memory leaks when an automat is torn down.
|
||||||
|
|
||||||
|
|
||||||
|
### Wiring the Cascade in UI:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// 4. Controller component: buttons trigger the UPSTREAM automat
|
||||||
|
class CascadeControls extends PureComponent {
|
||||||
|
handleTrigger = (step) => {
|
||||||
|
// 💡 CLICK WIRED HERE:
|
||||||
|
// Calling counterAutomat triggers notificationAutomat downstream!
|
||||||
|
counterAutomat.actions.increment(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClear = () => {
|
||||||
|
notificationAutomat.actions.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => this.handleTrigger(1)}>Trigger (+1)</button>
|
||||||
|
<button onClick={() => this.handleTrigger(5)}>Trigger (+5)</button>
|
||||||
|
<button onClick={this.handleClear}>Clear Stream</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Downstream component: automatically receives derived cascade messages
|
||||||
|
class NotificationBar extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
messages: notificationAutomat.state.messages, // ← from cascade
|
||||||
|
filter: 'all', // ← component-local
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsub = notificationAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsub();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { messages } = this.state;
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{messages.map((m) => (
|
||||||
|
<li key={m.id}>{m.text} ({m.time})</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `counterAutomat.setState()` fires → `transform` runs → `notificationAutomat.setState()` fires → `NotificationBar` automatically re-renders.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API-Backed Auto-Sync Counter (POST)
|
||||||
|
|
||||||
|
An automat can perform optimistic state updates immediately for instant UI feedback, while automatically synchronizing mutations to the backend via HTTP POST in the background:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// syncCounterAutomat.js
|
||||||
|
const syncCounterAutomat = new Automat(
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
count: 0,
|
||||||
|
syncStatus: 'synced', // 'syncing' | 'synced' | 'error'
|
||||||
|
lastSyncedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
async increment(step = 1) {
|
||||||
|
const { index, count } = syncCounterAutomat.state;
|
||||||
|
const nextCount = count + step;
|
||||||
|
|
||||||
|
// 1. Optimistic update (UI updates immediately):
|
||||||
|
syncCounterAutomat.setState({ count: nextCount, syncStatus: 'syncing' });
|
||||||
|
|
||||||
|
// 2. Automatic background sync via POST /api/counter:
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/counter', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ index, count: nextCount }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
syncCounterAutomat.setState({ syncStatus: 'synced', lastSyncedAt: data.savedAt });
|
||||||
|
} catch (err) {
|
||||||
|
syncCounterAutomat.setState({ syncStatus: 'error', error: err.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Wiring:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
class SyncCounterControls extends PureComponent {
|
||||||
|
state = syncCounterAutomat.state;
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsubscribe = syncCounterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { index, count, syncStatus, lastSyncedAt } = this.state;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3>Counter #{index}: {count}</h3>
|
||||||
|
<button onClick={() => syncCounterAutomat.actions.increment(1)}>+1</button>
|
||||||
|
<span>Status: {syncStatus === 'syncing' ? 'POST in flight…' : `Synced (${lastSyncedAt})`}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building the Standalone Library
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Produces minified, zero-dependency bundles in `dist/`:
|
||||||
|
- `dist/automat.es.js` (~1.14 kB raw / **545 B** gzipped)
|
||||||
|
- `dist/automat.umd.js` (~1.09 kB raw / **552 B** gzipped)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── lib/
|
||||||
|
│ ├── Automat.js ← core observable state class
|
||||||
|
│ └── index.js ← public re-exports
|
||||||
|
│
|
||||||
|
└── examples/
|
||||||
|
├── automats/
|
||||||
|
│ ├── counterAutomat.js ← local memory automat
|
||||||
|
│ ├── notificationAutomat.js ← subscribes to counter (cascade)
|
||||||
|
│ ├── syncCounterAutomat.js ← API-backed auto-sync indexed counter (POST)
|
||||||
|
│ └── indexAutomat.js ← shared index + dynamic window.automats Map
|
||||||
|
└── components/
|
||||||
|
├── CounterButton.jsx ← PureComponent with direct constructor & subscribe
|
||||||
|
├── CounterDisplay.jsx ← independent PureComponent synced via counterAutomat
|
||||||
|
├── CascadeControls.jsx ← PureComponent driving and demonstrating upstream cascade
|
||||||
|
├── NotificationBar.jsx ← independent PureComponent displaying cascade stream
|
||||||
|
├── SyncCounterControls.jsx ← PureComponent driving auto-sync indexed counter
|
||||||
|
├── SyncBackendMonitor.jsx ← PureComponent inspecting backend DB and POST payload
|
||||||
|
├── IndexSelector.jsx ← PureComponent driving shared index counter
|
||||||
|
└── DynamicAutomatSubscriber.jsx ← dynamically resubscribes to window.automats by index
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
22
index.html
Normal file
22
index.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Automat — React PureComponent State Manager</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Observable state management 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.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
890
package-lock.json
generated
Normal file
890
package-lock.json
generated
Normal file
@@ -0,0 +1,890 @@
|
|||||||
|
{
|
||||||
|
"name": "automat",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "automat",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"vite": "^8.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxc-project/types": {
|
||||||
|
"version": "0.150.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
|
||||||
|
"integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/oxc-project"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/pluginutils": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@vitejs/plugin-react": {
|
||||||
|
"version": "6.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
|
||||||
|
"integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rolldown/pluginutils": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"oxc-transform-react": "^0.145.0",
|
||||||
|
"vite": "^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@rolldown/plugin-babel": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"babel-plugin-react-compiler": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"oxc-transform-react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fdir": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"picomatch": "^3 || ^4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"picomatch": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"lightningcss-android-arm64": "1.33.0",
|
||||||
|
"lightningcss-darwin-arm64": "1.33.0",
|
||||||
|
"lightningcss-darwin-x64": "1.33.0",
|
||||||
|
"lightningcss-freebsd-x64": "1.33.0",
|
||||||
|
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||||
|
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||||
|
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||||
|
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||||
|
"lightningcss-linux-x64-musl": "1.33.0",
|
||||||
|
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||||
|
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-android-arm64": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nanoid": {
|
||||||
|
"version": "3.3.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||||
|
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/picocolors": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/picomatch": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postcss": {
|
||||||
|
"version": "8.5.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||||
|
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "^3.3.18",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react": {
|
||||||
|
"version": "19.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
|
||||||
|
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-dom": {
|
||||||
|
"version": "19.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
|
||||||
|
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"scheduler": "^0.28.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rolldown": {
|
||||||
|
"version": "1.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz",
|
||||||
|
"integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@oxc-project/types": "=0.150.0",
|
||||||
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rolldown": "bin/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@rolldown/binding-android-arm-eabi": "1.2.9",
|
||||||
|
"@rolldown/binding-android-arm64": "1.2.9",
|
||||||
|
"@rolldown/binding-darwin-arm64": "1.2.9",
|
||||||
|
"@rolldown/binding-darwin-x64": "1.2.9",
|
||||||
|
"@rolldown/binding-freebsd-x64": "1.2.9",
|
||||||
|
"@rolldown/binding-linux-arm-gnueabihf": "1.2.9",
|
||||||
|
"@rolldown/binding-linux-arm64-gnu": "1.2.9",
|
||||||
|
"@rolldown/binding-linux-arm64-musl": "1.2.9",
|
||||||
|
"@rolldown/binding-linux-ppc64-gnu": "1.2.9",
|
||||||
|
"@rolldown/binding-linux-s390x-gnu": "1.2.9",
|
||||||
|
"@rolldown/binding-linux-x64-gnu": "1.2.9",
|
||||||
|
"@rolldown/binding-linux-x64-musl": "1.2.9",
|
||||||
|
"@rolldown/binding-openharmony-arm64": "1.2.9",
|
||||||
|
"@rolldown/binding-win32-arm64-msvc": "1.2.9",
|
||||||
|
"@rolldown/binding-win32-x64-msvc": "1.2.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/scheduler": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/source-map-js": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyglobby": {
|
||||||
|
"version": "0.2.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
|
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fdir": "^6.5.0",
|
||||||
|
"picomatch": "^4.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lightningcss": "^1.33.0",
|
||||||
|
"picomatch": "^4.0.7",
|
||||||
|
"postcss": "^8.5.28",
|
||||||
|
"rolldown": "~1.2.6",
|
||||||
|
"tinyglobby": "^0.2.17"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vite": "bin/vite.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
|
"@vitejs/devtools": "^0.7.1",
|
||||||
|
"esbuild": "^0.27.0 || ^0.28.0",
|
||||||
|
"jiti": ">=1.21.0",
|
||||||
|
"less": "^4.0.0",
|
||||||
|
"sass": "^1.70.0",
|
||||||
|
"sass-embedded": "^1.70.0",
|
||||||
|
"stylus": ">=0.54.8",
|
||||||
|
"sugarss": "^5.0.0",
|
||||||
|
"terser": "^5.16.0",
|
||||||
|
"tsx": "^4.8.1",
|
||||||
|
"yaml": "^2.4.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitejs/devtools": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"esbuild": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jiti": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"less": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sass": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sass-embedded": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"stylus": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sugarss": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"terser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"tsx": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"yaml": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "automat",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Observable state management for React PureComponents — state independent of component lifecycle",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"build:lib": "vite build --config vite.config.lib.js",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/lib/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"vite": "^8.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
192
src/App.jsx
Normal file
192
src/App.jsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import CounterButton from './examples/components/CounterButton.jsx';
|
||||||
|
import CounterDisplay from './examples/components/CounterDisplay.jsx';
|
||||||
|
import CascadeControls from './examples/components/CascadeControls.jsx';
|
||||||
|
import NotificationBar from './examples/components/NotificationBar.jsx';
|
||||||
|
import SyncCounterControls from './examples/components/SyncCounterControls.jsx';
|
||||||
|
import SyncBackendMonitor from './examples/components/SyncBackendMonitor.jsx';
|
||||||
|
import IndexSelector from './examples/components/IndexSelector.jsx';
|
||||||
|
import DynamicAutomatSubscriber from './examples/components/DynamicAutomatSubscriber.jsx';
|
||||||
|
|
||||||
|
class App extends PureComponent {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
{/* ── Header ─────────────────────────────────────────────── */}
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="logo">
|
||||||
|
<span className="logo-icon">⚙</span>
|
||||||
|
<span className="logo-text">automat</span>
|
||||||
|
<span className="logo-version">v0.1.0</span>
|
||||||
|
</div>
|
||||||
|
<p className="tagline">
|
||||||
|
Observable state management for React{' '}
|
||||||
|
<code className="inline-code">PureComponent</code>
|
||||||
|
<br />
|
||||||
|
State lives independently of mounting — zero prop drilling.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="app-main">
|
||||||
|
{/* ── Example 1: Counter ────────────────────────────────── */}
|
||||||
|
<section className="example-section" aria-labelledby="ex1-title">
|
||||||
|
<div className="section-header">
|
||||||
|
<div className="section-title-row">
|
||||||
|
<span className="section-number">01</span>
|
||||||
|
<h2 id="ex1-title">Counter</h2>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Two independent <code className="inline-code">PureComponent</code>s
|
||||||
|
synchronized by a single <code className="inline-code">counterAutomat</code>.
|
||||||
|
Neither knows the other exists — they both subscribe to the same state container.
|
||||||
|
</p>
|
||||||
|
<div className="flow-diagram" aria-label="Data flow">
|
||||||
|
<span className="flow-node">counterAutomat</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="two-columns">
|
||||||
|
<CounterButton />
|
||||||
|
<CounterDisplay />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="section-divider" />
|
||||||
|
|
||||||
|
{/* ── Example 2: Cascade ───────────────────────────────── */}
|
||||||
|
<section className="example-section" aria-labelledby="ex2-title">
|
||||||
|
<div className="section-header">
|
||||||
|
<div className="section-title-row">
|
||||||
|
<span className="section-number">02</span>
|
||||||
|
<h2 id="ex2-title">Notification Cascade</h2>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<code className="inline-code">subscribeTo()</code> connects two automats into a
|
||||||
|
<strong> reactive pipeline</strong>. Clicking triggers in <code className="inline-code">CascadeControls</code> mutates{' '}
|
||||||
|
<code className="inline-code">counterAutomat</code> (upstream). This automatically invokes the{' '}
|
||||||
|
<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>
|
||||||
|
<div className="flow-diagram" aria-label="Cascade data flow">
|
||||||
|
<span className="flow-node">User Click</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">counterAutomat.actions.increment()</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">subscribeTo(upstream, transform)</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 className="two-columns">
|
||||||
|
<CascadeControls />
|
||||||
|
<NotificationBar />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="section-divider" />
|
||||||
|
|
||||||
|
{/* ── Example 3: API Auto-Sync ─────────────────────────── */}
|
||||||
|
<section className="example-section" aria-labelledby="ex3-title">
|
||||||
|
<div className="section-header">
|
||||||
|
<div className="section-title-row">
|
||||||
|
<span className="section-number">03</span>
|
||||||
|
<h2 id="ex3-title">API-Backed Auto-Sync Counter (POST)</h2>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<code className="inline-code">syncCounterAutomat</code> manages an indexed counter that automatically
|
||||||
|
synchronizes its mutations to the backend via HTTP <code className="inline-code">POST /api/counter</code>.
|
||||||
|
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>
|
||||||
|
<div className="flow-diagram" aria-label="API auto-sync flow">
|
||||||
|
<span className="flow-node">User Click</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">Optimistic setState()</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">POST /api/counter</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 className="two-columns">
|
||||||
|
<SyncCounterControls />
|
||||||
|
<SyncBackendMonitor />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="section-divider" />
|
||||||
|
|
||||||
|
{/* ── Example 4: Dynamic Map Resubscription ──────────── */}
|
||||||
|
<section className="example-section" aria-labelledby="ex4-title">
|
||||||
|
<div className="section-header">
|
||||||
|
<div className="section-title-row">
|
||||||
|
<span className="section-number">04</span>
|
||||||
|
<h2 id="ex4-title">Dynamic Window Map (Index-Driven Resubscription)</h2>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
The left side is a standard <code className="inline-code">indexAutomat</code> counter (like Example 1).
|
||||||
|
On the right side, that counter’s value is used to dynamically lookup, instantiate, and resubscribe to
|
||||||
|
a different <code className="inline-code">Automat</code> stored in <code className="inline-code">window.automats = new Map()</code>.
|
||||||
|
Stepping through indexes unhooks from the old Automat, subscribes to the new one, and instantly recalls its preserved state.
|
||||||
|
</p>
|
||||||
|
<div className="flow-diagram" aria-label="Index-driven resubscription flow">
|
||||||
|
<span className="flow-node">Shared Index: N</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">window.automats.get(N)</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node">Unsub Old / Sub New</span>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<span className="flow-node flow-node-component">Recall State for Slot #N</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="two-columns">
|
||||||
|
<IndexSelector />
|
||||||
|
<DynamicAutomatSubscriber />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="app-footer">
|
||||||
|
<p>
|
||||||
|
<code className="inline-code">automat</code> · MIT ·{' '}
|
||||||
|
State is independent of component lifecycle
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
31
src/examples/automats/counterAutomat.js
Normal file
31
src/examples/automats/counterAutomat.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Automat } from '../../lib/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A local-memory automat with no API backend.
|
||||||
|
*
|
||||||
|
* State: { count: number }
|
||||||
|
*
|
||||||
|
* Actions close over the automat instance and drive all state transitions.
|
||||||
|
* Multiple React components can subscribe — they all stay in sync through
|
||||||
|
* the automat's subscriber set, never through React's prop drilling.
|
||||||
|
*/
|
||||||
|
const counterAutomat = new Automat(
|
||||||
|
{ count: 0 },
|
||||||
|
{
|
||||||
|
increment(amount = 1) {
|
||||||
|
const { count } = counterAutomat.getState();
|
||||||
|
const step = typeof amount === 'number' ? amount : 1;
|
||||||
|
counterAutomat.setState({ count: count + step });
|
||||||
|
},
|
||||||
|
decrement(amount = 1) {
|
||||||
|
const { count } = counterAutomat.getState();
|
||||||
|
const step = typeof amount === 'number' ? amount : 1;
|
||||||
|
counterAutomat.setState({ count: count - step });
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
counterAutomat.setState({ count: 0 });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default counterAutomat;
|
||||||
76
src/examples/automats/indexAutomat.js
Normal file
76
src/examples/automats/indexAutomat.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Automat } from '../../lib/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normal shared Automat (like Example 1 counter), holding an index value.
|
||||||
|
* The right-side component uses this value to lookup, dynamically instantiate,
|
||||||
|
* and resubscribe to a different Automat stored in window.automats Map.
|
||||||
|
*/
|
||||||
|
export const indexAutomat = new Automat(
|
||||||
|
{ index: 0 },
|
||||||
|
{
|
||||||
|
increment(step = 1) {
|
||||||
|
indexAutomat.setState({ index: indexAutomat.state.index + step });
|
||||||
|
},
|
||||||
|
decrement(step = 1) {
|
||||||
|
const next = Math.max(0, indexAutomat.state.index - step);
|
||||||
|
indexAutomat.setState({ index: next });
|
||||||
|
},
|
||||||
|
setIndex(index) {
|
||||||
|
indexAutomat.setState({ index: Math.max(0, Number(index) || 0) });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global Map on window holding dynamically instantiated Automats per index.
|
||||||
|
*/
|
||||||
|
if (typeof window !== 'undefined' && !window.automats) {
|
||||||
|
window.automats = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accesses or dynamically instantiates an Automat in window.automats for the given index.
|
||||||
|
*
|
||||||
|
* @param {number} index
|
||||||
|
* @returns {Automat}
|
||||||
|
*/
|
||||||
|
export function getOrCreateSlotAutomat(index) {
|
||||||
|
const map = typeof window !== 'undefined' ? window.automats : null;
|
||||||
|
if (!map) {
|
||||||
|
return new Automat({ index, clicks: 0, notes: `Slot #${index}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.has(index)) {
|
||||||
|
const slotAutomat = new Automat(
|
||||||
|
{
|
||||||
|
index,
|
||||||
|
clicks: 0,
|
||||||
|
notes: `Notes for slot #${index}`,
|
||||||
|
lastModified: new Date().toLocaleTimeString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
click() {
|
||||||
|
slotAutomat.setState({
|
||||||
|
clicks: slotAutomat.state.clicks + 1,
|
||||||
|
lastModified: new Date().toLocaleTimeString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setNotes(notes) {
|
||||||
|
slotAutomat.setState({
|
||||||
|
notes,
|
||||||
|
lastModified: new Date().toLocaleTimeString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
slotAutomat.setState({
|
||||||
|
clicks: 0,
|
||||||
|
lastModified: new Date().toLocaleTimeString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
map.set(index, slotAutomat);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map.get(index);
|
||||||
|
}
|
||||||
52
src/examples/automats/notificationAutomat.js
Normal file
52
src/examples/automats/notificationAutomat.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Automat } from '../../lib/index.js';
|
||||||
|
import counterAutomat from './counterAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cascade example — notificationAutomat subscribes to counterAutomat.
|
||||||
|
*
|
||||||
|
* Every time the counter's state changes, the transform callback fires and
|
||||||
|
* prepends a new notification message. This in turn calls notificationAutomat.setState,
|
||||||
|
* which notifies all of notificationAutomat's own subscribers (e.g. <NotificationBar>).
|
||||||
|
*
|
||||||
|
* State: { messages: Array<{ id, text, time, count }> }
|
||||||
|
*/
|
||||||
|
const notificationAutomat = new Automat(
|
||||||
|
{ messages: [] },
|
||||||
|
{
|
||||||
|
clear() {
|
||||||
|
notificationAutomat.setState({ messages: [] });
|
||||||
|
},
|
||||||
|
addNotice(text, count = 0) {
|
||||||
|
const { messages } = notificationAutomat.getState();
|
||||||
|
notificationAutomat.setState({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: Date.now() + Math.random(),
|
||||||
|
text,
|
||||||
|
time: new Date().toLocaleTimeString(),
|
||||||
|
count,
|
||||||
|
},
|
||||||
|
...messages.slice(0, 9),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wire the cascade: notificationAutomat derives state from counterAutomat:
|
||||||
|
notificationAutomat.subscribeTo(
|
||||||
|
counterAutomat,
|
||||||
|
(upstream, my) => ({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: Date.now() + Math.random(),
|
||||||
|
text: `Cascade trigger: counter changed to ${upstream.count}`,
|
||||||
|
time: new Date().toLocaleTimeString(),
|
||||||
|
count: upstream.count,
|
||||||
|
},
|
||||||
|
...my.messages.slice(0, 9),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default notificationAutomat;
|
||||||
166
src/examples/automats/syncCounterAutomat.js
Normal file
166
src/examples/automats/syncCounterAutomat.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { Automat } from '../../lib/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to sync the indexed counter to the backend via POST /api/counter.
|
||||||
|
* Includes offline/fallback logic so it gracefully handles production previews.
|
||||||
|
*/
|
||||||
|
async function postCounterSync(index, count) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/counter', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ index, count, timestamp: Date.now() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Server returned ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
// Simulated fallback if running in an environment without the dev server backend:
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
index,
|
||||||
|
count,
|
||||||
|
savedAt: new Date().toLocaleTimeString(),
|
||||||
|
allRecords: [
|
||||||
|
{ index, count, updatedAt: new Date().toLocaleTimeString() },
|
||||||
|
],
|
||||||
|
fallback: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to fetch a specific index's state from backend via GET /api/counter?index=...
|
||||||
|
*/
|
||||||
|
async function fetchCounterIndex(index) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/counter?index=${index}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return { index, count: 0, updatedAt: 'Local' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API-backed indexed counter automat.
|
||||||
|
*
|
||||||
|
* Automatically syncs every mutation to the backend via HTTP POST /api/counter.
|
||||||
|
* Uses optimistic updates for instant UI response, followed by background POST synchronization.
|
||||||
|
*/
|
||||||
|
export const syncCounterAutomat = new Automat(
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
count: 0,
|
||||||
|
syncStatus: 'synced', // 'idle' | 'syncing' | 'synced' | 'error'
|
||||||
|
lastSyncedAt: 'Initial',
|
||||||
|
error: null,
|
||||||
|
backendRecords: [
|
||||||
|
{ index: 0, count: 0, updatedAt: 'Initial' },
|
||||||
|
{ index: 1, count: 5, updatedAt: 'Initial' },
|
||||||
|
{ index: 2, count: 10, updatedAt: 'Initial' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Increment count for current index & auto-sync to backend via POST
|
||||||
|
*/
|
||||||
|
async increment(step = 1) {
|
||||||
|
const { index, count } = syncCounterAutomat.state;
|
||||||
|
const nextCount = count + step;
|
||||||
|
|
||||||
|
// 1. Optimistic update:
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
count: nextCount,
|
||||||
|
syncStatus: 'syncing',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Auto-sync via POST /api/counter:
|
||||||
|
try {
|
||||||
|
const result = await postCounterSync(index, nextCount);
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
syncStatus: 'synced',
|
||||||
|
lastSyncedAt: result.savedAt,
|
||||||
|
backendRecords: result.allRecords || syncCounterAutomat.state.backendRecords,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
syncStatus: 'error',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrement count for current index & auto-sync to backend via POST
|
||||||
|
*/
|
||||||
|
async decrement(step = 1) {
|
||||||
|
const { index, count } = syncCounterAutomat.state;
|
||||||
|
const nextCount = Math.max(0, count - step);
|
||||||
|
|
||||||
|
// 1. Optimistic update:
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
count: nextCount,
|
||||||
|
syncStatus: 'syncing',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Auto-sync via POST /api/counter:
|
||||||
|
try {
|
||||||
|
const result = await postCounterSync(index, nextCount);
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
syncStatus: 'synced',
|
||||||
|
lastSyncedAt: result.savedAt,
|
||||||
|
backendRecords: result.allRecords || syncCounterAutomat.state.backendRecords,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
syncStatus: 'error',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a different counter index and load its backend state
|
||||||
|
*/
|
||||||
|
async setIndex(newIndex) {
|
||||||
|
const targetIndex = Number(newIndex);
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
index: targetIndex,
|
||||||
|
syncStatus: 'syncing',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const serverData = await fetchCounterIndex(targetIndex);
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
count: serverData.count ?? 0,
|
||||||
|
syncStatus: 'synced',
|
||||||
|
lastSyncedAt: serverData.updatedAt || new Date().toLocaleTimeString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset current counter to 0 & auto-sync via POST
|
||||||
|
*/
|
||||||
|
async reset() {
|
||||||
|
const { index } = syncCounterAutomat.state;
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
count: 0,
|
||||||
|
syncStatus: 'syncing',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await postCounterSync(index, 0);
|
||||||
|
syncCounterAutomat.setState({
|
||||||
|
syncStatus: 'synced',
|
||||||
|
lastSyncedAt: result.savedAt,
|
||||||
|
backendRecords: result.allRecords || syncCounterAutomat.state.backendRecords,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
163
src/examples/components/CascadeControls.jsx
Normal file
163
src/examples/components/CascadeControls.jsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import counterAutomat from '../automats/counterAutomat.js';
|
||||||
|
import notificationAutomat from '../automats/notificationAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CascadeControls — left column of Section 02 (Notification Cascade).
|
||||||
|
*
|
||||||
|
* Demonstrates:
|
||||||
|
* 1. Driving upstream transitions that cascade automatically into notificationAutomat.
|
||||||
|
* 2. Hybrid state: upstream count from counterAutomat + local cascade triggers counter.
|
||||||
|
* 3. Shows the complete wiring of `subscribeTo()`.
|
||||||
|
*/
|
||||||
|
class CascadeControls extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
// Hybrid initialization:
|
||||||
|
this.state = {
|
||||||
|
// 1. From upstream automat:
|
||||||
|
upstreamCount: counterAutomat.state.count,
|
||||||
|
// 2. Component-local state:
|
||||||
|
customText: 'System heartbeat',
|
||||||
|
cascadeFiredCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Subscribe to upstream automat to display its current state
|
||||||
|
this.unsubscribe = counterAutomat.subscribe(this, (state) => ({
|
||||||
|
upstreamCount: state.count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFireTrigger = (delta) => {
|
||||||
|
this.setState((prev) => ({ cascadeFiredCount: prev.cascadeFiredCount + 1 }));
|
||||||
|
counterAutomat.actions.increment(delta);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCustomNotice = () => {
|
||||||
|
const { customText, upstreamCount } = this.state;
|
||||||
|
this.setState((prev) => ({ cascadeFiredCount: prev.cascadeFiredCount + 1 }));
|
||||||
|
notificationAutomat.actions.addNotice(customText, upstreamCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClear = () => {
|
||||||
|
notificationAutomat.actions.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { upstreamCount, customText, cascadeFiredCount } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">🌊</span>
|
||||||
|
<h3>Cascade Trigger</h3>
|
||||||
|
<span className="badge badge-cascade">upstream</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Hybrid state display */}
|
||||||
|
<div className="hybrid-state-bar">
|
||||||
|
<div className="state-item">
|
||||||
|
<div className="state-item-header">
|
||||||
|
<span className="state-tag-automat">upstream</span>
|
||||||
|
<span>counter count</span>
|
||||||
|
</div>
|
||||||
|
<span className="state-item-val">{upstreamCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="state-item">
|
||||||
|
<div className="state-item-header">
|
||||||
|
<span className="state-tag-local">local</span>
|
||||||
|
<span>triggers fired</span>
|
||||||
|
</div>
|
||||||
|
<span className="state-item-val">{cascadeFiredCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-2)', marginBottom: 14 }}>
|
||||||
|
Clicking a trigger button invokes <code className="inline-code">counterAutomat.actions.increment()</code>.
|
||||||
|
Because <code className="inline-code">notificationAutomat</code> observes it with{' '}
|
||||||
|
<code className="inline-code">subscribeTo()</code>, the downstream log derives a new
|
||||||
|
message automatically without any component glue.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Trigger buttons */}
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginBottom: 14 }}>
|
||||||
|
<button
|
||||||
|
id="btn-cascade-trigger-1"
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={() => this.handleFireTrigger(1)}
|
||||||
|
>
|
||||||
|
Trigger (+1)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="btn-cascade-trigger-5"
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={() => this.handleFireTrigger(5)}
|
||||||
|
>
|
||||||
|
Trigger (+5)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-field"
|
||||||
|
value={customText}
|
||||||
|
onChange={(e) => this.setState({ customText: e.target.value })}
|
||||||
|
placeholder="Custom notification message..."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={this.handleCustomNotice}
|
||||||
|
style={{ whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
Post Notice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-full"
|
||||||
|
onClick={this.handleClear}
|
||||||
|
>
|
||||||
|
Clear Stream
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="code-snippet" style={{ marginTop: 16 }}>
|
||||||
|
<pre>{`// Concise reactive pipeline (ternary returns null to skip, or object to update):
|
||||||
|
notificationAutomat.subscribeTo(
|
||||||
|
counterAutomat,
|
||||||
|
(upstream, my) =>
|
||||||
|
upstream.count === 0
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: Date.now(),
|
||||||
|
text: \`Cascade: counter shifted to \${upstream.count}\`,
|
||||||
|
time: new Date().toLocaleTimeString(),
|
||||||
|
count: upstream.count,
|
||||||
|
},
|
||||||
|
...my.messages.slice(0, 9),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CascadeControls;
|
||||||
182
src/examples/components/CounterButton.jsx
Normal file
182
src/examples/components/CounterButton.jsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import counterAutomat from '../automats/counterAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CounterButton — left column.
|
||||||
|
*
|
||||||
|
* HYBRID STATE PATTERN:
|
||||||
|
* - Shared state: `count` comes from counterAutomat.
|
||||||
|
* - Local state: `step` (increment size) and `localClicks` (click counter)
|
||||||
|
* are private to this component.
|
||||||
|
*
|
||||||
|
* 1. Constructor combines direct automat access with local component state.
|
||||||
|
* 2. componentDidMount registers with counterAutomat.subscribe(this).
|
||||||
|
* 3. React's setState shallow-merges updates, so local fields remain untouched
|
||||||
|
* when the automat notifies this component of count updates.
|
||||||
|
*/
|
||||||
|
class CounterButton extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
// Hybrid initialization:
|
||||||
|
this.state = {
|
||||||
|
// 1. Initialized directly from the automat:
|
||||||
|
count: counterAutomat.state.count,
|
||||||
|
// 2. Component-local state:
|
||||||
|
step: 1,
|
||||||
|
localClicks: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Subscriber handling for React lifecycle management
|
||||||
|
this.unsubscribe = counterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSetStep = (step) => {
|
||||||
|
// Purely local state update
|
||||||
|
this.setState({ step });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleIncrement = () => {
|
||||||
|
const { step, localClicks } = this.state;
|
||||||
|
// Update local clicks counter
|
||||||
|
this.setState({ localClicks: localClicks + 1 });
|
||||||
|
// Trigger automat action with local step
|
||||||
|
counterAutomat.actions.increment(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDecrement = () => {
|
||||||
|
const { step, localClicks } = this.state;
|
||||||
|
this.setState({ localClicks: localClicks + 1 });
|
||||||
|
counterAutomat.actions.decrement(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
const { localClicks } = this.state;
|
||||||
|
this.setState({ localClicks: localClicks + 1 });
|
||||||
|
counterAutomat.actions.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { count, step, localClicks } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">⚡</span>
|
||||||
|
<h3>Counter Controls</h3>
|
||||||
|
<span className="badge badge-local">hybrid state</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Hybrid State breakdown */}
|
||||||
|
<div className="hybrid-state-bar">
|
||||||
|
<div className="state-item">
|
||||||
|
<div className="state-item-header">
|
||||||
|
<span className="state-tag-automat">automat</span>
|
||||||
|
<span>shared</span>
|
||||||
|
</div>
|
||||||
|
<span className="state-item-val">{count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="state-item">
|
||||||
|
<div className="state-item-header">
|
||||||
|
<span className="state-tag-local">local</span>
|
||||||
|
<span>button clicks</span>
|
||||||
|
</div>
|
||||||
|
<span className="state-item-val">{localClicks}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Local step selector */}
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
Step size <span className="state-tag-local">local</span>
|
||||||
|
</span>
|
||||||
|
<div className="segmented-control" role="group" aria-label="Step size">
|
||||||
|
{[1, 5, 10].map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
className={`seg-btn ${step === s ? 'active' : ''}`}
|
||||||
|
onClick={() => this.handleSetStep(s)}
|
||||||
|
>
|
||||||
|
±{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* +/- controls */}
|
||||||
|
<div className="counter-controls">
|
||||||
|
<button
|
||||||
|
id="btn-decrement"
|
||||||
|
className="btn btn-circle btn-secondary"
|
||||||
|
onClick={this.handleDecrement}
|
||||||
|
aria-label={`Decrement by ${step}`}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="btn-increment"
|
||||||
|
className="btn btn-circle btn-primary"
|
||||||
|
onClick={this.handleIncrement}
|
||||||
|
aria-label={`Increment by ${step}`}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="btn-reset"
|
||||||
|
className="btn btn-ghost btn-full"
|
||||||
|
onClick={this.handleReset}
|
||||||
|
>
|
||||||
|
Reset Count to 0
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="code-snippet">
|
||||||
|
<pre>{`// 1. Instantiate the automat outside React:
|
||||||
|
const counterAutomat = new Automat(
|
||||||
|
{ count: 0 },
|
||||||
|
{
|
||||||
|
increment(step = 1) {
|
||||||
|
counterAutomat.setState({ count: counterAutomat.state.count + step });
|
||||||
|
},
|
||||||
|
decrement(step = 1) {
|
||||||
|
counterAutomat.setState({ count: counterAutomat.state.count - step });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. PureComponent with hybrid state:
|
||||||
|
class CounterButton extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
count: counterAutomat.state.count, // ← from automat
|
||||||
|
step: 1, // ← component-local
|
||||||
|
localClicks: 0, // ← component-local
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsub = counterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsub();
|
||||||
|
}
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CounterButton;
|
||||||
172
src/examples/components/CounterDisplay.jsx
Normal file
172
src/examples/components/CounterDisplay.jsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import counterAutomat from '../automats/counterAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CounterDisplay — right column.
|
||||||
|
*
|
||||||
|
* HYBRID STATE PATTERN:
|
||||||
|
* - Shared state: `count` comes from counterAutomat.
|
||||||
|
* - Local state: `format` ('dec' | 'hex') is private to this component.
|
||||||
|
*
|
||||||
|
* Demonstrates that CounterDisplay can maintain its own presentation preferences
|
||||||
|
* while staying in sync with the shared automat state.
|
||||||
|
*/
|
||||||
|
class CounterDisplay extends PureComponent {
|
||||||
|
/** @type {HTMLElement|null} */
|
||||||
|
_numRef = null;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
// Hybrid initialization:
|
||||||
|
this.state = {
|
||||||
|
// 1. Initialized directly from automat:
|
||||||
|
count: counterAutomat.state.count,
|
||||||
|
// 2. Component-local presentation state:
|
||||||
|
format: 'dec',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Subscriber handling for React lifecycle
|
||||||
|
this.unsubscribe = counterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
// Pop animation whenever shared automat count changes
|
||||||
|
if (prevState.count !== this.state.count) {
|
||||||
|
if (this._numRef) {
|
||||||
|
this._numRef.classList.remove('count-animate');
|
||||||
|
void this._numRef.offsetWidth;
|
||||||
|
this._numRef.classList.add('count-animate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFormatChange = (format) => {
|
||||||
|
// Purely local state update
|
||||||
|
this.setState({ format });
|
||||||
|
};
|
||||||
|
|
||||||
|
formatValue(count, format) {
|
||||||
|
if (format === 'hex') {
|
||||||
|
return (count < 0 ? '-' : '') + '0x' + Math.abs(count).toString(16).toUpperCase();
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { count, format } = this.state;
|
||||||
|
const formatted = this.formatValue(count, format);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-display">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">📊</span>
|
||||||
|
<h3>Counter Display</h3>
|
||||||
|
<span className="badge badge-local">hybrid state</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Hybrid State breakdown */}
|
||||||
|
<div className="hybrid-state-bar">
|
||||||
|
<div className="state-item">
|
||||||
|
<div className="state-item-header">
|
||||||
|
<span className="state-tag-automat">automat</span>
|
||||||
|
<span>shared count</span>
|
||||||
|
</div>
|
||||||
|
<span className="state-item-val">{count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="state-item">
|
||||||
|
<div className="state-item-header">
|
||||||
|
<span className="state-tag-local">local</span>
|
||||||
|
<span>format preference</span>
|
||||||
|
</div>
|
||||||
|
<span className="state-item-val">{format.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Local format selector */}
|
||||||
|
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
Display format <span className="state-tag-local">local</span>
|
||||||
|
</span>
|
||||||
|
<div className="segmented-control" role="group" aria-label="Display format">
|
||||||
|
{['dec', 'hex'].map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
type="button"
|
||||||
|
className={`seg-btn ${format === f ? 'active' : ''}`}
|
||||||
|
onClick={() => this.handleFormatChange(f)}
|
||||||
|
>
|
||||||
|
{f.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="count-display-container">
|
||||||
|
{/* Gradient number with pop animation on change */}
|
||||||
|
<span
|
||||||
|
className="count-number"
|
||||||
|
ref={(el) => { this._numRef = el; }}
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
{formatted}
|
||||||
|
</span>
|
||||||
|
<span className="count-label">{format.toUpperCase()} format</span>
|
||||||
|
<span className="count-hint">
|
||||||
|
Independent PureComponent — no shared parent
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="code-snippet">
|
||||||
|
<pre>{`// 1. Instantiate automat outside React:
|
||||||
|
const counterAutomat = new Automat(
|
||||||
|
{ count: 0 },
|
||||||
|
{
|
||||||
|
increment(step = 1) {
|
||||||
|
counterAutomat.setState({ count: counterAutomat.state.count + step });
|
||||||
|
},
|
||||||
|
decrement(step = 1) {
|
||||||
|
counterAutomat.setState({ count: counterAutomat.state.count - step });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. PureComponent with hybrid state:
|
||||||
|
class CounterDisplay extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
count: counterAutomat.state.count, // ← from automat
|
||||||
|
format: 'dec', // ← component-local state
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsub = counterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsub();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { count, format } = this.state;
|
||||||
|
const value = format === 'hex' ? '0x' + count.toString(16) : count;
|
||||||
|
return <span>{value}</span>;
|
||||||
|
}
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CounterDisplay;
|
||||||
195
src/examples/components/DynamicAutomatSubscriber.jsx
Normal file
195
src/examples/components/DynamicAutomatSubscriber.jsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { indexAutomat, getOrCreateSlotAutomat } from '../automats/indexAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DynamicAutomatSubscriber — right column.
|
||||||
|
*
|
||||||
|
* 1. Subscribes to the shared `indexAutomat`.
|
||||||
|
* 2. Whenever `index` changes, it:
|
||||||
|
* - Unsubscribes from the previous Automat instance
|
||||||
|
* - Uses `index` to lookup or dynamically instantiate an Automat in `window.automats` Map
|
||||||
|
* - Immediately recalls its preserved state
|
||||||
|
* - Subscribes to the new Automat instance
|
||||||
|
*/
|
||||||
|
class DynamicAutomatSubscriber extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const initialIndex = indexAutomat.state.index;
|
||||||
|
const initialSlot = getOrCreateSlotAutomat(initialIndex);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
currentIndex: initialIndex,
|
||||||
|
slotState: initialSlot.state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// 1. Subscribe to the shared index automat
|
||||||
|
this.unsubIndex = indexAutomat.subscribe((indexState) => {
|
||||||
|
this.handleIndexChange(indexState.index);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Subscribe to the initial slot automat in window.automats
|
||||||
|
this.subscribeToSlot(this.state.currentIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubIndex) this.unsubIndex();
|
||||||
|
if (this.unsubSlot) this.unsubSlot();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleIndexChange(newIndex) {
|
||||||
|
if (newIndex === this.state.currentIndex) return;
|
||||||
|
|
||||||
|
// 💡 DYNAMIC RESUBSCRIPTION WORKFLOW:
|
||||||
|
// 1. Unsubscribe from the previous Automat
|
||||||
|
if (this.unsubSlot) {
|
||||||
|
this.unsubSlot();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Lookup or dynamically instantiate the Automat for this index in window.automats:
|
||||||
|
const slotAutomat = getOrCreateSlotAutomat(newIndex);
|
||||||
|
|
||||||
|
// 3. Immediately recall the stored state:
|
||||||
|
this.setState({
|
||||||
|
currentIndex: newIndex,
|
||||||
|
slotState: slotAutomat.state,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Resubscribe to the new instance:
|
||||||
|
this.unsubSlot = slotAutomat.subscribe((slotState) => {
|
||||||
|
this.setState({ slotState });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeToSlot(index) {
|
||||||
|
const slotAutomat = getOrCreateSlotAutomat(index);
|
||||||
|
this.unsubSlot = slotAutomat.subscribe((slotState) => {
|
||||||
|
this.setState({ slotState });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClickSlot = () => {
|
||||||
|
const slotAutomat = getOrCreateSlotAutomat(this.state.currentIndex);
|
||||||
|
slotAutomat.actions.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleNotesChange = (e) => {
|
||||||
|
const slotAutomat = getOrCreateSlotAutomat(this.state.currentIndex);
|
||||||
|
slotAutomat.actions.setNotes(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleResetSlot = () => {
|
||||||
|
const slotAutomat = getOrCreateSlotAutomat(this.state.currentIndex);
|
||||||
|
slotAutomat.actions.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { currentIndex, slotState } = this.state;
|
||||||
|
const map = typeof window !== 'undefined' ? window.automats : null;
|
||||||
|
const allIndexes = map ? Array.from(map.keys()).sort((a, b) => a - b) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-display">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">⚡</span>
|
||||||
|
<h3>Dynamic Slot Automat (Resubscribed by Index)</h3>
|
||||||
|
<span className="badge badge-cascade">window.automats.get({currentIndex})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Active slot recalled state */}
|
||||||
|
<div className="count-display-container" style={{ padding: '4px 0 14px' }}>
|
||||||
|
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '.1em', color: 'var(--text-3)', fontWeight: 700 }}>
|
||||||
|
Recalled State for Automat #{currentIndex}
|
||||||
|
</span>
|
||||||
|
<span className="count-number" style={{ fontSize: 68, margin: '2px 0' }}>
|
||||||
|
{slotState.clicks}
|
||||||
|
</span>
|
||||||
|
<span className="count-label">Clicks recorded on Slot #{currentIndex}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slot mutation controls */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={this.handleClickSlot}
|
||||||
|
>
|
||||||
|
+ Click Slot #{currentIndex}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={this.handleResetSlot}
|
||||||
|
>
|
||||||
|
Reset Slot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-field"
|
||||||
|
value={slotState.notes || ''}
|
||||||
|
onChange={this.handleNotesChange}
|
||||||
|
placeholder={`Notes for Slot #${currentIndex}...`}
|
||||||
|
style={{ fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table showing all entries in window.automats */}
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
All Instantiated Automats in window.automats Map:
|
||||||
|
</span>
|
||||||
|
<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) => {
|
||||||
|
const aut = map.get(idx);
|
||||||
|
const st = aut ? aut.state : {};
|
||||||
|
const isActive = idx === currentIndex;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '2px 0',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
color: isActive ? 'var(--accent)' : 'var(--text-2)',
|
||||||
|
fontWeight: isActive ? 700 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{isActive ? '▸ ' : ' '}window.automats.get({idx})
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
clicks: <strong>{st.clicks}</strong> · "{st.notes}"
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Code snippet showing resubscription logic */}
|
||||||
|
<div className="code-snippet">
|
||||||
|
<pre>{`// Resubscribing to different Automat driven by shared index:
|
||||||
|
handleIndexChange(newIndex) {
|
||||||
|
this.unsubSlot?.(); // 1. Unsub old
|
||||||
|
const slotAutomat = window.automats.get(newIndex)// 2. Access / create in Map
|
||||||
|
?? getOrCreateSlotAutomat(newIndex);
|
||||||
|
this.setState({ slotState: slotAutomat.state }); // 3. Recall state!
|
||||||
|
this.unsubSlot = slotAutomat.subscribe(this); // 4. Resubscribe!
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DynamicAutomatSubscriber;
|
||||||
118
src/examples/components/IndexSelector.jsx
Normal file
118
src/examples/components/IndexSelector.jsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { indexAutomat } from '../automats/indexAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IndexSelector — left column.
|
||||||
|
*
|
||||||
|
* A standard PureComponent driving indexAutomat (like Example 1).
|
||||||
|
* Clicking +/- changes the shared `index` value.
|
||||||
|
*/
|
||||||
|
class IndexSelector extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = indexAutomat.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsubscribe = indexAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubscribe) this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleIncrement = () => {
|
||||||
|
indexAutomat.actions.increment(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDecrement = () => {
|
||||||
|
indexAutomat.actions.decrement(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSelect = (i) => {
|
||||||
|
indexAutomat.actions.setIndex(i);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { index } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">🔢</span>
|
||||||
|
<h3>Shared Index Counter</h3>
|
||||||
|
<span className="badge badge-local">shared index</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Current Index Display */}
|
||||||
|
<div className="ctrl-current-value">
|
||||||
|
<span className="ctrl-current-label">Shared Automat Index</span>
|
||||||
|
<span className="ctrl-current-num">{index}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* +/- Controls */}
|
||||||
|
<div className="counter-controls">
|
||||||
|
<button
|
||||||
|
id="btn-index-dec"
|
||||||
|
type="button"
|
||||||
|
className="btn btn-circle btn-secondary"
|
||||||
|
onClick={this.handleDecrement}
|
||||||
|
disabled={index <= 0}
|
||||||
|
aria-label="Previous index"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="btn-index-inc"
|
||||||
|
type="button"
|
||||||
|
className="btn btn-circle btn-primary"
|
||||||
|
onClick={this.handleIncrement}
|
||||||
|
aria-label="Next index"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick jump pills */}
|
||||||
|
<div style={{ margin: '14px 0 10px', textAlign: 'center' }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
Quick jump to index:
|
||||||
|
</span>
|
||||||
|
<div className="segmented-control">
|
||||||
|
{[0, 1, 2, 3, 4].map((i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className={`seg-btn ${index === i ? 'active' : ''}`}
|
||||||
|
onClick={() => this.handleSelect(i)}
|
||||||
|
>
|
||||||
|
#{i}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-2)', textAlign: 'center', margin: '8px 0 14px' }}>
|
||||||
|
Changing this index causes the right side to dynamically lookup, instantiate, and resubscribe to{' '}
|
||||||
|
<code className="inline-code">{`window.automats.get(${index})`}</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="code-snippet">
|
||||||
|
<pre>{`// 1. Shared index automat:
|
||||||
|
const indexAutomat = new Automat({ index: 0 }, {
|
||||||
|
increment() { indexAutomat.setState({ index: indexAutomat.state.index + 1 }); },
|
||||||
|
decrement() { indexAutomat.setState({ index: indexAutomat.state.index - 1 }); },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Left component triggers the index change:
|
||||||
|
indexAutomat.actions.increment();`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IndexSelector;
|
||||||
155
src/examples/components/NotificationBar.jsx
Normal file
155
src/examples/components/NotificationBar.jsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import notificationAutomat from '../automats/notificationAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NotificationBar — right column of Section 02 (Notification Cascade).
|
||||||
|
*
|
||||||
|
* HYBRID STATE PATTERN:
|
||||||
|
* - Shared state: `messages` from notificationAutomat (derived from counterAutomat via cascade).
|
||||||
|
* - Local state: `filter` ('all' | 'positive' | 'negative') private to this component.
|
||||||
|
*
|
||||||
|
* Displays the resulting notifications in real-time.
|
||||||
|
*/
|
||||||
|
class NotificationBar extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
// Hybrid initialization:
|
||||||
|
this.state = {
|
||||||
|
// 1. Initialized directly from automat:
|
||||||
|
messages: notificationAutomat.state.messages,
|
||||||
|
// 2. Component-local filter state:
|
||||||
|
filter: 'all',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Subscriber handling for React lifecycle
|
||||||
|
this.unsubscribe = notificationAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFilterChange = (filter) => {
|
||||||
|
// Purely local state update
|
||||||
|
this.setState({ filter });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { messages, filter } = this.state;
|
||||||
|
|
||||||
|
const filteredMessages = messages.filter((m) => {
|
||||||
|
if (filter === 'positive') return m.count >= 0;
|
||||||
|
if (filter === 'negative') return m.count < 0;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">🔔</span>
|
||||||
|
<h3>Notification Stream</h3>
|
||||||
|
<span className="badge badge-cascade">downstream</span>
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<span className="notif-count-badge">{messages.length} total</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Hybrid state display */}
|
||||||
|
<div className="hybrid-state-bar">
|
||||||
|
<div className="state-item">
|
||||||
|
<div className="state-item-header">
|
||||||
|
<span className="state-tag-automat">cascade</span>
|
||||||
|
<span>items in stream</span>
|
||||||
|
</div>
|
||||||
|
<span className="state-item-val">{messages.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="state-item">
|
||||||
|
<div className="state-item-header">
|
||||||
|
<span className="state-tag-local">local</span>
|
||||||
|
<span>active filter</span>
|
||||||
|
</div>
|
||||||
|
<span className="state-item-val">{filter.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Local filter control */}
|
||||||
|
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
Filter stream <span className="state-tag-local">local</span>
|
||||||
|
</span>
|
||||||
|
<div className="segmented-control" role="group" aria-label="Notification filter">
|
||||||
|
{['all', 'positive', 'negative'].map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
type="button"
|
||||||
|
className={`seg-btn ${filter === f ? 'active' : ''}`}
|
||||||
|
onClick={() => this.handleFilterChange(f)}
|
||||||
|
>
|
||||||
|
{f.charAt(0).toUpperCase() + f.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredMessages.length === 0 ? (
|
||||||
|
<div className="empty-state" style={{ minHeight: 140 }}>
|
||||||
|
<span className="empty-state-icon">💤</span>
|
||||||
|
<p>No notifications matching "{filter}".</p>
|
||||||
|
<p className="empty-state-sub">Trigger counter changes to see cascade notifications.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="notification-list" aria-live="polite" style={{ maxHeight: 220, overflowY: 'auto' }}>
|
||||||
|
{filteredMessages.map((msg) => (
|
||||||
|
<li key={msg.id} className="notification-item">
|
||||||
|
<span className="notif-time">{msg.time}</span>
|
||||||
|
<span className="notif-text">{msg.text}</span>
|
||||||
|
<span
|
||||||
|
className="notif-count-pill"
|
||||||
|
style={{
|
||||||
|
background: msg.count >= 0 ? 'var(--green)' : 'var(--red)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg.count}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="code-snippet" style={{ marginTop: 16 }}>
|
||||||
|
<pre>{`// 4. Downstream component consumes the cascade:
|
||||||
|
class NotificationBar extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
// Reads directly from downstream notificationAutomat:
|
||||||
|
this.state = {
|
||||||
|
messages: notificationAutomat.state.messages, // ← derived from cascade
|
||||||
|
filter: 'all', // ← component-local state
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Re-renders automatically whenever the cascade emits new messages:
|
||||||
|
this.unsub = notificationAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsub();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { messages, filter } = this.state;
|
||||||
|
return <ul>{messages.map((m) => <li key={m.id}>{m.text}</li>)}</ul>;
|
||||||
|
}
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationBar;
|
||||||
115
src/examples/components/SyncBackendMonitor.jsx
Normal file
115
src/examples/components/SyncBackendMonitor.jsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { syncCounterAutomat } from '../automats/syncCounterAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SyncBackendMonitor — right column.
|
||||||
|
*
|
||||||
|
* Observes syncCounterAutomat and inspects the real-time server database state
|
||||||
|
* and HTTP POST payloads transmitted during auto-sync.
|
||||||
|
*/
|
||||||
|
class SyncBackendMonitor extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = syncCounterAutomat.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsubscribe = syncCounterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubscribe) this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { index, count, syncStatus, lastSyncedAt, backendRecords } = this.state;
|
||||||
|
const isSyncing = syncStatus === 'syncing';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-display">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">📡</span>
|
||||||
|
<h3>Backend Server Database Monitor</h3>
|
||||||
|
<span className="badge badge-api">POST /api/counter</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Last Transmitted Payload */}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
HTTP POST Payload (Sent Automatically on Change):
|
||||||
|
</span>
|
||||||
|
<div style={{ background: 'var(--surface-0)', border: '1px solid var(--border)', borderRadius: 'var(--r-md)', padding: '10px 12px', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||||
|
<div style={{ color: 'var(--text-3)', marginBottom: 4 }}>
|
||||||
|
<span style={{ color: 'var(--accent)', fontWeight: 700 }}>POST</span> /api/counter
|
||||||
|
</div>
|
||||||
|
<div style={{ color: isSyncing ? 'var(--orange)' : 'var(--aqua)' }}>
|
||||||
|
{JSON.stringify({ index, count }, null, 2)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6, fontSize: 11, color: isSyncing ? 'var(--orange)' : 'var(--green)', borderTop: '1px solid var(--surface-2)', paddingTop: 4 }}>
|
||||||
|
{isSyncing ? '⏳ Transmitting request to server…' : `✓ 200 OK — Saved at ${lastSyncedAt}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server-Side Database Records across all indexes */}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
Backend Server Database (Persisted Records):
|
||||||
|
</span>
|
||||||
|
<div style={{ background: 'var(--surface-0)', border: '1px solid var(--border)', borderRadius: 'var(--r-md)', padding: '8px 12px', maxHeight: 120, overflowY: 'auto' }}>
|
||||||
|
{backendRecords && backendRecords.length > 0 ? (
|
||||||
|
backendRecords.map((rec) => {
|
||||||
|
const isActive = rec.index === index;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rec.index}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '4px 0',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
borderBottom: '1px solid var(--surface-2)',
|
||||||
|
color: isActive ? 'var(--accent)' : 'var(--text-2)',
|
||||||
|
fontWeight: isActive ? 700 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{isActive ? '▸ ' : ' '}Index #{rec.index}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Server Count: <strong>{rec.count}</strong>
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-3)', fontSize: 11 }}>
|
||||||
|
{rec.updatedAt}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div style={{ color: 'var(--text-3)', fontSize: 12, padding: '4px 0' }}>
|
||||||
|
No backend records yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Code Snippet */}
|
||||||
|
<div className="code-snippet">
|
||||||
|
<pre>{`// Component rendering API-backed automat state:
|
||||||
|
class SyncBackendMonitor extends PureComponent {
|
||||||
|
componentDidMount() {
|
||||||
|
// Re-renders automatically on both optimistic update and POST resolution!
|
||||||
|
this.unsubscribe = syncCounterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SyncBackendMonitor;
|
||||||
178
src/examples/components/SyncCounterControls.jsx
Normal file
178
src/examples/components/SyncCounterControls.jsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { syncCounterAutomat } from '../automats/syncCounterAutomat.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SyncCounterControls — left column.
|
||||||
|
*
|
||||||
|
* Controls the indexed counter. Every mutation optimistically updates the UI
|
||||||
|
* and automatically dispatches an HTTP POST /api/counter in the background.
|
||||||
|
*/
|
||||||
|
class SyncCounterControls extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = syncCounterAutomat.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsubscribe = syncCounterAutomat.subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.unsubscribe) this.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleIncrement = (step = 1) => {
|
||||||
|
syncCounterAutomat.actions.increment(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDecrement = (step = 1) => {
|
||||||
|
syncCounterAutomat.actions.decrement(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
syncCounterAutomat.actions.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleIndexChange = (idx) => {
|
||||||
|
syncCounterAutomat.actions.setIndex(idx);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { index, count, syncStatus, lastSyncedAt, error } = this.state;
|
||||||
|
const isSyncing = syncStatus === 'syncing';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-icon">🔄</span>
|
||||||
|
<h3>Auto-Sync Indexed Counter</h3>
|
||||||
|
<span className={`badge ${isSyncing ? 'badge-cascade' : 'badge-local'}`}>
|
||||||
|
{isSyncing ? 'POST syncing…' : 'POST synced'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Index Selector */}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||||
|
Select Counter Index:
|
||||||
|
</span>
|
||||||
|
<div className="segmented-control">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className={`seg-btn ${index === i ? 'active' : ''}`}
|
||||||
|
onClick={() => this.handleIndexChange(i)}
|
||||||
|
disabled={isSyncing}
|
||||||
|
>
|
||||||
|
Counter #{i}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Value Display */}
|
||||||
|
<div className="ctrl-current-value" style={{ marginBottom: 14 }}>
|
||||||
|
<span className="ctrl-current-label">Counter #{index} (Local UI Value)</span>
|
||||||
|
<span className="ctrl-current-num">{count}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="counter-controls" style={{ gap: 14, marginBottom: 12 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-circle btn-secondary"
|
||||||
|
onClick={() => this.handleDecrement(1)}
|
||||||
|
aria-label="Decrement"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-circle btn-primary"
|
||||||
|
onClick={() => this.handleIncrement(1)}
|
||||||
|
aria-label="Increment"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={() => this.handleIncrement(5)}
|
||||||
|
>
|
||||||
|
+5 Quick Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={this.handleReset}
|
||||||
|
>
|
||||||
|
Reset to 0
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sync Status Banner */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: 'var(--surface-0)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
fontSize: 12,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{isSyncing ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-sm" />
|
||||||
|
<span style={{ color: 'var(--orange)' }}>POST /api/counter in flight…</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span style={{ color: 'var(--green)' }}>✓</span>
|
||||||
|
<span style={{ color: 'var(--text-2)' }}>Backend in sync</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--text-3)', fontSize: 11 }}>
|
||||||
|
{lastSyncedAt}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="weather-error" style={{ marginBottom: 12 }}>
|
||||||
|
<span>⚠️ {error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Code Snippet */}
|
||||||
|
<div className="code-snippet">
|
||||||
|
<pre>{`// Optimistic local update + automatic background POST sync:
|
||||||
|
async increment(step = 1) {
|
||||||
|
const next = syncCounterAutomat.state.count + step;
|
||||||
|
syncCounterAutomat.setState({ count: next, syncStatus: 'syncing' });
|
||||||
|
|
||||||
|
// Auto-sync via HTTP POST /api/counter:
|
||||||
|
const res = await fetch('/api/counter', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ index, count: next }),
|
||||||
|
});
|
||||||
|
syncCounterAutomat.setState({ syncStatus: 'synced' });
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SyncCounterControls;
|
||||||
1037
src/index.css
Normal file
1037
src/index.css
Normal file
File diff suppressed because it is too large
Load Diff
179
src/lib/Automat.js
Normal file
179
src/lib/Automat.js
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* @module Automat
|
||||||
|
*
|
||||||
|
* An observable state container whose lifetime is independent of any React component.
|
||||||
|
* State persists across mounts and unmounts; components always see the latest value
|
||||||
|
* when they mount because they read `automat.state` or `automat.getState()` in their constructor.
|
||||||
|
*
|
||||||
|
* 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>;
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class Automat {
|
||||||
|
/** @type {object} */
|
||||||
|
#state;
|
||||||
|
/** @type {Map<any, function>} Map of subscriber target -> notification callback */
|
||||||
|
#subscribers = new Map();
|
||||||
|
/** @type {object} */
|
||||||
|
#callbacks;
|
||||||
|
/** @type {Array<function>} */
|
||||||
|
#upstreamUnsubscribers = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} initialState Initial state snapshot.
|
||||||
|
* @param {object} [callbacks] Named action callbacks. Exposed via `.actions`.
|
||||||
|
*/
|
||||||
|
constructor(initialState = {}, callbacks = {}) {
|
||||||
|
this.#state = { ...initialState };
|
||||||
|
this.#callbacks = callbacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct property access to the current state snapshot.
|
||||||
|
* Use in PureComponent constructor: `this.state = automat.state;`
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
get state() {
|
||||||
|
return this.#state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current state snapshot.
|
||||||
|
* Use in PureComponent constructor: `this.state = automat.getState();`
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
getState() {
|
||||||
|
return this.#state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
this.#state = { ...this.#state, ...partial };
|
||||||
|
this.#notify();
|
||||||
|
return this.#state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscriber handling for React lifecycle management or listener callbacks.
|
||||||
|
*
|
||||||
|
* Supports:
|
||||||
|
* 1. A React component instance (has `.setState`):
|
||||||
|
* `this.unsubscribe = automat.subscribe(this);`
|
||||||
|
* 2. A React component instance with an optional selector:
|
||||||
|
* `this.unsubscribe = automat.subscribe(this, state => ({ count: state.count }));`
|
||||||
|
* 3. A listener function:
|
||||||
|
* `this.unsubscribe = automat.subscribe((state) => { ... });`
|
||||||
|
*
|
||||||
|
* @param {object|function} target React component instance or callback function.
|
||||||
|
* @param {function} [selector] Optional selector function mapping state.
|
||||||
|
* @returns {function} Unsubscribe function for componentWillUnmount.
|
||||||
|
*/
|
||||||
|
subscribe(target, selector) {
|
||||||
|
let notifyFn;
|
||||||
|
|
||||||
|
if (typeof target === 'function') {
|
||||||
|
notifyFn = target;
|
||||||
|
} else if (target && typeof target.setState === 'function') {
|
||||||
|
notifyFn = (state) => {
|
||||||
|
const next = typeof selector === 'function' ? selector(state) : state;
|
||||||
|
if (next != null) {
|
||||||
|
target.setState(next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new TypeError(
|
||||||
|
'Automat.subscribe expects a callback function or a React component instance with a setState method.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#subscribers.set(target, notifyFn);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
this.unsubscribe(target);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribes a component or listener function.
|
||||||
|
*
|
||||||
|
* Can be called directly in `componentWillUnmount`:
|
||||||
|
* `automat.unsubscribe(this);`
|
||||||
|
*
|
||||||
|
* @param {object|function} target
|
||||||
|
*/
|
||||||
|
unsubscribe(target) {
|
||||||
|
this.#subscribers.delete(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires this automat to derive state from an upstream automat.
|
||||||
|
*
|
||||||
|
* Whenever `upstreamAutomat` changes, `transform` is called with the upstream state
|
||||||
|
* 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) {
|
||||||
|
const unsub = upstreamAutomat.subscribe((upstreamState) => {
|
||||||
|
const partial = transform(upstreamState, this.#state);
|
||||||
|
if (partial != null) {
|
||||||
|
this.setState(partial);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.#upstreamUnsubscribers.push(unsub);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The named action callbacks passed in the constructor.
|
||||||
|
* @type {object}
|
||||||
|
*/
|
||||||
|
get actions() {
|
||||||
|
return this.#callbacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tears down all upstream subscriptions and clears the subscriber map.
|
||||||
|
*/
|
||||||
|
dispose() {
|
||||||
|
this.#upstreamUnsubscribers.forEach((fn) => fn());
|
||||||
|
this.#upstreamUnsubscribers = [];
|
||||||
|
this.#subscribers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
#notify() {
|
||||||
|
for (const notifyFn of this.#subscribers.values()) {
|
||||||
|
notifyFn(this.#state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/lib/index.js
Normal file
30
src/lib/index.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* @module automat
|
||||||
|
*
|
||||||
|
* Observable state management for React PureComponents.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* import { Automat } from './lib/index.js';
|
||||||
|
* import { PureComponent } from 'react';
|
||||||
|
*
|
||||||
|
* const counterAutomat = new Automat({ count: 0 }, {
|
||||||
|
* increment() { counterAutomat.setState({ count: counterAutomat.state.count + 1 }); },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* class Display extends PureComponent {
|
||||||
|
* constructor(props) {
|
||||||
|
* super(props);
|
||||||
|
* this.state = counterAutomat.state;
|
||||||
|
* }
|
||||||
|
* componentDidMount() {
|
||||||
|
* this.unsubscribe = counterAutomat.subscribe(this);
|
||||||
|
* }
|
||||||
|
* componentWillUnmount() {
|
||||||
|
* this.unsubscribe();
|
||||||
|
* }
|
||||||
|
* render() {
|
||||||
|
* return <span>{this.state.count}</span>;
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export { Automat } from './Automat.js';
|
||||||
10
src/main.jsx
Normal file
10
src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App.jsx';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
87
vite.config.js
Normal file
87
vite.config.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embedded in-memory backend plugin for Vite dev server.
|
||||||
|
* Handles POST /api/counter and GET /api/counter to support automatic
|
||||||
|
* backend synchronization for the indexed counter demo without external APIs.
|
||||||
|
*/
|
||||||
|
function counterBackendPlugin() {
|
||||||
|
const serverStore = new Map([
|
||||||
|
[0, { count: 0, updatedAt: 'Initial' }],
|
||||||
|
[1, { count: 5, updatedAt: 'Initial' }],
|
||||||
|
[2, { count: 10, updatedAt: 'Initial' }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'counter-backend-plugin',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use('/api/counter', (req, res, next) => {
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body || '{}');
|
||||||
|
const index = Number(data.index) || 0;
|
||||||
|
const count = Number(data.count) || 0;
|
||||||
|
const updatedAt = new Date().toLocaleTimeString();
|
||||||
|
|
||||||
|
serverStore.set(index, { count, updatedAt });
|
||||||
|
|
||||||
|
// 180ms simulated server latency to clearly illustrate syncing status
|
||||||
|
setTimeout(() => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
index,
|
||||||
|
count,
|
||||||
|
savedAt: updatedAt,
|
||||||
|
allRecords: Array.from(serverStore.entries()).map(([idx, val]) => ({
|
||||||
|
index: idx,
|
||||||
|
...val,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, 180);
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
const url = new URL(req.url, 'http://localhost');
|
||||||
|
const indexParam = url.searchParams.get('index');
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
if (indexParam !== null) {
|
||||||
|
const index = Number(indexParam);
|
||||||
|
const item = serverStore.get(index) || { count: 0, updatedAt: 'Unsynced' };
|
||||||
|
return res.end(JSON.stringify({ index, ...item }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
allRecords: Array.from(serverStore.entries()).map(([idx, val]) => ({
|
||||||
|
index: idx,
|
||||||
|
...val,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), counterBackendPlugin()],
|
||||||
|
});
|
||||||
24
vite.config.lib.js
Normal file
24
vite.config.lib.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { resolve } from 'path';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: false,
|
||||||
|
lib: {
|
||||||
|
entry: resolve(import.meta.dirname, 'src/lib/index.js'),
|
||||||
|
name: 'Automat',
|
||||||
|
fileName: (format) => `automat.${format}.js`,
|
||||||
|
formats: ['es', 'umd'],
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['react', 'react-dom'],
|
||||||
|
output: {
|
||||||
|
globals: {
|
||||||
|
react: 'React',
|
||||||
|
'react-dom': 'ReactDOM',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user