Component
TagPicker
Package version
@fluentui/react-tag-picker 9.10.3
React version
18.3.1
Environment
node v22.12.0
@fluentui/react-tag-picker 9.10.3
Chrome 147, production build, StrictMode OFF
Current Behavior
components/TagPickerControl/useTagPickerControl.tsx — a ResizeObserver callback schedules the
only write of --fui-TagPickerControl-aside-width inside a requestAnimationFrame. The
matching cancelAnimationFrame is called from the effect body rather than from the effect's
cleanup, so it races the observer's first callback and frequently cancels the frame that carries the
write.
Measured on one TagPicker, production build, no StrictMode, 12 fresh page loads:
|
frames that wrote the token |
resting padding-inline-end |
input width |
| today |
3 of 12 |
30px or 12px |
258.297 or 276.297 |
| after moving the cancel into the cleanup |
12 of 12 |
12px |
276.297 |
That is an 18px width swing between runs of the same page, with no user input and no code
change between loads.
Expected Behavior
The aside-width token is written on every mount. The control's resting geometry is deterministic.
Reproduction
Load a page containing a TagPicker with an aside (the clear/expand affordance) repeatedly in a
production build with StrictMode off, and read
getComputedStyle(control).getPropertyValue('--fui-TagPickerControl-aside-width') plus the
control's resting padding-inline-end on each load.
⚠ Reproduction trap — this is the single most likely way to wrongly conclude the report is
stale. Under StrictMode in a dev build the defect is invisible: unpatched measures 12/12,
because React's double-invoke detaches and re-attaches the ref, the observer fires again after the
effect has run, and the write lands. Anyone probing a StrictMode dev Storybook will see correct
behaviour every time.
⚠ Second trap — the mechanism is not what it looks like. This is not targetDocument
identity churn. Instrumented in the same production bundle, effectRuns is 1 on 12 of 12 — the
effect never re-runs. The race is React's passive-effect flush against the observer's first
callback, and the cross-tabulation is one-to-one: rafIdRef.current null → no cancel → 30px;
"set" → cancel → 12px. A maintainer handed the identity-churn story would check, find
targetDocument stable, and reasonably reject the report.
Steps to reproduce
- Build a page with a
TagPicker for production (NODE_ENV=production), without StrictMode.
- Load it 12 times, fresh each time.
- Record the control's resting
padding-inline-end on each load.
- Observe two distinct values across the runs.
Discovery context
Found during pixel-level verification of a styling layer built over
@fluentui/react-headless-components-preview. The layer's parity gate diffs each scene against its
@fluentui/react-components twin at threshold 0, and the TagPicker scene would not go green
deterministically — a scene that passes on some runs and fails on others is a race, not noise, so it
was traced rather than absorbed into a tolerance.
Proposed fix
Move the cancelAnimationFrame from the effect body into the effect's return () => { … }
cleanup. Roughly three lines.
Completeness was measured over four arms × 12 loads on a dev bundle before proposing it:
- no observer leak —
observe() calls equal constructor calls in every arm;
- no double-fire — the aside frame fires exactly once;
- no StrictMode regression — 12/12 with the fix;
- strictly better unmount — today nothing cancels the pending frame, which fires against a
detached ref and is swallowed by optional chaining.
Two regression tests were added to TagPickerControl.test.tsx, both verified to fail without the
fix. jsdom cannot reproduce the real timing race, but it can pin the ordering deterministically:
observe() is called from a ref callback, i.e. in the commit phase before React flushes the passive
effect, so a stub observer that invokes its callback synchronously puts a frame in flight by the
time the effect runs. The end-to-end browser probes remain the timing-level gate.
Note for whoever verifies: react-tag-picker's suite reports 1 failed / 181 passed both with and
without this change — a pre-existing @fluentui/react-icons snapshot drift, unrelated.
A working implementation is included in PR #[WINDMOD-PR-NUMBER] (commit f34b0a1125); happy to
split it into a standalone PR against this issue.
Suggested severity
High - No workaround (the write is internal; a consumer cannot schedule it themselves).
Are you willing to submit a PR to fix?
yes
Component
TagPicker
Package version
@fluentui/react-tag-picker9.10.3React version
18.3.1
Environment
Current Behavior
components/TagPickerControl/useTagPickerControl.tsx— aResizeObservercallback schedules theonly write of
--fui-TagPickerControl-aside-widthinside arequestAnimationFrame. Thematching
cancelAnimationFrameis called from the effect body rather than from the effect'scleanup, so it races the observer's first callback and frequently cancels the frame that carries the
write.
Measured on one
TagPicker, production build, no StrictMode, 12 fresh page loads:padding-inline-endThat is an 18px width swing between runs of the same page, with no user input and no code
change between loads.
Expected Behavior
The aside-width token is written on every mount. The control's resting geometry is deterministic.
Reproduction
Load a page containing a
TagPickerwith an aside (the clear/expand affordance) repeatedly in aproduction build with StrictMode off, and read
getComputedStyle(control).getPropertyValue('--fui-TagPickerControl-aside-width')plus thecontrol's resting
padding-inline-endon each load.⚠ Reproduction trap — this is the single most likely way to wrongly conclude the report is
stale. Under StrictMode in a dev build the defect is invisible: unpatched measures 12/12,
because React's double-invoke detaches and re-attaches the ref, the observer fires again after the
effect has run, and the write lands. Anyone probing a StrictMode dev Storybook will see correct
behaviour every time.
⚠ Second trap — the mechanism is not what it looks like. This is not
targetDocumentidentity churn. Instrumented in the same production bundle,
effectRunsis 1 on 12 of 12 — theeffect never re-runs. The race is React's passive-effect flush against the observer's first
callback, and the cross-tabulation is one-to-one:
rafIdRef.currentnull → no cancel → 30px;"set"→ cancel → 12px. A maintainer handed the identity-churn story would check, findtargetDocumentstable, and reasonably reject the report.Steps to reproduce
TagPickerfor production (NODE_ENV=production), withoutStrictMode.padding-inline-endon each load.Discovery context
Found during pixel-level verification of a styling layer built over
@fluentui/react-headless-components-preview. The layer's parity gate diffs each scene against its@fluentui/react-componentstwin at threshold 0, and the TagPicker scene would not go greendeterministically — a scene that passes on some runs and fails on others is a race, not noise, so it
was traced rather than absorbed into a tolerance.
Proposed fix
Move the
cancelAnimationFramefrom the effect body into the effect'sreturn () => { … }cleanup. Roughly three lines.
Completeness was measured over four arms × 12 loads on a dev bundle before proposing it:
observe()calls equal constructor calls in every arm;detached ref and is swallowed by optional chaining.
Two regression tests were added to
TagPickerControl.test.tsx, both verified to fail without thefix. jsdom cannot reproduce the real timing race, but it can pin the ordering deterministically:
observe()is called from a ref callback, i.e. in the commit phase before React flushes the passiveeffect, so a stub observer that invokes its callback synchronously puts a frame in flight by the
time the effect runs. The end-to-end browser probes remain the timing-level gate.
Note for whoever verifies:
react-tag-picker's suite reports 1 failed / 181 passed both with andwithout this change — a pre-existing
@fluentui/react-iconssnapshot drift, unrelated.A working implementation is included in PR #[WINDMOD-PR-NUMBER] (commit
f34b0a1125); happy tosplit it into a standalone PR against this issue.
Suggested severity
High - No workaround (the write is internal; a consumer cannot schedule it themselves).
Are you willing to submit a PR to fix?
yes