From c34719513cbc878be9810f24061147e44007ae5e Mon Sep 17 00:00:00 2001 From: seb Date: Thu, 17 Sep 2026 22:19:21 +0200 Subject: [PATCH] Genesis --- .gitignore | 2 + README.LLM.md | 371 ++++++ README.md | 500 ++++++++ index.html | 22 + package-lock.json | 890 ++++++++++++++ package.json | 23 + src/App.jsx | 192 +++ src/examples/automats/counterAutomat.js | 31 + src/examples/automats/indexAutomat.js | 76 ++ src/examples/automats/notificationAutomat.js | 52 + src/examples/automats/syncCounterAutomat.js | 166 +++ src/examples/components/CascadeControls.jsx | 163 +++ src/examples/components/CounterButton.jsx | 182 +++ src/examples/components/CounterDisplay.jsx | 172 +++ .../components/DynamicAutomatSubscriber.jsx | 195 ++++ src/examples/components/IndexSelector.jsx | 118 ++ src/examples/components/NotificationBar.jsx | 155 +++ .../components/SyncBackendMonitor.jsx | 115 ++ .../components/SyncCounterControls.jsx | 178 +++ src/index.css | 1037 +++++++++++++++++ src/lib/Automat.js | 179 +++ src/lib/index.js | 30 + src/main.jsx | 10 + vite.config.js | 87 ++ vite.config.lib.js | 24 + 25 files changed, 4970 insertions(+) create mode 100644 .gitignore create mode 100644 README.LLM.md create mode 100644 README.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/App.jsx create mode 100644 src/examples/automats/counterAutomat.js create mode 100644 src/examples/automats/indexAutomat.js create mode 100644 src/examples/automats/notificationAutomat.js create mode 100644 src/examples/automats/syncCounterAutomat.js create mode 100644 src/examples/components/CascadeControls.jsx create mode 100644 src/examples/components/CounterButton.jsx create mode 100644 src/examples/components/CounterDisplay.jsx create mode 100644 src/examples/components/DynamicAutomatSubscriber.jsx create mode 100644 src/examples/components/IndexSelector.jsx create mode 100644 src/examples/components/NotificationBar.jsx create mode 100644 src/examples/components/SyncBackendMonitor.jsx create mode 100644 src/examples/components/SyncCounterControls.jsx create mode 100644 src/index.css create mode 100644 src/lib/Automat.js create mode 100644 src/lib/index.js create mode 100644 src/main.jsx create mode 100644 vite.config.js create mode 100644 vite.config.lib.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2d59d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/node_modules +/dist \ No newline at end of file diff --git a/README.LLM.md b/README.LLM.md new file mode 100644 index 0000000..d5b32d2 --- /dev/null +++ b/README.LLM.md @@ -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>( + initialState: T, + actions?: A +): Automat +``` + +- `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` | 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(upstream: Automat, transform: (upstreamState: U, myState: T) => Partial \| 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 ( +
+

Count: {count}

+ + +
+ ); + } +} +``` + +--- + +### 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

Current Count: {this.state.count}

; + } +} +``` + +--- + +### 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 ( +
+

Slot #{currentIndex}

+

Recalled Clicks: {slotState.clicks}

+ +
+ ); + } +} +``` + +--- + +## 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f2f7d51 --- /dev/null +++ b/README.md @@ -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 ( +
+

Count: {count} Β· Local Clicks: {localClicks}

+ + {/* πŸ’‘ WIRED ONCLICK: calls handlers directly */} + + + +
+ ); + } +} + +// 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

Display: {this.state.count}

; + } +} +``` + +--- + +### 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 ( +
+ + {/* πŸ’‘ Click triggers handleSubmit β†’ searchAutomat.actions.search() */} + + +
    + {results?.map((item) => ( +
  • {item.title}
  • + ))} +
+
+ ); + } +} +``` + +--- + +## 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 ( +
+ + + +
+ ); + } +} + +// 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 ( +
    + {messages.map((m) => ( +
  • {m.text} ({m.time})
  • + ))} +
+ ); + } +} +``` + +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 ( +
+

Counter #{index}: {count}

+ + Status: {syncStatus === 'syncing' ? 'POST in flight…' : `Synced (${lastSyncedAt})`} +
+ ); + } +} +``` + +--- + +## 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 diff --git a/index.html b/index.html new file mode 100644 index 0000000..822d66a --- /dev/null +++ b/index.html @@ -0,0 +1,22 @@ + + + + + + Automat β€” React PureComponent State Manager + + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..39de314 --- /dev/null +++ b/package-lock.json @@ -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 + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9000ad8 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..ae1bee2 --- /dev/null +++ b/src/App.jsx @@ -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 ( +
+ {/* ── Header ─────────────────────────────────────────────── */} +
+
+ βš™ + automat + v0.1.0 +
+

+ Observable state management for React{' '} + PureComponent +
+ State lives independently of mounting β€” zero prop drilling. +

+
+ +
+ {/* ── Example 1: Counter ────────────────────────────────── */} +
+
+
+ 01 +

Counter

+
+

+ Two independent PureComponents + synchronized by a single counterAutomat. + Neither knows the other exists β€” they both subscribe to the same state container. +

+
+ counterAutomat + β†’ + CounterButton + & + CounterDisplay +
+
+ +
+ + +
+
+ +
+ + {/* ── Example 2: Cascade ───────────────────────────────── */} +
+
+
+ 02 +

Notification Cascade

+
+

+ subscribeTo() connects two automats into a + reactive pipeline. Clicking triggers in CascadeControls mutates{' '} + counterAutomat (upstream). This automatically invokes the{' '} + subscribeTo() transform callback, updating{' '} + notificationAutomat (downstream), which then re-renders{' '} + NotificationBar without any prop drilling or shared component parent. +

+
+ User Click + β†’ + counterAutomat.actions.increment() + β†’ + subscribeTo(upstream, transform) + β†’ + notificationAutomat.setState() + β†’ + NotificationBar +
+ +
+
+ ℹ️ How subscribeTo(upstreamAutomat, transform) Works +
+
    +
  • + upstreamState: The newly emitted state from the upstream automat (e.g. {`{ count: 1 }`}). +
  • +
  • + myState: The current state of this downstream automat right before updating (e.g. {`{ messages: [...] }`}). Acts as an accumulator to prepend events and cap history. +
  • +
  • + Return Value: The returned partial state object is automatically applied via this.setState(partial), notifying downstream UI subscribers. +
  • +
  • + Conditional Filtering: Return null or undefined to selectively skip updates and avoid triggering renders. +
  • +
+
+
+ +
+ + +
+
+ +
+ + {/* ── Example 3: API Auto-Sync ─────────────────────────── */} +
+
+
+ 03 +

API-Backed Auto-Sync Counter (POST)

+
+

+ syncCounterAutomat manages an indexed counter that automatically + synchronizes its mutations to the backend via HTTP POST /api/counter. + State updates are applied optimistically for instant UI response, followed by asynchronous + background synchronization and status tracking without any component-level fetch or lifecycle glue. +

+
+ User Click + β†’ + Optimistic setState() + β†’ + POST /api/counter + β†’ + Backend Database Updated + β†’ + Sync Status 200 OK +
+
+ +
+ + +
+
+ +
+ + {/* ── Example 4: Dynamic Map Resubscription ──────────── */} +
+
+
+ 04 +

Dynamic Window Map (Index-Driven Resubscription)

+
+

+ The left side is a standard indexAutomat counter (like Example 1). + On the right side, that counter’s value is used to dynamically lookup, instantiate, and resubscribe to + a different Automat stored in window.automats = new Map(). + Stepping through indexes unhooks from the old Automat, subscribes to the new one, and instantly recalls its preserved state. +

+
+ Shared Index: N + β†’ + window.automats.get(N) + β†’ + Unsub Old / Sub New + β†’ + Recall State for Slot #N +
+
+ +
+ + +
+
+
+ +
+

+ automat Β· MIT Β·{' '} + State is independent of component lifecycle +

+
+
+ ); + } +} + +export default App; diff --git a/src/examples/automats/counterAutomat.js b/src/examples/automats/counterAutomat.js new file mode 100644 index 0000000..2e222da --- /dev/null +++ b/src/examples/automats/counterAutomat.js @@ -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; diff --git a/src/examples/automats/indexAutomat.js b/src/examples/automats/indexAutomat.js new file mode 100644 index 0000000..eb7ab91 --- /dev/null +++ b/src/examples/automats/indexAutomat.js @@ -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); +} diff --git a/src/examples/automats/notificationAutomat.js b/src/examples/automats/notificationAutomat.js new file mode 100644 index 0000000..da48131 --- /dev/null +++ b/src/examples/automats/notificationAutomat.js @@ -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. ). + * + * 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; diff --git a/src/examples/automats/syncCounterAutomat.js b/src/examples/automats/syncCounterAutomat.js new file mode 100644 index 0000000..c0bcceb --- /dev/null +++ b/src/examples/automats/syncCounterAutomat.js @@ -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, + }); + }, + } +); diff --git a/src/examples/components/CascadeControls.jsx b/src/examples/components/CascadeControls.jsx new file mode 100644 index 0000000..72e59f7 --- /dev/null +++ b/src/examples/components/CascadeControls.jsx @@ -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 ( +
+
+ 🌊 +

Cascade Trigger

+ upstream +
+ +
+ {/* Hybrid state display */} +
+
+
+ upstream + counter count +
+ {upstreamCount} +
+
+
+ local + triggers fired +
+ {cascadeFiredCount} +
+
+ +

+ Clicking a trigger button invokes counterAutomat.actions.increment(). + Because notificationAutomat observes it with{' '} + subscribeTo(), the downstream log derives a new + message automatically without any component glue. +

+ + {/* Trigger buttons */} +
+ + +
+ +
+ this.setState({ customText: e.target.value })} + placeholder="Custom notification message..." + /> + +
+ + + +
+
{`// 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),
+          ],
+        }
+);`}
+
+
+
+ ); + } +} + +export default CascadeControls; diff --git a/src/examples/components/CounterButton.jsx b/src/examples/components/CounterButton.jsx new file mode 100644 index 0000000..beef4ea --- /dev/null +++ b/src/examples/components/CounterButton.jsx @@ -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 ( +
+
+ ⚑ +

Counter Controls

+ hybrid state +
+ +
+ {/* Hybrid State breakdown */} +
+
+
+ automat + shared +
+ {count} +
+
+
+ local + button clicks +
+ {localClicks} +
+
+ + {/* Local step selector */} +
+ + Step size local + +
+ {[1, 5, 10].map((s) => ( + + ))} +
+
+ + {/* +/- controls */} +
+ + + +
+ + + +
+
{`// 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();
+  }
+}`}
+
+
+
+ ); + } +} + +export default CounterButton; diff --git a/src/examples/components/CounterDisplay.jsx b/src/examples/components/CounterDisplay.jsx new file mode 100644 index 0000000..319187b --- /dev/null +++ b/src/examples/components/CounterDisplay.jsx @@ -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 ( +
+
+ πŸ“Š +

Counter Display

+ hybrid state +
+ +
+ {/* Hybrid State breakdown */} +
+
+
+ automat + shared count +
+ {count} +
+
+
+ local + format preference +
+ {format.toUpperCase()} +
+
+ + {/* Local format selector */} +
+ + Display format local + +
+ {['dec', 'hex'].map((f) => ( + + ))} +
+
+ +
+ {/* Gradient number with pop animation on change */} + { this._numRef = el; }} + aria-live="polite" + aria-atomic="true" + > + {formatted} + + {format.toUpperCase()} format + + Independent PureComponent β€” no shared parent + +
+ +
+
{`// 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 {value};
+  }
+}`}
+
+
+
+ ); + } +} + +export default CounterDisplay; diff --git a/src/examples/components/DynamicAutomatSubscriber.jsx b/src/examples/components/DynamicAutomatSubscriber.jsx new file mode 100644 index 0000000..82a7672 --- /dev/null +++ b/src/examples/components/DynamicAutomatSubscriber.jsx @@ -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 ( +
+
+ ⚑ +

Dynamic Slot Automat (Resubscribed by Index)

+ window.automats.get({currentIndex}) +
+ +
+ {/* Active slot recalled state */} +
+ + Recalled State for Automat #{currentIndex} + + + {slotState.clicks} + + Clicks recorded on Slot #{currentIndex} +
+ + {/* Slot mutation controls */} +
+ + +
+ +
+ +
+ + {/* Table showing all entries in window.automats */} +
+ + All Instantiated Automats in window.automats Map: + +
+ {allIndexes.map((idx) => { + const aut = map.get(idx); + const st = aut ? aut.state : {}; + const isActive = idx === currentIndex; + return ( +
+ + {isActive ? 'β–Έ ' : ' '}window.automats.get({idx}) + + + clicks: {st.clicks} Β· "{st.notes}" + +
+ ); + })} +
+
+ + {/* Code snippet showing resubscription logic */} +
+
{`// 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!
+}`}
+
+
+
+ ); + } +} + +export default DynamicAutomatSubscriber; diff --git a/src/examples/components/IndexSelector.jsx b/src/examples/components/IndexSelector.jsx new file mode 100644 index 0000000..fe97a9e --- /dev/null +++ b/src/examples/components/IndexSelector.jsx @@ -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 ( +
+
+ πŸ”’ +

Shared Index Counter

+ shared index +
+ +
+ {/* Current Index Display */} +
+ Shared Automat Index + {index} +
+ + {/* +/- Controls */} +
+ + + +
+ + {/* Quick jump pills */} +
+ + Quick jump to index: + +
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
+
+ +

+ Changing this index causes the right side to dynamically lookup, instantiate, and resubscribe to{' '} + {`window.automats.get(${index})`}. +

+ +
+
{`// 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();`}
+
+
+
+ ); + } +} + +export default IndexSelector; diff --git a/src/examples/components/NotificationBar.jsx b/src/examples/components/NotificationBar.jsx new file mode 100644 index 0000000..f7aa746 --- /dev/null +++ b/src/examples/components/NotificationBar.jsx @@ -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 ( +
+
+ πŸ”” +

Notification Stream

+ downstream + {messages.length > 0 && ( + {messages.length} total + )} +
+ +
+ {/* Hybrid state display */} +
+
+
+ cascade + items in stream +
+ {messages.length} +
+
+
+ local + active filter +
+ {filter.toUpperCase()} +
+
+ + {/* Local filter control */} +
+ + Filter stream local + +
+ {['all', 'positive', 'negative'].map((f) => ( + + ))} +
+
+ + {filteredMessages.length === 0 ? ( +
+ πŸ’€ +

No notifications matching "{filter}".

+

Trigger counter changes to see cascade notifications.

+
+ ) : ( +
    + {filteredMessages.map((msg) => ( +
  • + {msg.time} + {msg.text} + = 0 ? 'var(--green)' : 'var(--red)', + }} + > + {msg.count} + +
  • + ))} +
+ )} + +
+
{`// 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 
    {messages.map((m) =>
  • {m.text}
  • )}
; + } +}`}
+
+
+
+ ); + } +} + +export default NotificationBar; diff --git a/src/examples/components/SyncBackendMonitor.jsx b/src/examples/components/SyncBackendMonitor.jsx new file mode 100644 index 0000000..86a9df4 --- /dev/null +++ b/src/examples/components/SyncBackendMonitor.jsx @@ -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 ( +
+
+ πŸ“‘ +

Backend Server Database Monitor

+ POST /api/counter +
+ +
+ {/* Last Transmitted Payload */} +
+ + HTTP POST Payload (Sent Automatically on Change): + +
+
+ POST /api/counter +
+
+ {JSON.stringify({ index, count }, null, 2)} +
+
+ {isSyncing ? '⏳ Transmitting request to server…' : `βœ“ 200 OK β€” Saved at ${lastSyncedAt}`} +
+
+
+ + {/* Server-Side Database Records across all indexes */} +
+ + Backend Server Database (Persisted Records): + +
+ {backendRecords && backendRecords.length > 0 ? ( + backendRecords.map((rec) => { + const isActive = rec.index === index; + return ( +
+ + {isActive ? 'β–Έ ' : ' '}Index #{rec.index} + + + Server Count: {rec.count} + + + {rec.updatedAt} + +
+ ); + }) + ) : ( +
+ No backend records yet. +
+ )} +
+
+ + {/* Code Snippet */} +
+
{`// 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);
+  }
+}`}
+
+
+
+ ); + } +} + +export default SyncBackendMonitor; diff --git a/src/examples/components/SyncCounterControls.jsx b/src/examples/components/SyncCounterControls.jsx new file mode 100644 index 0000000..a763a2c --- /dev/null +++ b/src/examples/components/SyncCounterControls.jsx @@ -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 ( +
+
+ πŸ”„ +

Auto-Sync Indexed Counter

+ + {isSyncing ? 'POST syncing…' : 'POST synced'} + +
+ +
+ {/* Index Selector */} +
+ + Select Counter Index: + +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+
+ + {/* Current Value Display */} +
+ Counter #{index} (Local UI Value) + {count} +
+ + {/* Action Buttons */} +
+ + +
+ +
+ + +
+ + {/* Sync Status Banner */} +
+ + {isSyncing ? ( + <> + + POST /api/counter in flight… + + ) : ( + <> + βœ“ + Backend in sync + + )} + + + {lastSyncedAt} + +
+ + {error && ( +
+ ⚠️ {error} +
+ )} + + {/* Code Snippet */} +
+
{`// 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' });
+}`}
+
+
+
+ ); + } +} + +export default SyncCounterControls; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..0077794 --- /dev/null +++ b/src/index.css @@ -0,0 +1,1037 @@ +/* ============================================================ + Design System β€” Automat Dev App (Gruvbox Dark Theme) + Inter (sans) + JetBrains Mono loaded from index.html + Clean, solid colors β€” no gradients, no purplish tones. + ============================================================ */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + /* Gruvbox Dark Palette */ + --bg: #282828; + --surface-0: #1d2021; + --surface-1: #32302f; + --surface-2: #3c3836; + --surface-3: #504945; + --surface-4: #665c54; + + --border: #3c3836; + --border-hover: #504945; + --border-focus: #fabd2f; + + /* Gruvbox Accents (Warm & Earthy, No Purple) */ + --accent: #fabd2f; + --accent-hover: #d79921; + --yellow: #fabd2f; + --orange: #fe8019; + --green: #b8bb26; + --aqua: #8ec07c; + --blue: #83a598; + --red: #fb4934; + --purple: #fabd2f; /* Aliased to accent to prevent purplish fallback */ + + /* Fallback aliases for components that might read gradient vars */ + --gradient: var(--accent); + --gradient-text: var(--accent); + --gradient-warm: var(--orange); + + /* Gruvbox Text */ + --text-1: #ebdbb2; + --text-2: #d5c4a1; + --text-3: #928374; + --text-4: #665c54; + --text-bright: #fbf1c7; + --text-dark: #282828; + + /* Typography */ + --font-sans: 'Inter', system-ui, -apple-system, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + + /* Geometry */ + --r-sm: 4px; + --r-md: 6px; + --r-lg: 8px; + --r-xl: 12px; + --r-2xl: 16px; + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25); + --shadow-md: 0 3px 8px rgba(0, 0, 0, 0.35); + --shadow-lg: 0 6px 16px rgba(0, 0, 0, 0.45); +} + +/* ── Global ────────────────────────────────────────────────── */ + +html { + scroll-behavior: smooth; + color-scheme: dark; +} + +body { + background: var(--bg); + color: var(--text-1); + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.6; + min-height: 100vh; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} + +#root { + position: relative; +} + +/* ── App Shell ─────────────────────────────────────────────── */ + +.app { + max-width: 1160px; + margin: 0 auto; + padding: 0 24px 80px; +} + +/* ── Header ────────────────────────────────────────────────── */ + +.app-header { + padding: 48px 0 36px; + text-align: center; +} + +.logo { + display: inline-flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} + +.logo-icon { + font-size: 34px; + color: var(--accent); + display: inline-block; + line-height: 1; +} + +.logo-text { + font-size: 38px; + font-weight: 800; + letter-spacing: -1.5px; + color: var(--accent); +} + +.logo-version { + align-self: flex-end; + margin-bottom: 4px; + font-size: 12px; + font-family: var(--font-mono); + color: var(--text-3); + padding: 2px 8px; + border: 1px solid var(--border); + background: var(--surface-0); + border-radius: var(--r-sm); +} + +.tagline { + color: var(--text-2); + font-size: 15px; + line-height: 1.7; +} + +.inline-code { + font-family: var(--font-mono); + font-size: 0.85em; + background: var(--surface-0); + padding: 1px 6px; + border-radius: var(--r-sm); + color: var(--aqua); + border: 1px solid var(--border); +} + +.header-pills { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + margin-top: 16px; +} + +.pill { + font-size: 12px; + font-family: var(--font-mono); + padding: 3px 10px; + border-radius: var(--r-sm); + background: var(--surface-2); + border: 1px solid var(--border); + color: var(--text-2); +} + +/* ── Main ──────────────────────────────────────────────────── */ + +.app-main { + display: flex; + flex-direction: column; + gap: 0; +} + +/* ── Example Section ───────────────────────────────────────── */ + +.example-section { + padding: 8px 0 24px; +} + +.section-header { + margin-bottom: 20px; +} + +.section-title-row { + display: flex; + align-items: baseline; + gap: 12px; + margin-bottom: 8px; +} + +.section-number { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 700; + color: var(--accent); + padding: 2px 8px; + background: var(--surface-0); + border: 1px solid var(--border); + border-radius: var(--r-sm); +} + +.section-header h2 { + font-size: 22px; + font-weight: 700; + color: var(--text-bright); + display: flex; + align-items: center; + gap: 10px; +} + +.section-header h2::before { + content: ''; + display: inline-block; + width: 4px; + height: 20px; + border-radius: 2px; + background: var(--accent); + flex-shrink: 0; +} + +.section-header p { + color: var(--text-2); + font-size: 14px; + line-height: 1.65; + margin-bottom: 12px; + max-width: 680px; +} + +/* ── Flow diagram ──────────────────────────────────── */ + +.flow-diagram { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.flow-node { + font-family: var(--font-mono); + font-size: 12px; + padding: 4px 10px; + background: var(--surface-0); + border: 1px solid var(--border); + border-radius: var(--r-sm); + color: var(--text-2); + white-space: nowrap; +} + +.flow-node-component { + background: rgba(250, 189, 47, 0.1); + border-color: rgba(250, 189, 47, 0.35); + color: var(--accent); +} + +.flow-arrow { + color: var(--orange); + font-size: 15px; + font-weight: 700; +} + +.flow-sep { + color: var(--text-3); + font-size: 13px; +} + +/* ── Info / Documentation Callout ──────────────────── */ + +.info-box { + background: var(--surface-0); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--r-md); + padding: 14px 16px; + margin: 16px 0 20px; + font-size: 13px; + line-height: 1.6; + color: var(--text-2); +} + +.info-box-title { + font-weight: 700; + color: var(--accent); + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: .06em; +} + +.info-box ul { + margin-left: 18px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.info-box li strong { + color: var(--text-bright); +} + +.info-box code { + font-family: var(--font-mono); + font-size: 0.9em; + color: var(--aqua); + background: var(--surface-2); + padding: 1px 5px; + border-radius: 3px; +} + +/* ── Two-column grid ───────────────────────────────── */ + +.two-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +@media (max-width: 680px) { + .two-columns { + grid-template-columns: 1fr; + } +} + +/* ── Card ──────────────────────────────────────────── */ + +.card { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--r-lg); + overflow: hidden; + box-shadow: var(--shadow-sm); + transition: border-color .15s, box-shadow .15s; + display: flex; + flex-direction: column; +} + +.card:hover { + border-color: var(--border-hover); + box-shadow: var(--shadow-md); +} + +.card-display { + min-height: 340px; +} + +.card-header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 18px; + border-bottom: 1px solid var(--border); + background: var(--surface-0); + flex-shrink: 0; +} + +.card-icon { + font-size: 16px; +} + +.card-header h3 { + font-size: 13px; + font-weight: 600; + color: var(--text-bright); + flex: 1; + letter-spacing: .01em; +} + +.card-body { + padding: 20px 18px; + flex: 1; + display: flex; + flex-direction: column; + gap: 0; +} + +/* ── Badges ────────────────────────────────────────── */ + +.badge { + font-size: 10px; + font-family: var(--font-mono); + font-weight: 700; + padding: 2px 8px; + border-radius: var(--r-sm); + text-transform: uppercase; + letter-spacing: .05em; +} + +.badge-local { + background: rgba(184, 187, 38, 0.15); + color: var(--green); + border: 1px solid rgba(184, 187, 38, 0.3); +} + +.badge-cascade { + background: rgba(254, 128, 25, 0.15); + color: var(--orange); + border: 1px solid rgba(254, 128, 25, 0.3); +} + +.badge-api { + background: rgba(131, 165, 152, 0.15); + color: var(--blue); + border: 1px solid rgba(131, 165, 152, 0.3); +} + +/* ── Buttons ───────────────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 20px; + border-radius: var(--r-md); + border: 1px solid transparent; + font-family: var(--font-sans); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background-color .15s, border-color .15s, transform .1s; + user-select: none; + white-space: nowrap; +} + +.btn:active:not(:disabled) { + transform: translateY(1px); +} + +.btn:disabled { + opacity: .45; + cursor: not-allowed; +} + +.btn-primary { + background: var(--accent); + color: var(--text-dark); + font-weight: 700; + border-color: var(--accent); +} + +.btn-primary:hover:not(:disabled) { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.btn-secondary { + background: var(--surface-2); + color: var(--text-1); + border: 1px solid var(--border); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--surface-3); + border-color: var(--border-hover); +} + +.btn-ghost { + background: transparent; + color: var(--text-2); + border: 1px solid var(--border); +} + +.btn-ghost:hover:not(:disabled) { + background: var(--surface-2); + color: var(--text-1); + border-color: var(--border-hover); +} + +.btn-full { + width: 100%; +} + +/* Circle variant for +/- */ +.btn-circle { + width: 52px; + height: 52px; + border-radius: 50%; + font-size: 22px; + font-weight: 400; + padding: 0; + flex-shrink: 0; +} + +/* ── Counter Controls ──────────────────────────────── */ + +.ctrl-current-value { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 18px; + padding: 12px; + background: var(--surface-0); + border: 1px solid var(--border); + border-radius: var(--r-md); +} + +.ctrl-current-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: .1em; + color: var(--text-3); + margin-bottom: 4px; + font-weight: 700; +} + +.ctrl-current-num { + font-family: var(--font-mono); + font-size: 32px; + font-weight: 700; + color: var(--accent); +} + +.counter-controls { + display: flex; + align-items: center; + justify-content: center; + gap: 18px; + margin-bottom: 12px; +} + +/* ── Counter Display ───────────────────────────────── */ + +.count-display-container { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 16px 0; +} + +.count-number { + font-family: var(--font-mono); + font-size: 96px; + font-weight: 800; + line-height: 1; + color: var(--accent); + display: inline-block; + min-width: 3ch; + text-align: center; + transition: transform .1s; +} + +.count-number.count-animate { + animation: pop .2s ease-out; +} + +@keyframes pop { + 0% { transform: scale(1.15); } + 100% { transform: scale(1); } +} + +.count-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: .12em; + color: var(--text-3); + font-weight: 600; +} + +.count-hint { + font-size: 12px; + color: var(--text-3); + text-align: center; + margin-top: 4px; +} + +/* ── Notification card ─────────────────────────────── */ + +.notification-card { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--r-lg); + overflow: hidden; + box-shadow: var(--shadow-sm); + transition: border-color .15s; +} + +.notification-card:hover { + border-color: var(--border-hover); +} + +.notif-count-badge { + background: var(--accent); + color: var(--text-dark); + font-size: 11px; + font-weight: 700; + font-family: var(--font-mono); + padding: 1px 8px; + border-radius: var(--r-sm); + min-width: 20px; + text-align: center; +} + +.notification-list { + list-style: none; + max-height: 360px; + overflow-y: auto; + padding: 4px 0; +} + +.notification-list::-webkit-scrollbar { + width: 4px; +} + +.notification-list::-webkit-scrollbar-track { + background: transparent; +} + +.notification-list::-webkit-scrollbar-thumb { + background: var(--surface-3); + border-radius: 2px; +} + +.notification-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 18px; + border-bottom: 1px solid var(--border); +} + +.notification-item:last-child { + border-bottom: none; +} + +.notif-time { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-3); + white-space: nowrap; + flex-shrink: 0; +} + +.notif-text { + flex: 1; + font-size: 13px; + color: var(--text-2); +} + +.notif-count-pill { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 700; + padding: 2px 8px; + border-radius: var(--r-sm); + color: var(--text-dark); + flex-shrink: 0; +} + +/* ── Empty state ───────────────────────────────────── */ + +.empty-state { + padding: 36px 18px; + text-align: center; + color: var(--text-3); + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.empty-state-icon { + font-size: 32px; + display: block; + margin-bottom: 4px; + opacity: .75; +} + +.empty-state-sub { + font-size: 13px; + color: var(--text-3); + max-width: 280px; +} + +/* ── Weather Controls ──────────────────────────────── */ + +.weather-inputs { + display: flex; + flex-direction: column; + gap: 14px; +} + +.input-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.input-group label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .08em; + color: var(--text-3); +} + +.input-field { + width: 100%; + padding: 10px 12px; + background: var(--surface-0); + border: 1px solid var(--border); + border-radius: var(--r-md); + color: var(--text-1); + font-family: var(--font-sans); + font-size: 14px; + outline: none; + transition: border-color .15s; +} + +.input-field:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(250, 189, 47, 0.2); +} + +.input-field::placeholder { + color: var(--text-3); +} + +.input-field:disabled { + opacity: .5; +} + +.city-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.city-chip { + padding: 4px 10px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-sm); + font-size: 12px; + font-family: var(--font-sans); + color: var(--text-2); + cursor: pointer; + transition: background-color .15s, border-color .15s, color .15s; +} + +.city-chip:hover:not(:disabled) { + border-color: var(--accent); + color: var(--text-bright); + background: var(--surface-3); +} + +.city-chip:disabled { + opacity: .4; + cursor: not-allowed; +} + +.spinner-sm { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid rgba(40, 40, 40, 0.3); + border-top-color: var(--text-dark); + border-radius: 50%; + animation: spin .7s linear infinite; +} + +/* ── Weather Panel ─────────────────────────────────── */ + +.weather-panel-body { + align-items: stretch; + justify-content: center; +} + +.weather-loading { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text-3); + font-size: 14px; +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid var(--surface-2); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin .75s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.weather-error { + margin: auto 0; + padding: 14px; + background: rgba(251, 73, 52, 0.12); + border: 1px solid rgba(251, 73, 52, 0.25); + border-radius: var(--r-md); + color: var(--red); + font-size: 14px; + display: flex; + align-items: flex-start; + gap: 10px; + line-height: 1.5; +} + +.weather-data { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 10px 0; +} + +.weather-location { + text-align: center; +} + +.weather-location-name { + font-size: 20px; + font-weight: 700; + color: var(--text-bright); +} + +.weather-location-country { + font-size: 13px; + color: var(--text-3); + margin-top: 2px; +} + +.weather-icon { + font-size: 56px; + line-height: 1; +} + +.weather-temp { + display: flex; + align-items: flex-start; + gap: 2px; +} + +.weather-temp-value { + font-family: var(--font-mono); + font-size: 56px; + font-weight: 800; + line-height: 1; + color: var(--accent); +} + +.weather-temp-unit { + font-size: 24px; + color: var(--text-3); + padding-top: 8px; + font-weight: 400; +} + +.weather-condition { + font-size: 14px; + color: var(--text-2); + font-weight: 500; +} + +.weather-wind { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-3); +} + +.wind-arrow { + display: inline-block; + font-size: 16px; + color: var(--aqua); + font-weight: bold; +} + +.weather-fetched-at { + font-size: 11px; + color: var(--text-3); + font-family: var(--font-mono); +} + +/* ── Code Snippet ──────────────────────────────────── */ + +.code-snippet { + margin-top: auto; + padding-top: 14px; +} + +.code-snippet pre { + background: var(--surface-0); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 12px 14px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-2); + line-height: 1.6; + overflow-x: auto; + white-space: pre; +} + +/* ── Section divider ───────────────────────────────── */ + +.section-divider { + height: 1px; + background: var(--border); + margin: 36px 0; +} + +/* ── Footer ────────────────────────────────────────── */ + +.app-footer { + margin-top: 48px; + padding: 20px 0; + border-top: 1px solid var(--border); + text-align: center; + color: var(--text-3); + font-size: 13px; +} + +.external-link { + color: var(--aqua); + text-decoration: none; +} + +.external-link:hover { + text-decoration: underline; +} + +/* ── Hybrid State Indicators & Controls ────────────── */ + +.hybrid-state-bar { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 14px; + padding: 8px 10px; + background: var(--surface-0); + border: 1px solid var(--border); + border-radius: var(--r-md); + font-size: 12px; +} + +.state-item { + display: flex; + flex-direction: column; + gap: 2px; +} + +.state-item-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: .05em; + color: var(--text-3); + font-weight: 600; +} + +.state-tag-automat { + display: inline-block; + padding: 1px 5px; + border-radius: var(--r-sm); + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + background: rgba(250, 189, 47, 0.15); + color: var(--accent); + border: 1px solid rgba(250, 189, 47, 0.35); +} + +.state-tag-local { + display: inline-block; + padding: 1px 5px; + border-radius: var(--r-sm); + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + background: rgba(142, 192, 124, 0.15); + color: var(--aqua); + border: 1px solid rgba(142, 192, 124, 0.35); +} + +.state-item-val { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 600; + color: var(--text-1); +} + +.segmented-control { + display: inline-flex; + background: var(--surface-0); + border: 1px solid var(--border); + border-radius: var(--r-sm); + padding: 2px; + gap: 2px; +} + +.seg-btn { + background: transparent; + border: none; + color: var(--text-2); + font-size: 11px; + font-family: var(--font-sans); + font-weight: 600; + padding: 3px 8px; + border-radius: 3px; + cursor: pointer; + transition: background-color .15s, color .15s; +} + +.seg-btn:hover:not(.active) { + color: var(--text-1); + background: var(--surface-2); +} + +.seg-btn.active { + background: var(--accent); + color: var(--text-dark); + font-weight: 700; +} diff --git a/src/lib/Automat.js b/src/lib/Automat.js new file mode 100644 index 0000000..ec039d0 --- /dev/null +++ b/src/lib/Automat.js @@ -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 {this.state.count}; + * } + * } + * ``` + */ +export class Automat { + /** @type {object} */ + #state; + /** @type {Map} Map of subscriber target -> notification callback */ + #subscribers = new Map(); + /** @type {object} */ + #callbacks; + /** @type {Array} */ + #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); + } + } +} diff --git a/src/lib/index.js b/src/lib/index.js new file mode 100644 index 0000000..081eae7 --- /dev/null +++ b/src/lib/index.js @@ -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 {this.state.count}; + * } + * } + */ +export { Automat } from './Automat.js'; diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..d9cc93c --- /dev/null +++ b/src/main.jsx @@ -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( + + + +); diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..1637a2c --- /dev/null +++ b/vite.config.js @@ -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()], +}); diff --git a/vite.config.lib.js b/vite.config.lib.js new file mode 100644 index 0000000..b5b5d53 --- /dev/null +++ b/vite.config.lib.js @@ -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', + }, + }, + }, + }, +});