Spike: state history & revert (component identity + live authoritative registry) #1

Open
orchestrator wants to merge 0 commits from wip/identity-replay into main
Member

Status: SPIKE — not production. Exploratory work toward giving Gooey a branching state history (record → revert → fork) for replayable application state. Provisional API/naming throughout.

1. Component identity (foundation)

  • mount(target, node, { id }) sets a root identity (default "root"); backward-compatible.
  • key is consumed by createElement (intrinsic and component); stripped before props/DOM.
  • id = parent.type[discriminator], discriminator = key (URI-encoded) else #position; exposed to components as lifecycle.id.
  • Covers dynamic calc/collection/View lists and destroy/recreate/remount. (A review caught a real blocker here — dynamic lists produced empty ids — now fixed and regression-tested.)

2. State history (the spike)

  • Bound constructors on lifecycle: field / collection / dict / model, idempotent by name.
  • Recording: one subscription; subscribe forces a flush() + captures a baseline; one event per flush (an atomic moment); events { id, changes: [{ name, snapshot }] } arranged as a tree (an event's parent is the HEAD at emit time).
  • revertTo(id): apply the nearest ancestor keyframe + forward-replay deltas; runs under a scoped applying-history guard; a write after a revert forks the tree.
  • Snapshots: sparse deltas + depth-placed dense keyframes → revertTo is O(cells + N), not O(cells × path).
  • Coverage via a live-only authoritative registry (engine-level): every field/dict/collection/model registers on retain (0→1) and unregisters on release; the recorder bootstraps from it. So module-scope state with no lifecycle.* binding is still covered. Identity (stable names) still comes from the bindings.

Verification

  • Headless @srhazi/gooey-test runner ported: node ci/test-headless.mjs.
  • 557 tests pass / 21 files; tsc --noEmit clean.
  • Semantics proven by tests: idempotent remount, per-flush batching, linear + cross-branch revert, fork-after-revert, no self-recording, keyframes, the four kinds, structural mount/unmount, and the fork tree a→b→c→{d1→e1→f1 | d2→e2→f2} restored across both branches.

Known concerns for review

  • Per-kind liveness wiring (esp. Model, whose liveness is its backing dict's) and the observer effect: the recorder retains every tracked cell to observe it, keeping it live.
  • Identity: unbound cells get session-stable keys only; cross-session stability isn't guaranteed.
  • Models are fixed-schema — only keys present at creation are tracked.
  • Retention/GC and stop-recording release semantics are not settled.
  • Per-write (intra-flush) history is not implemented (flush granularity only).
> **Status: SPIKE — not production.** Exploratory work toward giving Gooey a **branching state history** (record → revert → fork) for replayable application state. Provisional API/naming throughout. ## 1. Component identity (foundation) - `mount(target, node, { id })` sets a root identity (default `"root"`); backward-compatible. - `key` is consumed by `createElement` (intrinsic **and** component); stripped before props/DOM. - id = `parent.type[discriminator]`, discriminator = `key` (URI-encoded) else `#position`; exposed to components as **`lifecycle.id`**. - Covers dynamic `calc`/`collection`/`View` lists and destroy/recreate/remount. (A review caught a real blocker here — dynamic lists produced empty ids — now fixed and regression-tested.) ## 2. State history (the spike) - **Bound constructors** on `lifecycle`: `field` / `collection` / `dict` / `model`, idempotent by name. - **Recording:** one subscription; subscribe forces a `flush()` + captures a **baseline**; **one event per flush** (an atomic moment); events `{ id, changes: [{ name, snapshot }] }` arranged as a **tree** (an event's parent is the HEAD at emit time). - **`revertTo(id)`:** apply the nearest ancestor keyframe + forward-replay deltas; runs under a scoped *applying-history* guard; a write after a revert **forks** the tree. - **Snapshots:** sparse deltas + **depth-placed dense keyframes** → `revertTo` is O(cells + N), not O(cells × path). - **Coverage via a live-only authoritative registry** (engine-level): every `field`/`dict`/`collection`/`model` registers on retain (`0→1`) and unregisters on release; the recorder **bootstraps** from it. So module-scope state with no `lifecycle.*` binding is still covered. **Identity** (stable names) still comes from the bindings. ## Verification - Headless `@srhazi/gooey-test` runner ported: `node ci/test-headless.mjs`. - **557 tests pass / 21 files**; `tsc --noEmit` clean. - Semantics proven by tests: idempotent remount, per-flush batching, linear + cross-branch revert, fork-after-revert, no self-recording, keyframes, the four kinds, structural mount/unmount, and the fork tree `a→b→c→{d1→e1→f1 | d2→e2→f2}` restored across both branches. ## Known concerns for review - **Per-kind liveness wiring** (esp. `Model`, whose liveness is its backing dict's) and the **observer effect**: the recorder retains every tracked cell to observe it, keeping it live. - **Identity**: unbound cells get session-stable keys only; cross-session stability isn't guaranteed. - **Models are fixed-schema** — only keys present at creation are tracked. - Retention/GC and stop-recording release semantics are **not settled**. - Per-write (intra-flush) history is **not** implemented (flush granularity only).
Assign every component and intrinsic element a deterministic identity
path (parent id + type name + key-or-position), composing top-down from
mount() through the constructed render tree. Expose the running
component's path via lifecycle.id and consume/strip the key prop in
createElement so it never reaches props or the DOM. Includes stable
keyed-list, nesting, namespace, and leak tests.
Label dynamic (calc/Dyn) and collection/view children before they are
retained, so component bodies synchronously read a non-empty lifecycle.id
(previously the primary list idiom yielded ""). Containers remember their
parent id and label newly spliced children and re-label on move/sort. Also:
namespace positional discriminators (#0) so key={0} cannot collide with an
unkeyed sibling, warn on duplicate keys, preserve prototype props when
stripping key, honor displayName, copy getChildren() output, reset identity
caches via a dependency-free reset registry, and add dynamic/collection/
conditional/key/async coverage.
Percent-encode keyed discriminators so no literal key can collide with a
positional (#index) id and distinct keys stay distinct. Skip relabel work
for all-keyed containers and short-circuit labelNode when a composed id is
unchanged, avoiding descendant re-walks. Align a component's rendered-root
discriminator with the scheme and honor its key via labelComponentOutput.
Correct relabel/lifecycle.id wording (unkeyed dynamic/collection reorder is
unsupported; ids are captured strings), and replace vacuous test assertions
with real ones plus collision/output-root coverage.
Add a minimal Gooey state-history spike (docs/increment-2-replay.md):
lifecycle.field(init, name) binds an authoritative cell under
`${lifecycle.id}.${name}`, idempotent across unmount/remount; a
single recording session subscribes to registered fields and emits one
batched event per flush (baseline + monotonic ids) into a history tree;
revertTo sets every registered cell along the root->id path under a
scoped applying-history guard (no self-recording) and forks on the next
write. Adds an engine after-flush hook and a statehistory test suite
covering idempotency, batching, linear/cross-branch revert, fork,
no-self-record, structural remount, and reset.
Add dense keyframes placed by path-depth since the last ancestor
keyframe (own cadence per branch; baseline is a depth-0 keyframe), a
configurable interval (default 24), and revertTo via nearest-ancestor
keyframe + forward delta replay -- semantically identical to the
root->id fold but O(cells + N). Forward-only deltas. Expose
getRevertStats() {keyframeApplied, keyframeId, deltasReplayed,
cellsSet} and setKeyframeInterval(). Tests build the spec fork tree with
one flush per step over x/p/q and restore to every checkpoint across
both branches (all three fields), asserting branch keyframes are used
and deltasReplayed <= N; plus an equivalence test with a large N.
Generalize the registered cell to {kind, initial, cell, snapshot,
restore, subscribe}; bindField wraps a Field, bindCollection wraps a
Collection (snapshot = fresh Array.from copy, restore = in-place chunked
splice). Change tracking becomes 'mark dirty, snapshot at flush' so a
collection records its final array at each flush (slice/ArrayEvent detail
deliberately ignored) while fields are unchanged. Add
lifecycle.collection(initial, name), idempotent by name like field.
Tests: mixed field+collection fork tree across both branches (push/
splice/reverse/moveSlice), one-event batching for multiple collection
ops, snapshot-copy isolation, and collection idempotent remount.
Add kind 'dict': snapshot = fresh Array.from(getItemsUnsafe().entries())
(entries array preserving key types; no read tracking, no live-Map
aliasing), restore = clear() then re-set each entry, initial = a copy,
subscribe ignores the initial ADD enumeration and the add/set/del
payload (mark dirty, snapshot at flush). Add lifecycle.dict(initial,
name), idempotent by name. Tests: mixed field+collection+dict fork tree
across both branches (set/delete/clear; keys unique to the abandoned
branch disappear on cross-branch restore), one-event batching, snapshot
isolation, and dict idempotent remount.
Add kind 'model': a model's authoritative state is its underlying dict, so
snapshot reuses the dict entries-array (Array.from(getModelDict(model)
.getItemsUnsafe().entries())) and restore reuses clear + re-set; only
subscribe is model-specific (model.subscribe(model, () => onChange()),
payload ignored, initial SET enumeration discarded). initial stored as a
copy. Add lifecycle.model(initial, name), idempotent by name,
fixed-schema (only initial keys are tracked). Tests: mixed four-kind fork
tree across both branches, model batching, snapshot isolation, and model
idempotent remount.
Add src/model/authoritative.ts, a live-only registry of field/collection/
dict/model cells. Membership tracks retain transitions (join on 0->1,
leave on 1->0), wired into Field/Dict __alive/__dead, Collection's ArraySub
lifecycle (registering the proxy via a back-ref), and Model anchored to
its backing dict (an internal dict override registers the model proxy as
one 'model' cell, never the dict too; the override carries a mutable cell
slot filled after the proxy is built). reset() clears it (callbacks run
before the graph is replaced).

statehistory now bootstraps coverage from getAuthoritativeCells() and
adopts cells that go live mid-session (subscribeAuthoritative), deduped
against lifecycle-bound cells by object identity. Unbound cells get a
session-stable name (__debugName when meaningful and unused, else an
auto id). Tests: registry membership per kind, model-registers-once,
reset clears, coverage bootstrap from a non-lifecycle field/dict,
mid-session adoption, and model recorded under its identity name.
@ -0,0 +1,263 @@
// Headless in-browser test harness for Gooey.
Owner

This should be a part of @srhazi/gooey-test -- we should make a new version of that package with this "headless" test runner exported as part of it.

This should be a part of `@srhazi/gooey-test` -- we should make a new version of that package with this "headless" test runner exported as part of it.
Author
Member

Moved into @srhazi/gooey-test as a real export — @srhazi/gooey-test/headless (runHeadless + a gooey-test-headless CLI). PR: sufian/gooey-test#1. ci/test-headless.mjs and s/test-headless are removed from this branch (commit f0fe184).

Moved into `@srhazi/gooey-test` as a real export — `@srhazi/gooey-test/headless` (`runHeadless` + a `gooey-test-headless` CLI). PR: https://git.abstract.properties/sufian/gooey-test/pulls/1. `ci/test-headless.mjs` and `s/test-headless` are removed from this branch (commit f0fe184).
sufian marked this conversation as resolved
@ -0,0 +28,4 @@
const VITE_STARTUP_TIMEOUT_MS = 30_000;
// Gooey's own `s/test` runs the perf suite only with `--perf`; match that.
const PERF_TEST = 'src/performance.test.tsx';
Owner

Because we're lifting it into @srhazi/gooey-test, it will need to be properly parameterized

Because we're lifting it into `@srhazi/gooey-test`, it will need to be properly parameterized
Author
Member

Done in the gooey-test package — the runner is parameterized: cwd, include/testFiles, viteConfigFile/viteArgs, host/port/strictPort, timeoutMs, browser, keepOpenOnFailure, remoteDebuggingPort, artifactsDir.

Done in the `gooey-test` package — the runner is parameterized: `cwd`, `include`/`testFiles`, `viteConfigFile`/`viteArgs`, `host`/`port`/`strictPort`, `timeoutMs`, `browser`, `keepOpenOnFailure`, `remoteDebuggingPort`, `artifactsDir`.
sufian marked this conversation as resolved
@ -0,0 +260,4 @@
.catch((err) => {
console.error('Unexpected error:', err);
process.exit(1);
});
Owner

Overall, the headless test runner looks good, I think it should have the option to keep the playwright session running on test failure, and expose a remote debugging port for debugging the test failure with chrome devtools.

Overall, the headless test runner looks good, I think it should have the option to keep the playwright session running on test failure, and expose a remote debugging port for debugging the test failure with chrome devtools.
Author
Member

Both added in the gooey-test package: keepOpenOnFailure (leaves the browser + Vite running and prints the testrunner URL) and remoteDebuggingPort (launches Chromium with --remote-debugging-port; verified /json/version responds).

Both added in the `gooey-test` package: `keepOpenOnFailure` (leaves the browser + Vite running and prints the testrunner URL) and `remoteDebuggingPort` (launches Chromium with `--remote-debugging-port`; verified `/json/version` responds).
sufian marked this conversation as resolved
@ -0,0 +1,285 @@
# Increment 2 — State history & revert (design spec)
Owner

This shouldn't be committed, these are just notes for our session

This shouldn't be committed, these are just notes for our session
Author
Member

Removed — docs/increment-2-replay.md is deleted in revision 2 (commit f0fe184). Agreed these were session notes, not repo content.

Removed — `docs/increment-2-replay.md` is deleted in revision 2 (commit f0fe184). Agreed these were session notes, not repo content.
sufian marked this conversation as resolved
@ -0,0 +5,4 @@
* engine itself depends on, like render-node identity) can register cleanup
* without introducing an import cycle back through `model/engine`.
*/
const resetCallbacks: (() => void)[] = [];
Owner

It looks like we don't have any mechanism to clear this list. We should, at least for tests so we don't leak across tests.

We should also have away to unregister a callback; maybe have registerReset return a function that removes the callback when called, similar to other patterns in the codebase.

It looks like we don't have any mechanism to clear this list. We should, at least for tests so we don't leak across tests. We should also have away to unregister a callback; maybe have `registerReset` return a function that removes the callback when called, similar to other patterns in the codebase.
Author
Member

registerReset now returns a disposer, and clearResetCallbacks() clears the list (test-only). Commit 817b330.

`registerReset` now returns a disposer, and `clearResetCallbacks()` clears the list (test-only). Commit 817b330.
sufian marked this conversation as resolved
@ -0,0 +41,4 @@
const c = collection([1, 2]);
assert.is(getAuthoritativeCells().length, 0);
c.retain();
Owner

This test is good for retaining the entire collection, but what if something is retaining one of the collection items? i.e. if const x = calc(() => c[0]); x.retain();

This test is good for retaining the entire collection, but what if something is retaining one of the collection items? i.e. if `const x = calc(() => c[0]); x.retain();`
Author
Member

Added — a test retaining a single collection item (const x = calc(() => c[0]); x.retain()). Commit 817b330.

Added — a test retaining a single collection item (`const x = calc(() => c[0]); x.retain()`). Commit 817b330.
sufian marked this conversation as resolved
@ -0,0 +56,4 @@
const d = dict<string, number>([['a', 1]]);
assert.is(getAuthoritativeCells().length, 0);
d.retain();
Owner

Same comment about retaining a single key

Same comment about retaining a single key
Author
Member

Added for dict — retaining a single key. Commit 817b330.

Added for dict — retaining a single key. Commit 817b330.
sufian marked this conversation as resolved
@ -0,0 +72,4 @@
const backing = getModelDict(m);
assert.is(getAuthoritativeCells().length, 0);
backing.retain();
Owner

Same about a single model key

Same about a single model key
Author
Member

Added for model — retaining a single property. Commit 817b330.

Added for model — retaining a single property. Commit 817b330.
sufian marked this conversation as resolved
@ -0,0 +26,4 @@
>();
export function registerAuthoritative(
cell: object,
Owner

We should avoid the generic object type and use something more specific; not just here, across this change. We should avoid using overly broad types unless something is truly generic across any type, in which case we should rely on type paramaters.

We should avoid the generic `object` type and use something more specific; not just here, across this change. We should avoid using overly broad types unless something is truly generic across any type, in which case we should rely on type paramaters.
Author
Member

Addressed — introduced a named AuthoritativeCell type; the broad object is gone. Commit 817b330.

Addressed — introduced a named `AuthoritativeCell` type; the broad `object` is gone. Commit 817b330.
sufian marked this conversation as resolved
@ -0,0 +40,4 @@
const kind = cells.get(cell);
if (kind === undefined) return;
cells.delete(cell);
for (const listener of [...listeners]) {
Owner

Doesn't Set have an iterator? Do we need to [...listeners]?

Doesn't Set have an iterator? Do we need to `[...listeners]`?
Author
Member

Right — it now iterates the Set directly; the [...listeners] spread is gone. Commit 817b330.

Right — it now iterates the `Set` directly; the `[...listeners]` spread is gone. Commit 817b330.
sufian marked this conversation as resolved
@ -70,0 +79,4 @@
*/
export interface DictAuthoritativeOverride {
cell: object | null;
kind: AuthoritativeKind;
Owner

This is only ever going to be 'model' | 'dict', right?

This is only ever going to be `'model' | 'dict'`, right?
Author
Member

Correct — narrowed to 'dict' | 'model'. Commit 817b330.

Correct — narrowed to `'dict' | 'model'`. Commit 817b330.
sufian marked this conversation as resolved
@ -288,0 +324,4 @@
export function dict<K, V>(
entries: [K, V][] = [],
debugName?: string,
authoritativeOverride?: DictAuthoritativeOverride | undefined
Owner

By allowing passing the authoritativeOverride in to dict, we are exposing that to the public interface. I don't think this should be exposed like that. Is there a better way to do this? (Maybe we use new Dict() instead of dict in model.ts)

By allowing passing the `authoritativeOverride` in to `dict`, we are exposing that to the public interface. I don't think this should be exposed like that. Is there a better way to do this? (Maybe we use `new Dict()` instead of `dict` in model.ts)
Author
Member

Agreed — authoritativeOverride is no longer part of the public dict() signature. Commit 817b330.

Agreed — `authoritativeOverride` is no longer part of the public `dict()` signature. Commit 817b330.
sufian marked this conversation as resolved
@ -34,0 +37,4 @@
cell: null as object | null,
kind: 'model' as const,
};
const modelDict = dict(
Owner

Yeah, maybe use new Dict() here to avoid making the authoritativeOverride public

Yeah, maybe use `new Dict()` here to avoid making the authoritativeOverride public
Author
Member

Done — model.ts uses new Dict(...) internally to avoid the public override. Commit 817b330.

Done — `model.ts` uses `new Dict(...)` internally to avoid the public override. Commit 817b330.
sufian marked this conversation as resolved
@ -0,0 +19,4 @@
reset();
subscribe();
subscribeCommit();
});
Owner

Each test calls createTarget() and cleanup(); why not do that in beforeEach()?

Each test calls createTarget() and cleanup(); why not do that in beforeEach()?
Author
Member

Moved target/cleanup into beforeEach. Commit 817b330.

Moved target/cleanup into `beforeEach`. Commit 817b330.
sufian marked this conversation as resolved
@ -0,0 +505,4 @@
* Like `buildForkTree`, but with a `tags` collection mutated on both branches
* (push / splice / reverse / moveSlice) alongside the fields.
*/
function buildMixedForkTree(keyframeInterval: number) {
Owner

I think the tests would be better if they interacted with the mounted DOM to trigger behaviors (i.e. click buttons, set input values, etc...), instead of pulling x/p/q/tags out from the last render call of App. This will prevent us from accidentally manipulating state when App is unmounted.

I think the tests would be better if they interacted with the mounted DOM to trigger behaviors (i.e. click buttons, set input values, etc...), instead of pulling x/p/q/tags out from the last render call of App. This will prevent us from accidentally manipulating state when App is unmounted.
Owner

And similarly, they should assert state by asserting the contents of what's rendered.

And similarly, they should assert state by asserting the contents of what's rendered.
Author
Member

Rewritten — tests now render a component exposing buttons/inputs bound to the global cells, click/type, and assert the rendered output (commit 817b330). Both this comment and the follow-up are covered.

Rewritten — tests now render a component exposing buttons/inputs bound to the global cells, click/type, and assert the rendered output (commit 817b330). Both this comment and the follow-up are covered.
sufian marked this conversation as resolved
@ -0,0 +104,4 @@
unsubscribe: (() => void) | undefined;
}
const DEFAULT_KEYFRAME_INTERVAL = 24;
Owner

"keyframe" here is a flush()?

"keyframe" here is a flush()?
Author
Member

"keyframe" there is the keyframe interval — the number of flushes between dense snapshots — not a flush. One history node per flush(); a node is a keyframe when its depth since the last keyframe reaches the interval. I'll make that naming unambiguous.

"keyframe" there is the *keyframe interval* — the number of flushes between dense snapshots — not a flush. One history node per `flush()`; a node is a keyframe when its depth since the last keyframe reaches the interval. I'll make that naming unambiguous.
sufian marked this conversation as resolved
@ -0,0 +167,4 @@
function ensureListener(): void {
if (listenerRegistered) return;
listenerRegistered = true;
registerAfterFlush(() => {
Owner

Why do we need to do work after flush instead of initializing things here?

Why do we need to do work _after_ flush instead of initializing things here?
Author
Member

The snapshot must be read after the graph processes: a flush() batches several writes to final values (e.g. a collection records its whole array, not slice events). Reading at subscribe/init time would capture pre-flush/slice-level state. The per-flush event is emitted from an after-flush hook.

The snapshot must be read *after* the graph processes: a `flush()` batches several writes to final values (e.g. a collection records its whole array, not slice events). Reading at subscribe/init time would capture pre-flush/slice-level state. The per-flush event is emitted from an after-flush hook.
Owner

Please leave a comment here explaining why this is the case.

Also, do we ever need to unregister this handler? I'm wary of code that doesn't have a mechanism to clean up after itself.

Please leave a comment here explaining why this is the case. Also, do we ever need to unregister this handler? I'm wary of code that doesn't have a mechanism to clean up after itself.
Author
Member

Two things:

  1. Will do — I'll add an inline comment here explaining why the snapshot is read after the flush: a flush() batches a cell's writes to their final value (a collection records its whole array, not slice events), so reading at subscribe/init time would capture pre-flush / slice-level state.
  2. Yes, it's cleaned up. The after-flush listener is registered per session and its disposer is called on unsubscribe (afterFlushUnsubscribe?.()), alongside the registry add/remove listener and releasing the recorded cells — see statehistory.ts:424–434. There is no module-level permanent registration; I'll make that explicit in the comment too.
Two things: 1. Will do — I'll add an inline comment here explaining why the snapshot is read *after* the flush: a `flush()` batches a cell's writes to their final value (a collection records its whole array, not slice events), so reading at subscribe/init time would capture pre-flush / slice-level state. 2. Yes, it's cleaned up. The after-flush listener is registered **per session** and its disposer is called on unsubscribe (`afterFlushUnsubscribe?.()`), alongside the registry add/remove listener and releasing the recorded cells — see `statehistory.ts:424–434`. There is no module-level permanent registration; I'll make that explicit in the comment too.
@ -0,0 +106,4 @@
const DEFAULT_KEYFRAME_INTERVAL = 24;
const fields = new Map<string, BoundCell>();
Owner

fields is a misnomer here, since these are all bound cells

fields is a misnomer here, since these are all bound cells
Author
Member

Renamed to cells. Commit 817b330.

Renamed to `cells`. Commit 817b330.
sufian marked this conversation as resolved
@ -0,0 +231,4 @@
let isInitial = true;
bound.unsubscribe = bound.subscribe(() => {
if (isInitial) {
isInitial = false;
Owner

Doing things once on subscriptions is a code smell, is this necessary? Set adds are idempotent, so maybe this was just for perf?

Doing things once on subscriptions is a code smell, is this necessary? Set adds are idempotent, so maybe this was just for perf?
Author
Member

Removed the module-level one-time flag — the after-flush listener is now registered per session and torn down on unsubscribe. Commit 817b330.

Removed the module-level one-time flag — the after-flush listener is now registered per session and torn down on unsubscribe. Commit 817b330.
sufian marked this conversation as resolved
@ -0,0 +322,4 @@
/** Replaces a collection's contents in place, chunked to avoid spread limits. */
function replaceCollection<T>(cell: Collection<T>, value: unknown): void {
const items = Array.isArray(value) ? (value as T[]) : [];
Owner

We should avoid casting where possible

We should avoid casting where possible
Author
Member

Reduced — the model cast is gone (the id now comes straight from __debugName, commit 4702b7a). One cast remains where the heterogeneous registry is dispatched to a concrete kind; I'll see whether the cellBuilders[kind] typing can absorb it.

Reduced — the model cast is gone (the id now comes straight from `__debugName`, commit 4702b7a). One cast remains where the heterogeneous registry is dispatched to a concrete kind; I'll see whether the `cellBuilders[kind]` typing can absorb it.
Owner

Ok, let me know what happens and if there are other casts we are performing that could be cleaned up

Ok, let me know what happens and if there are other casts we are performing that could be cleaned up
Author
Member

Will report back. Current state: the only new casts in the history/registry code are the four kind-dispatch casts at statehistory.ts:294–297 (cell as Field<unknown>, etc.) plus one value as T[] at :349. Those four are inherent to a heterogeneous Map<id, AuthoritativeCell> resolved to a concrete kind; I'll see whether a per-kind map or a generic cell type can absorb them. The other as casts you may notice in the diff (collection.ts / model.ts / field.ts) are pre-existing Gooey code, not from this change.

Will report back. Current state: the only *new* casts in the history/registry code are the four kind-dispatch casts at `statehistory.ts:294–297` (`cell as Field<unknown>`, etc.) plus one `value as T[]` at `:349`. Those four are inherent to a heterogeneous `Map<id, AuthoritativeCell>` resolved to a concrete kind; I'll see whether a per-kind map or a generic cell type can absorb them. The other `as` casts you may notice in the diff (`collection.ts` / `model.ts` / `field.ts`) are pre-existing Gooey code, not from this change.
@ -0,0 +419,4 @@
}
/**
* A session-stable name for an unbound cell: its `__debugName` when meaningful
Owner

Does this mean that if a unique id is not passed in, we still get the benefits of being able to restore to a previous point in time and then move back? I suspect this may not work well

Does this mean that if a unique id is not passed in, we still get the benefits of being able to restore to a previous point in time and then move back? I suspect this may not work well
Author
Member

Per your direction: recording now requires a unique id. A cell with a default (or duplicate) id is excluded from recording entirely, and duplicates warn (commit 5cbda36) — so it gets no partial restore.

Per your direction: recording now requires a unique id. A cell with a default (or duplicate) id is **excluded from recording entirely**, and duplicates warn (commit 5cbda36) — so it gets no partial restore.
sufian marked this conversation as resolved
@ -0,0 +453,4 @@
if (cellNames.has(cell)) return;
const name = deriveName(cell, kind);
let bound: BoundCell;
switch (kind) {
Owner

This probably could be a dict lookup; like: lookup[kind](cell)

This probably could be a dict lookup; like: `lookup[kind](cell)`
Author
Member

Done — cellBuilders[kind](cell) lookup. Commit 817b330.

Done — `cellBuilders[kind](cell)` lookup. Commit 817b330.
sufian marked this conversation as resolved
@ -22,1 +31,4 @@
);
// Re-label all children so unkeyed siblings track their
// post-splice positions.
relabelContainer(renderNode);
Owner

Why do we need to do this?

Why do we need to do this?
Author
Member

Reverted to main — it only existed for the (now-dropped) component-identity labeling. Commit 58c6e8e.

Reverted to `main` — it only existed for the (now-dropped) component-identity labeling. Commit 58c6e8e.
sufian marked this conversation as resolved
@ -51,0 +54,4 @@
// the DOM (as a bogus attribute). Preferring keys over sibling position is
// what makes identities stable across list reorders; see
// `viewcontroller/identity.ts`.
let key: unknown;
Owner

I'm having second thoughts about key

The whole point of key was to be able to do:

<div>
  {items.mapView((item) => <Item key={item.key} item={item} />)}
</div>

So that the Item component could pass in a globally unique/stable key so that it could use the field and other authoritative cells passed in.

However, not all components will need to use those. Only components that define authoritative state.

So the user is forced to pay the cost of adding these keys even if they aren't needed.

I'd like to take a smaller change (just the recording and restore), so that we don't have to deal with changing the component interface. I realize this is backing off a lot from our plan, but I'd rather add things incrementally.

I'm having second thoughts about `key` The whole point of key was to be able to do: ``` <div> {items.mapView((item) => <Item key={item.key} item={item} />)} </div> ``` So that the `Item` component could pass in a globally unique/stable `key` so that it could use the `field` and other authoritative cells passed in. However, not all components will need to use those. Only components that define authoritative state. So the user is forced to pay the cost of adding these keys even if they aren't needed. I'd like to take a smaller change (just the recording and restore), so that we don't have to deal with changing the component interface. I realize this is backing off a lot from our plan, but I'd rather add things incrementally.
Author
Member

Agreed and reverted — the whole component-interface change is gone: no key, no lifecycle.id, no factories; createelement/jsx/mount/render nodes match main (commit 58c6e8e). History now runs over global unique-id cells.

Agreed and reverted — the whole component-interface change is gone: no `key`, no `lifecycle.id`, no factories; `createelement`/`jsx`/`mount`/render nodes match `main` (commit 58c6e8e). History now runs over global unique-id cells.
sufian marked this conversation as resolved
sufian left a comment

Please see comments

Please see comments
Drop key consumption/typing, mount options, lifecycle.id and the bound
field/collection/dict/model constructors; restore the viewcontroller and
modelview files to main. Delete identity.ts/identity.test.tsx and the
lifecycle-bound statehistory tests. History keeps recording global
authoritative cells only.
Recorder now covers global authoritative cells keyed by their unique id
(debug name): cells with a default debug name or a duplicate id are
excluded entirely, duplicates warn. Remove the bound constructors.
Dict/model liveness is registered from the backing TrackedData's
lifecycle (reads retain the TrackedData, not the Dict), so dicts and
models register on read like collections do. Rewrite the history tests to
drive behaviour through the mounted DOM (buttons) and assert rendered
state.
registerReset now returns an unregister function and a clearResetCallbacks()
exists for tests. The state-history after-flush listener is registered per
session (and unregistered on unsubscribe/reset) instead of a module-level
once flag. Add inline notes clarifying one-event-per-flush vs keyframe
placement and why values are snapshotted after the flush. Add registry
coverage for retaining a single collection item / dict key / model property.
The headless runner moved into @srhazi/gooey-test; drop ci/test-headless.mjs,
s/test-headless, the @playwright/test devDependency, the test:headless
script and the lockfile entries. Remove docs/increment-2-replay.md (session
notes, not for commit).
Every authoritative kind (including the model proxy) now exposes its
unique id as __debugName, so cellDebugName needs no per-kind cast.
@ -0,0 +10,4 @@
import { getModelDict, model } from './model';
/**
* Gooey state-history (SPIKE)
Owner

Drop the "SPIKE" language here, this is meant to be real implementation of this.

Drop the "SPIKE" language here, this is meant to be real implementation of this.
Author
Member

Agreed — I'll drop the "SPIKE" framing and write this as the real implementation (module header + comments). No changes made yet, per your instruction.

Agreed — I'll drop the "SPIKE" framing and write this as the real implementation (module header + comments). No changes made yet, per your instruction.
sufian marked this conversation as resolved
@ -0,0 +13,4 @@
* Gooey state-history (SPIKE)
* ---------------------------
*
* Records changes to *authoritative global state* as a stream of batched,
Owner

I'd rewrite this as:

Record changes to authoritative gooey state cells (field, collection, dict, model) as a stream events forming a history tree.

Each node in the tree corresponds to a flush() (called manually or automatically). The consumer may move the HEAD pointer to any node via revertTo() with writes after a revert forking the tree.

For authoritative gooey state cells to participate in recording, they must be given an explicit unique id (their debug name):

  • field(0, 'x')
  • collection([], 'tags')
  • dict([], 'attrs')
  • model({...}, 'prog')

Each live cell is held in a registry. When recording starts, these cells are retained and not released until recording stops.

Cells that do not have a unique id are NOT recorded. If a cell is created that shares the same id as another cell, it is treated as the same underlying authoritative state (while recording).

Every keyframeInterval a full snapshot is taken, which is a name -> value of each cell. Changes between keyframes are sparse forward-only deltas. revertTo first applies the nearest full snapshot, and replays deltas until the target is reached.

Changes are recorded only on flush, so values written to same cell multiple times only have the last written value recorded.


A question about this:

If a cell is created that shares the same id as another cell, it is treated as the same underlying authoritative state (while recording).

Is this true?

I'd rewrite this as: Record changes to authoritative gooey state cells (field, collection, dict, model) as a stream events forming a history tree. Each node in the tree corresponds to a `flush()` (called manually or automatically). The consumer may move the `HEAD` pointer to any node via `revertTo()` with writes after a revert forking the tree. For authoritative gooey state cells to participate in recording, they must be given an explicit unique id (their debug name): - `field(0, 'x')` - `collection([], 'tags')` - `dict([], 'attrs')` - `model({...}, 'prog')` Each live cell is held in a registry. When recording starts, these cells are retained and not released until recording stops. Cells that do not have a unique id are NOT recorded. If a cell is created that shares the same id as another cell, it is treated as the same underlying authoritative state (while recording). Every `keyframeInterval` a full snapshot is taken, which is a `name -> value` of each cell. Changes between keyframes are sparse forward-only deltas. `revertTo` first applies the nearest full snapshot, and replays deltas until the target is reached. Changes are recorded only on flush, so values written to same cell multiple times only have the last written value recorded. --- A question about this: > If a cell is created that shares the same id as another cell, it is treated as the same underlying authoritative state (while recording). Is this true?
Author
Member

Your rewrite reads well; I'll adopt it (sans "SPIKE"). One correction on the duplicate-id sentence: it is not currently true that two cells sharing an id are treated as the same state. Per your earlier direction, a duplicate id is treated as a programming error — the later cell is excluded from recording and a warning is logged (commit 5cbda36); it is not aliased to the first. Everything else matches: nodes are per-flush(), the registry retains live cells for the session, keyframeInterval dense snapshots with forward-only sparse deltas between, and only the last value per cell per flush is recorded.

Question back: for duplicates, do you want exclude + warn (current) or true aliasing (same id ⇒ same underlying state)?

Your rewrite reads well; I'll adopt it (sans "SPIKE"). One correction on the duplicate-id sentence: it is **not** currently true that two cells sharing an id are treated as the same state. Per your earlier direction, a duplicate id is treated as a programming error — the later cell is **excluded from recording** and a warning is logged (commit 5cbda36); it is not aliased to the first. Everything else matches: nodes are per-`flush()`, the registry retains live cells for the session, `keyframeInterval` dense snapshots with forward-only sparse deltas between, and only the last value per cell per flush is recorded. Question back: for duplicates, do you want **exclude + warn** (current) or true **aliasing** (same id ⇒ same underlying state)?
Owner

Ah, I think the current behavior (excluding the later cell from recording) is more surprising than aliasing.

I have a question though: since we're already keeping track of live id -> cell values even when not recording, how difficult would it be to make it so that two live cell values alias to the same underlying state? If it's trivial, we should do that, it makes a consistent rule (all authoritative state that has an id refers to the same underlying state). I don't think it needs to be that a === b if both have the same id, but it would be a nice bonus if that was simple to implement.

Ah, I think the current behavior (excluding the later cell from recording) is more surprising than aliasing. I have a question though: since we're already keeping track of live id -> cell values _even when not recording_, how difficult would it be to make it so that two live cell values alias to the same underlying state? If it's trivial, we should do that, it makes a consistent rule (all authoritative state that has an id refers to the same underlying state). I don't think it needs to be that `a === b` if both have the same id, but it would be a nice bonus if that was simple to implement.
Author
Member

Agreed that exclude+warn is the more surprising rule; aliasing is more consistent with "an id names one piece of authoritative state".

Difficulty: the a === b form is modest but it is a core-semantics change, so I want to flag the trade before doing it.

Since every authoritative cell is made by a factory (field/dict/collection/model), the simple rule is: when an explicit id is given and a cell with that id already exists, the factory returns the existing cell instead of creating a new one — that yields a === b for free. Pieces:

  • a global id -> cell map consulted by the four factories (only for explicit ids — never the default 'field'/'dict' names, or every anonymous cell would alias to the previous one);
  • clean the entry when the cell dies (refcount 0) or the map leaks; cells created-but-not-yet-retained need care since the registry is live-only today.

The non-=== version (two distinct objects sharing one underlying store, like model over a dict) is more work — it needs a proxying layer per kind.

So the a === b route is a small change (a lookup + map lifecycle in the four constructors) with the nicer rule; the cost is that it changes the meaning of the public constructors for all Gooey users, not just recording. If that's acceptable, I'd do the a === b form.

One question before implementing: should a same id across kinds be an error (e.g. field(0, 'x') and collection([], 'x'))? I'd lean error — an id should be unique, and cross-kind reuse is almost certainly a bug.

Agreed that exclude+warn is the more surprising rule; aliasing is more consistent with "an id names one piece of authoritative state". Difficulty: the **`a === b` form is modest but it is a core-semantics change**, so I want to flag the trade before doing it. Since every authoritative cell is made by a factory (`field`/`dict`/`collection`/`model`), the simple rule is: when an **explicit** id is given and a cell with that id already exists, the factory returns the **existing** cell instead of creating a new one — that yields `a === b` for free. Pieces: - a global `id -> cell` map consulted by the four factories (only for *explicit* ids — never the default `'field'`/`'dict'` names, or every anonymous cell would alias to the previous one); - clean the entry when the cell dies (refcount 0) or the map leaks; cells created-but-not-yet-retained need care since the registry is live-only today. The non-`===` version (two distinct objects sharing one underlying store, like `model` over a `dict`) is **more** work — it needs a proxying layer per kind. So the `a === b` route is a small change (a lookup + map lifecycle in the four constructors) with the nicer rule; the cost is that it changes the meaning of the public constructors for *all* Gooey users, not just recording. If that's acceptable, I'd do the `a === b` form. One question before implementing: should a same id **across kinds** be an error (e.g. `field(0, 'x')` and `collection([], 'x')`)? I'd lean **error** — an id should be unique, and cross-kind reuse is almost certainly a bug.
sufian left a comment

Please respond to each thread.

Please respond to each thread.
Owner

Thanks, I've left a few more comments, can you reply to those?

Thanks, I've left a few more comments, can you reply to those?
Author
Member

⏸ Resume notes — session paused

Pausing here. This is what is not inferable from the MR/diff, for whoever (or whatever) resumes.

Open decisions (blocking the next code change)

  1. Duplicate ids — aliasing vs exclude+warn. Current revision 2 treats a duplicate id as a programming error: the later cell is excluded from recording and a warning is logged. You'd prefer aliasing (same id ⇒ same underlying state), ideally a === b. Not decided/implemented.
  2. ⚠️ The crux you flagged (pressure-test before implementing aliasing): a === b requires resolving an id at construction time — before a cell is retained — which means a registry of all constructed authoritative cells, not the current live-only registry. That's a different (and, unless GC'd, unbounded) structure. Open question: does aliasing force a construction-time id → cell map, and how is it reclaimed? (Constructed-but-never-retained cells, GC on __dead, etc.)
  3. Cross-kind same id (field(0,'x') vs collection([],'x')) — error? (proposal: error).
  4. Small follow-ups already agreed in-thread: adopt your rewritten module header (minus "SPIKE"); add an inline comment explaining the read-after-flush; the cid-102 cast cleanup.

Environment / dev loop (not in the repo)

  • Worktree: /Users/sufian/development/2026/gooey-identity (branch wip/identity-replay).
  • Its node_modules/@srhazi/gooey-test is symlinked to the gooey-test worktree /Users/sufian/development/2025/gooey-test-headless (self-contained; chromium installed).
  • Run tests:
    node --preserve-symlinks node_modules/@srhazi/gooey-test/dist/headless/cli.mjs --cwd "$PWD" --port 5173 --strict-port <files>
    → currently 523 tests / 20 files, exit 0. Exclude src/performance.test.tsx (needs --expose-gc).
  • There is an uncommitted node_modules/.headless.vite.config.ts (an fs.allow hack) that is no longer needed after the default-URL fix — safe to delete; node_modules is gitignored.
  • Commit author on this branch is sufian+orchestrator@gmail.com.

Cross-repo gate

  • The headless runner now lives in gooey-test PR #1. Gooey currently has no test:headless script (removed with the runner) — it needs a small script using @srhazi/gooey-test/headless after that PR lands/publishes.

Design lives outside the repo

  • docs/increment-2-replay.md was intentionally removed (session notes, per review). The full design — history tree, per-flush nodes, fork-on-write-after-revert, keyframeInterval dense snapshots + forward-only sparse deltas, the live authoritative registry, unique-id-only recording — lives in this PR's discussion + session notes only, not in the code.
## ⏸ Resume notes — session paused Pausing here. This is what is **not** inferable from the MR/diff, for whoever (or whatever) resumes. ### Open decisions (blocking the next code change) 1. **Duplicate ids — aliasing vs exclude+warn.** Current revision 2 treats a duplicate id as a programming error: the later cell is **excluded from recording** and a warning is logged. You'd prefer **aliasing** (same id ⇒ same underlying state), ideally `a === b`. **Not decided/implemented.** 2. **⚠️ The crux you flagged (pressure-test before implementing aliasing):** `a === b` requires resolving an id at **construction** time — *before* a cell is retained — which means a registry of **all constructed** authoritative cells, not the current **live-only** registry. That's a different (and, unless GC'd, unbounded) structure. Open question: does aliasing force a construction-time `id → cell` map, and how is it reclaimed? (Constructed-but-never-retained cells, GC on `__dead`, etc.) 3. **Cross-kind same id** (`field(0,'x')` vs `collection([],'x')`) — error? (proposal: error). 4. **Small follow-ups already agreed in-thread:** adopt your rewritten module header (minus "SPIKE"); add an inline comment explaining the read-after-flush; the cid-102 cast cleanup. ### Environment / dev loop (not in the repo) - Worktree: **`/Users/sufian/development/2026/gooey-identity`** (branch `wip/identity-replay`). - Its `node_modules/@srhazi/gooey-test` is **symlinked** to the gooey-test worktree `/Users/sufian/development/2025/gooey-test-headless` (self-contained; chromium installed). - Run tests: `node --preserve-symlinks node_modules/@srhazi/gooey-test/dist/headless/cli.mjs --cwd "$PWD" --port 5173 --strict-port <files>` → currently **523 tests / 20 files, exit 0**. Exclude `src/performance.test.tsx` (needs `--expose-gc`). - There is an uncommitted `node_modules/.headless.vite.config.ts` (an `fs.allow` hack) that is **no longer needed** after the default-URL fix — safe to delete; `node_modules` is gitignored. - Commit author on this branch is `sufian+orchestrator@gmail.com`. ### Cross-repo gate - The headless runner now lives in **gooey-test PR #1**. Gooey currently has **no `test:headless` script** (removed with the runner) — it needs a small script using `@srhazi/gooey-test/headless` **after** that PR lands/publishes. ### Design lives outside the repo - `docs/increment-2-replay.md` was **intentionally removed** (session notes, per review). The full design — history tree, per-flush nodes, fork-on-write-after-revert, keyframeInterval dense snapshots + forward-only sparse deltas, the live authoritative registry, unique-id-only recording — lives in **this PR's discussion + session notes only**, not in the code.
Author
Member

⏸ Resume notes — session 102 (paused; fresh eyes later)

Status: the spike is working; a few small bugs the lead saw are still to be enumerated/fixed. 609 tests / 23 files green; tsc clean. Commits on wip/identity-replay: 1b925cc (core), 10d7667 (demos overlay).

What changed since the last resume note

Core — shared backing per explicit id (model (iv)).

  • The live authoritative registry is the id index. Explicit, non-default ids name one live backing; field/dict are consumer facade + backing; collection/model resolve. a !== b.
  • Resolve-on-use: a retaining tracked read (isRetainingRead()) or an explicit retain()/subscribe() resolves; bare get()/set() do not.
  • A retaining read adopts the live backing (so read-then-write propagates — the initial render renders tree core test). The arousal warning fires on adopt (incl. the read path).
  • An explicit release detaches by minting a fresh private backing (gated !__anonymous); a dead cell writes locally. Anonymous/transient cells are unchanged (no register/adopt/repoint).
  • Cross-kind id reuse = error; recorder re-keyed on backings; hotSwap short-circuits shared explicit-id cells.
  • Design: statehistory also gained a non-destructive stop (stop = stop only; history + backing pin survive; revertTo works while stopped), getRoot(), and a tight public namespace export.

Demos — record/replay overlay.

  • A shared overlay is wired into all demos (mountWithOverlay): Record toggle, revert slider, Branch select; bottom-left, collapsible; overlay state anonymous (never recorded); dev-gated (DEBUG); demo stays interactive; revert is non-destructive (edits after a revert fork).
  • Demo authoritative state got explicit per-instance ids so recording/scrubbing restores the visible UI; derived caches stay anonymous.
  • Fixes landed: branch select (<option selected> rather than a value binding — Gooey applies props before children), 7-guis sub-app restore (stable per-app ids), Cells derived-cache rebuild on remount, uncontrolled inputs (todo/flash-test) moved into bound cells.
  • Verify with ./s/server (Vite, DEBUG: true).

Decisions (lead-ratified)

  • No a === b; shared underlying state, distinct cells. Cross-kind = error. Default ids never share/register. Dead-cell arousal (first-live wins, second adopts+warns).
  • (iv): canonical state is separate from any cell's private backing; reads adopt; detach mints a fresh copy; !__anonymous gate.
  • Read-then-write propagates is required.
  • Overlay: stop = stop only; start = fresh session; revert = HEAD move; edits after revert fork.
  • Major breaking change → release notes + version.

Open / next session

  • Enumerate and fix the small bugs the lead saw (not yet listed).
  • Cells app records per-cell isEditing for ~2574 cells (large keyframes); possibly derive it from Cells.state.
  • Overlay polish: --dh-faint caption contrast, motion budget, O(n)-per-event tree rebuild.
  • model.retain/release (expose or ratify subscribe-only).
  • Release: major version + notes; regenerate stale build/; update reactivity-patterns/ui-state guard docs.
  • Consumer docs for the public statehistory surface + stop/reset/pin semantics.

Environment

  • Worktree /Users/sufian/development/2026/gooey-identity; node_modules/@srhazi/gooey-test symlinked to /Users/sufian/development/2025/gooey-test-headless (restore it if a concurrent npm install clobbers it).
  • Tests: node --preserve-symlinks node_modules/@srhazi/gooey-test/dist/headless/cli.mjs --cwd "$PWD" --port 5173 --strict-port -x 'src/performance.test.tsx'.
  • Full design lives in the store scratch orchestrator/tmp/gooey-identity/ (shared-state-spec.md authoritative) + this PR.
## ⏸ Resume notes — session 102 (paused; fresh eyes later) Status: the spike is working; a few **small bugs** the lead saw are still to be enumerated/fixed. 609 tests / 23 files green; `tsc` clean. Commits on `wip/identity-replay`: `1b925cc` (core), `10d7667` (demos overlay). ### What changed since the last resume note **Core — shared backing per explicit id (model (iv)).** - The live authoritative registry **is** the id index. Explicit, non-default ids name one live backing; `field`/`dict` are consumer facade + backing; `collection`/`model` resolve. `a !== b`. - **Resolve-on-use**: a **retaining** tracked read (`isRetainingRead()`) or an explicit `retain()`/`subscribe()` resolves; bare `get()`/`set()` do not. - **A retaining read adopts** the live backing (so read-then-write propagates — the `initial render renders tree` core test). The arousal warning fires on adopt (incl. the read path). - **An explicit release detaches** by minting a fresh private backing (gated `!__anonymous`); a dead cell writes locally. Anonymous/transient cells are unchanged (no register/adopt/repoint). - Cross-kind id reuse = error; recorder re-keyed on backings; hotSwap short-circuits shared explicit-id cells. - Design: `statehistory` also gained a **non-destructive stop** (stop = stop only; history + backing pin survive; `revertTo` works while stopped), **`getRoot()`**, and a tight public namespace export. **Demos — record/replay overlay.** - A shared overlay is wired into all demos (`mountWithOverlay`): **Record toggle, revert slider, Branch select**; bottom-left, collapsible; overlay state anonymous (never recorded); dev-gated (`DEBUG`); demo stays interactive; revert is non-destructive (edits after a revert fork). - Demo authoritative state got explicit per-instance ids so recording/scrubbing restores the visible UI; derived caches stay anonymous. - Fixes landed: branch select (`<option selected>` rather than a `value` binding — Gooey applies props before children), 7-guis sub-app restore (stable per-app ids), Cells derived-cache rebuild on remount, uncontrolled inputs (todo/flash-test) moved into bound cells. - Verify with `./s/server` (Vite, `DEBUG: true`). ### Decisions (lead-ratified) - No `a === b`; shared underlying state, distinct cells. Cross-kind = error. Default ids never share/register. Dead-cell arousal (first-live wins, second adopts+warns). - (iv): canonical state is separate from any cell's private backing; reads adopt; detach mints a fresh copy; `!__anonymous` gate. - Read-then-write propagates is required. - Overlay: stop = stop only; start = fresh session; revert = HEAD move; edits after revert fork. - Major breaking change → release notes + version. ### Open / next session - **Enumerate and fix the small bugs the lead saw** (not yet listed). - Cells app records per-cell `isEditing` for ~2574 cells (large keyframes); possibly derive it from `Cells.state`. - Overlay polish: `--dh-faint` caption contrast, motion budget, O(n)-per-event tree rebuild. - `model.retain`/`release` (expose or ratify subscribe-only). - Release: major version + notes; regenerate stale `build/`; update reactivity-patterns/ui-state guard docs. - Consumer docs for the public `statehistory` surface + stop/reset/pin semantics. ### Environment - Worktree `/Users/sufian/development/2026/gooey-identity`; `node_modules/@srhazi/gooey-test` symlinked to `/Users/sufian/development/2025/gooey-test-headless` (restore it if a concurrent `npm install` clobbers it). - Tests: `node --preserve-symlinks node_modules/@srhazi/gooey-test/dist/headless/cli.mjs --cwd "$PWD" --port 5173 --strict-port -x 'src/performance.test.tsx'`. - Full design lives in the store scratch `orchestrator/tmp/gooey-identity/` (`shared-state-spec.md` authoritative) + this PR.
Explicit, non-default ids now name one live backing in the authoritative
registry. field/dict split into a consumer facade + backing; collection/model
resolve. A retaining read resolves-and-adopts the canonical backing; an
explicit release detaches by minting a fresh private backing. Anonymous cells
are unchanged; cross-kind id reuse is an error. Recorder re-keyed on backings;
hotSwap short-circuits shared explicit-id cells.

588 tests / 21 files green; tsc clean.
Adds a shared debug overlay to every demo (start/stop recording, revert
slider, branch select), backed by public statehistory tree/HEAD accessors
and a non-destructive stop (history + pin survive; revert works while
stopped). Gives the demos' authoritative state explicit, per-instance ids
so recording and scrubbing restore the visible UI; derived caches stay
anonymous. Fixes: branch select (option selected, not a value binding),
7-guis sub-app restore (stable ids), and the Cells derived-cache rebuild.

609 tests / 23 files green; tsc clean. Spike; provisional.
This branch is already included in the target branch. There is nothing to merge.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin wip/identity-replay:wip/identity-replay
git switch wip/identity-replay

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff wip/identity-replay
git switch wip/identity-replay
git rebase main
git switch main
git merge --ff-only wip/identity-replay
git switch wip/identity-replay
git rebase main
git switch main
git merge --no-ff wip/identity-replay
git switch main
git merge --squash wip/identity-replay
git switch main
git merge --ff-only wip/identity-replay
git switch main
git merge wip/identity-replay
git push origin main
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
legend/gooey!1
No description provided.