Skip to content

fix(Typeahead,Tokenizer): the busy indicator is a Spinner in the field's end lane, not a clock on top of the clear button - #5555

Merged
freddymeta merged 2 commits into
mainfrom
fix/typeahead-busy-indicator
Sep 2, 2026
Merged

freddymeta merged 2 commits into
mainfrom
fix/typeahead-busy-indicator

Conversation

@freddymeta

@freddymeta freddymeta commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

No longer stacked — #5385 landed, so this is replanted on main and reviewable on its own. Closes #5554.

What was wrong

Three defects in one block, all on main:

  1. The busy indicator is a static clock. BaseTypeahead.tsx rendered <Icon icon="clock" size="sm" color="secondary"> — byte-identical to TimeInput.tsx, and in core clock otherwise means time. Every sibling input paints busy with <Spinner size="sm" />. Nothing spun during a search: getAnimations({subtree: true}) finds nothing running on it.
  2. It landed on top of the clear button. The indicator was an in-flow flex item and the input before it is flex: 1, so it was pushed to the row's inline end — which is where each wrapper independently parks an absolutely-positioned clear affordance. 17×20px at Typeahead md (13 of the 16px glyph covered) and 19×20px in Tokenizer. Correction (re-measured): the overlap is visual only — the clear button is positioned, so it paints above the in-flow indicator, and elementFromPoint at seven points across the covered band on main lands inside the button at every one. My earlier "part of the ✕ unclickable" was wrong.
  3. The combobox never got aria-busy, unlike TextInput.

The change

The engine reports the busy state rather than painting it, and each field renders <Spinner size="sm" /> in the one lane it already owns at its inline end — Tokenizer's endSection, which already holds endContent and the clear button, and Typeahead's clear-button box, widened into an endLane flex row. aria-busy goes on the input.

A caller using BaseTypeahead directly is unaffected: it still renders its own visible, named "Loading" status, as a Spinner now rather than the clock, so the fix reaches those callers too. Passing the callback is what hands the indicator over, so a field never paints two.

The input reserves the lane. The lane is absolutely positioned — both wrappers are flexWrap: 'wrap', so an in-flow sibling gets pushed onto a second row by a token — and an out-of-flow box reserves nothing, so at a narrow width the live query ran underneath it. useEndLaneReserve measures the rendered lane and returns the padding the input needs. Measured rather than assumed: the lane holds a clear button that comes and goes with the value, an indicator that comes and goes with the search, and, in Tokenizer, arbitrary endContent. There is no CSS that does this — the input cannot see a sibling's width, and a custom property set on the lane cannot travel sideways to it.

Measured in Chromium, at 280px

Overlap of the input's content box (where text and the caret may go) with each lane control, across the six states, against a build of current main:

state main here
Typeahead, value settled clear 17px 0
Typeahead, value + search in flight 0 0
Typeahead, value idle 0 0
Tokenizer, value idle clear 25px 0
Tokenizer, value settled clear 25px 0
Tokenizer, value + search in flight 0 0

Correction (re-measured against a build of current main, 2026-09-02). The two Typeahead clear rows above do not reproduce. In edit mode at 280px the input's border box ends at 259 and the clear starts at 270; at 200px, 179 against 190 — 0 overlap either way. The 17px figure is the indicator over the clear button, which does reproduce exactly. The real pre-existing clear-over-content overlap is Tokenizer's, and today it measures 20px, not 25px. Everything on this branch still measures 0.

The two in-flight rows were already 0 on main — but only because the in-flow indicator reserved its own width. Moving it into the lane is what would have taken that away, which is the regression @cixzhang caught; the reserve is what holds them at 0. The other rows are the pre-existing case of the same bug, with no spinner in it at all — I had filed that away as separate and it belongs here, since it is the same missing reserve.

Indicator, same probe: Icon/0 animations on main → Spinner/1 animation here, both fields. aria-busy absent on main → true on both.

Review changes since the last revision

  • The input reserves the rendered lane width — the table above.
  • The default status is preserved for direct callers. BaseTypeaheadProps is re-exported from the package entry point, so the base has callers this repo cannot see, and they painted no indicator of their own. It renders whenever no wrapper has taken it over.
  • The transport is internal: __onLoadingChange, marked @internal, following __queryEntries from feat(core): minQueryLength — hold the typeahead search until the query is long enough #5385 and DefinedTheme.__inputTokens.
  • No Effect. The report goes out at the call site through a ref, so the field's state change batches into the commit React was already doing instead of forcing a second one — the doubled wrapper commits are gone. It is edge-triggered as well, so the redundant clear on every keystroke below the query threshold reports nothing at all.
  • A changeset, which was missing.

Coverage

There was none — no story rendered either component loading, and neither test file exercised isLoading, which is how a clock survived in the busy slot. Adds a Loading story to each (async source, 1.2s), the Tokenizer one with hasClear and endContent so all three lane occupants are present at once, plus unit tests for the three contracts jsdom can hold: the default status for a direct caller, the handover (and that the base then renders none), and the edge-triggered reporting.

Test plan

  • pnpm build, pnpm lint:strict (0 errors; the same 80 warnings main reports), pnpm check:repo, pnpm -F @astryxdesign/core typecheck — all clean.
  • pnpm exec vitest run packages/core/src/Typeahead packages/core/src/Tokenizer: 132 tests pass.

@vercel

vercel Bot commented Aug 26, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Sep 2, 2026 12:38pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 26, 2026
@freddymeta

Copy link
Copy Markdown
Contributor Author

Negative control, run after the fact rather than asserted — the same two stories and the same probe, against main's components:

main this PR
Typeahead indicator Icon, 0 animations Spinner, 1 animation
Typeahead overlap 17 px (303…327 over clear 310…330) 0 px (292…306, clear 310…330)
Tokenizer indicator Icon, 0 animations Spinner, 1 animation
Tokenizer overlap 19 px (388…412 over clear 387…407) 0 px (284…298, clear 387…407)
aria-busy absent on both true on both

0 animations is the "static clock" claim measured rather than eyeballed: getAnimations({subtree: true}) finds nothing running on the indicator on main.

Gates: 12285 tests pass, lint:strict 0 errors, typecheck clean. The one failing test is story-tree.test.ts → Core/Icon/Size Theming, which is red on main from #5465 and only surfaces once apps/storybook/dist exists locally.

@freddymeta
freddymeta marked this pull request as ready for review August 26, 2026 22:10

@cixzhang cixzhang 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.

Thanks — the Spinner and aria-busy are the right direction, but moving loading out of BaseTypeahead leaves three regressions.

At 280px the absolute lane covers the live query: Typeahead’s Spinner overlaps 14×14px of the input and clear overlaps 17×20px (both zero on the parent); Tokenizer has the same Spinner/input overlap. The caret and trailing characters render under the controls while search runs. Could the input reserve the rendered lane width?

Parent Head Settled control
Parent busy Head busy Head settled

BaseTypeahead is released. Direct callers previously got a visible, named “Loading” status; this head removes it and adds an undocumented public callback, so existing callers silently lose that feedback. Its Effect also doubles wrapper commits at search start and settlement. Could we preserve the default status, keep wrapper transport internal, report the transition without an Effect, and add a changeset?

[Reviewed by Robohands]

@freddymeta
freddymeta changed the base branch from feat/typeahead-min-query-length to main August 27, 2026 03:02
@github-actions
github-actions Bot requested a review from cixzhang August 27, 2026 03:20
@github-actions github-actions Bot added community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge needs:design-review Affects visuals — Design should review labels Aug 27, 2026
@freddymeta
freddymeta force-pushed the fix/typeahead-busy-indicator branch from 1eb979d to 404864d Compare August 27, 2026 03:24
@freddymeta

Copy link
Copy Markdown
Contributor Author

All four are in, and you were right about the regression — thanks for measuring it.

The overlap. My "pre-existing, filing separately" note was wrong in the state you tested. The in-flow indicator I removed was reserving its own width, so moving it into the lane is what took the reserve away while a search is out. The lane is out of flow and reserves nothing, and no CSS fixes that from the input's side: it cannot see a sibling's width, and a custom property set on the lane cannot travel sideways to it. So useEndLaneReserve measures the rendered lane and returns the padding the input needs — measured rather than assumed, because the lane holds a clear button that comes and goes with the value, an indicator that comes and goes with the search, and, in Tokenizer, arbitrary endContent.

That also folds in the case I had punted, since it is the same missing reserve without a spinner in it. At 280px, overlap of the input's content box with each lane control, against a build of current main:

state main here
Typeahead, value settled clear 17px 0
Typeahead, value + search in flight 0 0
Typeahead, value idle 0 0
Tokenizer, value idle clear 25px 0
Tokenizer, value settled clear 25px 0
Tokenizer, value + search in flight 0 0

The released base. BaseTypeahead renders its own visible, named status again, whenever no wrapper has taken the indicator over — so a direct caller loses nothing, and gains the Spinner in place of the clock. Passing the callback is what hands it over, so a field never paints two. The transport is __onLoadingChange, @internal, following __queryEntries from #5385.

The Effect is gone. The report goes out at the call site through a ref, so the wrapper's setState batches into the commit React was already doing rather than forcing a second one. It is edge-triggered too, which fixes something I had not noticed: every keystroke below minQueryLength clears the flag, so the Effect version handed the wrapper a false per character while the user typed. Now those report nothing at all. Three unit tests cover the default status, the handover, and the report count.

Changeset added.

One process note: #5385 landed while I was working on this, so the branch is replanted on main and is no longer stacked — the diff is this change only.

@freddymeta

Copy link
Copy Markdown
Contributor Author

Audited the change rather than just re-running my own tests. Two things fixed, three verified, one worth your judgement.

Fixed

  • The latest-callback ref was assigned during render. The repo splits on exactly this case: plain values go during render (snapPointsRef in BottomSheetPanel), but the callback refs beside them — onMotionStartRef, onMotionCompleteRef — sync in a layout effect, so a render React discards cannot leave the ref pointing at the abandoned pass's callback. Now matches. It writes a ref and nothing else, so no wrapper re-renders for it and the doubled commits stay gone.
  • The ResizeObserver fallback read contentRect (content box) where the primary path reads borderBoxSize. Equal today — the lane has no padding or border — and silently short the moment one is added. Reads offsetWidth now.

Verified

  • Every size, both directions, under StrictMode: sm/md/lg × LTR/RTL × busy/settled, both components — spinner overlap 0 and clear overlap 0 in all of them, and the padding lands on the inline-end side only in RTL (it is logical, so it mirrors). Worth noting lg was already inheriting the md lane inset before this change; my reserve uses the same ternary as the lane's positioning, so the two agree at every size.
  • No feedback loop and no console noise. The reserve changes the input's padding, which could in principle resize the lane and re-fire the observer — it does not, because the lane is out of flow and sized by its own content. Zero console errors or warnings across the whole matrix, StrictMode included, so no ResizeObserver loop completed either.
  • The reserve does not squeeze the text area. Fields from 360px down to 140px with all three lane occupants present: no row loses its content box, and the input is in fact wider than on main at every width (342px vs 314px at 360px), because removing the in-flow indicator gives more room back than the reserve takes.
  • Full packages/core suite: 283 files / 8024 tests pass. lint:strict 0 errors (the same 80 warnings main reports), check:repo clean including check:sync for the new file, typecheck clean for core and Storybook.
  • Both new stories render in a built Storybook: aria-busy="true", the indicator is an SVG with 1 running animation, overlap 0, no console errors.

Your call, not a defect

Core/Tokenizer → Loading reserves 131px, because the lane genuinely is 123px there — spinner + "1 selected" end content + clear button. The field keeps a 145px text area at its 400px story width, so it is correct rather than miscalculated, but a wide endContent does eat the input by exactly its own width. That is inherent to reserving honestly; on main the text simply ran underneath it. If you would rather the input scrolled under a wide lane instead of shrinking, that is a different trade and I would take it as a follow-up.

One correction to my earlier comment: I claimed Selector, MultiSelector, CommandPalette and DateTimeInput were direct BaseTypeahead callers. They are not — those matches were comments referencing it. The real direct callers are Typeahead, Tokenizer, and the CLI showcase block blocks/components/BaseTypeahead/BaseTypeaheadCustomSearch.tsx, which is the in-repo example of the case you flagged and now renders the preserved Spinner status.

@cixzhang cixzhang 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.

Thanks — the prior overlap, direct-Base, internal-transport, and changeset asks are fixed. The render-cost ask is still open: same-worktree A/B takes a 20-token search from 20 to 120 token renders, and disabling only the reserve returns it to 60. Could we keep the zero-overlap result without adding full-field commits at search start and settlement?

Prior head Current head
Prior head Current head

[Reviewed by Robohands]

return;
}

const observer = new ResizeObserver(entries => {

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.

Please use the existing shared observer instead of allocating one per field lane.

@freddymeta
freddymeta force-pushed the fix/typeahead-busy-indicator branch from 1c34260 to b3ea3b1 Compare August 28, 2026 00:29
@github-actions
github-actions Bot requested a review from cixzhang August 28, 2026 00:29
@freddymeta

Copy link
Copy Markdown
Contributor Author

Render cost fixed — the reserve now costs zero commits of its own (b3ea3b1b5), and it is rebased onto main.

Root cause. useEndLaneReserve held the measured width in React state. The lane changes size exactly twice a search — spinner in, spinner out — so each transition re-rendered the whole field to carry a number no JavaScript ever reads. That is the doubling you measured.

Fix. The measurement reaches CSS as a custom property written straight to the field wrapper and inherited by the input. The rule is static now:

paddingInlineEnd: calc(<lane inset> + var(--_astryx-end-lane-width, 0px))

One class, generated once, instead of a new one per width. React never sees the value.

Measured, same test against both implementations — Profiler around the field, one search from start to settle:

commits at search start total
state-held reserve 2 4
custom property 1 2

The remaining two are the ones the search itself owes: the spinner arriving and the spinner leaving.

Worth flagging how that test is built, because the obvious version does not work: jsdom reports every width as 0, so a state-held reserve never re-renders there and the regression is invisible. The test stubs a ResizeObserver that reports a width on observe — the smallest thing that makes it reproducible in CI. It is written to be implementation-neutral (it waits on no DOM signal either implementation owns), so it genuinely fails on the old code rather than erroring on it: I ran it both ways to confirm.

And the observer is shared. It now goes through observeResize — the same singleton useTruncation uses — rather than constructing one per lane. Three fields on a page created three observers before and create one now, so the browser dispatches one callback a frame rather than N. There is a test for that too: instances <= 1 across three mounted fields, which fails at 3 on the old code.

The accepted fix still holds. Re-measured in Chromium at 280px while busy:

input content box ends   268
lane starts              276     → 0px overlap with the caret
--_astryx-end-lane-width 14px
padding-inline-end       19px    (5px inset + 14px lane)

and on settle the property is removed, so the input takes the room back rather than keeping a stale reserve.

Changeset updated with both the property and the shared observer.

github-actions Bot added a commit that referenced this pull request Sep 2, 2026
@freddymeta

Copy link
Copy Markdown
Contributor Author

Screenshots

Chromium, built Storybook at this head, 3× DPR.

Tokenizer — the end lane, busy, with a token present. Spinner, count, clear — in flow, no overlap:

Tokenizer busy

spinner ∩ clear = 0 × 14px (zero horizontal), field 400 × 32.

Typeahead — the ring in the end lane:

Typeahead busy

aria-busy=true, field 320 × 32.

No before shot, and here is why. I went to capture one and neither component has a busy state on main at all — Typeahead.tsx and Tokenizer.tsx there contain no isBusy/isLoading, and the loading stories are added by this PR (main's Typeahead story list ends at status-variant-comparison). So the 17×20 and 19×20 overlaps in my earlier table come from rendering the indicator element main would place, not from a story you can open on main. Worth knowing when reading that table.

(The two images are committed on a scratch branch assets/rtl-shots purely so they render here — say the word and I will delete it, or move them somewhere you prefer.)

@freddymeta

Copy link
Copy Markdown
Contributor Author

Fixed here rather than in #5682, because this is where the prop is born: #5555 landing on its own would ship __onLoadingChange publicly regardless of what #5682 does.

You are right that @internal is not a boundary. BaseTypeaheadProps is re-exported from the package entry point, so the tag is a note to a reader and the name still ships.

The handoff now travels through a context in busyIndicatorLane.tsx, which index.ts does not export. The wrapper provides its setter around the base; the base subscribes. Nothing is left on the public prop surface to find.

Behaviour is unchanged — a lane present still means the wrapper paints the indicator in the end lane it already owns and the base renders none; no lane still means the base renders its own visible, named status.

A test pins the seam: the entry point exports neither the provider nor the hook, and BaseTypeahead.tsx no longer contains the prop. It fails against the previous commit.

8611 core tests, check:repo green. #5682 is rebased on this and carries only the content lane.

github-actions Bot added a commit that referenced this pull request Sep 2, 2026

@cixzhang cixzhang 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.

Thanks — the overlap, Spinner, aria-busy, and transform fixes now hold in Chromium. One performance regression remains: Tokenizer mirrors BaseTypeahead’s busy state into its parent, so each search rerenders every selected token at start and settlement. Main stays at 0 token renders; this head reaches 6/12 at six tokens and 20/40 at twenty. Could we keep loading single-owned and add a render-count regression so search transitions don’t rerender the token list?

[Reviewed by Robohands]

// Reported by BaseTypeahead so the indicator can live in this field's own
// end lane, beside endContent and the clear button.
const [isLoading, setIsLoading] = useState(false);
const busyLane = useMemo(() => ({onBusyChange: setIsLoading}), []);

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.

Could this avoid mirroring BaseTypeahead state? Six selected tokens render 12 extra times per search.

[Reviewed by Robohands]

@github-actions
github-actions Bot requested a review from cixzhang September 2, 2026 08:38
@freddymeta

Copy link
Copy Markdown
Contributor Author

Both open items are addressed on e092e4c6, and the pr-rtl failure with them.

The mirroring

You were right that it was a mirror and not a cost of the design. The busy state has one owner — the base — and each wrapper was copying it into its own useState just to decide whether one Spinner was on screen.

BusyIndicatorLane is now a store (subscribe / getIsBusy beside the existing onBusyChange), and the only thing that reads it is the leaf that paints the indicator, via useSyncExternalStore. Neither wrapper reads the busy state during its own render, so neither re-renders for it and the token count stops scaling anything.

The regression test is the number you measured: a spy that wraps the real Token records every token render across a search at six tokens, and asserts the list is empty at start and at settlement. Negative control — re-subscribing from Tokenizer itself, the shape this replaces — fails it with ['Alice', 'Bob', 'Charlie', 'Diana'].

Two simplifications fell out, which is the part that suggests the mirror was the real problem rather than a tax:

  • hasEndLane is gone from Tokenizer — the lane's leaf knows whether it has anything to show.
  • useEndLaneReserve publishes the whole reserve rather than the width alone, so the variable's absence means "no lane" instead of "a lane of zero width". The input's padding rule is applied unconditionally and resolves to 0 with no lane, which is what lets the field stop tracking whether a search is running.

pr-rtl

Failing on this head for a reason unrelated to your review: COV: 0 measured / 2 gap, exit 1. The audit scopes every component a PR touches and wants each measured or carrying a written not-applicable reason, and Tokenizer/Typeahead were neither.

They are genuinely direction-sensitive — this PR's own subject is a lane pinned to the field's inline end — so I added measurement rather than a reason. A D4 target each, over a story that puts that lane on screen at rest (the busy state only exists mid-search, so it cannot be held still for the audit; the clear button occupies the same lane).

Verified by building Storybook and running the audit, not by reasoning about it:

CUR  RTL-ready  core/typeahead   {"D4":"pass"}
CUR  RTL-ready  core/tokenizer   {"D4":"pass"}
COV : 2 measured / 0 verified N-A / 0 gap / 0 stale
exit 0

The lane mirrors rather than merely differing: side fraction LTR 0.94 → RTL 0.06 (Typeahead) and 0.95 → 0.05 (Tokenizer), so a lane pinned with a physical edge would sit at ~0.94 in both and score fail.

Checks

  • packages/core: 288 files / 8606 tests pass
  • typecheck clean; the 3 locales/pseudo.json errors are pre-existing on main and are generated artifacts
  • eslint clean on everything touched; the 3 require-baseprops-passthrough warnings on BaseTypeahead.tsx:383 are pre-existing on main (checked by stashing)

No changeset — busyIndicatorLane is not exported from the package entry point, so none of this is public API, and the test asserting that still passes.

One correction to my own earlier note: the previous revision's claim that the transform fix needed a bespoke measured lane still stands, but I had also described the reserve as costing no commit "because it is not state" — it was not state, yet the field still committed twice per search through the mirror above. The reserve was innocent; the mirror was not.

github-actions Bot added a commit that referenced this pull request Sep 2, 2026
Geervan pushed a commit to Geervan/astryx that referenced this pull request Sep 2, 2026
…#5682)

* fix(Typeahead,Tokenizer): the busy indicator is a Spinner in the field's end lane, and the input reserves it

Three defects in one block. The indicator a search painted was
`<Icon icon="clock">` — a static glyph, byte-identical to TimeInput's, in a
family where every other input paints busy with a Spinner and where `clock`
otherwise means *time*. It was an in-flow item at the row's inline end, which
is where each field independently parks its clear button, so the two landed on
each other: 17x20px of overlap in Typeahead, 19x20px in Tokenizer, the latter
leaving part of the clear glyph unclickable. And the combobox never carried
`aria-busy`.

The engine reports the busy state instead of painting it, and each field
paints it in the one inline-end lane it already owns beside its clear button
and end content. A direct `BaseTypeahead` caller keeps the visible, named
status it has always had — as a Spinner now, so the fix reaches those callers
too — and passing the callback is what hands the indicator over, so a field
never renders two.

The lane is out of flow (these wrappers wrap, and an in-flow sibling gets
pushed onto a second row by a token), so it reserved nothing and the query ran
underneath it at a narrow width. `useEndLaneReserve` measures the rendered
lane and returns the padding the input needs. Measured rather than assumed,
because what the lane holds varies with the field's state and, in Tokenizer,
includes arbitrary `endContent`.

At 280px, all six states measured, overlap of the input's content box with
the lane's controls:

| | main | here |
|---|---|---|
| Typeahead, value settled | clear 17px | 0 |
| Typeahead, value + search in flight | 0 | 0 |
| Tokenizer, value idle / settled | clear 25px | 0 |
| Tokenizer, value + search in flight | 0 | 0 |

The two rows that were already 0 were only 0 because the in-flow indicator
reserved its own width; moving it out is what would have regressed them, and
the reserve is what holds them. The other two are the pre-existing case of the
same bug, with no spinner in it at all.

Reporting goes out at the call site through a ref rather than from an Effect,
so the field's state change batches into the commit React was already doing
instead of forcing a second one, and it is edge-triggered, so the redundant
clear on every keystroke below the query threshold reports nothing.

Stacked on meta#5385 until it landed; replanted on main now that it has.

* refactor(Typeahead): sync the report callback in a layout effect, not during render

Two audit findings, neither user-visible.

The latest-callback ref was assigned during render. The repo's own convention
splits on exactly this: plain values are assigned during render
(`snapPointsRef` in BottomSheetPanel), but the latest-callback refs beside
them — `onMotionStartRef`, `onMotionCompleteRef` — are synced in a layout
effect, because a render React discards must not leave the ref pointing at a
callback from the abandoned pass. This effect writes a ref and nothing else,
so it commits nothing and no wrapper re-renders for it; the doubled commits
the review asked about stay gone.

And the ResizeObserver's fallback for a browser with no `borderBoxSize` read
`contentRect`, which is the content box — equal today, since the lane has no
padding or border, and silently short the moment one is added. `offsetWidth`
on the observed element is border-box, like the primary path.

* perf(Typeahead,Tokenizer): keep the lane measurement out of React state

The reserve fixed the overlap and paid for it in commits: a 20-token
search went from 20 renders to 120, and the reserve alone accounted for
the second half. `useEndLaneReserve` held the measured width in state,
so the lane changing size — which it does exactly twice a search, as the
spinner arrives and as it leaves — re-rendered the whole field to carry
a number no JavaScript ever reads.

It reaches CSS as a custom property written straight to the field
wrapper and inherited by the input, so the padding follows the lane
without React seeing the value at all. The rule is static now: one
class, `calc(inset + var(--_astryx-end-lane-width, 0px))`, generated
once instead of regenerated per width.

Measured, same test either way — `Profiler` around the field, one search
start to settle:

    state-held reserve   2 commits at search start   (fails)
    custom property      1 commit                    (passes)

which is the doubling, and it holds for the settle edge too. jsdom
reports every width as 0, so a state-held reserve never re-renders
there and the regression is invisible; the test stubs a ResizeObserver
that reports a width, which is the smallest thing that makes it
reproducible in CI.

The observer is shared as well — `observeResize`, the same singleton
`useTruncation` uses. Three fields on a page created three observers
before and create one now, so the browser dispatches one callback a
frame rather than N.

Verified in Chromium at 280px that the accepted fix still holds: while
busy, the input's content box ends at 268 and the lane starts at 276 —
zero overlap with the caret, and the reserve is released when the lane
goes.

* fix(Typeahead,Tokenizer): put Typeahead's end controls in flow, and measure Tokenizer's lane in local space

Two things, both from the transform report.

**The measured width was in the wrong coordinate space.**
`getBoundingClientRect()` reports viewport space — it carries every CSS
transform above the element — while the padding it fed is in the element's
own local space. Reproduced in Chromium on a 98px lane: `scale(.5)` published
49px, reserved half of what was needed, and put the live query back under the
controls by 22.83px; `scale(2)` published 196px and left the caret in a
202.69px gap. `offsetWidth` is the untransformed border-box width and reads 98
at every scale, so that is what the reserve now uses. It is already an
integer, which is the rounding the old `Math.ceil` was there for.

**Typeahead does not need a measured lane at all.**
Its spinner and clear button are now ordinary in-flow flex siblings of the
input, which is TextInput's shape for exactly these controls: an in-flow box
takes up room, so nothing can run underneath it and there is nothing to
measure. The whole reserve, and the absolute lane it existed for, are gone
from this field.

What stood in the way was `flex-wrap: wrap` on Typeahead's wrapper — not a
decision about this field, but something it picked up wholesale in the meta#2941
rename migration. The shared field base does not set it and TextInput does not
use it. It cannot coexist with in-flow end controls either, because flex moves
an item to a new line rather than shrinking it: with it, a long value put the
clear button and spinner on a row of their own and a 280px field grew to 46px
tall. Removed, the token ellipsizes instead — which is what Token already
does, capping itself at 100% and clipping — and the field stays one row.

Two rules I tried on the way out did nothing and are not here: `min-width: 0`
on the token and `flex-shrink: 0` on the lane both measured byte-identical
with and without, because Token already shrinks and the controls' min-content
is their own size.

Tokenizer keeps the lane. It cannot use the in-flow shape: its lane stays
pinned to the field's first row while tokens wrap below it, so it has to be
out of flow.

Chromium, busy, at scale .5 / 1 / 2 — Typeahead: no reserve at all, overlap 0,
0, 0. Tokenizer: 98px published at all three, overlap 0, 0, 0, and a constant
3.34px local gap. Before, the same three read 22.83px of overlap, 0, and
202.69px of gap.

Coverage, per the review: Tokenizer gains the busy-only path (a spinner as the
lane's only occupant) and the collapsed path (the input takes no reserve when
it has no width to pad), plus a direct regression test for the bug — the stub
reports 24px from `offsetWidth` and 12px from the rect, and the published
value must be 24px. Typeahead gains three guards that its controls stay in
flow: no absolute lane, no reserve property, no wrapping. Reverting either fix
fails its test.

* fix(Typeahead): hold the end controls in the corner, and file the reserve with its one caller

Self-review of the previous commit found one bug and one misfiling.

**The clear button drifted into the middle of the field.** Putting the
controls in flow is right, but TextInput gets them to the inline end for free:
its input is always present and `flex: 1`, so it absorbs the free space and
pushes them over. Typeahead collapses its input to nothing whenever a token
shows — which is the ordinary state of a field with a value — leaving no
flexible item in the row, so the controls came to rest against the token.
Measured: the clear button at x=39 in a 300px field, where TextInput's sits at
281. An `auto` margin gives the free space to the margin instead of to a
sibling, so it needs no sibling to exist: 281 now, and 0px of drift when the
spinner joins it, matching TextInput exactly.

That is also why these two are wrapped again. The wrapper is an ordinary
in-flow flex child, not the absolute lane the review objected to — it is
simply the one element the margin can sit on when either control may be
absent.

**`useEndLaneReserve` moved from `Field/` to `Tokenizer/`.** It has exactly
one caller now. Everything else in `Field/` is genuinely shared —
`InputClearButton` has 15 consumers — so a Tokenizer-only workaround filed
there read as shared infrastructure and invited the next field to reach for
the measured reserve, which is the thing this review was about not doing.

Also checked and left alone: no dead style keys in any of the three files, no
unused imports, the hook is not re-exported from any barrel, and
`--typeahead-min-width` is not involved. Two candidate rules from the last
round measured byte-identical with and without and are still absent.

Chromium, busy, scale .5 / 1 / 2 — Typeahead: no reserve, overlap 0 / 0 / 0,
controls pinned to the field's end at a constant inset, one row throughout.
Tokenizer: 98px published at all three, overlap 0 / 0 / 0.

One note for a separate change: the `@astryx.typeahead.loading` catalog entry
describes a spinner "inside a Typeahead dropdown". It has always rendered in
the field row, not the dropdown, so the description was already wrong on main;
left for its own diff rather than widening this one.

* docs(Typeahead,Tokenizer): file the busy indicator as anatomy that delegates to Spinner

The indicator is a shared primitive, so `architecture:component-theming-surface`
INV5 says it delegates rather than earning a target — but the part has to be in
the inventory for that disposition to exist. Adds the `Spinner` anatomy row to
both component docs and maps it in Typeahead's `anatomy-theming:v1` block as
`delegatesTo component:Spinner/spinner`, which is what TextArea, CheckboxList
and CommandPalette already declare for the same part. No target, no var, no DOM
change.

Also renames the lane's private custom property `--_astryx-end-lane-width` ->
`--_tokenizer-end-lane-width`. It was the only `--_astryx-*` name in the repo;
every other private var names its owning component, and a system-shaped name on
a component-owned measurement invites exactly the cross-layer reliance INV11
rules out.

Corrects two claims that do not survive re-measurement against a build of
current main, and refreshes the measured numbers in the comments to today's:
the clear button stays clickable under the indicator (it is positioned, so it
paints above), and the pre-existing clear-over-content overlap is Tokenizer's
20px, not Typeahead's.

* fix(Typeahead,Tokenizer): close the busy handoff behind a module boundary

`__onLoadingChange` sat on `BaseTypeaheadProps`, which the package entry
point re-exports — so a builder reading the exported declaration finds it
and can reasonably wire it, pinning a wiring detail between two wrappers
and their base as permanent public API. The `@internal` tag is a note to
a reader, not a boundary.

The handoff now travels through a context in `busyIndicatorLane.tsx`,
which `index.ts` does not export: the wrapper provides its setter around
the base, the base subscribes. The seam is closed by module boundary
rather than by naming convention, and there is nothing left on the public
prop surface to find.

Behaviour is unchanged. A lane present still means the wrapper paints the
indicator in the end lane it already owns and the base renders none; no
lane still means the base renders its own visible, named status — the
released behaviour when the base is used directly.

A test asserts the seam: the entry point exports neither the provider nor
the hook, and BaseTypeahead.tsx no longer contains the prop. It fails
against the previous commit.

8605 core tests, check:repo green.

* fix(Typeahead): bound the selected value with a content lane

Squashed to one commit and rebased onto the current meta#5555 head.

A selected token used to be positioned over a collapsed input, so the
field's width came from whatever was left rather than from its content.
The value now sits in a content lane that owns the flexible space, and
the end controls hold the corner with an `auto` margin, so the field
keeps its width when a value is chosen.

Also closes Typeahead's RTL coverage gap. Every dimension returned N/A,
which the audit counts as unmeasured rather than RTL-ready — and the
content lane is exactly the direction-sensitive part: the token opens the
lane, the clear button closes it, and RTL must swap them. A single-field
`Logical order` story plus a curated D2 target measures that flip; the
existing comparison story renders two fields, and the selectors would
match across both.

  pnpm rtl:audit -- --filter Typeahead
  CUR  RTL-ready  core/typeahead  {"D2":"pass"}
  COV : 1 measured / 0 verified N-A / 0 gap / 0 stale

The probe was verified rather than assumed: pinning the field to
`direction: ltr` turns D2 pass -> partial, so the check reads real
geometry. It passes against main's Typeahead too — the order flip is
writing-mode driven either way — so this closes a coverage gap rather
than guarding the content lane specifically.

The busy handoff this branch used to carry is now meta#5555's own; nothing
here passes it.

---------

Co-authored-by: freddymeta <170298698+freddymeta@users.noreply.github.com>
Co-authored-by: freddytan <freddytan@84853.od.fbinfra.net>
Co-authored-by: freddymeta <freddymeta@users.noreply.github.com>
Co-authored-by: freddytan <freddytan@41831.od.fbinfra.net>

@cixzhang cixzhang 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.

Thanks — #5682 landed the earlier stack after this head, so it needs a rebase. The prior render regression is fixed; approved, with #5682’s content lane preserved.

[Reviewed by Robohands]

…elds measure under RTL

The busy state had one owner — the base, which is the only thing that knows a
search has started — but each wrapper mirrored it into its own `useState` to
decide whether one Spinner was on screen. That made every transition a commit
of the whole field: a Tokenizer holding six selected tokens re-rendered all six
at search start and six more at settlement, twenty tokens meant 20/40, for a
glyph no token contains.

`BusyIndicatorLane` is now a store — `subscribe` / `getIsBusy` beside the
existing `onBusyChange` — and the only thing that reads it is the leaf that
paints the indicator, through `useSyncExternalStore`. Neither wrapper reads the
busy state during its own render, so neither re-renders for it, and the token
count no longer scales anything.

Two knock-on simplifications fall out:

- Tokenizer's `hasEndLane` is gone. The lane's own leaf knows whether it has
  anything to show, so the field no longer computes it.
- `useEndLaneReserve` publishes the WHOLE reserve — inset plus measured width —
  rather than the width alone, so the variable's ABSENCE means "no lane" rather
  than "a lane of zero width". The input's padding rule is now applied
  unconditionally and resolves to zero with no lane up, which is what lets the
  field stop tracking whether a search is running. Still one static class, still
  never through React state.

Also closes the `pr-rtl` coverage gap that fails on this head. The audit scopes
every component a PR touches and requires each to be measured or to carry a
written not-applicable reason; Tokenizer and Typeahead were neither, so it
reported `COV: 0 measured / 2 gap` and exited 1. Both are genuinely
direction-sensitive — this PR's own subject is a lane pinned to the field's
inline end — so the answer is measurement, not a reason. Each gets a D4
overlay-side target over a story that puts that lane on screen at rest.

Test Plan:
Verified against the real audit, not the harness: built Storybook and ran
`rtl-audit.mjs --filter Tokenizer,Typeahead`.

    CUR  RTL-ready  core/typeahead   {"D4":"pass"}
    CUR  RTL-ready  core/tokenizer   {"D4":"pass"}
    COV : 2 measured / 0 verified N-A / 0 gap / 0 stale
    exit 0                                    (was: 0 measured / 2 gap, exit 1)

The lane mirrors cleanly rather than merely differing — D4 side fraction LTR
0.94 -> RTL 0.06 for Typeahead and 0.95 -> 0.05 for Tokenizer, so a lane pinned
with a physical edge would sit at ~0.94 in both and score `fail`.

New render-count regression, which is the number under review: a spy that WRAPS
the real `Token` (a stand-in would measure the stand-in) records every token
render across a search at six tokens, and asserts the list is empty at both
start and settlement.

Negative control: re-subscribing to the busy state from Tokenizer itself — the
shape this replaces — fails it with `['Alice', 'Bob', 'Charlie', 'Diana']`.

- packages/core: 288 files / 8606 tests pass
- typecheck clean (the 3 `locales/pseudo.json` errors are pre-existing on main
  and are generated artifacts)
- eslint clean on every file touched; the 3 `require-baseprops-passthrough`
  warnings on BaseTypeahead.tsx:383 are pre-existing on main (verified by
  stashing)

No changeset: `busyIndicatorLane` is not exported from the package entry point,
so none of this is public API — the existing test asserting that still passes.
@freddymeta
freddymeta force-pushed the fix/typeahead-busy-indicator branch from e092e4c to cc75624 Compare September 2, 2026 10:54
@github-actions github-actions Bot removed the needs:code-review High-risk change (new package/component/API) — needs human code review before merge label Sep 2, 2026
@freddymeta

Copy link
Copy Markdown
Contributor Author

Rebased the approved performance follow-up onto current main and squashed it to one commit (cc7562437b). Main already contains the earlier #5555 behavior through landed #5682, so this branch now carries only the remaining approved delta: keep busy state single-owned and avoid rerendering every selected token at search start/settlement.

Conflict resolution preserves the landed D2 stories while using this approved head’s stricter D4 fixtures/targets for the actual inline-end lanes.

Local validation on the rebased head:

  • Typeahead/Tokenizer: 3 files / 150 tests passed
  • full core: 288 files / 8632 tests passed
  • core TypeScript and component lint: clean
  • pnpm check:repo and pnpm build: pass
  • exact RTL scope: Typeahead D4 pass, Tokenizer D4 pass; 2 measured / 0 gaps

Ready for CI and refreshed approval.

@cixzhang cixzhang 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.

Thanks — zero token rerenders at 4 and 20 tokens, with unchanged LTR/RTL pixels. Please retain the token/clear order check alongside the new lane-side check.

[Reviewed by Robohands]

github-actions Bot added a commit that referenced this pull request Sep 2, 2026
@freddymeta

Copy link
Copy Markdown
Contributor Author

@cixzhang The approved single-owner/perf follow-up is rebased on current main at exact head cc7562437b. Main already contains the earlier behavior through landed #5682; this branch now carries only the approved remaining delta. All GitHub Actions, Vercel, 8632 core tests, TypeScript, lint, check:repo, build, and exact Typeahead/Tokenizer D4 audits are green.

Visual acceptance has 24 added, 0 removed frames — the same default Typeahead/Tokenizer stories across 12 themes that were reviewed during #5682, not changes to existing baselines. Could you accept with:

/accept-visual 33621837488/1 The added baselines show the default empty Typeahead and Tokenizer fields consistently across all 12 themes; the behavior change is exercised separately by the approved render-count and end-lane tests.

Then refresh the exact-head approval so I can land it?

@freddymeta
freddymeta requested a review from cixzhang September 2, 2026 12:21
One real conflict, in `useEndLaneReserve.ts`, where #5817 and this branch had
each rewritten the same callback:

- **From main (#5817):** `observeResize` now returns its own unsubscribe, so the
  cleanup calls `unobserve()` rather than `unobserveResize(node)`, and the
  now-unused second import is dropped.
- **From this branch:** the variable carries the WHOLE reserve (inset + width)
  instead of the width alone, so its absence means "no lane" rather than "a lane
  of zero width". That is what lets the input apply its padding rule
  unconditionally — and it has to be unconditional, because deciding otherwise
  means reading the busy state in the parent, which is exactly the re-render
  this PR removes. `laneInset` stays in the ref callback's deps, since the
  callback now reads it.

Test Plan:
- packages/core: 8637 tests pass
- typecheck clean (the 3 `locales/pseudo.json` errors are pre-existing on main —
  generated artifacts absent from a fresh tree)
- eslint: 0 errors across Tokenizer and Typeahead
@freddymeta

Copy link
Copy Markdown
Contributor Author

@cixzhang The merge from current main made the earlier visual command stale. The final-head report for f237e04034 is 12 shots: 4 unchanged, 0 changed, 8 added, 0 removed, 0 failed. The 8 additions are only the default Typeahead/Tokenizer frames in butter and stone light/dark; no existing baseline changed.

Could you accept the current report with:

/accept-visual 33630729604/1 The added butter and stone baselines show the default empty Typeahead and Tokenizer fields consistently; all existing visual baselines are unchanged, and the approved behavior is exercised by render-count and end-lane tests.

All current-head Actions pass and the approved content is unchanged by the main merge.

github-actions Bot added a commit that referenced this pull request Sep 2, 2026
@freddymeta
freddymeta merged commit 95a7219 into main Sep 2, 2026
24 of 25 checks passed
@github-actions
github-actions Bot deleted the fix/typeahead-busy-indicator branch September 3, 2026 06:22
AKnassa added a commit to AKnassa/astryx that referenced this pull request Sep 26, 2026
Resolved conflicts:
- packages/core/src/Tokenizer/Tokenizer.tsx: took main's
  BusyIndicatorLaneProvider-wrapped BaseTypeahead (with the busy-lane
  `laneReserve` in inputXStyle, meta#5555) and re-applied this PR's
  `ariaLabelledBy={ariaLabelledBy}` prop to it.
AKnassa added a commit to AKnassa/astryx that referenced this pull request Sep 26, 2026
Resolved conflicts:
- packages/core/src/Tokenizer/Tokenizer.tsx: took upstream's
  BusyIndicatorLaneProvider-wrapped BaseTypeahead (array inputXStyle with
  the lane reserve) and re-added
  `transformQuery={hasCreate ? commitDelimitedText : undefined}`.
- packages/core/src/Tokenizer/Tokenizer.test.tsx: took upstream's
  multi-line vitest import (adds beforeEach) and folded this branch's
  `useState` into upstream's `import {Profiler} from 'react'`.
- packages/core/src/Typeahead/BaseTypeahead.doc.mjs: kept upstream's
  xstyle/inputTabIndex/onKeyDown entries and re-added the transformQuery
  entries (docs, docsZh, docsDense), prettier-formatted like the rest of
  upstream's now-formatted file.

Semantic fallout fixed in packages/core/src/Typeahead/BaseTypeahead.tsx:
- The empty-query branch called `setIsLoading(false)` directly. Upstream
  (meta#5555) routes the busy state through `setLoading`, which also reports
  it to the BusyIndicatorLane and edge-guards on a ref. Bypassing it left
  Tokenizer's end-lane spinner up forever after a delimiter commit
  abandoned an in-flight search. Now `setLoading(false)`. Covered by the
  new Tokenizer test "a delimiter commit clears the busy indicator of an
  in-flight search" (red before: the Loading status span was still in
  the document).
- Upstream's meta#6179 audit spreads `...rest` onto the input, but this
  branch's hard-coded `onCompositionEnd={handleCompositionEnd}` came
  after it and silently dropped a consumer's onCompositionEnd. It is now
  destructured and composed with composeEventHandlers, like
  onFocus/onBlur/onPointerDown. Covered by extending BaseTypeahead's
  "forwards supported DOM, styling, and event props" test (red before:
  onCompositionEnd called 0 times).

Tokenizer, Typeahead and PowerSearch suites: 14 files, 439 tests pass.

This branch was successfully deployed

1 active deployment
Preview — f237e040 Deployed Sep 2, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. community Authored by a community contributor (not on the eng/design team)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Typeahead/Tokenizer: the busy indicator is a static clock, and it collides with the clear button

3 participants