Skip to content

Review 5549

Cindy Zhang edited this page Aug 27, 2026 · 1 revision

PR

#5549 fix(Drawer): stop the panel repainting into the page after close() by freddymeta (bucket: collaborator; treat as internal)

HEAD REVIEWED

da75e7b9d2c6d564268ccd20dc120666ad2c4436

VERSIONS

LOOP VERSION: 1.5.0 AUDIT RUBRIC: 1.12

PRIOR REVIEW: none — GitHub reviews and inline comments checked at the exact head.

LANE

LANE: full WHY: the diff changes visible close behavior, adds render state, changes an Effect, adds a native transition listener and timer protocol, and is the base of the active Drawer set with #5550.

PROBLEM

WHY 1: after dialog.close() releases a modal Drawer from the top layer, a transformed/container ancestor can become the fixed panel's containing block while the panel still paints. WHY 2: someone who just dismissed the panel sees it jump back over the page they returned to; a start-side Drawer can also jump to the opposite edge when the caller clears the state that supplied side. WHY 3: that breaks spatial continuity and makes the person reacquire the page after every affected close, even though focus and pointer hit testing already reach the page. USER-FACING PROBLEM: a person dismissing a Drawer under a containing-block ancestor sees the dismissed panel reappear over unrelated content; a person closing a state-derived start Drawer sees it leave through the wrong edge. PROBLEM SEVERITY: harmful friction — the task completes and the underlay remains hit-testable, but the visible return to page context is wrong for the tail of the transition.

VERDICT: clear

SOLUTION

The panel waits for its own slide to finish rather than assuming a fixed duration. At completion, the native dialog closes and React hides the panel in the same task; the edge used at open is retained until the exit finishes. This removes the post-top-layer paint and keeps the departure on the edge the person opened.

SOLUTION (2 decisions · ~198 runtime changed lines of 245 total)

  1. Replace the fixed close timer/discrete-display hold with render-owned visibility, transition completion, a computed-duration backstop, and synchronous hide.
  2. Retain the opening side through exit so caller state reset cannot flip the departing panel. [NO STATED PROBLEM IN PR BODY]

BURDEN: high — one new state owner, one changed Effect, native listener, timer backstop, computed-style read, flushSync, a retained ref, and an added commit per closing Drawer. BURDEN MATCH: disproportionate as currently packaged — the behavior is an independently nameable close protocol but remains inline and browser-untested; a focused unit and browser regression are the bounded direction.

VERDICT: BLOCKS — complex lifecycle behavior has no focused behavior unit or checked-in browser test

ARCHITECTURE

OWNER: Drawer owns its open/close lifecycle; the native <dialog> owns top-layer state. TIER 1: the layer protocol suite, native dialog lifecycle, useScrollLock, and the existing transition-wait pattern in BottomSheetPanel.waitForTransition. TIER 2: none. SEAMS: modal and non-modal; transformed containing-block ancestors; start/end and RTL; close during entry; rapid reopen; repeated cycles; reduced motion; nested/stacked Drawers; Activity/unmount cleanup; #5550's bounded portal path. BEHAVIOR UNIT: inline — render-owned visibility, a ref latch, the open/close Effect, the unmount Effect, a transition event, timer, computed-style read, and synchronous commit form one timing state machine.

seam driven result
transformed ancestor head closes at display:none, 0×0; base still paints a 400×500 panel
close during entry head ends closed, hidden, and non-modal
rapid reopen head remains open, visible, and modal after the stale close is cancelled
3 repeated cycles all end closed/hidden
reduced motion computed transition is 0.01s; final state is closed/hidden
nested/stacked inner closes while outer stays open; both then close with zero visible dialogs
start-side prop reset head retains data-side=start; base flips to end

The implementation passes every driven seam, but the PR's tests mock showModal()/close() in jsdom and cannot observe top-layer release, containing-block repaint, real transitions, or paint order. A future edit can restore the reported frame while all 37 tests remain green. Independently confirmed: the controlled Chromium arm exposes a visible 400×500 post-close panel on the base, while the four selected head tests run against that base fail only their timing/side assertions and never inspect the painted frame.

VERDICT: BLOCKS — the multi-part close protocol is inline and its defining browser failure is not regression-tested in a browser

IMPACT

Every Drawer caller gets theme-duration-aware exit completion. Apps rendering a modal Drawer under transform, filter, contain, container-type, backdrop-filter, or will-change stop seeing the dismissed panel repaint; builders deriving side from open-state data now keep the opening edge through exit. Pointer activation and trigger focus restoration are unchanged.

VERDICT: clear

API

No API signature, prop, export, or default changes.

change public? class doc'd? verdict
none lab package is private no API update needed ok

OSSIFICATION: no new surface. The existing side theme state changes meaning during exit, but the target is in the private lab package and no stable consumer surface is added.

VERDICT: clear

THEMING

No target, token, CSS variable, or style contract is added or removed. Exit timing now reads the resolved transition duration, so a theme's --duration-medium value governs completion; the existing drawer[data-side] state stays on the opening side until hidden.

VERDICT: note — the existing side theme state changes during exit and the PR body does not record that second decision

BREAKING

BEHAVIOR: yes, intentionally — close timing follows the rendered transition and the opening side is retained through exit. API: no signature/default/export change; @astryxdesign/lab is private. VISUAL: yes, intentionally — post-close repaint disappears and start-side exit stays on start; outer geometry while open is unchanged. THEME: no target/token removal; an existing data-side value is retained longer during exit.

VERDICT: clear — intentional lab-only behavior correction, with the unstated side decision carried as a note

PERFORMANCE & RESOURCES

EFFECTS: one existing Effect at Drawer.tsx:494 is changed; the existing unmount-only dialog cleanup Effect at :585 remains part of the same native-dialog lifecycle.

Effect + deps external system why render/handler cannot do it measured render cost lifetime + cleanup focused test
useEffect [isOpen, hasScrim] Drawer.tsx:494 native dialog top-layer state + CSS transition controlled isOpen can change from a caller render; transition completion is a browser event +1 commit per close at N=1 and N=2 (realistic repository maximum) listener/timer exist only during close; both removed on finish or cleanup no checked-in browser test

RENDER: one additional commit per closing Drawer; base→head is 2→3 commits at N=1 and 1→2 for each closing Drawer at N=2. LISTENERS/OBSERVERS: one transitionend listener only during close; no observer; cleanup passed rapid reopen and repeated cycles. LAYOUT: one getComputedStyle() transition-timing read per close; no geometry read/write loop; transform remains compositor-only. BUNDLE: no dependency change; CI bundle report stayed 3.5 KB CJS / 1.5 KB gzip for lab.

The extra commit is the render-owned hide that removes the invalid painted frame, and it remains O(1) per active closing Drawer; no fan-out or listener leak was measured.

VERDICT: note — +1 commit and one style read per close, bounded to the closing Drawer

VISUAL EVIDENCE

VISUAL CHECK: manual frames required WHY: visible close behavior is the acceptance criterion, and exact-head pr-visual compared only 2 shots while 12 Drawer shots had no baseline; no existing story contains the transformed-ancestor failure.

Decisive pair — first frame after native close under a transformed ancestor

Before 58f9542 Head da75e7b
Before: closed Drawer still paints After: closed Drawer paints nothing

SENSOR RECEIPT: both review-drawer5549--lifecycle, neutral/light/LTR, 900×500@1, fine pointer/hover, identical transformed 620×360 subject, fonts loaded, zero page/Storybook errors, semantic state “drawer closed and focus restored.” Before: open=false, display:flex, visible dialog 400×500 at x≈730 with 2 transitions intentionally still running. Head: open=false, display:none, 0×0 with 0 running animations. Receipts: before · head.

Required control — the independent start-side behavior at 150 ms into close

Before 58f9542 Head da75e7b
Before: start Drawer flips and exits right After: start Drawer exits left

SENSOR RECEIPT: same story/theme/mode/direction/viewport/media/geometry and semantic “closing after opening from start.” Before: data-side=end, transform +273px; head: data-side=start, transform −271px. Receipts: before · head.

ARM: review-only Storybook story BASE: each checked-out commit above DELTA: one untracked story adding a transformed ancestor, state-derived side, profiler, nested Drawers, and underlay target; production files unchanged UNRELATED DELTA: none Exact arm: visual · performance · pointer

Every observed difference is intentional: the repaint removal is the PR's stated bug; the side retention is implemented by the second commit but absent from the PR body.

VERDICT: clear

A11Y & I18N

Exact-head pr-a11y is green and adds no baseline entry. Chromium verified trigger focus restoration after close, pointer hit testing under the residual rectangle (the underlying button remained activatable before and after), close during entry, rapid reopen, three repeated cycles, reduced motion, and nested/stacked close order; zero visible dialogs remained. No role, ARIA, keyboard mapping, string, locale, or direction API changes occur; start/end behavior was driven in LTR and the source continues to use logical anchoring. Only Chromium was available in this run; source inspection found no new engine dependency because the change uses long-supported transitionend/getComputedStyle browser APIs and ReactDOM flushSync, while removing reliance on discrete display transitions.

VERDICT: clear

JUDGEMENT

slot verdict
PROBLEM clear
SOLUTION BLOCKS — no focused behavior unit/browser test
ARCHITECTURE BLOCKS — inline close state machine
IMPACT clear
API clear
THEMING note — unrecorded side-state change
BREAKING clear
PERFORMANCE note — +1 commit/style read per close
VISUAL clear
A11Y & I18N clear

GOAL: met — at head the first frame after native close is display:none/0×0 under the transformed ancestor, and start-side exit remains start. DISPOSITION: inline close protocol without focused browser regression → blocks now; unstated start-side behavior → blocks now as a PR-body/evidence update; +1 close commit/style read → bounded feature cost, no separate block. ADVICE: bounded direction — move the close timing/lifecycle into a named hook or utility and check in the transformed-ancestor Chromium regression; BottomSheetPanel.waitForTransition is the landed transition-wait precedent, not a drop-in API. AUTHOR CAN PROCEED: yes — acceptance is a named/testable close protocol plus a checked-in real-browser red→green repro, and recording the start-side behavior in the PR body. WORST OUTCOME: “A future edit can restore the reported post-close painted frame while all 37 tests remain green.” → request changes.

JUDGEMENT NEEDED: none — correctness/testability against the existing Drawer contract.

request changes

  1. The close protocol is an inline native/React timing state machine with jsdom-only coverage. → someone closing a transformed-ancestor Drawer can get the same repainted panel again while every checked-in test stays green · Drawer.tsx:479-574
  2. The second commit retains side through exit but the PR body only describes repaint timing. → builders using the documented state-derived side shape get a different exit and theme-state value without that behavior being reviewable from the PR description · Drawer.tsx:654-684

REVIEW

Thanks for chasing this down. The matched close frames show the fix:

Before Head
Before: closed panel still paints Head: closed panel paints nothing

Two things before merge. The close now coordinates render state, a transition listener, timer, flushSync, and unmount cleanup, but the tests mock the dialog in jsdom. A future change can restore the same post-close() frame for anyone using a transformed ancestor while all 37 tests stay green. Could you move the close timing into a named hook or utility and add that Chromium regression? BottomSheetPanel.waitForTransition is the closest existing pattern.

Please also add the start-side latch to the PR body: builders using the documented state-derived side shape now get a different exit and theme-state value, but that second change is not described.

[Reviewed by Robohands]

INLINE (0–3, only when genuinely line-anchored)

  • packages/lab/src/Drawer/Drawer.tsx:494 — Could this close timing move to a named hook or utility with a Chromium test?
  • packages/lab/src/Drawer/Drawer.tsx:654 — Please add this second visible behavior change to the PR body.

EVIDENCE I DID NOT SPEND

  • Exact-head CI is green, including pr-a11y, pr-visual, pr-rtl, build, test, lint, sandbox, and Storybook.
  • The overlapping underlay target remained pointer-hit-testable even while the base panel still painted; this is a visual/context defect, not a pointer trap.
  • The four selected head tests are red against the base runtime (3 failures; the unrelated-property control stays green).

RELATIONSHIP TO #5550

#5550 stacks this exact head as its first two commits and says its bounded mode depends on this exit-state refactor. This candidate is request-changes on its own merits; under the set rule, post nothing and hold the set disposition until #5550's independent review is complete. Any close-protocol correction belongs at the bottom of the stack so both candidates inherit it.

TIME

TIME total 34m setup 6m — exact worktree + fast install + build config; warm main reused: no (controlled base/head arm used) reading 8m — kit, rubric 1.12, rulings, conventions, body, diff, siblings measuring 15m — 2 screenshot pairs, browser matrices, N=1/N=2 render counts, focused tests, negative control writing 5m — two draft/critic passes and final presentation waste 4m — first Storybook navigation timeout, one failed unbuilt-core typecheck, and a pointer target repositioned after its first placement missed the residual rectangle

WHAT I COULD NOT VERIFY

  • Safari and Firefox runtime behavior; only Chromium was available. Source inspection found no new engine-dependent CSS/API.

What changed before posting

Not posted: critic pass 1 added the decisive embedded frame pair, made the independent confirmation explicit, replaced loop jargon with a named hook/utility direction, and fixed the two-ask wording. Critic pass 2 passed. The Drawer set remains held until #5550 is reviewed.

Clone this wiki locally