Repository navigation
refactor(MobileNav): keep the closing dialog rendered through the layer runtime's presentation hold - #8590
Conversation
The host read getComputedStyle(element).touchAction at a recognizer's first attach, to keep a caller's own touch-action in force; inside a mount that forces a style recalc over everything just mounted (C21's 200-row year wheel: 9.2 ms at 1x, rows written afterward recalc again). Failing on this head: attaching calls getComputedStyle, and a caller's stylesheet value is intersected at once. A caller's inline value applies at once with no read; a stylesheet's is checked after the frame has painted.
A caller's inline touch-action is the style's own, read with no recalc, and applies at once. A stylesheet's needs a computed read, which now waits until the next frame has painted, when styles are clean, instead of running inside the attach, where it forced a style recalc over whatever had just mounted. The check is batched: the host's own values come off every pending element, one read serves them all, and the writes follow. Until it runs (a frame or two after the element mounts) the host's own value stands, so a caller's narrower stylesheet value can read wider for that long; no touch starts on an element in the frame it mounts in.
…on clock and Reduce Motion plan The motion README takes main's internal-roles sentence with this layer's animator; uikit.test.ts takes main's version (rows, docAbove) and keeps the check of values bound from an exported group, which timing.ts's Reduce Motion fade bindings need.
architecture:react-component-runtime INV3 asks every Effect to name the external system it synchronizes: the listener ref, the recognizer's target-action listener and isEnabled, the host attachment, the tracker's options, tracking expansion, press consumer and enabled state, the unmount flag, and the attachment's node.
… proof describe the roles' table values (DEC-11)
…reader architecture:react-component-runtime INV11 allows the keyboard module at most one layout read per keyboard change. `getMobileKeyboardHeight` measured again on every call, and the layout guide, each component's `useKeyboardHeight` and ChatLayout's commit all call it after the module has already measured the report: one show read the visual viewport's height 6 times in Full AI Chat in Chromium, 13 times in the module's test with the guide, a listener and two hooks. While the module listens, `getMobileKeyboardHeight` returns the height it measured at the last report. Every change of the visual viewport or the window is a report, so a reader between two reports still sees the current height; with nothing listening yet, it measures. Tests: the module's test counts one read for a report with the guide, a listener and two hooks reading the height (red before: 13), and an interaction test counts one read for a show in Full AI Chat (red on the previous build: 6).
…is focused without the reveal spec:AST-099 FR4 says every route into the composer's text, a tap included, focuses it without scrolling the page. The tap route claimed only contenteditable text: a `<textarea>` or `<input>` passed as ChatComposer's input (its documented plain textarea) was left to the browser, and with nothing focused no blur reaches the focus takeover, so Safari revealed the field by scrolling the document. The tap route now claims an unfocused text control in the composer that brings up a keyboard, and delivers it as it delivers contenteditable text: focused with `preventScroll`, with the caret where the standard `caretPositionFromPoint` maps the point inside the control, or after its text where the browser maps none. A focused control keeps its own taps (the browser moves the caret); a checkbox or an `inputmode="none"` field is never claimed. Tests (useChatKeyboard.test.tsx): ChatLayout with a textarea composer, under a coarse pointer, cancels the tap's default and focuses the textarea with `preventScroll` (red before: the browser's own focus); the claim takes an unfocused text control and leaves a focused one, a checkbox and an `inputmode="none"` field; the caret lands at the mapped offset, or after the text.
…d Chrome it moves once reported spec:AST-099 FR8 asks a ride within one frame of the keyboard on the measured keyboards, with Android emulator evidence against Gboard; Firefox for Android has FR2–FR5 only, and reports the keyboard once it is up. This branch plays UIKit's keyboard spring from the browser's report there, and times no ride to Gboard. The docs said the composer rides in step in Firefox for Android and rides the keyboard in Chrome on Android. ChatLayout's docs (English, Chinese, dense), the browser-support guide and the changeset now say: in Safari on iOS the composer rides on top of the keyboard in step with it; in Firefox for Android and Chrome on Android it moves above the keyboard once the browser reports it; under Chrome's `interactive-widget=resizes-content` it stays above it without moving.
…es a dip lands its move with it Under Reduce Motion on a touch screen: - A move over a stylesheet opacity of 0 dips nothing (it lands at once), but kept 0 as a borrow that outlived it, so a later move once the element showed read it as invisible and landed in view. A borrow now exists only while its dip runs. - stop() on a dip's opacity finished the fade but left the move's pending jump to land later, at opacity 1. A finished fade now lands the travel waiting for it at the same instant.
…ll request This pull request keeps curve sampling (hold, seek, sample, release, the curve end); the recorder, waitForAnimation, watchAnimations and waitForRest, with their self-tests, land separately on top. The holding self-tests that waited with them now poll the page for a running or idle animation. Chromium: 19 tests, 5x repeat, pass.
The harness's animation-state instruments, split out of the curve-sampling pull request so each stays reviewable: - a page-side recorder that sees every animation from its first frame or creation, so one that starts and ends before a test looks is still reported, with its finish time and start (kept when the page cancels it); - waitForAnimation: the running animations on an element past a minimum progress, or the ones that finished since the last wait, with their curve interval through the playback rate (a run going backward is dated from when it began); - watchAnimations: a watch taken before an action, for an entrance on a page's first contact; - waitForRest: rest judged by presented transform, translate, rotate, scale and opacity, not playState, so an end value held by the keyframes is not waited out, and a backward run's end delay and curve are. Self-tests cover each in real Chromium. Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
# Conflicts: # packages/core/src/motion/README.md # packages/core/src/motion/uikit/uikit.test.ts
…inishes a dip lands its move); a pseudo-element's own opacity hold is owned Conflicts in animator.ts take this layer's Reduce Motion plan, which gains #8055's rule in its own terms: a stylesheet that hides the element lends nothing, so the move lands at once with no dip and no borrow. #8055's stop rule already holds under the plan (the opacity pass lands the waiting move); its test passes unchanged. uikit.test.ts takes #8055's ordering. Also fixed (CodeBunny on 3d201a0): a pseudo-element shown with set({opacity: 1}) over a stylesheet that hides it holds that value with an animation, which now counts as an owned opacity (writesOwnOpacity), so a move or a fade by role returns to it instead of borrowing the hiding 0.
…e is not a motion role
… Effects; #8072 61e891d) into #8076 Both of cixzhang's rules kept: #8073's useNodeAttachment (a replaced node re-attaches, deps re-attach, each Effect names what it syncs) and #8076's guards, so neither the isEnabled sync's cancel nor a press tracker's release calls a caller back from an Effect. Both sides' tests stay.
… from its trigger A semantic merge: - Popover.tsx: the grow-from-trigger motion (usePopoverSurfaceMotion) sits beside main's bottom-sheet presentation (usePresentationSurface and the controlled-surface latch). It runs only when the anchored layer shows; a render-prop trigger records its anchor through setRenderTrigger. - motion/uikit/springs.ts takes main's per-spring constants and adds POPOVER_PRESENT_SPRING and POPOVER_DISMISS_SPRING with their rows; index.ts and the motion README list both keyboard and popover. - layer-runtime.md takes main's INV14/INV15 status paragraph.
Her af953bd merges #8076's bd9246d (main 2af4d30, with #8229's Popover presentation) into this branch, with her resolution of BottomSheetPanel.tsx against #8229. No conflicts with this branch's commits since 2d520a6: the result is her head plus those commits (the keyboard-height Effect's INV3 comment, one layout read per keyboard change, the text control tap route, and the Safari/Firefox/Chrome docs). `timing.ts` checked: #8076's with exactly #8484's change.
#8176's local head reads the layout once per keyboard change, focuses a text control composer tapped with nothing focused without the reveal, says the composer moves above the keyboard once reported in Firefox and Chrome for Android, and merges the maintainer's af953bd (#8076's bd9246d, main 2af4d30 with #8229). No conflicts; `timing.ts` is #8176's.
…ides in step #8176 says the composer moves above the keyboard once Chrome reports it, since it times no ride to Gboard (spec:AST-099 FR8 asks a ride within one frame of the keyboard, with Android emulator evidence). This branch times the ride to Gboard's measured slides, with that evidence: ChatLayout's docs (English, Chinese, dense) and the browser-support guide say the composer rides Gboard's slides in step in Chrome on Android, while a Tall BottomSheet makes its room once Chrome reports the keyboard. Firefox for Android keeps #8176's wording: it moves once Firefox reports the keyboard.
…ation until its close settles architecture:layer-runtime INV14 gives the layer runtime the presentation hold: keeping a layer's chosen presentation from the open until the close settles. Layer/presentationHold.ts (internal) is that one job: createPresentationHold for code that chooses at an open event, and usePresentationHold for a choice taken during render; the component releases it when its close settles. Popover adopts it for its three latches, with no behavior change: the controlled surface (taken as isOpen turns on, let go as it turns off), the sheet kept mounted until its dialog has closed, and the touch-or-pointer presentation its close plays in (usePopoverSurfaceMotion). Not the input hold (inputHold.ts). Not exported; a test locks that. Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…the record names the shared hold usePopoverSurfaceMotion's returned callback reads touchHold, so it is in its dependency list (lint). architecture:layer-runtime's current-gaps paragraph no longer says no shared presentation hold exists or that Popover builds its own: the runtime owns it (Layer/presentationHold.ts) and Popover uses it; Owning code and verified_by name it.
…er runtime's presentation hold MobileNav's own close hold (isCloseHeld with a wasOpen latch) kept the dialog rendered from the commit that closes it until its delayed close() ran. It now takes the layer runtime's presentation hold (Layer/presentationHold): held from the open, released after close() runs or when nothing is open, as before. No behavior change; MobileNav's tests pass unchanged.
|
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 |
|---|---|---|---|
| MobileNavToggle | 60.7 kB (0) · 10.0 kB CSS (0) | — | ✓ · ✓ · ✗ |
| AppShell | 91.1 kB (+92 B) · 13.5 kB CSS (0) | — | — · — · — |
| TopNav | 72.5 kB (+84 B) · 10.9 kB CSS (0) | — | — · — · — |
2 more components
| Component | Size | LoC | Visual · A11y · RTL |
|---|---|---|---|
| SideNav | 114.3 kB (+63 B) · 11.4 kB CSS (0) | — | — · — · — |
| MobileNav | 70.8 kB (+46 B) · 10.6 kB CSS (0) | — | ✓ · ✓ · ✓ |
Package size: @astryxdesign/core 7.22 MB packed (−603 B); no new dependencies.
API changes: none in this pull request
Committed API report behind this build: cli 29 rows, core 36 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
There was a problem hiding this comment.
Looks good to merge once #8587 lands: MobileNav's close hold now comes from the layer runtime's presentation hold with no change in behavior, and the close test that guards the hold still passes. Two optional follow-ups: MobileNav.spec.md's Current-state impact could say the close hold is now the runtime's, and the size table could note that CSS is unchanged. Test and lint CI don't run for pull requests into feat/presentation-hold, so they will first run when this targets main.
[Automated review]
99b0ba8 to
cf409fc
Compare
#8587 (the layer runtime's presentation hold) merged as a squash, so this takes main for everything outside MobileNav and keeps only MobileNav's adoption: main's MobileNav, including the shared scroll lock, with its close hold moved onto usePresentationHold.
There was a problem hiding this comment.
Approved pending CI: the CI run on this head ended without reporting the required docsite-test check, although every job it started passed, so CI needs a re-run before this merges.
MobileNav's close hold now comes from the layer runtime's presentation hold with the same open-styling sequence as before, and the close test that guards the hold fails without it. Optional follow-ups: shorten the hold comment (see the existing inline suggestion), add a regression test for the release when nothing is open (a native close, or an <Activity> hidden mid-close and shown closed), and update the MobileNav and layer-runtime records' gap text now that the presentation hold is the runtime's.
[Automated review]
| // Keeps the dialog rendered from the open until the delayed `close()` below | ||
| // has run: the layer runtime's presentation hold. The `display` transition | ||
| // on the dialog is meant to do this, but a browser that does not hold a | ||
| // discrete `display` transition (WebKit) drops the dialog to | ||
| // `display: none` at once while it is still an open modal: nothing is | ||
| // visible, yet the whole page stays inert until the timer fires. Held during | ||
| // render, so it is already held in the commit that closes. The drawer's | ||
| // transform and the backdrop stay keyed to `isOpen`, so the slide-out is | ||
| // unchanged. |
There was a problem hiding this comment.
Non-blocking: the second sentence of this comment runs about 50 words over several topics, and "so the slide-out is unchanged" compares with past code. A shorter form that keeps only the reason:
// Keeps the dialog rendered until the delayed `close()` below runs.
// WebKit does not hold a discrete `display` transition. It drops a closing
// modal dialog to `display: none` at once. The page then stays inert with
// nothing visible. The hook takes the hold during render, so the closing
// commit already has it.There was a problem hiding this comment.
Approved pending CI: CI has not finished on this head yet. The first run stopped before its required jobs because CI was full, and the re-run now in progress has to pass before this merges.
MobileNav's close hold now comes from the layer runtime's presentation hold, with the same open-styling sequence as before on every close path checked, and the close test that guards the hold fails without it. Optional follow-ups: shorten the hold comment (see the existing inline suggestion), add a regression test for the release when nothing is open (a native close, or an <Activity> shown again after the close), and update MobileNav's record now that the presentation hold is the runtime's.
[Automated review]
|
/approve-spec aee5cb6 |
|
(Posted by Cindy's agent.) Machine evidence (main a370b5e vs this head aee5cb6, MobileNav default story, phone viewports):
|
Follow-up to #8587 (the layer runtime's presentation hold, merged). The base is now
main, so this diff is MobileNav's adoption alone.What it does
MobileNav keeps its dialog rendered from the commit that closes it until its delayed
close()has run (WebKit drops a closing modal<dialog>todisplay: noneat once otherwise, leaving the page inert with nothing visible). That close hold (isCloseHeldwith awasOpenlatch) now uses the layer runtime's presentation hold (Layer/presentationHold.ts,architecture:layer-runtimeINV14). The hold is taken during render as MobileNav opens, so it's already held in the commit that closes. It's released afterclose()runs, or when nothing is open, exactly where the old hold was cleared. The drawer's transform and backdrop stay keyed toisOpen.No behavior change
MobileNav's tests are the oracle and pass unchanged (78), including the WebKit close-hold cases. The real-browser check is MobileNav's specs, which this PR's CI runs because the change touches the MobileNav folder.
Size (gzip KB, minified)
The same method as #8587: a fresh
packages/coredist at the base and at this head; esbuild bundle, minify, splitting, ESM, packages external.The shared hold is about 0.1 KB, loaded once with Popover's.
Overlap
#8374 (MobileNav's touch close timing) adds a second close timer that clears the old hold. It lands first; this pull request then merges it up, and its extra release point becomes
releasePresentation(). #8374 needs no change for this one.Testing
pnpm vitest run --project ui packages/core/src/MobileNav: 78 pass.[chore]changeset.Retarget (@cixzhang)
#8587 merged, so the base moved to
mainandmainis merged in at b6f4a72 (no force-push; the earlier stack history, including @vjeux's commits, stays in the branch). Outside MobileNav the merge takesmain. MobileNav takesmain's version, including the shared scroll lock, with its close hold moved ontousePresentationHold. MobileNav, AppShell and presentation-hold suites pass (190 tests).