Skip to content

Conversation

@Kyujenius
Copy link

@Kyujenius Kyujenius commented Jan 24, 2026

Problem

When deploying a TanStack Start application to Cloudflare Pages with SPA mode enabled, directly navigating to non-root routes (e.g., /example) causes React hydration errors.
Root Cause:

Solution

Implemented a two-part fix:

1. Build-time Marker Injection (start-plugin-core)

  • Inject window.__TSS_SPA_SHELL__ = true into the SPA shell during prerendering
  • Only applied to the shell file (identified by spa.prerender.outputPath)
  • Robust injection with fallback: tries </head> first, then <body> if not found

2. Client-side Detection (start-client-core)

  • Check for __TSS_SPA_SHELL__ marker before hydration
  • If marker exists AND current path ≠ basepath, skip server hydration
  • Let RouterProvider mount the router freshly (client render)
image

This fix is server-agnostic and will resolve similar hydration issues on servers like Caddy, Nginx, or Vercel when configured for SPA fallback 😄 👍

Testing

Unit Tests (11 new tests)

  • Normal hydration scenarios (no marker, root path)
  • SPA shell fallback detection (deep links, trailing slashes)
  • Custom basepath handling (3 scenarios)
  • Existing matches handling (2 scenarios)
  • Router configuration verification (2 tests)
    All tests passing ✅

Summary by CodeRabbit

  • New Features

    • SPA shell support: client now conditionally skips or performs hydration based on a runtime SPA-shell marker and the current URL path (respecting basepath).
    • Prerender now injects a SPA-shell marker into HTML to enable the above behavior.
  • Tests

    • Added comprehensive tests covering hydration fallback scenarios, basepath handling, existing router matches, and adapter merging.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 24, 2026

📝 Walkthrough

Walkthrough

Adds SPA-shell detection and a global window.__TSS_SPA_SHELL__ marker; hydration in the client start is now skipped when the marker is present and the current trimmed path differs from the router basepath. The prerender step injects the marker script into generated HTML when SPA shell mode is enabled.

Changes

Cohort / File(s) Summary
Hydration conditional logic
packages/start-client-core/src/client/hydrateStart.ts
Adds global Window.__TSS_SPA_SHELL__ declaration and changes hydration flow to skip SSR hydration when the SPA-shell marker is true and the current trimmed path is deeper than the router basepath.
Hydration test suite
packages/start-client-core/tests/hydrateStart.test.ts
Adds comprehensive tests covering normal hydration, SPA-shell fallback (root vs deep paths, trailing slashes), custom basepath behavior, existing router matches, and serialization adapter merging.
SPA shell marker injection
packages/start-plugin-core/src/prerender.ts
Injects a script setting window.__TSS_SPA_SHELL__ = true into prerendered HTML (before </head> or after <body> fallback) when isSpaShell is enabled.

Sequence Diagram

sequenceDiagram
    actor User
    participant Browser as Browser Window
    participant Router as Router
    participant Hydration as Hydration System

    User->>Browser: Navigate to /some/path
    Browser->>Browser: Read window.__TSS_SPA_SHELL__
    alt SPA shell marker present
        Browser->>Router: Check for router matches
        Router-->>Browser: No matches
        Browser->>Browser: Skip SSR hydration (client mount only)
    else No SPA marker OR path == basepath OR matches exist
        Browser->>Hydration: Perform SSR hydration
        Hydration-->>Browser: Hydrated
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • schiller-manuel
  • birkskyum

Poem

🐰
A tiny shell on head so bright,
I whisper "skip" when path's not right.
Hydration naps, the client wakes,
Deep-route hops and carrot cakes.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: fixing hydration mismatch in SPA fallback scenarios for TanStack Start, which aligns with the core problem being addressed across multiple files.

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

✨ Finishing touches
  • 📝 Generate docstrings

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.

Copy link
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

🤖 Fix all issues with AI agents
In `@packages/start-client-core/src/client/hydrateStart.ts`:
- Around line 45-55: The pathname equality check in hydrateStart.ts can fail
when routes differ only by a trailing slash; update the condition that compares
window.location.pathname to router.options.basepath to use the router path
normalization utilities (e.g., call removeTrailingSlash or use exactPathTest
from path.ts) so "/app" and "/app/" are treated as equal, and ensure you
reference window.__TSS_SPA_SHELL__, router.options.basepath and hydrate(router)
when making the change; also add a unit/integration test that asserts the logic
skips hydration for window.location.pathname = "/app/" when
router.options.basepath = "/app" to prevent regressions.
🧹 Nitpick comments (3)
packages/start-plugin-core/src/prerender.ts (1)

239-259: SPA shell marker injection logic is sound overall, but the fallback placement is outside <body>.

The </head> injection is correct. However, the fallback on lines 250-256 places the script before the <body tag (between </head> and <body>), not "at the beginning of body" as the comment states. While browsers tolerate this, for semantic correctness the script should be injected inside the body element.

Additionally, using a regex for the <body> fallback would be more robust for matching variants like <body class="...">.

♻️ Optional: Inject inside body element with regex matching
           } else {
-              // Fallback: inject at the beginning of body if </head> not found
-              const bodyOpenTag = '<body'
-              if (html.includes(bodyOpenTag)) {
-                html = html.replace(
-                  bodyOpenTag,
-                  '<script>window.__TSS_SPA_SHELL__ = true</script>' +
-                    bodyOpenTag,
-                )
-              }
+              // Fallback: inject at the beginning of body if </head> not found
+              html = html.replace(
+                /<body[^>]*>/i,
+                (match) => match + '<script>window.__TSS_SPA_SHELL__ = true</script>',
+              )
           }
packages/start-client-core/tests/hydrateStart.test.ts (2)

47-59: Redundant initialization check in beforeEach.

Lines 50-52 check if (!mockGetRouter) before initializing, but the module-level mocks on lines 27-45 already use a getter with lazy initialization. This check is never true at this point since the mocks ensure mockGetRouter is initialized on first access.

♻️ Remove redundant check
 beforeEach(() => {
-    // Ensure mockGetRouter is initialized
-    if (!mockGetRouter) {
-      mockGetRouter = vi.fn(() => Promise.resolve(createMockRouter()))
-    }
-    
     vi.clearAllMocks()
     delete window.__TSS_SPA_SHELL__
     delete window.__TSS_START_OPTIONS__
     window.history.pushState({}, '', '/')
     mockGetRouter.mockResolvedValue(createMockRouter())
   })

100-136: Consider adding a test for basepath trailing slash mismatch.

The custom basepath tests cover exact matches and deeper paths, but don't cover the edge case where basepath and pathname differ only by a trailing slash (e.g., basepath /app vs pathname /app/). This aligns with the potential issue in the implementation's strict equality comparison.

📋 Suggested additional test case
it('should handle trailing slash mismatch between basepath and pathname', async () => {
  window.__TSS_SPA_SHELL__ = true
  window.history.pushState({}, '', '/app/')  // trailing slash
  
  mockGetRouter.mockResolvedValueOnce(createMockRouter({
    options: { basepath: '/app' }  // no trailing slash
  }))
  
  await hydrateStart()
  // Current behavior: these don't match, so hydration is skipped
  // If trailing slash normalization is added, this should hydrate
  expect(hydrate).not.toHaveBeenCalled()
})

@schiller-manuel
Copy link
Contributor

why do we need this? what exactly is mismatching here? we should already handle this correctly in router IMO

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.

2 participants