LearnNewsExamplesServices
Frontmatter
id18494
titleMake RevealStateMachine use Neo lifecycle and config primitives
stateClosed
labels
bugairefactoringcoredashboard
assigneesneo-gpt
createdAtSep 8, 2026, 10:32 PM
updatedAtSep 9, 2026, 10:31 AM
githubUrlhttps://github.com/neomjs/neo/issues/18494
authorneo-gpt
commentsCount0
parentIssue18304
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 9, 2026, 10:31 AM

Make RevealStateMachine use Neo lifecycle and config primitives

Closed v13.2.0/archive-v13-2-0-chunk-20 bugairefactoringcoredashboard
neo-gpt
neo-gpt commented on Sep 8, 2026, 10:32 PM

Part of #18304. Follow-up to #18393.

Context

Operator-requested repair after the declarative host landed: RevealStateMachine should use the engine it extends. The earlier registration cut made overwrites/subclassing reach the owner; it did not replace the parallel scheduling and config machinery inside it. This ticket owns that remaining implementation quality and its demonstrated teardown defect, in one PR.

Source checked against merged f5c0208ae2cd7ee4c2663d1fd8ec71c186ef1321. RevealStateMachine's blob is 3fc4854de6f1bc705e9950832dc9f45ddaa8003d. Both src/core/Base.mjs and src/Neo.mjs were read in full before choosing this scope.

The Problem

The machine exposes setTimeoutFn/clearTimeoutFn, stores timerId, resets it in two callbacks, cancels it through clearTimer, and repeats timer cleanup in destroy. Its waits bypass Base's async ownership. Its constructor separately filters raw arguments before Neo processes effective configs.

This is more than appearance. An isolated Node probe with a controlled native timer queue reproduced:

Probe Current result
Ordinary reveal dwell One pending timer; zero calls to Base.registerAsync
Base.timeout control One pending timer; one registration; destruction clears it and rejects with Neo.isDestroyed
Destroy machine from its dwell-pending onChange notification Machine destroyed, then one new timer scheduled; timerId recreated on the destroyed object
Deliver that queued callback It writes timerId=null after destruction
Explicit dwellMs=NaN / graceMs=Infinity Restored to 150 / 300
The same invalid values in a subclass config Both remain non-finite

The teardown trigger is small:

let machine;
machine = Neo.create(RevealStateMachine, {
    revealOnHover: true,
    onChange: () => machine.destroy()
});
await machine.ready();
machine.tabHoverIn('editor');
// transition notified the consumer, which destroyed the owner;
// tabHoverIn nevertheless continued and scheduled another timer.

No visible-user regression or latency rate is claimed beyond these observations.

The Architectural Reality

  • Base.timeout, registerAsync/unregisterAsync, and Base.destroy already provide instance-owned wait/teardown behavior. Neo.isDestroyed is the engine cancellation sentinel; Neo's browser/Node rejection handling recognizes that exact value.
  • Neo.create/createConfig, setupClass, Base config hooks, set and beforeSetEnumValue provide effective config processing, inheritance/overwrites, validation and batched state updates. Raw-constructor normalization sees only one of those entry paths.
  • Base also offers delayable; inspect util/Function.mjs before selecting it. Its buffer has cancellation, but it does not register with Base's async map. Likewise, Base.timeout returns a promise, not a public per-wait timer handle. Neither spelling is a drop-in proof of correct retarget cancellation.
  • Rail.createRevealMachine already creates one owner with Neo.create and retains teardown responsibility. Rail forwards live dwell/grace and hover-policy updates. That ownership and the existing input methods remain.
  • The transition policy is legitimate domain logic: dwell/grace, hover opt-in, focus hold, retargeting and one coherent next/previous snapshot. This work removes duplicated engine machinery, not the policy.

The Fix

Refactor the whole class against those engine primitives. Remove the parallel injectable scheduler/canceller configuration and redundant lifecycle bookkeeping. Route delayed work through inherited async ownership; retain only the transition identity needed to cancel or supersede domain intent. Choose the scheduling form after proving cancellation, retarget and destruction behavior, rather than mechanically replacing function names or moving the same timer manager into another helper.

Move policy validation to the appropriate Neo config lifecycle so instance input, inherited/overwritten defaults and Rail's live updates obey the same documented rules. Review state vocabulary, batched mutation and notification against Base/Neo facilities: keep one coherent transition publication, and do not introduce duplicate notifications by blindly making each field reactive.

Keep asynchronous continuations as overridable owner methods where useful. Reentrant notifications must not allow the initiating input to schedule or publish after the owner is destroyed or its intent superseded. Adapt deterministic tests around the inherited engine seam; production must not carry a second clock API solely for tests.

Readability is part of the repair: remove obsolete constructor/default/timer scaffolding and repetitive explanations. Keep concise method intent and the transition contract; do not substitute cosmetic line reduction for ownership removal.

Scoped kernel prerequisite

The early-cancellation gap is now reproduced: Base.timeout(60000, {signal}) ignores an abort and leaves the native timer pending while its owner lives. Reveal retargeting needs that wait to retire, not merely lose permission to publish. Existing buffer/trap/unregisterAsync alternatives either retain separate cleanup or fail to cancel the producer.

Scope one optional, backward-compatible argument on the existing primitive: Base.timeout(time, {signal} = {}).

  • With no signal, retain existing scheduling, completion and Neo.isDestroyed teardown semantics.
  • A pre-aborted signal schedules/registers nothing and rejects with signal.reason.
  • An active abort clears the native timeout, unregisters the wait, detaches the listener and rejects with signal.reason.
  • Completion and owner destruction detach the abort listener too; destruction still rejects with Neo.isDestroyed.
  • No timer IDs, custom Promise properties, additional scheduler class or global registry become public.

The reveal owner retains one AbortController as transition identity, uses this inherited wait, and fences the continuation against superseded intent. Its post-notification liveness check still prevents starting work after reentrant destruction; this does not redefine Base calls made after destruction.

This is the explicitly scoped contract anticipated by the original ticket's kernel-gap clause. Routing: ticket-intake / local reversible change; no new subsystem, breaking API, daemon or persistence contract, so no architectural-sandbox graduation is needed. Existing core timeout tests own the kernel controls; reveal/Rail tests own the consumer behavior.

Contract Ledger

Surface Authority Required behavior Refusal / boundary Docs Evidence
Base.timeout optional signal Existing Base async registration/teardown Per-wait abort with reason; cleanup on every terminal; old calls unchanged Owner destruction remains Neo.isDestroyed Base JSDoc Pre-abort, abort/completion/destroy order, sibling isolation and listener cleanup
Dwell/grace scheduling Base async lifetime; reveal policy Engine-owned waits, correct cancellation and retargeting No post-destroy work or stale intent publication Class/method JSDoc Real inherited-path registration/teardown controls
Effective policy configs Neo config processing Defaults 150/300, hover off; consistent invalid-value handling across construction/inheritance/overwrite/live update Preserve documented fallback or identify a deliberate refusal; no native timer coercion Config JSDoc Same-value cross-entry controls
State and notification Existing reveal state table Exactly one coherent next/previous snapshot per effective change No partial pair, duplicate notification or reentrant resurrection Transition JSDoc Click/hover/focus/retarget and reentrant tests
Rail ownership/customization Existing registered owner contract One replaceable instance per Rail; pre-import overwrites still execute No singleton, wrapper owner or parallel raw constructor Rail creation seam Real Rail and two-owner isolation
Test clock surface Existing engine methods Deterministic timing without scheduler-function configs on production machine Do not replace one handwritten timer subsystem with another Test helper intent Negative control fails when inherited ownership is bypassed

Acceptance Criteria

  • AC-1: The production reveal path uses Base-owned async lifetime; the parallel setTimeoutFn/clearTimeoutFn mechanism and redundant cleanup are removed. A behavior-bearing control proves the inherited primitive actually executes; extends Base / setupClass token presence is insufficient.

  • AC-2: Preserve click toggle, hover opt-in, dwell, grace return, focus hold, hover/click retarget, Escape/outsideClick and itemCleared behavior, timings and coherent onChange snapshots.

  • AC-3: Cover explicit config, subclass defaults, pre-import Neo.overwrites and live Rail updates. Invalid timing/policy handling is consistent across these paths; the raw-constructor-only bypass is gone.

  • AC-4: Destroying from a transition notification leaves no newly scheduled work or post-destroy field writes. Retarget/cancel/clear races cannot publish obsolete intent. Cover both dwell and dismiss, plus repeated replacement of pending work; no unbounded obsolete waits.

  • AC-5: Two Rails remain independent; retiring one cancels only its work. Registered identity, method overwrites and the single factory extension point still operate through a real Rail.

  • AC-6: Deterministic tests exercise inherited timer/teardown behavior and retain the transition assertions. Run focused reveal/Rail units and the real-browser reveal suites; include a negative control and an exact-head rendered receipt.

  • AC-7: The PR names the deleted custom responsibilities and explains any surviving domain state. Source/JSDoc is coherent with the final implementation, with no second generic state-machine or timer service added.

  • AC-8: The scoped optional signal contract is covered at the actual Base primitive: no-signal compatibility, pre-abort with no timer/registration, independent sibling waits, listener/registration cleanup on completion/abort/destruction, and no second settlement on a late terminal.

Validation

Read the repository testing guidance. Focused units:

npx playwright test -c test/playwright/playwright.config.unit.mjs DockRevealStateMachine.spec.mjs DockRail.spec.mjs

Then the dashboard unit suite and relevant component/dashboard/DockRailReveal*, DockReveal* and DockRailLazyModule browser cases through the explicit component config. Preserve existing assertions; add the reentrant teardown and effective-config controls above.

Decision Record impact

Aligned-with ADR 0029 §2.7. Reveal interaction semantics and the boundary excluding document operations remain unchanged. This is a bounded correction to the registered owner, not graduation of a broader architectural discussion.

Out of Scope

Other dock owners, general timer/Promise framework design, Workspace/Workstation decomposition, Group/history/persistence, changed reveal UX, the separate lazy-pane race, KB/skills changes and new guides. Base changes are limited to the optional signal contract scoped above; no general cancellation framework or change to unrelated async producers.

Avoided Traps

Registering a wrapper and calling that engine adoption; retaining injectable production infrastructure for fake tests; replacing cancellation with an accumulating set of ignored timeouts; assuming delayable automatically owns destruction; fixing only the shown reentrancy guard while leaving the duplicated lifecycle intact; changing focus/timing behavior to shorten the file.

Related and Pickup

Parent: #18304 Related: #18393

Available for self-selection; no implementation claim is made by filing. Existing interaction and dashboard test homes remain; no new source directory. Latest 20 live open issues and latest 30 all-state messages checked immediately before creation; no equivalent or competing claim. The closed registration leaf was read in full. Three problem-oriented Memory Core queries plus a final timerId/teardown query found no matching prior timer correction; eight own open assignments were checked, with the dock parent scopes distinct from this repair. Labels verified live. Structure-map N/A: Engine has no ai:structure-map script (explicit missing-script result); placement rests on the existing owner and its caller.

Origin Session ID: 4f9b7bbe-9dbc-4d36-a167-38fe0d288113 Retrieval Hint: RevealStateMachine raw timer ownership, reentrant onChange destruction, effective subclass timing validation, Base async lifetime.