From 9151ea407accd1b7af575dcdd514e2b2e3fe6cbb Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 01:35:22 -0700 Subject: [PATCH] perf(clients): saving the thread list cache no longer freezes the UI (#13767) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/connection/environment-cache-store.ts | 19 +++++-- apps/web/src/connection/storage.test.ts | 23 ++++++++ apps/web/src/connection/storage.ts | 22 +++++--- .../src/platform/persistence.test.ts | 55 +++++++++++++++++++ .../src/platform/persistence.ts | 16 ++++++ 5 files changed, 122 insertions(+), 13 deletions(-) create mode 100644 packages/client-runtime/src/platform/persistence.test.ts diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 44a1d311453217f4c85e33572003c64ea0d5bb85..6e56524770b68d5735f093a21077c90f67e22550 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -1,6 +1,7 @@ import { ConnectionPersistenceError, EnvironmentCacheStore, + encodeShellSnapshotForCache, } from "@t3tools/client-runtime/platform"; import { type EnvironmentId, @@ -56,7 +57,6 @@ const StoredVcsRefs = Schema.Struct({ const decodeStoredShellSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(StoredShellSnapshot), ); -const encodeStoredShellSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredShellSnapshot)); const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(StoredThreadSnapshot), ); @@ -136,11 +136,18 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { - const payload = yield* encodeStoredShellSnapshot({ - schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, - environmentId, - snapshot, - }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); + const encodedSnapshot = yield* encodeShellSnapshotForCache(snapshot).pipe( + Effect.mapError((cause) => persistenceError("save-shell", cause)), + ); + const payload = yield* Effect.try({ + try: () => + JSON.stringify({ + schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + snapshot: encodedSnapshot, + } satisfies typeof StoredShellSnapshot.Encoded), + catch: (cause) => persistenceError("save-shell", cause), + }); yield* database .saveCache(environmentId, "shell", "snapshot", SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, payload) .pipe(Effect.mapError(mapDatabaseError("save-shell"))); diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index 773d20c3b8825c50aad3b8c4b3116611d4a1617f..206220768e9a75e68bb47f1daadd8f398d66a28b 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -87,6 +87,29 @@ describe("makeCatalogBackend", () => { expect(setConnectionCatalog).toHaveBeenCalledWith("{}"); }), ); + + it.effect("fails IndexedDB writes whose commit aborts", () => + Effect.gen(function* () { + vi.stubGlobal("window", {}); + const transaction = Object.assign(new EventTarget(), { + error: null as DOMException | null, + objectStore: () => ({ + put: () => { + // A failed commit aborts the transaction without an "error" event. + queueMicrotask(() => { + transaction.error = new DOMException("Quota exceeded", "QuotaExceededError"); + transaction.dispatchEvent(new Event("abort")); + }); + }, + }), + }); + const backend = makeCatalogBackend({ transaction: () => transaction } as never); + + const error = yield* backend.write("{}").pipe(Effect.flip); + + expect(error.message).toContain("QuotaExceededError"); + }), + ); }); describe("browser GitHub routing permissions", () => { diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 4c6287e1b7394f2e65ab0a70bb1d5c52fc56f6a7..ec2e4ea5175c0f5d7c14d69ef191c40e825734a7 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -6,6 +6,7 @@ import { ConnectionTargetStore, EMPTY_CONNECTION_CATALOG_DOCUMENT, EnvironmentCacheStore, + encodeShellSnapshotForCache, putRemoteDpopTokenInCatalog, registerConnectionInCatalog, removeCatalogValue, @@ -91,7 +92,6 @@ const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDoc const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson); -const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson); @@ -188,7 +188,9 @@ function writeDatabaseValue( ) { return Effect.callback((resume) => { const transaction = database.transaction(storeName, "readwrite"); - transaction.addEventListener("error", () => { + // Every failed write fires "abort". A failed commit, such as + // QuotaExceededError, fires only "abort" and no "error". + transaction.addEventListener("abort", () => { resume( Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")), ); @@ -606,11 +608,17 @@ export const connectionStorageLayer = Layer.effectContext( ), saveShell: (environmentId, snapshot) => Effect.gen(function* () { - const encoded = yield* encodeStoredShellSnapshot({ - schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, - environmentId, - snapshot, - }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); + const encodedSnapshot = yield* encodeShellSnapshotForCache(snapshot); + const encoded = yield* Effect.try({ + try: () => + // @effect-diagnostics-next-line preferSchemaOverJson:off - the snapshot is already encoded. + JSON.stringify({ + schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + snapshot: encodedSnapshot, + } satisfies typeof StoredShellSnapshot.Encoded), + catch: (cause) => persistenceError("save-shell", cause), + }); yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded); }).pipe( Effect.mapError((cause) => diff --git a/packages/client-runtime/src/platform/persistence.test.ts b/packages/client-runtime/src/platform/persistence.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e856e8479760bea28f70e03cb6f00939e1be68a --- /dev/null +++ b/packages/client-runtime/src/platform/persistence.test.ts @@ -0,0 +1,55 @@ +import { + OrchestrationProjectShell, + OrchestrationShellSnapshot, + OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Arr from "effect/Array"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary"; + +import { encodeShellSnapshotForCache } from "./persistence.ts"; + +// Generated values can hold untrimmed strings, which a decoded value never +// has. One encode and decode gives a value a client can hold; values that +// fail are dropped. Size 30 makes the generator fill optional fields. +const sampleDecoded = (schema: S) => + Effect.gen(function* () { + const encode = Schema.encodeEffect(schema); + const decode = Schema.decodeEffect(schema); + const generated = yield* Arbitrary.sampleEffect(Arbitrary.schema(schema), { + count: 1000, + size: 30, + }); + const decoded = yield* Effect.forEach(generated, (value) => + encode(value).pipe(Effect.flatMap(decode), Effect.option), + ); + return Arr.getSomes(decoded); + }); +const encodeSnapshot = Schema.encodeEffect(OrchestrationShellSnapshot); + +describe("encodeShellSnapshotForCache", () => { + it.effect("matches the Schema encoding of a generated snapshot", () => + Effect.gen(function* () { + const threads = yield* sampleDecoded(OrchestrationThreadShell); + const projects = yield* sampleDecoded(OrchestrationProjectShell); + const snapshot: OrchestrationShellSnapshot = { + snapshotSequence: 1, + // The generator rarely makes monogram icons, and they are the one + // project field whose encoding differs from the decoded value. + projects: projects.map((project, index) => + index % 2 === 0 + ? { ...project, projectIcon: { kind: "monogram", text: "T3", color: "blue" } } + : project, + ), + threads, + updatedAt: "2026-09-25T00:00:00.000Z", + }; + + expect(threads.length).toBeGreaterThan(0); + expect(projects.length).toBeGreaterThan(0); + expect(yield* encodeShellSnapshotForCache(snapshot)).toEqual(yield* encodeSnapshot(snapshot)); + }), + ); +}); diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 99469081dc10ea5a403a9602c2620acce23fc4d6..b2bc18222250ddec2310dab47e992cecab66cd7f 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -1,5 +1,6 @@ import { type EnvironmentId, + OrchestrationProjectShell, type OrchestrationShellSnapshot, type OrchestrationThreadDetailSnapshot, type ServerConfig, @@ -130,6 +131,21 @@ export class EnvironmentCacheStore extends Context.Service< } >()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {} +const encodeProjectShells = Schema.encodeEffect(Schema.Array(OrchestrationProjectShell)); + +/** + * Encodes a shell snapshot for `EnvironmentCacheStore.saveShell`. The result + * equals `Schema.encode(OrchestrationShellSnapshot)`, so the cache format does + * not change. Walking thousands of threads through Schema blocks the UI + * thread, and a decoded thread shell is already in its encoded form, so only + * the projects go through Schema: their icon has a real encode transform. + */ +export const encodeShellSnapshotForCache = (snapshot: OrchestrationShellSnapshot) => + Effect.map( + encodeProjectShells(snapshot.projects), + (projects) => ({ ...snapshot, projects }) satisfies typeof OrchestrationShellSnapshot.Encoded, + ); + export class EnvironmentOwnedDataCleanup extends Context.Reference<{ readonly clear: (environmentId: EnvironmentId) => Effect.Effect; }>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", { -- 2.54.0