Skip to content

feat(ContextMenu): prototype searchable async option loading - #3085

Draft
mfal wants to merge 2 commits into
nextfrom
feat/context-menu-async-prototype-1851
Draft

feat(ContextMenu): prototype searchable async option loading#3085
mfal wants to merge 2 commits into
nextfrom
feat/context-menu-async-prototype-1851

Conversation

@mfal

@mfal mfal commented Sep 2, 2026

Copy link
Copy Markdown
Member

Prototype, not a feature

Searchable, async-loaded, multi-select option menu for ContextMenu — built to answer the three challenges in #1851 and to give UX something concrete to react to. It is not meant to be merged as a public component.

part of #1851 — not fixes, because UX has to decide the open questions before this can ship.

Full write-up, including the API proposal and the remote-boundary analysis: #1851 (comment)

Why a prototype

A searchable async menu is a new interaction surface. Its visual and interaction design is UX's call, not the implementer's, and base design tokens are off limits. So this establishes the mechanics and the constraints, and stops there.

What it proves

  • Everything needed already ships in react-aria-components@1.20.0Autocomplete, useFilter, useAsyncList, Virtualizer, ListLayout, all stable, no UNSTABLE_. No new dependency. The repo used only useFilter before this.
  • Selection is independent of what is loaded. selectedKeys lives above the loader, which never sees it. An option cache keeps a selected option renderable after its page is gone — keeping the keys alone is not enough, because react-aria only renders items in the collection.
  • react-aria already preserves out-of-collection selected keys (measured: the browser tests pass with mergeSelection bypassed). The helper stays to pin that invariant against a minor upgrade and to resolve the "all" wildcard, which is collection-bound.
  • A superseded load never lands — every request runs under an AbortController, so typing "pro" then "prod" cannot leave the "pro" page on screen.
  • The ceiling is rendering, not fetching or filtering. Toggling one option costs ~70 ms at 25 rendered items and ~330–400 ms at 2000, because react-aria rebuilds the collection on every selection change. Paging avoids it; the UnpagedStress story exists to measure it.
  • Async loading needs no protocol change — provided the loader is not a prop the host calls. Detail in the issue comment; the short version is that on* props discard their return value, a non-on* function property does round-trip through @quilted/threads (as XAxis.tickFormatter already does), and the design that needs neither is the right one.

What is deliberately missing

  • No public API. Nothing added to public.ts or flr-universal.ts; no component is @flr-generate. No generated artifact changes — verified with git status after a full test:compile run, which runs the generators.
  • No design. The search field and load-more control are unstyled and unspaced. No new design tokens, no new SCSS (hence no *.module.d.scss.ts).
  • The two selected-option treatments are both implemented, on purposepin and inline — so UX can compare rather than be handed one.
  • No i18n (locales/*), no docs page, no remote-dom-demo page, no visual tests. All belong to the real component.
  • Not wired into Select or the List filter menus. The real implementation belongs in Options (shared by Select, ComboBox, Autocomplete) so they all get it at once.

Placement

packages/components/src/prototypes/contextMenuAsync/ — deliberately outside src/{components,integrations}, the glob that createDocPropertiesJson and the remote-component generator scan. It therefore cannot leak into doc-properties.json, the component index, or any remote artifact. Storybook still picks the stories up (src/**/*.stories.tsx).

Verification

  • pnpm nx test:unit components — 260 passed, including 7 new for the selection merge
  • pnpm nx test:compile components — clean
  • pnpm lint — 0 errors, 0 warnings in the new files
  • pnpm nx test:browser components --browser.name=webkit on the new test — 8 passed (4 tests × 2 projects), covering: selection surviving a filter that hides it, a pinned selection staying visible and uncheckable, selection surviving a second page load, and the trigger count staying right across filter changes
  • No visual snapshots committed; leftover failure screenshots from a red intermediate run were removed

Stories

Prototypes/AsyncOptionMenuDefault, PinSelected, InlineSelected, SlowBackend, SinglePage, UnpagedStress. Backed by a fake backend that filters and pages server-side over 5000 options, so the client never holds the universe.

@mfal mfal self-assigned this Sep 2, 2026
Engineering groundwork for searchable, async-loaded option lists in
ContextMenu. A prototype, not a public component: the interaction and
visual design belong to UX, so nothing is exported from public.ts or
flr-universal.ts and no component is @flr-generate annotated.

Lives in src/prototypes/, outside the src/{components,integrations} glob
that the docgen and remote-component generators scan, so it cannot leak
into a generated surface.

What it establishes:

- Everything needed ships in react-aria-components 1.20.0 already —
  Autocomplete, useFilter, useAsyncList, Virtualizer, ListLayout, all
  stable. No new dependency. The repo used only useFilter so far.
- Selection is independent of what is loaded. selectedKeys lives above
  the loader, which never sees it; an option cache keeps a selected
  option renderable after its page is gone. mergeSelection pins the
  invariant, and the browser test fails if react-aria ever stops
  preserving out-of-collection keys (measured: 1.20 preserves them).
- A superseded load never lands: every request runs under an
  AbortController, so typing "pro" then "prod" cannot leave the "pro"
  page on screen.
- The ceiling is rendering, not fetching or filtering. Toggling one
  option costs ~70 ms at 25 rendered items and ~350 ms at 2000, because
  react-aria rebuilds the collection on every selection change. Paging
  is what avoids it; the UnpagedStress story is there to measure it.

Deliberately open: the two treatments for a selected-but-filtered-out
option (pin vs. inline) are both implemented so UX can compare them, and
the search field and load-more control are unstyled.

part of #1851

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mfal
mfal force-pushed the feat/context-menu-async-prototype-1851 branch from 0f9898a to a788f05 Compare September 2, 2026 11:53
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment

Preview environments are ready:

Type URL
docs pr-3085.docs.review.flow-components.de
storybook pr-3085.storybook.review.flow-components.de

Images:

  • docs: ghcr.io/mittwald/flow/docs:pr-3085
  • storybook: ghcr.io/mittwald/flow/storybook:pr-3085

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