Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function PullRequestInlineCommentScreen({ route }: StaticScreenProps<Inli
const body = editedBody ?? saved?.body ?? "";
const detail = useEnvironmentQuery(composerPullRequests.detail(pullRequestTarget(params)));
const { refresh: refreshDetail } = detail;
useFocusEffect(() => refreshDetail());
useFocusEffect(refreshDetail);
const permissions = usePullRequestPermissions(params, detail.data);
const stale = detail.data && detail.data.headSha !== params.headSha;
const pending = useRef(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
const result = useEnvironmentQuery(detailAtom);
const pullRefresh = usePullToRefresh(detailAtom);
const { refresh: refreshDetail } = result;
useFocusEffect(() => refreshDetail());
useFocusEffect(refreshDetail);
const pr = usePullRequestLiveChecks(params, result.data);
const summary = params.initialSummary;
const identity = pr ?? summary;
Expand Down Expand Up @@ -893,7 +893,7 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) {
route.params.focus ? route.params.path : undefined,
);
const { refresh: refreshDetail } = detail;
useFocusEffect(() => refreshDetail());
useFocusEffect(refreshDetail);
const { loadMore } = result;
const parsed = filter.parsed;
const reviewDraft = usePullRequestReviewDraft(route.params);
Expand Down
190 changes: 190 additions & 0 deletions apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
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(() => ({
query: {
data: null as { run: { _tag: "Running" | "Completed" } } | 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 () => {
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: true } : true),
}));
vi.mock("../../../state/server", () => ({
serverEnvironment: { settingsValueAtom: () => "settings" },
}));
vi.mock("../../../state/pull-requests", () => ({
composerPullRequests: {
reviewGuide: (target: { number: number; environmentId: string }) =>
target.environmentId === "environment" && target.number === 1 ? 1 : 2,
generateReviewGuide: { permissionAtom: () => "permission" },
},
}));
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", () => ({
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<ParamListBase>,
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.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 = {
environmentId: "environment",
projectId: "project",
repository: "repo",
number: 1,
};
function Probe({ number, environmentId }: { number: number; environmentId: string }) {
useReviewGuide({ ...params, number, environmentId });
return null;
}
return {
render(number = 1, environmentId = params.environmentId) {
act(() =>
root.render(
<NavigationContext.Provider value={navigation.value}>
<Probe number={number} environmentId={environmentId} />
</NavigationContext.Provider>,
),
);
},
focus: navigation.focus,
[Symbol.dispose]() {
act(() => root.unmount());
},
};
}

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 = { ...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);
expect(mocks.query.refresh).toHaveBeenCalledTimes(2);

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("preserves the polling deadline across rerenders and stops polling 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);
act(() => vi.advanceTimersByTime(10_000));
expect(mocks.query.refresh).toHaveBeenCalledTimes(2);
screen.focus(true);
expect(mocks.query.refresh).toHaveBeenCalledTimes(3);
act(() => vi.advanceTimersByTime(5_000));
expect(mocks.query.refresh).toHaveBeenCalledTimes(4);

mocks.query = { ...mocks.query, isPending: false, data: { run: { _tag: "Completed" } } };
screen.render();
const refreshesOnCompletion = mocks.query.refresh.mock.calls.length;
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);
});
53 changes: 52 additions & 1 deletion apps/mobile/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { defineConfig } from "vite-plus";
import * as NodeModule from "node:module";
import * as NodeURL from "node:url";
import { defineConfig, normalizePath } from "vite-plus";
import "vite-plus/test/config";

const reactTests = [
Expand All @@ -8,6 +10,7 @@ const reactTests = [
"src/features/diffs/nativeReviewDiffSurface.test.tsx",
"src/features/pull-requests/usePullRequestDiff.test.tsx",
"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",
Expand All @@ -17,6 +20,16 @@ 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: (
code: string,
options: Record<string, unknown>,
) => 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,
Expand All @@ -36,6 +49,44 @@ export default defineConfig({
},
{
extends: true,
plugins: [
{
name: "mobile-release-react-compiler",
enforce: "pre",
async transform(code, id) {
if (
!normalizePath(id).startsWith(sourceRoot) ||
!/\.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,
Expand Down
11 changes: 4 additions & 7 deletions infra/task-cache/src/Cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -89,12 +88,10 @@ it.effect("cleans three full disjoint batches within the D1 invocation budget",
},
} as unknown as Parameters<typeof cleanup>[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);
Expand Down
Loading