fix(onboarding): keep the tour from bouncing between the Coach and Insights

With no provider set up, a fresh self-host's default, the Coach module
is on but /coach redirects to /insights because the Coach is not
available. The tour read the raw module map, kept its Coach stop, pushed
/coach, was sent back to /insights and pushed /coach again on the
pathname change: a loop.

The tour now reads the same capability-folded map as the navigation, so
the Coach stop is not in the list. And a stop whose page redirects is
dropped after one navigation, in the direction the person was going,
instead of being pushed again.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 12:13:15 +02:00
1 parent 676bb2eff7
commit 28d491174e
5 files changed
+231 -5

No files matched your search

@@ -13,6 +13,8 @@
* regress the gate. v1.18.6.1 — the tour is first-time-auto-start
* only; the former "manual restart from Settings" bypass was removed.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
@@ -147,3 +149,23 @@ describe("tourIncludesAchievements() — v1.18.0 B5 module gate", () => {
expect(tourIncludesAchievements({ sleep: false, mood: false })).toBe(true);
});
});
describe("the launcher hands the tour the capability-folded module map", () => {
// The raw `user.modules` says the Coach module is on for a fresh self-host
// with no provider, while `/coach` redirects to `/insights` because the
// Coach capability is unavailable: the tour pushed `/coach`, was sent back,
// and pushed again. The launcher must read `useNavModules()`, which folds
// the capability into `coach`, exactly as the nav does.
const source = readFileSync(
join(process.cwd(), "src/components/onboarding/tour-launcher.tsx"),
"utf8",
);
it("reads useNavModules()", () => {
expect(source).toMatch(/\buseNavModules\(\)/);
});
it("does not pass the raw account module map to the tour", () => {
expect(source).not.toMatch(/modules\s*=\s*user\?\.modules/);
});
});
+8 -3
View File
@@ -44,6 +44,7 @@ import { useCallback, useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useNavModules } from "@/hooks/use-nav-modules";
import { apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { OnboardingTour } from "./tour";
@@ -217,6 +218,7 @@ interface TourLauncherProps {
export function TourLauncher({ ready = true }: TourLauncherProps) {
const { user, isAuthenticated, isLoading } = useAuth();
const navModules = useNavModules();
const queryClient = useQueryClient();
const pathname = usePathname();
@@ -350,9 +352,12 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
if (showTour !== true) return null;
// The resolved module map gates which stops appear (default-on) and
// keeps the "Schritt n/total" counter honest.
const modules = user?.modules;
// The module map the nav reads gates which stops appear (default-on) and
// keeps the "Schritt n/total" counter honest. It folds the Coach
// capability in: with no provider or no consent the Coach module is on
// but `/coach` redirects, so the raw `user.modules` would keep a stop the
// tour can never stand on.
const modules = navModules;
// v1.18.6 — resume the full tour from the persisted checkpoint.
const resumeFromStopId = user?.onboardingTourProgress?.lastStopId ?? null;
+55 -2
View File
@@ -51,6 +51,8 @@ import {
prevStep,
skipTour,
stepCounter,
dropRedirectedStop,
stopRouteRedirected,
type TourModuleMap,
type TourState,
type TourStop,
@@ -257,6 +259,21 @@ export function OnboardingTour({
initTourState(stops, filterToStop ? null : resumeFromStopId),
);
const [rect, setRect] = useState<SpotlightRect | null>(null);
// Which stop the tour last navigated for, from where, and whether that
// stop's page was reached; and which way the person was travelling, so a
// stop whose page redirects is stepped past in the same direction.
const navRef = useRef<{
stopId: string | null;
pushedFrom: string | null;
arrived: boolean;
}>({ stopId: null, pushedFrom: null, arrived: false });
const directionRef = useRef<"forward" | "back">("forward");
// The navigation effect reads the latest `onClose` without re-running on
// its identity (the launcher passes a fresh closure every render).
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
const stop = currentStop(state);
@@ -285,8 +302,42 @@ export function OnboardingTour({
// stops (the two dashboard stops while on `/`, or the wrap-up which has
// no route) skip the push. `filterToStop` re-entry never navigates — it
// runs on the page the user is already viewing.
if (!filterToStop && stop.route && stop.route !== pathname) {
router.push(stop.route);
//
// One push per stop. A page that sends the person elsewhere (the Coach
// page redirects to `/insights` while the Coach is unavailable) would
// otherwise be pushed again on every pathname change, a loop between the
// two pages; such a stop is dropped and the tour moves on.
if (!filterToStop && stop.route) {
if (navRef.current.stopId !== stop.id) {
navRef.current = { stopId: stop.id, pushedFrom: null, arrived: false };
}
const nav = navRef.current;
if (pathname === stop.route) {
nav.arrived = true;
} else if (
stopRouteRedirected({
stopRoute: stop.route,
pathname,
pushedFrom: nav.pushedFrom,
arrived: nav.arrived,
})
) {
const direction = directionRef.current;
queueMicrotask(() =>
setState((prev) => {
if (currentStop(prev)?.id !== stop.id) return prev;
const next = dropRedirectedStop(prev, direction);
if (isTourFinished(next)) {
queueMicrotask(() => onCloseRef.current("completed"));
}
return next;
}),
);
return;
} else if (nav.pushedFrom === null) {
nav.pushedFrom = pathname;
router.push(stop.route);
}
}
const measureNow = () => {
@@ -397,6 +448,7 @@ export function OnboardingTour({
}, [onClose]);
const handleNext = useCallback(() => {
directionRef.current = "forward";
setState((prev) => {
const next = nextStep(prev);
if (isTourFinished(next) && next.outcome === "completed") {
@@ -407,6 +459,7 @@ export function OnboardingTour({
}, [onClose]);
const handlePrev = useCallback(() => {
directionRef.current = "back";
setState((prev) => prevStep(prev));
}, []);