From 60cfe2919cda6b7a552baf3034e0cc3fc139cc30 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 8 Jul 2026 17:13:57 +0300 Subject: [PATCH 1/2] Tree lens on @headless-tree: ARIA tree pattern, keyboard nav, typeahead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swaps the web Tree's hand-rolled internals for @headless-tree 1.7 (zero-dep DOM-free core + react hook, ~14 kB) — chosen over react-arborist / react-aria Tree / MUI X after a landscape survey: it is architecturally identical to the lens (flat visible-list render, bring-your-own markup, external state) and fully happy-dom-testable. - W3C ARIA tree pattern out of the box: role=tree/treeitem, aria-level/setsize/posinset/expanded/selected, roving tabindex; arrow keys / Home / End navigate, Enter activates, type-to-search opens a search box and marks matches (styled ring). - Author surface, buildForest, TUI renderer, registries: UNCHANGED. - Entity-level selection preserved: activation dispatches the node's KEY to select_state; controlled selectedItems derive from the bound value, so every occurrence of the selected entity highlights. - Re-read-safe disclosure preserved via CONTROLLED expandedItems: expand_depth default overlaid with a user-toggle map (diffed from the lib's setter) — the existing regression scenario passes. - UX change (standard tree pattern): row click both selects and toggles folders; the chevron is now a visual indicator. Tests 8 (was 6): + ARIA attributes/roving tabindex, ArrowDown/ ArrowRight keyboard nav, typeahead search (matches marked), duplicate- entity highlight. Test gotchas encoded: hotkey matching tracks HELD keys (keyup on document required) and typeahead matches event CODE (KeyB), not key. Verified live on the concepts graph: 152 nodes, arrow-key walk, "a" → search with 106 matches, click → /selected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh --- packages/core/src/catalog/lenses/tree.ts | Bin 3931 -> 4029 bytes packages/web/package.json | 2 + packages/web/src/components/Tree.test.tsx | 155 ++++++++----- packages/web/src/components/Tree.tsx | 254 +++++++++++++++------- pnpm-lock.yaml | 24 ++ 5 files changed, 300 insertions(+), 135 deletions(-) diff --git a/packages/core/src/catalog/lenses/tree.ts b/packages/core/src/catalog/lenses/tree.ts index a897c3f47b77c076bb28ba0abf03c3f9a6b736d6..74f8857552726720756c9de8f3f8e5ac50765485 100644 GIT binary patch delta 110 zcmWN^Jqp4=5C+grati%Qg#>$hLGTol%}3(TX2Q diff --git a/packages/web/package.json b/packages/web/package.json index 7490f40..6d6317d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -18,6 +18,8 @@ "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/urbanist": "^5.2.7", + "@headless-tree/core": "^1.7.0", + "@headless-tree/react": "^1.7.0", "@json-render/core": "^0.19.0", "@json-render/react": "^0.19.0", "@modernrelay/notebook-client": "workspace:*", diff --git a/packages/web/src/components/Tree.test.tsx b/packages/web/src/components/Tree.test.tsx index 3f4c406..14502c8 100644 --- a/packages/web/src/components/Tree.test.tsx +++ b/packages/web/src/components/Tree.test.tsx @@ -1,7 +1,8 @@ // @vitest-environment happy-dom // -// Tree lens through the REAL registry: forest grouping renders nested, -// disclosure collapses, node click writes select_state. +// Tree lens over @headless-tree, through the REAL registry: ARIA tree +// pattern, keyboard navigation, typeahead search, entity-level selection, +// and the re-read-safe disclosure policy. import { describe, it, expect, afterEach } from "vitest"; import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -9,6 +10,10 @@ import { JSONUIProvider, Renderer } from "@json-render/react"; import { assembleLensSpec, type QueryResult } from "@modernrelay/notebook-core"; import { webRegistry } from "../registry.js"; +// headless-tree's internal state updates run through React setState in +// effects/handlers; mark the env so act() covers them. +(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; + const ROWS = [ { d: "sys", dn: "Systems", c: "loop", cn: "Feedback loops", r: "attr", rn: "Attractors" }, { d: "sys", dn: "Systems", c: "loop", cn: "Feedback loops", r: "emer", rn: "Emergence" }, @@ -24,7 +29,7 @@ const result = (rows: Record[]): QueryResult => ({ rows, }); -function treeSpec(extra: Record = {}) { +function treeSpec(extra: Record = {}, rows = ROWS) { return assembleLensSpec( "tree", "Tree", @@ -37,7 +42,7 @@ function treeSpec(extra: Record = {}) { select_state: "/selected", ...extra, }, - result(ROWS), + result(rows), ); } @@ -62,36 +67,60 @@ function mount(spec: ReturnType): { return { host, root, rerender: render }; } +const itemByLabel = (host: HTMLElement, label: string): HTMLElement | undefined => + Array.from(host.querySelectorAll('[role="treeitem"]')).find( + (el) => el.textContent?.includes(label), + ); + +const click = (el: HTMLElement): void => { + act(() => { + el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); +}; + +// Hotkey matching tracks HELD keys (keydown on the tree, keyup on document) +// — release after every press or the next hotkey sees a chord. +const key = (el: HTMLElement, k: string, code?: string): void => { + act(() => { + el.dispatchEvent( + new KeyboardEvent("keydown", { key: k, code: code ?? k, bubbles: true }), + ); + document.dispatchEvent( + new KeyboardEvent("keyup", { key: k, code: code ?? k, bubbles: true }), + ); + }); +}; + afterEach(() => { document.body.innerHTML = ""; }); -describe("Tree lens (web)", () => { - it("renders the grouped forest with counts", () => { +describe("Tree lens (web, headless-tree)", () => { + it("renders the grouped forest with the ARIA tree pattern and counts", () => { const { host, root } = mount(treeSpec()); - const text = host.textContent ?? ""; - expect(text).toContain("Systems"); - expect(text).toContain("Cognitive"); - expect(text).toContain("Feedback loops"); - expect(text).toContain("Attractors"); - // Systems has 2 concepts; Feedback loops has 2 related - const badges = Array.from(host.querySelectorAll("li > div > [class*='badge'], li > div > span[class*='tabular']")).map((b) => b.textContent); - expect(badges).toContain("2"); + expect(host.querySelector('[role="tree"]')).toBeTruthy(); + const items = Array.from(host.querySelectorAll('[role="treeitem"]')); + expect(items.length).toBeGreaterThanOrEqual(7); // 2 domains + 3 concepts + leaves + const systems = itemByLabel(host, "Systems")!; + expect(systems.getAttribute("aria-level")).toBe("1"); + expect(systems.getAttribute("aria-expanded")).toBe("true"); + const loops = itemByLabel(host, "Feedback loops")!; + expect(loops.getAttribute("aria-level")).toBe("2"); + // count badges: Systems (2 concepts), Feedback loops (2 related) + expect(systems.textContent).toContain("2"); + expect(loops.textContent).toContain("2"); + // roving tabindex: exactly one 0 + const tabZero = items.filter((i) => i.getAttribute("tabindex") === "0"); + expect(tabZero.length).toBe(1); act(() => root.unmount()); }); - it("disclosure collapses a branch", () => { + it("row click collapses an expanded branch", () => { const { host, root } = mount(treeSpec()); - const collapseSystems = Array.from(host.querySelectorAll("button")).find( - (b) => b.getAttribute("aria-label") === "Collapse Systems", - )!; - expect(host.textContent).toContain("Feedback loops"); - act(() => { - collapseSystems.click(); - }); - // Systems' subtree hidden; Cognitive's still visible + expect(host.textContent).toContain("Chunking"); + click(itemByLabel(host, "Systems")!); expect(host.textContent).not.toContain("Chunking"); - expect(host.textContent).toContain("Bias"); + expect(host.textContent).toContain("Bias"); // Cognitive untouched act(() => root.unmount()); }); @@ -102,52 +131,60 @@ describe("Tree lens (web)", () => { act(() => root.unmount()); }); - it("node click writes its key value to select_state (highlight follows)", () => { + it("node click writes its key to select_state; every occurrence of the entity highlights", () => { const { host, root } = mount(treeSpec()); - const chunking = Array.from(host.querySelectorAll("span")).find( - (s) => s.textContent === "Chunking", - )!; - act(() => { - chunking.click(); - }); - // selection is the KEY value; the highlight class lands on the node - expect(chunking.className).toContain("bg-accent"); - // a different node with the same label elsewhere isn't highlighted - const systems = Array.from(host.querySelectorAll("span")).find( - (s) => s.textContent === "Systems", - )!; - expect(systems.className).not.toContain("bg-accent"); + // duplicate-entity case FIRST (a later Chunking click collapses its + // subtree, hiding one Attractors occurrence): both occurrences highlight + click(itemByLabel(host, "Attractors")!); + const attractors = Array.from( + host.querySelectorAll('[role="treeitem"]'), + ).filter((el) => el.textContent?.includes("Attractors")); + expect(attractors.length).toBe(2); + for (const el of attractors) expect(el.className).toContain("bg-accent"); + // single-entity: clicking Chunking moves the selection (and toggles it) + click(itemByLabel(host, "Chunking")!); + expect(itemByLabel(host, "Chunking")!.className).toContain("bg-accent"); + expect(itemByLabel(host, "Systems")!.className).not.toContain("bg-accent"); + act(() => root.unmount()); + }); + + it("keyboard: ArrowDown moves focus; ArrowRight expands a collapsed folder", () => { + const { host, root } = mount(treeSpec({ expand_depth: 1 })); + const systems = itemByLabel(host, "Systems")!; + act(() => systems.focus()); + key(systems, "ArrowDown"); + const loops = itemByLabel(host, "Feedback loops")!; + expect(loops.getAttribute("tabindex")).toBe("0"); // roving focus moved + expect(loops.getAttribute("aria-expanded")).toBe("false"); + act(() => loops.focus()); + key(loops, "ArrowRight"); + expect(host.textContent).toContain("Emergence"); // children now visible + act(() => root.unmount()); + }); + + it("typeahead opens search and marks matches", () => { + const { host, root } = mount(treeSpec()); + const systems = itemByLabel(host, "Systems")!; + act(() => systems.focus()); + key(systems, "b", "KeyB"); // typeahead matches the event CODE + const input = host.querySelector('input[aria-label="Search tree"]'); + expect(input).toBeTruthy(); + const bias = itemByLabel(host, "Bias")!; + expect(bias.className).toContain("ring-primary"); act(() => root.unmount()); }); it("re-read rows keep the default-open policy for NEW branches; user toggles survive", () => { const { host, root, rerender } = mount(treeSpec()); - // user collapses Systems - act(() => { - Array.from(host.querySelectorAll("button")) - .find((b) => b.getAttribute("aria-label") === "Collapse Systems")! - .click(); - }); + click(itemByLabel(host, "Systems")!); // user collapses Systems expect(host.textContent).not.toContain("Chunking"); - // a background re-read adds a brand-new domain - const grown = assembleLensSpec( - "tree", - "Tree", - { - levels: [ - { key: "d", label: "dn" }, - { key: "c", label: "cn" }, - { key: "r", label: "rn" }, - ], - select_state: "/selected", - }, - result([ + rerender( + treeSpec({}, [ ...ROWS, { d: "phys", dn: "Physics", c: "entropy", cn: "Entropy", r: "attr", rn: "Attractors" }, ]), ); - rerender(grown); - // the NEW domain follows the default policy (open — no expand_depth set) + // the NEW domain follows the default policy (open — no expand_depth) expect(host.textContent).toContain("Physics"); expect(host.textContent).toContain("Entropy"); // the user's explicit collapse of Systems SURVIVES the re-read diff --git a/packages/web/src/components/Tree.tsx b/packages/web/src/components/Tree.tsx index f6480e3..36b5d00 100644 --- a/packages/web/src/components/Tree.tsx +++ b/packages/web/src/components/Tree.tsx @@ -1,5 +1,12 @@ -import React, { useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useActions, useStateValue } from "@json-render/react"; +import { + hotkeysCoreFeature, + searchFeature, + selectionFeature, + syncDataLoaderFeature, +} from "@headless-tree/core"; +import { useTree } from "@headless-tree/react"; import { buildForest, type TreeNode, @@ -12,36 +19,144 @@ interface ComponentCtx

{ props: P; } +const ROOT_ID = "__root__"; + /** - * Tree lens — a collapsible forest grouped from path-shaped rows (one row per - * root→leaf chain; `levels` names the column pairs). Clicking a node label - * writes THAT node's key value to `select_state` (Table's mechanism), so any - * level drives dependent cells. Disclosure state is per-node local UI state. + * Tree lens — a collapsible forest grouped from path-shaped rows, rendered + * over @headless-tree (W3C ARIA tree pattern: roving tabindex, arrow-key + * navigation, Home/End, type-to-search). Selection is ENTITY-level: a node + * click/Enter writes the node's KEY value to `select_state`, and every + * occurrence of the selected entity in the forest highlights together. + * Row click also toggles folders (the standard tree pattern); disclosure + * state = the expand_depth default overlaid with explicit user toggles, so + * a query re-read gives NEW branches the default while user folds survive. */ export function Tree({ props: p, }: ComponentCtx): React.ReactElement { const actions = useActions(); const selected = useStateValue(p.select_state ?? "/__never__"); - const forest = useMemo( - () => buildForest(p.rows, p.levels), - [p.rows, p.levels], + const selectable = Boolean(p.select_state); + + // Forest + path index rebuilt when the query rows change. The synthetic + // root holds the forest roots as children (headless-tree needs one root). + const { byPath, forest } = useMemo(() => { + const forest = buildForest(p.rows, p.levels); + const byPath = new Map(); + const walk = (nodes: TreeNode[]): void => { + for (const node of nodes) { + byPath.set(node.path, node); + walk(node.children); + } + }; + walk(forest); + byPath.set(ROOT_ID, { + key: "", + label: p.title ?? "root", + path: ROOT_ID, + depth: -1, + children: forest, + }); + return { byPath, forest }; + }, [p.rows, p.levels, p.title]); + + const defaultOpen = useCallback( + (node: TreeNode): boolean => + node.children.length > 0 && + (p.expand_depth === undefined || node.depth < p.expand_depth), + [p.expand_depth], ); - // Disclosure = default policy + explicit user toggles. Deriving the default - // per node (instead of materializing a set once) keeps re-read forests - // correct: new paths get the expand_depth default; the user's explicit - // opens/closes survive by path identity. - const defaultOpen = (node: TreeNode): boolean => - node.children.length > 0 && - (p.expand_depth === undefined || node.depth < p.expand_depth); + + // Disclosure = default policy + explicit user toggles (re-read safe: new + // paths aren't in the toggle map, so they get the expand_depth default). const [userToggled, setUserToggled] = useState>( () => new Map(), ); - const isOpen = (node: TreeNode): boolean => - userToggled.get(node.path) ?? defaultOpen(node); - const toggle = (node: TreeNode): void => { - setUserToggled((prev) => new Map(prev).set(node.path, !isOpen(node))); - }; + const expandedItems = useMemo(() => { + const open: string[] = [ROOT_ID]; + for (const node of byPath.values()) { + if (node.path === ROOT_ID) continue; + if (userToggled.get(node.path) ?? defaultOpen(node)) open.push(node.path); + } + return open; + }, [byPath, userToggled, defaultOpen]); + const setExpandedItems = useCallback( + (updater: string[] | ((prev: string[]) => string[])) => { + setUserToggled((prevToggled) => { + const current = new Set(expandedItems); + const nextArr = + typeof updater === "function" ? updater(expandedItems) : updater; + const next = new Set(nextArr); + const toggled = new Map(prevToggled); + for (const path of next) { + if (!current.has(path)) toggled.set(path, true); + } + for (const path of current) { + if (!next.has(path) && path !== ROOT_ID) toggled.set(path, false); + } + return toggled; + }); + }, + [expandedItems], + ); + + // Entity-level selection: every path whose key matches the bound value. + const selectedItems = useMemo(() => { + if (!selectable || selected === undefined || selected === "") return []; + const paths: string[] = []; + for (const node of byPath.values()) { + if (node.path !== ROOT_ID && node.key === selected) paths.push(node.path); + } + return paths; + }, [byPath, selected, selectable]); + + const dispatchSelect = useCallback( + (key: string) => { + if (!selectable || key === "") return; + actions.execute({ + action: "setState", + params: { statePath: p.select_state, value: key }, + }); + }, + [actions, selectable, p.select_state], + ); + + const tree = useTree({ + rootItemId: ROOT_ID, + getItemName: (item) => item.getItemData()?.label ?? "", + isItemFolder: (item) => (item.getItemData()?.children.length ?? 0) > 0, + dataLoader: { + getItem: (id) => byPath.get(id) as TreeNode, + getChildren: (id) => (byPath.get(id)?.children ?? []).map((c) => c.path), + }, + state: { expandedItems, selectedItems }, + setExpandedItems, + // Selection is driven exclusively through the entity key in notebook + // state (dispatch → useStateValue → derived selectedItems), so the + // internal setter maps the picked path back to its key. + setSelectedItems: (updater) => { + const ids = + typeof updater === "function" ? updater(selectedItems) : updater; + const first = ids[0] !== undefined ? byPath.get(ids[0]) : undefined; + if (first !== undefined) dispatchSelect(first.key); + }, + onPrimaryAction: (item) => { + const data = item.getItemData(); + if (data !== undefined) dispatchSelect(data.key); + }, + features: [ + syncDataLoaderFeature, + selectionFeature, + hotkeysCoreFeature, + searchFeature, + ], + }); + + // New rows → new forest → re-flatten from the root. + useEffect(() => { + tree.rebuildTree(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [byPath]); if (forest.length === 0) { return ( @@ -51,69 +166,56 @@ export function Tree({ ); } - const selectable = Boolean(p.select_state); const showCounts = p.counts !== false; - const renderNode = (node: TreeNode): React.ReactElement => { - const hasChildren = node.children.length > 0; - const open = isOpen(node); - const isSelected = selectable && node.key === selected; - return ( -

  • -
    - {hasChildren ? ( - - ) : ( - - )} - - actions.execute({ - action: "setState", - params: { statePath: p.select_state, value: node.key }, - }), - } - : {})} - > - {node.label} - - {showCounts && hasChildren && ( - - {node.children.length} - - )} -
    - {hasChildren && open && ( -
      - {node.children.map(renderNode)} -
    - )} -
  • - ); - }; - return (
    {p.title && (

    {p.title}

    )} -
      {forest.map(renderNode)}
    + {tree.isSearchOpen() && ( + + )} +
    + {tree.getItems().map((item) => { + const node = item.getItemData(); + if (node === undefined) return null; + const level = item.getItemMeta().level; + const isFolder = item.isFolder(); + const isOpen = item.isExpanded(); + return ( +
    + + {isFolder ? (isOpen ? "▾" : "▸") : ""} + + {node.label} + {showCounts && isFolder && ( + + {node.children.length} + + )} +
    + ); + })} +
    ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a3d71d..dcb0439 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,12 @@ importers: '@fontsource-variable/urbanist': specifier: ^5.2.7 version: 5.2.7 + '@headless-tree/core': + specifier: ^1.7.0 + version: 1.7.0 + '@headless-tree/react': + specifier: ^1.7.0 + version: 1.7.0(@headless-tree/core@1.7.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@json-render/core': specifier: ^0.19.0 version: 0.19.0(zod@4.4.3) @@ -438,6 +444,16 @@ packages: '@fontsource-variable/urbanist@5.2.7': resolution: {integrity: sha512-OYeUoBQhwm3pbXG5yUxq1omO9H64Dx1eKxldKYOztrf+8GFG1vu3FP/zZpmFOwtgsPj2h6JYHX9VVWopSxvOyQ==} + '@headless-tree/core@1.7.0': + resolution: {integrity: sha512-LxcX7LNepwfOPrZcs4PNfDwCzbi326uCAX5jYBfw94jI+pZQA7ANaE/No3LW0XFgcBcQtK/G2bInbVEhLF1C/Q==} + + '@headless-tree/react@1.7.0': + resolution: {integrity: sha512-hcnG4mpTP98KVWZYqKsXNOoiv7ZQqZVM2jWwzEWtF+VDSz+1O/juMI+/Q0G4lZM+ez07/tmBBWjvZub/mLAUXQ==} + peerDependencies: + '@headless-tree/core': '*' + react: '*' + react-dom: '*' + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2027,6 +2043,14 @@ snapshots: '@fontsource-variable/urbanist@5.2.7': {} + '@headless-tree/core@1.7.0': {} + + '@headless-tree/react@1.7.0(@headless-tree/core@1.7.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@headless-tree/core': 1.7.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 From 24ee68c918cccb194e3caef572709e3045be0c1f Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 8 Jul 2026 17:23:35 +0300 Subject: [PATCH 2/2] Single dispatch site, updater-safe expansion diff, throwing test finder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile x3: (1) click fired dispatchSelect twice (selectionFeature's setSelectedItems AND onPrimaryAction) — two state patches per click meant double invalidation cycles for dependent cells; onPrimaryAction (which both click and Enter route through) is now the only dispatch site and the controlled-selection setter is an intentional no-op. (2) The expansion setter's functional-updater path computed against a render-time snapshot; "current" is now derived inside the state updater from the accumulated toggle map, so batched sequential updates see the true latest state. (3) itemByLabel throws a labeled error listing the tree's contents instead of tests dying on undefined.click. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh --- packages/web/src/components/Tree.test.tsx | 41 ++++++++++++-------- packages/web/src/components/Tree.tsx | 47 ++++++++++++++--------- 2 files changed, 53 insertions(+), 35 deletions(-) diff --git a/packages/web/src/components/Tree.test.tsx b/packages/web/src/components/Tree.test.tsx index 14502c8..45766c5 100644 --- a/packages/web/src/components/Tree.test.tsx +++ b/packages/web/src/components/Tree.test.tsx @@ -67,10 +67,19 @@ function mount(spec: ReturnType): { return { host, root, rerender: render }; } -const itemByLabel = (host: HTMLElement, label: string): HTMLElement | undefined => - Array.from(host.querySelectorAll('[role="treeitem"]')).find( - (el) => el.textContent?.includes(label), - ); +const itemByLabel = (host: HTMLElement, label: string): HTMLElement => { + const el = Array.from( + host.querySelectorAll('[role="treeitem"]'), + ).find((it) => it.textContent?.includes(label)); + if (el === undefined) { + throw new Error(`no treeitem labeled "${label}" — tree has: ${Array.from( + host.querySelectorAll('[role="treeitem"]'), + ) + .map((it) => it.textContent) + .join(" | ")}`); + } + return el; +}; const click = (el: HTMLElement): void => { act(() => { @@ -101,10 +110,10 @@ describe("Tree lens (web, headless-tree)", () => { expect(host.querySelector('[role="tree"]')).toBeTruthy(); const items = Array.from(host.querySelectorAll('[role="treeitem"]')); expect(items.length).toBeGreaterThanOrEqual(7); // 2 domains + 3 concepts + leaves - const systems = itemByLabel(host, "Systems")!; + const systems = itemByLabel(host, "Systems"); expect(systems.getAttribute("aria-level")).toBe("1"); expect(systems.getAttribute("aria-expanded")).toBe("true"); - const loops = itemByLabel(host, "Feedback loops")!; + const loops = itemByLabel(host, "Feedback loops"); expect(loops.getAttribute("aria-level")).toBe("2"); // count badges: Systems (2 concepts), Feedback loops (2 related) expect(systems.textContent).toContain("2"); @@ -118,7 +127,7 @@ describe("Tree lens (web, headless-tree)", () => { it("row click collapses an expanded branch", () => { const { host, root } = mount(treeSpec()); expect(host.textContent).toContain("Chunking"); - click(itemByLabel(host, "Systems")!); + click(itemByLabel(host, "Systems")); expect(host.textContent).not.toContain("Chunking"); expect(host.textContent).toContain("Bias"); // Cognitive untouched act(() => root.unmount()); @@ -135,25 +144,25 @@ describe("Tree lens (web, headless-tree)", () => { const { host, root } = mount(treeSpec()); // duplicate-entity case FIRST (a later Chunking click collapses its // subtree, hiding one Attractors occurrence): both occurrences highlight - click(itemByLabel(host, "Attractors")!); + click(itemByLabel(host, "Attractors")); const attractors = Array.from( host.querySelectorAll('[role="treeitem"]'), ).filter((el) => el.textContent?.includes("Attractors")); expect(attractors.length).toBe(2); for (const el of attractors) expect(el.className).toContain("bg-accent"); // single-entity: clicking Chunking moves the selection (and toggles it) - click(itemByLabel(host, "Chunking")!); - expect(itemByLabel(host, "Chunking")!.className).toContain("bg-accent"); - expect(itemByLabel(host, "Systems")!.className).not.toContain("bg-accent"); + click(itemByLabel(host, "Chunking")); + expect(itemByLabel(host, "Chunking").className).toContain("bg-accent"); + expect(itemByLabel(host, "Systems").className).not.toContain("bg-accent"); act(() => root.unmount()); }); it("keyboard: ArrowDown moves focus; ArrowRight expands a collapsed folder", () => { const { host, root } = mount(treeSpec({ expand_depth: 1 })); - const systems = itemByLabel(host, "Systems")!; + const systems = itemByLabel(host, "Systems"); act(() => systems.focus()); key(systems, "ArrowDown"); - const loops = itemByLabel(host, "Feedback loops")!; + const loops = itemByLabel(host, "Feedback loops"); expect(loops.getAttribute("tabindex")).toBe("0"); // roving focus moved expect(loops.getAttribute("aria-expanded")).toBe("false"); act(() => loops.focus()); @@ -164,19 +173,19 @@ describe("Tree lens (web, headless-tree)", () => { it("typeahead opens search and marks matches", () => { const { host, root } = mount(treeSpec()); - const systems = itemByLabel(host, "Systems")!; + const systems = itemByLabel(host, "Systems"); act(() => systems.focus()); key(systems, "b", "KeyB"); // typeahead matches the event CODE const input = host.querySelector('input[aria-label="Search tree"]'); expect(input).toBeTruthy(); - const bias = itemByLabel(host, "Bias")!; + const bias = itemByLabel(host, "Bias"); expect(bias.className).toContain("ring-primary"); act(() => root.unmount()); }); it("re-read rows keep the default-open policy for NEW branches; user toggles survive", () => { const { host, root, rerender } = mount(treeSpec()); - click(itemByLabel(host, "Systems")!); // user collapses Systems + click(itemByLabel(host, "Systems")); // user collapses Systems expect(host.textContent).not.toContain("Chunking"); rerender( treeSpec({}, [ diff --git a/packages/web/src/components/Tree.tsx b/packages/web/src/components/Tree.tsx index 36b5d00..fc21393 100644 --- a/packages/web/src/components/Tree.tsx +++ b/packages/web/src/components/Tree.tsx @@ -72,20 +72,31 @@ export function Tree({ const [userToggled, setUserToggled] = useState>( () => new Map(), ); - const expandedItems = useMemo(() => { - const open: string[] = [ROOT_ID]; - for (const node of byPath.values()) { - if (node.path === ROOT_ID) continue; - if (userToggled.get(node.path) ?? defaultOpen(node)) open.push(node.path); - } - return open; - }, [byPath, userToggled, defaultOpen]); + const computeExpanded = useCallback( + (toggled: ReadonlyMap): string[] => { + const open: string[] = [ROOT_ID]; + for (const node of byPath.values()) { + if (node.path === ROOT_ID) continue; + if (toggled.get(node.path) ?? defaultOpen(node)) open.push(node.path); + } + return open; + }, + [byPath, defaultOpen], + ); + const expandedItems = useMemo( + () => computeExpanded(userToggled), + [computeExpanded, userToggled], + ); const setExpandedItems = useCallback( (updater: string[] | ((prev: string[]) => string[])) => { + // "Current" is derived INSIDE the state updater from the accumulated + // toggle map, so a functional updater sees the true latest expansion + // even across batched sequential calls (not a render-time snapshot). setUserToggled((prevToggled) => { - const current = new Set(expandedItems); + const currentArr = computeExpanded(prevToggled); + const current = new Set(currentArr); const nextArr = - typeof updater === "function" ? updater(expandedItems) : updater; + typeof updater === "function" ? updater(currentArr) : updater; const next = new Set(nextArr); const toggled = new Map(prevToggled); for (const path of next) { @@ -97,7 +108,7 @@ export function Tree({ return toggled; }); }, - [expandedItems], + [computeExpanded], ); // Entity-level selection: every path whose key matches the bound value. @@ -132,14 +143,12 @@ export function Tree({ state: { expandedItems, selectedItems }, setExpandedItems, // Selection is driven exclusively through the entity key in notebook - // state (dispatch → useStateValue → derived selectedItems), so the - // internal setter maps the picked path back to its key. - setSelectedItems: (updater) => { - const ids = - typeof updater === "function" ? updater(selectedItems) : updater; - const first = ids[0] !== undefined ? byPath.get(ids[0]) : undefined; - if (first !== undefined) dispatchSelect(first.key); - }, + // state (dispatch → useStateValue → derived selectedItems). The single + // dispatch site is onPrimaryAction — both click and Enter route through + // primaryAction, and dispatching from setSelectedItems too would double + // the state patch (two invalidation cycles per click). The setter is + // still required for controlled state; it intentionally does nothing. + setSelectedItems: () => {}, onPrimaryAction: (item) => { const data = item.getItemData(); if (data !== undefined) dispatchSelect(data.key);