Skip to content

fix(Tokenizer): focus hand-offs live in the controls' callback refs and the confirmation's close handler - #9162

Merged
cixzhang merged 4 commits into
mainfrom
lane4/tokenizer-focus-handoffs
Oct 31, 2026
Merged

cixzhang merged 4 commits into
mainfrom
lane4/tokenizer-focus-handoffs

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

User impact

  • What this is: a conformance change. Where focus lands, which words are announced, and the field's onFocus/onBlur reports are all unchanged.
  • What moved:
    • The presentation-swap hand-off (the latch releasing into the popover, or adaptive changing while the sheet is closed) moves from an effect, fed by a document.activeElement read during render, into the controls' callback refs (INV6).
    • The Clear all confirmation's focus move and "All items removed" announcement move from an effect, waiting on a later render, into the confirm/cancel handler (INV2).
  • Who sees it: nobody outside, for now. The swap and the sheet are internal (experimental.presentation, not yet public), so public fields, which render only the popover, behave as before.

Spec

  • architecture:react-component-runtime INV6: the callback ref owns attach, detach and replacement. INV2: a discrete action stays in its handler. These come from the raw-effects audit's findings for Tokenizer.tsx:864 and TokenizerBottomSheet.tsx:350.
  • component:Tokenizer FR16 (focus follows a presentation swap) and FR24 (focus after the confirmation) are unchanged and still hold.

Changes

  • Tokenizer.tsx: one handOffFocusRef callback is merged into the input's and the trigger's refs.
    • On detach, the control notes whether it held focus. It is still in the document then, which also covers a control focused before hydration (hasAutoFocus) that no focus event reported.
    • The inline wrapper's callback ref (now stable) notes focus anywhere inside it on detach, so focus on a token's Remove button or the inline Clear all hands off too.
    • Focus leaving the field (the wrapper's blur-capture leave branch, the trigger's onBlur) clears any pending hand-off. A later re-attach (a new inline ref, or a swap) then never pulls focus back into a field the user has left.
    • On attach, the new control takes focus only if one was pending and focus is detached (isFocusDetached).
    • The render-phase read, its effect, and the write-only isControlFocusedRef are removed.
  • TokenizerBottomSheet.tsx: closeClearConfirmation runs flushSync to unmount the confirmation, so the sheet is the modal on top again. It then focuses search and announces (confirm), or focuses Clear all (cancel). The effect and afterConfirmationRef are removed.
  • Tests:
    • a swap from a focused Remove button and from a focused inline Clear all (each entered from inside the field) lands on the trigger, and fails without the wrapper's note;
    • after the wrapper re-attaches while focused (an isDisabled toggle with disabledMessage) and the user leaves, a new inline ref pulls no focus back, and this fails without the leave clear. A control that re-attaches without a swap (a new inline ref callback) moves no focus and reports no second onFocus. It fails if the hand-off ignores where focus is.

Evidence

  • Existing tests that now act as oracles:
    • the FR16 swap tests (adaptive while closed; adaptive while open; SSR hydration with hasAutoFocus) fail with the refs' hand-off removed;
    • the FR24 confirmation-focus tests fail with the handler's focus moves removed.
  • Suites: Tokenizer passes (165). Strict lint passes. The single flushSync carries an eslint exception that gives the reason.

Credit: @imdreamrunner (#7977), who built the sheet and these hand-offs.

…nd the confirmation's close handler

From the raw-effects audit (architecture:react-component-runtime INV2,
INV6). The presentation-swap hand-off was an effect fed by a read of
document.activeElement during render; both controls' callback refs now own
it: the detaching control notes it held focus (still in the document then,
which also covers a control focused before hydration), and the attaching
control takes it. The Clear all confirmation's close handler commits the
unmount with flushSync and then moves focus and announces, instead of an
effect waiting for the next render. Existing FR16 and FR24 focus tests
fail with either move removed; a new test shows a re-attach without a
swap moves no focus.

Co-authored-by: imdreamrunner <imdreamrunner@users.noreply.github.com>
@vercel

vercel Bot commented Oct 31, 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 Oct 31, 2026 9:17pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 31, 2026, 9:29 PM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 31, 2026
@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Preview: the links below open this branch's latest Vercel deployment, which can trail a new push until that push's deployment is ready.

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

Tokenizer (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 331B 331B 0B
Lines of Code 1953 1953 -
Complexity Very High Very High (309) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/PowerSearch 171.8KB 171.8KB -1B 20.3KB 20.3KB 0B
core/Tokenizer 71.6KB 71.6KB +30B 16.9KB 16.9KB 0B

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 36.0KB 36.0KB 0B

Published Package Size

Package Download before Download after Delta Unpacked before Unpacked after Delta Files
@astryxdesign/core 6.52MB 6.52MB -16B (-0.0%) 27.71MB 27.71MB -310B (-0.0%) 3699

10 other published packages are unchanged.

Download is the gzip tarball npm fetches; unpacked is what lands in node_modules.

Accessibility Audit

Status: Accessibility did not run on this pull request; add ci:run-a11y to run it. It runs after merges to main and before each release.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested: moving both hand-offs out of Effects is the right direction, and the FR16 and FR24 tests prove the new homes carry them, but the swap hand-off now covers only the input and the trigger. With presentation: 'adaptive', focus on a token's Remove button or the inline Clear all button drops to the page body when the screen turns compact-touch, where the base moved it to the trigger.

  • Note the pending hand-off whenever focus is inside the detaching field, and add swap tests that start on Remove and on Clear all.
  • Reword the changeset and the PR's "Unchanged" line: the public Tokenizer has no presentation swap or sheet, so the release note should not announce a consumer-facing fix.
  • Call isFocusDetached from utils/focusReturn.ts for the body check in handOffFocusRef.

[Automated review]

Comment on lines +858 to +862
return () => {
if (node.ownerDocument.activeElement === node) {
isFocusPendingHandOffRef.current = true;
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only the input or the trigger notes a pending hand-off here. With presentation: 'adaptive', a keyboard user on a token's Remove button or the inline Clear all button loses focus to the page body when the screen turns compact-touch; the base moved it to the trigger, because its flag was set on any focus entry into the field. Noting the hand-off when focus is anywhere inside the detaching field (for example from a cleanup-returning callback ref on the inline wrapper that checks el.contains(el.ownerDocument.activeElement)) would restore that, with swap tests that start on Remove and on Clear all.

const takesFocus = isFocusPendingHandOffRef.current;
isFocusPendingHandOffRef.current = false;
const active = node.ownerDocument.activeElement;
if (takesFocus && (active == null || active === node.ownerDocument.body)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is isFocusDetached(node.ownerDocument) from utils/focusReturn.ts, which also treats documentElement as detached. Calling it keeps one definition of "focus is on nothing".

'@astryxdesign/core': patch
---

[fix] `Tokenizer` hands focus to the new control when a presentation swap removes the focused one, through the controls' callback refs instead of a read of the focused element during render. A render React discards, as concurrent rendering can, no longer drops a keyboard user's focus to the page. After the sheet's Clear all confirmation closes, focus moves, and the clear is announced, in the confirm or cancel handler itself. Thanks @imdreamrunner (#7977).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The public Tokenizer offers no presentation option and renders only the popover, so it has no presentation swap or sheet, and on the base a discarded render only affected the hasAutoFocus hydration focus. As written, the release note tells consumers a public focus bug was fixed. Suggest wording that says the change is inside the internal sheet presentation (not yet public) and that public fields are unchanged.

@cixzhang
cixzhang marked this pull request as ready for review October 31, 2026 17:29
…n or Clear all; changeset says the change is internal

Review follow-up: only the input and the trigger noted a pending hand-off,
so with adaptive presentation, focus on a token's Remove button or the
inline Clear all fell to the body on a swap to the sheet, where the base
moved it to the trigger. The inline wrapper's callback ref (now stable)
notes focus anywhere inside it on detach, with a swap test from each. The
body check uses isFocusDetached. The changeset is a [chore] that says the
change sits inside the not-yet-public sheet presentation.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested: the follow-up fixes the Remove and Clear all hand-off, but a hand-off left pending by the inline wrapper's ref cleanup can now pull focus back into a field the user has left.

  • Toggling isDisabled on a focused field with disabledMessage re-attaches the wrapper and leaves the flag set, so after the user blurs to the page a new inline ref refocuses the input without onFocus, and an adaptive swap focuses the trigger; clearing the flag in handleBlurCapture's leave branch and the trigger's onBlur, with a regression test, fixes both.
  • A discarded render did not drop focus on the base (its ref writes persist), so the PR description's Before and the changeset's "Public fields … are unchanged" should describe a conformance change instead.
  • The new test's Remove step focuses Remove from the page, which the field redirects to the input; focus the input first and assert Remove holds focus before the swap.

[Automated review]

}
return () => {
if (el.contains(el.ownerDocument.activeElement)) {
isFocusPendingHandOffRef.current = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This cleanup also runs when the same wrapper re-attaches through a new wrapperCallbackRef, which happens whenever disabledMessageTooltip.ref changes identity (for example when isDisabled toggles on a field with disabledMessage). Nothing clears the flag on leave, so after the user blurs to the page, the next attach (a new inline ref on the input, or an adaptive swap) takes focus back, with onFocus suppressed. Clearing isFocusPendingHandOffRef in handleBlurCapture's leave branch and the trigger's onBlur keeps every existing test green and stops it.

render(<Harness presentation="adaptive" hasClear />);
// Inline: a token's Remove button holds focus, then the screen turns
// compact-touch.
screen.getByRole('button', {name: 'Remove Design'}).focus();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Focusing Remove from the page counts as entering the field, and handleFocusCapture redirects it to the input, so this step swaps from the combobox and passes without the wrapper's note. Focusing the combobox first and asserting expect(remove).toHaveFocus() before turn(true) makes it test the Remove path.

'@astryxdesign/core': patch
---

[chore] Inside `Tokenizer`'s internal sheet presentation (not yet public), focus hand-offs move from effects into callback refs and the Clear all confirmation's close handler. Public fields, which render only the popover, are unchanged. Thanks @imdreamrunner (#7977).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"Public fields … are unchanged" holds only once the pending hand-off is cleared on leave: today a public field can get focus pulled back into its input after a disabled toggle.

…geset calls this a conformance change

Review follow-up: a wrapper re-attached while focused (an isDisabled
toggle with disabledMessage) left the hand-off pending, so after the user
left the field a new inline ref or a swap pulled focus back. The
blur-capture leave branch and the trigger's onBlur now clear it, with a
regression test that fails without the clear. The Remove/Clear all swap
test enters the field first, so it tests the Remove path. The changeset
is a [chore] conformance note: where focus lands is unchanged.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Looks good: the follow-up clears a pending hand-off when focus leaves the field, so a re-attached wrapper no longer pulls focus back, and the new test fails without that clear. Every focus destination (a swap from the input, a Remove button or Clear all, and the Clear all confirmation) matches main, and CI is green. One optional addition: a test where a hand-off is pending and focus sits on an in-field Remove button when the input re-attaches would pin the isFocusDetached guard, which no current test exercises.

[Automated review]

@cixzhang
cixzhang merged commit b735ef0 into main Oct 31, 2026
53 checks passed
@github-actions
github-actions Bot deleted the lane4/tokenizer-focus-handoffs branch November 1, 2026 06:05

This branch was successfully deployed

1 active deployment
Preview — d7d54967 Deployed Oct 31, 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant