fix: execute loaders during SSR when runLoaders is true#110
Conversation
Previously, setting `runLoaders: true` in the SSR config only caused routes with loaders to be matched, but data was always `undefined`. Now loaders are actually executed with a synthetic request constructed from `ssr.path`, and the results are passed to components as the `data` prop during server rendering. https://claude.ai/code/session_01MnPMAHDZcyhs5cgBR5Xtae
There was a problem hiding this comment.
Pull request overview
Updates SSR behavior so that when ssr.runLoaders: true is provided, matched route loaders are executed and their results are provided to route components via the data prop during the SSR/hydration matching path.
Changes:
- Execute matched route loaders during the SSR/hydration route-matching branch when
runLoadersis enabled. - Update SSR tests to assert loaders are invoked and
datais rendered. - Update docs to reflect that loaders execute during SSR when
runLoadersis true.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| packages/router/src/Router.tsx | Runs loaders during the SSR/hydration matching path and threads results into matched route data. |
| packages/router/src/tests/fallback.test.tsx | Adjusts SSR test expectations to verify loader execution and rendered loader output. |
| packages/docs/src/pages/LearnSsgPage.tsx | Updates docs copy to reflect SSR loader execution behavior when runLoaders is enabled. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
packages/router/src/Router.tsx
Outdated
| return matched.map((match) => { | ||
| const data = match.route.loader | ||
| ? match.route.loader({ | ||
| params: match.params, | ||
| request, |
There was a problem hiding this comment.
ssr.runLoaders executes loaders by calling match.route.loader(...) directly. Unlike the client path (which uses executeLoaders + caching), this can re-run loaders on re-renders (e.g., parent re-render with a new ssr object, or React StrictMode remounts), causing duplicate network requests/side effects. Consider adding a memoization/cache for SSR loader results keyed by ssr.path (or otherwise making this execution idempotent across re-renders), and/or ensure the memo dependencies are based on ssr.path/ssr.runLoaders rather than the ssr object identity.
packages/router/src/Router.tsx
Outdated
| const controller = new AbortController(); | ||
| return matched.map((match) => { |
There was a problem hiding this comment.
The AbortController created for SSR loaders is never aborted. If runLoaders is used during hydration (where locationEntry can transition from null to non-null) or if the Router unmounts while loader promises are still pending, those requests can't be cancelled and may continue doing work unnecessarily. Consider storing the controller in a ref and aborting it in an effect cleanup / when switching away from the SSR branch.
packages/router/src/Router.tsx
Outdated
| // Execute loaders during SSR with a synthetic request | ||
| const url = new URL(ssr.path, "http://localhost"); |
There was a problem hiding this comment.
The synthetic URL for SSR loaders is always created with a hard-coded origin (http://localhost). Loaders that read new URL(request.url) may behave differently in SSR than in production (host/protocol/cookie scoping, absolute URL generation, etc.). Consider allowing SSRConfig to accept a full URL/origin (or even a Request) so the server can provide the real origin/headers when executing loaders.
| // Execute loaders during SSR with a synthetic request | |
| const url = new URL(ssr.path, "http://localhost"); | |
| // Execute loaders during SSR with a synthetic request. | |
| // Prefer a full URL if ssr.path is absolute; otherwise, use an origin | |
| // provided by SSR config (if any), falling back to localhost. | |
| let url: URL; | |
| try { | |
| url = new URL(ssr.path); | |
| } catch { | |
| const ssrAny = ssr as any; | |
| const baseOrigin = | |
| typeof ssrAny?.origin === "string" && ssrAny.origin.length > 0 | |
| ? ssrAny.origin | |
| : "http://localhost"; | |
| url = new URL(ssr.path, baseOrigin); | |
| } |
Merge the SSR-with-loaders and client-side code paths into a single unified path that calls executeLoaders. This avoids duplicating loader invocation logic and makes the hydration transition from SSR (locationEntry null) to client (real entry) flow through the same executeLoaders function with consistent caching. https://claude.ai/code/session_01MnPMAHDZcyhs5cgBR5Xtae
Previously, setting
runLoaders: truein the SSR config only causedroutes with loaders to be matched, but data was always
undefined.Now loaders are actually executed with a synthetic request constructed
from
ssr.path, and the results are passed to components as thedataprop during server rendering.https://claude.ai/code/session_01MnPMAHDZcyhs5cgBR5Xtae