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 @@...@@ -1,6 +1,7 @@
1import {1import {
2 ConnectionPersistenceError,2 ConnectionPersistenceError,
3 EnvironmentCacheStore,3 EnvironmentCacheStore,
4 encodeShellSnapshotForCache,
4} from "@t3tools/client-runtime/platform";5} from "@t3tools/client-runtime/platform";
5import {6import {
6 type EnvironmentId,7 type EnvironmentId,
...@@ -56,7 +57,6 @@ const StoredVcsRefs = Schema.Struct({...@@ -56,7 +57,6 @@ const StoredVcsRefs = Schema.Struct({
56const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(57const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(
57 Schema.fromJsonString(StoredShellSnapshot),58 Schema.fromJsonString(StoredShellSnapshot),
58);59);
59const encodeStoredShellSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredShellSnapshot));
60const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(60const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(
61 Schema.fromJsonString(StoredThreadSnapshot),61 Schema.fromJsonString(StoredThreadSnapshot),
62);62);
...@@ -136,11 +136,18 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {...@@ -136,11 +136,18 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
136 }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))),136 }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))),
137 ),137 ),
138 saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) {138 saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) {
139 const payload = yield* encodeStoredShellSnapshot({139 const encodedSnapshot = yield* encodeShellSnapshotForCache(snapshot).pipe(
140 schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,140 Effect.mapError((cause) => persistenceError("save-shell", cause)),
141 environmentId,141 );
142 snapshot,142 const payload = yield* Effect.try({
143 }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));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 });
144 yield* database151 yield* database
145 .saveCache(environmentId, "shell", "snapshot", SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, payload)152 .saveCache(environmentId, "shell", "snapshot", SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, payload)
146 .pipe(Effect.mapError(mapDatabaseError("save-shell")));153 .pipe(Effect.mapError(mapDatabaseError("save-shell")));
apps/web/src/connection/storage.test.ts+23
...@@ -87,6 +87,29 @@ describe("makeCatalogBackend", () => {...@@ -87,6 +87,29 @@ describe("makeCatalogBackend", () => {
87 expect(setConnectionCatalog).toHaveBeenCalledWith("{}");87 expect(setConnectionCatalog).toHaveBeenCalledWith("{}");
88 }),88 }),
89 );89 );
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 );
90});113});
91114
92describe("browser GitHub routing permissions", () => {115describe("browser GitHub routing permissions", () => {
apps/web/src/connection/storage.ts+15-7
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 ConnectionTargetStore,6 ConnectionTargetStore,
7 EMPTY_CONNECTION_CATALOG_DOCUMENT,7 EMPTY_CONNECTION_CATALOG_DOCUMENT,
8 EnvironmentCacheStore,8 EnvironmentCacheStore,
9 encodeShellSnapshotForCache,
9 putRemoteDpopTokenInCatalog,10 putRemoteDpopTokenInCatalog,
10 registerConnectionInCatalog,11 registerConnectionInCatalog,
11 removeCatalogValue,12 removeCatalogValue,
...@@ -91,7 +92,6 @@ const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDoc...@@ -91,7 +92,6 @@ const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDoc
91const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson);92const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson);
92const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson);93const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson);
93const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson);94const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson);
94const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson);
95const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson);95const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson);
96const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson);96const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson);
97const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson);97const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson);
...@@ -188,7 +188,9 @@ function writeDatabaseValue(...@@ -188,7 +188,9 @@ function writeDatabaseValue(
188) {188) {
189 return Effect.callback<void, ConnectionTransientError>((resume) => {189 return Effect.callback<void, ConnectionTransientError>((resume) => {
190 const transaction = database.transaction(storeName, "readwrite");190 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", () => {
192 resume(194 resume(
193 Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")),195 Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")),
194 );196 );
...@@ -606,11 +608,17 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -606,11 +608,17 @@ export const connectionStorageLayer = Layer.effectContext(
606 ),608 ),
607 saveShell: (environmentId, snapshot) =>609 saveShell: (environmentId, snapshot) =>
608 Effect.gen(function* () {610 Effect.gen(function* () {
609 const encoded = yield* encodeStoredShellSnapshot({611 const encodedSnapshot = yield* encodeShellSnapshotForCache(snapshot);
610 schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,612 const encoded = yield* Effect.try({
611 environmentId,613 try: () =>
612 snapshot,614 // @effect-diagnostics-next-line preferSchemaOverJson:off - the snapshot is already encoded.
613 }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));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 });
614 yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded);622 yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded);
615 }).pipe(623 }).pipe(
616 Effect.mapError((cause) =>624 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 @@...@@ -1,5 +1,6 @@
1import {1import {
2 type EnvironmentId,2 type EnvironmentId,
3 OrchestrationProjectShell,
3 type OrchestrationShellSnapshot,4 type OrchestrationShellSnapshot,
4 type OrchestrationThreadDetailSnapshot,5 type OrchestrationThreadDetailSnapshot,
5 type ServerConfig,6 type ServerConfig,
...@@ -130,6 +131,21 @@ export class EnvironmentCacheStore extends Context.Service<...@@ -130,6 +131,21 @@ export class EnvironmentCacheStore extends Context.Service<
130 }131 }
131>()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {}132>()("@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
133export class EnvironmentOwnedDataCleanup extends Context.Reference<{149export class EnvironmentOwnedDataCleanup extends Context.Reference<{
134 readonly clear: (environmentId: EnvironmentId) => Effect.Effect<void>;150 readonly clear: (environmentId: EnvironmentId) => Effect.Effect<void>;
135}>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", {151}>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", {