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";...@@ -19,6 +19,7 @@ import { useEnvironmentQuery } from "../state/query";
19import { environmentShell } from "../state/shell";19import { environmentShell } from "../state/shell";
20import {20import {
21 buildThreadRouteParams,21 buildThreadRouteParams,
22 isThreadRouteSnapshotAuthoritative,
22 resolveThreadRouteRenderState,23 resolveThreadRouteRenderState,
23 type ThreadRouteTarget,24 type ThreadRouteTarget,
24} from "../threadRoutes";25} from "../threadRoutes";
...@@ -72,7 +73,7 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) {...@@ -72,7 +73,7 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) {
72 );73 );
73 const serverThreadShell = serverThread;74 const serverThreadShell = serverThread;
74 const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null);75 const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null);
75 const bootstrapComplete = shell.data?.snapshot._tag === "Some";76 const bootstrapComplete = isThreadRouteSnapshotAuthoritative(shell.data?.status);
76 const draftThread = useComposerDraftStore((store) =>77 const draftThread = useComposerDraftStore((store) =>
77 serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null,78 serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null,
78 );79 );
apps/web/src/threadRoutes.test.ts+38
...@@ -6,6 +6,7 @@ import { DraftId } from "./composerDraftStore";...@@ -6,6 +6,7 @@ import { DraftId } from "./composerDraftStore";
6import {6import {
7 buildDraftThreadRouteParams,7 buildDraftThreadRouteParams,
8 buildThreadRouteParams,8 buildThreadRouteParams,
9 isThreadRouteSnapshotAuthoritative,
9 resolveActiveThreadRouteRef,10 resolveActiveThreadRouteRef,
10 resolveThreadRouteRenderState,11 resolveThreadRouteRenderState,
11 resolveThreadRouteRef,12 resolveThreadRouteRef,
...@@ -13,6 +14,13 @@ import {...@@ -13,6 +14,13 @@ import {
13} from "./threadRoutes";14} from "./threadRoutes";
1415
15describe("threadRoutes", () => {16describe("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
16 it("builds canonical thread route params from a scoped ref", () => {24 it("builds canonical thread route params from a scoped ref", () => {
17 const ref = scopeThreadRef("env-1" as never, ThreadId.make("thread-1"));25 const ref = scopeThreadRef("env-1" as never, ThreadId.make("thread-1"));
1826
...@@ -124,6 +132,25 @@ describe("threadRoutes", () => {...@@ -124,6 +132,25 @@ describe("threadRoutes", () => {
124 ).toBe("ready");132 ).toBe("ready");
125 });133 });
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
127 it("distinguishes bootstrap loading from a missing thread", () => {154 it("distinguishes bootstrap loading from a missing thread", () => {
128 expect(155 expect(
129 resolveThreadRouteRenderState({156 resolveThreadRouteRenderState({
...@@ -153,4 +180,15 @@ describe("threadRoutes", () => {...@@ -153,4 +180,15 @@ describe("threadRoutes", () => {
153 }),180 }),
154 ).toBe("missing");181 ).toBe("missing");
155 });182 });
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 });
156});194});
apps/web/src/threadRoutes.ts+11-7
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { scopeThreadRef } from "@t3tools/client-runtime/environment";1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";
2import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts";3import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
3import type { DraftId } from "./composerDraftStore";4import type { DraftId } from "./composerDraftStore";
45
...@@ -20,22 +21,25 @@ type DraftThreadRouteState = {...@@ -20,22 +21,25 @@ type DraftThreadRouteState = {
2021
21export type ThreadRouteRenderState = "loading" | "ready" | "missing";22export type ThreadRouteRenderState = "loading" | "ready" | "missing";
2223
24export function isThreadRouteSnapshotAuthoritative(
25 status: EnvironmentShellStatus | undefined,
26): boolean {
27 return status === "live";
28}
29
23export function resolveThreadRouteRenderState(input: {30export function resolveThreadRouteRenderState(input: {
24 bootstrapComplete: boolean;31 bootstrapComplete: boolean;
25 serverThreadExists: boolean;32 serverThreadExists: boolean;
26 serverThreadDeleted: boolean;33 serverThreadDeleted: boolean;
27 draftThreadExists: boolean;34 draftThreadExists: boolean;
28}): ThreadRouteRenderState {35}): ThreadRouteRenderState {
29 if (!input.bootstrapComplete) {36 if (input.draftThreadExists || (input.serverThreadExists && !input.serverThreadDeleted)) {
30 return "loading";
31 }
32 if (input.draftThreadExists) {
33 return "ready";37 return "ready";
34 }38 }
35 if (input.serverThreadDeleted) {39 if (!input.bootstrapComplete) {
36 return "missing";40 return "loading";
37 }41 }
38 return input.serverThreadExists ? "ready" : "missing";42 return "missing";
39}43}
4044
41export function buildThreadRouteParams(ref: ScopedThreadRef): {45export function buildThreadRouteParams(ref: ScopedThreadRef): {