📝(frontend) document explorer selection store architecture

Add an architecture doc covering the rationale behind the selection
store refactor: why React Context did not scale to 200+ rows during a
marquee drag, why useSyncExternalStore with per-id listeners enables
automatic bailout, and how global vs embedded scopes are wired.

Lives under docs/architecture/ for long-term reference.
This commit is contained in:
Nathan Panchout
2026-04-14 09:22:59 +02:00
parent 7ef7d56317
commit 3a730b87cf

View File

@@ -0,0 +1,393 @@
# Explorer selection — external store pattern
## Context and symptom
In Drive, the user can select multiple files or folders in the explorer either
by clicking them one by one, or by drawing a rubber-band (marquee) rectangle
with the mouse — every item the rectangle covers is added to the selection.
On folders with a handful of items it works fine. But as soon as a folder
holds **~200+ items on the same page**, the marquee drag becomes janky: small
freezes / FPS drops happen every time the mouse enters or leaves an item. The
experience is broken on large folders — precisely the folders where multi
selection matters the most.
### Isolating the culprit
A simple experiment: replacing every cell body with a plain `<div>hello</div>`
makes the lag **disappear completely**. Conclusion: the cost is **not** in
updating the selection state itself, but in the **render cost of the heavy
cells** (icons, tooltips, dnd-kit `<Draggable>` / `<Droppable>`, etc.) which
were all re-rendered on every mouse tick.
## Diagnosis — where did the render storm come from?
Before this refactor, the selection state lived in a `useState<Item[]>` on the
`GlobalExplorerContext` provider. Any change called `setSelectedItems`, which
triggered the following chain on **every mousemove** that touched an item:
1. **`AppExplorerInner.onSelectionMove`** — the viselect callback fires on
every mousemove and calls `setSelectedItems(prev => [...])`.
2. **`GlobalExplorerContext`** — new state → new provider value → every
consumer of `useGlobalExplorer()` re-renders (there are many across the
tree).
3. **`EmbeddedExplorerGrid`** — recomputes a local `selectedItemsMap` via
`useMemo` that depends on the new array reference → new map.
4. **`contextValue` of the grid** — `useMemo(..., [props, ...])` where `props`
is a brand new object on every parent render, so this `useMemo` was
effectively useless — it produced a new value every time.
5. **`<tbody>`** — the 200+ rows were mapped **inline** without `React.memo`,
so a parent re-render meant 200 `<tr>` re-renders.
6. **Cells** (`NameCell`, `ActionsCell`, `MobileCell`) — consumed
`selectedItemsMap` via `useEmbeddedExplorerGridContext()`. No memoization.
All 200+ cells re-rendered **even when their own `isSelected` had not
changed**.
7. **Heavy subtrees inside cells**`<Draggable>` (dnd-kit), `<Tooltip>`,
`<ItemIcon>`, `<Icon>`, `<LoadingRing>`, `<Droppable>` — all re-rendered on
every tick for 200 rows.
In other words: a mousemove that only covered **3 new items** still forced
**200 full re-renders** of heavy cells, not 3.
An existing comment in `EmbeddedExplorerGrid.tsx` already admitted the
problem:
> The context is only here to avoid the rerendering of react table cells
> when passing props to cells, with a context we avoid that by passing props
> via context, but it's quite overkill, unfortunately we did not find a
> better solution.
Routing through a React Context does **not** solve the problem — every
consumer of a Context re-renders as soon as its value changes.
## Chosen strategy
**Goal:** during a marquee drag, only the cells whose `isSelected` actually
flipped should re-render. Everything else (parent `EmbeddedExplorerGrid`, the
197 unaffected rows, components higher up in the tree) must stay inert.
**Approach:** replace the React `selectedItems: Item[]` state with a small
**external store**, compatible with React 18's `useSyncExternalStore`, that
exposes **per-id selector hooks**. React bails out automatically when the
snapshot returned by `getSnapshot` is equal (by `Object.is`) to the previous
value → a component subscribed via `useIsItemSelected(id)` only re-renders
when its boolean actually flips from `false → true` or `true → false`.
No new dependency: no Zustand or similar — a tiny store class plus
`useSyncExternalStore` is enough (~150 lines).
### Why `useSyncExternalStore` and not something else?
This is the core of the refactor: it is what makes the **per-id bailout**
possible. Alternatives we considered:
#### Alternative 1 — `useState` + React Context
That was exactly what we had before. Structural issue: **a React Context
notifies all of its consumers whenever its value changes**, with no way to
filter by consumer. You cannot tell a row "only re-render when YOUR id flips",
because React has no visibility into what each consumer actually looks at.
Cannot scale to 200+ rows without tree-wide re-renders.
#### Alternative 2 — `useState` + prop-drilling with `React.memo`
Pass `isSelected: boolean` as a prop to each memoized row. Works in theory,
but in practice:
- The parent (`EmbeddedExplorerGrid`) has to subscribe to the full
`selectedItems` array to know what to pass to each row → it re-renders on
every marquee tick.
- Even if `React.memo` bails each row, **the parent still runs the body of
`.map()` 200 times and creates 200 fresh prop objects**. Cheaper than
re-rendering 200 subtrees, but still wasteful.
- That parent re-render cascades to everything non-memoized in its subtree
(contextValue, internal `useMemo`s, etc.).
#### Alternative 3 — Custom event emitter + local `useState` per row
Each row subscribes via `useEffect` to an event emitter and keeps its own
`useState<boolean>` for `isSelected`. Works, but:
- That is **exactly** what `useSyncExternalStore` does internally, only with
proper SSR support (`getServerSnapshot`), concurrent-mode consistency
(tearing under Concurrent Mode), and unmount cleanup.
- With a `useEffect` + `useState` roll-your-own, there is a small lag between
the change and the row render (the effect only runs after commit), which
can cause visual tearing under rapid updates.
- `useSyncExternalStore` guarantees **synchronous consistency**: the row
always sees the current store value, never a stale one.
#### What `useSyncExternalStore` does in practice
Its signature is:
```ts
useSyncExternalStore<T>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T
```
Three functions, React handles the rest:
1. **Calls `subscribe`** once on mount, keeps the teardown for unmount. We are
handed a callback (`onStoreChange`) that we invoke whenever we want
subscribers to re-check the snapshot.
2. **Calls `getSnapshot` on every render** and **every time `onStoreChange`
fires**. It compares the new value to the previous one **with `Object.is`**.
If they are equal, React **bails out** the component render — the
function body does not even run.
3. Guarantees consistency: if the value changes mid-render, React re-runs the
render with the fresh value (no tearing under Concurrent Mode).
That `Object.is` on the snapshot is what makes this design work. For
`useIsItemSelected(id)`, `getSnapshot` returns a **primitive boolean**. Two
equal booleans are `Object.is`-equal → React bails **automatically**, with no
`React.memo`, no custom comparator, no gymnastics.
#### The detail that makes it all work: per-id listeners
`useSyncExternalStore` alone would not be enough if we notified **every**
listener on every change. Imagine: add an item to the selection, notify all
rows → each row re-computes its snapshot → React compares, bails for 199
rows, re-renders 1. That is better than the old situation (no tree-wide
re-render), but every tick still fires 200 `getSnapshot` calls.
`SelectionStore.setSelectedItems` goes further: it diffs **the previous and
new id Maps** (`changedIds`) and only notifies the listeners of ids whose
status actually changed:
```ts
const changedIds = new Set<string>();
prevMap.forEach((_, id) => {
if (!nextMap.has(id)) changedIds.add(id);
});
nextMap.forEach((_, id) => {
if (!prevMap.has(id)) changedIds.add(id);
});
changedIds.forEach((id) => {
this.idListeners.get(id)?.forEach((l) => l());
});
```
Result: if 2 items are added to the selection during a tick, we notify
**exactly 2 listeners**, not 200. React re-renders those 2 rows, and does
literally **nothing** for the other 198 — no render, no `getSnapshot`, not
even an `Object.is`. Optimal.
If the caller passes a new array reference whose id-set is identical to the
previous one (e.g. shift-click over a range that is already fully selected),
the store keeps the previous reference and no listener is woken up at all.
#### One-sentence summary
`useSyncExternalStore` is the only React primitive that simultaneously
(a) subscribes to state living **outside** React, (b) gives **automatic
bailout** when the scoped value does not change, and (c) guarantees
**synchronous, tearing-free rendering** — exactly the three properties needed
so that a marquee tick only re-renders the affected rows.
## Implementation details
### 1. The selection store
File: `src/frontend/apps/drive/src/features/explorer/stores/selectionStore.ts`
Contains:
- A `SelectionStore` class:
- Internal state: an `Item[]` (stable snapshot for consumers of the full
list) and a `Map<string, Item>` (O(1) id lookup).
- Two listener sets:
- **Global listeners** for components that show the full list or the
count (selection bar, SDK picker selection bar, drag overlay, etc.).
- **Per-id listeners** for grid rows that only care about their own
`isSelected` status.
- `setSelectedItems(action)` accepts either an array or an updater (same
signature as `Dispatch<SetStateAction<Item[]>>`). **Only notifies per-id
listeners whose status actually changed** — the key point that lets the
197 unaffected rows do nothing.
- `getSelectedItems()`, `isSelected(id)`, `clear()`, `subscribe()`,
`subscribeToId()`.
- A `SelectionStoreContext` React context to carry the instance.
- Ergonomic hooks:
- `useSelectionStore()` — returns the instance (for imperative reads inside
event handlers).
- `useSelectedItems()` — full list, global subscription.
- `useIsItemSelected(id)` — boolean scoped to one id, per-id subscription.
**This is the hook rows and cells use.**
- `useHasSelection()``count > 0` boolean, useful to show/hide the
selection bar without subscribing to the full count.
- `useSelectionCount()` — selection size.
- `useSetSelectedItems()` — stable setter.
- `useCreateSelectionStore()` — creates a local instance via `useRef`
(needed to scope a selection to a subtree, see below).
### 2. Wiring into `GlobalExplorerContext`
File: `src/frontend/apps/drive/src/features/explorer/components/GlobalExplorerContext.tsx`
- The provider creates a `SelectionStore` instance via
`useCreateSelectionStore()` and exposes it through
`<SelectionStoreContext.Provider>`.
- `selectedItems`, `selectedItemsMap` and `setSelectedItems` are **removed**
from `GlobalExplorerContext`'s value. Consumers that needed them now go
through `useSelectedItems()` / `useIsItemSelected()` /
`useSetSelectedItems()`.
- Effects that used to clear the selection (right-panel open, folder
navigation) now call `selectionStore.clear()`.
### 3. Two selection scopes: global and local
The explorer runs in two modes:
- **App mode** — main Drive navigation, one global store created by
`GlobalExplorerProvider`.
- **Embedded mode** — used by the "Move to" modal (the user picks a target
folder) and by the SDK picker. Each usage has **its own local selection**
selecting a folder in the move modal must not alter the main explorer's
selection.
Solution: `EmbeddedExplorer` (and its `useEmbeddedExplorer` hook) creates its
own `SelectionStore` via `useCreateSelectionStore()` and exposes it through a
local `SelectionStoreContext.Provider`. Because React providers shadow each
other naturally, grid cells always read the nearest provider's store without
needing to know where the grid is rendered.
### 4. `EmbeddedExplorerGrid` refactor
File: `src/frontend/apps/drive/src/features/explorer/components/embedded-explorer/EmbeddedExplorerGrid.tsx`
- Removed the `selectedItems` / `setSelectedItems` props (the grid reads
everything through `useSelectionStore()` internally).
- Removed the local `selectedItemsMap` that was recomputed every render.
- Removed `selectedItemsMap` from `EmbeddedExplorerGridContextType`.
- Click / context-menu / over handlers are extracted into stable
`useCallback`s. Selection reads are **imperative** through
`selectionStore.getSelectedItems()` or `selectionStore.isSelected(id)`
no subscription, no parent re-render during a marquee.
- The `<tbody>` mapping now delegates to a new memoized
**`EmbeddedExplorerGridRow`** component.
### 5. `EmbeddedExplorerGridRow` component
File: `src/frontend/apps/drive/src/features/explorer/components/embedded-explorer/EmbeddedExplorerGridRow.tsx`
- Wrapped in `React.memo`.
- Reads `isSelected` via `useIsItemSelected(row.original.id)` → subscription
scoped to this item's id.
- Receives `row`, `isOvered`, `onClickRow`, `onContextMenuRow`, `onOver` as
props. These props are stable (the row is stable as long as its data does
not change, the callbacks are `useCallback`s with primitive deps in the
parent).
- Result: when the selection changes, **only the rows whose status flips
receive a notification and re-render**. The others bail out at the hook
level, before the component body even runs.
### 6. Cell memoization
Files:
- `EmbeddedExplorerGridNameCell.tsx`
- `EmbeddedExplorerGridActionsCell.tsx`
- `EmbeddedExplorerGridMobileCell.tsx`
Each cell is wrapped in `React.memo`. `EmbeddedExplorerGridNameCell` (and the
`useDisableDragGridItem` helper in `hooks.tsx`) no longer read
`selectedItemsMap` from the grid context — they use
`useIsItemSelected(item.id)`. They only re-render when their own selection
flips.
### 7. Avoiding cascading re-renders from higher components
The classic trap: if a component high up in the tree (e.g.
`AppExplorerInner`, `ExplorerDndProvider`) subscribes to the full selection
via `useSelectedItems()`, **it** re-renders on every marquee tick, and that
re-render cascades down to `EmbeddedExplorerGrid` → rows → cells. All the
downstream memoization work is wasted.
To avoid that, parents do **not** subscribe to the full list when they don't
need it:
- **`AppExplorerInner`** no longer reads `selectedItems`. To decide between
the selection bar and the filters it uses `useHasSelection()`, which only
re-renders when the boolean **flips** (count 0 → 1 or 1 → 0). During a
marquee, the boolean flips at most twice — imperceptible.
- **`ExplorerDndProvider`** no longer renders the count directly. The drag
overlay and the move confirmation modal are extracted into two small child
components (`SelectionCountDragOverlay` and
`ExplorerTreeMoveConfirmationModalWithCount`) that subscribe to the count
via `useSelectionCount()`. The provider itself subscribes to nothing, so
its children do not re-render because of it.
- **`useTableKeyboardNavigation`** — the effect that re-focuses the table
after each selection change used `[selectedItems]` as a dependency. It now
uses a **direct imperative subscription** to `selectionStore.subscribe()`
inside a `useEffect`: the side effect fires, but the host component (the
grid) is not invalidated.
### 8. Call-site migration
Every file that used to read `useGlobalExplorer().selectedItems` or
`.selectedItemsMap` has been migrated:
- `AppExplorerInner.tsx`
- `AppExplorerGrid.tsx`
- `ExplorerSelectionBar.tsx`
- `ExplorerDndProvider.tsx`
- `ExplorerRightPanelContent.tsx`
- `ExplorerRenameItemModal.tsx`
- `ExplorerMoveFolderModal.tsx`
- `EmbeddedExplorerGridNameCell.tsx` + `hooks.tsx`
- `useTableKeyboardNavigation.ts`
- `EmbeddedExplorer.tsx` + `useEmbeddedExplorer`
- `pages/explorer/trash/index.tsx`
- `pages/sdk/explorer/index.tsx` + `SdkPickerFooter`
Depending on their needs, those files use either `useSelectedItems()`
(reactive list), `useSelectionStore().getSelectedItems()` (imperative read
inside a handler), or `useIsItemSelected(id)` / `useHasSelection()` /
`useSelectionCount()` when a scoped subscription is enough.
## What this refactor solves
### The reported bug
Marquee drag over a folder with 200+ items is smooth again: stable 60 FPS, no
perceivable micro-freeze. On every mouse tick during the marquee:
| | Before | After |
| ------------------------------------------------ | --------------- | ---------------------------------------------------------------- |
| Parent grid renders | 1 | **0** |
| `<tr>` renders | 200 | **= number of rows whose `isSelected` flips** (typically 03) |
| Heavy cell renders (NameCell, etc.) | 200 × 3 columns | **= number of affected rows × 3** |
| Re-renders of Draggable / Tooltip / ItemIcon | 600+ | **≈ 09** |
### Positive side effects
- **Single click / Cmd+click / Shift+click**: same gains — no more cascading
re-renders, only the rows that flipped update.
- **Keyboard navigation** (up/down arrows): the table focus effect is now
decoupled from the render → more efficient, and more correct (no React
dependency on an always-new array reference).
- **Modals** that consume the selection (rename, move): imperative reads from
the store, no more superfluous subscriptions.
- **Cleaner code**: gone is the duplicated `selectedItemsMap` (once in
`GlobalExplorerContext`, once in `EmbeddedExplorerGrid`); gone is the
comment acknowledging the "Context as a workaround" trick that did not
actually work.
- **Clean scoping** of local vs global selection: the "Move to" modal and the
SDK picker now have a strictly isolated selection, with no risk of leaking
into the main explorer.
### What was left untouched
- `overedItemIds` (the ids of items hovered during a dnd-kit drag) suffers
structurally from the same problem: it is a `Record<string, boolean>`
stored in a Context; every update creates a new object and invalidates all
consumers. During a DnD drag over a large folder, the same cascading
re-render pattern happens. This refactor does **not** tackle that: it is
not the reported bug, and the fix is the exact same pattern (external store
+ `useIsItemOvered(id)` hook). To be done in a follow-up if the need
arises.