From 7e3afee492ffe7440f83c494754c98d3ab9da210 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 23 Oct 2026 16:32:37 -0700 Subject: [PATCH 1/6] fix(mobile): keep PR focus refresh callbacks stable --- .../PullRequestInlineCommentScreen.tsx | 2 +- .../pull-requests/PullRequestReviewSheet.tsx | 2 +- .../pull-requests/PullRequestRouteScreen.tsx | 4 +- .../guide/useReviewGuide.test.tsx | 160 ++++++++++++++++++ .../pull-requests/guide/useReviewGuide.ts | 18 +- apps/mobile/vite.config.ts | 1 + 6 files changed, 175 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx diff --git a/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx index 7a8152e01dc09..d0b7a5ae88019 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx @@ -33,7 +33,7 @@ export function PullRequestInlineCommentScreen({ route }: StaticScreenProps refreshDetail()); + useFocusEffect(refreshDetail); const permissions = usePullRequestPermissions(params, detail.data); const stale = detail.data && detail.data.headSha !== params.headSha; const pending = useRef(false); diff --git a/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx b/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx index 1758c8f57f559..3f79090e44efa 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx @@ -68,7 +68,7 @@ export function PullRequestReviewSheet({ const target = pullRequestTarget(params); const result = useEnvironmentQuery(composerPullRequests.detail(target)); const { refresh: refreshDetail } = result; - useFocusEffect(() => refreshDetail()); + useFocusEffect(refreshDetail); const permissions = usePullRequestPermissions(params, result.data); const submission = useActionSubmission(params); const review = useAtomCommand(composerPullRequests.submitReview, { reportFailure: false }); diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index a513fd424ced1..790d060215f04 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -344,7 +344,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps refreshDetail()); + useFocusEffect(refreshDetail); const pr = result.data; const summary = params.initialSummary; const identity = pr ?? summary; @@ -778,7 +778,7 @@ export function PullRequestFileRouteScreen({ ); const detail = useEnvironmentQuery(composerPullRequests.detail(requestTarget(route.params))); const { refresh: refreshDetail } = detail; - useFocusEffect(() => refreshDetail()); + useFocusEffect(refreshDetail); const { loadMore } = result; const parsed = filter.parsed; const reviewDraft = usePullRequestReviewDraft(route.params); diff --git a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx new file mode 100644 index 0000000000000..28a303c718360 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx @@ -0,0 +1,160 @@ +import { + NavigationContext, + type NavigationProp, + type ParamListBase, +} from "@react-navigation/native"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + enabled: true, + query: { + data: null as { run: { _tag: "Running" | "Completed" } } | null, + isPending: false, + refresh: vi.fn<() => void>(), + }, +})); + +vi.mock("@react-navigation/native", async () => { + const { createRequire } = await import("node:module"); + const { realpathSync } = await import("node:fs"); + const require = createRequire(import.meta.url); + const nativeRequire = createRequire( + realpathSync(require.resolve("@react-navigation/native/package.json")), + ); + // Use the shipped focus hook without importing the React Native view layer. + const { NavigationContext, useFocusEffect }: typeof import("@react-navigation/native") = + nativeRequire("@react-navigation/core"); + return { NavigationContext, useFocusEffect }; +}); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: unknown) => + atom === "settings" ? { reviewGuidesEnabled: mocks.enabled } : true, +})); +vi.mock("../../../state/server", () => ({ + serverEnvironment: { settingsValueAtom: () => "settings" }, +})); +vi.mock("../../../state/pull-requests", () => ({ + composerPullRequests: { + reviewGuide: () => "guide", + generateReviewGuide: { permissionAtom: () => "permission" }, + }, +})); +vi.mock("../../../state/query", () => ({ useEnvironmentQuery: () => ({ ...mocks.query }) })); +vi.mock("../../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../usePullRequestConnected", () => ({ usePullRequestConnected: () => true })); +vi.mock("../usePullRequestDiff", () => ({ + pullRequestTarget: (params: unknown) => params, +})); + +import { useReviewGuide } from "./useReviewGuide"; + +function createNavigation() { + const listeners = { focus: new Set<() => void>(), blur: new Set<() => void>() }; + let focused = true; + return { + value: { + isFocused: () => focused, + addListener: (event: keyof typeof listeners, listener: () => void) => { + listeners[event].add(listener); + return () => listeners[event].delete(listener); + }, + } as unknown as NavigationProp, + focus(next: boolean) { + focused = next; + act(() => { + for (const listener of listeners[next ? "focus" : "blur"]) listener(); + }); + }, + }; +} + +function setup() { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mocks.enabled = true; + mocks.query = { data: null, isPending: false, refresh: vi.fn() }; + const root = createRoot(document.createElement("div")); + const navigation = createNavigation(); + const params = { + environmentId: "environment", + projectId: "project", + repository: "repo", + number: 1, + }; + function Probe() { + useReviewGuide(params); + return null; + } + return { + render() { + act(() => + root.render( + + + , + ), + ); + }, + focus: navigation.focus, + [Symbol.dispose]() { + act(() => root.unmount()); + }, + }; +} + +it("refreshes once per focus across pending and result rerenders", () => { + using screen = setup(); + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + + mocks.query.isPending = true; + screen.render(); + mocks.query = { ...mocks.query, isPending: false, data: { run: { _tag: "Completed" } } }; + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + + screen.focus(false); + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + screen.focus(true); + screen.focus(true); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); + + mocks.query.refresh = vi.fn(); + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); +}); + +it("keeps running-guide polling through result rerenders and stops on blur or completion", () => { + vi.useFakeTimers(); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + using screen = setup(); + mocks.query.data = { run: { _tag: "Running" } }; + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + + act(() => vi.advanceTimersByTime(4_000)); + mocks.query = { ...mocks.query, isPending: true, data: { run: { _tag: "Running" } } }; + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(1_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); + + screen.focus(false); + expect(vi.getTimerCount()).toBe(0); + screen.focus(true); + expect(mocks.query.refresh).toHaveBeenCalledTimes(3); + expect(vi.getTimerCount()).toBe(1); + + mocks.query = { ...mocks.query, isPending: false, data: { run: { _tag: "Completed" } } }; + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(4); + expect(vi.getTimerCount()).toBe(0); + + mocks.enabled = false; + screen.render(); + screen.focus(false); + screen.focus(true); + expect(mocks.query.refresh).toHaveBeenCalledTimes(4); +}); diff --git a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts index 5dad083e76108..c290ea85a87c3 100644 --- a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts +++ b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; -import { useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { usePullRequestConnected } from "../usePullRequestConnected"; import { composerPullRequests } from "../../../state/pull-requests"; import { useEnvironmentQuery } from "../../../state/query"; @@ -34,13 +34,15 @@ export function useReviewGuide(params: PullRequestParams) { const pending = useRef(false); const running = starting || query.data?.run?._tag === "Running"; const refresh = query.refresh; - useFocusEffect(() => { - if (!enabled) return; - refresh(); - if (!running) return; - const timer = setInterval(refresh, 5_000); - return () => clearInterval(timer); - }); + useFocusEffect( + useCallback(() => { + if (!enabled) return; + refresh(); + if (!running) return; + const timer = setInterval(refresh, 5_000); + return () => clearInterval(timer); + }, [enabled, refresh, running]), + ); const write = async () => { if (!enabled || !canWrite || running || pending.current) return; pending.current = true; diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 640deb7f6bc27..d091306e5e2d5 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -3,6 +3,7 @@ import "vite-plus/test/config"; const reactTests = [ "src/components/ui/Dialog.android.test.tsx", + "src/features/pull-requests/guide/useReviewGuide.test.tsx", "src/features/settings/SettingsServerControlsRouteScreen.test.tsx", "src/features/settings/components/BranchNamingSettings.test.tsx", "src/native/NativeHeaderToolbar.test.tsx", From 05e08334b099ba57b3583182ddea47c79a4d9bec Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 09:24:38 +0000 Subject: [PATCH 2/6] test(mobile): verify PR refresh and polling lifecycle --- .../guide/useReviewGuide.test.tsx | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx index 28a303c718360..00eef64d5b653 100644 --- a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx +++ b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx @@ -8,12 +8,12 @@ import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ - enabled: true, query: { data: null as { run: { _tag: "Running" | "Completed" } } | null, isPending: false, refresh: vi.fn<() => void>(), }, + otherQuery: { data: null, isPending: false, refresh: vi.fn<() => void>() }, })); vi.mock("@react-navigation/native", async () => { @@ -29,19 +29,20 @@ vi.mock("@react-navigation/native", async () => { return { NavigationContext, useFocusEffect }; }); vi.mock("@effect/atom-react", () => ({ - useAtomValue: (atom: unknown) => - atom === "settings" ? { reviewGuidesEnabled: mocks.enabled } : true, + useAtomValue: (atom: unknown) => (atom === "settings" ? { reviewGuidesEnabled: true } : true), })); vi.mock("../../../state/server", () => ({ serverEnvironment: { settingsValueAtom: () => "settings" }, })); vi.mock("../../../state/pull-requests", () => ({ composerPullRequests: { - reviewGuide: () => "guide", + reviewGuide: (target: { number: number }) => target.number, generateReviewGuide: { permissionAtom: () => "permission" }, }, })); -vi.mock("../../../state/query", () => ({ useEnvironmentQuery: () => ({ ...mocks.query }) })); +vi.mock("../../../state/query", () => ({ + useEnvironmentQuery: (number: number) => ({ ...(number === 1 ? mocks.query : mocks.otherQuery) }), +})); vi.mock("../../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); vi.mock("../usePullRequestConnected", () => ({ usePullRequestConnected: () => true })); vi.mock("../usePullRequestDiff", () => ({ @@ -72,8 +73,8 @@ function createNavigation() { function setup() { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - mocks.enabled = true; mocks.query = { data: null, isPending: false, refresh: vi.fn() }; + mocks.otherQuery = { data: null, isPending: false, refresh: vi.fn() }; const root = createRoot(document.createElement("div")); const navigation = createNavigation(); const params = { @@ -82,16 +83,16 @@ function setup() { repository: "repo", number: 1, }; - function Probe() { - useReviewGuide(params); + function Probe({ number }: { number: number }) { + useReviewGuide({ ...params, number }); return null; } return { - render() { + render(number = 1) { act(() => root.render( - + , ), ); @@ -103,12 +104,12 @@ function setup() { }; } -it("refreshes once per focus across pending and result rerenders", () => { +it("refreshes on focus and PR changes without refreshing again for query rerenders", () => { using screen = setup(); screen.render(); expect(mocks.query.refresh).toHaveBeenCalledTimes(1); - mocks.query.isPending = true; + mocks.query = { ...mocks.query, isPending: true }; screen.render(); mocks.query = { ...mocks.query, isPending: false, data: { run: { _tag: "Completed" } } }; screen.render(); @@ -118,15 +119,16 @@ it("refreshes once per focus across pending and result rerenders", () => { screen.render(); expect(mocks.query.refresh).toHaveBeenCalledTimes(1); screen.focus(true); - screen.focus(true); expect(mocks.query.refresh).toHaveBeenCalledTimes(2); - mocks.query.refresh = vi.fn(); - screen.render(); - expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + screen.render(2); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(1); + screen.render(2); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(1); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); }); -it("keeps running-guide polling through result rerenders and stops on blur or completion", () => { +it("preserves the polling deadline across rerenders and stops polling on blur or completion", () => { vi.useFakeTimers(); using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; using screen = setup(); @@ -142,19 +144,16 @@ it("keeps running-guide polling through result rerenders and stops on blur or co expect(mocks.query.refresh).toHaveBeenCalledTimes(2); screen.focus(false); - expect(vi.getTimerCount()).toBe(0); + act(() => vi.advanceTimersByTime(10_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); screen.focus(true); expect(mocks.query.refresh).toHaveBeenCalledTimes(3); - expect(vi.getTimerCount()).toBe(1); - - mocks.query = { ...mocks.query, isPending: false, data: { run: { _tag: "Completed" } } }; - screen.render(); + act(() => vi.advanceTimersByTime(5_000)); expect(mocks.query.refresh).toHaveBeenCalledTimes(4); - expect(vi.getTimerCount()).toBe(0); - mocks.enabled = false; + mocks.query = { ...mocks.query, isPending: false, data: { run: { _tag: "Completed" } } }; screen.render(); - screen.focus(false); - screen.focus(true); - expect(mocks.query.refresh).toHaveBeenCalledTimes(4); + const refreshesOnCompletion = mocks.query.refresh.mock.calls.length; + act(() => vi.advanceTimersByTime(10_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(refreshesOnCompletion); }); From d558268bc2b1c092b80c574af28831199db339dd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 09:27:57 +0000 Subject: [PATCH 3/6] test(mobile): cover review guide environment polling cleanup --- .../guide/useReviewGuide.test.tsx | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx index 00eef64d5b653..764cd5f881ac9 100644 --- a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx +++ b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx @@ -13,7 +13,11 @@ const mocks = vi.hoisted(() => ({ isPending: false, refresh: vi.fn<() => void>(), }, - otherQuery: { data: null, isPending: false, refresh: vi.fn<() => void>() }, + otherQuery: { + data: null as { run: { _tag: "Running" | "Completed" } } | null, + isPending: false, + refresh: vi.fn<() => void>(), + }, })); vi.mock("@react-navigation/native", async () => { @@ -36,7 +40,8 @@ vi.mock("../../../state/server", () => ({ })); vi.mock("../../../state/pull-requests", () => ({ composerPullRequests: { - reviewGuide: (target: { number: number }) => target.number, + reviewGuide: (target: { number: number; environmentId: string }) => + target.environmentId === "environment" && target.number === 1 ? 1 : 2, generateReviewGuide: { permissionAtom: () => "permission" }, }, })); @@ -83,16 +88,16 @@ function setup() { repository: "repo", number: 1, }; - function Probe({ number }: { number: number }) { - useReviewGuide({ ...params, number }); + function Probe({ number, environmentId }: { number: number; environmentId: string }) { + useReviewGuide({ ...params, number, environmentId }); return null; } return { - render(number = 1) { + render(number = 1, environmentId = params.environmentId) { act(() => root.render( - + , ), ); @@ -157,3 +162,29 @@ it("preserves the polling deadline across rerenders and stops polling on blur or act(() => vi.advanceTimersByTime(10_000)); expect(mocks.query.refresh).toHaveBeenCalledTimes(refreshesOnCompletion); }); + +it("retires the old environment's polling on target change and clears polling on unmount", () => { + vi.useFakeTimers(); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + { + using screen = setup(); + mocks.query.data = { run: { _tag: "Running" } }; + mocks.otherQuery.data = { run: { _tag: "Running" } }; + screen.render(); + act(() => vi.advanceTimersByTime(4_000)); + + screen.render(1, "other-environment"); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(1_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(4_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(2); + } + act(() => vi.advanceTimersByTime(10_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); +}); From bd728da283555196e2c6cf6a8869c88a27f35e46 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 17:21:45 -0700 Subject: [PATCH 4/6] test(task-cache): satisfy single-service provision lint --- infra/task-cache/src/Cleanup.test.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/infra/task-cache/src/Cleanup.test.ts b/infra/task-cache/src/Cleanup.test.ts index b68cba81be7fc..2e4953068ca44 100644 --- a/infra/task-cache/src/Cleanup.test.ts +++ b/infra/task-cache/src/Cleanup.test.ts @@ -2,7 +2,6 @@ import * as NodeFS from "node:fs"; import * as NodeSqlite from "node:sqlite"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import { expect, it } from "@effect/vitest"; import { cleanup } from "../vendor/vite-plus-remote-cache-cloudflare/src/database.ts"; import * as Cache from "./Cache.ts"; @@ -89,12 +88,10 @@ it.effect("cleans three full disjoint batches within the D1 invocation budget", }, } as unknown as Parameters[0]; yield* Cleanup.run().pipe( - Effect.provide( - Layer.succeed(Cache.Cache, { - handle: () => Effect.die("unused"), - cleanup: Effect.promise(() => cleanup(env)), - }), - ), + Effect.provideService(Cache.Cache, { + handle: () => Effect.die("unused"), + cleanup: Effect.promise(() => cleanup(env)), + }), ); expect(claims.map((claim) => claim.length)).toEqual([256, 256, 256]); expect(new Set(claims.flat()).size).toBe(768); From 414b226253122eee9ad59d4ec80e36f965723afd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 23:38:13 -0700 Subject: [PATCH 5/6] test(mobile): run React hooks through the release compiler --- .../pull-requests/guide/useReviewGuide.ts | 18 ++++--- apps/mobile/vite.config.ts | 48 +++++++++++++++++++ 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts index c290ea85a87c3..5dad083e76108 100644 --- a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts +++ b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; -import { useCallback, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { usePullRequestConnected } from "../usePullRequestConnected"; import { composerPullRequests } from "../../../state/pull-requests"; import { useEnvironmentQuery } from "../../../state/query"; @@ -34,15 +34,13 @@ export function useReviewGuide(params: PullRequestParams) { const pending = useRef(false); const running = starting || query.data?.run?._tag === "Running"; const refresh = query.refresh; - useFocusEffect( - useCallback(() => { - if (!enabled) return; - refresh(); - if (!running) return; - const timer = setInterval(refresh, 5_000); - return () => clearInterval(timer); - }, [enabled, refresh, running]), - ); + useFocusEffect(() => { + if (!enabled) return; + refresh(); + if (!running) return; + const timer = setInterval(refresh, 5_000); + return () => clearInterval(timer); + }); const write = async () => { if (!enabled || !canWrite || running || pending.current) return; pending.current = true; diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 06fbea098ac0a..2eb88ed22a396 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -1,3 +1,4 @@ +import * as NodeModule from "node:module"; import { defineConfig } from "vite-plus"; import "vite-plus/test/config"; @@ -18,6 +19,15 @@ const reactTests = [ "src/state/use-selected-thread-git-actions.test.tsx", ]; +const require = NodeModule.createRequire(import.meta.url); +const babel: { + transformAsync: ( + code: string, + options: Record, + ) => Promise<{ code?: string | null; map?: object | null } | null>; +} = require("@babel/core"); +const babelConfig = require("./babel.config.js")({ cache: () => {} }); + export default defineConfig({ test: { hookTimeout: 60_000, @@ -37,6 +47,44 @@ export default defineConfig({ }, { extends: true, + plugins: [ + { + name: "mobile-release-react-compiler", + enforce: "pre", + async transform(code, id) { + if ( + !id.startsWith(new URL("./src/", import.meta.url).pathname) || + !/\.tsx?$/.test(id) || + /\.test\.tsx?$/.test(id) + ) { + return; + } + const result = await babel.transformAsync(code, { + ...babelConfig, + presets: babelConfig.presets.map(([name, options]: [string, object]) => [ + require.resolve(name), + // Vite runs in Node; inline Babel helpers instead of Metro runtime imports. + { ...options, enableBabelRuntime: false }, + ]), + filename: id, + babelrc: false, + configFile: false, + sourceMaps: true, + caller: { + name: "metro", + bundler: "metro", + platform: "ios", + isDev: false, + isServer: false, + isNodeModule: false, + supportsStaticESM: true, + supportsReactCompiler: true, + }, + }); + if (result?.code) return { code: result.code, map: result.map }; + }, + }, + ], test: { name: "react", include: reactTests, From 0a43bc0013eea3dd6eb62bf4378a931f6d438e58 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 23:44:48 -0700 Subject: [PATCH 6/6] fix(mobile): normalize compiler test source paths --- apps/mobile/vite.config.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 2eb88ed22a396..367aaa4a2391c 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -1,5 +1,6 @@ import * as NodeModule from "node:module"; -import { defineConfig } from "vite-plus"; +import * as NodeURL from "node:url"; +import { defineConfig, normalizePath } from "vite-plus"; import "vite-plus/test/config"; const reactTests = [ @@ -19,6 +20,7 @@ const reactTests = [ "src/state/use-selected-thread-git-actions.test.tsx", ]; +const sourceRoot = `${normalizePath(NodeURL.fileURLToPath(new URL("./src", import.meta.url)))}/`; const require = NodeModule.createRequire(import.meta.url); const babel: { transformAsync: ( @@ -53,7 +55,7 @@ export default defineConfig({ enforce: "pre", async transform(code, id) { if ( - !id.startsWith(new URL("./src/", import.meta.url).pathname) || + !normalizePath(id).startsWith(sourceRoot) || !/\.tsx?$/.test(id) || /\.test\.tsx?$/.test(id) ) {