Skip to content

feat(web): event-based waits PR-3 — data-state lifecycle + page.clock helper#496

Merged
intendednull merged 13 commits into
mainfrom
claude/event-based-waits-pr3
Apr 30, 2026
Merged

feat(web): event-based waits PR-3 — data-state lifecycle + page.clock helper#496
intendednull merged 13 commits into
mainfrom
claude/event-based-waits-pr3

Conversation

@intendednull
Copy link
Copy Markdown
Owner

Summary

PR-3 of the event-based testing initiative (PR-2 = #495). Two foundations:

  1. Four-phase data-state lifecycle (closed | opening | open | closing) on five animated UI elements. Tests gate on await expect(el).toHaveAttribute('data-state', 'open') instead of sleeping after a click. Driven by transitionend filtered on each component's specific driving CSS property; reduced-motion shortcut snaps to terminal when transition-duration is 0s so prefers-reduced-motion: reduce doesn't hang the test.

  2. page.clock helper + longPressWithClock variant in e2e/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

Component Driving property Source signal Existing attr kept
grove_drawer.rs transform open: Signal<bool> data-open (additive)
mobile_shell.rs transform* drawer_open: RwSignal<bool> (none)
confirm_dialog.rs opacity* visible: ReadSignal<bool> (none)
bottom_sheet.rs transform open: Signal<bool> data-open (additive)
message.rs action sheet transform show_sheet: Memo<bool> class binding (additive)

* denotes lifecycle-without-CSS-transition cases (see "Known limitations" below).

Tab bar SKIPPED (0f79399). Audited crates/web/components.css:173-260 and tab_bar.rs: .mobile-tab-bar[data-visible="false"] uses display: none (no transition) and .tab.tab-active has no transition declaration, only a non-animated transform: translate(-50%, -50%) for centering. Applying the lifecycle would produce instant closed → open with no observable opening/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) — never opening/closing:

  1. mobile_shell.rs — the .mobile-shell root has no transform transition; the actual drawer slide is animated by the inner <GroveDrawer> (which has its own lifecycle from f2f83c2). 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 for closed/open.

  2. confirm_dialog.rs — uses no CSS transition at all (the dialog appears instantly under default CSS). The lifecycle's transitionend listener is dormant; the reduced-motion shortcut path snaps every state change to terminal. Effectively closed → open with no observable intermediate phases. Listed in the spec's lifecycle target set, so we wired it for consistency.

  3. bottom_sheet.rs driving property is transform, not opacity as the plan stated. Re-reading components.css:296-318: the default rule is .bottom-sheet { transition: transform var(--motion-slow) var(--motion-ease) }; only the prefers-reduced-motion: reduce media query swaps in transition: opacity. The implementer chose transform to 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 of just check-all.

Other deviations

  • lifecycle.rs was added as a shared helper module (LifecycleState enum + advance fn + is_zero_duration reduced-motion check). The spec's open-question §"Should the data-state attribute 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 + transitionend closure) stays inline so each component picks its own driving property without indirection.
  • One chore(web): apply cargo fmt to lifecycle.rs + browser.rs commit (689f642) — pre-existing rustfmt drift in lifecycle.rs from ce0fca5 plus a small browser.rs fmt-fix. Not a Tasks 3–10 deliverable; folded in to make cargo fmt --check pass.

Test plan

  • cargo fmt --check clean
  • cargo clippy --workspace --tests -- -D warnings clean
  • cargo check --target wasm32-unknown-unknown -p willow-web clean
  • cargo test --workspace --lib — all suites pass, 0 failures
  • npx tsc --noEmit ... e2e/helpers/clock.ts e2e/helpers/touch.ts clean
  • npx eslint e2e/ clean
  • CI: just check-all FEATURES=test-hooks runs the full Playwright + browser suite (the 3 new lifecycle tests) and the symbol-leak guard.

Out of scope (deferred)

  • Migration of any spec from longPresslongPressWithClock (per-spec via e2e: migrate remaining specs to event-based waits #458).
  • Replacement of data-open/data-visible consumers 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.
  • Three-peer drift simulation via page.clock.setFixedTime — no test needs it yet.
  • Promotion of the lifecycle pattern into a Leptos wrapper component — open question deferred per spec.

https://claude.ai/code/session_01AKogx2HEvgHw41aPHyp1Va


Generated by Claude Code

claude added 13 commits April 29, 2026 22:38
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.
@intendednull intendednull merged commit a166f2b into main Apr 30, 2026
7 checks passed
@intendednull intendednull deleted the claude/event-based-waits-pr3 branch April 30, 2026 00:41
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants