test(e2e): gate clicks that can land before hydration commits - #2717
Conversation
The soft-nav canary test waited only for the navigation runtime to publish functions.navigate before clicking the Link. That happens before React commits the hydrated tree — a probe measures a 12-30ms window locally, wider on a loaded runner — so a click can land on an anchor with no listener attached, the browser follows the href, and the reload canary the test asserts on is lost. Use waitForAppRouterHydration, which also requires __VINEXT_HYDRATED_AT (an App Router post-commit effect) and settles two frames. It is what the file's six other tests and the sibling app-with-src spec covering the same contract already use. Closes cloudflare#2716. Recurrence of cloudflare#1769; same race as cloudflare#2409.
commit: |
Performance benchmarksCompared 0 improved · 0 regressed · 6 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
The 12 clicks in tests/e2e/use-params-app-pages/ were dispatched with no hydration gate. App Router publishes the navigation runtime 3-6 rAF ticks before __VINEXT_HYDRATED_AT, and hydration is complete neither when goto() resolves nor after one visibility assertion, so a click can land before React attaches its delegated listeners and be dropped. Gate each click on the helper the clicked element's router actually owns: waitForAppRouterHydration for the App Router page at /, waitForHydration for the Pages Router page at /form-source. The prefetch test additionally asserted zero RSC requests, which passes vacuously when the handler never runs. Add a positive control: the button now also prefetches an App-owned /prefetch-control, whose RSC request only appears if the handler fired. Its request filter also matched a literal ".rsc" path suffix, which this project never emits (_rsc is a query param), so switch to the shared isAppRouterRscRequestForPath helper. Refs cloudflare#2716.
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review
Test-only change, well-reasoned, and every factual claim in the description holds up against the code. Approving in spirit — a couple of minor observations below, none blocking.
What I verified
- Post-commit hydration marker.
packages/vinext/src/server/app-browser-entry.ts:1079-1086writeswindow.__VINEXT_HYDRATED_ATinside a passiveuseEffectthat also callshydrationCachePublication.complete().waitForAppRouterHydration(tests/e2e/helpers.ts:35-63) additionally requires that marker plus a two-rAF settle, so it genuinely gates past commit rather than past runtime publication. The guard swap indev-error-overlay.spec.tsis correct and the helper was already imported. - Router ownership per gated test.
/and/rerenders/[dynamic]areapp/;/form-sourceispages/form-source/index.tsx;#basic-formet al. live on that Pages page. Each click is gated by the router that owns the clicked element (waitForAppRouterHydrationvswaitForHydration). - Positive control isolation.
/prefetch-controlresolves only throughapp/[...path]/page.tsx(App root catch-all); there is nopages/prefetch-control, and the only reference in the fixture tree is therouter.prefetchcall added inapp/page.tsx. No<Link>targets it, so its RSC request can only originate from the handler. The/account/detailsrejection rationale is sound — it is a<Link>(#to-app-priority) subject to viewport prefetch. - De-vacuumed
.rscfilter. Switching fromu.includes(".rsc")toisAppRouterRscRequestForPath(pathname +_rscparam +rsc: 1header) is correct given this project emits?_rsc=query URLs where the literal-dot substring never matched.
Minor observations (non-blocking)
-
Negative assertion vs. viewport prefetch of
#to-pages. Therequestslistener is registered afterwaitForAppRouterHydration, and the negative target/pages-dir/foobaris also a viewport<Link>(#to-pages). If a regression ever made the hybrid short-circuit emit an RSC request, viewport prefetch of that Link (not just the button handler) could trip the assertion — which is arguably desirable (still catches the bug) but means the assertion is not strictly attributable to the button click. Worth a one-line note if you want the test's failure signal to stay unambiguous. -
review pathitem 3 is a live footgun. The positive control silently degrades to vacuous if a future edit adds a<Link href="/prefetch-control">toapp/page.tsx. The inline comment atapp/page.tsx:40-44captures this, which is good; no action needed, just flagging that the invariant is only enforced by comment.
No timeout bumps, no waitForHydration churn, no production code touched. LGTM.
|
Review posted. Summary of my findings: PR #2717 is a test-only change that fixes an intermittent E2E flake (a click landing before React commits the hydrated tree, causing a document navigation instead of a soft RSC nav). It swaps a weak inline gate ( I verified every factual claim against the code:
Posted as a COMMENT review with two non-blocking observations: the negative assertion target is also a viewport |
Closes #2716.
The failure
Dev error overlay › soft-nav to a broken route still updates the URLintermittently failsexpect(canary).toBe(true)withReceived: undefined: the browser performed a document navigation and lostwindow.__vinextReloadCanaryinstead of the soft RSC navigation the test asserts on. Most recently on #2708 (https://github.com/cloudflare/vinext/actions/runs/30244321290/job/89907934259), failing both the initial attempt and retry #1. It is a recurrence of #1769 and the same race #2409 fixed for theapp-with-srcspec.The test gated its click on an inline
waitForFunctionthat only checked the navigation runtime exposesfunctions.navigate. That object is published before React commits the hydrated tree, so the predicate can pass while the anchor still has no click listener.I swept the rest of the suite before fixing it
A one-line guard swap is worth little if the shape is everywhere, so I audited all 200 E2E specs first. It is everywhere: 80 tests across 30 files click without a hydration gate, 29 of them high-exposure (click within
<=1awaited step ofgoto()), 11 concentrated intests/e2e/use-params-app-pages/. Method, measurements and the cleared false positives are in #2716 (comment).Polling at rAF cadence, counting ticks where the gate signal is observable but the post-commit marker is not:
navigationRuntime.functions.navigate__VINEXT_ROOT__(waitForHydration)Hydration is finished neither when
goto()resolves nor after one visibility assertion:Timing the dev-overlay page, recording when each signal first becomes observable:
12–30ms, idle and local. Playwright's
click()runs actionability checks and scrolling before dispatching, so on a loaded runner it lands inside that window — which is why the same shard also reportederror-interactive.spec.ts:51flaky.What this fixes
The confirmed failure.
dev-error-overlay.spec.tsnow useswaitForAppRouterHydration, the guard its six other tests and the siblingapp-with-src/dev-overlay-recovery.spec.tsalready rely on. I checked the four other specs that referenceSymbol.for("vinext.navigationRuntime")inline (advanced,rsc-fetch-errors,build-id-navigation,hash-popstate-scroll); all drive navigation programmatically rather than gating an anchor click, so this site was the only one.The cluster. Every ungated click in
tests/e2e/use-params-app-pages/, gated by whichever router owns the clicked element. The fixture is hybrid, so this is not a per-directory decision:use-params.spec.ts— 7 clicks and 1 hover on App pages (app/page.tsx,app/rerenders/[dynamic]) →waitForAppRouterHydrationpages-form.spec.ts— 5 clicks on/form-source, which ispages/form-source/index.tsx→waitForHydrationThat is 12 clicks, not the audit's 11; I enumerated this directory by hand rather than trusting the detector, which also misfiled the whole directory as Pages Router from its path string.
waitForHydrationitself is untouched. It is theoretically weak —__VINEXT_ROOT__is assigned beforeawait hydrationCommitted— but measures a 0-tick gap, so changing it would churn 95 call sites for nothing. Applying it where there was no gate at all is still worth it.A test that was never flaky, just vacuous.
use-params.spec.ts:90clicks#router-prefetch-pagesand asserts zero RSC requests. A lost click means the handler never runs,prefetch()is never called, and the assertion passes for the wrong reason — quietly, indefinitely. A gate alone does not fix that, so the fixture's handler now also prefetches/prefetch-controland the test asserts that request was issued.Picking that control target took some care.
/account/detailswas the obvious choice and is wrong: it is already a<Link>on the page, andlink.tsxruns a sharedIntersectionObserverfor viewport prefetching, so its RSC request appears after hydration whether or not anything is clicked./prefetch-controlis App-owned via the root catch-allapp/[...path]and nothing links to it. Both prefetches sit in one handler invocation, so the control cannot pass while the subject call is skipped.A second vacuity in the same assertion, unrelated to the race. Its filter was
req.url().includes(".rsc")— a literal dot. This project emits query-param RSC URLs:"...?_rsc=abc".includes(".rsc")isfalse, so a real RSC prefetch to/pages-dir/foobarwould have been filtered out before the assertion ever saw it. Now uses the existingisAppRouterRscRequestForPath, matching pathname +_rsc+rsc: 1— all three verified on a captured request.What this is not
No timeout bumps, no retry annotations, no new helper, no change to
waitForHydration.Not the rest of the audit. The other 18 high-exposure candidates span 9 files in 6 Playwright projects, several doing a full build, so validating them here would be slow and hard to review; #2716 tracks them. That list is a candidate list, not a defect list — of three I sampled, two must not be touched.
server-actions.spec.ts:64and:372run withjavaScriptEnabled: false, where a gate would hang until timeout, andcatch-error.spec.ts:52is already correctly gated withwaitForHydrationbecause it clicks a Pages Router element.Validation
Measured:
vp checkclean.tests/e2e/use-params-app-pages/scoped to its own project,--repeat-each=5: 90 passed.app-routerproject,--repeat-each=5: 5 passed.onClickoff#router-prefetch-pages, rebuilt the fixture, and ran both versions of the test against a button whose handler cannot fire:upstream/mainthe prefetch handler never ran, so the assertion below would pass vacuouslyThe fixture was restored before committing.
Reasoned, not measured:
app-routerproject.use-params-app-pagesis a prod build and was not separately probed.Review path
Two commits:
76bdd20is the one-line guard swap,0432586the cluster. Worth checking:__VINEXT_HYDRATED_ATis written post-commit — theuseEffectinpackages/vinext/src/server/app-browser-entry.tsthat also callshydrationCachePublication.complete()./and/rerenders/foobarareapp/,/form-sourceispages/.app/page.tsxlinks to/prefetch-control. If a future edit adds one, the positive control goes soft.