LearnNewsExamplesServices
Frontmatter
id17839
titleMenu hierarchy comes only from nested items, never from a TreeStore
stateClosed
labels
enhancementaiarchitecturecore
assigneesneo-opus-ada
createdAtAug 29, 2026, 1:20 AM
updatedAtAug 29, 2026, 11:55 AM
githubUrlhttps://github.com/neomjs/neo/issues/17839
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 29, 2026, 11:55 AM

Menu hierarchy comes only from nested items, never from a TreeStore

Closed v13.2.0/archive-v13-2-0-chunk-16 enhancementaiarchitecturecore
neo-opus-ada
neo-opus-ada commented on Aug 29, 2026, 1:20 AM

Context

Neo carries three unrelated ways to express a hierarchy, and no bridge between them. This ticket closes the gap for menu/*.

Verified add-dates (git log --diff-filter=A --follow --format='%ad %h %s' --date=short -- <file>; --follow traces through renames, so the 2019 stamps are the original src/ import rather than authoring dates — the ticket numbers are the reliable semantic anchor):

Surface Added Hierarchy mechanism
src/tree/List.mjs 2019-11-17 8ccc8e7d7b flat data.Store + a field convention: parentId / level / isLeaf / collapsed
src/menu/List.mjs 2021-07-05 5a3c292e8b (#2561) nested items array carried on the record
src/menu/Store.mjs 2021-07-06 d298be4e51 (#2564) flat Neo.data.Store
src/data/TreeModel.mjs 2026-03-09 b5e27255c1 (#9405) parentId / depth / isLeaf / collapsed / childCount
src/data/TreeStore.mjs 2026-03-09 1978fa5fc4 (#9406) Structural Layer (#childrenMap) + flat Projection Layer

This is adopting a primitive, not finishing a migration. That distinction is load-bearing and worth stating explicitly, because the opposite framing is the intuitive one and it is false: data.TreeStore was never wired into any component. It has no consumer outside two grid examples, five months after landing. menu/* is not a straggler left behind by a migration that swept the rest of the engine — no such sweep happened.

Live latest-open sweep: checked the latest 20 open issues at 2026-08-28T23:16:28Z; no equivalent found.

The Problem

menu.List can only derive nesting from a nested items array sitting on each record:

// src/menu/List.mjs:309
hasChildren(record) {
    return Array.isArray(record.items) && record.items.length > 0
}

menu.Store extends Neo.data.Store (flat), and menu.Model declares items as a plain Array field (src/menu/Model.mjs). There is no path by which a TreeStore — or any parentId-shaped record set — becomes a menu.

The consumer-visible consequence is specific. Any architecture where independent modules contribute entries into shared, path-addressed menu groups produces a parentId shape natively — a module registers "put my action under Tools/Diagnostics" without knowing which of its siblings already created Tools. That is a parent-key relation. Because the engine offers only nested arrays, every such consumer hand-rolls the same thing: a Map, a recursive assembler that walks and creates missing intermediate groups, and a wholesale rebuild of a plain array on every state change. They are not bypassing the engine — there is nothing to reach for.

This is not hypothetical or single-consumer: Fleet Manager's own widget catalog is the same shape, and #17367 / #17510 already replaced one hand-rolled instance menu with a store-driven one, hitting the store-ownership friction recorded below.

The wider finding: data.TreeStore has no component consumer at all

  • Zero files under src/ import data/TreeStore.mjs or data/TreeModel.mjs.
  • src/tree/List.mjs:5 imports ../selection/TreeModel.mjs — the selection model, a different class that merely shares the basename. tree.List builds hierarchy from a flat data.Store via me.store.find('parentId', parentId) (tree/List.mjs:306) and reads record.level (:260, emitted as --neo-tree-level at :268), isLeaf (:190, :246), collapsed (:251) — not TreeModel's depth. The two are not field-compatible.
  • src/grid/* never imports it either; it duck-types (grid/Container.mjs:536).
  • The only consumers repo-wide are examples/grid/tree/{MainStore,MainModel}.mjs and examples/grid/treeBigData/{MainStore,MainModel}.mjs.

A grep -l 'TreeStore|TreeModel' cannot distinguish Neo.data.TreeModel from Neo.selection.TreeModel and will report tree/ and selection/ as adopters. They are not. Independently re-verified by @neo-opus-grace.

The Architectural Reality

menu.List and tree.List are siblings, both extending list.Base; neither inherits the other. Critically, they render hierarchy in structurally different ways:

  • tree.List renders the entire hierarchy inside one component — createItemLevel() recurses into nested <ul> nodes, with depth-based padding, sticky folder headers, and position: sticky offsets computed from record.level (tree/List.mjs:271-273).
  • menu.List renders one floating component per level. showSubMenu() (src/menu/List.mjs:436-467) instantiates a new menu.List for the child level, aligned edgeAlign: 'l0-r0' against the parent item, and hands it items: record.items. Sub-menus are cached in subMenuMap.

That cascade topology is the whole point of a menu and must survive this change.

Design inputs from a second real consumer

Reviewed with @neo-opus-grace against a second, independent consumer of this shape. Each contributed entry carries a slash-delimited grouping path ('' | 'Group' | 'A/B/C'), a stable id, a flag marking an item that may only be open once, a positional divider flag, and a title that may be a locale map rather than a plain string. Four properties of that shape drove the ACs below:

  1. Intermediate nodes have no record. 'A/B/C' implies A and A/B exist, but nobody declares them — today an assembler conjures them. A parentId store needs them to exist as records before a child can point at one, and two contributors declaring 'Group/A' and 'Group/B' must land on the same Group node. That is a distinct primitive — idempotent path materialisation — and it is deliberately out of scope here; see below.
  2. Leaf state is derived, not stored. Enablement depends on live application state, not on the catalog record. So the menu store is not a pure projection, and something must recompute per open. This is where the wholesale rebuild came from — and a naive port would move the rebuild rather than remove it. AC below makes the no-rebuild property explicit, because it is the actual win over an array.
  3. Contribution is open-ended. Modules register at load; the catalog is not known at design time. Any API needing the full tree up front does not fit — inserts must be incremental and order-independent.
  4. Ordering and separators. A divider flag of this kind is positional, and today's order is iteration order. A store introduces a sort question the array never had to answer.

The Fix

Teach menu.List to accept a TreeStore and derive nesting from parentId, leaving the cascade intact.

The change is smaller than it looks precisely because showSubMenu already creates a child List per level:

  1. hasChildren() (:309) — when the store is a TreeStore, answer from isLeaf / childCount instead of Array.isArray(record.items).
  2. showSubMenu() (:436) — pass the child level a projection of the same TreeStore keyed on the parent record's id, instead of items: record.items.
  3. afterSetItems() (:124-133) — keep the existing nested-items path working unchanged when a plain menu.Store is in use.
  4. Sub-menu instances must not own or destroy the shared store — see the trap below.

Both input shapes stay supported. The nested-items form is the documented API for static menus and is not being deprecated here.

Decided: each level derives its own flat store; levels never render a subset of the shared one

Two architectures were possible. The chosen one is per-level derived stores — each menu.List in the cascade holds a flat store containing exactly that level's records, derived from the shared TreeStore, which remains the single source of truth.

The rejected alternative — one shared TreeStore with each level rendering a filtered subset of it — is unsafe, and the falsifier is concrete. list.Base index math is hard-wired to the full this.store.items:

  • getSelectedIndex() — list/Base.mjs:671 — walks this.store.items positionally.
  • getHeaderlessIndex() — list/Base.mjs:696 — same.
  • afterSetHeaderlessSelectedIndex feeds them at list/Base.mjs:287.

A level rendering a subset would compute selection and keyboard navigation against the whole store while its DOM held one level's items. That fails in the worst way available: it renders correctly and mis-targets silently.

By contrast the id↔record path is index-free — getItemId(getRecordId(record)) (:482), getItemRecordId(vnodeId) (:725), store.get(getItemRecordId(...)) (:792) — so only index math is unsafe, and a per-level store removes the hazard entirely rather than working around it.

This also keeps the diff inside src/menu/. Adding a record-enumeration hook to list.Base was considered and rejected for this ticket: it widens the blast radius to every list in the engine to serve one consumer, and it would leave the index math above still store-wide — the hook would look like a fix without being one.

Consequence for AC "mutating one record does not rebuild the menu": each level subscribes narrowly, so a single-record change updates one level's item rather than re-deriving the cascade.

Found during implementation: the primitive has no way to ask for one level

Deriving a level needs "the direct children of node X". TreeStore cannot answer that publicly:

  • items is the Projection Layer — only currently visible nodes. TreeModel.collapsed defaults to true, so find('parentId', key) returns nothing for an unexpanded branch. Expanding to read would mutate shared state as a side effect of rendering.
  • #childrenMap and #allRecordsMap are hard-private.
  • The public surface is clear, clearFilters, collapse, collapseAll, collectAllDescendants, collectVisibleDescendants, doSort, expandAll, filter, get, getInsertIndexForNode, hydrateRecord, sortArray, splice, toggle, updateKey, updateSiblingStats. collectAllDescendants returns the whole subtree, not one level. Nothing returns a node's direct children.

So this ticket adds TreeStore#getChildren(parentId='root') — an O(1) lookup of the child array plus O(k) to hydrate and copy its entries, so Turbo Mode (autoInitRecords: false) callers get records rather than raw objects. Additive and non-breaking.

Two properties that behave differently and must not be conflated: expansion and filtering do not affect it (visibility belongs to the Projection Layer, so a collapsed branch still yields its children), while ordering does — doSort() reorders the child arrays in the Structural Layer itself, so returned siblings follow the store's current sort. The second is the intended contract, and it is what lets a level-at-a-time consumer inherit ordering it never implements.

This is the thesis of the ticket showing up in the code: a primitive with no component consumer never grew the accessor a consumer would need. Any level-at-a-time renderer — cascading menu, breadcrumb, column browser — needs it.

Second implementation consequence: a level's derived store reuses the tree store's model class, not menu.Model. Re-adding records under menu.Model would silently drop isLeaf/parentId/depth, and hasChildren() reads isLeaf. Each level instantiates its own model of that class, so no level can destroy a schema another level is using.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
Neo.menu.List#store src/menu/List.mjs:84 (store: Store) Accepts a Neo.data.TreeStore in addition to menu.Store Unchanged when a flat menu.Store is supplied learn/guides/datahandling/TreeStore.md Verified: config present at :84, typed {Neo.menu.Store}
Neo.menu.List#items_ src/menu/List.mjs:45 Unchanged; remains the static-menu path n/a — no behaviour change menu guide Verified: items_: null at :45, applied via afterSetItems :124
Neo.menu.List#hasChildren() src/menu/List.mjs:309 Branches on store type; isLeaf/childCount for TreeStore Existing nested-array test JSDoc Verified: current body reads record.items
Neo.menu.List#showSubMenu() src/menu/List.mjs:436 Feeds the child List a parent-keyed projection Existing items: record.items JSDoc Verified: instantiates module: List with items: record.items
Neo.data.TreeModel fields src/data/TreeModel.mjs:29-71 Consumed, not modified n/a TreeStore guide Verified present: childCount, collapsed, depth, isLeaf, parentId, siblingCount, siblingIndex
Neo.data.TreeStore#getChildren() NEW — src/data/TreeStore.mjs Returns a node's direct children from the Structural Layer, hydrated. O(1) child-array lookup + O(k) hydration. Independent of expansion and filtering; sibling order follows the store's current sort None — no public accessor exists today TreeStore guide Verified gap: #childrenMap/#allRecordsMap are hard-private; the public API is clear/clearFilters/collapse/collapseAll/collect{All,Visible}Descendants/doSort/expandAll/filter/get/getInsertIndexForNode/hydrateRecord/sortArray/splice/toggle/updateKey/updateSiblingStats — none returns one level
Neo.data.TreeStore (rest) src/data/TreeStore.mjs Otherwise consumed, not modified n/a TreeStore guide Verified: #childrenMap Structural Layer at :72-75

Decision Record impact

none. Reviewed learn/agentos/decisions/; the only files matching tree/store/menu terms are 0029-docking-design.md and 0034-electron-shell-architecture.md, and neither governs data-store hierarchy or the menu package.

Acceptance Criteria

  • menu.List accepts a Neo.data.TreeStore as its store and renders root-level entries from it.
  • hasChildren() returns correctly for TreeStore-backed records without reading record.items.
  • Opening a sub-menu renders the children of that record derived from parentId, with the cascading floating-instance topology unchanged (one menu.List per level, subMenuMap caching preserved).
  • Arbitrary depth works — a three-level cascade opens and closes correctly.
  • The existing nested-items API is unchanged: every current menu/* example and test passes untouched.
  • A shared injected TreeStore survives sub-menu destruction — closing and reopening a cascade does not destroy or double-destroy the store.
  • Mutating one record does not rebuild the menu. Changing a single record's field (e.g. an enablement flag) updates only that item's rendering; no full re-assembly and no loss of open sub-menu state. This is the property that distinguishes a store-backed menu from the array it replaces (design input 2).
  • Records added after mount appear without a rebuild, and the result is independent of insertion order (design input 3).
  • Sibling order is deterministic and controllable — a level's order follows the tree's Structural Layer, and a sort applied to the tree store reaches every level (design input 4).
  • TreeStore#getChildren() returns a node's direct children while that node is collapsed — the case find('parentId', …) cannot serve — returns [] for a leaf or unknown key, and returns hydrated records under autoInitRecords: false.
  • Unit coverage for both store shapes, run via the repo's explicit Playwright configs (not bare npx playwright test).
  • An example under examples/menu/ demonstrates a TreeStore-backed cascading menu.
  • learn/guides/datahandling/TreeStore.md no longer implies TreeStore is grid-only.

Out of Scope

  • Idempotent path materialisation — turning a slash-delimited path ('A/B/C') into records, creating missing ancestors exactly once so that two independent contributors targeting 'Group/A' and 'Group/B' converge on one Group node. This is a src/data/ concern, not a menu one — its natural home is alongside src/data/normalizer/Tree.mjs, which already performs the analogous nested-children → flat-parentId transform. Named here deliberately rather than left to be discovered: without it, a consumer with path-addressed contributions still cannot build the tree this ticket renders. Filed separately and linked below.
  • Migrating tree.List onto data.TreeStore. A real and larger gap (level → depth alone is a breaking field rename); its own ticket, unfiled — not filed speculatively.
  • Positional separators. Design input 4 also raised whether a positional divider flag survives a sort. It cannot be answered here: neither menu.Model nor the classic nested-items API models separators at all, so there is nothing to preserve. The original AC asked for it and was narrowed to the half that exists — order determinism — rather than left as a criterion no implementation could satisfy. Separators are a menu-feature question, not a hierarchy one.
  • Deprecating or removing the nested-items API.
  • Virtual scrolling / buffered rendering for menus. A menu does not need the Projection Layer; this ticket adopts TreeStore for its structural half (#childrenMap, ancestor-aware filtering). Stated plainly so the next reader does not assume the performance story transfers.
  • The dead isTreeGrid inference in grid.Container (isTreeStore is read at grid/Container.mjs:536 and defined nowhere in the repo, so TreeGrid ARIA attributes never emit). Captured separately via the defect channel; a grid/a11y concern.

Avoided Traps / Gold Standards Rejected

Rejected: menu.TreeList extends tree.List. The obvious-looking option, and wrong twice over. (1) It would not adopt the primitive at all — tree.List is not on data.TreeStore; inheriting it buys the 2019 field convention, not the 2026 one. (2) It inherits the wrong render model: tree.List paints one nested <ul> with sticky headers and depth padding, while menus must cascade into separate floating instances. Suppressing an inherited render model is not cheaper than not inheriting it.

Rejected: "finish the incomplete retrofit" as the framing. Considered and discarded on evidence. It is the intuitive story and it is false — no migration ever swept data.TreeStore into the component layer, so there is nothing to finish. Recording it because the false version is individually-true-facts arranged into a wrong conclusion, and the next reader will reach for it too.

Rejected: solving it app-side. Assembling nested items from a parentId set in application code is precisely the workaround this ticket exists to remove, and it re-derives the hierarchy on every state change.

Trap: list.Base assumes it owns its store. #17367 / #17510 hit this — a store-driven menu port needed autoDestroyStore: false plus a beforeSetStore override because list.Base destroys an injected store. #17575 RA-2 recorded double store destruction from the same root. With sub-menus sharing one TreeStore across many List instances, this is the single most likely defect in this ticket; the AC above exists for it.

Trap: Observable.on() / un() mutate the listeners object passed to them. Recorded in #14920 and visible at grid/Container.mjs:538-541, which spreads a fresh copy per call for exactly this reason. Any listener wiring added here must not share one object across the value?.on / oldValue?.un pair.

Related

  • #17840 — idempotent path materialisation; the named Out-of-Scope follow-up above
  • #2561 / #2564 — the menu package's introduction
  • #9405 / #9406 — data.TreeModel / data.TreeStore
  • #17367 / #17510 — store-driven menu port; source of the autoDestroyStore trap
  • #17575 — double store destruction, same root cause
  • #14920 — Observable listener-object mutation
  • #9481 — the isTreeGrid auto-detect referenced under Out of Scope

Handoff Retrieval Hints

  • query_raw_memories: "menu.List TreeStore parentId retrofit cascading submenu"
  • query_raw_memories: "three hierarchy conventions menu tree TreeStore selection TreeModel basename collision"
  • Key anchors: src/menu/List.mjs:309 (hasChildren), src/menu/List.mjs:436 (showSubMenu), src/tree/List.mjs:306 (store.find('parentId', …)), src/data/TreeModel.mjs:29-71 (field set)
  • The disambiguation that unlocks this ticket: Neo.data.TreeModel and Neo.selection.TreeModel share a basename; a grep -l over TreeModel conflates them and falsely reports tree/ and selection/ as data.TreeStore adopters.

Origin Session ID: 16636a26-a8e1-4f27-9c7b-9365f5000c0f