Repository navigation
perf(Layer): a closing layer reads its animations only when its closed state declares an exit - #10368
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report🔎 Preview ·
|
| Component | Size | LoC | Visual · A11y · RTL |
|---|---|---|---|
| LayerProvider | 4.1 kB (0) · 20 B CSS (0) | — | ✓ · ✓ · ✗ |
| lab/CommentPin | 53.2 kB (+231 B) · 1.2 kB CSS (0) | — | — · — · — |
| TabList | 40.7 kB (+227 B) · 6.4 kB CSS (0) | — | — · — · — |
112 more components
| Component | Size | LoC | Visual · A11y · RTL |
|---|---|---|---|
| ChatComposerInput | 67.0 kB (+223 B) · 10.7 kB CSS (0) | — | — · — · — |
| ChatComposerTokenElement | 67.0 kB (+223 B) · 10.7 kB CSS (0) | — | — · — · — |
| Typeahead | 75.1 kB (+221 B) · 12.2 kB CSS (0) | — | — · — · — |
| DateInput | 177.3 kB (+220 B) · 16.5 kB CSS (0) | — | — · — · — |
| FileInput | 64.8 kB (+219 B) · 10.7 kB CSS (0) | — | — · — · — |
| Thumbnail | 56.7 kB (+219 B) · 11.0 kB CSS (0) | — | — · — · — |
| richtext/RichTextEditor | 292.0 kB (+219 B) · 2.6 kB CSS (0) | — | — · — · — |
| SegmentedControl | 90.1 kB (+218 B) · 6.8 kB CSS (0) | — | — · — · — |
| BreadcrumbItem | 76.6 kB (+217 B) · 11.6 kB CSS (0) | — | — · — · — |
| Avatar | 40.0 kB (+215 B) · 5.5 kB CSS (0) | — | — · — · — |
| DateTimeInput | 199.7 kB (+215 B) · 18.2 kB CSS (0) | — | — · — · — |
| BaseTypeahead | 62.6 kB (+214 B) · 10.4 kB CSS (0) | — | — · — · — |
| FieldLabel | 47.7 kB (+214 B) · 5.6 kB CSS (0) | — | — · — · — |
| InputGroup | 52.6 kB (+213 B) · 7.8 kB CSS (0) | — | — · — · — |
| SideNavItem | 62.3 kB (+213 B) · 10.5 kB CSS (0) | — | — · — · — |
| CodeBlock | 67.1 kB (+212 B) · 10.7 kB CSS (0) | — | — · — · — |
| TopNav | 64.1 kB (+212 B) · 10.3 kB CSS (0) | — | — · — · — |
| richtext/RichTextView | 271.8 kB (+212 B) · 2.3 kB CSS (0) | — | — · — · — |
| RadioList | 52.3 kB (+211 B) · 7.2 kB CSS (0) | — | — · — · — |
| RadioListItem | 63.2 kB (+211 B) · 9.8 kB CSS (0) | — | — · — · — |
| lab/SelectableCardGroup | 60.2 kB (+211 B) · 162 B CSS (0) | — | — · — · — |
| lab/TourStep | 86.4 kB (+211 B) · 590 B CSS (0) | — | — · — · — |
| Button | 52.3 kB (+210 B) · 9.4 kB CSS (0) | — | — · — · — |
| Carousel | 61.2 kB (+210 B) · 10.4 kB CSS (0) | — | — · — · — |
| MobileNavToggle | 52.6 kB (+210 B) · 9.4 kB CSS (0) | — | — · — · — |
| Toolbar | 40.0 kB (+210 B) · 7.9 kB CSS (0) | — | — · — · — |
| CheckboxList | 54.8 kB (+209 B) · 8.0 kB CSS (0) | — | — · — · — |
| IconButton | 52.3 kB (+209 B) · 9.4 kB CSS (0) | — | — · — · — |
| ListItem | 65.4 kB (+209 B) · 11.0 kB CSS (0) | — | — · — · — |
| Stepper | 54.0 kB (+209 B) · 9.6 kB CSS (0) | — | — · — · — |
| TopNavMegaMenu | 61.6 kB (+209 B) · 11.3 kB CSS (0) | — | — · — · — |
| ChatLayout | 55.6 kB (+208 B) · 9.9 kB CSS (0) | — | — · — · — |
| Lightbox | 103.6 kB (+208 B) · 9.9 kB CSS (0) | — | — · — · — |
| TextArea | 58.4 kB (+208 B) · 9.2 kB CSS (0) | — | — · — · — |
| Switch | 96.9 kB (+207 B) · 9.9 kB CSS (0) | — | — · — · — |
| TextInput | 63.4 kB (+207 B) · 10.9 kB CSS (0) | — | — · — · — |
| ChatLayoutScrollButton | 52.8 kB (+206 B) · 9.6 kB CSS (0) | — | — · — · — |
| CheckboxInput | 60.3 kB (+206 B) · 9.1 kB CSS (0) | — | — · — · — |
| SideNavCollapseButton | 53.1 kB (+206 B) · 9.5 kB CSS (0) | — | — · — · — |
| SideNavHeading | 67.4 kB (+206 B) · 10.7 kB CSS (0) | — | — · — · — |
| ToggleButton | 53.2 kB (+206 B) · 9.5 kB CSS (0) | — | — · — · — |
| ChatComposer | 68.9 kB (+205 B) · 11.3 kB CSS (0) | — | — · — · — |
| ChatSendButton | 52.7 kB (+205 B) · 9.4 kB CSS (0) | — | — · — · — |
| ChatDictationButton | 52.9 kB (+204 B) · 9.5 kB CSS (0) | — | — · — · — |
| Field | 51.0 kB (+204 B) · 7.2 kB CSS (0) | — | — · — · — |
| Popover | 80.2 kB (+204 B) · 13.5 kB CSS (0) | — | — · — · — |
| SideNav | 82.0 kB (+204 B) · 10.8 kB CSS (0) | — | — · — · — |
| Table | 101.1 kB (+204 B) · 17.3 kB CSS (0) | — | — · — · — |
| DialogHeader | 55.1 kB (+203 B) · 10.9 kB CSS (0) | — | — · — · — |
| MobileNav | 62.4 kB (+203 B) · 10.1 kB CSS (0) | — | — · — · — |
| lab/ChatEmojiPicker | 98.2 kB (+203 B) · 773 B CSS (0) | — | — · — · — |
| AvatarGroupOverflow | 45.0 kB (+202 B) · 5.5 kB CSS (0) | — | — · — · — |
| Link | 49.3 kB (+202 B) · 7.0 kB CSS (0) | — | — · — · — |
| TabMenu | 63.7 kB (+202 B) · 10.5 kB CSS (0) | — | — · — · — |
| Banner | 54.4 kB (+201 B) · 9.9 kB CSS (0) | — | — · — · — |
| Pagination | 104.7 kB (+201 B) · 17.3 kB CSS (0) | — | — · — · — |
| PowerSearch | 277.1 kB (+201 B) · 23.2 kB CSS (0) | — | — · — · — |
| lab/DiffViewer | 78.7 kB (+201 B) · 2.1 kB CSS (0) | — | — · — · — |
| AppShell | 82.2 kB (+200 B) · 12.9 kB CSS (0) | — | — · — · — |
| AvatarGroup | 42.7 kB (+200 B) · 5.6 kB CSS (0) | — | — · — · — |
| DateRangeInput | 87.3 kB (+200 B) · 12.2 kB CSS (0) | — | — · — · — |
| DrawerHeader | 55.3 kB (+200 B) · 12.3 kB CSS (0) | — | — · — · — |
| TableSelectionToolbar | 61.8 kB (+200 B) · 12.5 kB CSS (0) | — | — · — · — |
| charts/Chart | 180.9 kB (+199 B) · 494 B CSS (0) | — | — · — · — |
| Calendar | 63.3 kB (+199 B) · 10.0 kB CSS (0) | — | — · — · — |
| TableHeaderCell | 92.5 kB (+199 B) · 16.8 kB CSS (0) | — | — · — · — |
| TopNavMenu | 66.5 kB (+199 B) · 10.6 kB CSS (0) | — | — · — · — |
| AlertDialog | 96.5 kB (+198 B) · 13.8 kB CSS (0) | — | — · — · — |
| InfoTip | 43.8 kB (+198 B) · 5.3 kB CSS (0) | — | — · — · — |
| lab/EmojiPicker | 81.9 kB (+198 B) · 751 B CSS (0) | — | — · — · — |
| MultiSelector | 89.8 kB (+197 B) · 16.4 kB CSS (0) | — | — · — · — |
| Selector | 94.5 kB (+196 B) · 17.0 kB CSS (0) | — | — · — · — |
| TableCell | 92.9 kB (+195 B) · 17.1 kB CSS (0) | — | — · — · — |
| lab/Schedule | 100.3 kB (+195 B) · 3.9 kB CSS (0) | — | — · — · — |
| MoreMenu | 88.2 kB (+194 B) · 16.8 kB CSS (0) | — | — · — · — |
| Slider | 102.2 kB (+194 B) · 8.7 kB CSS (0) | — | — · — · — |
| charts/ChartTooltip | 158.3 kB (+193 B) · 423 B CSS (0) | — | — · — · — |
| TopNavHeading | 66.8 kB (+193 B) · 10.5 kB CSS (0) | — | — · — · — |
| lab/TransferListSelector | 98.6 kB (+193 B) · 1.9 kB CSS (0) | — | — · — · — |
| richtext/RichTextEditorToolbar | 245.7 kB (+193 B) · 454 B CSS (0) | — | — · — · — |
| ComplexSelector | 66.4 kB (+192 B) · 11.5 kB CSS (0) | — | — · — · — |
| DropdownMenuSubMenu | 65.2 kB (+192 B) · 9.4 kB CSS (0) | — | — · — · — |
| TimeInput | 154.0 kB (+192 B) · 15.6 kB CSS (0) | — | — · — · — |
| Tokenizer | 86.4 kB (+192 B) · 18.2 kB CSS (0) | — | — · — · — |
| lab/CommentThread | 303.2 kB (+192 B) · 2.4 kB CSS (0) | — | — · — · — |
| ContextMenu | 91.7 kB (+190 B) · 16.7 kB CSS (0) | — | — · — · — |
| HoverCard | 24.9 kB (+190 B) · 4.3 kB CSS (0) | — | — · — · — |
| CheckboxListItem | 66.3 kB (+187 B) · 11.0 kB CSS (0) | — | — · — · — |
| DropdownMenu | 87.9 kB (+187 B) · 16.8 kB CSS (0) | — | — · — · — |
| Tooltip | 26.4 kB (+187 B) · 4.3 kB CSS (0) | — | — · — · — |
| Markdown | 163.5 kB (+186 B) · 19.6 kB CSS (0) | — | — · — · — |
| NumberInput | 67.4 kB (+185 B) · 11.2 kB CSS (0) | — | — · — · — |
| lab/TransferList | 91.6 kB (+185 B) · 1.7 kB CSS (0) | — | — · — · — |
| lab/ChatReactionBar | 99.3 kB (+184 B) · 1.1 kB CSS (0) | — | — · — · — |
| lab/ListInput | 96.9 kB (+181 B) · 1.6 kB CSS (0) | — | — · — · — |
| Toast | 89.9 kB (+178 B) · 9.7 kB CSS (0) | — | — · — · — |
| lab/Comments | 308.3 kB (+177 B) · 2.5 kB CSS (0) | — | — · — · — |
| StatusDot | 28.1 kB (+173 B) · 5.3 kB CSS (0) | — | — · — · — |
| lab/CommentComposer | 139.5 kB (+155 B) · 1.9 kB CSS (0) | — | — · — · — |
| CommandPaletteInput | 27.9 kB (+1 B) · 7.5 kB CSS (0) | — | — · — · — |
| Text | 7.6 kB (+1 B) · 5.4 kB CSS (0) | — | — · — · — |
| charts/ChartLegend | 35.8 kB (0) · 224 B CSS (0) | — | — · — · — |
| ChatMessageList | 23.9 kB (0) · 7.0 kB CSS (0) | — | — · — · — |
| ChatToolCalls | 27.8 kB (0) · 8.3 kB CSS (0) | — | — · — · — |
| CommandPalette | 67.2 kB (0) · 12.4 kB CSS (0) | — | — · — · — |
| Heading | 8.2 kB (0) · 5.4 kB CSS (0) | — | — · — · — |
| ProgressBar | 11.5 kB (0) · 6.2 kB CSS (0) | — | — · — · — |
| Spinner | 20.5 kB (0) · 6.1 kB CSS (0) | — | — · — · — |
| Timer | 9.7 kB (0) · 5.9 kB CSS (0) | — | — · — · — |
| Timestamp | 23.2 kB (−1 B) · 9.7 kB CSS (0) | — | — · — · — |
| layer | — | — | — · ✓ · — |
| Layer | — | — | ✓ · ✓ · — |
Package size: @astryxdesign/core 6.84 MB packed (+1.6 kB); 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, ci:run-a11y or ci:run-rtl to run the full suites.
Generated by PR Enrichment workflow | View full report
There was a problem hiding this comment.
Requesting changes: skipping the animation read for layers that cannot keep painting removes the extra layout on close, but the decision is read from the wrong style in one case.
useLayer.tsx:1506and:1659readkeepsPaintingAfterHidefrom the open-state style while the browser runs a close transition from the closed-state style, so a layer whose discrete close transition is declared only on its closed state loses its trigger's anchor name while it still fades (architecture:layer-runtimeINV18); decide after the hide fromgetComputedStyle(layer).display !== 'none', a non-geometry read, and add a test for that styling.
Two smaller notes are inline: the JSDoc and changeset say overlay alone keeps a hidden layer shown, which it does not, and one added comment restates the code. CI has not run on this draft head yet.
[Automated review]
ebf360e to
fe50cdb
Compare
There was a problem hiding this comment.
Approved pending CI: CI has not run on this head yet, and it starts when the pull request is marked ready for review.
A layer with no discrete display close transition now skips the animation read on close, and the earlier problem is fixed: a fade declared only on the closed state keeps its trigger's anchor name until it ends, because the answer is read from the closed state's style at the show. One non-blocking comment-wording suggestion is inline.
[Automated review]
fe50cdb to
ee087b2
Compare
There was a problem hiding this comment.
Requesting changes: skipping the animation read for a layer with no exit removes the extra layout on close, but the new check misses two kinds of exit that keep a hidden layer painting.
- Miss:
closeKeepsShown(useLayer.tsx:538-561) counts only a discretedisplaytransition, so a layer that animates out through a closed-state keyframe animation or a script animation ofdisplay(both keep a hidden popover painting in Chromium) loses its trigger's anchor at the hide and jumps away from the trigger mid-fade, againstarchitecture:layer-runtimeINV18 ("A layer that animates out counts as shown until its animations end"); keep the read for those exits, or narrow INV18 through a record change an engineering owner approves. - Miss: the changeset's "A layer that fades out … still keeps its trigger's anchor name until the fade ends" and the JSDoc's "and so does every animation in it" (
useLayer.tsx:532-534) are false for those exits, so scope them to a closed state that declares a discretedisplaytransition unless the code fix makes them true.
Non-blocking: the comment at useLayer.tsx:1234 restates the ref's name. CI has not run on this draft head yet.
[Automated review]
…d state declares an exit Closing an anchored layer read the layer's animations right after hidePopover(), to keep the trigger's anchor name through a close fade. That read updates style and layout between the close's writes, so every close laid out the page twice. A hidden popover keeps showing only while its closed state holds display: through a discrete display transition, or through a keyframe animation the close starts. The runtime reads that from the closed state's style while the layer is still closed, and reads the animations at the close only for such a layer. layer-runtime INV18's fade clause now says so, consistent with react-component-runtime INV18; an animation a script starts is not tracked. overlay-churn's interaction layouts go from 800 to 709 on CI.
ee087b2 to
59740bb
Compare
|
/approve-spec 59740bb |
There was a problem hiding this comment.
Approved pending CI: CI has not run on this head yet, and it starts when the pull request is marked ready for review.
Closing a layer with no declared exit no longer reads its animations, a closed-state keyframe exit keeps its trigger's anchor through the fade again, and the INV18 rewording that stops tracking script-started animations carries an engineering owner's approval for this head. Two non-blocking suggestions:
- The changeset says the runtime reads when the closed state declares "a keyframe animation"; entrance keyframes declared for both states are not read, so "a keyframe animation that only its closed state declares" is accurate.
- The JSDoc of
closeTransitionKeepsShown(first sentence) and ofanimationNamesrestates the function names; keep only the reason.
[Automated review]
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [perf] Closing a Popover, menu or other anchored layer no longer lays out the page an extra time. The layer runtime reads a closing layer's animations only when the layer's closed state declares a discrete `display` transition or a keyframe animation. It reads that style while the layer is still closed. Such a layer, as TopNavMegaMenu's panel is, still keeps its trigger's anchor name until its fade ends. An animation a script starts is not tracked. |
There was a problem hiding this comment.
Non-blocking: the entrance keyframes of Popover and the menus (layerAnimations) are declared on the closed state too, and the runtime does not read them. "…or a keyframe animation that only its closed state declares" matches what the code reads.
| } | ||
|
|
||
| /** | ||
| * Whether the closed state's transition keeps `layer` shown after it hides. |
There was a problem hiding this comment.
Non-blocking (INV29): this sentence restates closeTransitionKeepsShown, and the JSDoc at line 562 restates animationNames. Drop both; the two sentences after this one keep the reason.
Fixes #10247
User impact
Every close of an anchored layer (Popover, DropdownMenu, Tooltip, HoverCard, ContextMenu, Selector lists, …) laid out the whole page one extra time. A page that opens and closes such layers often paid for each close twice. The nightly perf scene
overlay-churn(150 opens and closes) went from 752 to 852 layouts per interaction when the trigger's anchor name started following the layer's shown state, and the open regression issue (#10247) tracks it.Problem and solution fit
layer.getAnimations({subtree: true})right afterhidePopover(). That call updates style and layout. It ran between the hide's own writes and React's commit, so the page laid out once inside the close task and again in the frame that paints the close.architecture:react-component-runtimeINV18 keeps layout reads to the ones a record requires, in one batched pass per change, never between writes. This read sat between writes on every close, and it was needed only for a layer that fades out.display: through a discretedisplaytransition (transition-behavior: allow-discrete), or through a keyframe animation that the close starts, which can animatedisplay. Without either, the browser gives itdisplay: noneas it hides, and its CSS animations stop. The close takes its values from the closed state's style, so the runtime reads that style while the layer is still closed, at the start ofshow()and before the show writes anything. There the read lays nothing out. At the close, only a layer whose closed state declares such an exit has its animations read, exactly as before.displayafter the hide: measured on the same base, that read raisedoverlay-churnto 944 (samples 872 to 988), abovemain's 804. Like the animation read, it updates style between the writes of the close.Expected behavior and authority
architecture:react-component-runtimeINV18: layout is read as little as possible, and never between writes. A forced read on every close does not meet it.architecture:layer-runtimeINV18: only a shown layer's trigger is an anchor. This PR makes its fade clause consistent with react-component-runtime INV18. A layer counts as shown until the CSS transitions or keyframe animations that its closed state declares end; an animation a script starts is not tracked. That clause is the only one that needed the read on every close. TopNavMegaMenu's panel declares a discretedisplaytransition, so its close still reads the fade and keeps the name until the fade ends.exitAnimationsinreleaseAnchor).Smallest restoration
useLayer.tsx:show()reads the closed layer's computed style before its writes:closeTransitionKeepsShown(transition-property,transition-behavior,transition-duration,transition-delay) and itsanimation-name. A layer styled with an instant open and a fading close (transitionDuration: {default: '200ms', ':popover-open': '0s'}) counts, because its closed state declares the fade.showPopover(), it reads the open state'sanimation-name. A keyframe animation that only the closed state declares starts at the close and can animatedisplay, so it counts. One that both states declare is the entrance (layerAnimations), and the hide stops it, so it does not.releaseAnchorreads the layer's animations only when one of those counts. Otherwise it takes the trigger's anchor name away at once, in the same task as the hide, as it already did when it found no animation. Our ownhide()and the browser's own hide (toggle) both use it.docs/architecture/layer-runtime.md: INV18's fade sentence now reads "A layer that animates out through a CSS transition or keyframe animation it declares for its closed state counts as shown until those animations end; an animation a script starts is not tracked." The verification table gains an INV18 row for the closed-state exits.Evidence
overlay-churninteractionLayouts, from the repository's perf workflow (perf-nightly.yml, dispatched, 5 samples each, Chromium on the standard runner):mainat 6943968 (this PR's base)mainshows one forced layout insideexitAnimationson every Popover and menu close (19 of 20 closes), then the frame's own layout.hidePopover()itself, as before fix(Layer): a closed layer's trigger is no CSS anchor #9963.comments-panel2,table8,tree-list5,markdown1 interaction layouts). The Dialog in the same scene has no anchor and is unchanged.useLayer.test.tsx: "reads no animations when nothing keeps the hidden layer shown" spies ongetAnimationsfor both our hide and the browser's own hide. "reads the animations of a layer whose closed state runs a keyframe animation" covers the keyframe exit. "reads the close transition from the closed state's style" covers an instant open with a fading close. The fade cases declare TopNavMegaMenu's discretedisplaytransition and still keep the name until the fade ends.The nightly's 924 on the second night was run-to-run variation, not a second change: a dispatched run on the same commit measured 832.
Writing rubric results
Over
docs/architecture/layer-runtime.md:R1 narration: none; the new INV18 sentence and the verification row state what the runtime does, in the present tense.
R2 research: none in the record; the measurements are in this description.
R3 evidence: the new verification row names the unit test file, not a pull request.
R4 rejected alternatives: none.
R5 change references: none in the record.
R6 open questions: none.
Scope
Testing
vitest run --project ui packages/core/src/{Layer,Popover,DropdownMenu,ContextMenu,Tooltip,HoverCard,TopNav}: 52 files, 1488 tests pass.Menu.a11y.test.tsxtimed out once (30 s) under machine load and passes on a re-run.check:knowledgeandcheck:changesetspass.ci:run-perfon this PR, and the dispatched runs above.TooltipClosedTriggerAnchor.interaction.browser.spec.tscovers TopNavMegaMenu's panel staying placed through its close fade in Chromium.