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:
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:
- 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.
- 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.
- 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.
- 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:
hasChildren() (:309) — when the store is a TreeStore, answer from isLeaf / childCount instead of Array.isArray(record.items).
showSubMenu() (:436) — pass the child level a projection of the same TreeStore keyed on the parent record's id, instead of items: record.items.
afterSetItems() (:124-133) — keep the existing nested-items path working unchanged when a plain menu.Store is in use.
- 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
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
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>;--followtraces through renames, so the 2019 stamps are the originalsrc/import rather than authoring dates — the ticket numbers are the reliable semantic anchor):src/tree/List.mjs8ccc8e7d7bdata.Store+ a field convention:parentId/level/isLeaf/collapsedsrc/menu/List.mjs5a3c292e8b(#2561)itemsarray carried on the recordsrc/menu/Store.mjsd298be4e51(#2564)Neo.data.Storesrc/data/TreeModel.mjsb5e27255c1(#9405)parentId/depth/isLeaf/collapsed/childCountsrc/data/TreeStore.mjs1978fa5fc4(#9406)#childrenMap) + flat Projection LayerThis 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.TreeStorewas 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.Listcan only derive nesting from a nesteditemsarray 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), andmenu.Modeldeclaresitemsas a plainArrayfield (src/menu/Model.mjs). There is no path by which aTreeStore— or anyparentId-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
parentIdshape natively — a module registers "put my action underTools/Diagnostics" without knowing which of its siblings already createdTools. That is a parent-key relation. Because the engine offers only nested arrays, every such consumer hand-rolls the same thing: aMap, 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.TreeStorehas no component consumer at allsrc/importdata/TreeStore.mjsordata/TreeModel.mjs.src/tree/List.mjs:5imports../selection/TreeModel.mjs— the selection model, a different class that merely shares the basename.tree.Listbuilds hierarchy from a flatdata.Storeviame.store.find('parentId', parentId)(tree/List.mjs:306) and readsrecord.level(:260, emitted as--neo-tree-levelat:268),isLeaf(:190,:246),collapsed(:251) — notTreeModel'sdepth. The two are not field-compatible.src/grid/*never imports it either; it duck-types (grid/Container.mjs:536).examples/grid/tree/{MainStore,MainModel}.mjsandexamples/grid/treeBigData/{MainStore,MainModel}.mjs.A
grep -l 'TreeStore|TreeModel'cannot distinguishNeo.data.TreeModelfromNeo.selection.TreeModeland will reporttree/andselection/as adopters. They are not. Independently re-verified by @neo-opus-grace.The Architectural Reality
menu.Listandtree.Listare siblings, both extendinglist.Base; neither inherits the other. Critically, they render hierarchy in structurally different ways:tree.Listrenders the entire hierarchy inside one component —createItemLevel()recurses into nested<ul>nodes, with depth-based padding, sticky folder headers, andposition: stickyoffsets computed fromrecord.level(tree/List.mjs:271-273).menu.Listrenders one floating component per level.showSubMenu()(src/menu/List.mjs:436-467) instantiates a newmenu.Listfor the child level, alignededgeAlign: 'l0-r0'against the parent item, and hands ititems: record.items. Sub-menus are cached insubMenuMap.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:'A/B/C'impliesAandA/Bexist, but nobody declares them — today an assembler conjures them. AparentIdstore 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 sameGroupnode. That is a distinct primitive — idempotent path materialisation — and it is deliberately out of scope here; see below.The Fix
Teach
menu.Listto accept aTreeStoreand derive nesting fromparentId, leaving the cascade intact.The change is smaller than it looks precisely because
showSubMenualready creates a child List per level:hasChildren()(:309) — when the store is aTreeStore, answer fromisLeaf/childCountinstead ofArray.isArray(record.items).showSubMenu()(:436) — pass the child level a projection of the sameTreeStorekeyed on the parent record's id, instead ofitems: record.items.afterSetItems()(:124-133) — keep the existing nested-itemspath working unchanged when a plainmenu.Storeis in use.Both input shapes stay supported. The nested-
itemsform 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.Listin the cascade holds a flat store containing exactly that level's records, derived from the sharedTreeStore, which remains the single source of truth.The rejected alternative — one shared
TreeStorewith each level rendering a filtered subset of it — is unsafe, and the falsifier is concrete.list.Baseindex math is hard-wired to the fullthis.store.items:getSelectedIndex()—list/Base.mjs:671— walksthis.store.itemspositionally.getHeaderlessIndex()—list/Base.mjs:696— same.afterSetHeaderlessSelectedIndexfeeds them atlist/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 tolist.Basewas 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".
TreeStorecannot answer that publicly:itemsis the Projection Layer — only currently visible nodes.TreeModel.collapseddefaults totrue, sofind('parentId', key)returns nothing for an unexpanded branch. Expanding to read would mutate shared state as a side effect of rendering.#childrenMapand#allRecordsMapare hard-private.clear,clearFilters,collapse,collapseAll,collectAllDescendants,collectVisibleDescendants,doSort,expandAll,filter,get,getInsertIndexForNode,hydrateRecord,sortArray,splice,toggle,updateKey,updateSiblingStats.collectAllDescendantsreturns 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 undermenu.Modelwould silently dropisLeaf/parentId/depth, andhasChildren()readsisLeaf. Each level instantiates its own model of that class, so no level can destroy a schema another level is using.Contract Ledger Matrix
Neo.menu.List#storesrc/menu/List.mjs:84(store: Store)Neo.data.TreeStorein addition tomenu.Storemenu.Storeis suppliedlearn/guides/datahandling/TreeStore.md:84, typed{Neo.menu.Store}Neo.menu.List#items_src/menu/List.mjs:45items_: nullat:45, applied viaafterSetItems:124Neo.menu.List#hasChildren()src/menu/List.mjs:309isLeaf/childCountforTreeStorerecord.itemsNeo.menu.List#showSubMenu()src/menu/List.mjs:436items: record.itemsmodule: Listwithitems: record.itemsNeo.data.TreeModelfieldssrc/data/TreeModel.mjs:29-71childCount,collapsed,depth,isLeaf,parentId,siblingCount,siblingIndexNeo.data.TreeStore#getChildren()src/data/TreeStore.mjs#childrenMap/#allRecordsMapare 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 levelNeo.data.TreeStore(rest)src/data/TreeStore.mjs#childrenMapStructural Layer at:72-75Decision Record impact
none. Reviewedlearn/agentos/decisions/; the only files matching tree/store/menu terms are0029-docking-design.mdand0034-electron-shell-architecture.md, and neither governs data-store hierarchy or the menu package.Acceptance Criteria
menu.Listaccepts aNeo.data.TreeStoreas itsstoreand renders root-level entries from it.hasChildren()returns correctly forTreeStore-backed records without readingrecord.items.parentId, with the cascading floating-instance topology unchanged (onemenu.Listper level,subMenuMapcaching preserved).itemsAPI is unchanged: every currentmenu/*example and test passes untouched.TreeStoresurvives sub-menu destruction — closing and reopening a cascade does not destroy or double-destroy the store.TreeStore#getChildren()returns a node's direct children while that node is collapsed — the casefind('parentId', …)cannot serve — returns[]for a leaf or unknown key, and returns hydrated records underautoInitRecords: false.npx playwright test).examples/menu/demonstrates aTreeStore-backed cascading menu.learn/guides/datahandling/TreeStore.mdno longer impliesTreeStoreis grid-only.Out of Scope
'A/B/C') into records, creating missing ancestors exactly once so that two independent contributors targeting'Group/A'and'Group/B'converge on oneGroupnode. This is asrc/data/concern, not a menu one — its natural home is alongsidesrc/data/normalizer/Tree.mjs, which already performs the analogous nested-children→ flat-parentIdtransform. 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.tree.Listontodata.TreeStore. A real and larger gap (level→depthalone is a breaking field rename); its own ticket, unfiled — not filed speculatively.menu.Modelnor the classic nested-itemsAPI 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.itemsAPI.TreeStorefor its structural half (#childrenMap, ancestor-aware filtering). Stated plainly so the next reader does not assume the performance story transfers.isTreeGridinference ingrid.Container(isTreeStoreis read atgrid/Container.mjs:536and defined nowhere in the repo, so TreeGrid ARIA attributes never emit). Captured separately via the defect channel; agrid/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.Listis not ondata.TreeStore; inheriting it buys the 2019 field convention, not the 2026 one. (2) It inherits the wrong render model:tree.Listpaints 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.TreeStoreinto 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
itemsfrom aparentIdset in application code is precisely the workaround this ticket exists to remove, and it re-derives the hierarchy on every state change.Trap:
list.Baseassumes it owns its store. #17367 / #17510 hit this — a store-driven menu port neededautoDestroyStore: falseplus abeforeSetStoreoverride becauselist.Basedestroys an injected store. #17575 RA-2 recorded double store destruction from the same root. With sub-menus sharing oneTreeStoreacross 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 atgrid/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 thevalue?.on/oldValue?.unpair.Related
data.TreeModel/data.TreeStoreautoDestroyStoretrapObservablelistener-object mutationisTreeGridauto-detect referenced under Out of ScopeHandoff 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"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)Neo.data.TreeModelandNeo.selection.TreeModelshare a basename; agrep -loverTreeModelconflates them and falsely reportstree/andselection/asdata.TreeStoreadopters.Origin Session ID: 16636a26-a8e1-4f27-9c7b-9365f5000c0f