authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-26 01:35:22-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-26 01:35:22-07:00
log9151ea407accd1b7af575dcdd514e2b2e3fe6cbb
tree6481c440459cf3901c0253f0e3d02fb185dd6172
parent1d6f23b51929117d5e69ded8c5c0f8e195d75d4a
signature Signed by PGP key B5690EEEBB952194

perf(clients): saving the thread list cache no longer freezes the UI (#13767)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

5 files changed, 122 insertions(+), 13 deletions(-)

apps/mobile/src/connection/environment-cache-store.ts+13-6
......@@ -1,6 +1,7 @@
11import {
22 ConnectionPersistenceError,
33 EnvironmentCacheStore,
4 encodeShellSnapshotForCache,
45} from "@t3tools/client-runtime/platform";
56import {
67 type EnvironmentId,
......@@ -56,7 +57,6 @@ const StoredVcsRefs = Schema.Struct({
5657const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(
5758 Schema.fromJsonString(StoredShellSnapshot),
5859);
59const encodeStoredShellSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredShellSnapshot));
6060const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(
6161 Schema.fromJsonString(StoredThreadSnapshot),
6262);
......@@ -136,11 +136,18 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
136136 }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))),
137137 ),
138138 saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) {
139 const payload = yield* encodeStoredShellSnapshot({
140 schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,
141 environmentId,
142 snapshot,
143 }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));
139 const encodedSnapshot = yield* encodeShellSnapshotForCache(snapshot).pipe(
140 Effect.mapError((cause) => persistenceError("save-shell", cause)),
141 );
142 const payload = yield* Effect.try({
143 try: () =>
144 JSON.stringify({
145 schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,
146 environmentId,
147 snapshot: encodedSnapshot,
148 } satisfies typeof StoredShellSnapshot.Encoded),
149 catch: (cause) => persistenceError("save-shell", cause),
150 });
144151 yield* database
145152 .saveCache(environmentId, "shell", "snapshot", SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, payload)
146153 .pipe(Effect.mapError(mapDatabaseError("save-shell")));
apps/web/src/connection/storage.test.ts+23
......@@ -87,6 +87,29 @@ describe("makeCatalogBackend", () => {
8787 expect(setConnectionCatalog).toHaveBeenCalledWith("{}");
8888 }),
8989 );
90
91 it.effect("fails IndexedDB writes whose commit aborts", () =>
92 Effect.gen(function* () {
93 vi.stubGlobal("window", {});
94 const transaction = Object.assign(new EventTarget(), {
95 error: null as DOMException | null,
96 objectStore: () => ({
97 put: () => {
98 // A failed commit aborts the transaction without an "error" event.
99 queueMicrotask(() => {
100 transaction.error = new DOMException("Quota exceeded", "QuotaExceededError");
101 transaction.dispatchEvent(new Event("abort"));
102 });
103 },
104 }),
105 });
106 const backend = makeCatalogBackend({ transaction: () => transaction } as never);
107
108 const error = yield* backend.write("{}").pipe(Effect.flip);
109
110 expect(error.message).toContain("QuotaExceededError");
111 }),
112 );
90113});
91114
92115describe("browser GitHub routing permissions", () => {
apps/web/src/connection/storage.ts+15-7
......@@ -6,6 +6,7 @@ import {
66 ConnectionTargetStore,
77 EMPTY_CONNECTION_CATALOG_DOCUMENT,
88 EnvironmentCacheStore,
9 encodeShellSnapshotForCache,
910 putRemoteDpopTokenInCatalog,
1011 registerConnectionInCatalog,
1112 removeCatalogValue,
......@@ -91,7 +92,6 @@ const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDoc
9192const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson);
9293const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson);
9394const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson);
94const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson);
9595const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson);
9696const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson);
9797const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson);
......@@ -188,7 +188,9 @@ function writeDatabaseValue(
188188) {
189189 return Effect.callback<void, ConnectionTransientError>((resume) => {
190190 const transaction = database.transaction(storeName, "readwrite");
191 transaction.addEventListener("error", () => {
191 // Every failed write fires "abort". A failed commit, such as
192 // QuotaExceededError, fires only "abort" and no "error".
193 transaction.addEventListener("abort", () => {
192194 resume(
193195 Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")),
194196 );
......@@ -606,11 +608,17 @@ export const connectionStorageLayer = Layer.effectContext(
606608 ),
607609 saveShell: (environmentId, snapshot) =>
608610 Effect.gen(function* () {
609 const encoded = yield* encodeStoredShellSnapshot({
610 schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,
611 environmentId,
612 snapshot,
613 }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));
611 const encodedSnapshot = yield* encodeShellSnapshotForCache(snapshot);
612 const encoded = yield* Effect.try({
613 try: () =>
614 // @effect-diagnostics-next-line preferSchemaOverJson:off - the snapshot is already encoded.
615 JSON.stringify({
616 schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,
617 environmentId,
618 snapshot: encodedSnapshot,
619 } satisfies typeof StoredShellSnapshot.Encoded),
620 catch: (cause) => persistenceError("save-shell", cause),
621 });
614622 yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded);
615623 }).pipe(
616624 Effect.mapError((cause) =>
packages/client-runtime/src/platform/persistence.test.ts created+55
......@@ -0,0 +1,55 @@
1import {
2 OrchestrationProjectShell,
3 OrchestrationShellSnapshot,
4 OrchestrationThreadShell,
5} from "@t3tools/contracts";
6import { describe, expect, it } from "@effect/vitest";
7import * as Arr from "effect/Array";
8import * as Effect from "effect/Effect";
9import * as Schema from "effect/Schema";
10import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary";
11
12import { encodeShellSnapshotForCache } from "./persistence.ts";
13
14// Generated values can hold untrimmed strings, which a decoded value never
15// has. One encode and decode gives a value a client can hold; values that
16// fail are dropped. Size 30 makes the generator fill optional fields.
17const sampleDecoded = <S extends Schema.Constraint>(schema: S) =>
18 Effect.gen(function* () {
19 const encode = Schema.encodeEffect(schema);
20 const decode = Schema.decodeEffect(schema);
21 const generated = yield* Arbitrary.sampleEffect(Arbitrary.schema(schema), {
22 count: 1000,
23 size: 30,
24 });
25 const decoded = yield* Effect.forEach(generated, (value) =>
26 encode(value).pipe(Effect.flatMap(decode), Effect.option),
27 );
28 return Arr.getSomes(decoded);
29 });
30const encodeSnapshot = Schema.encodeEffect(OrchestrationShellSnapshot);
31
32describe("encodeShellSnapshotForCache", () => {
33 it.effect("matches the Schema encoding of a generated snapshot", () =>
34 Effect.gen(function* () {
35 const threads = yield* sampleDecoded(OrchestrationThreadShell);
36 const projects = yield* sampleDecoded(OrchestrationProjectShell);
37 const snapshot: OrchestrationShellSnapshot = {
38 snapshotSequence: 1,
39 // The generator rarely makes monogram icons, and they are the one
40 // project field whose encoding differs from the decoded value.
41 projects: projects.map((project, index) =>
42 index % 2 === 0
43 ? { ...project, projectIcon: { kind: "monogram", text: "T3", color: "blue" } }
44 : project,
45 ),
46 threads,
47 updatedAt: "2026-09-25T00:00:00.000Z",
48 };
49
50 expect(threads.length).toBeGreaterThan(0);
51 expect(projects.length).toBeGreaterThan(0);
52 expect(yield* encodeShellSnapshotForCache(snapshot)).toEqual(yield* encodeSnapshot(snapshot));
53 }),
54 );
55});
packages/client-runtime/src/platform/persistence.ts+16
......@@ -1,5 +1,6 @@
11import {
22 type EnvironmentId,
3 OrchestrationProjectShell,
34 type OrchestrationShellSnapshot,
45 type OrchestrationThreadDetailSnapshot,
56 type ServerConfig,
......@@ -130,6 +131,21 @@ export class EnvironmentCacheStore extends Context.Service<
130131 }
131132>()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {}
132133
134const encodeProjectShells = Schema.encodeEffect(Schema.Array(OrchestrationProjectShell));
135
136/**
137 * Encodes a shell snapshot for `EnvironmentCacheStore.saveShell`. The result
138 * equals `Schema.encode(OrchestrationShellSnapshot)`, so the cache format does
139 * not change. Walking thousands of threads through Schema blocks the UI
140 * thread, and a decoded thread shell is already in its encoded form, so only
141 * the projects go through Schema: their icon has a real encode transform.
142 */
143export const encodeShellSnapshotForCache = (snapshot: OrchestrationShellSnapshot) =>
144 Effect.map(
145 encodeProjectShells(snapshot.projects),
146 (projects) => ({ ...snapshot, projects }) satisfies typeof OrchestrationShellSnapshot.Encoded,
147 );
148
133149export class EnvironmentOwnedDataCleanup extends Context.Reference<{
134150 readonly clear: (environmentId: EnvironmentId) => Effect.Effect<void>;
135151}>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", {