Separate shell subscriptions from Home session data and cover effect convergence, transition cleanup, current callbacks and signed-out fallback with real React lifecycle tests. Record baseline-equivalent local failures and outstanding browser/build verification. Co-Authored-By: Claude Code <noreply@anthropic.com>
187 lines
8.6 KiB
TypeScript
187 lines
8.6 KiB
TypeScript
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<typeof sessionList.sessionListSidebarModel> | 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 <SidebarProvider escapeBlocked={registration?.escapeBlocked ?? false}>
|
|
<SidebarInset inert={registration?.insetInert}>{children}</SidebarInset>
|
|
</SidebarProvider>;
|
|
}
|
|
function Router() {
|
|
const [home, showHome] = useState(true);
|
|
useEffect(() => { setPage = showHome; }, []);
|
|
return <sessionList.SessionListProvider><Shell>{home ? <HomeProbe /> : <Target />}</Shell></sessionList.SessionListProvider>;
|
|
}
|
|
try {
|
|
await harness.render(options.strict ? <StrictMode><Router /></StrictMode> : <Router />);
|
|
} 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(); }
|
|
});
|