diff --git a/frontend/src/components/home-onboarding-shell-slot.tsx b/frontend/src/components/home-onboarding-shell-slot.tsx index 0521c8db..0be4afaf 100644 --- a/frontend/src/components/home-onboarding-shell-slot.tsx +++ b/frontend/src/components/home-onboarding-shell-slot.tsx @@ -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 => { + const loaded = loadedHomeOnboardingShell(); + if (loaded) { + // A thenable that calls back synchronously: React marks the lazy resolved + // before it would suspend. + const settled: PromiseLike = { + then: (resolve) => Promise.resolve(resolve ? resolve({ default: loaded }) : { default: loaded }) as never, + }; + return settled as Promise; + } + return loadHomeOnboardingShell().then((Shell) => ({ default: Shell })); +}); + export function HomeOnboardingShellSlot(props: HomeOnboardingShellProps) { - const Loaded = loadedHomeOnboardingShell(); - return Loaded ? : ; + return ( + + + + ); } diff --git a/frontend/tests/home-first-load-20260930.test.ts b/frontend/tests/home-first-load-20260930.test.tsx similarity index 94% rename from frontend/tests/home-first-load-20260930.test.ts rename to frontend/tests/home-first-load-20260930.test.tsx index 32c6c7fa..4f222795 100644 --- a/frontend/tests/home-first-load-20260930.test.ts +++ b/frontend/tests/home-first-load-20260930.test.tsx @@ -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( + 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/); +}); diff --git a/frontend/tests/home-warm-snapshot.test.ts b/frontend/tests/home-warm-snapshot.test.ts index 6f29b9b1..8a81a905 100644 --- a/frontend/tests/home-warm-snapshot.test.ts +++ b/frontend/tests/home-warm-snapshot.test.ts @@ -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(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", () => {