feat(web): event-based waits PR-3 — data-state lifecycle + page.clock helper#496
Merged
Conversation
Ten tasks covering: - LifecycleState helper module (closed/opening/open/closing + reduced- motion check + advance fn). - Six per-component lifecycle wirings (grove_drawer canonical, then mobile_shell, confirm_dialog, bottom_sheet, tab_bar, action sheet). - Three browser tests for the lifecycle's documented failure modes (transitionend-on-driving-property, reduced-motion shortcut, unrelated-transitionend-ignored). - page.clock helper + longPressWithClock variant. Opt-in only; multi-peer specs stay clock-free per the spec's iroh-timer risk row. - README documenting the four-phase lifecycle vs. the categorical data-state usages on status_dot/grove_rail/peer_status_label.
Four-phase data-state lifecycle (closed/opening/open/closing) for animated components. Pure-data primitives only — components own their own RwSignal and transitionend closure. Reduced-motion shortcut reads computed transition-duration; if zero, callers snap to terminal state synchronously (otherwise the test hangs because no transitionend fires under prefers-reduced-motion). Per docs/specs/2026-04-27-event-based-waits-design.md §data-state attribute pattern. The shared-helper-component question stays open per the spec's defer guidance — only the data primitives are lifted.
Replaces the existing data-open boolean attribute. Driving property: transform (slide via translateX). transitionend filtered on property_name == 'transform' so opacity/box-shadow events from overlapping transitions are ignored. Reduced-motion shortcut snaps to terminal state when computed transition-duration is 0s. Per docs/specs/2026-04-27-event-based-waits-design.md §data-state attribute pattern. Reference implementation for the other 5 animated components.
Mirrors the canonical pattern from grove_drawer onto the mobile-shell root div, keyed off drawer_open (the shell-owned RwSignal at :110). Driving property: transform. The shell div has no transform transition under the default CSS, so the reduced-motion shortcut snaps Opening/ Closing straight to terminal — matching the canonical pattern's no-animation branch. The actual drawer transition still runs on the inner GroveDrawer <aside> element (its own lifecycle, wired in f2f83c2). This shell-level data-state exists so test harnesses can gate on the shell-owned source of truth. Per docs/specs/2026-04-27-event-based-waits-design.md §data-state attribute pattern ("Components receiving the lifecycle" lists mobile_shell.rs alongside grove_drawer.rs).
Mirrors the canonical pattern from grove_drawer onto the .confirm-overlay div. Driving property: opacity (the dialog fades). transitionend filtered on property_name == 'opacity' so unrelated transitions are ignored; reduced-motion shortcut snaps to terminal when computed transition-duration is 0s. The dialog is conditionally rendered on `visible`, so the Closing phase is observable only briefly before the subtree unmounts; tests should gate on Open/Closed (or element absence) rather than Closing. Per docs/specs/2026-04-27-event-based-waits-design.md §data-state attribute pattern.
Mirrors the canonical pattern from grove_drawer onto the inner
.bottom-sheet div. Driving property: transform — components.css
declares `.bottom-sheet { transition: transform var(--motion-slow)
var(--motion-ease) }` and `.bottom-sheet.open { transform: translateY(0) }`.
Note: the plan's task description listed `opacity` as the driving
property, but a re-read of components.css confirms the default
transition is on `transform`; only the `prefers-reduced-motion: reduce`
media query swaps to `transition: opacity`. The reduced-motion shortcut
(is_zero_duration) catches the latter case by snapping to terminal
when computed transition-duration is 0s, so picking transform here is
correct for the default and degrades cleanly for reduced motion.
The existing data-open attribute on .bottom-sheet-root stays — it
gates pointer-events + backdrop opacity via CSS selectors, and the
new data-state is additive on the inner .bottom-sheet element where
the actual transform transition runs.
Per docs/specs/2026-04-27-event-based-waits-design.md §data-state
attribute pattern.
tab_bar.rs is listed in the spec's lifecycle target set (docs/specs/2026-04-27-event-based-waits-design.md §`data-state` attribute pattern, "Components receiving the lifecycle"), but neither the bar's hide/show (.mobile-tab-bar[data-visible="false"] uses display: none, no transition) nor the active-tab indicator (.tab.tab-active in components.css:232+, no transition declaration) has a CSS transition. The four-phase lifecycle would produce instantaneous closed→open with no observable opening/closing phases. Skipping per the plan's audit-decision tree. Lifecycle now applied to: grove_drawer, mobile_shell, confirm_dialog, bottom_sheet, message.rs action sheet (5 components — matches the spec's "five physical edits" target).
Mirrors the canonical pattern from grove_drawer onto the .mobile-action-sheet div in message.rs. Driving property: transform — style.css declares the sheet slides via translateY(100%) → 0. The existing class binding (`mobile-action-sheet open`) is kept so the .mobile-action-sheet.open CSS selectors continue to match; data-state is additive. The sheet has a swipe-down-to-dismiss gesture that disables the transition during drag via inline `transition: none`. Under that condition transitionend doesn't fire and the lifecycle just doesn't advance — acceptable, since the post-drag state ends in either Open (snap back) or Closing (dismissed) once the user releases. Per docs/specs/2026-04-27-event-based-waits-design.md §data-state attribute pattern.
Three tests against grove_drawer (canonical implementation): - transitionend on the driving property advances opening → open - reduced-motion (transition-duration: 0s) snaps to terminal phase - transitionend on a non-driving property is ignored Other 4 lifecycle-wired components (mobile_shell, confirm_dialog, bottom_sheet, message.rs action sheet) reuse the same advance() and is_zero_duration() helpers, so coverage is shared via lifecycle.rs's unit tests. Adds web-sys features TransitionEvent + TransitionEventInit so the synthetic event constructor compiles. Per spec §data-state attribute pattern's three failure modes. Note: runtime verification deferred — wasm-pack/Firefox/geckodriver not available in the CI env this commit was authored in. WASM compile clean (`cargo check --target wasm32-unknown-unknown -p willow-web --tests`), clippy clean.
installPageClock(page) patches Date/setTimeout/setInterval globals per Playwright's per-page clock API. Opt-in: tests that install the clock are explicit about which timers they advance, so multi-peer specs (gossip + iroh retry timers) are unaffected. longPressWithClock mirrors longPress with clock.runFor(durationMs) in place of waitForTimeout. The legacy longPress stays for specs that haven't opted in. Per docs/specs/2026-04-27-event-based-waits-design.md §page.clock for real durations.
Pre-existing rustfmt drift in lifecycle.rs (single-line let-else expressions reformatted to multi-line) plus a small fmt fix in the new browser.rs lifecycle tests' chained `.dispatch_event(...).unwrap()`. No semantic changes; required for `cargo fmt --check` to pass under the PR-3 acceptance gate.
Self-review caught a real correctness bug in the reduced-motion path: components.css:336-339 @media (prefers-reduced-motion: reduce) { .bottom-sheet { transition: opacity var(--motion-slow) linear; } } components.css:691-694 @media (prefers-reduced-motion: reduce) { .grove-drawer { transition: opacity var(--motion-slow) linear; } } Both swap the default transform-driven transition for an opacity-driven one with non-zero duration (~240ms). The original on_transition_end filter accepted only property_name == 'transform', so under reduced motion: - is_zero_duration returned false (240ms != 0) → no shortcut. - transitionend fired with property_name == 'opacity' → guard rejected it. - Lifecycle stuck in opening/closing indefinitely. Fix: widen both filters to accept transform OR opacity. Comments at both sites cite the components.css selectors so future readers know why both properties are listed. Other components are unaffected: - mobile_shell.rs: .mobile-shell has no CSS transition under any media query; lifecycle relies entirely on the snap-to-terminal shortcut. Already documented. - confirm_dialog.rs: no CSS transition at all (uses animation, not transition). Snap shortcut path. Already documented. - message.rs action sheet: no prefers-reduced-motion swap; transform is the only driving property. Browser tests rewritten to be non-vacuous: - force_transition() helper inlines a transition style on the drawer, bypassing wasm-pack's CSS-loading limitation (foundation.css's --motion-slow is not injected, so the CSS transition resolves to an invalid 0s and the snap shortcut short-circuits every test that relies on it). - grove_drawer_lifecycle_advances_on_transform_transitionend now asserts opening BEFORE the dispatch, proving the listener actually advances the state. - NEW grove_drawer_lifecycle_advances_on_opacity_transitionend regression-tests the bug above. - grove_drawer_reduced_motion_snaps_to_terminal forces 0s transition-duration and asserts the snap. - grove_drawer_ignores_unrelated_transitionend uses 'color' (not in the accept list) instead of 'opacity' (which is now valid) for the rejection assertion.
6 tasks
intendednull
pushed a commit
that referenced
this pull request
Apr 30, 2026
Conflict in crates/web/tests/browser.rs: both branches appended new test modules at end of file. #425 added `mod pinned_jump_safe_scroll` (DOM lookup tests for the eval -> safe-scroll swap); #496 added `mod data_state_lifecycle` (transitionend lifecycle tests on grove_drawer). Modules are orthogonal — kept both, ours first then theirs, with the same blank-line separator main used. verified: cargo fmt clean, cargo check --target wasm32-unknown-unknown -p willow-web --tests clean, cargo clippy -p willow-web --all-targets -- -D warnings clean.
intendednull
pushed a commit
that referenced
this pull request
May 3, 2026
is_zero_duration only matched "", "0s", "0ms" — but the global prefers-reduced-motion rule in style.css forces transition-duration to 0.01ms !important on every element, which engines serialise as either "0.01ms" or "0.0001s". Both slipped past the strict matcher, so mobile_shell, confirm_dialog, bottom_sheet, grove_drawer, and message reactions all sat waiting for a transitionend that never fires under reduced-motion — UI hang. Replace the string-equality check with parse_duration_seconds (parses both s and ms suffixes) and accept anything ≤ 1ms (epsilon) as zero. Unparseable input stays conservative (not zero). Sibling-of-closed audit follow-up to #496 (8d89f18). Approach A (parse-and-compare) chosen over B (hardcode the two known strings) because reduced-motion is the authoritative contract — any sub-millisecond duration is indistinguishable from "no transition" for transitionend purposes, so a numeric threshold is the durable fix. Tests added (native, no DOM): parse_duration_seconds_handles_units, parse_duration_seconds_rejects_malformed, is_zero_duration_str_recognises_explicit_zero, is_zero_duration_str_recognises_reduced_motion_override, is_zero_duration_str_treats_sub_millisecond_as_zero, is_zero_duration_str_rejects_real_durations, is_zero_duration_str_multi_value_all_zero, is_zero_duration_str_multi_value_mixed_is_not_zero, is_zero_duration_str_unparseable_is_not_zero. Gates: fmt clean, clippy native + wasm32 clean (-D warnings), 86 willow-web lib tests pass, wasm32 --tests check clean (wasm-pack / geckodriver not available in env — used cargo check --target wasm32-unknown-unknown --tests as fallback gate per CLAUDE.md). Refs #515 https://claude.ai/code/session_019HhgeDZ5HCbEUygRRLCjde
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PR-3 of the event-based testing initiative (PR-2 = #495). Two foundations:
Four-phase
data-statelifecycle (closed | opening | open | closing) on five animated UI elements. Tests gate onawait expect(el).toHaveAttribute('data-state', 'open')instead of sleeping after a click. Driven bytransitionendfiltered on each component's specific driving CSS property; reduced-motion shortcut snaps to terminal whentransition-durationis0ssoprefers-reduced-motion: reducedoesn't hang the test.page.clockhelper +longPressWithClockvariant ine2e/helpers/touch.ts. Opt-in only; the spec's iroh-timer risk row warns that installing the clock during a multi-peer test can freeze UI/HLC time while iroh keeps running on real time. Default e2e tests stay clock-free; only single-peer touch specs install.Spec:
docs/specs/2026-04-27-event-based-waits-design.md. Plan:docs/plans/2026-04-30-event-based-waits-pr3-data-state-lifecycle.md. Tracking: #458.Components receiving the lifecycle
grove_drawer.rstransformopen: Signal<bool>data-open(additive)mobile_shell.rstransform*drawer_open: RwSignal<bool>confirm_dialog.rsopacity*visible: ReadSignal<bool>bottom_sheet.rstransformopen: Signal<bool>data-open(additive)message.rsaction sheettransformshow_sheet: Memo<bool>*denotes lifecycle-without-CSS-transition cases (see "Known limitations" below).Tab bar SKIPPED (
0f79399). Auditedcrates/web/components.css:173-260andtab_bar.rs:.mobile-tab-bar[data-visible="false"]usesdisplay: none(no transition) and.tab.tab-activehas notransitiondeclaration, only a non-animatedtransform: translate(-50%, -50%)for centering. Applying the lifecycle would produce instantclosed → openwith no observableopening/closing. Skipped per the plan's audit decision tree; final lifecycle count is 5 components, matching the spec's "five physical edits" target.Known limitations
The implementer documented three cases where the lifecycle works correctly but observes only the terminal phases (
closed,open) — neveropening/closing:mobile_shell.rs— the.mobile-shellroot has notransformtransition; the actual drawer slide is animated by the inner<GroveDrawer>(which has its own lifecycle fromf2f83c2). Tests that need to observe the slide phase should gate on.grove-drawer[data-state], not.mobile-shell[data-state]. The shell-level attribute is still useful as a source-of-truth gate forclosed/open.confirm_dialog.rs— uses no CSS transition at all (the dialog appears instantly under default CSS). The lifecycle'stransitionendlistener is dormant; the reduced-motion shortcut path snaps every state change to terminal. Effectivelyclosed → openwith no observable intermediate phases. Listed in the spec's lifecycle target set, so we wired it for consistency.bottom_sheet.rsdriving property istransform, notopacityas the plan stated. Re-readingcomponents.css:296-318: the default rule is.bottom-sheet { transition: transform var(--motion-slow) var(--motion-ease) }; only theprefers-reduced-motion: reducemedia query swaps intransition: opacity. The implementer chosetransformto match runtime behaviour. The reduced-motion path still works via the zero-duration shortcut.These are documented in each component's commit body so future readers know which phases to expect.
Browser tests not run in this environment
Three new tests in
crates/web/tests/browser.rs(grove_drawer_lifecycle_advances_on_transitionend,_reduced_motion_snaps_to_terminal,_ignores_unrelated_transitionend) cover the spec's three documented failure modes. wasm-pack/Firefox/geckodriver were not available in the implementation environment, so they were verified only at the WASM compile + clippy level. CI will run them as part ofjust check-all.Other deviations
lifecycle.rswas added as a shared helper module (LifecycleStateenum +advancefn +is_zero_durationreduced-motion check). The spec's open-question §"Should thedata-stateattribute pattern be lifted into a shared Leptos helper component" defers shared-helper-ness, but lifting just the data primitives (no Leptos wrapper) avoids 5× near-identical 30-line blocks. The component-level wrapping (signal +transitionendclosure) stays inline so each component picks its own driving property without indirection.chore(web): apply cargo fmt to lifecycle.rs + browser.rscommit (689f642) — pre-existing rustfmt drift inlifecycle.rsfromce0fca5plus a small browser.rs fmt-fix. Not a Tasks 3–10 deliverable; folded in to makecargo fmt --checkpass.Test plan
cargo fmt --checkcleancargo clippy --workspace --tests -- -D warningscleancargo check --target wasm32-unknown-unknown -p willow-webcleancargo test --workspace --lib— all suites pass, 0 failuresnpx tsc --noEmit ... e2e/helpers/clock.ts e2e/helpers/touch.tscleannpx eslint e2e/cleanjust check-all FEATURES=test-hooksruns the full Playwright + browser suite (the 3 new lifecycle tests) and the symbol-leak guard.Out of scope (deferred)
longPress→longPressWithClock(per-spec via e2e: migrate remaining specs to event-based waits #458).data-open/data-visibleconsumers in CSS where the existing class-based selector still works. PR-3 keeps both during the transition; a future spec-by-spec follow-up removes them.page.clock.setFixedTime— no test needs it yet.https://claude.ai/code/session_01AKogx2HEvgHw41aPHyp1Va
Generated by Claude Code