LearnNewsExamplesServices
Frontmatter
id18062
titleA lazy-module rail item reveals empty; load it on reveal like a card
stateClosed
labels
bugaicore
assigneesneo-fable-clio
createdAtSep 2, 2026, 10:54 AM
updatedAtSep 2, 2026, 2:41 PM
githubUrlhttps://github.com/neomjs/neo/issues/18062
authorneo-fable-clio
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 2, 2026, 2:41 PM

A lazy-module rail item reveals empty; load it on reveal like a card

neo-fable-clio
neo-fable-clio commented on Sep 2, 2026, 10:54 AM

Context

The Fleet cockpit resolves two auto-hidden rail panes with module: () => import(...) (define-agent, wake routes). Revealing either from its rail tab opens an empty overlay (neomjs/neo-agent-institution#74). PR neomjs/neo-agent-institution#77 worked around it with a static import; the operator rejected that shape on 2026-09-02: a rail must lazy-load exactly like a tab container — no active item at startup, the module loaded on activation — because an eagerly imported pane pays class loading, construction, initAsync and any autoLoad store request at app boot (a buffered grid inside a never-opened pane is the canonical cost).

Observed (unit reproducer on dev@8c86f4478f, red-first): Rail#syncRevealPane → slot.add({...resolved}) → container.Base#insert → TypeError: item.createVdomReference is not a function at src/container/Base.mjs:692. createItem parks a function-module item as a plain object with vdom.removeDom: true (src/container/Base.mjs:442–462); createItems accepts that at construction because a card layout resolves it on activation (src/layout/Card.mjs:111, loadModule at :197), while insert() assumes an instance. Inferred: the throw escapes the reveal transition after the overlay became visible, so the consumer sees an empty slot rather than an error.

Live latest-open sweep: latest 20 open issues read 2026-09-02T08:52Z, none equivalent (the auto-hide leaves #13280 / #13158 predate lazy rail items). A2A: no competing lane-claim in the window. KB ticket sweep: no match.

The Problem

Lazy module functions are a card-layout capability: layout.Card#afterSetActiveIndex loads the active item's module and replaces the parked object (form.Container#loadModules delegates to parent.layout.loadModule for the same reason). A rail is the auto-hide analog of a tab strip with no active tab — reveal IS activation — but the reveal path materializes the pane through a plain container.Base slot (RevealOverlay#paneSlot) that has no loader. Nothing on that path resolves the function, so a lazy rail item can never render, and the failure is a throw inside a state-machine transition rather than a visible error.

The Architectural Reality

  • src/dashboard/dock/interaction/Rail.mjs:757 syncRevealPane: the resolver cascade live instance → config → blueprint → placeholder; a config is added with slot.add({...resolved}) (:794) and cached in revealPaneCache.
  • src/dashboard/dock/interaction/RevealOverlay.mjs paneSlot: a plain Neo.container.Base (items: []), pane-blind by design.
  • src/container/Base.mjs:442 lazy detection (module && !module.isClass && Neo.isFunction(module)), :459–462 the parking, :692 insert() calling createVdomReference() on whatever createItem returned.
  • src/layout/Card.mjs:197 loadModule: await module() → .default, sets className / module, deletes the parked vdom, creates the instance, re-seats the vdom reference, fires cardLoaded.
  • ADR 0029 §2.6 row 1: a resolved instance is "moved / re-parented, never destroyed"; §2.7: "Click a rail tab → transient reveal: the pane renders as an overlay". A lazy item has no instance until its first reveal — creating it there is first materialization, not recreation.
  • Consumer: apps/agentos/view/fleet/cockpit/Container.mjs resolveDockItemConfig — define-agent (lazy on dev before PR #77) and wakeRoutes (module: () => import('../wake/Container.mjs')), both auto-hidden rail tools.

The Fix

In Rail#syncRevealPane, a resolved config whose module is a function (Neo.typeOf(module) === 'Function') is loaded before it is added: a protected async loadRevealPane(itemId, config) awaits config.module(), takes .default, and — if the rail is alive and revealOverlay.revealPaneItemId still names itemId — adds {...config, module} to the slot, caches the instance in revealPaneCache and runs the lock sync as for a synchronous pane. A reveal dismissed before the import settles adds nothing; the next reveal resolves again (the module registry makes the second import immediate). Non-lazy configs, live instances, blueprints and placeholders keep the synchronous path; syncRevealPane itself stays synchronous. The JSDoc names the tab-card parallel.

The failure route (amended 2026-09-02 on the reviewer's reproduction, PR #18063 Round 1): a rejected import must clear the lease (else the ??= short-circuit disables the item for the session and the rejection goes unhandled), show the adapter's recoverable placeholder in the still-open reveal — uncached, so the next reveal retries the import — and a rail destroyed while the import is in flight settles the load to null: core.Base#destroy deletes every own property, so the post-await guard reads the lease map optionally.

ADR 0029 §2.6: one amendment sentence under the "moved, never re-instantiated" row — a lazy item's first reveal creates its instance (first materialization); the never-recreate rule governs instances that exist.

Contract Ledger

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
A resolveComponentRef / resolvePane config whose module is a function, revealed through a rail layout.Card#loadModule (src/layout/Card.mjs:197); container.Base#createItem lazy detection (:442) loaded on first reveal; the instance is added to the pane slot and cached per item until the import settles the slot is empty for that tick; a dismissed reveal loads nothing; a REJECTED import clears the lease, the open reveal shows the recoverable placeholder (uncached), the next reveal retries; a rail destroyed mid-load settles the load to null Rail#syncRevealPane + loadRevealPane JSDoc; ADR 0029 §2.6 amendment unit arms AC-1 / AC-2 / AC-7 red-first; component arm AC-3

Decision Record impact: amends ADR 0029 §2.6 (one sentence; operator ruling 2026-09-02).

Acceptance Criteria

  • AC-1 test/playwright/unit/dashboard/DockRail.spec.mjs: a resolver config with a function module materializes on reveal — the slot holds a Neo instance of the loaded class with the config applied, cached in revealPaneCache; the arm is red on dev (the createVdomReference throw).
  • AC-2 same spec: a reveal dismissed before the import settles adds nothing to the slot; the next reveal materializes the pane.
  • AC-3 component arm (test/playwright/component/dashboard/, on a new apps/dock-lazy-rail fixture lifted from the dock-maximize sibling): the lazy pane's module is not loaded at boot (a module-scope witness readable through the workspace) and is loaded and rendered inside the visible reveal overlay after the rail-tab click.
  • AC-4 unit dashboard/ and the dock component specs green; the pin path is unchanged (a pinned lazy item enters the tab flow through the card layout's own load).
  • AC-5 ADR 0029 §2.6 carries the amendment sentence.
  • AC-6 post-merge: neomjs/neo-agent-institution#77 restores module: () => import(...) for define-agent (wake routes stays lazy) and bumps the engine pin; the define-agent reveal renders the form from the rail tab and from the bootstrap CTA.
  • AC-7 (added 2026-09-02, reviewer's reproduction) same unit spec: a rejected import clears the lease, the open reveal shows the recoverable placeholder, dismissal destroys that placeholder rather than parking it, and the next reveal retries and materializes; a rail destroyed while the import is in flight settles the load to null without a throw.

Out of Scope

  • A generic lazy loader on container.Base#insert: a parked item in a container without a card layout can never be activated, so parking there would trade a throw for an invisible item.
  • The blueprint path (JSON, no functions) and hover-reveal timing.
  • The institution PR itself (AC-6 is the consumer row).

Avoided Traps

  • Static import in the consumer (the PR #77 shape): the pane's module graph joins app boot; a heavy pane pays construction + initAsync + store autoLoad before first paint — exactly what lazy modules exist to avoid. Rejected by the operator 2026-09-02.
  • Placeholder-then-swap in the slot: two commits per reveal and a cache entry to replace; the card layout adds nothing until the module is loaded — keep parity.
  • Making syncRevealPane async: its callers are synchronous state-machine transitions; only the lazy branch defers.
  • Caching the failure placeholder: a cached placeholder would be re-parented on the next reveal and the import never retried — the placeholder is transient by design.

Related

  • neomjs/neo-agent-institution#74 (the symptom), neomjs/neo-agent-institution#77 (the rejected static-import shape, to be reworked into the pin bump), #13280 (the auto-hide interaction layer), #18039 (the rail's reveal-pane cache and release paths), ADR 0029 §2.6 / §2.7.

Structure-map gate (§1c): N/A — dashboard/dock engine surface, no ai/ touch. Structural pre-flight: no new src file; the component fixture lifts the dock-maximize sibling.

Origin Session ID: 91f83b9c-df95-4f72-a68f-d33f470792ac

Retrieval Hint: "dock rail lazy module reveal loadRevealPane card layout activation"

📜 Clio