Repository navigation
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
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Negative control, run after the fact rather than asserted — the same two stories and the same probe, against
Gates: 12285 tests pass, |
cixzhang
left a comment
There was a problem hiding this comment.
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 |
|---|---|---|
![]() |
![]() |
![]() |
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]
1eb979d to
404864d
Compare
|
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 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
The released base. The Effect is gone. The report goes out at the call site through a ref, so the wrapper's Changeset added. One process note: #5385 landed while I was working on this, so the branch is replanted on |
|
Audited the change rather than just re-running my own tests. Two things fixed, three verified, one worth your judgement. Fixed
Verified
Your call, not a defect
One correction to my earlier comment: I claimed Selector, MultiSelector, CommandPalette and DateTimeInput were direct |
cixzhang
left a comment
There was a problem hiding this comment.
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 |
|---|---|
![]() |
![]() |
[Reviewed by Robohands]
| return; | ||
| } | ||
|
|
||
| const observer = new ResizeObserver(entries => { |
There was a problem hiding this comment.
Please use the existing shared observer instead of allocating one per field lane.
1c34260 to
b3ea3b1
Compare
|
Render cost fixed — the reserve now costs zero commits of its own ( Root cause. 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: One class, generated once, instead of a new one per width. React never sees the value. Measured, same test against both implementations —
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 And the observer is shared. It now goes through The accepted fix still holds. Re-measured in Chromium at 280px while busy: 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. |
ScreenshotsChromium, built Storybook at this head, 3× DPR. Tokenizer — the end lane, busy, with a token present. Spinner, count, clear — in flow, no overlap:
Typeahead — the ring in the end lane:
No before shot, and here is why. I went to capture one and neither component has a busy state on (The two images are committed on a scratch branch |
|
Fixed here rather than in #5682, because this is where the prop is born: #5555 landing on its own would ship You are right that The handoff now travels through a context in 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 8611 core tests, |
cixzhang
left a comment
There was a problem hiding this comment.
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}), []); |
There was a problem hiding this comment.
Could this avoid mirroring BaseTypeahead state? Six selected tokens render 12 extra times per search.
[Reviewed by Robohands]
|
Both open items are addressed on The mirroringYou 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
The regression test is the number you measured: a spy that wraps the real Two simplifications fell out, which is the part that suggests the mirror was the real problem rather than a tax:
pr-rtlFailing on this head for a reason unrelated to your review: 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: 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 Checks
No changeset — 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. |
…#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>
…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.
e092e4c to
cc75624
Compare
|
Rebased the approved performance follow-up onto current main and squashed it to one commit ( 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:
Ready for CI and refreshed approval. |
cixzhang
left a comment
There was a problem hiding this comment.
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]
|
@cixzhang The approved single-owner/perf follow-up is rebased on current main at exact head 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:
Then refresh the exact-head approval so I can land it? |
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
|
@cixzhang The merge from current main made the earlier visual command stale. The final-head report for Could you accept the current report with:
All current-head Actions pass and the approved content is unchanged by the main merge. |
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.
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.







Summary
No longer stacked — #5385 landed, so this is replanted on
mainand reviewable on its own. Closes #5554.What was wrong
Three defects in one block, all on
main:BaseTypeahead.tsxrendered<Icon icon="clock" size="sm" color="secondary">— byte-identical toTimeInput.tsx, and in coreclockotherwise means time. Every sibling input paints busy with<Spinner size="sm" />. Nothing spun during a search:getAnimations({subtree: true})finds nothing running on it.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, andelementFromPointat seven points across the covered band onmainlands inside the button at every one. My earlier "part of the ✕ unclickable" was wrong.aria-busy, unlikeTextInput.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'sendSection, which already holdsendContentand the clear button, and Typeahead's clear-button box, widened into anendLaneflex row.aria-busygoes on the input.A caller using
BaseTypeaheaddirectly 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.useEndLaneReservemeasures 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, arbitraryendContent. 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:Correction (re-measured against a build of current
main, 2026-09-02). The two Typeaheadclearrows 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-busyabsent on main →trueon both.Review changes since the last revision
BaseTypeaheadPropsis 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.__onLoadingChange, marked@internal, following__queryEntriesfrom feat(core): minQueryLength — hold the typeahead search until the query is long enough #5385 andDefinedTheme.__inputTokens.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 aLoadingstory to each (async source, 1.2s), the Tokenizer one withhasClearandendContentso 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 warningsmainreports),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.