Skip to content

fix(pages-router): throw on javascript: URL synchronously in router.push/replace#1575

Open
james-elicx wants to merge 1 commit into
mainfrom
fix/javascript-url-warning
Open

fix(pages-router): throw on javascript: URL synchronously in router.push/replace#1575
james-elicx wants to merge 1 commit into
mainfrom
fix/javascript-url-warning

Conversation

@james-elicx
Copy link
Copy Markdown
Member

@james-elicx james-elicx commented May 22, 2026

Refs #1349 — Pages Router router.push/router.replace only. Follow-up issue: #1576.

Summary

  • Hoist the dangerous-scheme guard out of the async performNavigation body and into Router.push / Router.replace themselves so the throw is fully synchronous. Matches Next.js's Pages Router push/replace at packages/next/src/shared/lib/router/router.ts:1025-1033,1057-1065.
  • Previously the throw was wrapped in a rejected Promise (because performNavigation is async). When a typical Pages Router fixture does <button onClick={() => router.push("javascript:...")}> the Promise is not awaited, React's event-handler error reporter never observes it, and the matching console.error is never surfaced through page logs — the failure mode asserted by test/e2e/app-dir/javascript-urls/javascript-urls.test.ts:341,376.
  • The matching console.error("Next.js has blocked a javascript: URL as a security precaution.") from assertSafeNavigationUrl is unchanged; navigation continues to be blocked. The inner guard inside performNavigation is kept as defence-in-depth.

Out of scope (tracked in #1576)

  • App Router <Link> click warning
  • Pages Router <Link> click warning
  • App Router router.push / router.replace warning

Do not close #1349 when this PR merges — close it when #1576 also lands.

Test plan

  • Updated tests/router-javascript-urls.test.ts Pages Router cases to assert the synchronous throw, plus a regression test that Router.push never returns a Promise on a dangerous URL.
  • vp test run tests/router-javascript-urls.test.ts (22 tests passing).
  • vp test run tests/link-navigation.test.ts tests/link.test.ts tests/navigation-runtime.test.ts tests/url-safety.test.ts (178 tests passing).
  • vp check clean.

Hoist the `javascript:` / `data:` / `vbscript:` block from inside the
async `performNavigation` body up into `Router.push` / `Router.replace`
themselves so the throw is fully synchronous. Mirrors Next.js's Pages
Router `push`/`replace` at
packages/next/src/shared/lib/router/router.ts:1025-1033,1057-1065.

Why: `performNavigation` is an `async` function, so throwing from inside
it wraps the error in a rejected Promise. `<button onClick={() =>
router.push("javascript:...")}>` does not await that Promise, so React's
event-handler error reporter never observes the throw and the matching
`console.error` is not surfaced through page logs. That is the failure
shape asserted by Next.js's
`test/e2e/app-dir/javascript-urls/javascript-urls.test.ts:341,376`.

Behaviour preserved: navigation is still blocked, and the matching
`console.error("Next.js has blocked a javascript: URL as a security
precaution.")` is still emitted by `assertSafeNavigationUrl`. The inner
guard inside `performNavigation` is kept as defence-in-depth.

Refs #1349
@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 22, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1575

commit: 56c59cd

@github-actions
Copy link
Copy Markdown

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
web preview production

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Clean, well-scoped fix. The analysis is correct: an async function always wraps thrown exceptions in a rejected Promise, so the previous guard inside performNavigation could never surface the error synchronously through React's event-handler error reporter. Hoisting the guard into the synchronous push/replace wrappers matches Next.js's Pages Router behavior.

A few observations:

Defence-in-depth is preserved correctly. The inner assertSafeNavigationUrl call inside performNavigation is kept, and the comment is updated to explain its role as a catch-all for any hypothetical future caller that bypasses RouterMethods. This is the right layering.

useRouter() consumers also get the fix. PagesRouterProvider wires Router.push / Router.replace directly into the context value (router.ts:1009-1010), so hook-based callers inherit the synchronous guard without any additional changes.

The regression test ("does not return a rejected Promise") is a valuable addition — it directly asserts the invariant that caused the original bug (the return value is never assigned because the throw fires first).

performNavigation is module-private. Confirmed there are no other call sites — only RouterMethods.push and RouterMethods.replace call it. The defence-in-depth guard is still justified as forward-looking protection, but the surface area is well-contained.

Type signature note (non-blocking): NextRouter.push/replace are typed as returning Promise<boolean>, which is accurate for the happy path (the return performNavigation(...) still returns the async function's Promise). On the dangerous-URL path, the synchronous throw prevents a return value entirely. This is the same contract as Next.js's Pages Router — their push/replace are also typed as returning Promise<boolean> but throw synchronously before reaching the async body. No change needed, just noting the type-level contract is sound.

Looks good to ship once CI is green.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 22, 2026

Review posted. Approved PR #1575 with a detailed analysis covering:

  • Correctness: The core insight is sound -- async functions wrap synchronous throws in rejected Promises, so the guard must be hoisted into the synchronous push/replace wrappers to match Next.js's event-handler error reporting behavior.
  • Defence-in-depth: The inner guard in performNavigation is correctly preserved for future-proofing.
  • Hook consumer coverage: useRouter() consumers inherit the fix because PagesRouterProvider delegates to Router.push/Router.replace directly.
  • Regression test quality: The "does not return a rejected Promise" test directly asserts the invariant.
  • Type safety: The Promise<boolean> return type is sound -- the synchronous throw prevents the return path, same as Next.js.

CI is still running (Check passed, several E2E jobs passed, integration tests pending). Good to merge once green.

github run

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.

javascript: URL navigation: missing console error warnings

1 participant