Skip to content

fix: stop an escaped browser-test click from aborting the whole run - #1276

Open
vivek7405 wants to merge 4 commits into
mainfrom
fix/browser-nav-guard
Open

fix: stop an escaped browser-test click from aborting the whole run#1276
vivek7405 wants to merge 4 commits into
mainfrom
fix/browser-nav-guard

Conversation

@vivek7405

@vivek7405 vivek7405 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Closes #1135

Follows #1270, which merged first per the order both issues specify.

Summary

web-test-runner aborts the whole SESSION, not one file, when the page navigates. That made every browser test that clicks a real <a href> or submits a real <form> a single point of failure for all 67 browser files: whenever the router lost the race to intercept, the browser did the real navigation and the run died reporting 0 failed and then exiting 1, which reads as an infrastructure blip rather than a test problem.

One shared test/browser-nav-guard.js now cancels the default activation, so an interception gap fails ONE test on its own assertion. installNavGuard() returns { fallbacks, remove }, and it is opt-in per suite, so a new suite that clicks a real link has to install it.

The phase is the load-bearing detail, and it is the opposite of the obvious choice. The listener is on window in the BUBBLE phase, the last step of the propagation path, so it runs after the router's document-level listeners and preventDefault still cancels the default action. A CAPTURE-phase guard would set defaultPrevented before the router ever saw the event, and the router returns immediately on that flag, so every guarded router test would pass while testing nothing at all. nav-guard.test.js exists to red on exactly that.

The anchor is resolved from e.composedPath(), not e.target.closest('a[href]'). The listener sits on window, so a click inside an open shadow root arrives retargeted to the host and closest() walks only the light tree, never finding the link. That failed open for precisely the case the router itself handles through findAnchorInPath, making the backstop narrower than the thing it backstops.

Forms are cancelled on submit, not on the submit control's click: the form's default action fires on submit, so cancelling the click would stop the form from ever submitting and the router would never see it. form-action-submit.test.js had already hand-rolled this identical listener with identical reasoning, which is the duplication the shared module replaces.

Test plan

  • Browser: full npm run test:browser green, 67/67 files on Chromium, Firefox, and WebKit. New packages/core/test/routing/browser/nav-guard.test.js, 5/5 on all three. A 20x Firefox loop over the four touched suites had zero failures.
  • Counterfactual (capture phase): flipping both listeners to capture reds the two "does NOT suppress the router" tests on all three engines while the blocking tests still pass, which is precisely the silent no-op the file guards.
  • Counterfactual (shadow root): reverting the composed-path lookup to closest() navigates the runner page away on the shadow-root test and aborts the session, which is the failure this PR exists to prevent.
  • Suites guarded: router-js-handled, frame-missing, frame-targeting (both suites), view-transition-head-and-suspense (both), view-transitions-permanent, stream-action (all three), fetch-revalidates, navigation-error, form-action-submit, submit-state, query-params. packages/ui/test/components/browser/ui-a11y.test.js is deliberately NOT guarded: its only anchors are pure-fragment href="#..." links, which never navigate the page away.
  • The two website suites keep their capture-phase blockNav and gain a comment explaining that suppressing the router is the intent there, so a future agent does not "unify" them onto the shared helper.
  • Unit: N/A, browser-test infrastructure with no Node-side surface. The Node runner excludes browser/ and collects only *.test.*, so it cannot reach this diff.
  • Bun parity: N/A, browser-only test code, no runtime-sensitive server surface.
  • Dogfood: packages/core/src is untouched, so no framework behaviour changed. The website was booted anyway because this PR edits one of its docs pages: 200 on /, /docs/testing, /ui, and /ui/button in prod mode, with no broken modulepreload hints. webjs check reports the same 60 findings as the main baseline.
  • Docs: .agents/skills/webjs/references/testing.md (the rule, the phase reason, the composed-path reason, and a copy-pasteable snippet, since packages/cli/lib/create.js copies this skill verbatim into every scaffolded app and such an app has no shared helper), website/app/docs/testing/page.ts (same rule on the docs site), packages/core/AGENTS.md (Tests section).

@vivek7405
vivek7405 force-pushed the fix/browser-nav-guard branch from 6e4e435 to f540698 Compare August 5, 2026 10:30

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the guard against the router's own click handling and re-ran the sweep. The window-bubble phase reasoning holds up and the dedicated test for it is the right shape, but three things needed fixing.

The real bug is that the guard was narrower than the thing it backstops. It resolved the anchor with e.target.closest('a[href]'), and since the listener sits on window, a click inside an open shadow root arrives retargeted to the host, so closest() walks the light tree and never sees the link. The router deliberately uses composedPath() for exactly this case. Confirmed by reverting it: a shadow link navigates the runner page away and takes the session down, which is the failure this whole PR is about. Now walks the composed path, with a test that reds on the revert.

The sweep for suites needing the guard also missed files, because the shell glob was not recursing. Two more are genuinely exposed and now guarded. The ui-a11y suite came up as a candidate but its only anchors are pure-fragment hrefs, which never navigate the page away, so it is correctly left alone.

Last, the guard is opt-in per suite, so a comment claiming every browser suite gets it was simply false and now says a new suite has to install it. Also added the docs-site section, since the scaffold copies the repo-root skill verbatim into every generated app and that surface has its own page.

Comment thread test/browser-nav-guard.js
Comment thread packages/core/test/routing/browser/stream-action.test.js
@vivek7405 vivek7405 self-assigned this Aug 5, 2026
@vivek7405
vivek7405 force-pushed the fix/browser-nav-guard branch from f540698 to c28bbac Compare August 5, 2026 10:42

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second pass, scoped to the composed-path fix and its blast radius. Nothing left to change in the code.

The guard now matches the router's own findAnchorInPath in both traversal and the instanceof HTMLAnchorElement test, and the extra hasAttribute('href') narrowing is harmless since an hrefless anchor has no activation behaviour and the router bails on it anyway. Worth writing down that the submit half needs no path walk at all: submit is composed: false, so it never crosses a shadow boundary, and the guard is blind to a shadow-root form in exactly the same way the router is. The backstop is not narrower than the router anywhere now, which was the whole complaint.

The sweep was re-run independently across every browser file the two WTR configs actually glob, checking each unguarded file for a real activation rather than just the presence of an anchor or form. Several render links or forms and never activate them, so they need nothing. No missed suite this time. The ui-a11y exclusion holds for a stronger reason than I gave: its fragment anchors are not merely harmless, they are never clicked at all.

One stale claim in the PR body, which said the scaffold skill has no references/testing.md. It does: the generator copies the repo-root skill wholesale into every generated app, and a scaffold test reads that exact file back out. That is precisely why the wording was changed to hand the app author a snippet instead of pointing at a framework-repo module, so the body was contradicting the commit it described. Body corrected, and it now also lists the docs-site page.

@vivek7405
vivek7405 force-pushed the fix/router-js-handled-flake branch from 66a90c2 to e5881c2 Compare August 5, 2026 10:55
@vivek7405
vivek7405 force-pushed the fix/browser-nav-guard branch 2 times, most recently from a77418c to 77c4efa Compare August 5, 2026 10:58
Base automatically changed from fix/router-js-handled-flake to main August 5, 2026 11:14
@vivek7405
vivek7405 force-pushed the fix/browser-nav-guard branch from 77c4efa to d82f0f6 Compare August 5, 2026 11:16
@vivek7405
vivek7405 marked this pull request as ready for review August 5, 2026 11:28
…abort the run

web-test-runner aborts the whole SESSION, not one file, when the page
navigates, so any browser test that clicks a real anchor or submits a real
form is a single point of failure for all 60+ browser test files. When the
router loses the race to intercept, the run dies with 0 failed plus exit 1,
which reads as an infrastructure blip rather than a test problem.

The guard listens on window in the BUBBLE phase, which is the load-bearing
detail. Window bubble is the last step of the propagation path, so it runs
after the router's document-level listeners and preventDefault still cancels
the default action. A capture-phase guard would set defaultPrevented before
the router ever saw the event, and the router returns early on that flag, so
every guarded router test would pass while testing nothing.

Forms are blocked on submit rather than on the submit control's click, since
cancelling that click would stop the form from ever submitting and the router
would never see it.
…real link

Nine router suites click real anchors or submit real forms, so each was a
single point of failure for the whole browser run. They now install the shared
guard per test.

form-action-submit had hand-rolled the identical window-bubble listener with
the identical reasoning, which is the duplication the shared module replaces.
router-js-handled now reads its degradation events off the guard rather than
registering a second listener for the same thing.

The two website suites keep their capture-phase blockNav and gain a comment
saying why: capture suppresses the router on purpose there, because those
tests exercise a menu rather than navigation, and a live router would issue
real page fetches.
…er two missed suites

Three real gaps from review.

The guard resolved the anchor with e.target.closest('a[href]'). The listener
is on window, so a click inside an open shadow root arrives retargeted to the
host and closest() walks only light-tree ancestors, never finding the link.
The guard failed open for exactly the case the router itself handles via
composedPath, so it was narrower than the thing it backstops. It now walks the
composed path the same way, with a test that navigates the runner page away
when reverted.

The first sweep for suites needing the guard missed files because the shell
glob did not recurse. view-transitions-permanent drives eight real anchor
clicks and stream-action calls requestSubmit on a real form; both are now
guarded. A pure-fragment href needs no guard, so the ui-a11y suite is
correctly left alone.

The guard is opt-in per suite, so the comment claiming every browser suite
gets it was wrong and now says a new suite has to install it.
@vivek7405
vivek7405 force-pushed the fix/browser-nav-guard branch from d82f0f6 to 109a5fa Compare August 5, 2026 12:01
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.

fix: a flaky plain-link test aborts the whole Browser CI job

1 participant