diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 934feac61a839e0e0df1ed73d718ed4b3e6092dd..1040e9d70bc53fb32ec34070450ee0200f44a4ae 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -19,6 +19,7 @@ import { useEnvironmentQuery } from "../state/query"; import { environmentShell } from "../state/shell"; import { buildThreadRouteParams, + isThreadRouteSnapshotAuthoritative, resolveThreadRouteRenderState, type ThreadRouteTarget, } from "../threadRoutes"; @@ -72,7 +73,7 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { ); const serverThreadShell = serverThread; const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null); - const bootstrapComplete = shell.data?.snapshot._tag === "Some"; + const bootstrapComplete = isThreadRouteSnapshotAuthoritative(shell.data?.status); const draftThread = useComposerDraftStore((store) => serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null, ); diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index 34380d12668914e42043103e932dffdc5d1a5687..4a9b00a42be60e52baae9011bcdcbc2709e807ec 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -6,6 +6,7 @@ import { DraftId } from "./composerDraftStore"; import { buildDraftThreadRouteParams, buildThreadRouteParams, + isThreadRouteSnapshotAuthoritative, resolveActiveThreadRouteRef, resolveThreadRouteRenderState, resolveThreadRouteRef, @@ -13,6 +14,13 @@ import { } from "./threadRoutes"; describe("threadRoutes", () => { + it("does not treat cached thread lists as authoritative for deep links", () => { + expect(isThreadRouteSnapshotAuthoritative("empty")).toBe(false); + expect(isThreadRouteSnapshotAuthoritative("cached")).toBe(false); + expect(isThreadRouteSnapshotAuthoritative("synchronizing")).toBe(false); + expect(isThreadRouteSnapshotAuthoritative("live")).toBe(true); + }); + it("builds canonical thread route params from a scoped ref", () => { const ref = scopeThreadRef("env-1" as never, ThreadId.make("thread-1")); @@ -124,6 +132,25 @@ describe("threadRoutes", () => { ).toBe("ready"); }); + it.each([ + { status: "cached", source: "server thread" }, + { status: "synchronizing", source: "server thread" }, + { status: "cached", source: "draft" }, + { status: "synchronizing", source: "draft" }, + ] as const)( + "keeps available $source visible while the shell is $status", + ({ status, source }) => { + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: isThreadRouteSnapshotAuthoritative(status), + serverThreadExists: source === "server thread", + serverThreadDeleted: false, + draftThreadExists: source === "draft", + }), + ).toBe("ready"); + }, + ); + it("distinguishes bootstrap loading from a missing thread", () => { expect( resolveThreadRouteRenderState({ @@ -153,4 +180,15 @@ describe("threadRoutes", () => { }), ).toBe("missing"); }); + + it("waits for the live shell before redirecting a deleted thread", () => { + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: isThreadRouteSnapshotAuthoritative("cached"), + serverThreadExists: true, + serverThreadDeleted: true, + draftThreadExists: false, + }), + ).toBe("loading"); + }); }); diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index 8d6c036669c60a2e42f93cd63f0ad42e4af70277..9220141740d304d3808135ccc3bd29337d7e28eb 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -1,4 +1,5 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import type { DraftId } from "./composerDraftStore"; @@ -20,22 +21,25 @@ type DraftThreadRouteState = { export type ThreadRouteRenderState = "loading" | "ready" | "missing"; +export function isThreadRouteSnapshotAuthoritative( + status: EnvironmentShellStatus | undefined, +): boolean { + return status === "live"; +} + export function resolveThreadRouteRenderState(input: { bootstrapComplete: boolean; serverThreadExists: boolean; serverThreadDeleted: boolean; draftThreadExists: boolean; }): ThreadRouteRenderState { + if (input.draftThreadExists || (input.serverThreadExists && !input.serverThreadDeleted)) { + return "ready"; + } if (!input.bootstrapComplete) { return "loading"; } - if (input.draftThreadExists) { - return "ready"; - } - if (input.serverThreadDeleted) { - return "missing"; - } - return input.serverThreadExists ? "ready" : "missing"; + return "missing"; } export function buildThreadRouteParams(ref: ScopedThreadRef): {