feat: prefetch route data via loaders to eliminate white-screen flash on nav (JEF-64) - #178
Conversation
… on nav (JEF-64)
Navigating to a page not yet visited this session showed a brief blank
flash. router.tsx already set defaultPreload: 'intent' (prefetches a
route's JS chunk on hover), but no route defined a `loader`, so each
page's GraphQL query only started fetching after the component mounted
post-navigation — not during the hover-intent preload window. TanStack
Router blocks a navigation's transition on its target route's loader,
so once a loader exists, the previous page stays on screen (no blank
frame) until data is ready, then swaps in with the query cache already
warm.
- router.tsx / __root.tsx: wire the app's queryClient into the router
context (createRootRouteWithContext) so loaders can call
`context.queryClient.ensureQueryData(...)`.
- dashboard, calendar: queryOptions() shared between the new loader and
the existing useQuery call (single source of truth for queryKey +
queryFn, so they can't drift out of sync).
- applications/board, analytics: same pattern, but the query options
had to move into new sibling -board-queries.ts / -analytics-queries.ts
modules rather than living inside -board-page.tsx / -analytics-page.tsx
— those routes use `lazyRouteComponent` specifically to keep dnd-kit
and recharts out of the eagerly-loaded route chunk, and importing the
query options directly from the component file would have dragged
those dependencies in eagerly along with them. Verified post-build
that -board-page and -analytics-page remain separate chunks.
- applications/index: infiniteQueryOptions() + ensureInfiniteQueryData.
loaderDeps only tracks the URL-driven status/starred filters (not the
local-state search input, which is always empty on a fresh nav) so
hovering a filtered link (e.g. the "Applied" tab) also prefetches
correctly, not just the unfiltered default.
- applications/$applicationId/{index,edit}: both routes fetch the exact
same application by id under the same query key but each had its own
copy of the query string and Application type; extracted both into a
shared -application-query.ts so the two routes' loaders and the
detail/edit pages' useQuery calls all reference one definition.
Deliberately left out: account.tsx (7 independent queries with no
single blocking one — the page already renders progressively rather
than blank) and assistant.tsx (no data-fetching query on load at all).
Verified: typecheck/lint/test/build all clean (148/148 web tests,
unaffected since tests mock createFileRoute and render components
directly, bypassing loaders entirely). Confirmed via build output that
-board-page/-analytics-page chunks stayed separate (code-splitting
intact) and the main router chunk size didn't grow appreciably.
Manually verified end-to-end with Playwright against a live dev server:
registered a user, clicked through to Calendar and Analytics, and
confirmed via a DOM-attach watcher that the loading-skeleton element
never appears during the transition (previously the empty component
would mount and show it while its own query fetched).
Ref: JEF-64
WalkthroughThe web app now passes ChangesTanStack Query route preloading
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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. Comment |
|
Preview deployments for this PR: |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
apps/web/src/routes/_authenticated/analytics.tsx (1)
3-3: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse the required
#/*alias for shared analytics query imports.Both imports resolve inside
apps/web/srcand should use the repository alias.
apps/web/src/routes/_authenticated/analytics.tsx#L3-L3: replace./-analytics-querieswith#/routes/_authenticated/-analytics-queries.apps/web/src/routes/_authenticated/-analytics-page.tsx#L5-L5: replace./-analytics-querieswith#/routes/_authenticated/-analytics-queries.As per coding guidelines,
apps/web/src/**/*.{ts,tsx}must use the#/*path alias for imports resolving to./src/*where applicable.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/routes/_authenticated/analytics.tsx` at line 3, Update the analyticsQueryOptions imports in apps/web/src/routes/_authenticated/analytics.tsx at lines 3-3 and apps/web/src/routes/_authenticated/-analytics-page.tsx at lines 5-5 to use the `#/routes/_authenticated/-analytics-queries` alias instead of the relative ./-analytics-queries path.Source: Coding guidelines
apps/web/src/routes/__root.tsx (1)
11-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse the
#/*alias for the internal stylesheet import.
../styles.css?urlresolves toapps/web/src/styles.css. Replace it with#/styles.css?urlwhen the configured alias supports asset URL imports.As per coding guidelines, files under
apps/web/src/**/*.{ts,tsx}must use the#/*path alias for imports that resolve to./src/*where applicable.Proposed change
-import appCss from '../styles.css?url'; +import appCss from '`#/styles.css`?url';🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/routes/__root.tsx` at line 11, Update the appCss import in the root route module to use the configured `#/`* alias for the internal stylesheet asset, replacing the relative ../styles.css?url path with the equivalent aliased URL import.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/web/src/routes/_authenticated/applications/`$applicationId/index.tsx:
- Around line 71-72: Disable SSR in both protected application route
configurations:
apps/web/src/routes/_authenticated/applications/$applicationId/index.tsx lines
71-72 and
apps/web/src/routes/_authenticated/applications/$applicationId/edit.tsx lines
45-46. Add the route-level ssr: false setting alongside each loader so
applicationQueryOptions loaders run only after client hydration.
In `@apps/web/src/routes/_authenticated/applications/board.tsx`:
- Around line 4-6: Update the Route configurations in
apps/web/src/routes/_authenticated/applications/board.tsx lines 4-6 and
apps/web/src/routes/_authenticated/applications/index.tsx lines 116-124 to
disable SSR and enable session hydration with the established client-only auth
configuration, ensuring hydrateSession runs for both API-backed protected
loaders.
---
Nitpick comments:
In `@apps/web/src/routes/__root.tsx`:
- Line 11: Update the appCss import in the root route module to use the
configured `#/`* alias for the internal stylesheet asset, replacing the relative
../styles.css?url path with the equivalent aliased URL import.
In `@apps/web/src/routes/_authenticated/analytics.tsx`:
- Line 3: Update the analyticsQueryOptions imports in
apps/web/src/routes/_authenticated/analytics.tsx at lines 3-3 and
apps/web/src/routes/_authenticated/-analytics-page.tsx at lines 5-5 to use the
`#/routes/_authenticated/-analytics-queries` alias instead of the relative
./-analytics-queries path.
🪄 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: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 7ebf4762-79ed-43c4-8f64-5aafa4f3b0b1
📒 Files selected for processing (14)
apps/web/src/router.tsxapps/web/src/routes/__root.tsxapps/web/src/routes/_authenticated/-analytics-page.tsxapps/web/src/routes/_authenticated/-analytics-queries.tsapps/web/src/routes/_authenticated/analytics.tsxapps/web/src/routes/_authenticated/applications/$applicationId/-application-query.tsapps/web/src/routes/_authenticated/applications/$applicationId/edit.tsxapps/web/src/routes/_authenticated/applications/$applicationId/index.tsxapps/web/src/routes/_authenticated/applications/-board-page.tsxapps/web/src/routes/_authenticated/applications/-board-queries.tsapps/web/src/routes/_authenticated/applications/board.tsxapps/web/src/routes/_authenticated/applications/index.tsxapps/web/src/routes/_authenticated/calendar.tsxapps/web/src/routes/_authenticated/dashboard.tsx
| loader: ({ context: { queryClient }, params: { applicationId } }) => | ||
| queryClient.ensureQueryData(applicationQueryOptions(applicationId)), |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== candidate files =="
for f in \
apps/web/src/routes/_authenticated/applications/\$applicationId/index.tsx \
apps/web/src/routes/_authenticated/applications/\$applicationId/edit.tsx
do
echo "--- $f"
wc -l "$f"
sed -n '1,140p' "$f"
done
echo "== route declarations =="
rg -n "CreateRootRoute|createFileRoute|ssr:|ssr \?|hydratedRoute|loader:" apps/web/src/routes apps/web/src -g '*.ts' -g '*.tsx' | sed -n '1,220p'Repository: mankatcheung/job-finder
Length of output: 23254
🌐 Web query:
TanStack Router SSR loader execution before hydration API server cookie unavailable react start
💡 Result:
In TanStack Router (especially when using TanStack Start for SSR), loaders are client-first but execute on both the client and the server during SSR [1]. Because browser-only APIs like document.cookie do not exist on the server, you cannot directly access cookies within a loader using standard client-side methods [1]. To access cookies securely on the server during the initial SSR request, you must use server-side primitives provided by TanStack Start rather than trying to read the browser's cookie storage directly [2][3]. Key strategies for handling this include: 1. Use Server Functions for Server-Side Access: Instead of accessing cookies directly in the loader, define a createServerFn to handle authentication or data fetching that requires cookie access [4][5]. Server functions have access to the underlying request context and can safely read cookies on the server using getCookie from @tanstack/react-start/server [6][3]. You can then call this server function from your beforeLoad or loader [4][7]. 2. Context-Based Authentication: The recommended pattern is to validate the session on the server via beforeLoad in your root route or layout [4]. - Use a createServerFn to read the HttpOnly cookie and validate it against your API or session store [4][8]. - Return the authenticated user or session data from this server function [8]. - Assign the result to the router's context in beforeLoad so that it is available to all nested routes, preventing the need for subsequent cookie-based lookups [4]. 3. Isomorphic Logic: If you need logic that runs in both environments, you must use an isomorphic approach to account for the different execution contexts [6]. - Server: Use getCookie from @tanstack/react-start/server [6][3]. - Client: Use standard browser APIs (e.g., document.cookie) [6]. - You can combine these using a utility like createIsomorphicFn to maintain clean, type-safe code that avoids runtime errors during build or SSR execution [6]. Important Note: TanStack Router loaders are not "server-only" (unlike Remix or Next.js loaders) [1]. They will run on the client during client-side navigation. Therefore, ensure any code interacting with cookies is either server-only (called via server functions) or environment-aware to prevent hydration mismatches or server crashes caused by referencing non-existent browser globals [6][1].
Citations:
- 1: https://github.com/TanStack/router/blob/main/packages/router-core/skills/router-core/ssr/SKILL.md
- 2: https://tanstack.com/start/v0/docs/framework/react/guide/authentication-server-primitives
- 3: https://github.com/TanStack/router/blob/main/packages/start-server-core/skills/start-server-core/SKILL.md
- 4: Handling Auth with TanStack Start and TanStack Router TanStack/router#6391
- 5: https://tanstack.com/start/v0/docs/framework/react/guide/authentication
- 6: https://www.answeroverflow.com/m/1351633397746765905
- 7: https://www.answeroverflow.com/m/1335194553501614173
- 8: https://tanstack.com/router/v1/docs/framework/react/examples/start-basic-auth
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== route tree relevant entries =="
sed -n '420,460p' apps/web/src/routeTree.gen.ts | cat -n
echo "== SSR config references =="
rg -n "dehydrate|hydrateSession|beforeLoad|hydrate\(|reactServer|`@tanstack/react-start`|serverContext|ssr: false|ssr \? " apps/web/src apps/web -g '*.ts' -g '*.tsx' -g '*.js' -g '*.json' | sed -n '1,260p'
echo "== query client / server setup files =="
fd -e ts -e tsx -e js -e json . apps/web/src | rg 'queryClient|server|start|client|router|hydration|route' | sed -n '1,120p'Repository: mankatcheung/job-finder
Length of output: 11116
🌐 Web query:
TanStack Router ssr false loader execute server browser cookie unavailable initial request hydration
💡 Result:
When you set ssr: false in TanStack Router, the router disables server-side execution of that route's beforeLoad and loader functions, as well as the server-side rendering of the route component [1][2]. Consequently, because the server does not execute the loader, it does not have access to client-side cookies during the initial request [3][4]. In this configuration, the initial HTML sent to the browser is a "shell" (often a loading state or empty), and the route's data loading and component rendering are deferred until the client takes over [1][2]. Once the application loads in the browser, the router hydrates, and the loader executes on the client, where it can naturally access browser cookies via document.cookie or other client-side storage APIs [3][5]. To avoid issues with missing cookies or hydration mismatches: 1. Client-Side Access: If you use ssr: false, your loaders will only run on the client, allowing them to access browser cookies directly [3]. However, this means the user will initially see a loading state while the browser fetches data [1]. 2. Server-Side Access (Recommended for Auth): If you need to access cookies during the initial request to perform server-side checks (e.g., for authentication) and avoid hydration mismatches, you should keep ssr: true and use server functions [6][7]. You can create a createServerFn that accesses the request object to read cookies from the Cookie header [6][7]. By calling this server function within your loader (which is isomorphic), the server can validate the session and return the necessary data, which the router then automatically dehydrates and provides to the client upon hydration [3][7]. Using ssr: false purely to "solve" cookie access issues is generally considered a workaround that results in a worse user experience due to the loss of initial server-rendered data [3]. The preferred architectural pattern is to use isomorphic server functions to handle cookie-based data requirements, ensuring the data is available on the server during the initial request and correctly hydrated on the client [3][7].
Citations:
- 1: https://tanstack.com/start/latest/docs/framework/react/guide/selective-ssr
- 2: https://tanstack.com/start/v0/docs/framework/react/guide/selective-ssr
- 3: https://github.com/TanStack/router/blob/main/packages/router-core/skills/router-core/ssr/SKILL.md
- 4: https://www.answeroverflow.com/m/1351633397746765905
- 5: https://tanstack.com/start/v0/docs/framework/react/guide/execution-model
- 6: Handling Auth with TanStack Start and TanStack Router TanStack/router#6391
- 7: feat: page-level hydration via
hydrateroute option TanStack/router#6092
Disable SSR for both protected application loaders.
Global ssr is enabled for this app, so these routes inherit server-side loader execution unless overridden. Add ssr: false to both route configurations so the loaders only run after client hydration and can use the API cookie.
apps/web/src/routes/_authenticated/applications/$applicationId/index.tsxapps/web/src/routes/_authenticated/applications/$applicationId/edit.tsx
📍 Affects 2 files
apps/web/src/routes/_authenticated/applications/$applicationId/index.tsx#L71-L72(this comment)apps/web/src/routes/_authenticated/applications/$applicationId/edit.tsx#L45-L46
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/routes/_authenticated/applications/`$applicationId/index.tsx
around lines 71 - 72, Disable SSR in both protected application route
configurations:
apps/web/src/routes/_authenticated/applications/$applicationId/index.tsx lines
71-72 and
apps/web/src/routes/_authenticated/applications/$applicationId/edit.tsx lines
45-46. Add the route-level ssr: false setting alongside each loader so
applicationQueryOptions loaders run only after client hydration.
Source: Coding guidelines
| export const Route = createFileRoute('/_authenticated/applications/board')({ | ||
| loader: ({ context: { queryClient } }) => | ||
| queryClient.ensureQueryData(boardApplicationsQueryOptions), |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find the established client-side authentication route pattern.
rg -n -C 4 'hydrateSession|ssr:\s*false' apps/web/src/routes apps/web/srcRepository: mankatcheung/job-finder
Length of output: 19582
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '--- candidate files ---\n'
wc -l apps/web/src/routes/_authenticated/applications/board.tsx apps/web/src/routes/_authenticated/applications/index.tsx apps/web/src/routes/_authenticated/route.tsx
printf '\n--- board.tsx ---\n'
cat -n apps/web/src/routes/_authenticated/applications/board.tsx
printf '\n--- index.tsx relevant section ---\n'
sed -n '90,135p' apps/web/src/routes/_authenticated/applications/index.tsx | cat -n
printf '\n--- _authenticated/route.tsx relevant section ---\n'
sed -n '70,86p' apps/web/src/routes/_authenticated/route.tsx | cat -nRepository: mankatcheung/job-finder
Length of output: 252
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- candidate files line counts ---'
wc -l apps/web/src/routes/_authenticated/applications/board.tsx apps/web/src/routes/_authenticated/applications/index.tsx apps/web/src/routes/_authenticated/route.tsx
echo
echo '--- board.tsx ---'
cat -n apps/web/src/routes/_authenticated/applications/board.tsx
echo
echo '--- index.tsx relevant section ---'
sed -n '90,135p' apps/web/src/routes/_authenticated/applications/index.tsx | cat -n
echo
echo '--- _authenticated/route.tsx relevant section ---'
sed -n '70,86p' apps/web/src/routes/_authenticated/route.tsx | cat -nRepository: mankatcheung/job-finder
Length of output: 3453
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- board query options and client auth setup ---'
sed -n '1,180p' apps/web/src/routes/_authenticated/applications/-board-queries.tsx | cat -n
echo
echo '--- graphql client auth/session ---'
sed -n '1,120p' apps/web/src/graphql/client.ts | cat -n
echo
echo '--- access token handling in graphql client ---'
rg -n 'accessToken|setAccessToken|Authorization|getAccessToken|hydrateSession' apps/web/src/graphql/client.ts apps/web/src/routes/_authenticated/applications/-board-queries.tsxRepository: mankatcheung/job-finder
Length of output: 321
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- locate board query file ---'
fd -a -i 'board.*queries' . || true
echo
echo '--- relevant auth helpers ---'
sed -n '1,120p' apps/web/src/graphql/client.ts | cat -n
echo
echo '--- search board application files ---'
git ls-files apps/web/src/routes/_authenticated/applications | sortRepository: mankatcheung/job-finder
Length of output: 5485
Disable SSR and hydrate the session for API-backed protected loaders.
hydrateSession() must run after ssr: false can preserve the in-memory access token for these child routes; otherwise the loaders can invalidate queries when the web server has no bearer token to attach.
apps/web/src/routes/_authenticated/applications/board.tsx#L4-L6: add the client-only auth configuration.apps/web/src/routes/_authenticated/applications/index.tsx#L116-L124: add the same client-only auth configuration.
📍 Affects 2 files
apps/web/src/routes/_authenticated/applications/board.tsx#L4-L6(this comment)apps/web/src/routes/_authenticated/applications/index.tsx#L116-L124
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/routes/_authenticated/applications/board.tsx` around lines 4 -
6, Update the Route configurations in
apps/web/src/routes/_authenticated/applications/board.tsx lines 4-6 and
apps/web/src/routes/_authenticated/applications/index.tsx lines 116-124 to
disable SSR and enable session hydration with the established client-only auth
configuration, ensuring hydrateSession runs for both API-backed protected
loaders.
Source: Coding guidelines
Summary
Navigating to a page not yet visited this session showed a brief blank flash.
router.tsxalready setdefaultPreload: 'intent'(prefetches a route's JS chunk on hover), but no route defined aloader, so each page's GraphQL query only started fetching after the component mounted post-navigation — not during the hover-intent preload window. TanStack Router blocks a navigation's transition on its target route'sloader, so once a loader exists, the previous page stays on screen (no blank frame) until data is ready, then swaps in with the query cache already warm.router.tsx/__root.tsx: wire the app'squeryClientinto the router context (createRootRouteWithContext) so loaders can callcontext.queryClient.ensureQueryData(...).queryOptions()shared between the new loader and the existinguseQuerycall (single source of truth for queryKey + queryFn, so they can't drift out of sync).-board-queries.ts/-analytics-queries.tsmodules rather than living inside-board-page.tsx/-analytics-page.tsx— those routes uselazyRouteComponentspecifically to keep dnd-kit and recharts out of the eagerly-loaded route chunk, and importing the query options directly from the component file would have dragged those dependencies in eagerly along with them. Verified post-build that-board-page/-analytics-pageremain separate chunks.infiniteQueryOptions()+ensureInfiniteQueryData.loaderDepsonly tracks the URL-drivenstatus/starredfilters (not the local-state search input, which is always empty on a fresh nav) so hovering a filtered link (e.g. the "Applied" tab) also prefetches correctly, not just the unfiltered default.Applicationtype; extracted both into a shared-application-query.tsso the two routes' loaders and the detail/edit pages'useQuerycalls all reference one definition.Deliberately left out:
account.tsx(7 independent queries with no single blocking one — the page already renders progressively rather than blank) andassistant.tsx(no data-fetching query on load at all).Ref: Linear JEF-64.
Test plan
pnpm --filter @job-finder/web typecheck/lint/build— cleanpnpm --filter @job-finder/web test— 148/148 passing, unaffected since tests mockcreateFileRouteand render components directly, bypassing loaders entirely-board-page/-analytics-pageremain their own separate chunks (code-splitting intact) and the main router chunk size didn't grow appreciably🤖 Generated with Claude Code
https://claude.ai/code/session_01N2PBmsuzPhrmNnfZf6C3BM
Summary by CodeRabbit
Performance
User Experience