Skip to content

fix: preserve unmasked route on SSR hard reload#7116

Open
tmm wants to merge 6 commits intoTanStack:mainfrom
tmm:tmm/mask-reload-hard-refresh
Open

fix: preserve unmasked route on SSR hard reload#7116
tmm wants to merge 6 commits intoTanStack:mainfrom
tmm:tmm/mask-reload-hard-refresh

Conversation

@tmm
Copy link
Copy Markdown

@tmm tmm commented Apr 8, 2026

SSR hard reloads on masked routes can lose the real route because the server only sees the masked URL, so hydration can drop modal or dialog UI that depends on the unmasked route.

This injects a small pre-hydration script for masks with unmaskOnReload that restores history.state.__tempLocation before hydration (while preserving the SSR nonce).

Closes #7115.

Summary by CodeRabbit

  • New Features

    • Server-side rendering now preserves masked route navigation state during hard page reloads, maintaining user context and preventing loss of state across browser refreshes
  • Tests

    • Added comprehensive test coverage for route state preservation behavior during page reload scenarios

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 8, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an optional "unmask on reload" pre-hydration inline script: server-side rendering emits the script when route masks opt into unmaskOnReload; client computes and includes it during hydration. Also adds route-path→RegExp helper, unmask-script generation/runtime, public re-exports, and tests.

Changes

Cohort / File(s) Summary
Head tag injection
packages/react-router/src/headContentUtils.tsx
Appends a conditional inline script tag built by buildUnmaskOnReloadHeadScript(router, nonce) to server and client head tags; includes CSP nonce when provided and uses useMemo on the client.
Unmask script generation & runtime
packages/router-core/src/unmask-on-reload-script.ts, packages/router-core/src/unmask-on-reload-inline.ts
Adds getUnmaskOnReloadScriptFromRouteMasks / getUnmaskOnReloadScript to serialize route-mask regex sources into an escaped payload and produce an inline script (or null), plus a runtime that reads window.history.state?.__tempLocation, tests masks, and calls window.location.replace(...) when appropriate.
Route path → RegExp helper
packages/router-core/src/path.ts
Adds exported routePathToRegExpSource(routePath: string) to produce a regex source string from route path templates; introduces escapeRegExp and uses it in decode pattern construction.
Public re-exports
packages/router-core/src/index.ts
Re-exports routePathToRegExpSource, getUnmaskOnReloadScript, and getUnmaskOnReloadScriptFromRouteMasks from their modules.
Tests — react-router (SSR)
packages/react-router/tests/Scripts.test.tsx
Adds SSR tests asserting the injected unmask inline script appears with nonce and runtime markers when unmaskOnReload: true, and is omitted when disabled.
Tests — router-core
packages/router-core/tests/path.test.ts, packages/router-core/tests/unmask-on-reload-script.test.ts
Adds unit tests for routePathToRegExpSource (various path forms) and for getUnmaskOnReloadScript/getUnmaskOnReloadScriptFromRouteMasks (null for empty, contains payload and runtime markers for non-empty).
Changesets
.changeset/calmly-fox-smiled.md, .changeset/swiftly-otter-jumped.md
Adds changesets noting patch releases for react-router and router-core documenting SSR unmask-on-reload behavior.

Sequence Diagram

sequenceDiagram
    autonumber
    participant Server as Server (SSR)
    participant Browser as Browser (HTML)
    participant Script as Unmask Script
    participant Router as Router (Client)

    Server->>Server: Inspect router.options.routeMasks for unmaskOnReload
    Server->>Browser: Render HTML including inline unmask script (with nonce if provided)
    Browser->>Browser: Load HTML / begin hydration
    Browser->>Script: Execute inline unmask script
    Script->>Script: Read window.history.state?.__tempLocation
    Script->>Script: Build RegExp list from embedded routeMaskSources
    Script->>Script: Compare stored pathname vs current location
    alt stored pathname differs and matches a mask
        Script->>Browser: window.location.replace(stored pathname + search + hash)
        Browser->>Router: Browser navigates / reloads to real route
    else no relevant stored location or no match
        Script->>Script: Do nothing
    end
    Router->>Browser: Hydration continues on current location
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I tuck a script within the head,
To read the trail that history led.
If masked paths hide the honest route,
I nudge the page, then hop about. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main objective: preserving the unmasked route during SSR hard reload scenarios with masked routes.
Linked Issues check ✅ Passed The PR implements all required functionality from issue #7115: injects unmaskOnReload script for SSR, restores route state before hydration, and preserves nonce attribute.
Out of Scope Changes check ✅ Passed All changes directly support the unmaskOnReload feature for SSR hard reload recovery; no unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

Bundle Size Benchmarks

  • Commit: 5a81726f0a2f
  • Measured at: 2026-04-08T15:47:22.111Z
  • Baseline source: history:796406da66cf
  • Dashboard: bundle-size history
Scenario Current (gzip) Delta vs baseline Raw Brotli Trend
react-router.minimal 87.49 KiB +4 B (+0.00%) 275.79 KiB 76.03 KiB ████▁▁▁▁▁▂▃▃
react-router.full 91.22 KiB +457 B (+0.49%) 288.06 KiB 79.19 KiB ▂▂▂▂▁▁▁▁▁▁▁█
solid-router.minimal 35.56 KiB +2 B (+0.01%) 107.29 KiB 31.90 KiB ████▁▁▁▁▁▂██
solid-router.full 40.03 KiB +3 B (+0.01%) 120.82 KiB 35.92 KiB ████▁▁▁▁▁▂▆▇
vue-router.minimal 53.39 KiB +6 B (+0.01%) 153.10 KiB 48.06 KiB ████▁▁▁▁▁▂▄▄
vue-router.full 58.25 KiB +8 B (+0.01%) 168.56 KiB 52.18 KiB ████▁▁▁▁▁▂▄▅
react-start.minimal 102.41 KiB +416 B (+0.40%) 325.12 KiB 88.65 KiB ▂▂▂▂▁▁▁▁▁▁▁█
react-start.full 105.82 KiB +454 B (+0.42%) 335.46 KiB 91.55 KiB ▂▂▂▂▁▁▁▁▁▁▁█
solid-start.minimal 49.67 KiB +11 B (+0.02%) 153.54 KiB 43.74 KiB ▆▆▆▆▁▁▁▂▂▂▇█
solid-start.full 55.18 KiB +9 B (+0.02%) 169.77 KiB 48.50 KiB ▇▇▇▇▁▂▂▂▂▂▇█

Trend sparkline is historical gzip bytes ending with this PR measurement; lower is better.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/react-router/src/headContentUtils.tsx`:
- Around line 458-478: The regex builder routePathToRegExpSource currently
yields "^$" for the root path because no segments are added; update
routePathToRegExpSource so that when routePath === "/" or when no non-empty
segments are present (i.e., regExpSource is still "^") it appends a "/" before
returning, producing "^/$"; make this change inside routePathToRegExpSource
(referencing the function name) so all root-path masks correctly match "/"
instead of the empty string.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cada9402-3d78-4982-bcf2-ef1523ec7a43

📥 Commits

Reviewing files that changed from the base of the PR and between 5a81726 and 32cfdc1.

📒 Files selected for processing (2)
  • packages/react-router/src/headContentUtils.tsx
  • packages/react-router/tests/Scripts.test.tsx

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/react-router/src/headContentUtils.tsx (1)

404-408: ESLint rule configuration issue (not a code defect).

The static analysis reports the react-hooks/rules-of-hooks rule definition is missing. This is a linter configuration issue rather than a code problem. The eslint-disable comment follows the established pattern used throughout this file for the isServer conditional branching.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-router/src/headContentUtils.tsx` around lines 404 - 408, The
eslint-disable-next-line comment suppressing react-hooks/rules-of-hooks for the
useMemo that computes unmaskOnReloadScript (calling
buildUnmaskOnReloadHeadScript with router and nonce) is a linter config
workaround; instead remove that inline disable and fix the ESLint configuration
to include the react-hooks plugin and rules (ensure
"plugin:react-hooks/recommended" or "react-hooks/rules-of-hooks": "error" is
enabled, and add an override if this file legitimately uses static
conditionals), so the rule is present and the existing pattern for isServer
branching remains valid without inline disables.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/react-router/src/headContentUtils.tsx`:
- Around line 404-408: The eslint-disable-next-line comment suppressing
react-hooks/rules-of-hooks for the useMemo that computes unmaskOnReloadScript
(calling buildUnmaskOnReloadHeadScript with router and nonce) is a linter config
workaround; instead remove that inline disable and fix the ESLint configuration
to include the react-hooks plugin and rules (ensure
"plugin:react-hooks/recommended" or "react-hooks/rules-of-hooks": "error" is
enabled, and add an override if this file legitimately uses static
conditionals), so the rule is present and the existing pattern for isServer
branching remains valid without inline disables.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bca4e4d7-9a61-42c6-92a3-943894c14e40

📥 Commits

Reviewing files that changed from the base of the PR and between 32cfdc1 and 84d81fd.

📒 Files selected for processing (4)
  • packages/react-router/src/headContentUtils.tsx
  • packages/router-core/src/index.ts
  • packages/router-core/src/path.ts
  • packages/router-core/tests/path.test.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/router-core/tests/unmask-on-reload-script.test.ts (1)

9-15: Consider adding an escaping regression assertion.

It would be useful to add one test that passes a mask source containing </> and asserts the emitted script string does not contain a literal </script> sequence.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/router-core/tests/unmask-on-reload-script.test.ts` around lines 9 -
15, Add a regression assertion to the existing test that calls
getUnmaskOnReloadScript with a mask source containing angle-bracket characters
(for example a string including "<" and ">" or the literal "</script>") and
assert that the returned script string does not contain the raw literal
"</script>" sequence; this verifies proper escaping/encoding in
getUnmaskOnReloadScript so the emitted inline script cannot prematurely close a
containing script tag.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/router-core/tests/unmask-on-reload-script.test.ts`:
- Around line 9-15: Add a regression assertion to the existing test that calls
getUnmaskOnReloadScript with a mask source containing angle-bracket characters
(for example a string including "<" and ">" or the literal "</script>") and
assert that the returned script string does not contain the raw literal
"</script>" sequence; this verifies proper escaping/encoding in
getUnmaskOnReloadScript so the emitted inline script cannot prematurely close a
containing script tag.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a2982799-a78d-45a8-9cc0-44727d865c7d

📥 Commits

Reviewing files that changed from the base of the PR and between 84d81fd and cf19c21.

📒 Files selected for processing (5)
  • packages/react-router/src/headContentUtils.tsx
  • packages/router-core/src/index.ts
  • packages/router-core/src/unmask-on-reload-inline.ts
  • packages/router-core/src/unmask-on-reload-script.ts
  • packages/router-core/tests/unmask-on-reload-script.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/router-core/src/index.ts

@tmm tmm changed the title fix: preserve unmasked route on reload fix: preserve unmasked route on SSR hard reload Apr 8, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/react-router/src/headContentUtils.tsx (1)

432-436: Prefer omitting attrs instead of setting it to undefined.

Using conditional object spread keeps the tag shape tighter and avoids optional-property edge cases in stricter TS configurations.

Proposed refactor
   return {
     tag: 'script',
-    attrs: nonce ? { nonce } : undefined,
+    ...(nonce ? { attrs: { nonce } } : {}),
     children: script,
   } satisfies RouterManagedTag

As per coding guidelines: "**/*.{ts,tsx}: Use TypeScript strict mode with extensive type safety".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-router/src/headContentUtils.tsx` around lines 432 - 436, The
returned tag object currently sets attrs to undefined when nonce is falsy;
instead omit the attrs property entirely to avoid optional-property edge cases.
Change the return to conditionally spread attrs only when nonce is present
(e.g., spread {...(nonce ? { attrs: { nonce } } : {})}) so the object only
contains attrs when nonce is truthy; keep tag: 'script', children: script and
the trailing satisfies RouterManagedTag as-is. Ensure you reference the nonce
variable and the RouterManagedTag assertion in headContentUtils.tsx.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/router-core/src/unmask-on-reload-script.ts`:
- Around line 1-4: The import order breaks the import/order rule because the
type import AnyRoute and RouteMask is placed before a regular import
(escapeHtml); move the type-only import from './route' to after the other
non-type imports so all sibling type imports are grouped at the end—ensure
imports remain: minifiedUnmaskOnReloadScript, routePathToRegExpSource,
escapeHtml first, then import type { AnyRoute, RouteMask } from './route'.

---

Nitpick comments:
In `@packages/react-router/src/headContentUtils.tsx`:
- Around line 432-436: The returned tag object currently sets attrs to undefined
when nonce is falsy; instead omit the attrs property entirely to avoid
optional-property edge cases. Change the return to conditionally spread attrs
only when nonce is present (e.g., spread {...(nonce ? { attrs: { nonce } } :
{})}) so the object only contains attrs when nonce is truthy; keep tag:
'script', children: script and the trailing satisfies RouterManagedTag as-is.
Ensure you reference the nonce variable and the RouterManagedTag assertion in
headContentUtils.tsx.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: da57c7cd-dd37-457e-b8ab-580f44471f44

📥 Commits

Reviewing files that changed from the base of the PR and between ba06728 and b9bcdbf.

📒 Files selected for processing (6)
  • .changeset/calmly-fox-smiled.md
  • .changeset/swiftly-otter-jumped.md
  • packages/react-router/src/headContentUtils.tsx
  • packages/router-core/src/index.ts
  • packages/router-core/src/unmask-on-reload-script.ts
  • packages/router-core/tests/unmask-on-reload-script.test.ts
✅ Files skipped from review due to trivial changes (2)
  • .changeset/calmly-fox-smiled.md
  • .changeset/swiftly-otter-jumped.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/router-core/tests/unmask-on-reload-script.test.ts
  • packages/router-core/src/index.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Start: route masks with unmaskOnReload lose the real route on hard reload

1 participant