Skip to content

fix(onboarding): stop resetting users to the start when a step disappears#3105

Merged
adboio merged 3 commits into
mainfrom
posthog-code/fix-onboarding-reset-loop
Jul 2, 2026
Merged

fix(onboarding): stop resetting users to the start when a step disappears#3105
adboio merged 3 commits into
mainfrom
posthog-code/fix-onboarding-reset-loop

Conversation

@adboio

@adboio adboio commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

The bug

Onboarding kept kicking users back to the beginning (Paul's report). Reconstructed from his logs + our own analytics (Onboarding step viewed/completed events, project 2):

  1. useHasImportableConfig returned data?.total !== 0, which is true while the onboardingImport.getSummary query has no data yet (undefined !== 0). The import-config step ("Your Claude Code environment is ready") therefore flickers into activeSteps during every pending/retry window and out when the query settles at 0 or errors. With refetchOnWindowFocus: true and a query that keeps rejecting, every alt-tab back into the app re-arms the trap (TanStack v5 resets a no-data query to pending/error: null when a refetch starts).
  2. Anyone who clicked Continue on install-cli during an "in" window landed on the phantom step; seconds later it vanished and useOnboardingFlow did setCurrentStep(activeSteps[0]) → welcome. Paul hit this twice in one session (10:22:20 and 10:22:54 his time), matching his screenshots to the second.
  3. Since June 10: 65 users landed on import-config, zero ever completed it, still occurring on current builds (~1 in 6 onboardings pass through the trap window). The same reset also fires for the invite-code step when hasCodeAccess resolves late.

Root cause of the failing query

The app never serves onboardingImport. The renderer and @posthog/ui are typed against HostRouter (the merged router in packages/host-router, which has the route), but the Electron main process serves the hand-assembled trpcRouter in apps/code/src/main/trpc/router.ts. Before the #2442 package split (June 10) that file registered onboardingImport and the feature worked; the split moved the router file into packages/host-router, added it to the merged hostRouter (used only for its type), and dropped it from the served assembly. Every getSummary call since has died instantly with NOT_FOUND — silently, because IPC procedure errors were never logged. The analytics corroborate: import-config step views begin June 9, the zero-completion pattern begins exactly June 10.

The fixes

  • apps/code router: register onboardingImport again, and add a compile-time guard (Exclude<keyof HostRouter, keyof TrpcRouter> must be never) so dropping any HostRouter route from the app assembly now fails typecheck with an error that names the missing route.
  • useOnboardingFlow: when the current step drops out of activeSteps, move to the nearest surviving step (forward in canonical order, then backward) via a new pure nearestActiveStep helper in @posthog/core — never back to the start.
  • useHasImportableConfig: only include the step once the summary has resolved with total > 0. Loading and error states no longer conjure a step the user can be dropped into.
  • electron-trpc: createIPCHandler now accepts an onError hook, wired in apps/code main to a host-trpc scoped logger, so host procedure failures show up in main.log instead of vanishing (this is why Paul's logs were empty through the whole loop).

Tests

  • nearestActiveStep unit tests (forward, backward, still-active cases) in packages/core
  • onError transport test in packages/electron-trpc
  • Compile-time guard verified both ways: with the route removed, tsc fails with Type 'true' is not assignable to type '"onboardingImport"'; with it registered, clean
  • pnpm --filter @posthog/core|@posthog/ui|@posthog/electron-trpc test and typechecks for core/ui/electron-trpc/apps-code all pass; biome clean

🤖 Generated with Claude Code

…ears

Onboarding kept kicking users back to the welcome screen mid-flow. The
conditional import-config step flickers in while the getSummary query is
pending (undefined data read as "importable") and out when it settles;
anyone standing on it was reset to activeSteps[0]. Analytics show 65
users landed on import-config since June 10 and zero ever completed it.

- useOnboardingFlow now moves to the nearest surviving step (forward in
  canonical order, then backward) instead of the first step
- useHasImportableConfig only includes the step once the summary has
  resolved with total > 0 — loading/error no longer count as importable
- electron-trpc createIPCHandler accepts an onError hook, wired to a
  host-trpc scoped logger in main, so procedure failures (like the
  getSummary rejections behind this loop) finally show up in main.log

Generated-By: PostHog Code
Task-Id: 53087b3c-6633-4aaf-b1cb-4b840f010503
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit 20382c9.

@trunk-io

trunk-io Bot commented Jul 2, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "fix(onboarding): stop resetting users to..." | Re-trigger Greptile

Comment thread packages/core/src/onboarding/steps.ts Outdated
const candidate = ONBOARDING_STEPS[i];
if (activeSteps.includes(candidate)) return candidate;
}
return activeSteps[0];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Fallback return is undefined when activeSteps is empty

activeSteps[0] is undefined at runtime when the array is empty, but the return type is OnboardingStep. The two loops together cover every canonical index except step itself, so this line is only reached when activeSteps is empty. computeActiveSteps always keeps "welcome", so this is unreachable today, but the type contract is misleading — a future caller passing an empty array would get a silent undefined with no TypeScript warning unless noUncheckedIndexedAccess is enabled.

Comment on lines +105 to +150
test("reports procedure failures through onError and still responds", async () => {
const failingRouter = t.router({
failingQuery: t.procedure.query(() => {
throw new Error("db exploded");
}),
});
const onError = vi.fn();
const event = makeEvent({
reply: vi.fn(),
sender: {
isDestroyed: () => false,
on: () => {},
},
});

await handleIPCMessage({
createContext: async () => ({}),
event,
internalId: "1-1:1",
message: {
method: "request",
operation: {
context: {},
id: 1,
input: undefined,
path: "failingQuery",
type: "query",
signal: undefined,
},
},
router: failingRouter,
operations: new Map(),
onError,
});

expect(onError).toHaveBeenCalledOnce();
expect(onError.mock.lastCall?.[0]).toMatchObject({
path: "failingQuery",
type: "query",
});
expect(onError.mock.lastCall?.[0].error.cause?.message).toBe("db exploded");
expect(event.reply.mock.lastCall?.[1]).toMatchObject({
id: 1,
error: expect.anything(),
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 onError is only exercised on the query/outer-catch path

The new test proves onError fires when a synchronous query throws (the outer try/catch at line 233 of handleIPCMessage.ts). There are two other onError call sites — the subscription iterator error branch (next instanceof Error, line 160) and the run().catch() handler (line 211) — that remain uncovered. Given that getSummary is a query the PR description identifies as perpetually failing, the subscription paths could easily hide the same silent-failure problem that motivated this change.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

adboio added 2 commits July 2, 2026 14:26
Root cause of getSummary never succeeding: the renderer is typed against
HostRouter (which has onboardingImport), but the Electron app serves its
own hand-assembled trpcRouter, and the #2442 package split dropped
onboardingImport from that assembly while keeping it in the type. Every
getSummary call since June 10 has died with NOT_FOUND — zero users ever
completed the import-config step.

Registers the route again and adds a compile-time guard asserting the
app router serves every HostRouter route; a future drop now fails
typecheck with an error naming the missing route.

Generated-By: PostHog Code
Task-Id: 53087b3c-6633-4aaf-b1cb-4b840f010503
…subscription onError test

Addresses the two Greptile P2 review comments:

- nearestActiveStep returned activeSteps[0] as its final fallback, which
  is undefined when the list is empty despite the OnboardingStep return
  type. It now returns the input step unchanged ("stay put") — the empty
  list is unreachable today since computeActiveSteps always keeps
  welcome, but the contract is honest now. Adds an empty-array test.

- onError in electron-trpc was only tested on the query/outer-catch
  path. Adds a test for the subscription iterator-error branch: an
  observable that emits an error mid-stream must invoke onError with
  path/type and still serialize an error envelope to the renderer
  (which arrives before the final "stopped" message).

Generated-By: PostHog Code
Task-Id: 53087b3c-6633-4aaf-b1cb-4b840f010503
@adboio adboio self-assigned this Jul 2, 2026
@adboio adboio requested a review from a team July 2, 2026 19:17
@adboio adboio merged commit 0a7c297 into main Jul 2, 2026
23 checks passed
@adboio adboio deleted the posthog-code/fix-onboarding-reset-loop branch July 2, 2026 19:27
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.

2 participants