Skip to content

feat(lab): add useListState headless core - #2726

Closed
korvin89 wants to merge 3 commits into
mainfrom
dataui-3901-uselist-state
Closed

korvin89 wants to merge 3 commits into
mainfrom
dataui-3901-uselist-state

Conversation

@korvin89

@korvin89 korvin89 commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

🤖 AI generated

Adds useListState and the initial lab/List/core module — the headless state layer of a new List / TreeList / GridList family. Development happens in lab; nothing is wired into a public entry point yet.

What's inside

  • useListState — normalizes items (objects or plain strings) into a flat index (id / level / parent / children / childrenState / disabled / type) and derives the ordered visibleIds slice from expansion. Expansion is controlled via expandedIds or uncontrolled via defaultExpandedIds + onExpandedUpdate.
  • Incremental reconciliation — the index is rebuilt into fresh maps on each new items reference, reusing unchanged-by-reference subtrees so getters run only for changed nodes. It never mutates a previously produced index, so it is render-pure and safe under React concurrent mode.
  • ListChildrenState — async-subtree state that disambiguates getItemChildren returning undefined: a tree leaf vs an unloaded (lazy) folder, plus a loaded empty folder ([]).
  • getItemType (item / section) — structural role of a node, so a later layer can tell a selectable row from a non-interactive group label. See below.
  • TypesListChildrenState, ListItemGetters, ListItemType, UseListStateProps, ListState.

Structural item type (item / section) — why & how it's used

Adds getItemType?: (item) => 'item' | 'section' (default 'item') and a getItemType(id) accessor on ListState.

Why it lives in the data, not inferred by role

The selection hook layered on top of this state (next PR) needs a role-independent way to tell a selectable row from a non-interactive group label — it has no role and is created before the behavior layer. Inferring "a node with children is a section header" only holds for a listbox; in a tree a node with children is a selectable folder, so that rule can't live in a role-agnostic layer. Marking the role explicitly in the data (as react-aria collections do via node types) keeps selection role-agnostic.

It also decouples two axes the inference rule conflated: disclosure (expand / collapse) and selectability. A section still holds children and expands like any other node; and a node with children can be marked 'item' to act as a selectable group — even in a flat list. The type governs selection and keyboard navigation only, not disclosure; keeping a section always-open is a controlled-expansion concern (expandedIds).

How the components will use it

// List (listbox): a node with children is a non-interactive section header;
// leaves are options. Selection and keyboard nav skip the header row.
<List
  items={items}
  getItemType={(i) => (i.children ? 'section' : 'item')}
  selectionMode="multiple"
/>

// TreeList (tree): a folder IS a selectable, navigable treeitem — the default
// type, so no getItemType is needed.
<TreeList items={fsNodes} selectionMode="multiple" />

// "Clickable group" in a flat list: opt a parent row into being selectable
// while it still groups its children (impossible with the leaf-vs-header rule).
<List
  items={items}
  getItemType={(i) => (i.selectableGroup || !i.children ? 'item' : 'section')}
/>
// The selection layer then reads it with no role of its own:
const canSelect = (id: string) =>
  state.getItemType(id) === 'item' && !state.isDisabled(id);

Summary by Sourcery

Introduce a headless List core state layer with a new useListState hook that normalizes list/tree data into a structural index and derives visibleIds from expansion.

New Features:

  • Add useListState hook and ListState/UseListStateProps types for headless list and tree state management, including controlled/uncontrolled expansion.
  • Support structural item typing (item vs section), async children state, and accessors for levels, parents, children, disabled state, and expansion.
  • Expose lab List core entrypoint that re-exports useListState and related types for internal consumption.

Enhancements:

  • Implement an incremental, render-pure list state index with reconciliation and memoization to reuse unchanged subtrees and avoid recomputing getters on expansion.
  • Add performance safeguards ensuring expansion cost scales with visible nodes only and validating duplicate ids in development.
  • Document the List core module and useListState API, including getters, async subtree semantics, and returned state shape.

Tests:

  • Add comprehensive tests covering normalization, structural indexes, item type handling, expansion behavior, async subtree states, memoization, reconciliation scenarios, duplicate id warnings, and large-tree performance smoke checks.

@sourcery-ai

sourcery-ai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Introduce the headless List core with a new useListState hook, its structural index reconciliation utilities, and accompanying types, tests, and README documentation.

File-Level Changes

Change Details Files
Implement headless list/tree state management via useListState and a structural index with controlled/uncontrolled expansion.
  • Add useListState hook that normalizes items into a flat index, manages expansion state via useControlledState, and exposes ListState accessors and visibleIds.
  • Ensure expansion state supports both controlled and uncontrolled usage, with referentially stable setExpanded and accessors across expansion changes.
  • Compute visibleIds from a prebuilt index and current expansion set so expansion never re-traverses the item forest.
src/components/lab/List/core/useListState/useListState.ts
src/components/lab/List/core/useListState/types.ts
Add listStateIndex utilities to build and reconcile the structural index and derive visible node ids efficiently.
  • Define ListNode and ListStateIndex structures capturing item, hierarchy, childrenIds, disabled state, async childrenState, and structural type per node.
  • Implement resolveStructuralGetters with sensible defaults for id, children, disabled, childrenState, and item type, including support for primitive string items.
  • Implement reconcileListStateIndex that rebuilds a fresh map per items reference, reusing unchanged-by-reference subtrees and warning on duplicate ids in dev mode.
  • Implement computeVisibleIds that flattens only expanded paths so cost is proportional to visible nodes in large, mostly-collapsed trees.
src/components/lab/List/core/useListState/listStateIndex.ts
Define core List types for async subtree state, structural item type, and configurable data getters, and expose a lab List core entrypoint.
  • Introduce ListChildrenState, ListItemType, and ListItemGetters with documented defaults and behavior for async loading and structural roles.
  • Define UseListStateProps and ListState interfaces describing the useListState API surface and its accessors for hierarchy, disabled state, expansion, and type.
  • Add lab List core index modules that export useListState and associated types for internal consumption.
  • Document the List core headless API, useListState contract, and property/return shapes in a new README.
src/components/lab/List/core/types.ts
src/components/lab/List/core/useListState/types.ts
src/components/lab/List/core/index.ts
src/components/lab/List/core/useListState/index.ts
src/components/lab/List/core/README.md
Add comprehensive unit tests for useListState covering normalization, type handling, expansion behavior, memoization, reconciliation, and performance characteristics.
  • Test normalization for primitive string items and object items with default and custom getters for id and disabled state.
  • Validate item type behavior including defaults, custom getItemType usage, reuse of types on unchanged subtrees, and stability across updates.
  • Exercise structural index accessors for level, parentId, childrenIds, and visibleIds under various expansion scenarios (controlled and uncontrolled).
  • Cover async subtree ListChildrenState semantics for lazy, loading, loaded empty, and leaf nodes.
  • Verify memoization and incremental reconciliation behavior including subtree reuse, pruning removed nodes, moving/reordering nodes, and duplicate-id dev warnings.
  • Add a large-tree bench-smoke test ensuring expansion does not walk hidden nodes and visibleIds cost is proportional to visible nodes.
src/components/lab/List/core/useListState/__tests__/useListState.test.tsx

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@gravity-ui

gravity-ui Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Preview is ready.

@gravity-ui

gravity-ui Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

🎭 Component Tests Report is ready.

@korvin89
korvin89 force-pushed the dataui-3901-uselist-state branch from 4047c3b to 01ae96f Compare July 3, 2026 18:51
@korvin89
korvin89 marked this pull request as ready for review July 3, 2026 19:42
@korvin89
korvin89 requested review from ValeraS and amje as code owners July 3, 2026 19:42

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • In core/index.ts you re-export ListState and UseListStateProps from ./useListState, but those types are defined in useListState/types.ts and not re-exported from useListState.ts, so this barrel export will fail; update the export to source them from ./useListState/types (or re-export them from useListState.ts).
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `core/index.ts` you re-export `ListState` and `UseListStateProps` from `./useListState`, but those types are defined in `useListState/types.ts` and not re-exported from `useListState.ts`, so this barrel export will fail; update the export to source them from `./useListState/types` (or re-export them from `useListState.ts`).

## Individual Comments

### Comment 1
<location path="src/components/lab/List/core/useListState/listStateIndex.ts" line_range="126" />
<code_context>
+    };
+
+    const reconcileNode = (item: T, level: number, parentId: string | undefined): string => {
+        const id = getItemId(item);
+
+        // Unchanged reference at the same position ⇒ the whole subtree is intact; copy it over.
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Consider adding dev-time validation that `getItemId` returns a string to surface misconfigurations earlier.

The code currently trusts `getItemId` to always return a string, but if a consumer returns a non-string (e.g. number or `null`), the maps will still accept it and cause subtle, hard-to-debug behavior. Adding a `NODE_ENV !== 'production'` check that asserts `typeof id === 'string'` and logs a clear error (like the duplicate-id check) would catch these misconfigurations early without impacting production performance.

Suggested implementation:

```typescript
    const reconcileNode = (item: T, level: number, parentId: string | undefined): string => {
        const id = getItemId(item);

        if (process.env.NODE_ENV !== 'production' && typeof id !== 'string') {
            // Dev-time validation: surface misconfigured getItemId implementations early.
            // Using console.error to match duplicate-id style diagnostics without affecting production.
            console.error(
                [
                    'MUI: ListStateIndex#getItemId should return a string.',
                    `Received: ${String(id)} (type: ${typeof id}).`,
                    'This can cause subtle bugs in list state reconciliation. ',
                    'Ensure getItemId(item) always returns a stable string identifier.',
                ].join(' '),
            );
        }

        // Unchanged reference at the same position ⇒ the whole subtree is intact; copy it over.
        if (
            previous &&
            previous.itemById.get(id) === item &&
            previous.levelById.get(id) === level &&
            previous.parentById.get(id) === parentId
        ) {
            copyReusedSubtree(id);
            return id;
        }


```

1. If this file already has a shared dev-only logging helper (e.g. a `warn()` / `error()` utility or a MUI-specific diagnostic function), consider replacing the `console.error` call with that helper for consistency.
2. If there are existing tests around duplicate-id diagnostics, you may want to add a similar test case asserting that a non-string id triggers this warning in development mode and is silent in production mode.
</issue_to_address>

### Comment 2
<location path="src/components/lab/List/core/useListState/listStateIndex.ts" line_range="146" />
<code_context>
+        disabledById.set(id, getItemDisabled(item));
+        childrenStateById.set(id, getItemChildrenState(item));
+
+        const childItems = getItemChildren(item);
+        let childIds: string[] | undefined;
+        if (childItems === undefined) {
</code_context>
<issue_to_address>
**suggestion:** Handle or guard against non-array values from `getItemChildren` to avoid hard-to-debug runtime errors.

This path assumes any truthy `getItemChildren` result is a dense array, since it uses `.length` and index access. If a consumer returns a NodeList, Set, sparse array, or other non-array truthy value, you can get subtle runtime failures. Consider a dev-only `Array.isArray(childItems)` assertion before using `.length` and indices to catch misconfigurations early without affecting production performance.

Suggested implementation:

```typescript
        childrenStateById.set(id, getItemChildrenState(item));

        const childItems = getItemChildren(item);

        if (process.env.NODE_ENV !== 'production' && childItems !== undefined && !Array.isArray(childItems)) {
            throw new Error(
                'getItemChildren must return an array or undefined. ' +
                `Received: ${Object.prototype.toString.call(childItems)} for item with id "${id}".`,
            );
        }

        let childIds: string[] | undefined;
        if (childItems === undefined) {
            childIds = undefined;
        } else {
            childIds = new Array<string>(childItems.length);
            for (let i = 0; i < childItems.length; i++) {
                childIds[i] = reconcileNode(childItems[i], level + 1, id);
            }
        }
        childrenIdsById.set(id, childIds);

```

If this codebase already uses a specific dev flag or assertion utility (e.g. `__DEV__`, `warning`, or `invariant`), you should replace the `process.env.NODE_ENV !== 'production'` check and `throw new Error(...)` with that existing convention to keep the implementation consistent.
</issue_to_address>

### Comment 3
<location path="src/components/lab/List/core/useListState/listStateIndex.ts" line_range="14" />
<code_context>
+ * read-only source to reuse unchanged subtrees, which keeps *getter* work proportional to the
+ * change; the map allocation itself is O(total nodes) of cheap `Map.set` calls.
+ */
+export interface ListStateIndex<T> {
+    /** The `items` reference this index was reconciled from — used to short-circuit rebuilds */
+    sourceItems: T[];
</code_context>
<issue_to_address>
**issue (complexity):** Consider collapsing the parallel maps into a single node map and extracting duplicate-id tracking into a helper to simplify and decouple the list index logic.

You can keep the subtree-reuse behavior but significantly reduce the cross-map coupling by introducing a single `nodeById` map and pushing duplicate tracking into a thin wrapper.

### 1. Replace parallel maps with a single `nodeById`

Instead of manually keeping six maps in sync, store a unified node record. This makes `copyReusedSubtree` and `reconcileNode` much easier to reason about.

```ts
interface NodeInfo<T> {
    item: T;
    level: number;
    parentId?: string;
    childrenIds?: string[];
    childrenState?: ListChildrenState;
    disabled: boolean;
}

interface ListStateIndex<T> {
    sourceItems: T[];
    rootIds: string[];
    nodeById: Map<string, NodeInfo<T>>;
}
```

Then `copyReusedSubtree` and `reconcileNode` only deal with one map:

```ts
const nodeById = new Map<string, NodeInfo<T>>();

const copyReusedSubtree = (id: string) => {
    const prevNode = (previous as ListStateIndex<T>).nodeById.get(id);
    if (!prevNode) return;

    markSeen(id);
    nodeById.set(id, prevNode); // shallow copy is fine if NodeInfo is treated as immutable

    const childIds = prevNode.childrenIds;
    if (childIds) {
        for (let i = 0; i < childIds.length; i++) {
            copyReusedSubtree(childIds[i]);
        }
    }
};

const reconcileNode = (item: T, level: number, parentId: string | undefined): string => {
    const id = getItemId(item);

    if (
        previous &&
        previous.nodeById.get(id)?.item === item &&
        previous.nodeById.get(id)?.level === level &&
        previous.nodeById.get(id)?.parentId === parentId
    ) {
        copyReusedSubtree(id);
        return id;
    }

    markSeen(id);

    const childItems = getItemChildren(item);
    const childIds =
        childItems === undefined
            ? undefined
            : childItems.map((child) => reconcileNode(child, level + 1, id));

    nodeById.set(id, {
        item,
        level,
        parentId,
        childrenIds: childIds,
        childrenState: getItemChildrenState(item),
        disabled: getItemDisabled(item),
    });

    return id;
};
```

`computeVisibleIds` then only reads from `nodeById`:

```ts
export function computeVisibleIds<T>(
    index: ListStateIndex<T>,
    expandedIds: ReadonlySet<string>,
): string[] {
    const result: string[] = [];

    const walk = (ids: string[]) => {
        for (let i = 0; i < ids.length; i++) {
            const id = ids[i];
            result.push(id);

            const node = index.nodeById.get(id);
            const childIds = node?.childrenIds;
            if (childIds && childIds.length > 0 && expandedIds.has(id)) {
                walk(childIds);
            }
        }
    };

    walk(index.rootIds);
    return result;
}
```

This preserves all existing behavior (including subtree reuse) but removes the need to keep multiple maps perfectly aligned.

### 2. Isolate duplicate-id tracking from core traversal

You can keep the same validation but keep the traversal logic cleaner by wrapping `markSeen` creation:

```ts
function createMarkSeen() {
    if (process.env.NODE_ENV === 'production') {
        return (_id: string) => {};
    }
    const seen = new Set<string>();
    return (id: string) => {
        if (seen.has(id)) {
            console.error(
                `[useListState] Duplicate item id "${id}". Item ids must be unique — pass getItemId to derive stable, unique ids.`,
            );
        }
        seen.add(id);
    };
}

// in reconcileListStateIndex:
const markSeen = createMarkSeen();
```

This keeps debugging concerns out of the core reconciliation code paths, while retaining the same validation behavior.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/components/lab/List/core/useListState/listStateIndex.ts
Comment thread src/components/lab/List/core/useListState/listStateIndex.ts
Comment thread src/components/lab/List/core/useListState/listStateIndex.ts
Per Sourcery review on the PR: replace the six parallel per-field maps
(itemById / levelById / parentById / childrenIdsById / childrenStateById /
disabledById) with one `nodeById` map of node records. Reduces cross-map
coupling and makes subtree reuse cheaper (an unchanged subtree reuses its
immutable records by reference). Behavior unchanged; tests green.
@korvin89

korvin89 commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI generated

Re: Sourcery's overall review note:

In core/index.ts you re-export ListState and UseListStateProps from ./useListState, but those types are defined in useListState/types.ts and not re-exported from useListState.ts, so this barrel export will fail.

False positive: there is no core/useListState.ts file. ./useListState resolves to the core/useListState/ directory, whose index.ts re-exports those types from ./types. tsc --noEmit passes, so the barrel resolves and compiles.

@korvin89
korvin89 marked this pull request as draft July 3, 2026 21:10
Add a `getItemType` getter (`'item' | 'section'`, default `'item'`) and a
`getItemType(id)` accessor on `ListState`, plus a `ListItemType` export.

The upcoming selection hook needs a role-independent way to tell a selectable
row from a non-interactive group label. A "leaf is an option, node with children
is a section header" rule holds only for `listbox` and misclassifies a `tree`
folder — and the selection hook has no role and runs before the behavior layer,
so it cannot consult one. Marking the role explicitly in data (as react-aria
collections do) keeps selection role-agnostic and, unlike the inference rule,
lets a node with children also be a selectable group in a flat list — disclosure
and selectability stay independent axes.

Type is purely structural: it governs selection and navigation, not disclosure.
A `section` still holds children and expands like any node; keeping a section
always-open is a controlled-expansion concern.
@korvin89
korvin89 marked this pull request as ready for review July 6, 2026 13:44

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src/components/lab/List/core/useListState/__tests__/useListState.test.tsx" line_range="22-31" />
<code_context>
+    describe('normalization', () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test for an empty items array to assert baseline behavior.

There’s no test explicitly covering the `items: []` case. Please add something like `useListState({ items: [] })` that asserts `visibleIds` is `[]`, `getItemById` returns `undefined` for any id, and no errors/logs occur, to lock in the empty-list behavior and guard against regressions in index initialization.
</issue_to_address>

### Comment 2
<location path="src/components/lab/List/core/useListState/__tests__/useListState.test.tsx" line_range="134-143" />
<code_context>
+    describe('visibleIds & expansion', () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding a test for expanding/collapsing an id that does not exist in the index.

Current expansion tests only cover valid ids. Since `setExpanded` operates on the expanded id set, calling `setExpanded('missing', true)` should neither throw nor change `visibleIds` when no node exists for that id. Please add a test to confirm unknown ids are handled safely (no crash, no `visibleIds` changes), to improve robustness against stale ids from callers.

Suggested implementation:

```typescript
    describe('visibleIds & expansion', () => {
        it('shows only roots when collapsed', () => {
            const {result} = renderHook(() => useListState({items: tree}));
            expect(result.current.visibleIds).toEqual(['root', 'sibling']);
        });

        it('expands and collapses (uncontrolled)', () => {
            const {result} = renderHook(() => useListState({items: tree, defaultExpandedIds: []}));

            act(() => result.current.setExpanded('root', true));
            expect(result.current.visibleIds).toEqual(['root', 'c1', 'c2', 'sibling']);
        });

        it('ignores expansion/collapse requests for unknown ids', () => {
            const {result} = renderHook(() => useListState({items: tree, defaultExpandedIds: []}));

            const initialVisibleIds = result.current.visibleIds;

            act(() => {
                result.current.setExpanded('missing', true);
            });
            expect(result.current.visibleIds).toEqual(initialVisibleIds);

            act(() => {
                result.current.setExpanded('missing', false);
            });
            expect(result.current.visibleIds).toEqual(initialVisibleIds);

```

If the actual test structure around `visibleIds & expansion` differs from the shown snippet (e.g., more expectations inside the uncontrolled expansion test or different indentation), adjust the SEARCH block to match the exact code segment and reapply the same REPLACE content. Also ensure that `renderHook`, `act`, and `useListState` are already imported at the top of the file as in the existing tests; no new imports are required for this change.
</issue_to_address>

### Comment 3
<location path="src/components/lab/List/core/useListState/listStateIndex.ts" line_range="97" />
<code_context>
+
+    const nodeById = new Map<string, ListNode<T>>();
+
+    const seen = process.env.NODE_ENV === 'production' ? null : new Set<string>();
+    const markSeen = (id: string) => {
+        if (process.env.NODE_ENV !== 'production' && seen) {
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the duplicate-id checking into a factory that returns a no-op in production so `markSeen` is simple and free of environment checks in the traversal code.

You can simplify the duplicate-id tracking and remove the environment-dependent branching from the hot path by encapsulating it in a helper, while keeping the behavior (dev-only warnings) intact.

Right now every `markSeen` call pays the cost of checking `NODE_ENV` and whether `seen` is defined:

```ts
const seen = process.env.NODE_ENV === 'production' ? null : new Set<string>();
const markSeen = (id: string) => {
    if (process.env.NODE_ENV !== 'production' && seen) {
        if (seen.has(id)) {
            console.error(/* ... */);
        }
        seen.add(id);
    }
};
```

Instead, define `markSeen` once based on `NODE_ENV`, and make the production version a no-op. This removes branching and makes the reconciliation traversal easier to follow:

```ts
const createDuplicateChecker = () => {
    if (process.env.NODE_ENV === 'production') {
        // No-op in production
        return (_id: string) => {};
    }

    const seen = new Set<string>();
    return (id: string) => {
        if (seen.has(id)) {
            console.error(
                `[useListState] Duplicate item id "${id}". Item ids must be unique — pass getItemId to derive stable, unique ids.`,
            );
        }
        seen.add(id);
    };
};

const markSeen = createDuplicateChecker();
```

Then the reconciliation logic can use `markSeen(id)` without any extra guards:

```ts
const copyReusedSubtree = (id: string) => {
    const node = previous?.nodeById.get(id);
    if (!node) {
        return;
    }
    markSeen(id);
    nodeById.set(id, node);
    if (node.childrenIds) {
        for (let i = 0; i < node.childrenIds.length; i++) {
            copyReusedSubtree(node.childrenIds[i]);
        }
    }
};

const reconcileNode = (item: T, level: number, parentId: string | undefined): string => {
    const id = getItemId(item);

    const prevNode = previous?.nodeById.get(id);
    if (prevNode && prevNode.item === item && prevNode.level === level && prevNode.parentId === parentId) {
        copyReusedSubtree(id);
        return id;
    }

    markSeen(id);
    // ...
};
```

This keeps all functionality (dev-only duplicate warnings, subtree reuse, immutability) but reduces the cognitive load in the core traversal by separating environment concerns from reconciliation.
</issue_to_address>

### Comment 4
<location path="src/components/lab/List/core/useListState/useListState.ts" line_range="41" />
<code_context>
+
+    const expandedSet = React.useMemo(() => new Set(currentExpandedIds), [currentExpandedIds]);
+
+    // Refs keep `setExpanded` referentially stable while reading the latest expansion and setter.
+    const expandedIdsRef = React.useRef(currentExpandedIds);
+    expandedIdsRef.current = currentExpandedIds;
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying the expansion handling by removing ref-backed state and collapsing memoized accessors into a single memoized return object.

The main complexity comes from the ref-backed `setExpanded` and the layered `useMemo` usage. You can keep all functionality while simplifying by:

1. **Drop the refs and accept changing `setExpanded` identity**

You can rely on React’s normal handler identity changes instead of ref indirection. This removes three refs and makes data flow clearer:

```ts
const [currentExpandedIds, setExpandedIds] = useControlledState(
  expandedIds,
  defaultExpandedIds ?? EMPTY_IDS,
  onExpandedUpdate,
);

const expandedSet = React.useMemo(
  () => new Set(currentExpandedIds),
  [currentExpandedIds],
);

const setExpanded = React.useCallback(
  (id: string, expanded: boolean) => {
    if (expandedSet.has(id) === expanded) {
      return;
    }

    const next = expanded
      ? [...currentExpandedIds, id]
      : currentExpandedIds.filter((expandedId) => expandedId !== id);

    setExpandedIds(next);
  },
  [expandedSet, currentExpandedIds, setExpandedIds],
);
```

This keeps behavior identical (controlled/uncontrolled handling + expansion logic), but removes `expandedIdsRef`, `expandedSetRef`, and `setExpandedIdsRef`.

2. **Inline accessors into a single `useMemo` for the returned state**

You can skip the intermediate `accessors` memo and assemble the full `ListState` in one place:

```ts
const visibleIds = React.useMemo(
  () => computeVisibleIds(index, expandedSet),
  [index, expandedSet],
);

return React.useMemo<ListState<T>>(
  () => ({
    getItemById: (id: string) => index.nodeById.get(id)?.item,
    getLevel: (id: string) => index.nodeById.get(id)?.level ?? 0,
    getParentId: (id: string) => index.nodeById.get(id)?.parentId,
    getChildrenIds: (id: string) => index.nodeById.get(id)?.childrenIds,
    getChildrenState: (id: string) => index.nodeById.get(id)?.childrenState,
    isDisabled: (id: string) => index.nodeById.get(id)?.disabled ?? false,
    getItemType: (id: string) => index.nodeById.get(id)?.type ?? 'item',
    visibleIds,
    isExpanded: (id) => expandedSet.has(id),
    setExpanded,
  }),
  [index, visibleIds, expandedSet, setExpanded],
);
```

This keeps memoization and all existing getters but removes one level of indirection, making the return shape easier to follow.

These two focused changes reduce indirection and memo layering without altering the feature set or the controlled/uncontrolled behavior.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +22 to +31
describe('normalization', () => {
it('handles primitive string items with zero getters', () => {
const {result} = renderHook(() => useListState({items: ['Apple', 'Pear', 'Plum']}));

expect(result.current.visibleIds).toEqual(['Apple', 'Pear', 'Plum']);
expect(result.current.getItemById('Apple')).toBe('Apple');
expect(result.current.getLevel('Apple')).toBe(0);
expect(result.current.getParentId('Apple')).toBeUndefined();
expect(result.current.getChildrenIds('Apple')).toBeUndefined();
expect(result.current.isDisabled('Apple')).toBe(false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a test for an empty items array to assert baseline behavior.

There’s no test explicitly covering the items: [] case. Please add something like useListState({ items: [] }) that asserts visibleIds is [], getItemById returns undefined for any id, and no errors/logs occur, to lock in the empty-list behavior and guard against regressions in index initialization.

Comment on lines +134 to +143
describe('visibleIds & expansion', () => {
it('shows only roots when collapsed', () => {
const {result} = renderHook(() => useListState({items: tree}));
expect(result.current.visibleIds).toEqual(['root', 'sibling']);
});

it('expands and collapses (uncontrolled)', () => {
const {result} = renderHook(() => useListState({items: tree, defaultExpandedIds: []}));

act(() => result.current.setExpanded('root', true));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding a test for expanding/collapsing an id that does not exist in the index.

Current expansion tests only cover valid ids. Since setExpanded operates on the expanded id set, calling setExpanded('missing', true) should neither throw nor change visibleIds when no node exists for that id. Please add a test to confirm unknown ids are handled safely (no crash, no visibleIds changes), to improve robustness against stale ids from callers.

Suggested implementation:

    describe('visibleIds & expansion', () => {
        it('shows only roots when collapsed', () => {
            const {result} = renderHook(() => useListState({items: tree}));
            expect(result.current.visibleIds).toEqual(['root', 'sibling']);
        });

        it('expands and collapses (uncontrolled)', () => {
            const {result} = renderHook(() => useListState({items: tree, defaultExpandedIds: []}));

            act(() => result.current.setExpanded('root', true));
            expect(result.current.visibleIds).toEqual(['root', 'c1', 'c2', 'sibling']);
        });

        it('ignores expansion/collapse requests for unknown ids', () => {
            const {result} = renderHook(() => useListState({items: tree, defaultExpandedIds: []}));

            const initialVisibleIds = result.current.visibleIds;

            act(() => {
                result.current.setExpanded('missing', true);
            });
            expect(result.current.visibleIds).toEqual(initialVisibleIds);

            act(() => {
                result.current.setExpanded('missing', false);
            });
            expect(result.current.visibleIds).toEqual(initialVisibleIds);

If the actual test structure around visibleIds & expansion differs from the shown snippet (e.g., more expectations inside the uncontrolled expansion test or different indentation), adjust the SEARCH block to match the exact code segment and reapply the same REPLACE content. Also ensure that renderHook, act, and useListState are already imported at the top of the file as in the existing tests; no new imports are required for this change.


const nodeById = new Map<string, ListNode<T>>();

const seen = process.env.NODE_ENV === 'production' ? null : new Set<string>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the duplicate-id checking into a factory that returns a no-op in production so markSeen is simple and free of environment checks in the traversal code.

You can simplify the duplicate-id tracking and remove the environment-dependent branching from the hot path by encapsulating it in a helper, while keeping the behavior (dev-only warnings) intact.

Right now every markSeen call pays the cost of checking NODE_ENV and whether seen is defined:

const seen = process.env.NODE_ENV === 'production' ? null : new Set<string>();
const markSeen = (id: string) => {
    if (process.env.NODE_ENV !== 'production' && seen) {
        if (seen.has(id)) {
            console.error(/* ... */);
        }
        seen.add(id);
    }
};

Instead, define markSeen once based on NODE_ENV, and make the production version a no-op. This removes branching and makes the reconciliation traversal easier to follow:

const createDuplicateChecker = () => {
    if (process.env.NODE_ENV === 'production') {
        // No-op in production
        return (_id: string) => {};
    }

    const seen = new Set<string>();
    return (id: string) => {
        if (seen.has(id)) {
            console.error(
                `[useListState] Duplicate item id "${id}". Item ids must be unique — pass getItemId to derive stable, unique ids.`,
            );
        }
        seen.add(id);
    };
};

const markSeen = createDuplicateChecker();

Then the reconciliation logic can use markSeen(id) without any extra guards:

const copyReusedSubtree = (id: string) => {
    const node = previous?.nodeById.get(id);
    if (!node) {
        return;
    }
    markSeen(id);
    nodeById.set(id, node);
    if (node.childrenIds) {
        for (let i = 0; i < node.childrenIds.length; i++) {
            copyReusedSubtree(node.childrenIds[i]);
        }
    }
};

const reconcileNode = (item: T, level: number, parentId: string | undefined): string => {
    const id = getItemId(item);

    const prevNode = previous?.nodeById.get(id);
    if (prevNode && prevNode.item === item && prevNode.level === level && prevNode.parentId === parentId) {
        copyReusedSubtree(id);
        return id;
    }

    markSeen(id);
    // ...
};

This keeps all functionality (dev-only duplicate warnings, subtree reuse, immutability) but reduces the cognitive load in the core traversal by separating environment concerns from reconciliation.


const expandedSet = React.useMemo(() => new Set(currentExpandedIds), [currentExpandedIds]);

// Refs keep `setExpanded` referentially stable while reading the latest expansion and setter.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider simplifying the expansion handling by removing ref-backed state and collapsing memoized accessors into a single memoized return object.

The main complexity comes from the ref-backed setExpanded and the layered useMemo usage. You can keep all functionality while simplifying by:

  1. Drop the refs and accept changing setExpanded identity

You can rely on React’s normal handler identity changes instead of ref indirection. This removes three refs and makes data flow clearer:

const [currentExpandedIds, setExpandedIds] = useControlledState(
  expandedIds,
  defaultExpandedIds ?? EMPTY_IDS,
  onExpandedUpdate,
);

const expandedSet = React.useMemo(
  () => new Set(currentExpandedIds),
  [currentExpandedIds],
);

const setExpanded = React.useCallback(
  (id: string, expanded: boolean) => {
    if (expandedSet.has(id) === expanded) {
      return;
    }

    const next = expanded
      ? [...currentExpandedIds, id]
      : currentExpandedIds.filter((expandedId) => expandedId !== id);

    setExpandedIds(next);
  },
  [expandedSet, currentExpandedIds, setExpandedIds],
);

This keeps behavior identical (controlled/uncontrolled handling + expansion logic), but removes expandedIdsRef, expandedSetRef, and setExpandedIdsRef.

  1. Inline accessors into a single useMemo for the returned state

You can skip the intermediate accessors memo and assemble the full ListState in one place:

const visibleIds = React.useMemo(
  () => computeVisibleIds(index, expandedSet),
  [index, expandedSet],
);

return React.useMemo<ListState<T>>(
  () => ({
    getItemById: (id: string) => index.nodeById.get(id)?.item,
    getLevel: (id: string) => index.nodeById.get(id)?.level ?? 0,
    getParentId: (id: string) => index.nodeById.get(id)?.parentId,
    getChildrenIds: (id: string) => index.nodeById.get(id)?.childrenIds,
    getChildrenState: (id: string) => index.nodeById.get(id)?.childrenState,
    isDisabled: (id: string) => index.nodeById.get(id)?.disabled ?? false,
    getItemType: (id: string) => index.nodeById.get(id)?.type ?? 'item',
    visibleIds,
    isExpanded: (id) => expandedSet.has(id),
    setExpanded,
  }),
  [index, visibleIds, expandedSet, setExpanded],
);

This keeps memoization and all existing getters but removes one level of indirection, making the return shape easier to follow.

These two focused changes reduce indirection and memo layering without altering the feature set or the controlled/uncontrolled behavior.

@korvin89 korvin89 closed this Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant