fix(home): onboarding slot uses a module-level lazy (no component chosen during render); warm-snapshot tests load the city table like the boot does

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-10-01 00:45:30 +08:00
co-authored by Claude Opus 5.5
parent 31d0737897
commit e2c3791fad
3 changed files with 69 additions and 11 deletions
@@ -1,22 +1,38 @@
"use client";
import dynamic from "next/dynamic";
import { lazy, Suspense } from "react";
import type { HomeOnboardingShellProps } from "@/components/home-onboarding-shell";
import type { HomeOnboardingShell, HomeOnboardingShellProps } from "@/components/home-onboarding-shell";
import { loadedHomeOnboardingShell, loadHomeOnboardingShell } from "@/lib/home-onboarding-shell-loader";
// Only reached when the preload below did not finish (or failed) before the
// shell had to paint; renders nothing until the chunk arrives, never a spinner.
const DynamicHomeOnboardingShell = dynamic(loadHomeOnboardingShell, { loading: () => null });
type ShellModule = { default: typeof HomeOnboardingShell };
/**
* The onboarding shell (birth-place picker, China city table, birth-time
* intake) is only for people whose profile is incomplete, so it left the home
* first-screen bundle (TASK-home-first-load T7, BUG-1130). The cold boot
* preloads it before reveal when the profile is incomplete, so this renders the
* loaded component directly and the reveal shows it in the same frame.
* preloads it before reveal when the profile is incomplete; `lazy` is then
* handed an already-settled thenable, which React resolves synchronously, so
* the reveal shows the shell in the same frame. Without the preload it loads
* behind an empty fallback — never a spinner.
*/
const LazyHomeOnboardingShell = lazy((): Promise<ShellModule> => {
const loaded = loadedHomeOnboardingShell();
if (loaded) {
// A thenable that calls back synchronously: React marks the lazy resolved
// before it would suspend.
const settled: PromiseLike<ShellModule> = {
then: (resolve) => Promise.resolve(resolve ? resolve({ default: loaded }) : { default: loaded }) as never,
};
return settled as Promise<ShellModule>;
}
return loadHomeOnboardingShell().then((Shell) => ({ default: Shell }));
});
export function HomeOnboardingShellSlot(props: HomeOnboardingShellProps) {
const Loaded = loadedHomeOnboardingShell();
return Loaded ? <Loaded {...props} /> : <DynamicHomeOnboardingShell {...props} />;
return (
<Suspense fallback={null}>
<LazyHomeOnboardingShell {...props} />
</Suspense>
);
}
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import * as acorn from "acorn";
@@ -10,7 +12,10 @@ import { BOOT_ACCOUNT_PATH, BOOT_CHART_PROFILES_PATH, BOOT_MODELS_PATH, BOOT_REA
import { chinaLocations } from "../src/data/china-locations.ts";
import { ensureChinaLocations, loadedChinaCountry, profileNeedsChinaLocations, resetChinaLocationsForTests } from "../src/lib/china-locations-client.ts";
import { readProfile, selectedBirthPlace } from "../src/lib/home-profile.ts";
import { emptyProfile, previewModelCatalog, type Account } from "../src/lib/home-types.ts";
import { HomeOnboardingShellSlot } from "../src/components/home-onboarding-shell-slot.tsx";
import type { BirthTimeGuidedController } from "../src/hooks/use-birth-time-guided-journey.ts";
import { loadHomeOnboardingShell } from "../src/lib/home-onboarding-shell-loader.ts";
import { emptyProfile, presetOnboardingMessage, previewModelCatalog, type Account } from "../src/lib/home-types.ts";
import { resetRectificationEntrySummaryReadForTests, startRectificationEntrySummaryRead, takeRectificationEntrySummary } from "../src/lib/rectification-entry-summary-read.ts";
import { runHomeBootstrap, type HomeBootstrapDeps } from "../src/lib/home-bootstrap-run.ts";
import { loadSessionList, type SessionListBoot } from "../src/lib/session-list-context.tsx";
@@ -19,6 +24,7 @@ import { installWindow } from "./home-bootstrap-fake-timers.ts";
// TASK-home-first-load-20260930 T1 (BUG-1127): the cold boot reads the account,
// the people list and the remembered person's sessions in one round. Synthetic
// data only.
Object.assign(globalThis, { React });
const ACCOUNT_ID = "11111111-1111-4111-8111-111111111127";
const OTHER_ID = "33333333-3333-4333-8333-333333333127";
@@ -452,3 +458,29 @@ test("an incomplete profile waits for the onboarding shell before reveal; a comp
}
restoreStorage();
});
test("a preloaded onboarding shell renders in the reveal frame, not behind an empty fallback (T7)", async () => {
await loadHomeOnboardingShell();
const markup = renderToStaticMarkup(
<HomeOnboardingShellSlot
profileComplete={false}
profileDraft={emptyProfile}
setProfileDraft={() => undefined}
onboardingStep="name"
presetMessageLength={presetOnboardingMessage.length}
currentOnboardingMessage={presetOnboardingMessage}
onboardingJustCompleted={false}
onboardingCardReady={false}
presetMessageFinished
birthTimeJourney={null}
birthTimeAssessmentPhase={null}
birthTimeError=""
birthTimeGuided={{} as BirthTimeGuidedController}
profileSaving={false}
saveOnboardingBirth={() => undefined}
saveOnboardingPlace={() => undefined}
retryBirthTimeAssessment={() => undefined}
/>,
);
assert.match(markup, /你好,我是 Jyotisha/);
});
+11 -1
View File
@@ -20,6 +20,7 @@ import {
type HomeWarmStartInput,
} from "../src/lib/home-warm-start.ts";
import type { Account, ChatSession, Profile } from "../src/lib/home-types.ts";
import { ensureChinaLocations } from "../src/lib/china-locations-client.ts";
// BUG-1040 T1: the warm snapshot store and the warm-start decision, as pure
// functions. Synthetic ids and profiles only.
@@ -77,6 +78,12 @@ function withStorage<T>(run: () => T): T {
}
}
// 原值:夹具 selfProfile 只有省市编码,直接可判为资料完整。
// 新值:用例前先加载城市表(ensureChinaLocations),断言不变。
// 原因:TASK-home-first-load T7 把城市表移出首屏,只有编码的老资料要等表加载后才能解析地点;
// 正式流程在启动时(人物目录读完之前)就会加载,这里补上同样的一步。
test.before(async () => { await ensureChinaLocations(); });
test("write → read returns the snapshot for the same account", () => {
clearHomeWarmSnapshot();
writeHomeWarmSnapshot(snapshotFor(USER));
@@ -123,7 +130,10 @@ test("sign-out and the provider's signed-out and account-change branches clear t
const provider = readFileSync(new URL("../src/lib/session-list-context.tsx", import.meta.url), "utf8");
assert.match(provider, /accountResult\.status === 401\) \{\n\s+clearHomeWarmSnapshot\(\);/);
assert.match(provider, /sessionResponse\.status === 401\) \{\n\s+clearHomeWarmSnapshot\(\);/);
assert.match(provider, /readCurrentSubjectAccountId\(\) !== accountPayload\.user\.id\) \{\n(?:\s+\/\/.*\n)*\s+clearHomeWarmSnapshot\(\);/);
// 原值:/readCurrentSubjectAccountId\(\) !== accountPayload\.user\.id\) \{ … clearHomeWarmSnapshot\(\);/
// 新值:先 `const boundAccount = readCurrentSubjectAccountId();`,再 `boundAccount !== accountPayload.user.id) { … clearHomeWarmSnapshot();`
// 原因:TASK-home-first-load T1 要用同一个值判断「是否已有绑定账户」(首登不作废刚读到的人物目录);换账户仍先清快照。
assert.match(provider, /const boundAccount = readCurrentSubjectAccountId\(\);\n\s+if \(boundAccount !== accountPayload\.user\.id\) \{\n(?:\s+\/\/.*\n)*\s+clearHomeWarmSnapshot\(\);/);
});
test("the snapshot lives in memory only", () => {