authorgravatar for saphid@gmail.comAlex Southwell <saphid@gmail.com> 2026-10-11 14:31:12+11:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 03:31:12+00:00
log6c130163941618480da333979bf6fc675d803e4f
tree35391e4ade6dca235d0cdc5c7fee07f4bb03c000
parent5ce5a8f658b229c36fc4dc371bb4dc4157500c00
signature Signed by PGP key B5690EEEBB952194

fix(web): keep thread links open until the shell is live (#14697)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 51 insertions(+), 8 deletions(-)

apps/web/src/components/ThreadRouteView.tsx+2-1
......@@ -19,6 +19,7 @@ import { useEnvironmentQuery } from "../state/query";
1919import { environmentShell } from "../state/shell";
2020import {
2121 buildThreadRouteParams,
22 isThreadRouteSnapshotAuthoritative,
2223 resolveThreadRouteRenderState,
2324 type ThreadRouteTarget,
2425} from "../threadRoutes";
......@@ -72,7 +73,7 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) {
7273 );
7374 const serverThreadShell = serverThread;
7475 const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null);
75 const bootstrapComplete = shell.data?.snapshot._tag === "Some";
76 const bootstrapComplete = isThreadRouteSnapshotAuthoritative(shell.data?.status);
7677 const draftThread = useComposerDraftStore((store) =>
7778 serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null,
7879 );
apps/web/src/threadRoutes.test.ts+38
......@@ -6,6 +6,7 @@ import { DraftId } from "./composerDraftStore";
66import {
77 buildDraftThreadRouteParams,
88 buildThreadRouteParams,
9 isThreadRouteSnapshotAuthoritative,
910 resolveActiveThreadRouteRef,
1011 resolveThreadRouteRenderState,
1112 resolveThreadRouteRef,
......@@ -13,6 +14,13 @@ import {
1314} from "./threadRoutes";
1415
1516describe("threadRoutes", () => {
17 it("does not treat cached thread lists as authoritative for deep links", () => {
18 expect(isThreadRouteSnapshotAuthoritative("empty")).toBe(false);
19 expect(isThreadRouteSnapshotAuthoritative("cached")).toBe(false);
20 expect(isThreadRouteSnapshotAuthoritative("synchronizing")).toBe(false);
21 expect(isThreadRouteSnapshotAuthoritative("live")).toBe(true);
22 });
23
1624 it("builds canonical thread route params from a scoped ref", () => {
1725 const ref = scopeThreadRef("env-1" as never, ThreadId.make("thread-1"));
1826
......@@ -124,6 +132,25 @@ describe("threadRoutes", () => {
124132 ).toBe("ready");
125133 });
126134
135 it.each([
136 { status: "cached", source: "server thread" },
137 { status: "synchronizing", source: "server thread" },
138 { status: "cached", source: "draft" },
139 { status: "synchronizing", source: "draft" },
140 ] as const)(
141 "keeps available $source visible while the shell is $status",
142 ({ status, source }) => {
143 expect(
144 resolveThreadRouteRenderState({
145 bootstrapComplete: isThreadRouteSnapshotAuthoritative(status),
146 serverThreadExists: source === "server thread",
147 serverThreadDeleted: false,
148 draftThreadExists: source === "draft",
149 }),
150 ).toBe("ready");
151 },
152 );
153
127154 it("distinguishes bootstrap loading from a missing thread", () => {
128155 expect(
129156 resolveThreadRouteRenderState({
......@@ -153,4 +180,15 @@ describe("threadRoutes", () => {
153180 }),
154181 ).toBe("missing");
155182 });
183
184 it("waits for the live shell before redirecting a deleted thread", () => {
185 expect(
186 resolveThreadRouteRenderState({
187 bootstrapComplete: isThreadRouteSnapshotAuthoritative("cached"),
188 serverThreadExists: true,
189 serverThreadDeleted: true,
190 draftThreadExists: false,
191 }),
192 ).toBe("loading");
193 });
156194});
apps/web/src/threadRoutes.ts+11-7
......@@ -1,4 +1,5 @@
11import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";
23import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
34import type { DraftId } from "./composerDraftStore";
45
......@@ -20,22 +21,25 @@ type DraftThreadRouteState = {
2021
2122export type ThreadRouteRenderState = "loading" | "ready" | "missing";
2223
24export function isThreadRouteSnapshotAuthoritative(
25 status: EnvironmentShellStatus | undefined,
26): boolean {
27 return status === "live";
28}
29
2330export function resolveThreadRouteRenderState(input: {
2431 bootstrapComplete: boolean;
2532 serverThreadExists: boolean;
2633 serverThreadDeleted: boolean;
2734 draftThreadExists: boolean;
2835}): ThreadRouteRenderState {
29 if (!input.bootstrapComplete) {
30 return "loading";
31 }
32 if (input.draftThreadExists) {
36 if (input.draftThreadExists || (input.serverThreadExists && !input.serverThreadDeleted)) {
3337 return "ready";
3438 }
35 if (input.serverThreadDeleted) {
36 return "missing";
39 if (!input.bootstrapComplete) {
40 return "loading";
3741 }
38 return input.serverThreadExists ? "ready" : "missing";
42 return "missing";
3943}
4044
4145export function buildThreadRouteParams(ref: ScopedThreadRef): {