Skip to content

fix(onboarding): stop the completed-onboarding guard reading a shared cache on the server - #181

Open
bookingseo wants to merge 1 commit into
every-app:mainfrom
bookingseo:fix/onboarding-guard-shared-cache
Open

fix(onboarding): stop the completed-onboarding guard reading a shared cache on the server#181
bookingseo wants to merge 1 commit into
every-app:mainfrom
bookingseo:fix/onboarding-guard-shared-cache

Conversation

@bookingseo

Copy link
Copy Markdown
Contributor

The bug

/_authenticated/onboarding/ gates its redirect on a shared cache read that runs on the server:

beforeLoad: async () => {
  const data = await queryClient.ensureQueryData(onboardingAnswersQueryOptions());
  if (data.completedAt) throw redirect({ to: "/", replace: true });
},

beforeLoad runs on the server for the initial document request. queryClient lives in module scope (src/client/tanstack-db/queryClient.ts), which a worker isolate reuses across requests. ["onboardingAnswers"] carries no user id, and the default staleTime is 5 minutes.

So within that window, a signed-in visitor's ensureQueryData can resolve with the previous request's answers and decide their redirect from another account's completedAt — bouncing someone who has not onboarded to /, or showing the wizard to someone who already finished.

Measured, not argued

On a dev worker running this branch's base, with a probe temporarily added to that beforeLoad:

PROBE isServer=true moduleHits=1 readBack=undefined
PROBE isServer=true moduleHits=2 readBack="written-by-request-1"
PROBE isServer=true moduleHits=3 readBack="written-by-request-2"

Three separate document requests. isServer=true — the hook runs on the server. The module counter increments across requests — module scope survives them. And a value written into the shared queryClient during one request is read back by the next, which is exactly the mechanism ensureQueryData rides.

Control: hooks under /_project/p/$projectId produce no server-side hits at all, because that subtree is already ssr: false.

Nothing rehydrates this cache into the browser either — there is no dehydrate or HydrationBoundary in the repo — so the server-side read buys nothing even when it happens to be correct.

The fix

ssr: false on the route. The guard then runs only in the browser, where the cache is per-tab.

This is what /_project/p/$projectId/route.tsx already does, with the same rationale ("SSR would only render empty chrome") — and it applies here for the same reason: the app tree renders inside ClientOnly, so this route never had server-rendered content to lose. Client navigation is unchanged; beforeLoad still blocks before the component renders.

Side effect worth having: a failing server-side read no longer turns the whole document into a 500. Locally, /onboarding?step=0 went 500 → 200 on this base.

The test

src/client/lib/route-lifecycle-query-client.test.ts pins the rule repo-wide: no route beforeLoad/loader may reach the shared query client unless the route declares ssr: false. Today that is one route — this one.

Verified in both directions: commenting out the new ssr: false reddens it. The exemption reads the declared option after stripping comments, so a commented-out ssr: false, cannot buy an exemption — an earlier version of the check was fooled by exactly that, and only re-running the control against the real route tree caught it.

Happy to drop the test if a repo-wide structural check is more than you want here; the one-line fix stands on its own.

Checks

tsc --noEmit clean, oxlint 0 warnings / 0 errors, vitest run 736 passed. Prettier clean on both changed files.

… cache on the server

`beforeLoad` runs on the server for the initial document request, and
`queryClient` lives in module scope, which a worker isolate reuses across
requests. `["onboardingAnswers"]` carries no user id and the default staleTime
is 5 minutes, so `ensureQueryData` on the server can resolve with the *previous*
request's answers and decide this visitor's redirect from another account's
`completedAt` — sending someone who has not onboarded to `/`, or showing the
wizard to someone who finished it.

Measured on a dev worker rather than argued: the hook logs `isServer=true`, a
module counter increments across separate document requests, and a value written
into the shared client during one request is read back by the next. Nothing
rehydrates that cache into the browser either — there is no `dehydrate` or
`HydrationBoundary` anywhere — so the server-side read buys nothing even when it
happens to be correct.

Marking the route `ssr: false` keeps the guard in the browser, where the cache is
per-tab. That matches what `/_project/p/$projectId` already does, and for the
same reason: the page renders inside `ClientOnly`, so SSR only produced empty
chrome. It also stops a failing server-side read from turning the whole document
into a 500 — locally, `/onboarding` went from 500 to 200.

The test pins the rule repo-wide: no route lifecycle hook may touch the shared
query client unless the route is client-only. Verified in both directions —
commenting out the new `ssr: false` reddens it.
HasimEmre pushed a commit to HasimEmre/open-seo that referenced this pull request Jul 30, 2026
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.

1 participant