From 98a484fa22f9acb5d3f21b70b8cc38c25e480ad3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 04:02:19 -0700 Subject: [PATCH 1/4] refactor(mobile): structure image prefetch failures Co-authored-by: codex --- .../files/workspace-file-image-cache.test.ts | 40 ++++++++++++-- .../files/workspace-file-image-cache.ts | 54 +++++++++++++------ 2 files changed, 75 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/features/files/workspace-file-image-cache.test.ts b/apps/mobile/src/features/files/workspace-file-image-cache.test.ts index 4acb67361a8..b1ab0843e46 100644 --- a/apps/mobile/src/features/files/workspace-file-image-cache.test.ts +++ b/apps/mobile/src/features/files/workspace-file-image-cache.test.ts @@ -1,8 +1,13 @@ +import * as Cause from "effect/Cause"; import { AtomRegistry } from "effect/unstable/reactivity"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { describe, expect, it, vi } from "vite-plus/test"; -import { createWorkspaceFileImageAtomFamily } from "./workspace-file-image-cache"; +import { + createWorkspaceFileImageAtomFamily, + WorkspaceImagePrefetchFailedError, + WorkspaceImagePrefetchUnavailableError, +} from "./workspace-file-image-cache"; describe("workspaceFileImageAtom", () => { it("reuses a prefetched image across route remounts", async () => { @@ -48,15 +53,44 @@ describe("workspaceFileImageAtom", () => { registry.dispose(); }); - it("exposes prefetch failures", async () => { + it("reports an unavailable image when prefetch completes without caching it", async () => { + const uri = "https://example.test/missing.png"; const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: async () => false }); const registry = AtomRegistry.make(); - const atom = imageAtom("https://example.test/missing.png"); + const atom = imageAtom(uri); const unmount = registry.mount(atom); await vi.waitFor(() => { expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); }); + const result = registry.get(atom); + if (AsyncResult.isFailure(result)) { + expect(Cause.squash(result.cause)).toEqual( + new WorkspaceImagePrefetchUnavailableError({ uri }), + ); + } + + unmount(); + registry.dispose(); + }); + + it("preserves rejected prefetch causes with the image URI", async () => { + const uri = "https://example.test/rejected.png"; + const cause = new Error("native image loader failed"); + const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: () => Promise.reject(cause) }); + const registry = AtomRegistry.make(); + const atom = imageAtom(uri); + const unmount = registry.mount(atom); + + await vi.waitFor(() => { + expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); + }); + const result = registry.get(atom); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + expect(error).toEqual(new WorkspaceImagePrefetchFailedError({ uri, cause })); + expect((error as WorkspaceImagePrefetchFailedError).cause).toBe(cause); + } unmount(); registry.dispose(); diff --git a/apps/mobile/src/features/files/workspace-file-image-cache.ts b/apps/mobile/src/features/files/workspace-file-image-cache.ts index 3f58f65b46c..b98104ac264 100644 --- a/apps/mobile/src/features/files/workspace-file-image-cache.ts +++ b/apps/mobile/src/features/files/workspace-file-image-cache.ts @@ -1,5 +1,6 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; const WORKSPACE_IMAGE_IDLE_TTL_MS = 30 * 60_000; @@ -8,10 +9,34 @@ type ImagePrefetch = (uri: string) => Promise; class WorkspaceImageCacheKey extends Data.Class<{ readonly uri: string }> {} -export class WorkspaceImagePrefetchError extends Data.TaggedError("WorkspaceImagePrefetchError")<{ - readonly cause?: unknown; - readonly uri: string; -}> {} +export class WorkspaceImagePrefetchUnavailableError extends Schema.TaggedErrorClass()( + "WorkspaceImagePrefetchUnavailableError", + { + uri: Schema.String, + }, +) { + override get message(): string { + return `Image prefetch did not cache ${this.uri}.`; + } +} + +export class WorkspaceImagePrefetchFailedError extends Schema.TaggedErrorClass()( + "WorkspaceImagePrefetchFailedError", + { + uri: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Image prefetch failed for ${this.uri}.`; + } +} + +export const WorkspaceImagePrefetchError = Schema.Union([ + WorkspaceImagePrefetchUnavailableError, + WorkspaceImagePrefetchFailedError, +]); +export type WorkspaceImagePrefetchError = typeof WorkspaceImagePrefetchError.Type; async function prefetchWithNativeImage(uri: string): Promise { const { Image } = await import("react-native"); @@ -26,18 +51,15 @@ export function createWorkspaceFileImageAtomFamily(options?: { const prefetch = options?.prefetch ?? prefetchWithNativeImage; const family = Atom.family((key: WorkspaceImageCacheKey) => Atom.make( - Effect.tryPromise({ - try: async () => { - const cached = await prefetch(key.uri); - if (!cached) { - throw new WorkspaceImagePrefetchError({ uri: key.uri }); - } - return key.uri; - }, - catch: (cause) => - cause instanceof WorkspaceImagePrefetchError - ? cause - : new WorkspaceImagePrefetchError({ uri: key.uri, cause }), + Effect.gen(function* () { + const cached = yield* Effect.tryPromise({ + try: () => prefetch(key.uri), + catch: (cause) => new WorkspaceImagePrefetchFailedError({ uri: key.uri, cause }), + }); + if (!cached) { + return yield* new WorkspaceImagePrefetchUnavailableError({ uri: key.uri }); + } + return key.uri; }), ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`mobile:workspace-image:${key.uri}`)), ); From 1cb49238239e0126fecf6cb303d02881394bb5bc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 04:04:24 -0700 Subject: [PATCH 2/4] refactor(mobile): structure source highlight failures Co-authored-by: codex --- .../files/sourceHighlightingState.test.ts | 21 +++++++++++++------ .../features/files/sourceHighlightingState.ts | 19 +++++++++++++---- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/files/sourceHighlightingState.test.ts b/apps/mobile/src/features/files/sourceHighlightingState.test.ts index 6c4c00e1663..23b5334d9b1 100644 --- a/apps/mobile/src/features/files/sourceHighlightingState.test.ts +++ b/apps/mobile/src/features/files/sourceHighlightingState.test.ts @@ -1,9 +1,11 @@ +import * as Cause from "effect/Cause"; import { AtomRegistry } from "effect/unstable/reactivity"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { createSourceHighlightAtomFamily, + SourceHighlightError, type SourceHighlightTokens, } from "./sourceHighlightingState"; @@ -100,22 +102,29 @@ describe("sourceHighlightingState", () => { registry.dispose(); }); - it("exposes highlighter errors as a failed async result", async () => { - const highlight = vi.fn(async () => { - throw new Error("highlight failed"); - }); + it("preserves highlighter failures with the source path and theme", async () => { + const cause = new Error("highlight failed"); + const highlight = vi.fn(() => Promise.reject(cause)); const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight }); const registry = AtomRegistry.make(); + const path = "src/example.ts"; + const theme = "light" as const; const atom = sourceHighlightAtom({ - path: "src/example.ts", + path, contents: "const value = 1;", - theme: "light", + theme, }); const unmount = registry.mount(atom); await vi.waitFor(() => { expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); }); + const result = registry.get(atom); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + expect(error).toEqual(new SourceHighlightError({ path, theme, cause })); + expect((error as SourceHighlightError).cause).toBe(cause); + } unmount(); registry.dispose(); diff --git a/apps/mobile/src/features/files/sourceHighlightingState.ts b/apps/mobile/src/features/files/sourceHighlightingState.ts index 43363115bc8..45506d1cfcf 100644 --- a/apps/mobile/src/features/files/sourceHighlightingState.ts +++ b/apps/mobile/src/features/files/sourceHighlightingState.ts @@ -1,5 +1,6 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; import { @@ -22,9 +23,18 @@ type SourceHighlighter = (input: SourceHighlightInput) => Promise {} -class SourceHighlightError extends Data.TaggedError("SourceHighlightError")<{ - readonly cause: unknown; -}> {} +export class SourceHighlightError extends Schema.TaggedErrorClass()( + "SourceHighlightError", + { + path: Schema.String, + theme: Schema.Literals(["light", "dark"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not highlight ${this.path} with the ${this.theme} theme.`; + } +} export function createSourceHighlightAtomFamily(options?: { readonly highlight?: SourceHighlighter; @@ -36,7 +46,8 @@ export function createSourceHighlightAtomFamily(options?: { Atom.make( Effect.tryPromise({ try: () => highlight(request), - catch: (cause) => new SourceHighlightError({ cause }), + catch: (cause) => + new SourceHighlightError({ path: request.path, theme: request.theme, cause }), }), ).pipe( Atom.setIdleTTL(idleTtlMs), From 8b17c80d717d8f9c930ea550751bcb31ac66b671 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 09:07:48 -0700 Subject: [PATCH 3/4] fix(mobile): redact signed image prefetch targets Co-authored-by: codex --- .../files/workspace-file-image-cache.test.ts | 34 ++++++++++++--- .../files/workspace-file-image-cache.ts | 43 ++++++++++++++----- 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/files/workspace-file-image-cache.test.ts b/apps/mobile/src/features/files/workspace-file-image-cache.test.ts index b1ab0843e46..3764d4b29d0 100644 --- a/apps/mobile/src/features/files/workspace-file-image-cache.test.ts +++ b/apps/mobile/src/features/files/workspace-file-image-cache.test.ts @@ -1,4 +1,5 @@ import * as Cause from "effect/Cause"; +import * as Hash from "effect/Hash"; import { AtomRegistry } from "effect/unstable/reactivity"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -54,10 +55,12 @@ describe("workspaceFileImageAtom", () => { }); it("reports an unavailable image when prefetch completes without caching it", async () => { - const uri = "https://example.test/missing.png"; + const uri = "https://example.test/api/assets/signed-secret-token/missing.png?signature=private"; const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: async () => false }); const registry = AtomRegistry.make(); const atom = imageAtom(uri); + expect(atom.label?.[0]).not.toContain("signed-secret-token"); + expect(atom.label?.[0]).not.toContain("signature=private"); const unmount = registry.mount(atom); await vi.waitFor(() => { @@ -65,17 +68,26 @@ describe("workspaceFileImageAtom", () => { }); const result = registry.get(atom); if (AsyncResult.isFailure(result)) { - expect(Cause.squash(result.cause)).toEqual( - new WorkspaceImagePrefetchUnavailableError({ uri }), + const error = Cause.squash(result.cause); + expect(error).toEqual( + new WorkspaceImagePrefetchUnavailableError({ + uriHash: Hash.hash(uri), + uriLength: uri.length, + uriProtocol: "https:", + }), ); + expect(error).not.toHaveProperty("uri"); + expect(String(error)).not.toContain("signed-secret-token"); + expect(String(error)).not.toContain("signature=private"); } unmount(); registry.dispose(); }); - it("preserves rejected prefetch causes with the image URI", async () => { - const uri = "https://example.test/rejected.png"; + it("preserves rejected prefetch causes without retaining the signed image URI", async () => { + const uri = + "https://example.test/api/assets/signed-secret-token/rejected.png?signature=private"; const cause = new Error("native image loader failed"); const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: () => Promise.reject(cause) }); const registry = AtomRegistry.make(); @@ -88,8 +100,18 @@ describe("workspaceFileImageAtom", () => { const result = registry.get(atom); if (AsyncResult.isFailure(result)) { const error = Cause.squash(result.cause); - expect(error).toEqual(new WorkspaceImagePrefetchFailedError({ uri, cause })); + expect(error).toEqual( + new WorkspaceImagePrefetchFailedError({ + uriHash: Hash.hash(uri), + uriLength: uri.length, + uriProtocol: "https:", + cause, + }), + ); expect((error as WorkspaceImagePrefetchFailedError).cause).toBe(cause); + expect(error).not.toHaveProperty("uri"); + expect(String(error)).not.toContain("signed-secret-token"); + expect(String(error)).not.toContain("signature=private"); } unmount(); diff --git a/apps/mobile/src/features/files/workspace-file-image-cache.ts b/apps/mobile/src/features/files/workspace-file-image-cache.ts index b98104ac264..a94d6655e70 100644 --- a/apps/mobile/src/features/files/workspace-file-image-cache.ts +++ b/apps/mobile/src/features/files/workspace-file-image-cache.ts @@ -1,5 +1,6 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Hash from "effect/Hash"; import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; @@ -12,23 +13,27 @@ class WorkspaceImageCacheKey extends Data.Class<{ readonly uri: string }> {} export class WorkspaceImagePrefetchUnavailableError extends Schema.TaggedErrorClass()( "WorkspaceImagePrefetchUnavailableError", { - uri: Schema.String, + uriHash: Schema.Number, + uriLength: Schema.Number, + uriProtocol: Schema.NullOr(Schema.String), }, ) { override get message(): string { - return `Image prefetch did not cache ${this.uri}.`; + return `Image prefetch did not cache the requested ${this.uriProtocol ?? "unknown-protocol"} resource (URI length ${this.uriLength}).`; } } export class WorkspaceImagePrefetchFailedError extends Schema.TaggedErrorClass()( "WorkspaceImagePrefetchFailedError", { - uri: Schema.String, + uriHash: Schema.Number, + uriLength: Schema.Number, + uriProtocol: Schema.NullOr(Schema.String), cause: Schema.Defect(), }, ) { override get message(): string { - return `Image prefetch failed for ${this.uri}.`; + return `Image prefetch failed for the requested ${this.uriProtocol ?? "unknown-protocol"} resource (URI length ${this.uriLength}).`; } } @@ -43,26 +48,44 @@ async function prefetchWithNativeImage(uri: string): Promise { return Image.prefetch(uri); } +function describeWorkspaceImageUri(uri: string) { + let uriProtocol: string | null = null; + try { + uriProtocol = new URL(uri).protocol || null; + } catch { + // Relative and malformed URIs still retain safe length/hash diagnostics. + } + return { + uriHash: Hash.hash(uri), + uriLength: uri.length, + uriProtocol, + }; +} + export function createWorkspaceFileImageAtomFamily(options?: { readonly idleTtlMs?: number; readonly prefetch?: ImagePrefetch; }) { const idleTtlMs = options?.idleTtlMs ?? WORKSPACE_IMAGE_IDLE_TTL_MS; const prefetch = options?.prefetch ?? prefetchWithNativeImage; - const family = Atom.family((key: WorkspaceImageCacheKey) => - Atom.make( + const family = Atom.family((key: WorkspaceImageCacheKey) => { + const uriContext = describeWorkspaceImageUri(key.uri); + return Atom.make( Effect.gen(function* () { const cached = yield* Effect.tryPromise({ try: () => prefetch(key.uri), - catch: (cause) => new WorkspaceImagePrefetchFailedError({ uri: key.uri, cause }), + catch: (cause) => new WorkspaceImagePrefetchFailedError({ ...uriContext, cause }), }); if (!cached) { - return yield* new WorkspaceImagePrefetchUnavailableError({ uri: key.uri }); + return yield* new WorkspaceImagePrefetchUnavailableError(uriContext); } return key.uri; }), - ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`mobile:workspace-image:${key.uri}`)), - ); + ).pipe( + Atom.setIdleTTL(idleTtlMs), + Atom.withLabel(`mobile:workspace-image:${uriContext.uriHash.toString(36)}`), + ); + }); return (uri: string) => family(new WorkspaceImageCacheKey({ uri })); } From e0369ec2043c7c79b7d8ad4f22f2283734778394 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 09:18:11 -0700 Subject: [PATCH 4/4] Reuse shared URL diagnostics Co-authored-by: codex --- .../src/features/files/workspace-file-image-cache.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/files/workspace-file-image-cache.ts b/apps/mobile/src/features/files/workspace-file-image-cache.ts index a94d6655e70..9251f57185c 100644 --- a/apps/mobile/src/features/files/workspace-file-image-cache.ts +++ b/apps/mobile/src/features/files/workspace-file-image-cache.ts @@ -1,3 +1,4 @@ +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Hash from "effect/Hash"; @@ -49,16 +50,11 @@ async function prefetchWithNativeImage(uri: string): Promise { } function describeWorkspaceImageUri(uri: string) { - let uriProtocol: string | null = null; - try { - uriProtocol = new URL(uri).protocol || null; - } catch { - // Relative and malformed URIs still retain safe length/hash diagnostics. - } + const diagnostics = getUrlDiagnostics(uri); return { uriHash: Hash.hash(uri), - uriLength: uri.length, - uriProtocol, + uriLength: diagnostics.inputLength, + uriProtocol: diagnostics.protocol ?? null, }; }