import assert from "node:assert/strict"; import test from "node:test"; import { StrictMode, startTransition, useEffect, useState, type ReactNode } from "react"; import { SidebarInset, SidebarProvider } from "../src/components/ui/sidebar.tsx"; import { useHomeShellRegistration } from "../src/hooks/use-home-shell-registration.ts"; import * as sessionList from "../src/lib/session-list-context.tsx"; import { emptyProfile, type Account, type ChatSession } from "../src/lib/home-types.ts"; import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; const account: Account = { user: { id: "11111111-1111-4111-8111-111111111111", email: null }, profile: { ...emptyProfile, name: "Synthetic" }, avatar: null, credits: 10, isAdmin: false, adminUrl: null, rectificationPriceCredits: 0, activeSubscription: null, hasConfirmedBirthTime: false, hasUsableBirthTime: false, }; const row: ChatSession = { id: "22222222-2222-4222-8222-222222222222", title: "Synthetic session", theme: "general", modelId: "test", messages: [], createdAt: 1, updatedAt: 1, sessionType: "consultation", rectificationCaseId: null, chartProfileId: null, chartProfileName: null, chartProfileRole: null, pinned: false, archivedAt: null, messagesHydrated: false, }; const noop = () => {}; const trigger = { current: null }; const charts: never[] = []; async function fixture(options: { strict?: boolean; unauthorized?: boolean } = {}) { const harness = createClientLifecycleHarness(); const originalFetch = globalThis.fetch; const requests: string[] = []; globalThis.fetch = (async (input) => { const url = String(input); requests.push(url); assert.ok(url === "/api/account" || url.startsWith("/api/sessions?"), `unexpected fetch ${url}`); return new Response(JSON.stringify(url === "/api/account" ? account : { sessions: [], nextCursor: null }), { status: options.unauthorized ? 401 : 200, }); }) as typeof fetch; let homeRenders = 0; let targetCommits = 0; let latestList: sessionList.SessionListContextValue | undefined; let latestShell: sessionList.ShellRegistration | null = null; let latestModel: ReturnType | undefined; let setPage: (home: boolean) => void = noop; let revise: (revision: number) => void = noop; const callbackRevisions: number[] = []; function HomeProbe() { const list = sessionList.useSessionList(); const [revision, setRevision] = useState(0); // Bound failure even if the old effect/context feedback is reintroduced. homeRenders += 1; if (homeRenders > 50) throw new Error("Home registration failed to settle within 50 renders"); const changed = () => { callbackRevisions.push(revision); }; useHomeShellRegistration({ account: list.account, accountMenuOpen: revision > 0, accountTrigger: trigger, activeSessionId: `revision-${revision}`, cancellationPending: false, chartLibrary: charts, creatingSession: false, hydrated: list.settled, loadMoreSessions: changed, modalOpen: revision > 0, modelCatalog: null, openAccountDialog: changed, pendingSessionId: null, profile: { ...emptyProfile, name: `Synthetic ${revision}` }, rectificationErrorMessage: "", rectificationErrorSessionId: null, rectificationOpeningSessionId: null, rectificationSurfaceOpen: false, registerShellControls: list.registerShellControls, renameSession: changed, selectSession: changed, sessionMenuId: null, sessions: list.sessions, sessionsCursor: null, setAccountMenuOpen: noop, setPendingSessionDeletion: noop, setSessionMenuId: noop, shareSession: changed, showArchivedSessions: false, startNewChat: changed, toggleArchivedSession: changed, toggleArchivedView: changed, togglePinnedSession: changed, // Like useSessionManagement, intentionally new array/callbacks each render. visibleSessions: list.sessions.filter(() => true), }); useEffect(() => { revise = setRevision; }, []); return null; } function Target() { useEffect(() => { targetCommits += 1; }, []); return null; } function Shell({ children }: { children: ReactNode }) { const list = sessionList.useSessionList(); const registration = sessionList.useShellRegistration(); useEffect(() => { latestList = list; latestShell = registration; latestModel = sessionList.sessionListSidebarModel(list, registration); }); // Exercise the actual second provider and unchanged children identity too: // shell context updates must not re-render the Home registration producer. return {children} ; } function Router() { const [home, showHome] = useState(true); useEffect(() => { setPage = showHome; }, []); return {home ? : }; } try { await harness.render(options.strict ? : ); } catch (error) { await harness.close(); globalThis.fetch = originalFetch; throw error; } return { harness, requests, callbackRevisions, get renders() { return homeRenders; }, get commits() { return targetCommits; }, get list() { assert.ok(latestList); return latestList; }, get shell() { return latestShell; }, get model() { assert.ok(latestModel); return latestModel; }, async navigate(home: boolean) { await harness.update(() => startTransition(() => setPage(home))); }, async revise(value: number) { await harness.update(() => revise(value)); }, async close() { try { await harness.close(); } finally { globalThis.fetch = originalFetch; } }, }; } for (const strict of [false, true]) { test(`home registration settles and transitions unregister/reenter (StrictMode=${strict})`, async () => { const app = await fixture({ strict }); try { assert.deepEqual(app.harness.errors, []); assert.ok(app.shell, "hydrated Home registers its controls"); const idleRenders = app.renders; await app.harness.idle(); assert.equal(app.renders, idleRenders, "idle shell does not feed updates back into Home"); assert.ok(idleRenders < 15, `unexpected render growth: ${idleRenders}`); const reads = app.requests.length; await app.navigate(false); assert.deepEqual(app.harness.errors, []); assert.ok(app.commits > 0, "startTransition commits the destination"); assert.equal(app.shell, null, "Home cleanup unregisters controls"); assert.equal(app.model.account?.name, "Synthetic", "secondary page uses account fallback"); await app.navigate(true); assert.ok(app.shell, "returning Home registers new controls"); assert.equal(app.requests.length, reads, "shared provider does not refetch on route changes"); await app.harness.idle(); assert.deepEqual(app.harness.errors, []); } finally { await app.close(); } }); } test("shell receives current callbacks, account and session state without freezing closures", async () => { const app = await fixture(); try { assert.deepEqual(app.harness.errors, []); const initialCallback = app.shell?.controls.onOpenProfile; await app.revise(1); assert.equal(app.shell?.activeSessionId, "revision-1"); assert.equal(app.shell?.insetInert, true); assert.equal(app.shell?.escapeBlocked, true); assert.notEqual(app.shell?.controls.onOpenProfile, initialCallback); app.shell?.controls.onOpenProfile(); assert.deepEqual(app.callbackRevisions, [1]); await app.harness.update(() => { app.list.setAccount({ ...account, credits: 27 }); app.list.setSessions([row]); }); assert.equal(app.model.account?.credits, 27); assert.equal(app.model.sessions[0]?.id, row.id); await app.navigate(false); assert.equal(app.shell, null); assert.equal(app.model.account?.credits, 27); assert.equal(app.model.sessions[0]?.id, row.id); assert.deepEqual(app.harness.errors, []); } finally { await app.close(); } }); test("401 settles signed-out fallback with no shell registration", async () => { const app = await fixture({ unauthorized: true }); try { assert.deepEqual(app.harness.errors, []); assert.equal(app.list.signedOut, true); assert.equal(app.list.settled, true); await app.list.ready; assert.equal(app.shell, null); assert.deepEqual(app.model.sessions, []); assert.equal(app.model.account, null); const renders = app.renders; await app.harness.idle(); assert.equal(app.renders, renders); } finally { await app.close(); } });