Skip to content

fix(BottomSheet): a render reads the stored viewport height - #10363

Merged
cixzhang merged 4 commits into
meta:mainfrom
vjeux:uikit/c17-sheet-viewport-store
Nov 7, 2026
Merged

cixzhang merged 4 commits into
meta:mainfrom
vjeux:uikit/c17-sheet-viewport-store

Conversation

@vjeux

@vjeux vjeux commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

User impact

A tap that renders an open BottomSheet again, such as DateInput's month and year toggle or a checkbox in the sheet, made the sheet read window.innerHeight in React's commit, after the commit's DOM writes. So the browser recalculated style and layout inside the tap's handler. The first frame after the tap is unchanged by this fix, because the frame then does the same recalculation (Evidence).

Problem and solution fit

  • Why: the panel's useSyncExternalStore snapshot was an inline function that read innerHeight. Its identity changed on every render, so React re-read it in the commit.
  • The fix: the panel keeps the layout viewport's height in a module-level store. One resize listener updates it: the first open sheet adds it, and the last one removes it. React gets stable module functions for the snapshot and the server snapshot. While the listener runs, a render reads the stored value and the commit reads no viewport height. With no listener, a render reads innerHeight and stores nothing, so a render that never commits leaves no stale height.
  • No behavior change: the value and its updates are today's, innerHeight at the first render and on each resize. Sheet height, reserve and detents are unchanged; snapHeights still reads the viewport when a gesture asks.

Expected behavior and authority

architecture:react-component-runtime INV18: layout is read as little as possible, once per change. INV5: the listener has one owner and goes with the last sheet. INV9: the snapshot keeps its identity.

Smallest restoration

One store in BottomSheetPanel.tsx. No public API.

Evidence

  • Reproduction before the change: in Chromium at a 4× CPU slowdown, the toggle's tap forced a 41–42 ms style recalculation and a 2.5–2.8 ms layout from innerHeight, through React's store check. The tap reads innerHeight 2 times (the toggle) and 3 times (a checkbox).
  • Result after the change: the tap reads innerHeight 0 times, and the click's task forces no style or layout. The click handler drops from 109–110 ms to 65–71 ms. The scrolling body still measures its scroll geometry in the next frame, as on main.
  • Representative unchanged path: the click's task is 127–130 ms before and 126–142 ms after, and the iOS Simulator's first frame is unchanged (a table in Testing).
  • Regression test or other mutation-sensitive proof: unit cases fail on main and on mutants that keep the listener, skip the resize refresh, read innerHeight through a stable function, or store the height with no listener (a render beside a suspending sibling).

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

Local (Linux), on main 39f2441: the full pnpm test:node (16,962 tests, including the routing-lines check of every browser spec); core tsc; units for BottomSheet, DateInput, hooks and utils (3,321); ESLint on the changed files; Prettier; check:knowledge; the Changeset coverage check; pnpm review:pre. On a Storybook built from this head: the new read-count spec 3 of 3 in Chromium, twice (on main's build its two tap cases fail with 2 and 3 reads); BottomSheet's a11y specs in Chromium 84 of 84 and its interaction specs 16 of 16. BottomSheet's and DateInput's specs in WebKit under the CI profile (4 CPUs, CI=1): a11y 54 passed, 2 skipped; interaction 25 passed, 15 skipped. Opening a sheet still reads the height once after its commit, in the store's first subscription, as today's open does.

Chromium, 4× CPU slowdown, DateInput bottom-sheet-wide-range toggle (3 taps each) main this change
Forced style and layout in the click's task 44–45 ms 0 ms
Click handler 109–110 ms 65–71 ms
The click's task 127.0, 130.0, 129.1 ms 128.0, 142.3, 126.0 ms
iOS 26.2 Simulator, iPhone 17 Pro, no throttle: tap to next frame (median) main this change
DateInput bottom-sheet-wide-range (n=4) 40 ms 41 ms
DateTimeInput default, bottom sheet (n=3) 44 ms 45 ms

Overlap: git merge-tree is clean with #10233 and #9776. #9777 conflicts with main in 15 files, and this adds none. The store needs none of #10233's realm-shared values: each copy of Core reads the same innerHeight through its own listener.

BottomSheetPanel read window.innerHeight in an inline
useSyncExternalStore snapshot. Its identity changed on every render, so
React re-read it in the commit after the DOM writes, and the browser
recalculated style and layout in the tap's handler. The panel now keeps
the layout viewport's height in a module-level store. One resize
listener updates it: the first open sheet adds it and the last removes
it. React gets stable module functions for the snapshot and the server
snapshot, so a render reads the stored value and the commit reads
nothing.

The value and its updates are today's: innerHeight, at the first render
and on each resize. In Chromium and on the iOS Simulator the tap's first
frame is unchanged: the frame then does the same recalculation.
@vercel

vercel Bot commented Nov 6, 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 Nov 6, 2026 10:50pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 6, 2026, 11:07 PM UTC

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

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · d8c2447

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
DateTimeInput 196.3 kB (+122 B) · 18.1 kB CSS (0) — — · — · —
PowerSearch 272.3 kB (+105 B) · 23.1 kB CSS (0) — — · — · —
DateInput 173.8 kB (+88 B) · 16.4 kB CSS (0) — — · — · —
22 more components
Component Size LoC Visual · A11y · RTL
TimeInput 150.6 kB (+85 B) · 15.5 kB CSS (0) — — · — · —
BottomSheet 59.5 kB (+77 B) · 7.5 kB CSS (0) — ✓ · ✓ · —
ContextMenu 90.6 kB (+1 B) · 16.6 kB CSS (0) — — · — · —
lab/ChatEmojiPicker 96.7 kB (+1 B) · 773 B CSS (0) — — · — · —
lab/CommentThread 302.0 kB (+1 B) · 2.4 kB CSS (0) — — · — · —
BottomSheetSwitcher 17.5 kB (0) · 3.4 kB CSS (0) — ✓ · ✓ · —
DropdownMenu 86.3 kB (0) · 16.7 kB CSS (0) — — · — · —
Markdown 162.4 kB (0) · 19.5 kB CSS (0) — — · — · —
MoreMenu 86.6 kB (0) · 16.7 kB CSS (0) — — · — · —
Pagination 103.4 kB (0) · 17.3 kB CSS (0) — — · — · —
Popover 78.7 kB (0) · 13.4 kB CSS (0) — — · — · —
Selector 93.2 kB (0) · 16.9 kB CSS (0) — — · — · —
Table 100.0 kB (0) · 17.3 kB CSS (0) — — · — · —
TableCell 91.8 kB (0) · 17.0 kB CSS (0) — — · — · —
TableHeaderCell 91.4 kB (0) · 16.8 kB CSS (0) — — · — · —
Tokenizer 84.9 kB (0) · 18.1 kB CSS (0) — — · — · —
lab/ChatReactionBar 97.9 kB (0) · 1.1 kB CSS (0) — — · — · —
lab/CommentComposer 138.0 kB (0) · 1.9 kB CSS (0) — — · — · —
lab/Comments 307.2 kB (0) · 2.5 kB CSS (0) — — · — · —
lab/TourStep 84.9 kB (0) · 590 B CSS (0) — — · — · —
richtext/RichTextEditorToolbar 244.4 kB (0) · 454 B CSS (0) — — · — · —
MultiSelector 88.8 kB (−1 B) · 16.3 kB CSS (0) — — · — · —

Package size: @astryxdesign/core 6.79 MB packed (+998 B); no new dependencies.

API changes: none in this pull request

Committed API report behind this build: cli 19 rows, core 24 rows; run pnpm api:report.

Touched components checked; add ci:run-visual or ci:run-rtl to run the full suites.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot 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.

Approved pending CI: CI has not run on this head yet. It runs once the pull request leaves draft and a maintainer adds the ci:run label.

The stored viewport height removes the commit-time innerHeight read without changing the sheet: a tap in an open sheet reads it 3 times on main and 0 times here in Chromium, the new re-render test fails on main, and the reserve tests pass unchanged.

Optional: readViewportHeight stores a height during a render that may never commit (a suspending sibling, say), so the next sheet's first commit can use a stale reserve until its subscription corrects it; returning layoutViewportHeight() uncached while no listener is attached keeps the comment's "null with no sheet open" true.

[Automated review]

A render that never commits, such as one beside a suspending sibling,
stored a height that no listener kept current, and the next sheet's
first commit used it. A render now reads the viewport while no sheet
listens, and the first subscription stores the height and lets React
compare it with the one it rendered.

@astracat-bot astracat-bot Bot 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.

Approved pending CI: CI has not run on this head yet. It runs once the pull request leaves draft and a maintainer adds the ci:run label.

Storing the viewport height only while the shared resize listener runs fixes the stale-reserve case from the last review: the new discarded-render test fails on the previous revision (a 360 px reserve instead of 260 px) and passes here, and the re-render test still fails on main with 3 reads.

Optional, inherited from main: handleViewportResize also runs for element resize events, such as a <video>'s, and reads innerHeight each time; an isNode(event.target) guard, as the keyboard module uses, would skip them.

[Automated review]

@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 6, 2026
@vjeux
vjeux marked this pull request as ready for review November 6, 2026 21:54
A @Pr-a11y scope token names a component with its package, so the
accessibility contracts job rejected the bare DateInput token before
it ran any spec.
@vjeux
vjeux marked this pull request as draft November 6, 2026 22:18
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec d8fc02e

@astracat-bot astracat-bot Bot 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.

Changes requested: the viewport-height store is sound, but the release note claims more than the change proves.

  • Miss: the changeset's first sentence says "an open sheet that renders again reads no layout", but the change and its tests cover only the innerHeight read and the sheet's scrolling body still measures scroll geometry after such a render (18 to 36 reads per toggle in Chromium), so please reword it to "an open sheet that renders again no longer reads the viewport height" (.changeset/bottom-sheet-viewport-height-store.md:5).

The code needs no change: the re-render test fails on main with 3 reads, a Chromium tap in an open sheet drops from 3 innerHeight reads to 0 with pixel-identical frames, and the reserve tests pass unchanged.

[Automated review]

The scrolling body still measures its scroll geometry after an open
sheet renders again, as before. So the changeset says the sheet no
longer reads the viewport height, not that it reads no layout.
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec d8c2447

@astracat-bot astracat-bot Bot 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.

Approved pending CI: CI has not run on this head yet. The pull request already carries ci:run, so CI runs once it is marked ready for review.

The changeset now claims only what the change proves: an open sheet that renders again no longer reads the viewport height. The re-render test fails against the base component with 3 reads and passes here, a Chromium tap in an open sheet drops from 3 innerHeight reads to 0 with pixel-identical frames and an unchanged reserve, and the BottomSheet and DateInput unit suites pass.

[Automated review]

@vjeux
vjeux marked this pull request as ready for review November 6, 2026 23:27
@cixzhang cixzhang added the ci:run-a11y Run the accessibility suite on this PR; write access, next push (AST-030 FR12) label Nov 7, 2026
@cixzhang
cixzhang marked this pull request as draft November 7, 2026 03:59
@cixzhang
cixzhang marked this pull request as ready for review November 7, 2026 03:59
@cixzhang
cixzhang merged commit 3c1f7b2 into meta:main Nov 7, 2026
257 of 270 checks passed
@vjeux
vjeux deleted the uikit/c17-sheet-viewport-store branch November 7, 2026 04:30

This branch was successfully deployed

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

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) ci:run-a11y Run the accessibility suite on this PR; write access, next push (AST-030 FR12) 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.

2 participants