From a2b6fd827426f11904762d817e1107f80731c131 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 08:17:23 -0700 Subject: [PATCH 1/2] Structure mobile file rendering errors Co-authored-by: codex --- .../files/sourceHighlightingState.test.ts | 19 +++++++++++--- .../features/files/sourceHighlightingState.ts | 21 ++++++++++++--- .../files/workspace-file-image-cache.test.ts | 26 ++++++++++++++++--- .../files/workspace-file-image-cache.ts | 20 ++++++++++---- 4 files changed, 70 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/files/sourceHighlightingState.test.ts b/apps/mobile/src/features/files/sourceHighlightingState.test.ts index 6c4c00e1663..184d5761db9 100644 --- a/apps/mobile/src/features/files/sourceHighlightingState.test.ts +++ b/apps/mobile/src/features/files/sourceHighlightingState.test.ts @@ -1,9 +1,12 @@ +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; 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, + isSourceHighlightError, type SourceHighlightTokens, } from "./sourceHighlightingState"; @@ -101,8 +104,9 @@ describe("sourceHighlightingState", () => { }); it("exposes highlighter errors as a failed async result", async () => { + const cause = new Error("highlight failed"); const highlight = vi.fn(async () => { - throw new Error("highlight failed"); + throw cause; }); const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight }); const registry = AtomRegistry.make(); @@ -113,8 +117,17 @@ describe("sourceHighlightingState", () => { }); const unmount = registry.mount(atom); - await vi.waitFor(() => { - expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); + await vi.waitFor(() => expect(AsyncResult.isFailure(registry.get(atom))).toBe(true)); + const result = registry.get(atom); + const error = AsyncResult.isFailure(result) + ? Option.getOrUndefined(Cause.findErrorOption(result.cause)) + : undefined; + + expect(isSourceHighlightError(error)).toBe(true); + expect(error).toMatchObject({ + path: "src/example.ts", + theme: "light", + cause, }); unmount(); diff --git a/apps/mobile/src/features/files/sourceHighlightingState.ts b/apps/mobile/src/features/files/sourceHighlightingState.ts index 43363115bc8..18090441770 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,20 @@ 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 `Failed to highlight ${this.path} with the ${this.theme} theme.`; + } +} + +export const isSourceHighlightError = Schema.is(SourceHighlightError); export function createSourceHighlightAtomFamily(options?: { readonly highlight?: SourceHighlighter; @@ -36,7 +48,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), 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..9740a4595e5 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 * as Option from "effect/Option"; 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, + isWorkspaceImagePrefetchError, +} from "./workspace-file-image-cache"; describe("workspaceFileImageAtom", () => { it("reuses a prefetched image across route remounts", async () => { @@ -49,13 +54,26 @@ describe("workspaceFileImageAtom", () => { }); it("exposes prefetch failures", async () => { - const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: async () => false }); + const cause = new Error("native image prefetch failed"); + const imageAtom = createWorkspaceFileImageAtomFamily({ + prefetch: async () => { + throw cause; + }, + }); const registry = AtomRegistry.make(); const atom = imageAtom("https://example.test/missing.png"); const unmount = registry.mount(atom); - await vi.waitFor(() => { - expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); + await vi.waitFor(() => expect(AsyncResult.isFailure(registry.get(atom))).toBe(true)); + const result = registry.get(atom); + const error = AsyncResult.isFailure(result) + ? Option.getOrUndefined(Cause.findErrorOption(result.cause)) + : undefined; + + expect(isWorkspaceImagePrefetchError(error)).toBe(true); + expect(error).toMatchObject({ + uri: "https://example.test/missing.png", + cause, }); 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 3f58f65b46c..6e4f16fb59e 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,19 @@ 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 WorkspaceImagePrefetchError extends Schema.TaggedErrorClass()( + "WorkspaceImagePrefetchError", + { + uri: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Failed to prefetch workspace image ${this.uri}.`; + } +} + +export const isWorkspaceImagePrefetchError = Schema.is(WorkspaceImagePrefetchError); async function prefetchWithNativeImage(uri: string): Promise { const { Image } = await import("react-native"); @@ -35,7 +45,7 @@ export function createWorkspaceFileImageAtomFamily(options?: { return key.uri; }, catch: (cause) => - cause instanceof WorkspaceImagePrefetchError + isWorkspaceImagePrefetchError(cause) ? cause : new WorkspaceImagePrefetchError({ uri: key.uri, cause }), }), From 1cb9811e93380fe5489e3dc0d4d89582fb5f6cf1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 08:25:47 -0700 Subject: [PATCH 2/2] test(mobile): retain false prefetch coverage Co-authored-by: codex --- .../files/workspace-file-image-cache.test.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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 9740a4595e5..22948da97c4 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 @@ -53,7 +53,27 @@ describe("workspaceFileImageAtom", () => { registry.dispose(); }); - it("exposes prefetch failures", async () => { + it("exposes a false native prefetch result", async () => { + const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: async () => false }); + const registry = AtomRegistry.make(); + const atom = imageAtom("https://example.test/missing.png"); + const unmount = registry.mount(atom); + + await vi.waitFor(() => expect(AsyncResult.isFailure(registry.get(atom))).toBe(true)); + const result = registry.get(atom); + const error = AsyncResult.isFailure(result) + ? Option.getOrUndefined(Cause.findErrorOption(result.cause)) + : undefined; + + expect(isWorkspaceImagePrefetchError(error)).toBe(true); + expect(error).toMatchObject({ uri: "https://example.test/missing.png" }); + expect(error).not.toHaveProperty("cause"); + + unmount(); + registry.dispose(); + }); + + it("preserves thrown prefetch causes", async () => { const cause = new Error("native image prefetch failed"); const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: async () => {