From c859b59c99da4eb3d3354d2c98bf0b92b915e4a8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 17 Aug 2026 23:43:01 +0200 Subject: [PATCH] fix(app): dismiss the composer track panel when a row is chosen Track pills became menu surfaces when the trackers moved out of stacked cards, but ComposerTrackRow kept the raw Pressable it had as an inline card row. Nothing in that path reaches selectItem, so the popover or sheet stayed parked over the transcript after opening a subagent, and on iOS the action fired while UIKit was still tearing the sheet down. Rows now hand the press to the engine and take closeOnSelect, as MenuItem does; bulk archive opts out because its progress lands in the panel. The running mark is the shared StatusRing rather than a still dot the pill drew for itself, and the remaining dots read their colour from the one bucket-to-colour map. The slot is sized for the ring in every state so the label does not step sideways when the last child finishes. Mounting the real menu engine in a test needed harness work: stubs for two packages that ship untranspiled Flow, reanimated left out of Vite's prebundle so its .web files win, and the gaps filled in the unistyles test theme. --- docs/menus.md | 6 + .../browser/provider-subagents.real.spec.ts | 3 + .../composer/tracks-panel.browser.test.tsx | 183 ++++++++++++++++++ packages/app/src/composer/tracks.test.tsx | 3 + packages/app/src/composer/tracks.tsx | 171 +++++++++------- packages/app/src/subagents/track.tsx | 3 + .../app/test-stubs/gorhom-bottom-sheet.ts | 13 ++ .../app/test-stubs/lucide-react-native.ts | 1 + .../test-stubs/react-native-fabric-shim.ts | 8 + .../react-native-safe-area-context.ts | 12 ++ .../app/test-stubs/react-native-unistyles.ts | 28 ++- .../reanimated-validate-worklets-version.ts | 3 + packages/app/vitest.config.ts | 35 ++++ 13 files changed, 396 insertions(+), 73 deletions(-) create mode 100644 packages/app/src/composer/tracks-panel.browser.test.tsx create mode 100644 packages/app/test-stubs/gorhom-bottom-sheet.ts create mode 100644 packages/app/test-stubs/react-native-fabric-shim.ts create mode 100644 packages/app/test-stubs/react-native-safe-area-context.ts create mode 100644 packages/app/test-stubs/reanimated-validate-worklets-version.ts diff --git a/docs/menus.md b/docs/menus.md index 580762d17..6cf31b342 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -37,6 +37,12 @@ and it loses. Selections used to be dropped entirely for exactly that reason. The consequence for callers: an `onSelect` on iOS runs a beat after the press. Don't add a second delay on top of it, and don't read state that the same press mutated. +This is why a row inside a menu surface goes through `selectItem` rather than calling its handler +from its own `Pressable`. `MenuItem` is not the only row shape on the engine — `ComposerTrackRow` +is another, with its own icons and hover-revealed actions — but both hand the press to the engine +and take `closeOnSelect` to say whether choosing them ends the surface. A row that owns its press +outright leaves the surface open behind whatever it opened, and skips the iOS wait. + ## Pages A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id` diff --git a/packages/app/e2e/browser/provider-subagents.real.spec.ts b/packages/app/e2e/browser/provider-subagents.real.spec.ts index 0b735c308..9141e4abc 100644 --- a/packages/app/e2e/browser/provider-subagents.real.spec.ts +++ b/packages/app/e2e/browser/provider-subagents.real.spec.ts @@ -89,6 +89,9 @@ test.describe("real provider subagent timelines", () => { } await rows.first().click(); + // Choosing a row is a menu selection: the track panel goes with it. + await expect(page.getByTestId("subagents-track-header-panel")).toBeHidden(); + const panel = page.getByTestId("provider-subagent-panel"); await expect(panel).toBeVisible({ timeout: 30_000 }); if (scenario.expectedSubtitle) { diff --git a/packages/app/src/composer/tracks-panel.browser.test.tsx b/packages/app/src/composer/tracks-panel.browser.test.tsx new file mode 100644 index 000000000..52833b9dc --- /dev/null +++ b/packages/app/src/composer/tracks-panel.browser.test.tsx @@ -0,0 +1,183 @@ +import React, { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { Pressable, Text } from "react-native"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ComposerTrackPill, ComposerTrackRow } from "./tracks"; + +// App sources compile against the classic JSX runtime, which expects React on the global. +beforeEach(() => vi.stubGlobal("React", React)); + +/** + * The real menu engine, in a real browser, because both things under test only exist there: the + * panel's dismissal runs through `selectItem`, and the running mark is a Web Animations rotation + * that jsdom cannot report. + */ + +interface Mounted { + root: Root; + container: HTMLDivElement; +} + +const mounted: Mounted[] = []; + +function mount(node: ReactNode): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render(node)); + mounted.push({ root, container }); + return container; +} + +afterEach(() => { + for (const entry of mounted.splice(0)) { + act(() => entry.root.unmount()); + entry.container.remove(); + } +}); + +function click(element: Element): void { + act(() => { + (element as HTMLElement).click(); + }); +} + +function pill(): HTMLElement { + const trigger = document.querySelector('[data-testid="pill"]'); + if (!(trigger instanceof HTMLElement)) { + throw new Error("track pill did not render"); + } + return trigger; +} + +function openPanel(): void { + click(pill()); +} + +function row(testID: string): Element | null { + return document.querySelector(`[data-testid="${testID}"]`); +} + +function isPanelOpen(): boolean { + return pill().getAttribute("aria-expanded") === "true"; +} + +describe("composer track panel", () => { + it("dismisses itself when a row is chosen", () => { + const onPress = vi.fn(); + mount( + + + Subagent one + + , + ); + + openPanel(); + const target = row("row"); + expect(target).not.toBeNull(); + + click(target as Element); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(isPanelOpen()).toBe(false); + expect(row("row")).toBeNull(); + }); + + it("stays open for a row whose result lands in the panel", () => { + const onPress = vi.fn(); + mount( + + + Archive finished + + , + ); + + openPanel(); + click(row("row") as Element); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(isPanelOpen()).toBe(true); + expect(row("row")).not.toBeNull(); + }); + + it("leaves the panel alone when a row's own action button is pressed", () => { + const onPress = vi.fn(); + const onAction = vi.fn(); + mount( + + + Subagent one + + Archive + + + , + ); + + openPanel(); + click(row("row-archive") as Element); + + expect(onAction).toHaveBeenCalledTimes(1); + expect(onPress).not.toHaveBeenCalled(); + expect(isPanelOpen()).toBe(true); + }); +}); + +describe("composer track pill status mark", () => { + function mountMark(statusBucket: "running" | "failed"): HTMLElement { + const container = mount( + + rows + , + ); + const mark = container.querySelector('[data-testid="pill"] > div'); + if (!(mark instanceof HTMLElement)) { + throw new Error("pill did not render a status mark"); + } + return mark; + } + + it("spins the shared ring while a child is running", () => { + const mark = mountMark("running"); + const animated = [...mark.querySelectorAll("*")].filter( + (element) => element.getAnimations().length > 0, + ); + + expect(animated).toHaveLength(1); + // The rotation is on the carrier; the quarter arc it turns is the coloured top border inside. + const arc = animated[0]?.firstElementChild as HTMLElement; + const arcStyle = getComputedStyle(arc); + expect(arcStyle.borderTopColor).toBe("rgb(38, 138, 224)"); + expect(arcStyle.borderLeftColor).toBe("rgba(0, 0, 0, 0)"); + }); + + it("draws a still dot for every other state", () => { + const mark = mountMark("failed"); + const animated = [...mark.querySelectorAll("*")].filter( + (element) => element.getAnimations().length > 0, + ); + + expect(animated).toHaveLength(0); + const dot = mark.firstElementChild as HTMLElement; + expect(getComputedStyle(dot).backgroundColor).toBe("rgb(241, 46, 47)"); + }); + + it("reserves one slot whatever the state, so the label never steps sideways", () => { + const running = mountMark("running").getBoundingClientRect().width; + const failed = mountMark("failed").getBoundingClientRect().width; + + expect(running).toBe(failed); + }); +}); diff --git a/packages/app/src/composer/tracks.test.tsx b/packages/app/src/composer/tracks.test.tsx index 1c7c95dc5..e8b0d73f5 100644 --- a/packages/app/src/composer/tracks.test.tsx +++ b/packages/app/src/composer/tracks.test.tsx @@ -12,6 +12,9 @@ vi.mock("react-native", () => ({ }, Pressable: "button", Text: "span", + // The status ring reaches for plain React Native styles rather than Unistyles ones — see the + // note on `rotatorStyles` in status-ring/frame.tsx. + StyleSheet: { create: (styles: T): T => styles }, View: ({ children, style, diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx index 976717822..a964d4b3c 100644 --- a/packages/app/src/composer/tracks.tsx +++ b/packages/app/src/composer/tracks.tsx @@ -8,8 +8,11 @@ import { useMenuContext, type MenuTriggerState, } from "@/components/ui/menu"; +import { StatusRing } from "@/components/status-ring"; +import { STATUS_RING_FRAME_SIZE } from "@/components/status-ring/geometry"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; +import { getStatusDotColor } from "@/utils/status-dot-color"; import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles"; @@ -43,7 +46,7 @@ export interface ComposerTrackPillProps { panelTitle: string; testID: string; accessibilityLabel?: string; - /** Drawn as a leading dot. `null` leaves the pill text-only. */ + /** Drawn as a leading mark — a dot, or the running ring. `null` leaves the pill text-only. */ statusBucket?: SidebarStateBucket | null; /** Panel body. Rendered into a popover on wide screens and a sheet on compact ones. */ children: ReactNode; @@ -135,7 +138,7 @@ function ComposerTrackPillTrigger({ {...ariaExpandedProps} style={pillStyle} > - + {label} @@ -148,6 +151,12 @@ export interface ComposerTrackRowProps { children: ReactNode | ((state: { active: boolean }) => ReactNode); /** Rows that open something are pressable and fill on press or hover. A read-only row is not. */ onPress?: () => void; + /** + * Dismiss the panel when this row is chosen. Same name, same default as `MenuItem`, because it + * is the same decision: a row that navigates away is done with the panel, a row whose result + * lands inside the panel is not. + */ + closeOnSelect?: boolean; disabled?: boolean; accessibilityLabel?: string; testID?: string; @@ -160,17 +169,27 @@ export interface ComposerTrackRowProps { * * Hover lives on the outer plain View and press on the inner Pressable, per docs/hover.md: rows * carry action buttons, and a Pressable tracking its own hover fights every Pressable inside it. + * + * A press is a menu selection, not a bare callback: the panel is a menu surface, so dismissal — + * and on iOS the wait for UIKit to finish tearing the sheet down before the action runs — belongs + * to the engine that opened it. The row only decides whether choosing it ends the panel. */ export function ComposerTrackRow({ children, onPress, + closeOnSelect = true, disabled = false, accessibilityLabel, testID, }: ComposerTrackRowProps): ReactElement { + const { selectItem } = useMenuContext("ComposerTrackRow"); const [hovered, setHovered] = useState(false); const handlePointerEnter = useCallback(() => setHovered(true), []); const handlePointerLeave = useCallback(() => setHovered(false), []); + const handleSelect = useCallback( + () => selectItem(onPress, closeOnSelect), + [closeOnSelect, onPress, selectItem], + ); const renderRow = useCallback( (active: boolean) => ( @@ -196,7 +215,7 @@ export function ComposerTrackRow({ accessibilityLabel={accessibilityLabel} testID={testID} disabled={disabled} - onPress={onPress} + onPress={handleSelect} > {renderPressed} @@ -204,23 +223,27 @@ export function ComposerTrackRow({ ); } -function ComposerTrackDot({ bucket }: { bucket: SidebarStateBucket | null }): ReactElement | null { - const colorStyle = bucket ? dotColorStyle(bucket) : null; - const dotStyle = useMemo(() => [styles.dot, colorStyle], [colorStyle]); - if (!colorStyle) { +/** + * The pill's leading state mark. Running is the ring every other running indicator in the app + * uses; the rest are the dot it grows from. + */ +function ComposerTrackMark({ bucket }: { bucket: SidebarStateBucket | null }): ReactElement | null { + if (!bucket) { return null; } - return ; + return ( + + {bucket === "running" ? : } + + ); } -function dotColorStyle(bucket: SidebarStateBucket) { +function dotColorStyle(bucket: Exclude) { switch (bucket) { case "needs_input": return styles.dotNeedsInput; case "failed": return styles.dotFailed; - case "running": - return styles.dotRunning; case "attention": return styles.dotAttention; case "done": @@ -228,67 +251,71 @@ function dotColorStyle(bucket: SidebarStateBucket) { } } -const styles = StyleSheet.create((theme) => ({ - bar: { - position: "absolute", - left: 0, - right: 0, - bottom: 0, - alignItems: "center", - paddingHorizontal: theme.spacing[4], - paddingBottom: { - xs: COMPOSER_PILL_CLEARANCE.compact, - md: COMPOSER_PILL_CLEARANCE.wide, - }, - }, - track: { - width: "100%", - maxWidth: MAX_CONTENT_WIDTH, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - }, - // The rail every panel row sits on: inset from the panel edge so the fill is a rounded block - // inside it, and tall enough that revealing an action button cannot resize the row. - row: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - minHeight: 32, - marginHorizontal: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1], - borderRadius: theme.borderRadius.md, - }, - rowActive: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - minHeight: 32, - marginHorizontal: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1], - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.surface2, - }, - dot: { +const styles = StyleSheet.create((theme) => { + // Colours come from the one bucket-to-colour map so the pill cannot drift from the status dots + // everywhere else, and are baked into each variant so the style prop stays a stable object. + const statusDot = (bucket: Exclude) => ({ width: STATUS_INDICATOR_FILLED_DOT_SIZE, height: STATUS_INDICATOR_FILLED_DOT_SIZE, borderRadius: theme.borderRadius.full, - }, - dotNeedsInput: { - backgroundColor: theme.colors.statusDotWarning, - }, - dotFailed: { - backgroundColor: theme.colors.statusDotDanger, - }, - dotRunning: { - backgroundColor: theme.colors.statusDotRunning, - }, - dotAttention: { - backgroundColor: theme.colors.statusDotSuccess, - }, - dotDone: { - backgroundColor: theme.colors.border, - }, -})); + backgroundColor: getStatusDotColor({ theme, bucket, showDoneAsInactive: true }) ?? undefined, + }); + + return { + bar: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + alignItems: "center", + paddingHorizontal: theme.spacing[4], + paddingBottom: { + xs: COMPOSER_PILL_CLEARANCE.compact, + md: COMPOSER_PILL_CLEARANCE.wide, + }, + }, + track: { + width: "100%", + maxWidth: MAX_CONTENT_WIDTH, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + // The rail every panel row sits on: inset from the panel edge so the fill is a rounded block + // inside it, and tall enough that revealing an action button cannot resize the row. + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minHeight: 32, + marginHorizontal: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.md, + }, + rowActive: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minHeight: 32, + marginHorizontal: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + }, + // Sized for the widest mark that can land in it, which is the ring rather than the dot. The + // slot is the same width whatever the state, so the label does not step sideways when the last + // child stops running. + mark: { + width: STATUS_RING_FRAME_SIZE, + height: STATUS_RING_FRAME_SIZE, + alignItems: "center", + justifyContent: "center", + }, + dotNeedsInput: statusDot("needs_input"), + dotFailed: statusDot("failed"), + dotAttention: statusDot("attention"), + dotDone: statusDot("done"), + }; +}); diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index a98f70ae3..940a2cab5 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -155,6 +155,9 @@ function ArchiveFinishedRow({ accessibilityLabel={t("subagents.archiveFinishedAction")} testID="subagents-track-archive-finished" disabled={disabled} + // Progress and the retry count land on this row, so the panel is where the result of + // pressing it shows up. Dismissing would hide the thing the press produces. + closeOnSelect={false} onPress={onPress} > {renderRow} diff --git a/packages/app/test-stubs/gorhom-bottom-sheet.ts b/packages/app/test-stubs/gorhom-bottom-sheet.ts new file mode 100644 index 000000000..5be5f7b5f --- /dev/null +++ b/packages/app/test-stubs/gorhom-bottom-sheet.ts @@ -0,0 +1,13 @@ +// The package ships untranspiled Flow, which the unit project cannot parse. The sheet is a native +// presentation; tests that reach a menu do so through the popover, so rendering nothing is enough +// to keep the module graph importable. +const Stub = () => null; +const PassThrough = ({ children }: { children?: unknown }) => children; + +export default Stub; +export const BottomSheetModal = Stub; +export const BottomSheetModalProvider = PassThrough; +export const BottomSheetBackdrop = Stub; +export const BottomSheetScrollView = PassThrough; +export const BottomSheetView = PassThrough; +export const BottomSheetTextInput = Stub; diff --git a/packages/app/test-stubs/lucide-react-native.ts b/packages/app/test-stubs/lucide-react-native.ts index a7f4f76b1..716a0e2d6 100644 --- a/packages/app/test-stubs/lucide-react-native.ts +++ b/packages/app/test-stubs/lucide-react-native.ts @@ -22,6 +22,7 @@ export const Blocks = StubIcon; export const Bot = StubIcon; export const Brain = StubIcon; export const Check = StubIcon; +export const CheckCircle = StubIcon; export const CheckCircle2 = StubIcon; export const ChevronDown = StubIcon; export const ChevronLeft = StubIcon; diff --git a/packages/app/test-stubs/react-native-fabric-shim.ts b/packages/app/test-stubs/react-native-fabric-shim.ts new file mode 100644 index 000000000..344607a33 --- /dev/null +++ b/packages/app/test-stubs/react-native-fabric-shim.ts @@ -0,0 +1,8 @@ +// Reanimated's native code path imports `react-native/Libraries/Renderer/shims/ReactFabric`. +// The `react-native` alias below points at a react-native-web file, so the subpath cannot +// resolve; on web reanimated takes `findHostInstance.web.js` and never reaches this. +export function findHostInstance_DEPRECATED(): null { + return null; +} + +export default { findHostInstance_DEPRECATED }; diff --git a/packages/app/test-stubs/react-native-safe-area-context.ts b/packages/app/test-stubs/react-native-safe-area-context.ts new file mode 100644 index 000000000..ae793cd92 --- /dev/null +++ b/packages/app/test-stubs/react-native-safe-area-context.ts @@ -0,0 +1,12 @@ +// The package ships untranspiled Flow, which the unit project cannot parse. Insets are a device +// fact no test asserts on, so they are zero. +const ZERO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 } as const; + +export const useSafeAreaInsets = () => ZERO_INSETS; +export const useSafeAreaFrame = () => ({ x: 0, y: 0, width: 0, height: 0 }); +export const SafeAreaProvider = ({ children }: { children?: unknown }) => children; +export const SafeAreaView = ({ children }: { children?: unknown }) => children; +export const initialWindowMetrics = { + insets: ZERO_INSETS, + frame: { x: 0, y: 0, width: 0, height: 0 }, +}; diff --git a/packages/app/test-stubs/react-native-unistyles.ts b/packages/app/test-stubs/react-native-unistyles.ts index 9b7015334..1484e7a7d 100644 --- a/packages/app/test-stubs/react-native-unistyles.ts +++ b/packages/app/test-stubs/react-native-unistyles.ts @@ -7,6 +7,11 @@ const testTheme = { statusDanger: "#b91c1c", statusWarning: "#d97706", statusMerged: "#7c3aed", + // The light band's values, so a test can name the colour it expects. + statusDotSuccess: "#299f51", + statusDotDanger: "#f12e2f", + statusDotWarning: "#b37824", + statusDotRunning: "#268ae0", accent: "#2563eb", accentForeground: "#ffffff", destructive: "#dc2626", @@ -19,6 +24,7 @@ const testTheme = { palette: { amber: { 500: "#f59e0b" }, blue: { 300: "#93c5fd" }, + green: { 500: "#22c55e" }, red: { 300: "#fca5a5" }, white: "#ffffff", }, @@ -45,8 +51,28 @@ const testTheme = { xl: 12, full: 9999, }, - iconSize: { sm: 16 }, + iconSize: { sm: 16, md: 20 }, opacity: { 50: 0.5 }, + shadow: { + sm: { + shadowColor: "rgba(0, 0, 0, 0.02)", + shadowOffset: { width: 0, height: 2 }, + shadowRadius: 8, + elevation: 2, + }, + md: { + shadowColor: "rgba(0, 0, 0, 0.04)", + shadowOffset: { width: 0, height: 4 }, + shadowRadius: 16, + elevation: 4, + }, + lg: { + shadowColor: "rgba(0, 0, 0, 0.08)", + shadowOffset: { width: 0, height: 8 }, + shadowRadius: 24, + elevation: 8, + }, + }, }; type StyleFactory = (theme: typeof testTheme) => T; diff --git a/packages/app/test-stubs/reanimated-validate-worklets-version.ts b/packages/app/test-stubs/reanimated-validate-worklets-version.ts new file mode 100644 index 000000000..b0247ab2b --- /dev/null +++ b/packages/app/test-stubs/reanimated-validate-worklets-version.ts @@ -0,0 +1,3 @@ +// Reanimated imports this CJS script with a default import. Unbundled, the browser cannot interop +// it, and the check it performs is a native-build concern with nothing to say in a browser test. +export default function validateWorkletsVersion(): void {} diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index fa3ec72e0..5c1b82060 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -60,6 +60,20 @@ export default defineConfig({ }, }, }, + // Reanimated ships one file per platform and picks between them by extension + // (`findHostInstance.web.js`). Vite's dependency optimizer does not apply `resolve.extensions`, + // so it scans the native files and dies on imports react-native-web has no answer for. + // Unbundled, the same imports go through the resolver below and land on the web files. + optimizeDeps: { + exclude: ["react-native-reanimated"], + }, + // The globals a React Native bundler defines, which esbuild is no longer there to supply for + // the package excluded above. + define: { + "process.env.JEST_WORKER_ID": "undefined", + __DEV__: "false", + global: "globalThis", + }, resolve: { extensions: [ ".web.mjs", @@ -86,6 +100,13 @@ export default defineConfig({ replacement: path.resolve(__dirname, "../relay/src/index.ts"), }, { find: "@", replacement: path.resolve(__dirname, "src") }, + // Must precede the `react-native` alias: a string `find` matches by prefix, so this subpath + // would otherwise resolve inside a react-native-web *file* and break the dependency scan. + // Reanimated only imports it on the native path, which no test takes. + { + find: /^react-native\/Libraries\/Renderer\/shims\/ReactFabric$/, + replacement: path.resolve(__dirname, "test-stubs/react-native-fabric-shim.ts"), + }, // Point to the ESM build so Vite can transform its imports and apply the // react alias below (the CJS build uses require('react') which bypasses // Vite alias resolution). @@ -114,6 +135,20 @@ export default defineConfig({ find: /^react-native-svg$/, replacement: path.resolve(__dirname, "test-stubs/react-native-svg.ts"), }, + // Both ship untranspiled Flow and fail to parse on import, which takes out any test that + // mounts a menu surface. + { + find: /^react-native-safe-area-context$/, + replacement: path.resolve(__dirname, "test-stubs/react-native-safe-area-context.ts"), + }, + { + find: /^@gorhom\/bottom-sheet$/, + replacement: path.resolve(__dirname, "test-stubs/gorhom-bottom-sheet.ts"), + }, + { + find: /^react-native-reanimated\/scripts\/validate-worklets-version$/, + replacement: path.resolve(__dirname, "test-stubs/reanimated-validate-worklets-version.ts"), + }, { find: /^expo-linking$/, replacement: path.resolve(__dirname, "test-stubs/expo-linking.ts"),