From a4fceb9ca5e303ea7f676f2193675019eacedeba Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 25 May 2026 18:50:33 -0700
Subject: [PATCH 01/21] Patch victory-native with explicitSize + headless props

Mirrors the upstream PR (https://github.com/FormidableLabs/victory-native-xl/pull/657)
into the installed package so the Bun renderer can drive CartesianChart headlessly.
Adds pixelmatch / pngjs for the upcoming reference-PNG test and marks
@shopify/react-native-skia as a trusted dependency for Bun.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 package-lock.json                         |  42 +++-
 package.json                              |   6 +
 patches/victory-native+41.20.2.details.md |  27 +++
 patches/victory-native+41.20.2.patch      | 242 ++++++++++++++++++++++
 4 files changed, 315 insertions(+), 2 deletions(-)
 create mode 100644 patches/victory-native+41.20.2.details.md
 create mode 100644 patches/victory-native+41.20.2.patch

diff --git a/package-lock.json b/package-lock.json
index 6f0b2818ea43..8ca680b1e01a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -206,6 +206,7 @@
         "@types/mime-db": "^1.43.5",
         "@types/node": "^20.11.5",
         "@types/pako": "^2.0.3",
+        "@types/pngjs": "^6.0.5",
         "@types/pusher-js": "^5.1.0",
         "@types/react": "^19.2.0",
         "@types/react-collapse": "^5.0.1",
@@ -269,6 +270,8 @@
         "openai": "^6.16.0",
         "patch-package": "^8.1.0-canary.1",
         "peggy": "^4.0.3",
+        "pixelmatch": "^7.2.0",
+        "pngjs": "^7.0.0",
         "portfinder": "^1.0.34",
         "prettier": "3.7.4",
         "react-native-clean-project": "^4.0.0-alpha4.0",
@@ -17079,6 +17082,16 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/pngjs": {
+      "version": "6.0.5",
+      "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz",
+      "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/prop-types": {
       "version": "15.7.14",
       "license": "MIT"
@@ -33385,6 +33398,19 @@
         "node": ">= 6"
       }
     },
+    "node_modules/pixelmatch": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.2.0.tgz",
+      "integrity": "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "pngjs": "^7.0.0"
+      },
+      "bin": {
+        "pixelmatch": "bin/pixelmatch"
+      }
+    },
     "node_modules/pkg-dir": {
       "version": "4.2.0",
       "dev": true,
@@ -33545,10 +33571,13 @@
       }
     },
     "node_modules/pngjs": {
-      "version": "5.0.0",
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
+      "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
+      "dev": true,
       "license": "MIT",
       "engines": {
-        "node": ">=10.13.0"
+        "node": ">=14.19.0"
       }
     },
     "node_modules/portfinder": {
@@ -33915,6 +33944,15 @@
         "node": ">=10.13.0"
       }
     },
+    "node_modules/qrcode/node_modules/pngjs": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+      "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
     "node_modules/qs": {
       "version": "6.13.0",
       "devOptional": true,
diff --git a/package.json b/package.json
index d4f88fb7c0cb..6c92f76d749d 100644
--- a/package.json
+++ b/package.json
@@ -273,6 +273,7 @@
     "@types/mime-db": "^1.43.5",
     "@types/node": "^20.11.5",
     "@types/pako": "^2.0.3",
+    "@types/pngjs": "^6.0.5",
     "@types/pusher-js": "^5.1.0",
     "@types/react": "^19.2.0",
     "@types/react-collapse": "^5.0.1",
@@ -336,6 +337,8 @@
     "openai": "^6.16.0",
     "patch-package": "^8.1.0-canary.1",
     "peggy": "^4.0.3",
+    "pixelmatch": "^7.2.0",
+    "pngjs": "^7.0.0",
     "portfinder": "^1.0.34",
     "prettier": "3.7.4",
     "react-native-clean-project": "^4.0.0-alpha4.0",
@@ -421,6 +424,9 @@
       "expo": "55.0.25"
     }
   },
+  "trustedDependencies": [
+    "@shopify/react-native-skia"
+  ],
   "expo": {
     "autolinking": {
       "exclude": [
diff --git a/patches/victory-native+41.20.2.details.md b/patches/victory-native+41.20.2.details.md
new file mode 100644
index 000000000000..947ee5539f60
--- /dev/null
+++ b/patches/victory-native+41.20.2.details.md
@@ -0,0 +1,27 @@
+# victory-native patch: explicitSize + headless props
+
+Mirrors upstream PR: <https://github.com/FormidableLabs/victory-native-xl/pull/657>
+
+## Why
+
+The `server/victory-chart-renderer` Bun CLI renders Expensify chart XML to PNG using
+the same `CartesianChart` users see in-app, but it runs without React Native — there is no
+host view to fire `onLayout`, and the existing `<Canvas>` wrapper expects to be mounted
+inside one. This patch adds two opt-in props that let the CLI drive the chart with a
+fixed canvas size and emit only the Skia subtree (so we can paint it onto an offscreen
+surface created via `LoadSkiaWeb`):
+
+- `explicitSize?: { width: number; height: number }` — seeds the chart's size state
+  immediately, skipping the `onLayout` round-trip.
+- `headless?: boolean` — when paired with `explicitSize`, returns a Skia-only `<Group>`
+  containing the chart contents instead of wrapping them in `<Canvas>` and a gesture
+  handler. The chart still renders identically inside React Native when neither prop is
+  passed, so the in-app code path is unchanged.
+
+The patch lives in both `src/` (consumed by Metro via the package's `react-native`
+field) and `dist/` (consumed by Bun, which falls through to the `import` condition and
+loads compiled JS / `.d.ts`).
+
+## Remove when
+
+Upstream PR is merged and we upgrade `victory-native` to a release containing it.
diff --git a/patches/victory-native+41.20.2.patch b/patches/victory-native+41.20.2.patch
new file mode 100644
index 000000000000..8b916df23059
--- /dev/null
+++ b/patches/victory-native+41.20.2.patch
@@ -0,0 +1,242 @@
+diff --git a/node_modules/victory-native/dist/cartesian/CartesianChart.d.ts b/node_modules/victory-native/dist/cartesian/CartesianChart.d.ts
+index 5b718aa..28a2dd8 100644
+--- a/node_modules/victory-native/dist/cartesian/CartesianChart.d.ts
++++ b/node_modules/victory-native/dist/cartesian/CartesianChart.d.ts
+@@ -14,6 +14,10 @@ export type CartesianChartRef<T = undefined> = {
+     canvas: CanvasRef | null;
+     actions: CartesianActionsHandle<T>;
+ };
++export type CartesianChartExplicitSize = {
++    width: number;
++    height: number;
++};
+ type CartesianChartProps<RawData extends Record<string, unknown>, XK extends keyof InputFields<RawData>, YK extends keyof NumericalFields<RawData>> = {
+     data: RawData[];
+     xKey: XK;
+@@ -62,6 +66,16 @@ type CartesianChartProps<RawData extends Record<string, unknown>, XK extends key
+         x: InputFields<RawData>[XK];
+         y: Record<YK, number>;
+     }> | undefined>>;
++    /**
++     * When provided, initializes chart dimensions without waiting for React Native
++     * `onLayout`. Required when using `headless`.
++     */
++    explicitSize?: CartesianChartExplicitSize;
++    /**
++     * When `true` (with `explicitSize`), renders a Skia-only subtree suitable for
++     * headless renderers that cannot mount React Native views.
++     */
++    headless?: boolean;
+ };
+ export declare function CartesianChart<RawData extends Record<string, unknown>, XK extends keyof InputFields<RawData>, YK extends keyof NumericalFields<RawData>>({ transformState, children, ref, ...rest }: CartesianChartProps<RawData, XK, YK>): React.JSX.Element;
+ export {};
+diff --git a/node_modules/victory-native/dist/cartesian/CartesianChart.js b/node_modules/victory-native/dist/cartesian/CartesianChart.js
+index a3a842c..11f1772 100644
+--- a/node_modules/victory-native/dist/cartesian/CartesianChart.js
++++ b/node_modules/victory-native/dist/cartesian/CartesianChart.js
+@@ -71,18 +71,22 @@ function CartesianChart(_a) {
+     </CartesianTransformContext_1.CartesianTransformProvider>);
+ }
+ exports.CartesianChart = CartesianChart;
+-function CartesianChartContent({ data, xKey, yKeys, padding, domainPadding, children, renderOutside = () => null, axisOptions, domain, chartPressState, chartPressConfig, gestureHandlerConfig, onChartBoundsChange, onScaleChange, gestureLongPressDelay = 100, xAxis, yAxis, frame, transformState, transformConfig, customGestures, actionsRef, viewport, ref, }) {
++function CartesianChartContent({ data, xKey, yKeys, padding, domainPadding, children, renderOutside = () => null, axisOptions, domain, chartPressState, chartPressConfig, gestureHandlerConfig, onChartBoundsChange, onScaleChange, gestureLongPressDelay = 100, xAxis, yAxis, frame, transformState, transformConfig, customGestures, actionsRef, viewport, ref, explicitSize, headless, }) {
+     var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
+-    const [size, setSize] = React.useState({ width: 0, height: 0 });
++    const [size, setSize] = React.useState(explicitSize !== null && explicitSize !== void 0 ? explicitSize : { width: 0, height: 0 });
+     const chartBoundsRef = React.useRef(undefined);
+     const xScaleRef = React.useRef(undefined);
+     const yScaleRef = React.useRef(undefined);
+     const canvasRef = React.useRef(null);
+-    const [hasMeasuredLayoutSize, setHasMeasuredLayoutSize] = React.useState(false);
++    const [hasMeasuredLayoutSize, setHasMeasuredLayoutSize] = React.useState(Boolean(explicitSize));
++    const isHeadless = Boolean(headless && explicitSize);
+     const onLayout = React.useCallback(({ nativeEvent: { layout } }) => {
++        if (explicitSize) {
++            return;
++        }
+         setHasMeasuredLayoutSize(true);
+         setSize(layout);
+-    }, []);
++    }, [explicitSize]);
+     const normalizedAxisProps = (0, useBuildChartAxis_1.useBuildChartAxis)({
+         xAxis,
+         yAxis,
+@@ -458,8 +462,7 @@ function CartesianChartContent({ data, xKey, yKeys, padding, domainPadding, chil
+         : null;
+     const XAxisComponents = hasMeasuredLayoutSize && (axisOptions || xAxis) ? (<XAxis_1.XAxis {...normalizedAxisProps.xAxis} xScale={xScale} yScale={zoomY.rescaleY(primaryYScale)} ix={_tData.ix} isNumericalData={isNumericalData} chartBounds={chartBounds} zoom={zoomX}/>) : null;
+     const FrameComponent = hasMeasuredLayoutSize && (axisOptions || frame) ? (<Frame_1.Frame {...normalizedAxisProps.frame} xScale={xScale} yScale={primaryYScale}/>) : null;
+-    // Body of the chart.
+-    const body = (<react_native_skia_1.Canvas ref={canvasRef} style={{ flex: 1 }}>
++    const chartContent = (<>
+       {YAxisComponents}
+       {XAxisComponents}
+       {FrameComponent}
+@@ -471,6 +474,9 @@ function CartesianChartContent({ data, xKey, yKeys, padding, domainPadding, chil
+         </react_native_skia_1.Group>
+       </CartesianChartContext_1.CartesianChartProvider>
+       {hasMeasuredLayoutSize && (renderOutside === null || renderOutside === void 0 ? void 0 : renderOutside(renderArg))}
++    </>);
++    const body = isHeadless ? (<react_native_skia_1.Group>{chartContent}</react_native_skia_1.Group>) : (<react_native_skia_1.Canvas ref={canvasRef} style={{ flex: 1 }}>
++      {chartContent}
+     </react_native_skia_1.Canvas>);
+     let composed = customGestures !== null && customGestures !== void 0 ? customGestures : react_native_gesture_handler_1.Gesture.Race();
+     if (transformState) {
+@@ -486,8 +492,11 @@ function CartesianChartContent({ data, xKey, yKeys, padding, domainPadding, chil
+     if (chartPressState) {
+         composed = react_native_gesture_handler_1.Gesture.Race(composed, panGesture);
+     }
++    if (isHeadless) {
++        return body;
++    }
+     return (<react_native_gesture_handler_1.GestureHandlerRootView>
+-      <react_native_1.View style={{ flex: 1, overflow: "hidden" }} onLayout={onLayout}>
++      <react_native_1.View style={{ flex: explicitSize ? undefined : 1, width: explicitSize === null || explicitSize === void 0 ? void 0 : explicitSize.width, height: explicitSize === null || explicitSize === void 0 ? void 0 : explicitSize.height, overflow: "hidden" }} onLayout={explicitSize ? undefined : onLayout}>
+         {body}
+         <GestureHandler_1.GestureHandler config={gestureHandlerConfig} gesture={composed} transformState={transformState} dimensions={{
+             x: Math.min(xScale.range()[0], 0),
+diff --git a/node_modules/victory-native/dist/index.d.ts b/node_modules/victory-native/dist/index.d.ts
+index 86ccba5..b590cc5 100644
+--- a/node_modules/victory-native/dist/index.d.ts
++++ b/node_modules/victory-native/dist/index.d.ts
+@@ -1,7 +1,7 @@
+ /**
+  * Cartesian chart exports (including useful types)
+  */
+-export { CartesianChart, type CartesianActionsHandle, type CartesianChartRef, } from "./cartesian/CartesianChart";
++export { CartesianChart, type CartesianActionsHandle, type CartesianChartExplicitSize, type CartesianChartRef, } from "./cartesian/CartesianChart";
+ export { type InputDatum, type CartesianChartRenderArg, type Viewport, type ChartBounds, type YAxisSide, type XAxisSide, type PointsArray, type Scale, type MaybeNumber, } from "./types";
+ export { type CurveType } from "./cartesian/utils/curves";
+ export { type RoundedCorners } from "./utils/createRoundedRectPath";
+diff --git a/node_modules/victory-native/src/cartesian/CartesianChart.tsx b/node_modules/victory-native/src/cartesian/CartesianChart.tsx
+index 9b82b54..f48b723 100644
+--- a/node_modules/victory-native/src/cartesian/CartesianChart.tsx
++++ b/node_modules/victory-native/src/cartesian/CartesianChart.tsx
+@@ -72,6 +72,11 @@ export type CartesianChartRef<T = undefined> = {
+   actions: CartesianActionsHandle<T>;
+ };
+ 
++export type CartesianChartExplicitSize = {
++  width: number;
++  height: number;
++};
++
+ type CartesianChartProps<
+   RawData extends Record<string, unknown>,
+   XK extends keyof InputFields<RawData>,
+@@ -130,6 +135,16 @@ type CartesianChartProps<
+       | undefined
+     >
+   >;
++  /**
++   * When provided, initializes chart dimensions without waiting for React Native
++   * `onLayout`. Required when using `headless`.
++   */
++  explicitSize?: CartesianChartExplicitSize;
++  /**
++   * When `true` (with `explicitSize`), renders a Skia-only subtree suitable for
++   * headless renderers that cannot mount React Native views.
++   */
++  headless?: boolean;
+ };
+ 
+ export function CartesianChart<
+@@ -180,8 +195,12 @@ function CartesianChartContent<
+   actionsRef,
+   viewport,
+   ref,
++  explicitSize,
++  headless,
+ }: CartesianChartProps<RawData, XK, YK>) {
+-  const [size, setSize] = React.useState({ width: 0, height: 0 });
++  const [size, setSize] = React.useState(
++    explicitSize ?? { width: 0, height: 0 },
++  );
+   const chartBoundsRef = React.useRef<ChartBounds | undefined>(undefined);
+   const xScaleRef = React.useRef<
+     ScaleLogarithmic<number, number> | ScaleLinear<number, number> | undefined
+@@ -191,14 +210,20 @@ function CartesianChartContent<
+     undefined,
+   );
+   const canvasRef = React.useRef<CanvasRef | null>(null);
+-  const [hasMeasuredLayoutSize, setHasMeasuredLayoutSize] =
+-    React.useState(false);
++  const [hasMeasuredLayoutSize, setHasMeasuredLayoutSize] = React.useState(
++    Boolean(explicitSize),
++  );
++  const isHeadless = Boolean(headless && explicitSize);
+   const onLayout = React.useCallback(
+     ({ nativeEvent: { layout } }: LayoutChangeEvent) => {
++      if (explicitSize) {
++        return;
++      }
++
+       setHasMeasuredLayoutSize(true);
+       setSize(layout);
+     },
+-    [],
++    [explicitSize],
+   );
+   const normalizedAxisProps = useBuildChartAxis({
+     xAxis,
+@@ -707,9 +732,8 @@ function CartesianChartContent<
+       />
+     ) : null;
+ 
+-  // Body of the chart.
+-  const body = (
+-    <Canvas ref={canvasRef} style={{ flex: 1 }}>
++  const chartContent = (
++    <>
+       {YAxisComponents}
+       {XAxisComponents}
+       {FrameComponent}
+@@ -721,6 +745,14 @@ function CartesianChartContent<
+         </Group>
+       </CartesianChartProvider>
+       {hasMeasuredLayoutSize && renderOutside?.(renderArg)}
++    </>
++  );
++
++  const body = isHeadless ? (
++    <Group>{chartContent}</Group>
++  ) : (
++    <Canvas ref={canvasRef} style={{ flex: 1 }}>
++      {chartContent}
+     </Canvas>
+   );
+ 
+@@ -748,9 +780,21 @@ function CartesianChartContent<
+     composed = Gesture.Race(composed, panGesture);
+   }
+ 
++  if (isHeadless) {
++    return body;
++  }
++
+   return (
+     <GestureHandlerRootView>
+-      <View style={{ flex: 1, overflow: "hidden" }} onLayout={onLayout}>
++      <View
++        style={{
++          flex: explicitSize ? undefined : 1,
++          width: explicitSize?.width,
++          height: explicitSize?.height,
++          overflow: "hidden",
++        }}
++        onLayout={explicitSize ? undefined : onLayout}
++      >
+         {body}
+         <GestureHandler
+           config={gestureHandlerConfig}
+diff --git a/node_modules/victory-native/src/index.ts b/node_modules/victory-native/src/index.ts
+index 3065639..e291087 100644
+--- a/node_modules/victory-native/src/index.ts
++++ b/node_modules/victory-native/src/index.ts
+@@ -4,6 +4,7 @@
+ export {
+   CartesianChart,
+   type CartesianActionsHandle,
++  type CartesianChartExplicitSize,
+   type CartesianChartRef,
+ } from "./cartesian/CartesianChart";
+ 

From 83e35a69a41def3c7c4854cffd93e95afb5bd371 Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 25 May 2026 19:21:43 -0700
Subject: [PATCH 02/21] Render a hardcoded CartesianChart from the Bun CLI

Replaces the PR 2 hello-world entry with a real headless `CartesianChart` that
draws two bars to a Skia offscreen surface and writes the result as a PNG. To
get `victory-native` and `@shopify/react-native-skia` to load under Bun, the
React Native peers are stubbed via tsconfig path mappings dropped into both
installed packages by patch-package; Metro ignores those tsconfigs so the
in-app build is untouched.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 package.json                                  |   6 +-
 ...hopify+react-native-skia+2.4.14.details.md |  21 ++++
 .../@shopify+react-native-skia+2.4.14.patch   |  25 +++++
 patches/victory-native+41.20.2.details.md     |   8 ++
 patches/victory-native+41.20.2.patch          |  22 ++++
 server/tsconfig.json                          |  23 +++-
 server/victory-chart-renderer/src/cli.ts      |  60 ++++++++++-
 .../stubs/react-native-gesture-handler.ts     | 100 ++++++++++++++++++
 .../stubs/react-native-reanimated.js          |  66 ++++++++++++
 .../stubs/react-native-subpath.ts             |  12 +++
 .../stubs/react-native.ts                     |  65 ++++++++++++
 11 files changed, 401 insertions(+), 7 deletions(-)
 create mode 100644 patches/@shopify+react-native-skia+2.4.14.details.md
 create mode 100644 patches/@shopify+react-native-skia+2.4.14.patch
 create mode 100644 server/victory-chart-renderer/stubs/react-native-gesture-handler.ts
 create mode 100644 server/victory-chart-renderer/stubs/react-native-reanimated.js
 create mode 100644 server/victory-chart-renderer/stubs/react-native-subpath.ts
 create mode 100644 server/victory-chart-renderer/stubs/react-native.ts

diff --git a/package.json b/package.json
index 6c92f76d749d..09b5eec2a725 100644
--- a/package.json
+++ b/package.json
@@ -72,9 +72,9 @@
     "web:dist": "concurrently \"npm:web-proxy\" \"http-server ./dist --cors --port 8080 -P http://localhost:9000\"",
     "octokit": "cd scripts && npx ts-node -i -e \"$(cat ./octokit.ts)\"",
     "compress-svg": "npx ts-node scripts/compressSvg.ts --dir assets/images && npx ts-node scripts/compressSvg.ts --dir docs/assets/images",
-    "server:vcr:dev": "bun run server/victory-chart-renderer/src/cli.ts",
-    "server:vcr:build:linux": "bun build --compile --target=bun-linux-x64 server/victory-chart-renderer/src/cli.ts --outfile server/victory-chart-renderer/dist/victory-chart-renderer-linux-x64",
-    "server:vcr:build:macos": "bun build --compile --target=bun-darwin-arm64 server/victory-chart-renderer/src/cli.ts --outfile server/victory-chart-renderer/dist/victory-chart-renderer-darwin-arm64"
+    "server:vcr:dev": "bun --conditions=react-native run server/victory-chart-renderer/src/cli.ts",
+    "server:vcr:build:linux": "bun build --conditions=react-native --compile --target=bun-linux-x64 server/victory-chart-renderer/src/cli.ts --outfile server/victory-chart-renderer/dist/victory-chart-renderer-linux-x64",
+    "server:vcr:build:macos": "bun build --conditions=react-native --compile --target=bun-darwin-arm64 server/victory-chart-renderer/src/cli.ts --outfile server/victory-chart-renderer/dist/victory-chart-renderer-darwin-arm64"
   },
   "dependencies": {
     "@babel/runtime": "^7.25.0",
diff --git a/patches/@shopify+react-native-skia+2.4.14.details.md b/patches/@shopify+react-native-skia+2.4.14.details.md
new file mode 100644
index 000000000000..e8a4656a94f2
--- /dev/null
+++ b/patches/@shopify+react-native-skia+2.4.14.details.md
@@ -0,0 +1,21 @@
+# @shopify/react-native-skia patch: local tsconfig for Bun headless renderer
+
+## Why
+
+The `server/victory-chart-renderer` Bun CLI renders Skia content without React
+Native. Bun (and any other non-RN consumer) loads `@shopify/react-native-skia`'s
+root entry, which transitively imports `react-native`,
+`react-native-reanimated`, and `react-native/Libraries/...` subpaths. None of
+those packages run in Bun.
+
+This patch drops a minimal `tsconfig.json` inside the installed package so that
+when Bun resolves imports from files in `node_modules/@shopify/react-native-skia/...`
+it walks up to this `tsconfig.json` and remaps the React Native dependencies to
+the stubs in `server/victory-chart-renderer/stubs/`. Metro doesn't consult
+`tsconfig.json` paths, so the in-app build is unaffected.
+
+## Remove when
+
+We replace the Bun renderer with a different mechanism, or the Skia package
+ships a `bun` export condition (or equivalent) that lets us load a stripped
+entry without React Native dependencies.
diff --git a/patches/@shopify+react-native-skia+2.4.14.patch b/patches/@shopify+react-native-skia+2.4.14.patch
new file mode 100644
index 000000000000..0b5f8bc62178
--- /dev/null
+++ b/patches/@shopify+react-native-skia+2.4.14.patch
@@ -0,0 +1,25 @@
+diff --git a/node_modules/@shopify/react-native-skia/tsconfig.json b/node_modules/@shopify/react-native-skia/tsconfig.json
+new file mode 100644
+index 0000000..50f2975
+--- /dev/null
++++ b/node_modules/@shopify/react-native-skia/tsconfig.json
+@@ -0,0 +1,19 @@
++{
++  "compilerOptions": {
++    "baseUrl": ".",
++    "paths": {
++      "react-native": [
++        "../../../server/victory-chart-renderer/stubs/react-native.ts"
++      ],
++      "react-native/*": [
++        "../../../server/victory-chart-renderer/stubs/react-native-subpath.ts"
++      ],
++      "react-native-reanimated": [
++        "../../../server/victory-chart-renderer/stubs/react-native-reanimated.js"
++      ],
++      "react-native-gesture-handler": [
++        "../../../server/victory-chart-renderer/stubs/react-native-gesture-handler.ts"
++      ]
++    }
++  }
++}
diff --git a/patches/victory-native+41.20.2.details.md b/patches/victory-native+41.20.2.details.md
index 947ee5539f60..3ae2126211fc 100644
--- a/patches/victory-native+41.20.2.details.md
+++ b/patches/victory-native+41.20.2.details.md
@@ -22,6 +22,14 @@ The patch lives in both `src/` (consumed by Metro via the package's `react-nativ
 field) and `dist/` (consumed by Bun, which falls through to the `import` condition and
 loads compiled JS / `.d.ts`).
 
+A small `tsconfig.json` is also dropped into the installed package. Bun's
+resolver walks up to the closest `tsconfig.json` when resolving bare specifiers
+in `node_modules/victory-native/...`, so this file remaps the chart's React
+Native dependencies (`react-native`, `react-native-reanimated`,
+`react-native-gesture-handler`) to the stubs in
+`server/victory-chart-renderer/stubs/`. Metro doesn't honor `tsconfig.json`
+paths, so the in-app build is unaffected.
+
 ## Remove when
 
 Upstream PR is merged and we upgrade `victory-native` to a release containing it.
diff --git a/patches/victory-native+41.20.2.patch b/patches/victory-native+41.20.2.patch
index 8b916df23059..5007104b1649 100644
--- a/patches/victory-native+41.20.2.patch
+++ b/patches/victory-native+41.20.2.patch
@@ -240,3 +240,25 @@ index 3065639..e291087 100644
    type CartesianChartRef,
  } from "./cartesian/CartesianChart";
  
+diff --git a/node_modules/victory-native/tsconfig.json b/node_modules/victory-native/tsconfig.json
+new file mode 100644
+index 0000000..0fff5ee
+--- /dev/null
++++ b/node_modules/victory-native/tsconfig.json
+@@ -0,0 +1,16 @@
++{
++  "compilerOptions": {
++    "baseUrl": ".",
++    "paths": {
++      "react-native": [
++        "../../server/victory-chart-renderer/stubs/react-native.ts"
++      ],
++      "react-native-reanimated": [
++        "../../server/victory-chart-renderer/stubs/react-native-reanimated.js"
++      ],
++      "react-native-gesture-handler": [
++        "../../server/victory-chart-renderer/stubs/react-native-gesture-handler.ts"
++      ]
++    }
++  }
++}
diff --git a/server/tsconfig.json b/server/tsconfig.json
index 0c0135bb370d..f739bda96471 100644
--- a/server/tsconfig.json
+++ b/server/tsconfig.json
@@ -1,7 +1,26 @@
 {
     "extends": "../tsconfig.json",
     "compilerOptions": {
-        "types": ["@types/bun"]
+        "types": ["@types/bun"],
+        "paths": {
+            "react-native": ["./victory-chart-renderer/stubs/react-native.ts"],
+            "react-native-reanimated": ["./victory-chart-renderer/stubs/react-native-reanimated.js"],
+            "react-native-gesture-handler": ["./victory-chart-renderer/stubs/react-native-gesture-handler.ts"],
+            "@assets/*": ["../assets/*"],
+            "@components/*": ["../src/components/*"],
+            "@github/*": ["../.github/*"],
+            "@hooks/*": ["../src/hooks/*"],
+            "@libs/*": ["../src/libs/*"],
+            "@navigation/*": ["../src/libs/Navigation/*"],
+            "@pages/*": ["../src/pages/*"],
+            "@prompts/*": ["../prompts/*"],
+            "@scripts/*": ["../scripts/*", "../.github/scripts/*"],
+            "@styles/*": ["../src/styles/*"],
+            "@src/*": ["../src/*"],
+            "@userActions/*": ["../src/libs/actions/*"],
+            "@selectors/*": ["../src/selectors/*"],
+            "tests/*": ["../tests/*"]
+        }
     },
-    "include": ["victory-chart-renderer/src", "victory-chart-renderer/__tests__"]
+    "include": ["victory-chart-renderer/src", "victory-chart-renderer/__tests__", "victory-chart-renderer/stubs"]
 }
diff --git a/server/victory-chart-renderer/src/cli.ts b/server/victory-chart-renderer/src/cli.ts
index 59adda34eb26..196e9a18b6b1 100644
--- a/server/victory-chart-renderer/src/cli.ts
+++ b/server/victory-chart-renderer/src/cli.ts
@@ -1,12 +1,68 @@
+/* eslint-disable no-restricted-imports */
+import {LoadSkiaWeb} from '@shopify/react-native-skia/lib/module/web/LoadSkiaWeb';
+import {JsiSkApi} from '@shopify/react-native-skia/lib/module/skia/web';
+import {drawOffscreen, makeOffscreenSurface} from '@shopify/react-native-skia/lib/module/headless';
 import CLI from '@scripts/utils/CLI';
 
 const cli = new CLI({
     positionalArgs: [
         {
             name: 'outPath',
-            description: 'Output file path for the scaffold message',
+            description: 'Path to write the rendered PNG to',
         },
     ],
 });
 
-await Bun.write(cli.positionalArgs.outPath, 'victory-chart-renderer scaffolded\n');
+await LoadSkiaWeb();
+
+// The `Skia` named export from `@shopify/react-native-skia` is captured at
+// module load time from `globalThis.SkiaApi` (the native bridge on iOS/Android).
+// Off-device we have to seed it ourselves before anything imports `victory-native`,
+// which calls `Skia.Path.Make()` from inside `useBarPath`.
+(globalThis as unknown as {SkiaApi: ReturnType<typeof JsiSkApi>}).SkiaApi = JsiSkApi(
+    (globalThis as unknown as {CanvasKit: unknown}).CanvasKit,
+);
+
+const [React, victoryNative] = await Promise.all([import('react'), import('victory-native')]);
+const {createElement} = React;
+const {Bar, CartesianChart} = victoryNative;
+
+const width = 400;
+const height = 250;
+
+const data: Array<{x: number; y: number}> = [
+    {x: 1, y: 60},
+    {x: 2, y: 90},
+];
+
+const chartElement = createElement(
+    CartesianChart<(typeof data)[number], 'x', 'y'>,
+    {
+        data,
+        xKey: 'x',
+        yKeys: ['y'],
+        domain: {x: [0, 3], y: [0, 100]},
+        domainPadding: {left: 40, right: 40, top: 20, bottom: 20},
+        padding: 20,
+        explicitSize: {width, height},
+        headless: true,
+        xAxis: {lineColor: '#111827', lineWidth: 2},
+        yAxis: [{yKeys: ['y'], lineColor: '#111827', lineWidth: 2}],
+    },
+    ({points, chartBounds}) =>
+        createElement(Bar, {
+            points: points.y,
+            chartBounds,
+            color: '#4f46e5',
+            innerPadding: 0.45,
+        }),
+);
+
+using surface = makeOffscreenSurface(width, height);
+using image = await drawOffscreen(surface, chartElement);
+const pngBytes = image.encodeToBytes();
+if (!pngBytes) {
+    throw new Error('Skia failed to encode the rendered chart to PNG bytes');
+}
+
+await Bun.write(cli.positionalArgs.outPath, pngBytes);
diff --git a/server/victory-chart-renderer/stubs/react-native-gesture-handler.ts b/server/victory-chart-renderer/stubs/react-native-gesture-handler.ts
new file mode 100644
index 000000000000..99e666b1cc57
--- /dev/null
+++ b/server/victory-chart-renderer/stubs/react-native-gesture-handler.ts
@@ -0,0 +1,100 @@
+// Minimal `react-native-gesture-handler` stub for the headless Bun renderer.
+//
+// The chart code builds gesture configs (`Gesture.Pan()...`) and composes
+// them (`Gesture.Race`, `Gesture.Simultaneous`) during render even when no
+// gesture is ever attached. We expose a chainable builder so those calls
+// succeed; the headless branch of `CartesianChart` early-returns before any
+// gesture handlers would actually be attached.
+
+import type {FunctionComponent, PropsWithChildren} from 'react';
+
+class GestureBuilder {
+    onTouchesDown(): this {
+        return this;
+    }
+
+    onStart(): this {
+        return this;
+    }
+
+    onTouchesMove(): this {
+        return this;
+    }
+
+    onTouchesUp(): this {
+        return this;
+    }
+
+    onEnd(): this {
+        return this;
+    }
+
+    onFinalize(): this {
+        return this;
+    }
+
+    onChange(): this {
+        return this;
+    }
+
+    onUpdate(): this {
+        return this;
+    }
+
+    activateAfterLongPress(): this {
+        return this;
+    }
+
+    activeOffsetX(): this {
+        return this;
+    }
+
+    activeOffsetY(): this {
+        return this;
+    }
+
+    failOffsetX(): this {
+        return this;
+    }
+
+    failOffsetY(): this {
+        return this;
+    }
+
+    minDistance(): this {
+        return this;
+    }
+
+    enabled(): this {
+        return this;
+    }
+
+    runOnJS(): this {
+        return this;
+    }
+}
+
+const buildGesture = (): GestureBuilder => new GestureBuilder();
+
+export const Gesture = {
+    Pan: buildGesture,
+    Pinch: buildGesture,
+    Rotation: buildGesture,
+    Tap: buildGesture,
+    LongPress: buildGesture,
+    Manual: buildGesture,
+    Race: (..._gestures: unknown[]) => buildGesture(),
+    Simultaneous: (..._gestures: unknown[]) => buildGesture(),
+    Exclusive: (..._gestures: unknown[]) => buildGesture(),
+};
+
+const noopComponent: FunctionComponent<PropsWithChildren<unknown>> = () => null;
+
+export const GestureHandlerRootView = noopComponent;
+export const GestureDetector = noopComponent;
+
+export type ComposedGesture = GestureBuilder;
+export type GestureType = GestureBuilder;
+export type PanGesture = GestureBuilder;
+export type PinchGesture = GestureBuilder;
+export type TouchData = {id: number; x: number; y: number; absoluteX: number; absoluteY: number};
diff --git a/server/victory-chart-renderer/stubs/react-native-reanimated.js b/server/victory-chart-renderer/stubs/react-native-reanimated.js
new file mode 100644
index 000000000000..316b6f770c80
--- /dev/null
+++ b/server/victory-chart-renderer/stubs/react-native-reanimated.js
@@ -0,0 +1,66 @@
+// Minimal `react-native-reanimated` stub for the headless Bun renderer.
+//
+// `victory-native`'s `CartesianChart` calls `useSharedValue` while rendering,
+// and `@shopify/react-native-skia`'s `ReanimatedProxy` reads arbitrary
+// properties off the module at load (`Rea.createWorkletRuntime`,
+// `Rea.runOnRuntime`, etc.). Both must keep working without a real Reanimated
+// runtime mounted.
+//
+// To handle both shapes uniformly, the module exports a Proxy. ES-style named
+// imports (`import { useSharedValue } from ...`) read the proxy's own
+// properties; default imports (`import Rea from ...`) get the same proxy,
+// which falls back to a no-op function for any unknown property.
+//
+// This file is CommonJS so that Bun returns the proxy directly from
+// `require("react-native-reanimated")`.
+'use strict';
+
+const noopFn = () => undefined;
+
+const noopComponent = () => null;
+
+const sharedValue = (initialValue) => ({value: initialValue});
+
+const exports_ = {
+    __esModule: true,
+    default: undefined,
+    useSharedValue: sharedValue,
+    useDerivedValue: (updater) => ({value: updater()}),
+    useAnimatedStyle: (factory) => factory(),
+    useAnimatedReaction: noopFn,
+    makeMutable: sharedValue,
+    isSharedValue: (value) => typeof value === 'object' && value !== null && 'value' in value,
+    runOnJS: (fn) => fn,
+    runOnUI: (fn) => fn,
+    withTiming: (value) => value,
+    withSpring: (value) => value,
+    withDecay: noopFn,
+    withDelay: (_delay, animation) => animation,
+    withRepeat: (animation) => animation,
+    cancelAnimation: noopFn,
+    Easing: {
+        linear: () => 0,
+        ease: () => 0,
+        bezier: () => () => 0,
+        inOut: (fn) => fn,
+    },
+    View: noopComponent,
+    Image: noopComponent,
+    Text: noopComponent,
+    ScrollView: noopComponent,
+};
+
+const proxy = new Proxy(exports_, {
+    get(target, prop) {
+        if (prop in target) {
+            return target[prop];
+        }
+        return noopFn;
+    },
+});
+
+// Match Reanimated's runtime shape: `import Rea from "react-native-reanimated"`
+// returns the same proxy as `import * as Rea`.
+exports_.default = proxy;
+
+module.exports = proxy;
diff --git a/server/victory-chart-renderer/stubs/react-native-subpath.ts b/server/victory-chart-renderer/stubs/react-native-subpath.ts
new file mode 100644
index 000000000000..2628d084bcea
--- /dev/null
+++ b/server/victory-chart-renderer/stubs/react-native-subpath.ts
@@ -0,0 +1,12 @@
+// Catch-all stub for `react-native/Libraries/...` style deep imports that
+// `@shopify/react-native-skia` emits at module load (e.g. its native picture
+// view spec or asset registry helpers). None of those code paths execute in
+// the Bun headless renderer, but their import statements still run, so this
+// stub provides a no-op default plus a few of the most common named exports.
+
+const noopFunction = (..._args: unknown[]) => null;
+
+export default noopFunction;
+export const codegenNativeComponent = noopFunction;
+export const registerAsset = (asset: unknown) => asset;
+export const getAssetByID = () => null;
diff --git a/server/victory-chart-renderer/stubs/react-native.ts b/server/victory-chart-renderer/stubs/react-native.ts
new file mode 100644
index 000000000000..9da326cc8c50
--- /dev/null
+++ b/server/victory-chart-renderer/stubs/react-native.ts
@@ -0,0 +1,65 @@
+// Minimal `react-native` stub for the headless Bun renderer.
+//
+// `react-native`'s real entry pulls in Flow-typed sources, native module
+// bindings, and Metro-resolved platform extensions that Bun cannot parse.
+// Our headless `CartesianChart` path never mounts a real RN view tree, so we
+// only need enough surface area for `victory-native` and `@shopify/react-native-skia`
+// modules to load without throwing while we walk the headless branch.
+//
+// Anything that escapes into the render tree (e.g. `<View>`) renders to `null`
+// so it cannot affect the Skia output.
+
+import type {FunctionComponent, PropsWithChildren} from 'react';
+
+const noopComponent: FunctionComponent<PropsWithChildren<unknown>> = () => null;
+
+export type LayoutChangeEvent = {nativeEvent: {layout: {x: number; y: number; width: number; height: number}}};
+export type ViewStyle = Record<string, unknown>;
+export type ImageStyle = Record<string, unknown>;
+export type TextStyle = Record<string, unknown>;
+export type StyleProp<T> = T | T[] | null | undefined | false;
+export type TransformsStyle = Record<string, unknown>;
+
+export const View = noopComponent;
+export const Image = Object.assign(noopComponent, {
+    resolveAssetSource: (source: unknown) => {
+        if (source && typeof source === 'object' && 'uri' in (source as Record<string, unknown>)) {
+            return source as {uri: string};
+        }
+        return {uri: String(source)};
+    },
+});
+export const Text = noopComponent;
+
+export const Platform = {
+    OS: 'web' as const,
+    Version: 0,
+    select: <T,>(specifics: {default?: T; web?: T}) => specifics.web ?? specifics.default,
+};
+
+export const PixelRatio = {
+    get: () => 1,
+    getFontScale: () => 1,
+    getPixelSizeForLayoutSize: (size: number) => size,
+    roundToNearestPixel: (size: number) => size,
+};
+
+export const StyleSheet = {
+    create: <T,>(styles: T): T => styles,
+    flatten: <T,>(style: T): T => style,
+    compose: <T,>(a: T, _b: T): T => a,
+    hairlineWidth: 1,
+    absoluteFill: {},
+    absoluteFillObject: {},
+};
+
+export const findNodeHandle = () => null;
+
+// `@shopify/react-native-skia/lib/module/specs/NativeSkiaModule` does
+// `TurboModuleRegistry.getEnforcing("RNSkiaModule")` at module load. The
+// `Platform.OS === "web"` short-circuit in NativeSetup then ignores the
+// missing module, but we still need the named export to exist.
+export const TurboModuleRegistry = {
+    get: (_name: string): null => null,
+    getEnforcing: (_name: string): null => null,
+};

From 390a69ef98f0004a0898bb00950310d14f62bbf9 Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 25 May 2026 19:35:53 -0700
Subject: [PATCH 03/21] Wire up reference-PNG test, JSX entry, and lint config

- Switch the Bun CLI to a `.tsx` JSX entry so the chart tree types cleanly
  through `createElement` without `react/no-children-prop` workarounds.
- Replace the PR 2 hello-world smoke test with a real reference-PNG check
  that decodes the rendered PNG with `pngjs`, compares it to a checked-in
  `__tests__/__golden__/smoke.png` via `pixelmatch`, and supports
  `UPDATE_GOLDEN=1 bun test` to regenerate the golden intentionally.
- Extend the server ESLint overrides to cover `.tsx` so the subproject's
  Bun-aware tsconfig wins for the new entry point.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 config/eslint/eslint.config.mjs               |   2 +-
 package.json                                  |   6 +-
 .../__tests__/__golden__/smoke.png            | Bin 0 -> 1951 bytes
 .../__tests__/cli.test.ts                     |  54 +++++++++++++---
 .../src/{cli.ts => cli.tsx}                   |  61 +++++++++---------
 .../stubs/react-native-gesture-handler.ts     |  26 ++++----
 .../stubs/react-native-reanimated.js          |   9 ++-
 .../stubs/react-native-subpath.ts             |  10 +--
 .../stubs/react-native.ts                     |  44 +++++++------
 9 files changed, 127 insertions(+), 85 deletions(-)
 create mode 100644 server/victory-chart-renderer/__tests__/__golden__/smoke.png
 rename server/victory-chart-renderer/src/{cli.ts => cli.tsx} (52%)

diff --git a/config/eslint/eslint.config.mjs b/config/eslint/eslint.config.mjs
index 329caf5f6170..0c39109f1954 100644
--- a/config/eslint/eslint.config.mjs
+++ b/config/eslint/eslint.config.mjs
@@ -736,7 +736,7 @@ const config = defineConfig([
     },
 
     {
-        files: ['server/**/*.ts'],
+        files: ['server/**/*.ts', 'server/**/*.tsx'],
         languageOptions: {
             parserOptions: {
                 project: path.resolve(projectRoot, 'server/tsconfig.json'),
diff --git a/package.json b/package.json
index 09b5eec2a725..7ffc5121477d 100644
--- a/package.json
+++ b/package.json
@@ -72,9 +72,9 @@
     "web:dist": "concurrently \"npm:web-proxy\" \"http-server ./dist --cors --port 8080 -P http://localhost:9000\"",
     "octokit": "cd scripts && npx ts-node -i -e \"$(cat ./octokit.ts)\"",
     "compress-svg": "npx ts-node scripts/compressSvg.ts --dir assets/images && npx ts-node scripts/compressSvg.ts --dir docs/assets/images",
-    "server:vcr:dev": "bun --conditions=react-native run server/victory-chart-renderer/src/cli.ts",
-    "server:vcr:build:linux": "bun build --conditions=react-native --compile --target=bun-linux-x64 server/victory-chart-renderer/src/cli.ts --outfile server/victory-chart-renderer/dist/victory-chart-renderer-linux-x64",
-    "server:vcr:build:macos": "bun build --conditions=react-native --compile --target=bun-darwin-arm64 server/victory-chart-renderer/src/cli.ts --outfile server/victory-chart-renderer/dist/victory-chart-renderer-darwin-arm64"
+    "server:vcr:dev": "bun --conditions=react-native run server/victory-chart-renderer/src/cli.tsx",
+    "server:vcr:build:linux": "bun build --conditions=react-native --compile --target=bun-linux-x64 server/victory-chart-renderer/src/cli.tsx --outfile server/victory-chart-renderer/dist/victory-chart-renderer-linux-x64",
+    "server:vcr:build:macos": "bun build --conditions=react-native --compile --target=bun-darwin-arm64 server/victory-chart-renderer/src/cli.tsx --outfile server/victory-chart-renderer/dist/victory-chart-renderer-darwin-arm64"
   },
   "dependencies": {
     "@babel/runtime": "^7.25.0",
diff --git a/server/victory-chart-renderer/__tests__/__golden__/smoke.png b/server/victory-chart-renderer/__tests__/__golden__/smoke.png
new file mode 100644
index 0000000000000000000000000000000000000000..39916a7162d5dfffe98ded4f80e17b54165f2d87
GIT binary patch
diff --git a/server/victory-chart-renderer/__tests__/cli.test.ts b/server/victory-chart-renderer/__tests__/cli.test.ts
index af8ade0c59ca..323c467e604c 100644
--- a/server/victory-chart-renderer/__tests__/cli.test.ts
+++ b/server/victory-chart-renderer/__tests__/cli.test.ts
@@ -1,25 +1,63 @@
 import {expect, test} from 'bun:test';
 import {spawnSync} from 'node:child_process';
-import {mkdtempSync, readFileSync, rmSync} from 'node:fs';
+import {copyFileSync, mkdtempSync, readFileSync, rmSync} from 'node:fs';
 import {tmpdir} from 'node:os';
 import {join} from 'node:path';
+import pixelmatch from 'pixelmatch';
+import {PNG} from 'pngjs';
 
-const projectRoot = join(import.meta.dir, '../../..');
-const cliPath = join(projectRoot, 'server/victory-chart-renderer/src/cli.ts');
+const cliDir = join(import.meta.dir, '..');
+const projectRoot = join(cliDir, '../..');
+const cliPath = join(cliDir, 'src/cli.tsx');
+const goldenPath = join(cliDir, '__tests__/__golden__/smoke.png');
 
-test('CLI writes the scaffold message to outPath', () => {
-    const tempDir = mkdtempSync(join(tmpdir(), 'vcr-cli-test-'));
-    const outPath = join(tempDir, 'out.txt');
+const EXPECTED_WIDTH = 400;
+const EXPECTED_HEIGHT = 250;
+
+// Set `UPDATE_GOLDEN=1` while running the test to refresh the reference PNG.
+// Intentionally separate from the comparison flow so a regression can't quietly
+// rewrite the golden.
+const UPDATE_GOLDEN = process.env.UPDATE_GOLDEN === '1';
+
+test('CLI renders a chart whose PNG matches the golden reference', () => {
+    const tempDir = mkdtempSync(join(tmpdir(), 'vcr-smoke-'));
+    const actualPath = join(tempDir, 'smoke-actual.png');
 
     try {
-        const result = spawnSync('bun', ['run', cliPath, outPath], {
+        const result = spawnSync('bun', ['--conditions=react-native', 'run', cliPath, actualPath], {
             cwd: projectRoot,
             encoding: 'utf8',
         });
 
         expect(result.status).toBe(0);
-        expect(readFileSync(outPath, 'utf8')).toBe('victory-chart-renderer scaffolded\n');
+
+        if (UPDATE_GOLDEN) {
+            copyFileSync(actualPath, goldenPath);
+        }
+
+        const actual = PNG.sync.read(readFileSync(actualPath));
+        const golden = PNG.sync.read(readFileSync(goldenPath));
+
+        expect(actual.width).toBe(EXPECTED_WIDTH);
+        expect(actual.height).toBe(EXPECTED_HEIGHT);
+        expect(golden.width).toBe(EXPECTED_WIDTH);
+        expect(golden.height).toBe(EXPECTED_HEIGHT);
+
+        const diff = new PNG({width: EXPECTED_WIDTH, height: EXPECTED_HEIGHT});
+        const mismatchedPixels = pixelmatch(actual.data, golden.data, diff.data, EXPECTED_WIDTH, EXPECTED_HEIGHT, {
+            threshold: 0.1,
+        });
+
+        // Allow a tiny number of antialiasing-driven pixel differences without making the test brittle.
+        const maxAllowedMismatch = Math.floor(EXPECTED_WIDTH * EXPECTED_HEIGHT * 0.001);
+        expect(mismatchedPixels).toBeLessThanOrEqual(maxAllowedMismatch);
     } finally {
         rmSync(tempDir, {recursive: true, force: true});
     }
 });
+
+// Sanity-check that the golden lives where we expect; this keeps an
+// accidentally-deleted reference from looking like a passing test.
+test('golden reference PNG exists on disk', () => {
+    expect(() => readFileSync(goldenPath)).not.toThrow();
+});
diff --git a/server/victory-chart-renderer/src/cli.ts b/server/victory-chart-renderer/src/cli.tsx
similarity index 52%
rename from server/victory-chart-renderer/src/cli.ts
rename to server/victory-chart-renderer/src/cli.tsx
index 196e9a18b6b1..7ab39b846f39 100644
--- a/server/victory-chart-renderer/src/cli.ts
+++ b/server/victory-chart-renderer/src/cli.tsx
@@ -1,7 +1,6 @@
-/* eslint-disable no-restricted-imports */
-import {LoadSkiaWeb} from '@shopify/react-native-skia/lib/module/web/LoadSkiaWeb';
-import {JsiSkApi} from '@shopify/react-native-skia/lib/module/skia/web';
 import {drawOffscreen, makeOffscreenSurface} from '@shopify/react-native-skia/lib/module/headless';
+import {JsiSkApi} from '@shopify/react-native-skia/lib/module/skia/web';
+import {LoadSkiaWeb} from '@shopify/react-native-skia/lib/module/web/LoadSkiaWeb';
 import CLI from '@scripts/utils/CLI';
 
 const cli = new CLI({
@@ -19,43 +18,43 @@ await LoadSkiaWeb();
 // module load time from `globalThis.SkiaApi` (the native bridge on iOS/Android).
 // Off-device we have to seed it ourselves before anything imports `victory-native`,
 // which calls `Skia.Path.Make()` from inside `useBarPath`.
-(globalThis as unknown as {SkiaApi: ReturnType<typeof JsiSkApi>}).SkiaApi = JsiSkApi(
-    (globalThis as unknown as {CanvasKit: unknown}).CanvasKit,
-);
+const globalScope = globalThis as unknown as {CanvasKit: Parameters<typeof JsiSkApi>[0]; SkiaApi: ReturnType<typeof JsiSkApi>};
+globalScope.SkiaApi = JsiSkApi(globalScope.CanvasKit);
+
+const {Bar, CartesianChart} = await import('victory-native');
 
-const [React, victoryNative] = await Promise.all([import('react'), import('victory-native')]);
-const {createElement} = React;
-const {Bar, CartesianChart} = victoryNative;
+type ChartDatum = {x: number; y: number};
 
 const width = 400;
 const height = 250;
 
-const data: Array<{x: number; y: number}> = [
+const data: ChartDatum[] = [
     {x: 1, y: 60},
     {x: 2, y: 90},
 ];
 
-const chartElement = createElement(
-    CartesianChart<(typeof data)[number], 'x', 'y'>,
-    {
-        data,
-        xKey: 'x',
-        yKeys: ['y'],
-        domain: {x: [0, 3], y: [0, 100]},
-        domainPadding: {left: 40, right: 40, top: 20, bottom: 20},
-        padding: 20,
-        explicitSize: {width, height},
-        headless: true,
-        xAxis: {lineColor: '#111827', lineWidth: 2},
-        yAxis: [{yKeys: ['y'], lineColor: '#111827', lineWidth: 2}],
-    },
-    ({points, chartBounds}) =>
-        createElement(Bar, {
-            points: points.y,
-            chartBounds,
-            color: '#4f46e5',
-            innerPadding: 0.45,
-        }),
+const chartElement = (
+    <CartesianChart<ChartDatum, 'x', 'y'>
+        data={data}
+        xKey="x"
+        yKeys={['y']}
+        domain={{x: [0, 3], y: [0, 100]}}
+        domainPadding={{left: 40, right: 40, top: 20, bottom: 20}}
+        padding={20}
+        explicitSize={{width, height}}
+        headless
+        xAxis={{lineColor: '#111827', lineWidth: 2}}
+        yAxis={[{yKeys: ['y'], lineColor: '#111827', lineWidth: 2}]}
+    >
+        {({points, chartBounds}) => (
+            <Bar
+                points={points.y}
+                chartBounds={chartBounds}
+                color="#4f46e5"
+                innerPadding={0.45}
+            />
+        )}
+    </CartesianChart>
 );
 
 using surface = makeOffscreenSurface(width, height);
diff --git a/server/victory-chart-renderer/stubs/react-native-gesture-handler.ts b/server/victory-chart-renderer/stubs/react-native-gesture-handler.ts
index 99e666b1cc57..fc6d63e1893f 100644
--- a/server/victory-chart-renderer/stubs/react-native-gesture-handler.ts
+++ b/server/victory-chart-renderer/stubs/react-native-gesture-handler.ts
@@ -5,7 +5,6 @@
 // gesture is ever attached. We expose a chainable builder so those calls
 // succeed; the headless branch of `CartesianChart` early-returns before any
 // gesture handlers would actually be attached.
-
 import type {FunctionComponent, PropsWithChildren} from 'react';
 
 class GestureBuilder {
@@ -76,25 +75,28 @@ class GestureBuilder {
 
 const buildGesture = (): GestureBuilder => new GestureBuilder();
 
-export const Gesture = {
+const Gesture = {
     Pan: buildGesture,
     Pinch: buildGesture,
     Rotation: buildGesture,
     Tap: buildGesture,
     LongPress: buildGesture,
     Manual: buildGesture,
-    Race: (..._gestures: unknown[]) => buildGesture(),
-    Simultaneous: (..._gestures: unknown[]) => buildGesture(),
-    Exclusive: (..._gestures: unknown[]) => buildGesture(),
+    Race: () => buildGesture(),
+    Simultaneous: () => buildGesture(),
+    Exclusive: () => buildGesture(),
 };
 
 const noopComponent: FunctionComponent<PropsWithChildren<unknown>> = () => null;
 
-export const GestureHandlerRootView = noopComponent;
-export const GestureDetector = noopComponent;
+const GestureHandlerRootView = noopComponent;
+const GestureDetector = noopComponent;
+
+type ComposedGesture = GestureBuilder;
+type GestureType = GestureBuilder;
+type PanGesture = GestureBuilder;
+type PinchGesture = GestureBuilder;
+type TouchData = {id: number; x: number; y: number; absoluteX: number; absoluteY: number};
 
-export type ComposedGesture = GestureBuilder;
-export type GestureType = GestureBuilder;
-export type PanGesture = GestureBuilder;
-export type PinchGesture = GestureBuilder;
-export type TouchData = {id: number; x: number; y: number; absoluteX: number; absoluteY: number};
+export type {ComposedGesture, GestureType, PanGesture, PinchGesture, TouchData};
+export {Gesture, GestureHandlerRootView, GestureDetector};
diff --git a/server/victory-chart-renderer/stubs/react-native-reanimated.js b/server/victory-chart-renderer/stubs/react-native-reanimated.js
index 316b6f770c80..3ee122c66df2 100644
--- a/server/victory-chart-renderer/stubs/react-native-reanimated.js
+++ b/server/victory-chart-renderer/stubs/react-native-reanimated.js
@@ -13,7 +13,6 @@
 //
 // This file is CommonJS so that Bun returns the proxy directly from
 // `require("react-native-reanimated")`.
-'use strict';
 
 const noopFn = () => undefined;
 
@@ -21,7 +20,7 @@ const noopComponent = () => null;
 
 const sharedValue = (initialValue) => ({value: initialValue});
 
-const exports_ = {
+const namedExports = {
     __esModule: true,
     default: undefined,
     useSharedValue: sharedValue,
@@ -35,7 +34,7 @@ const exports_ = {
     withTiming: (value) => value,
     withSpring: (value) => value,
     withDecay: noopFn,
-    withDelay: (_delay, animation) => animation,
+    withDelay: (delay, animation) => animation,
     withRepeat: (animation) => animation,
     cancelAnimation: noopFn,
     Easing: {
@@ -50,7 +49,7 @@ const exports_ = {
     ScrollView: noopComponent,
 };
 
-const proxy = new Proxy(exports_, {
+const proxy = new Proxy(namedExports, {
     get(target, prop) {
         if (prop in target) {
             return target[prop];
@@ -61,6 +60,6 @@ const proxy = new Proxy(exports_, {
 
 // Match Reanimated's runtime shape: `import Rea from "react-native-reanimated"`
 // returns the same proxy as `import * as Rea`.
-exports_.default = proxy;
+namedExports.default = proxy;
 
 module.exports = proxy;
diff --git a/server/victory-chart-renderer/stubs/react-native-subpath.ts b/server/victory-chart-renderer/stubs/react-native-subpath.ts
index 2628d084bcea..0eda28658b0f 100644
--- a/server/victory-chart-renderer/stubs/react-native-subpath.ts
+++ b/server/victory-chart-renderer/stubs/react-native-subpath.ts
@@ -4,9 +4,11 @@
 // the Bun headless renderer, but their import statements still run, so this
 // stub provides a no-op default plus a few of the most common named exports.
 
-const noopFunction = (..._args: unknown[]) => null;
+const noopFunction = () => null;
 
+const codegenNativeComponent = noopFunction;
+const registerAsset = (asset: unknown) => asset;
+const getAssetByID = () => null;
+
+export {codegenNativeComponent, registerAsset, getAssetByID};
 export default noopFunction;
-export const codegenNativeComponent = noopFunction;
-export const registerAsset = (asset: unknown) => asset;
-export const getAssetByID = () => null;
diff --git a/server/victory-chart-renderer/stubs/react-native.ts b/server/victory-chart-renderer/stubs/react-native.ts
index 9da326cc8c50..12840f238608 100644
--- a/server/victory-chart-renderer/stubs/react-native.ts
+++ b/server/victory-chart-renderer/stubs/react-native.ts
@@ -8,20 +8,19 @@
 //
 // Anything that escapes into the render tree (e.g. `<View>`) renders to `null`
 // so it cannot affect the Skia output.
-
 import type {FunctionComponent, PropsWithChildren} from 'react';
 
 const noopComponent: FunctionComponent<PropsWithChildren<unknown>> = () => null;
 
-export type LayoutChangeEvent = {nativeEvent: {layout: {x: number; y: number; width: number; height: number}}};
-export type ViewStyle = Record<string, unknown>;
-export type ImageStyle = Record<string, unknown>;
-export type TextStyle = Record<string, unknown>;
-export type StyleProp<T> = T | T[] | null | undefined | false;
-export type TransformsStyle = Record<string, unknown>;
+type LayoutChangeEvent = {nativeEvent: {layout: {x: number; y: number; width: number; height: number}}};
+type ViewStyle = Record<string, unknown>;
+type ImageStyle = Record<string, unknown>;
+type TextStyle = Record<string, unknown>;
+type StyleProp<T> = T | T[] | null | undefined | false;
+type TransformsStyle = Record<string, unknown>;
 
-export const View = noopComponent;
-export const Image = Object.assign(noopComponent, {
+const View = noopComponent;
+const Image = Object.assign(noopComponent, {
     resolveAssetSource: (source: unknown) => {
         if (source && typeof source === 'object' && 'uri' in (source as Record<string, unknown>)) {
             return source as {uri: string};
@@ -29,37 +28,40 @@ export const Image = Object.assign(noopComponent, {
         return {uri: String(source)};
     },
 });
-export const Text = noopComponent;
+const Text = noopComponent;
 
-export const Platform = {
+const Platform = {
     OS: 'web' as const,
     Version: 0,
-    select: <T,>(specifics: {default?: T; web?: T}) => specifics.web ?? specifics.default,
+    select: <T>(specifics: {default?: T; web?: T}) => specifics.web ?? specifics.default,
 };
 
-export const PixelRatio = {
+const PixelRatio = {
     get: () => 1,
     getFontScale: () => 1,
     getPixelSizeForLayoutSize: (size: number) => size,
     roundToNearestPixel: (size: number) => size,
 };
 
-export const StyleSheet = {
-    create: <T,>(styles: T): T => styles,
-    flatten: <T,>(style: T): T => style,
-    compose: <T,>(a: T, _b: T): T => a,
+const StyleSheet = {
+    create: <T>(styles: T): T => styles,
+    flatten: <T>(style: T): T => style,
+    compose: <T>(a: T): T => a,
     hairlineWidth: 1,
     absoluteFill: {},
     absoluteFillObject: {},
 };
 
-export const findNodeHandle = () => null;
+const findNodeHandle = () => null;
 
 // `@shopify/react-native-skia/lib/module/specs/NativeSkiaModule` does
 // `TurboModuleRegistry.getEnforcing("RNSkiaModule")` at module load. The
 // `Platform.OS === "web"` short-circuit in NativeSetup then ignores the
 // missing module, but we still need the named export to exist.
-export const TurboModuleRegistry = {
-    get: (_name: string): null => null,
-    getEnforcing: (_name: string): null => null,
+const TurboModuleRegistry = {
+    get: (): null => null,
+    getEnforcing: (): null => null,
 };
+
+export type {LayoutChangeEvent, ViewStyle, ImageStyle, TextStyle, StyleProp, TransformsStyle};
+export {View, Image, Text, Platform, PixelRatio, StyleSheet, findNodeHandle, TurboModuleRegistry};

From 7abe629bc294846647502dd75b480c120f9b80a9 Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Thu, 28 May 2026 09:23:44 -0700
Subject: [PATCH 04/21] Move VCR to npm workspace with stub packages

Replace patch-package tsconfig stubs with @expensify/victory-chart-renderer
workspace: file: RN stub packages, root server:vcr:* delegates via -w, and
Bun bundler plugin for dev/compile because victory-native hoists to root.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 config/eslint/eslint.config.mjs               |  2 +-
 package-lock.json                             | 59 +++++++++++++++++--
 package.json                                  | 21 +++----
 ...hopify+react-native-skia+2.4.14.details.md | 21 -------
 .../@shopify+react-native-skia+2.4.14.patch   | 25 --------
 patches/victory-native/details.md             | 17 ------
 ...victory-native+41.21.0+002+bun-stubs.patch | 22 -------
 server/tsconfig.json                          | 26 --------
 server/victory-chart-renderer/.gitignore      |  3 +
 server/victory-chart-renderer/README.md       | 21 +++++--
 server/victory-chart-renderer/package.json    | 39 ++++++++++++
 .../victory-chart-renderer/scripts/build.ts   | 30 ++++++++++
 server/victory-chart-renderer/scripts/dev.ts  | 41 +++++++++++++
 .../scripts/rnStubPlugin.ts                   | 30 ++++++++++
 .../index.ts}                                 |  0
 .../react-native-gesture-handler/package.json |  7 +++
 .../index.js}                                 |  0
 .../react-native-reanimated/package.json      |  6 ++
 .../index.ts}                                 |  0
 .../stubs/react-native/package.json           | 11 ++++
 .../subpath.ts}                               |  0
 .../victory-chart-renderer/tests/cli.test.ts  |  8 +--
 server/victory-chart-renderer/tsconfig.json   | 10 ++++
 23 files changed, 259 insertions(+), 140 deletions(-)
 delete mode 100644 patches/@shopify+react-native-skia+2.4.14.details.md
 delete mode 100644 patches/@shopify+react-native-skia+2.4.14.patch
 delete mode 100644 patches/victory-native/victory-native+41.21.0+002+bun-stubs.patch
 delete mode 100644 server/tsconfig.json
 create mode 100644 server/victory-chart-renderer/.gitignore
 create mode 100644 server/victory-chart-renderer/package.json
 create mode 100644 server/victory-chart-renderer/scripts/build.ts
 create mode 100644 server/victory-chart-renderer/scripts/dev.ts
 create mode 100644 server/victory-chart-renderer/scripts/rnStubPlugin.ts
 rename server/victory-chart-renderer/stubs/{react-native-gesture-handler.ts => react-native-gesture-handler/index.ts} (100%)
 create mode 100644 server/victory-chart-renderer/stubs/react-native-gesture-handler/package.json
 rename server/victory-chart-renderer/stubs/{react-native-reanimated.js => react-native-reanimated/index.js} (100%)
 create mode 100644 server/victory-chart-renderer/stubs/react-native-reanimated/package.json
 rename server/victory-chart-renderer/stubs/{react-native.ts => react-native/index.ts} (100%)
 create mode 100644 server/victory-chart-renderer/stubs/react-native/package.json
 rename server/victory-chart-renderer/stubs/{react-native-subpath.ts => react-native/subpath.ts} (100%)
 create mode 100644 server/victory-chart-renderer/tsconfig.json

diff --git a/config/eslint/eslint.config.mjs b/config/eslint/eslint.config.mjs
index b8edb82f57a8..b7ca91c8f671 100644
--- a/config/eslint/eslint.config.mjs
+++ b/config/eslint/eslint.config.mjs
@@ -739,7 +739,7 @@ const config = defineConfig([
         files: ['server/**/*.ts', 'server/**/*.tsx'],
         languageOptions: {
             parserOptions: {
-                project: path.resolve(projectRoot, 'server/tsconfig.json'),
+                project: path.resolve(projectRoot, 'server/victory-chart-renderer/tsconfig.json'),
             },
         },
     },
diff --git a/package-lock.json b/package-lock.json
index 34384f0566f7..ddba731701ef 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,9 @@
       "version": "9.3.84-0",
       "hasInstallScript": true,
       "license": "MIT",
+      "workspaces": [
+        "server/victory-chart-renderer"
+      ],
       "dependencies": {
         "@babel/runtime": "^7.25.0",
         "@dnd-kit/core": "^6.3.1",
@@ -206,7 +209,6 @@
         "@types/mime-db": "^1.43.5",
         "@types/node": "^20.11.5",
         "@types/pako": "^2.0.3",
-        "@types/pngjs": "^6.0.5",
         "@types/pusher-js": "^5.1.0",
         "@types/react": "^19.2.0",
         "@types/react-collapse": "^5.0.1",
@@ -270,8 +272,6 @@
         "openai": "^6.16.0",
         "patch-package": "^8.1.0-canary.1",
         "peggy": "^4.0.3",
-        "pixelmatch": "^7.2.0",
-        "pngjs": "^7.0.0",
         "portfinder": "^1.0.34",
         "prettier": "3.7.4",
         "react-native-clean-project": "^4.0.0-alpha4.0",
@@ -5670,6 +5670,10 @@
         "react-native": "*"
       }
     },
+    "node_modules/@expensify/victory-chart-renderer": {
+      "resolved": "server/victory-chart-renderer",
+      "link": true
+    },
     "node_modules/@expo/code-signing-certificates": {
       "version": "0.0.6",
       "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
@@ -22803,9 +22807,9 @@
     },
     "node_modules/eslint-config-airbnb-typescript/node_modules/eslint-config-airbnb-base": {
       "name": "eslint-config-expensify",
-      "version": "2.0.89",
-      "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.89.tgz",
-      "integrity": "sha512-fI57deNANpTwvq8LMKet7M/D1zGAnzzmsOsQ9m55Psun6MRK9lj1hhFZhV0Q1GGfC8la6adAoGJ7aQz96aWShg==",
+      "version": "2.0.91",
+      "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.91.tgz",
+      "integrity": "sha512-sdVa7o7Cl9JIV2UiENQTue9nlt8sc45B7SyiKdtDizajY7tMK402PEa9ngPIkRt/0xzKOFC+jtPy8pMXg6OSeg==",
       "dev": true,
       "license": "ISC",
       "dependencies": {
@@ -22822,6 +22826,7 @@
         "eslint-plugin-react": "^7.37.5",
         "eslint-plugin-react-hooks": "^5.2.0",
         "eslint-plugin-rulesdir": "^0.2.2",
+        "globals": "^15.14.0",
         "lodash": "^4.17.21",
         "underscore": "^1.13.6"
       }
@@ -40584,6 +40589,48 @@
       "peerDependencies": {
         "zod": "^3.25.0 || ^4.0.0"
       }
+    },
+    "server/victory-chart-renderer": {
+      "name": "@expensify/victory-chart-renderer",
+      "version": "0.0.0",
+      "dependencies": {
+        "@shopify/react-native-skia": "^2.4.14",
+        "react": "19.2.0",
+        "react-native": "file:./stubs/react-native",
+        "react-native-gesture-handler": "file:./stubs/react-native-gesture-handler",
+        "react-native-reanimated": "file:./stubs/react-native-reanimated",
+        "victory-native": "^41.21.0"
+      },
+      "devDependencies": {
+        "@types/bun": "1.3.14",
+        "@types/pngjs": "^6.0.5",
+        "pixelmatch": "^7.2.0",
+        "pngjs": "^7.0.0"
+      },
+      "engines": {
+        "bun": "1.3.14"
+      }
+    },
+    "server/victory-chart-renderer/node_modules/react-native": {
+      "resolved": "server/victory-chart-renderer/stubs/react-native",
+      "link": true
+    },
+    "server/victory-chart-renderer/node_modules/react-native-gesture-handler": {
+      "resolved": "server/victory-chart-renderer/stubs/react-native-gesture-handler",
+      "link": true
+    },
+    "server/victory-chart-renderer/node_modules/react-native-reanimated": {
+      "resolved": "server/victory-chart-renderer/stubs/react-native-reanimated",
+      "link": true
+    },
+    "server/victory-chart-renderer/stubs/react-native": {
+      "version": "0.0.0-stub"
+    },
+    "server/victory-chart-renderer/stubs/react-native-gesture-handler": {
+      "version": "0.0.0-stub"
+    },
+    "server/victory-chart-renderer/stubs/react-native-reanimated": {
+      "version": "0.0.0-stub"
     }
   }
 }
diff --git a/package.json b/package.json
index 6204a241895a..e706b9b89973 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
   "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
   "license": "MIT",
   "private": true,
+  "workspaces": ["server/victory-chart-renderer"],
   "scripts": {
     "i-standalone": "STANDALONE_NEW_DOT=true npm i",
     "install-standalone": "STANDALONE_NEW_DOT=true npm install",
@@ -43,8 +44,8 @@
     "test:verbose": "TZ=utc NODE_OPTIONS=\"--experimental-vm-modules --max_old_space_size=4096\" JEST_VERBOSE=true jest",
     "test:debug": "TZ=utc NODE_OPTIONS='--inspect-brk --experimental-vm-modules' jest --runInBand",
     "perf-test": "NODE_OPTIONS=--experimental-vm-modules npx reassure",
-    "typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc && tsc -p server/tsconfig.json",
-    "typecheck-tsgo": "tsgo --noEmit --incremental --tsBuildInfoFile tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p server/tsconfig.json --incremental --tsBuildInfoFile server/tsconfig.tsgo.tsbuildinfo",
+    "typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc && tsc -p server/victory-chart-renderer/tsconfig.json",
+    "typecheck-tsgo": "tsgo --noEmit --incremental --tsBuildInfoFile tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p server/victory-chart-renderer/tsconfig.json --incremental --tsBuildInfoFile server/victory-chart-renderer/tsconfig.tsgo.tsbuildinfo",
     "lint": "./scripts/lint.sh",
     "lint-changed": "./scripts/lintChanged.sh",
     "lint-watch": "onchange '**/*.{js,jsx,ts,tsx,mjs,cjs}' -- ./scripts/lint.sh {{changed}}",
@@ -72,11 +73,11 @@
     "web:dist": "concurrently \"npm:web-proxy\" \"http-server ./dist --cors --port 8080 -P http://localhost:9000\"",
     "octokit": "cd scripts && npx ts-node -i -e \"$(cat ./octokit.ts)\"",
     "compress-svg": "npx ts-node scripts/compressSvg.ts --dir assets/images && npx ts-node scripts/compressSvg.ts --dir docs/assets/images",
-    "server:vcr:dev": "bun --conditions=react-native run server/victory-chart-renderer/src/cli.tsx",
-    "server:vcr:test": "bun test server/victory-chart-renderer/tests",
-    "server:vcr:build:linux": "bun build --conditions=react-native --compile --target=bun-linux-x64 server/victory-chart-renderer/src/cli.tsx --outfile server/victory-chart-renderer/dist/victory-chart-renderer-linux-x64",
-    "server:vcr:build:linux-arm": "bun build --conditions=react-native --compile --target=bun-linux-arm64 server/victory-chart-renderer/src/cli.tsx --outfile server/victory-chart-renderer/dist/victory-chart-renderer-linux-arm64",
-    "server:vcr:build:macos": "bun build --conditions=react-native --compile --target=bun-darwin-arm64 server/victory-chart-renderer/src/cli.tsx --outfile server/victory-chart-renderer/dist/victory-chart-renderer-darwin-arm64"
+    "server:vcr:dev": "npm run dev -w @expensify/victory-chart-renderer",
+    "server:vcr:test": "npm run test -w @expensify/victory-chart-renderer",
+    "server:vcr:build:linux": "npm run build:linux -w @expensify/victory-chart-renderer",
+    "server:vcr:build:linux-arm": "npm run build:linux-arm -w @expensify/victory-chart-renderer",
+    "server:vcr:build:macos": "npm run build:macos -w @expensify/victory-chart-renderer"
   },
   "dependencies": {
     "@babel/runtime": "^7.25.0",
@@ -275,7 +276,6 @@
     "@types/mime-db": "^1.43.5",
     "@types/node": "^20.11.5",
     "@types/pako": "^2.0.3",
-    "@types/pngjs": "^6.0.5",
     "@types/pusher-js": "^5.1.0",
     "@types/react": "^19.2.0",
     "@types/react-collapse": "^5.0.1",
@@ -339,8 +339,6 @@
     "openai": "^6.16.0",
     "patch-package": "^8.1.0-canary.1",
     "peggy": "^4.0.3",
-    "pixelmatch": "^7.2.0",
-    "pngjs": "^7.0.0",
     "portfinder": "^1.0.34",
     "prettier": "3.7.4",
     "react-native-clean-project": "^4.0.0-alpha4.0",
@@ -426,9 +424,6 @@
       "expo": "55.0.25"
     }
   },
-  "trustedDependencies": [
-    "@shopify/react-native-skia"
-  ],
   "expo": {
     "autolinking": {
       "exclude": [
diff --git a/patches/@shopify+react-native-skia+2.4.14.details.md b/patches/@shopify+react-native-skia+2.4.14.details.md
deleted file mode 100644
index e8a4656a94f2..000000000000
--- a/patches/@shopify+react-native-skia+2.4.14.details.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# @shopify/react-native-skia patch: local tsconfig for Bun headless renderer
-
-## Why
-
-The `server/victory-chart-renderer` Bun CLI renders Skia content without React
-Native. Bun (and any other non-RN consumer) loads `@shopify/react-native-skia`'s
-root entry, which transitively imports `react-native`,
-`react-native-reanimated`, and `react-native/Libraries/...` subpaths. None of
-those packages run in Bun.
-
-This patch drops a minimal `tsconfig.json` inside the installed package so that
-when Bun resolves imports from files in `node_modules/@shopify/react-native-skia/...`
-it walks up to this `tsconfig.json` and remaps the React Native dependencies to
-the stubs in `server/victory-chart-renderer/stubs/`. Metro doesn't consult
-`tsconfig.json` paths, so the in-app build is unaffected.
-
-## Remove when
-
-We replace the Bun renderer with a different mechanism, or the Skia package
-ships a `bun` export condition (or equivalent) that lets us load a stripped
-entry without React Native dependencies.
diff --git a/patches/@shopify+react-native-skia+2.4.14.patch b/patches/@shopify+react-native-skia+2.4.14.patch
deleted file mode 100644
index 0b5f8bc62178..000000000000
--- a/patches/@shopify+react-native-skia+2.4.14.patch
+++ /dev/null
@@ -1,25 +0,0 @@
-diff --git a/node_modules/@shopify/react-native-skia/tsconfig.json b/node_modules/@shopify/react-native-skia/tsconfig.json
-new file mode 100644
-index 0000000..50f2975
---- /dev/null
-+++ b/node_modules/@shopify/react-native-skia/tsconfig.json
-@@ -0,0 +1,19 @@
-+{
-+  "compilerOptions": {
-+    "baseUrl": ".",
-+    "paths": {
-+      "react-native": [
-+        "../../../server/victory-chart-renderer/stubs/react-native.ts"
-+      ],
-+      "react-native/*": [
-+        "../../../server/victory-chart-renderer/stubs/react-native-subpath.ts"
-+      ],
-+      "react-native-reanimated": [
-+        "../../../server/victory-chart-renderer/stubs/react-native-reanimated.js"
-+      ],
-+      "react-native-gesture-handler": [
-+        "../../../server/victory-chart-renderer/stubs/react-native-gesture-handler.ts"
-+      ]
-+    }
-+  }
-+}
diff --git a/patches/victory-native/details.md b/patches/victory-native/details.md
index 7aeb2d68c71f..a2a6fcc031f6 100644
--- a/patches/victory-native/details.md
+++ b/patches/victory-native/details.md
@@ -1,22 +1,5 @@
 # `victory-native` patches
 
-### [victory-native+41.21.0+002+bun-stubs.patch](victory-native+41.21.0+002+bun-stubs.patch)
-
-- Reason:
-
-    ```
-    Drops a tsconfig.json into the installed package so Bun's resolver can remap
-    react-native, react-native-reanimated, and react-native-gesture-handler to the
-    stubs in server/victory-chart-renderer/stubs/ when loading victory-native for the
-    headless chart renderer CLI. Metro ignores tsconfig paths, so the in-app build is
-    unaffected.
-    ```
-
-- Upstream PR/issue: N/A — Bun-specific wiring for the victory-chart-renderer CLI.
-- E/App issue: https://github.com/Expensify/Expensify/issues/91528
-- PR introducing patch: https://github.com/Expensify/App/pull/91672
-- Remove when: We replace the Bun renderer or victory-native ships a bun export condition.
-
 ### [victory-native+41.21.0+001+horizontal-bars.patch](victory-native+41.21.0+001+horizontal-bars.patch)
 
 - Reason:
diff --git a/patches/victory-native/victory-native+41.21.0+002+bun-stubs.patch b/patches/victory-native/victory-native+41.21.0+002+bun-stubs.patch
deleted file mode 100644
index 569a1477a4d2..000000000000
--- a/patches/victory-native/victory-native+41.21.0+002+bun-stubs.patch
+++ /dev/null
@@ -1,22 +0,0 @@
-diff --git a/node_modules/victory-native/tsconfig.json b/node_modules/victory-native/tsconfig.json
-new file mode 100644
-index 0000000..0fff5ee
---- /dev/null
-+++ b/node_modules/victory-native/tsconfig.json
-@@ -0,0 +1,16 @@
-+{
-+  "compilerOptions": {
-+    "baseUrl": ".",
-+    "paths": {
-+      "react-native": [
-+        "../../server/victory-chart-renderer/stubs/react-native.ts"
-+      ],
-+      "react-native-reanimated": [
-+        "../../server/victory-chart-renderer/stubs/react-native-reanimated.js"
-+      ],
-+      "react-native-gesture-handler": [
-+        "../../server/victory-chart-renderer/stubs/react-native-gesture-handler.ts"
-+      ]
-+    }
-+  }
-+}
diff --git a/server/tsconfig.json b/server/tsconfig.json
deleted file mode 100644
index d4e1bf66228b..000000000000
--- a/server/tsconfig.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
-    "extends": "../tsconfig.json",
-    "compilerOptions": {
-        "types": ["@types/bun"],
-        "paths": {
-            "react-native": ["./victory-chart-renderer/stubs/react-native.ts"],
-            "react-native-reanimated": ["./victory-chart-renderer/stubs/react-native-reanimated.js"],
-            "react-native-gesture-handler": ["./victory-chart-renderer/stubs/react-native-gesture-handler.ts"],
-            "@assets/*": ["../assets/*"],
-            "@components/*": ["../src/components/*"],
-            "@github/*": ["../.github/*"],
-            "@hooks/*": ["../src/hooks/*"],
-            "@libs/*": ["../src/libs/*"],
-            "@navigation/*": ["../src/libs/Navigation/*"],
-            "@pages/*": ["../src/pages/*"],
-            "@prompts/*": ["../prompts/*"],
-            "@scripts/*": ["../scripts/*", "../.github/scripts/*"],
-            "@styles/*": ["../src/styles/*"],
-            "@src/*": ["../src/*"],
-            "@userActions/*": ["../src/libs/actions/*"],
-            "@selectors/*": ["../src/selectors/*"],
-            "tests/*": ["../tests/*"]
-        }
-    },
-    "include": ["victory-chart-renderer/src", "victory-chart-renderer/tests", "victory-chart-renderer/stubs"]
-}
diff --git a/server/victory-chart-renderer/.gitignore b/server/victory-chart-renderer/.gitignore
new file mode 100644
index 000000000000..5547cab15cef
--- /dev/null
+++ b/server/victory-chart-renderer/.gitignore
@@ -0,0 +1,3 @@
+.dev/
+dist/
+bun.lock
diff --git a/server/victory-chart-renderer/README.md b/server/victory-chart-renderer/README.md
index 001455cf30a6..edeabf445785 100644
--- a/server/victory-chart-renderer/README.md
+++ b/server/victory-chart-renderer/README.md
@@ -1,10 +1,14 @@
 # Victory Chart Renderer
 
-Standalone Bun CLI that will render Expensify chart XML to PNG using the same chart code as the App.
+Standalone Bun CLI (`@expensify/victory-chart-renderer`) that renders Expensify charts to PNG using the same `victory-native` and Skia code paths as the App.
+
+This package is an [npm workspace](https://docs.npmjs.com/cli/using-npm/workspaces) child of the App root. React Native peer dependencies are declared as local `file:` stub packages under `stubs/` (with `overrides` + `installConfig.hoistingLimits` so they stay in this workspace). At runtime, `scripts/dev.ts` and `scripts/build.ts` bundle the CLI with a Bun plugin that redirects `react-native`, `react-native-reanimated`, `react-native-gesture-handler`, and `react-native/*` imports to those stubs, because hoisted `victory-native` at the repo root would otherwise resolve the real native modules. The in-app Metro build is unaffected.
 
 ## Project layout
 
 - `src/` — CLI source
+- `stubs/` — `file:` stub packages (`react-native`, `react-native-reanimated`, `react-native-gesture-handler`) used only in this workspace
+- `scripts/` — Bun bundler entrypoints (`dev.ts`, `build.ts`, `rnStubPlugin.ts`)
 - `tests/` — Bun integration tests (`bun:test`)
 - `dist/` — compiled binaries (gitignored)
 
@@ -16,6 +20,12 @@ From the App repository root:
 npm run server:vcr:dev /tmp/out.png
 ```
 
+Or from this directory:
+
+```bash
+npm run dev /tmp/out.png
+```
+
 ## Tests
 
 From the App repository root:
@@ -27,20 +37,23 @@ npm run server:vcr:test
 Or from this directory:
 
 ```bash
-bun test
+npm test
 ```
 
 To refresh the reference PNG after an intentional rendering change:
 
 ```bash
-UPDATE_GOLDEN=1 bun test
+UPDATE_GOLDEN=1 npm test
 ```
 
 ## Compiled binaries
 
+From the App repository root:
+
 ```bash
 npm run server:vcr:build:linux
+npm run server:vcr:build:linux-arm
 npm run server:vcr:build:macos
 ```
 
-Binaries are written to `server/victory-chart-renderer/dist/` (gitignored).
+Binaries are written to `dist/` (gitignored).
diff --git a/server/victory-chart-renderer/package.json b/server/victory-chart-renderer/package.json
new file mode 100644
index 000000000000..730bbf121095
--- /dev/null
+++ b/server/victory-chart-renderer/package.json
@@ -0,0 +1,39 @@
+{
+    "name": "@expensify/victory-chart-renderer",
+    "version": "0.0.0",
+    "private": true,
+    "description": "Headless Bun CLI that renders Expensify chart XML to PNG using victory-native and Skia.",
+    "scripts": {
+        "dev": "bun run scripts/dev.ts",
+        "test": "bun test",
+        "build:linux": "bun run scripts/build.ts bun-linux-x64 dist/victory-chart-renderer-linux-x64",
+        "build:linux-arm": "bun run scripts/build.ts bun-linux-arm64 dist/victory-chart-renderer-linux-arm64",
+        "build:macos": "bun run scripts/build.ts bun-darwin-arm64 dist/victory-chart-renderer-darwin-arm64"
+    },
+    "dependencies": {
+        "@shopify/react-native-skia": "^2.4.14",
+        "react": "19.2.0",
+        "react-native": "file:./stubs/react-native",
+        "react-native-gesture-handler": "file:./stubs/react-native-gesture-handler",
+        "react-native-reanimated": "file:./stubs/react-native-reanimated",
+        "victory-native": "^41.21.0"
+    },
+    "devDependencies": {
+        "@types/bun": "1.3.14",
+        "@types/pngjs": "^6.0.5",
+        "pixelmatch": "^7.2.0",
+        "pngjs": "^7.0.0"
+    },
+    "overrides": {
+        "react-native": "file:./stubs/react-native",
+        "react-native-reanimated": "file:./stubs/react-native-reanimated",
+        "react-native-gesture-handler": "file:./stubs/react-native-gesture-handler"
+    },
+    "installConfig": {
+        "hoistingLimits": "workspaces"
+    },
+    "trustedDependencies": ["@shopify/react-native-skia"],
+    "engines": {
+        "bun": "1.3.14"
+    }
+}
diff --git a/server/victory-chart-renderer/scripts/build.ts b/server/victory-chart-renderer/scripts/build.ts
new file mode 100644
index 000000000000..764382ad6d65
--- /dev/null
+++ b/server/victory-chart-renderer/scripts/build.ts
@@ -0,0 +1,30 @@
+import {join, resolve} from 'node:path';
+import {createRnStubPlugin} from './rnStubPlugin';
+
+const packageRoot = resolve(import.meta.dir, '..');
+const stubRoot = resolve(packageRoot, 'stubs');
+
+const target = process.argv[2];
+const outfile = process.argv[3];
+
+if (!target || !outfile) {
+    throw new Error('Usage: bun run scripts/build.ts <bun-target> <outfile>');
+}
+
+const buildResult = await Bun.build({
+    entrypoints: [join(packageRoot, 'src/cli.tsx')],
+    compile: {
+        target,
+        outfile,
+    },
+    packages: 'bundle',
+    conditions: ['react-native'],
+    plugins: [createRnStubPlugin(stubRoot)],
+});
+
+if (!buildResult.success) {
+    for (const log of buildResult.logs) {
+        console.error(log);
+    }
+    throw new Error(`Failed to compile victory-chart-renderer for ${target}`);
+}
diff --git a/server/victory-chart-renderer/scripts/dev.ts b/server/victory-chart-renderer/scripts/dev.ts
new file mode 100644
index 000000000000..39d5af1ea930
--- /dev/null
+++ b/server/victory-chart-renderer/scripts/dev.ts
@@ -0,0 +1,41 @@
+import {spawnSync} from 'node:child_process';
+import {mkdirSync} from 'node:fs';
+import {join, resolve} from 'node:path';
+import {createRnStubPlugin} from './rnStubPlugin';
+
+const packageRoot = resolve(import.meta.dir, '..');
+const repoRoot = resolve(packageRoot, '../..');
+const stubRoot = resolve(packageRoot, 'stubs');
+const outFile = resolve(packageRoot, '.dev/cli.js');
+
+mkdirSync(join(packageRoot, '.dev'), {recursive: true});
+
+const buildResult = await Bun.build({
+    entrypoints: [resolve(packageRoot, 'src/cli.tsx')],
+    outfile: outFile,
+    target: 'bun',
+    packages: 'bundle',
+    conditions: ['react-native'],
+    plugins: [createRnStubPlugin(stubRoot)],
+});
+
+if (!buildResult.success) {
+    for (const log of buildResult.logs) {
+        console.error(log);
+    }
+    throw new Error('Failed to bundle victory-chart-renderer CLI');
+}
+
+const [bundle] = buildResult.outputs;
+if (!bundle) {
+    throw new Error('Bundled CLI output is missing');
+}
+
+await Bun.write(outFile, bundle);
+
+const runResult = spawnSync(process.execPath, [outFile, ...process.argv.slice(2)], {
+    cwd: repoRoot,
+    stdio: 'inherit',
+});
+
+process.exit(runResult.status ?? 1);
diff --git a/server/victory-chart-renderer/scripts/rnStubPlugin.ts b/server/victory-chart-renderer/scripts/rnStubPlugin.ts
new file mode 100644
index 000000000000..f122cb30c61c
--- /dev/null
+++ b/server/victory-chart-renderer/scripts/rnStubPlugin.ts
@@ -0,0 +1,30 @@
+import type {BunPlugin} from 'bun';
+import {resolve} from 'node:path';
+
+export function createRnStubPlugin(stubRoot: string): BunPlugin {
+    const reactNativeStub = resolve(stubRoot, 'react-native/index.ts');
+    const reactNativeSubpathStub = resolve(stubRoot, 'react-native/subpath.ts');
+    const reanimatedStub = resolve(stubRoot, 'react-native-reanimated/index.js');
+    const gestureHandlerStub = resolve(stubRoot, 'react-native-gesture-handler/index.ts');
+
+    return {
+        name: 'victory-chart-renderer-rn-stubs',
+        setup(build) {
+            build.onResolve({filter: /^react-native$/}, () => ({
+                path: reactNativeStub,
+            }));
+
+            build.onResolve({filter: /^react-native\/.+/}, () => ({
+                path: reactNativeSubpathStub,
+            }));
+
+            build.onResolve({filter: /^react-native-reanimated$/}, () => ({
+                path: reanimatedStub,
+            }));
+
+            build.onResolve({filter: /^react-native-gesture-handler$/}, () => ({
+                path: gestureHandlerStub,
+            }));
+        },
+    };
+}
diff --git a/server/victory-chart-renderer/stubs/react-native-gesture-handler.ts b/server/victory-chart-renderer/stubs/react-native-gesture-handler/index.ts
similarity index 100%
rename from server/victory-chart-renderer/stubs/react-native-gesture-handler.ts
rename to server/victory-chart-renderer/stubs/react-native-gesture-handler/index.ts
diff --git a/server/victory-chart-renderer/stubs/react-native-gesture-handler/package.json b/server/victory-chart-renderer/stubs/react-native-gesture-handler/package.json
new file mode 100644
index 000000000000..84bc99ed96dc
--- /dev/null
+++ b/server/victory-chart-renderer/stubs/react-native-gesture-handler/package.json
@@ -0,0 +1,7 @@
+{
+    "name": "react-native-gesture-handler",
+    "version": "0.0.0-stub",
+    "private": true,
+    "type": "module",
+    "main": "./index.ts"
+}
diff --git a/server/victory-chart-renderer/stubs/react-native-reanimated.js b/server/victory-chart-renderer/stubs/react-native-reanimated/index.js
similarity index 100%
rename from server/victory-chart-renderer/stubs/react-native-reanimated.js
rename to server/victory-chart-renderer/stubs/react-native-reanimated/index.js
diff --git a/server/victory-chart-renderer/stubs/react-native-reanimated/package.json b/server/victory-chart-renderer/stubs/react-native-reanimated/package.json
new file mode 100644
index 000000000000..107271909f92
--- /dev/null
+++ b/server/victory-chart-renderer/stubs/react-native-reanimated/package.json
@@ -0,0 +1,6 @@
+{
+    "name": "react-native-reanimated",
+    "version": "0.0.0-stub",
+    "private": true,
+    "main": "./index.js"
+}
diff --git a/server/victory-chart-renderer/stubs/react-native.ts b/server/victory-chart-renderer/stubs/react-native/index.ts
similarity index 100%
rename from server/victory-chart-renderer/stubs/react-native.ts
rename to server/victory-chart-renderer/stubs/react-native/index.ts
diff --git a/server/victory-chart-renderer/stubs/react-native/package.json b/server/victory-chart-renderer/stubs/react-native/package.json
new file mode 100644
index 000000000000..5851456cbebb
--- /dev/null
+++ b/server/victory-chart-renderer/stubs/react-native/package.json
@@ -0,0 +1,11 @@
+{
+    "name": "react-native",
+    "version": "0.0.0-stub",
+    "private": true,
+    "type": "module",
+    "main": "./index.ts",
+    "exports": {
+        ".": "./index.ts",
+        "./*": "./subpath.ts"
+    }
+}
diff --git a/server/victory-chart-renderer/stubs/react-native-subpath.ts b/server/victory-chart-renderer/stubs/react-native/subpath.ts
similarity index 100%
rename from server/victory-chart-renderer/stubs/react-native-subpath.ts
rename to server/victory-chart-renderer/stubs/react-native/subpath.ts
diff --git a/server/victory-chart-renderer/tests/cli.test.ts b/server/victory-chart-renderer/tests/cli.test.ts
index c157c1cc659e..64a48763b1f6 100644
--- a/server/victory-chart-renderer/tests/cli.test.ts
+++ b/server/victory-chart-renderer/tests/cli.test.ts
@@ -6,9 +6,7 @@ import {join} from 'node:path';
 import pixelmatch from 'pixelmatch';
 import {PNG} from 'pngjs';
 
-const cliDir = join(import.meta.dir, '..');
-const projectRoot = join(cliDir, '../..');
-const cliPath = join(cliDir, 'src/cli.tsx');
+const repoRoot = join(import.meta.dir, '../../..');
 const goldenPath = join(import.meta.dir, '__golden__/smoke.png');
 
 const EXPECTED_WIDTH = 400;
@@ -24,8 +22,8 @@ test('CLI renders a chart whose PNG matches the golden reference', () => {
     const actualPath = join(tempDir, 'smoke-actual.png');
 
     try {
-        const result = spawnSync('bun', ['--conditions=react-native', 'run', cliPath, actualPath], {
-            cwd: projectRoot,
+        const result = spawnSync('npm', ['run', 'dev', '-w', '@expensify/victory-chart-renderer', '--', actualPath], {
+            cwd: repoRoot,
             encoding: 'utf8',
         });
 
diff --git a/server/victory-chart-renderer/tsconfig.json b/server/victory-chart-renderer/tsconfig.json
new file mode 100644
index 000000000000..365469e3528d
--- /dev/null
+++ b/server/victory-chart-renderer/tsconfig.json
@@ -0,0 +1,10 @@
+{
+    "extends": "../../tsconfig.json",
+    "compilerOptions": {
+        "types": ["@types/bun"],
+        "paths": {
+            "@scripts/*": ["../../scripts/*", "../../.github/scripts/*"]
+        }
+    },
+    "include": ["src", "tests", "stubs", "scripts"]
+}

From 3580f416bf38369aaa12bb491202e0dd2757bcd0 Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Fri, 29 May 2026 06:50:15 -0700
Subject: [PATCH 05/21] Move VCR stubs to server/ and harden headless types

Relocate stub packages and shared server/tsconfig so the workspace
references them via file:../stubs. Convert reanimated stub to TypeScript,
add @src paths for CLI typechecking, and fix VCR script lint/types.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 config/eslint/eslint.config.mjs               |  2 +-
 package-lock.json                             | 30 +++++---
 package.json                                  |  4 +-
 .../react-native-gesture-handler/index.ts     |  0
 .../react-native-gesture-handler/package.json |  0
 server/stubs/react-native-reanimated/index.ts | 69 +++++++++++++++++++
 .../react-native-reanimated/package.json      |  0
 .../stubs/react-native/index.ts               |  0
 .../stubs/react-native/package.json           |  0
 .../stubs/react-native/subpath.ts             |  0
 server/tsconfig.json                          | 11 +++
 server/victory-chart-renderer/README.md       |  4 +-
 server/victory-chart-renderer/package.json    | 12 ++--
 .../victory-chart-renderer/scripts/build.ts   | 10 +--
 server/victory-chart-renderer/scripts/dev.ts  |  5 +-
 .../scripts/rnStubPlugin.ts                   |  4 +-
 .../stubs/react-native-reanimated/index.js    | 65 -----------------
 server/victory-chart-renderer/tsconfig.json   | 10 ---
 18 files changed, 121 insertions(+), 105 deletions(-)
 rename server/{victory-chart-renderer => }/stubs/react-native-gesture-handler/index.ts (100%)
 rename server/{victory-chart-renderer => }/stubs/react-native-gesture-handler/package.json (100%)
 create mode 100644 server/stubs/react-native-reanimated/index.ts
 rename server/{victory-chart-renderer => }/stubs/react-native-reanimated/package.json (100%)
 rename server/{victory-chart-renderer => }/stubs/react-native/index.ts (100%)
 rename server/{victory-chart-renderer => }/stubs/react-native/package.json (100%)
 rename server/{victory-chart-renderer => }/stubs/react-native/subpath.ts (100%)
 create mode 100644 server/tsconfig.json
 delete mode 100644 server/victory-chart-renderer/stubs/react-native-reanimated/index.js
 delete mode 100644 server/victory-chart-renderer/tsconfig.json

diff --git a/config/eslint/eslint.config.mjs b/config/eslint/eslint.config.mjs
index b7ca91c8f671..b8edb82f57a8 100644
--- a/config/eslint/eslint.config.mjs
+++ b/config/eslint/eslint.config.mjs
@@ -739,7 +739,7 @@ const config = defineConfig([
         files: ['server/**/*.ts', 'server/**/*.tsx'],
         languageOptions: {
             parserOptions: {
-                project: path.resolve(projectRoot, 'server/victory-chart-renderer/tsconfig.json'),
+                project: path.resolve(projectRoot, 'server/tsconfig.json'),
             },
         },
     },
diff --git a/package-lock.json b/package-lock.json
index ddba731701ef..9ee47b7b89d8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -40590,15 +40590,24 @@
         "zod": "^3.25.0 || ^4.0.0"
       }
     },
+    "server/stubs/react-native": {
+      "version": "0.0.0-stub"
+    },
+    "server/stubs/react-native-gesture-handler": {
+      "version": "0.0.0-stub"
+    },
+    "server/stubs/react-native-reanimated": {
+      "version": "0.0.0-stub"
+    },
     "server/victory-chart-renderer": {
       "name": "@expensify/victory-chart-renderer",
       "version": "0.0.0",
       "dependencies": {
         "@shopify/react-native-skia": "^2.4.14",
         "react": "19.2.0",
-        "react-native": "file:./stubs/react-native",
-        "react-native-gesture-handler": "file:./stubs/react-native-gesture-handler",
-        "react-native-reanimated": "file:./stubs/react-native-reanimated",
+        "react-native": "file:../stubs/react-native",
+        "react-native-gesture-handler": "file:../stubs/react-native-gesture-handler",
+        "react-native-reanimated": "file:../stubs/react-native-reanimated",
         "victory-native": "^41.21.0"
       },
       "devDependencies": {
@@ -40612,25 +40621,28 @@
       }
     },
     "server/victory-chart-renderer/node_modules/react-native": {
-      "resolved": "server/victory-chart-renderer/stubs/react-native",
+      "resolved": "server/stubs/react-native",
       "link": true
     },
     "server/victory-chart-renderer/node_modules/react-native-gesture-handler": {
-      "resolved": "server/victory-chart-renderer/stubs/react-native-gesture-handler",
+      "resolved": "server/stubs/react-native-gesture-handler",
       "link": true
     },
     "server/victory-chart-renderer/node_modules/react-native-reanimated": {
-      "resolved": "server/victory-chart-renderer/stubs/react-native-reanimated",
+      "resolved": "server/stubs/react-native-reanimated",
       "link": true
     },
     "server/victory-chart-renderer/stubs/react-native": {
-      "version": "0.0.0-stub"
+      "version": "0.0.0-stub",
+      "extraneous": true
     },
     "server/victory-chart-renderer/stubs/react-native-gesture-handler": {
-      "version": "0.0.0-stub"
+      "version": "0.0.0-stub",
+      "extraneous": true
     },
     "server/victory-chart-renderer/stubs/react-native-reanimated": {
-      "version": "0.0.0-stub"
+      "version": "0.0.0-stub",
+      "extraneous": true
     }
   }
 }
diff --git a/package.json b/package.json
index e706b9b89973..d9b8f9d81091 100644
--- a/package.json
+++ b/package.json
@@ -44,8 +44,8 @@
     "test:verbose": "TZ=utc NODE_OPTIONS=\"--experimental-vm-modules --max_old_space_size=4096\" JEST_VERBOSE=true jest",
     "test:debug": "TZ=utc NODE_OPTIONS='--inspect-brk --experimental-vm-modules' jest --runInBand",
     "perf-test": "NODE_OPTIONS=--experimental-vm-modules npx reassure",
-    "typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc && tsc -p server/victory-chart-renderer/tsconfig.json",
-    "typecheck-tsgo": "tsgo --noEmit --incremental --tsBuildInfoFile tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p server/victory-chart-renderer/tsconfig.json --incremental --tsBuildInfoFile server/victory-chart-renderer/tsconfig.tsgo.tsbuildinfo",
+    "typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc && tsc -p server/tsconfig.json",
+    "typecheck-tsgo": "tsgo --noEmit --incremental --tsBuildInfoFile tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p server/tsconfig.json --incremental --tsBuildInfoFile server/tsconfig.tsgo.tsbuildinfo",
     "lint": "./scripts/lint.sh",
     "lint-changed": "./scripts/lintChanged.sh",
     "lint-watch": "onchange '**/*.{js,jsx,ts,tsx,mjs,cjs}' -- ./scripts/lint.sh {{changed}}",
diff --git a/server/victory-chart-renderer/stubs/react-native-gesture-handler/index.ts b/server/stubs/react-native-gesture-handler/index.ts
similarity index 100%
rename from server/victory-chart-renderer/stubs/react-native-gesture-handler/index.ts
rename to server/stubs/react-native-gesture-handler/index.ts
diff --git a/server/victory-chart-renderer/stubs/react-native-gesture-handler/package.json b/server/stubs/react-native-gesture-handler/package.json
similarity index 100%
rename from server/victory-chart-renderer/stubs/react-native-gesture-handler/package.json
rename to server/stubs/react-native-gesture-handler/package.json
diff --git a/server/stubs/react-native-reanimated/index.ts b/server/stubs/react-native-reanimated/index.ts
new file mode 100644
index 000000000000..b09e78d7e909
--- /dev/null
+++ b/server/stubs/react-native-reanimated/index.ts
@@ -0,0 +1,69 @@
+// Minimal `react-native-reanimated` stub for the headless Bun renderer.
+//
+// `victory-native`'s `CartesianChart` calls `useSharedValue` while rendering,
+// and `@shopify/react-native-skia`'s `ReanimatedProxy` reads arbitrary
+// properties off the module at load (`Rea.createWorkletRuntime`,
+// `Rea.runOnRuntime`, etc.). Both must keep working without a real Reanimated
+// runtime mounted.
+//
+// To handle both shapes uniformly, the module exports a Proxy. ES-style named
+// imports (`import { useSharedValue } from ...`) read the proxy's own
+// properties; default imports (`import Rea from ...`) get the same proxy,
+// which falls back to a no-op function for any unknown property.
+//
+// `export =` keeps CommonJS semantics so `require("react-native-reanimated")`
+// returns the proxy directly from Bun.
+import type {FunctionComponent} from 'react';
+
+type SharedValue<T> = {value: T};
+type NoopFn = () => void;
+
+const noopFn: NoopFn = () => undefined;
+
+const noopComponent: FunctionComponent = () => null;
+
+const sharedValue = <T>(initialValue: T): SharedValue<T> => ({value: initialValue});
+
+const namedExports = {
+    __esModule: true as const,
+    default: undefined as unknown,
+    useSharedValue: sharedValue,
+    useDerivedValue: <T>(updater: () => T): SharedValue<T> => ({value: updater()}),
+    useAnimatedStyle: <T>(factory: () => T): T => factory(),
+    useAnimatedReaction: noopFn,
+    makeMutable: sharedValue,
+    isSharedValue: (value: unknown): value is SharedValue<unknown> => typeof value === 'object' && value !== null && 'value' in value,
+    runOnJS: <T extends NoopFn>(fn: T): T => fn,
+    runOnUI: <T extends NoopFn>(fn: T): T => fn,
+    withTiming: <T>(value: T): T => value,
+    withSpring: <T>(value: T): T => value,
+    withDecay: noopFn,
+    withDelay: <T>(_delay: number, animation: T): T => animation,
+    withRepeat: <T>(animation: T): T => animation,
+    cancelAnimation: noopFn,
+    Easing: {
+        linear: () => 0,
+        ease: () => 0,
+        bezier: () => () => 0,
+        inOut: (fn: () => number) => fn,
+    },
+    View: noopComponent,
+    Image: noopComponent,
+    Text: noopComponent,
+    ScrollView: noopComponent,
+};
+
+type NamedExports = typeof namedExports;
+
+const proxy = new Proxy(namedExports, {
+    get(target, prop: string | symbol) {
+        if (typeof prop === 'string' && prop in target) {
+            return target[prop as keyof NamedExports];
+        }
+        return noopFn;
+    },
+}) as NamedExports & Record<string, NoopFn>;
+
+namedExports.default = proxy;
+
+export = proxy;
diff --git a/server/victory-chart-renderer/stubs/react-native-reanimated/package.json b/server/stubs/react-native-reanimated/package.json
similarity index 100%
rename from server/victory-chart-renderer/stubs/react-native-reanimated/package.json
rename to server/stubs/react-native-reanimated/package.json
diff --git a/server/victory-chart-renderer/stubs/react-native/index.ts b/server/stubs/react-native/index.ts
similarity index 100%
rename from server/victory-chart-renderer/stubs/react-native/index.ts
rename to server/stubs/react-native/index.ts
diff --git a/server/victory-chart-renderer/stubs/react-native/package.json b/server/stubs/react-native/package.json
similarity index 100%
rename from server/victory-chart-renderer/stubs/react-native/package.json
rename to server/stubs/react-native/package.json
diff --git a/server/victory-chart-renderer/stubs/react-native/subpath.ts b/server/stubs/react-native/subpath.ts
similarity index 100%
rename from server/victory-chart-renderer/stubs/react-native/subpath.ts
rename to server/stubs/react-native/subpath.ts
diff --git a/server/tsconfig.json b/server/tsconfig.json
new file mode 100644
index 000000000000..efcd93854907
--- /dev/null
+++ b/server/tsconfig.json
@@ -0,0 +1,11 @@
+{
+    "extends": "../tsconfig.json",
+    "compilerOptions": {
+        "types": ["@types/bun"],
+        "paths": {
+            "@scripts/*": ["../scripts/*", "../.github/scripts/*"],
+            "@src/*": ["../src/*"]
+        }
+    },
+    "include": ["stubs", "victory-chart-renderer/src", "victory-chart-renderer/tests", "victory-chart-renderer/scripts"]
+}
diff --git a/server/victory-chart-renderer/README.md b/server/victory-chart-renderer/README.md
index edeabf445785..540836a11ed0 100644
--- a/server/victory-chart-renderer/README.md
+++ b/server/victory-chart-renderer/README.md
@@ -2,12 +2,12 @@
 
 Standalone Bun CLI (`@expensify/victory-chart-renderer`) that renders Expensify charts to PNG using the same `victory-native` and Skia code paths as the App.
 
-This package is an [npm workspace](https://docs.npmjs.com/cli/using-npm/workspaces) child of the App root. React Native peer dependencies are declared as local `file:` stub packages under `stubs/` (with `overrides` + `installConfig.hoistingLimits` so they stay in this workspace). At runtime, `scripts/dev.ts` and `scripts/build.ts` bundle the CLI with a Bun plugin that redirects `react-native`, `react-native-reanimated`, `react-native-gesture-handler`, and `react-native/*` imports to those stubs, because hoisted `victory-native` at the repo root would otherwise resolve the real native modules. The in-app Metro build is unaffected.
+This package is an [npm workspace](https://docs.npmjs.com/cli/using-npm/workspaces) child of the App root. React Native peer dependencies are declared as local `file:` stub packages under [`../stubs/`](../stubs/) (with `overrides` + `installConfig.hoistingLimits` so they stay in this workspace). At runtime, `scripts/dev.ts` and `scripts/build.ts` bundle the CLI with a Bun plugin that redirects `react-native`, `react-native-reanimated`, `react-native-gesture-handler`, and `react-native/*` imports to those stubs, because hoisted `victory-native` at the repo root would otherwise resolve the real native modules. The in-app Metro build is unaffected.
 
 ## Project layout
 
 - `src/` — CLI source
-- `stubs/` — `file:` stub packages (`react-native`, `react-native-reanimated`, `react-native-gesture-handler`) used only in this workspace
+- `../stubs/` — shared `file:` stub packages (`react-native`, `react-native-reanimated`, `react-native-gesture-handler`) for server-side Bun tooling
 - `scripts/` — Bun bundler entrypoints (`dev.ts`, `build.ts`, `rnStubPlugin.ts`)
 - `tests/` — Bun integration tests (`bun:test`)
 - `dist/` — compiled binaries (gitignored)
diff --git a/server/victory-chart-renderer/package.json b/server/victory-chart-renderer/package.json
index 730bbf121095..f8d66c933b7e 100644
--- a/server/victory-chart-renderer/package.json
+++ b/server/victory-chart-renderer/package.json
@@ -13,9 +13,9 @@
     "dependencies": {
         "@shopify/react-native-skia": "^2.4.14",
         "react": "19.2.0",
-        "react-native": "file:./stubs/react-native",
-        "react-native-gesture-handler": "file:./stubs/react-native-gesture-handler",
-        "react-native-reanimated": "file:./stubs/react-native-reanimated",
+        "react-native": "file:../stubs/react-native",
+        "react-native-gesture-handler": "file:../stubs/react-native-gesture-handler",
+        "react-native-reanimated": "file:../stubs/react-native-reanimated",
         "victory-native": "^41.21.0"
     },
     "devDependencies": {
@@ -25,9 +25,9 @@
         "pngjs": "^7.0.0"
     },
     "overrides": {
-        "react-native": "file:./stubs/react-native",
-        "react-native-reanimated": "file:./stubs/react-native-reanimated",
-        "react-native-gesture-handler": "file:./stubs/react-native-gesture-handler"
+        "react-native": "file:../stubs/react-native",
+        "react-native-reanimated": "file:../stubs/react-native-reanimated",
+        "react-native-gesture-handler": "file:../stubs/react-native-gesture-handler"
     },
     "installConfig": {
         "hoistingLimits": "workspaces"
diff --git a/server/victory-chart-renderer/scripts/build.ts b/server/victory-chart-renderer/scripts/build.ts
index 764382ad6d65..969baa4fb1d7 100644
--- a/server/victory-chart-renderer/scripts/build.ts
+++ b/server/victory-chart-renderer/scripts/build.ts
@@ -1,11 +1,11 @@
 import {join, resolve} from 'node:path';
-import {createRnStubPlugin} from './rnStubPlugin';
+import createRnStubPlugin from './rnStubPlugin';
 
 const packageRoot = resolve(import.meta.dir, '..');
-const stubRoot = resolve(packageRoot, 'stubs');
+const stubRoot = resolve(packageRoot, '../stubs');
 
-const target = process.argv[2];
-const outfile = process.argv[3];
+const target = process.argv.at(2);
+const outfile = process.argv.at(3);
 
 if (!target || !outfile) {
     throw new Error('Usage: bun run scripts/build.ts <bun-target> <outfile>');
@@ -14,7 +14,7 @@ if (!target || !outfile) {
 const buildResult = await Bun.build({
     entrypoints: [join(packageRoot, 'src/cli.tsx')],
     compile: {
-        target,
+        target: target as Bun.Build.CompileTarget,
         outfile,
     },
     packages: 'bundle',
diff --git a/server/victory-chart-renderer/scripts/dev.ts b/server/victory-chart-renderer/scripts/dev.ts
index 39d5af1ea930..5bb1ec9cd5b8 100644
--- a/server/victory-chart-renderer/scripts/dev.ts
+++ b/server/victory-chart-renderer/scripts/dev.ts
@@ -1,18 +1,17 @@
 import {spawnSync} from 'node:child_process';
 import {mkdirSync} from 'node:fs';
 import {join, resolve} from 'node:path';
-import {createRnStubPlugin} from './rnStubPlugin';
+import createRnStubPlugin from './rnStubPlugin';
 
 const packageRoot = resolve(import.meta.dir, '..');
 const repoRoot = resolve(packageRoot, '../..');
-const stubRoot = resolve(packageRoot, 'stubs');
+const stubRoot = resolve(packageRoot, '../stubs');
 const outFile = resolve(packageRoot, '.dev/cli.js');
 
 mkdirSync(join(packageRoot, '.dev'), {recursive: true});
 
 const buildResult = await Bun.build({
     entrypoints: [resolve(packageRoot, 'src/cli.tsx')],
-    outfile: outFile,
     target: 'bun',
     packages: 'bundle',
     conditions: ['react-native'],
diff --git a/server/victory-chart-renderer/scripts/rnStubPlugin.ts b/server/victory-chart-renderer/scripts/rnStubPlugin.ts
index f122cb30c61c..764f1fbae279 100644
--- a/server/victory-chart-renderer/scripts/rnStubPlugin.ts
+++ b/server/victory-chart-renderer/scripts/rnStubPlugin.ts
@@ -1,10 +1,10 @@
 import type {BunPlugin} from 'bun';
 import {resolve} from 'node:path';
 
-export function createRnStubPlugin(stubRoot: string): BunPlugin {
+export default function createRnStubPlugin(stubRoot: string): BunPlugin {
     const reactNativeStub = resolve(stubRoot, 'react-native/index.ts');
     const reactNativeSubpathStub = resolve(stubRoot, 'react-native/subpath.ts');
-    const reanimatedStub = resolve(stubRoot, 'react-native-reanimated/index.js');
+    const reanimatedStub = resolve(stubRoot, 'react-native-reanimated/index.ts');
     const gestureHandlerStub = resolve(stubRoot, 'react-native-gesture-handler/index.ts');
 
     return {
diff --git a/server/victory-chart-renderer/stubs/react-native-reanimated/index.js b/server/victory-chart-renderer/stubs/react-native-reanimated/index.js
deleted file mode 100644
index 3ee122c66df2..000000000000
--- a/server/victory-chart-renderer/stubs/react-native-reanimated/index.js
+++ /dev/null
@@ -1,65 +0,0 @@
-// Minimal `react-native-reanimated` stub for the headless Bun renderer.
-//
-// `victory-native`'s `CartesianChart` calls `useSharedValue` while rendering,
-// and `@shopify/react-native-skia`'s `ReanimatedProxy` reads arbitrary
-// properties off the module at load (`Rea.createWorkletRuntime`,
-// `Rea.runOnRuntime`, etc.). Both must keep working without a real Reanimated
-// runtime mounted.
-//
-// To handle both shapes uniformly, the module exports a Proxy. ES-style named
-// imports (`import { useSharedValue } from ...`) read the proxy's own
-// properties; default imports (`import Rea from ...`) get the same proxy,
-// which falls back to a no-op function for any unknown property.
-//
-// This file is CommonJS so that Bun returns the proxy directly from
-// `require("react-native-reanimated")`.
-
-const noopFn = () => undefined;
-
-const noopComponent = () => null;
-
-const sharedValue = (initialValue) => ({value: initialValue});
-
-const namedExports = {
-    __esModule: true,
-    default: undefined,
-    useSharedValue: sharedValue,
-    useDerivedValue: (updater) => ({value: updater()}),
-    useAnimatedStyle: (factory) => factory(),
-    useAnimatedReaction: noopFn,
-    makeMutable: sharedValue,
-    isSharedValue: (value) => typeof value === 'object' && value !== null && 'value' in value,
-    runOnJS: (fn) => fn,
-    runOnUI: (fn) => fn,
-    withTiming: (value) => value,
-    withSpring: (value) => value,
-    withDecay: noopFn,
-    withDelay: (delay, animation) => animation,
-    withRepeat: (animation) => animation,
-    cancelAnimation: noopFn,
-    Easing: {
-        linear: () => 0,
-        ease: () => 0,
-        bezier: () => () => 0,
-        inOut: (fn) => fn,
-    },
-    View: noopComponent,
-    Image: noopComponent,
-    Text: noopComponent,
-    ScrollView: noopComponent,
-};
-
-const proxy = new Proxy(namedExports, {
-    get(target, prop) {
-        if (prop in target) {
-            return target[prop];
-        }
-        return noopFn;
-    },
-});
-
-// Match Reanimated's runtime shape: `import Rea from "react-native-reanimated"`
-// returns the same proxy as `import * as Rea`.
-namedExports.default = proxy;
-
-module.exports = proxy;
diff --git a/server/victory-chart-renderer/tsconfig.json b/server/victory-chart-renderer/tsconfig.json
deleted file mode 100644
index 365469e3528d..000000000000
--- a/server/victory-chart-renderer/tsconfig.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-    "extends": "../../tsconfig.json",
-    "compilerOptions": {
-        "types": ["@types/bun"],
-        "paths": {
-            "@scripts/*": ["../../scripts/*", "../../.github/scripts/*"]
-        }
-    },
-    "include": ["src", "tests", "stubs", "scripts"]
-}

From d49c72472d3bf5a91a6e161d9bbc3ad01812ff95 Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Fri, 29 May 2026 06:50:24 -0700
Subject: [PATCH 06/21] Harden headless RN stub types and lint

Add explicit return types, convert gesture builder methods to fields,
and point reanimated package main at index.ts.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 .../react-native-gesture-handler/index.ts     | 65 +++++--------------
 .../react-native-reanimated/package.json      |  2 +-
 server/stubs/react-native/index.ts            | 11 ++--
 server/stubs/react-native/subpath.ts          |  4 +-
 4 files changed, 27 insertions(+), 55 deletions(-)

diff --git a/server/stubs/react-native-gesture-handler/index.ts b/server/stubs/react-native-gesture-handler/index.ts
index fc6d63e1893f..41f8b986bba3 100644
--- a/server/stubs/react-native-gesture-handler/index.ts
+++ b/server/stubs/react-native-gesture-handler/index.ts
@@ -8,69 +8,38 @@
 import type {FunctionComponent, PropsWithChildren} from 'react';
 
 class GestureBuilder {
-    onTouchesDown(): this {
-        return this;
-    }
+    // Chainable no-ops: victory-native builds gesture configs during render even in headless mode.
+    onTouchesDown = (): this => this;
 
-    onStart(): this {
-        return this;
-    }
+    onStart = (): this => this;
 
-    onTouchesMove(): this {
-        return this;
-    }
+    onTouchesMove = (): this => this;
 
-    onTouchesUp(): this {
-        return this;
-    }
+    onTouchesUp = (): this => this;
 
-    onEnd(): this {
-        return this;
-    }
+    onEnd = (): this => this;
 
-    onFinalize(): this {
-        return this;
-    }
+    onFinalize = (): this => this;
 
-    onChange(): this {
-        return this;
-    }
+    onChange = (): this => this;
 
-    onUpdate(): this {
-        return this;
-    }
+    onUpdate = (): this => this;
 
-    activateAfterLongPress(): this {
-        return this;
-    }
+    activateAfterLongPress = (): this => this;
 
-    activeOffsetX(): this {
-        return this;
-    }
+    activeOffsetX = (): this => this;
 
-    activeOffsetY(): this {
-        return this;
-    }
+    activeOffsetY = (): this => this;
 
-    failOffsetX(): this {
-        return this;
-    }
+    failOffsetX = (): this => this;
 
-    failOffsetY(): this {
-        return this;
-    }
+    failOffsetY = (): this => this;
 
-    minDistance(): this {
-        return this;
-    }
+    minDistance = (): this => this;
 
-    enabled(): this {
-        return this;
-    }
+    enabled = (): this => this;
 
-    runOnJS(): this {
-        return this;
-    }
+    runOnJS = (): this => this;
 }
 
 const buildGesture = (): GestureBuilder => new GestureBuilder();
diff --git a/server/stubs/react-native-reanimated/package.json b/server/stubs/react-native-reanimated/package.json
index 107271909f92..49389f3a58b7 100644
--- a/server/stubs/react-native-reanimated/package.json
+++ b/server/stubs/react-native-reanimated/package.json
@@ -2,5 +2,5 @@
     "name": "react-native-reanimated",
     "version": "0.0.0-stub",
     "private": true,
-    "main": "./index.js"
+    "main": "./index.ts"
 }
diff --git a/server/stubs/react-native/index.ts b/server/stubs/react-native/index.ts
index 12840f238608..527faef725c1 100644
--- a/server/stubs/react-native/index.ts
+++ b/server/stubs/react-native/index.ts
@@ -20,10 +20,13 @@ type StyleProp<T> = T | T[] | null | undefined | false;
 type TransformsStyle = Record<string, unknown>;
 
 const View = noopComponent;
+type ImageResolvedAssetSource = {uri: string};
+
 const Image = Object.assign(noopComponent, {
-    resolveAssetSource: (source: unknown) => {
-        if (source && typeof source === 'object' && 'uri' in (source as Record<string, unknown>)) {
-            return source as {uri: string};
+    resolveAssetSource: (source: unknown): ImageResolvedAssetSource => {
+        if (source && typeof source === 'object' && 'uri' in source) {
+            const {uri} = source as {uri: unknown};
+            return {uri: String(uri)};
         }
         return {uri: String(source)};
     },
@@ -33,7 +36,7 @@ const Text = noopComponent;
 const Platform = {
     OS: 'web' as const,
     Version: 0,
-    select: <T>(specifics: {default?: T; web?: T}) => specifics.web ?? specifics.default,
+    select: <T>(specifics: {default?: T; web?: T}): T | undefined => specifics.web ?? specifics.default,
 };
 
 const PixelRatio = {
diff --git a/server/stubs/react-native/subpath.ts b/server/stubs/react-native/subpath.ts
index 0eda28658b0f..01a0256a1a5d 100644
--- a/server/stubs/react-native/subpath.ts
+++ b/server/stubs/react-native/subpath.ts
@@ -7,8 +7,8 @@
 const noopFunction = () => null;
 
 const codegenNativeComponent = noopFunction;
-const registerAsset = (asset: unknown) => asset;
-const getAssetByID = () => null;
+const registerAsset = <T>(asset: T): T => asset;
+const getAssetByID = (): null => null;
 
 export {codegenNativeComponent, registerAsset, getAssetByID};
 export default noopFunction;

From d52b017ec99f346cc04d199f07df4ca6958569b6 Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 1 Jun 2026 08:06:27 -0700
Subject: [PATCH 07/21] Parse VCR build script args with shared CLI utility

Use named --target and --outfile flags in build.ts so argument parsing
matches other scripts and npm build scripts pass explicit flags.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 server/victory-chart-renderer/package.json     |  6 +++---
 server/victory-chart-renderer/scripts/build.ts | 17 ++++++++++++-----
 2 files changed, 15 insertions(+), 8 deletions(-)

diff --git a/server/victory-chart-renderer/package.json b/server/victory-chart-renderer/package.json
index f8d66c933b7e..bd9fa44e353f 100644
--- a/server/victory-chart-renderer/package.json
+++ b/server/victory-chart-renderer/package.json
@@ -6,9 +6,9 @@
     "scripts": {
         "dev": "bun run scripts/dev.ts",
         "test": "bun test",
-        "build:linux": "bun run scripts/build.ts bun-linux-x64 dist/victory-chart-renderer-linux-x64",
-        "build:linux-arm": "bun run scripts/build.ts bun-linux-arm64 dist/victory-chart-renderer-linux-arm64",
-        "build:macos": "bun run scripts/build.ts bun-darwin-arm64 dist/victory-chart-renderer-darwin-arm64"
+        "build:linux": "bun run scripts/build.ts --target bun-linux-x64 --outfile dist/victory-chart-renderer-linux-x64",
+        "build:linux-arm": "bun run scripts/build.ts --target bun-linux-arm64 --outfile dist/victory-chart-renderer-linux-arm64",
+        "build:macos": "bun run scripts/build.ts --target bun-darwin-arm64 --outfile dist/victory-chart-renderer-darwin-arm64"
     },
     "dependencies": {
         "@shopify/react-native-skia": "^2.4.14",
diff --git a/server/victory-chart-renderer/scripts/build.ts b/server/victory-chart-renderer/scripts/build.ts
index 969baa4fb1d7..bf27b2296a9e 100644
--- a/server/victory-chart-renderer/scripts/build.ts
+++ b/server/victory-chart-renderer/scripts/build.ts
@@ -1,15 +1,22 @@
 import {join, resolve} from 'node:path';
+import CLI from '@scripts/utils/CLI';
 import createRnStubPlugin from './rnStubPlugin';
 
 const packageRoot = resolve(import.meta.dir, '..');
 const stubRoot = resolve(packageRoot, '../stubs');
 
-const target = process.argv.at(2);
-const outfile = process.argv.at(3);
+const cli = new CLI({
+    namedArgs: {
+        target: {
+            description: 'Bun compile target (e.g. bun-darwin-arm64, bun-linux-x64)',
+        },
+        outfile: {
+            description: 'Path for the compiled binary output',
+        },
+    },
+});
 
-if (!target || !outfile) {
-    throw new Error('Usage: bun run scripts/build.ts <bun-target> <outfile>');
-}
+const {target, outfile} = cli.namedArgs;
 
 const buildResult = await Bun.build({
     entrypoints: [join(packageRoot, 'src/cli.tsx')],

From 2697795c4ee5aca2efa6bb89c26c884d1999fb64 Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 1 Jun 2026 08:06:56 -0700
Subject: [PATCH 08/21] Use named --outPath flag for VCR CLI output

Align cli.tsx with build.ts by parsing output path via CLI namedArgs.
Update the golden test and README examples to pass --outPath.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 server/victory-chart-renderer/README.md         | 4 ++--
 server/victory-chart-renderer/src/cli.tsx       | 9 ++++-----
 server/victory-chart-renderer/tests/cli.test.ts | 2 +-
 3 files changed, 7 insertions(+), 8 deletions(-)

diff --git a/server/victory-chart-renderer/README.md b/server/victory-chart-renderer/README.md
index 540836a11ed0..83390d1057a9 100644
--- a/server/victory-chart-renderer/README.md
+++ b/server/victory-chart-renderer/README.md
@@ -17,13 +17,13 @@ This package is an [npm workspace](https://docs.npmjs.com/cli/using-npm/workspac
 From the App repository root:
 
 ```bash
-npm run server:vcr:dev /tmp/out.png
+npm run server:vcr:dev -- --outPath /tmp/out.png
 ```
 
 Or from this directory:
 
 ```bash
-npm run dev /tmp/out.png
+npm run dev -- --outPath /tmp/out.png
 ```
 
 ## Tests
diff --git a/server/victory-chart-renderer/src/cli.tsx b/server/victory-chart-renderer/src/cli.tsx
index 7ab39b846f39..a44ca1718b81 100644
--- a/server/victory-chart-renderer/src/cli.tsx
+++ b/server/victory-chart-renderer/src/cli.tsx
@@ -4,12 +4,11 @@ import {LoadSkiaWeb} from '@shopify/react-native-skia/lib/module/web/LoadSkiaWeb
 import CLI from '@scripts/utils/CLI';
 
 const cli = new CLI({
-    positionalArgs: [
-        {
-            name: 'outPath',
+    namedArgs: {
+        outPath: {
             description: 'Path to write the rendered PNG to',
         },
-    ],
+    },
 });
 
 await LoadSkiaWeb();
@@ -64,4 +63,4 @@ if (!pngBytes) {
     throw new Error('Skia failed to encode the rendered chart to PNG bytes');
 }
 
-await Bun.write(cli.positionalArgs.outPath, pngBytes);
+await Bun.write(cli.namedArgs.outPath, pngBytes);
diff --git a/server/victory-chart-renderer/tests/cli.test.ts b/server/victory-chart-renderer/tests/cli.test.ts
index 64a48763b1f6..dc1314d69fbd 100644
--- a/server/victory-chart-renderer/tests/cli.test.ts
+++ b/server/victory-chart-renderer/tests/cli.test.ts
@@ -22,7 +22,7 @@ test('CLI renders a chart whose PNG matches the golden reference', () => {
     const actualPath = join(tempDir, 'smoke-actual.png');
 
     try {
-        const result = spawnSync('npm', ['run', 'dev', '-w', '@expensify/victory-chart-renderer', '--', actualPath], {
+        const result = spawnSync('npm', ['run', 'dev', '-w', '@expensify/victory-chart-renderer', '--', '--outPath', actualPath], {
             cwd: repoRoot,
             encoding: 'utf8',
         });

From 018da43c632208246a000183b841b742e17a947e Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 1 Jun 2026 08:09:39 -0700
Subject: [PATCH 09/21] Validate VCR CLI outPath ends with .png

Reject invalid output paths during CLI parsing so mis-typed extensions
fail before loading Skia.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 server/victory-chart-renderer/src/cli.tsx | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/server/victory-chart-renderer/src/cli.tsx b/server/victory-chart-renderer/src/cli.tsx
index a44ca1718b81..774eccbad696 100644
--- a/server/victory-chart-renderer/src/cli.tsx
+++ b/server/victory-chart-renderer/src/cli.tsx
@@ -7,6 +7,12 @@ const cli = new CLI({
     namedArgs: {
         outPath: {
             description: 'Path to write the rendered PNG to',
+            parse: (val) => {
+                if (!val.toLowerCase().endsWith('.png')) {
+                    throw new Error('outPath must end with .png');
+                }
+                return val;
+            },
         },
     },
 });

From 33e0a1bea8d60457d170a39f58812bb16989f4ce Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 1 Jun 2026 08:11:09 -0700
Subject: [PATCH 10/21] Use SHOULD_ prefix for bool

---
 server/victory-chart-renderer/tests/cli.test.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/server/victory-chart-renderer/tests/cli.test.ts b/server/victory-chart-renderer/tests/cli.test.ts
index dc1314d69fbd..3706557d60b0 100644
--- a/server/victory-chart-renderer/tests/cli.test.ts
+++ b/server/victory-chart-renderer/tests/cli.test.ts
@@ -15,7 +15,7 @@ const EXPECTED_HEIGHT = 250;
 // Set `UPDATE_GOLDEN=1` while running the test to refresh the reference PNG.
 // Intentionally separate from the comparison flow so a regression can't quietly
 // rewrite the golden.
-const UPDATE_GOLDEN = process.env.UPDATE_GOLDEN === '1';
+const SHOULD_UPDATE_GOLDEN = process.env.UPDATE_GOLDEN === '1';
 
 test('CLI renders a chart whose PNG matches the golden reference', () => {
     const tempDir = mkdtempSync(join(tmpdir(), 'vcr-smoke-'));
@@ -29,7 +29,7 @@ test('CLI renders a chart whose PNG matches the golden reference', () => {
 
         expect(result.status).toBe(0);
 
-        if (UPDATE_GOLDEN) {
+        if (SHOULD_UPDATE_GOLDEN) {
             copyFileSync(actualPath, goldenPath);
         }
 

From c6a49b9f3316256dd3b512a0c83fd9f2280478e6 Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 1 Jun 2026 08:17:44 -0700
Subject: [PATCH 11/21] Consolidate VCR gitignore rules at repo root

Move victory-chart-renderer ignore patterns into the App .gitignore
and ignore bun.lock repo-wide since npm workspaces own dependency locks.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 .gitignore                               | 6 +++++-
 server/victory-chart-renderer/.gitignore | 3 ---
 2 files changed, 5 insertions(+), 4 deletions(-)
 delete mode 100644 server/victory-chart-renderer/.gitignore

diff --git a/.gitignore b/.gitignore
index 9b179a0dc0d7..60585e4c6e5b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -174,5 +174,9 @@ agent-device-output/
 # cspell cache
 .cspellcache
 
-# Server CLI build artifacts
+# Server CLI build artifacts (victory-chart-renderer, etc.)
 server/*/dist/
+server/victory-chart-renderer/.dev/
+
+# Bun lockfiles (VCR uses npm workspaces + package-lock.json; bun is runtime-only)
+bun.lock
diff --git a/server/victory-chart-renderer/.gitignore b/server/victory-chart-renderer/.gitignore
deleted file mode 100644
index 5547cab15cef..000000000000
--- a/server/victory-chart-renderer/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-.dev/
-dist/
-bun.lock

From ed4de0299e7bc72708b5785575a30c5cc8b9273b Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 1 Jun 2026 08:23:25 -0700
Subject: [PATCH 12/21] Document VCR dev, build, and stub plugin scripts

Add block comments explaining bundling, compile vs dev flow, and why
RN imports are redirected without referencing specific npm invocations.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 server/victory-chart-renderer/scripts/build.ts      | 11 +++++++++++
 server/victory-chart-renderer/scripts/dev.ts        | 13 +++++++++++++
 .../victory-chart-renderer/scripts/rnStubPlugin.ts  | 11 +++++++++++
 3 files changed, 35 insertions(+)

diff --git a/server/victory-chart-renderer/scripts/build.ts b/server/victory-chart-renderer/scripts/build.ts
index bf27b2296a9e..69414765e494 100644
--- a/server/victory-chart-renderer/scripts/build.ts
+++ b/server/victory-chart-renderer/scripts/build.ts
@@ -1,3 +1,14 @@
+/*
+ * Cross-compiles the victory-chart-renderer CLI into a standalone binary.
+ *
+ * Like dev.ts, this bundles src/cli.tsx with rnStubPlugin so hoisted victory-native does
+ * not pull real react-native into the graph. Unlike dev.ts, Bun.build uses `compile` to
+ * emit a platform-specific executable (--target, --outfile) rather than a .dev/cli.js
+ * bundle run under Bun.
+ *
+ * Required --target and --outfile select the Bun compile target and output path. The script
+ * exits after writing the binary; it does not run it.
+ */
 import {join, resolve} from 'node:path';
 import CLI from '@scripts/utils/CLI';
 import createRnStubPlugin from './rnStubPlugin';
diff --git a/server/victory-chart-renderer/scripts/dev.ts b/server/victory-chart-renderer/scripts/dev.ts
index 5bb1ec9cd5b8..1136f47f96a2 100644
--- a/server/victory-chart-renderer/scripts/dev.ts
+++ b/server/victory-chart-renderer/scripts/dev.ts
@@ -1,3 +1,16 @@
+/*
+ * Development runner for the victory-chart-renderer CLI.
+ *
+ * victory-native is hoisted to the App repo root, so a bare `bun run src/cli.tsx` would
+ * resolve real react-native native sources that Bun cannot bundle. This script bundles
+ * src/cli.tsx with rnStubPlugin (redirecting RN imports to server/stubs), writes the
+ * bundle to .dev/cli.js, then executes it under the current Bun binary.
+ *
+ * process.argv entries after this script are forwarded unchanged to the bundled CLI.
+ * The child process cwd is the App repository root (two levels above this package).
+ *
+ * For a standalone executable instead of a Bun-run bundle, use build.ts.
+ */
 import {spawnSync} from 'node:child_process';
 import {mkdirSync} from 'node:fs';
 import {join, resolve} from 'node:path';
diff --git a/server/victory-chart-renderer/scripts/rnStubPlugin.ts b/server/victory-chart-renderer/scripts/rnStubPlugin.ts
index 764f1fbae279..4d0bb064ca51 100644
--- a/server/victory-chart-renderer/scripts/rnStubPlugin.ts
+++ b/server/victory-chart-renderer/scripts/rnStubPlugin.ts
@@ -1,3 +1,14 @@
+/*
+ * Bun bundler plugin shared by dev.ts and build.ts.
+ *
+ * npm workspaces install victory-native at the App root. Bun would otherwise follow those
+ * imports into Flow sources, native bindings, and Metro platform files. The headless chart
+ * path only needs modules to load and export no-op components; Skia draws off-screen.
+ *
+ * onResolve hooks map react-native, react-native/* subpaths, react-native-reanimated, and
+ * react-native-gesture-handler to the file: stub packages under server/stubs/. Stub paths
+ * must be absolute because Bun resolves plugin targets from the bundler context.
+ */
 import type {BunPlugin} from 'bun';
 import {resolve} from 'node:path';
 

From 3ccbf951c9ec7dd3ab537e661af05e2d35190b4e Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 1 Jun 2026 08:23:41 -0700
Subject: [PATCH 13/21] Move RN stub Bun plugin to server/plugins

rnStubPlugin is shared bundler utilities, not a runnable script. VCR
dev and build import it from ../../plugins/rnStubPlugin.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 .../scripts => plugins}/rnStubPlugin.ts                      | 2 +-
 server/tsconfig.json                                         | 2 +-
 server/victory-chart-renderer/README.md                      | 5 +++--
 server/victory-chart-renderer/scripts/build.ts               | 2 +-
 server/victory-chart-renderer/scripts/dev.ts                 | 2 +-
 5 files changed, 7 insertions(+), 6 deletions(-)
 rename server/{victory-chart-renderer/scripts => plugins}/rnStubPlugin.ts (95%)

diff --git a/server/victory-chart-renderer/scripts/rnStubPlugin.ts b/server/plugins/rnStubPlugin.ts
similarity index 95%
rename from server/victory-chart-renderer/scripts/rnStubPlugin.ts
rename to server/plugins/rnStubPlugin.ts
index 4d0bb064ca51..8c45ef25918c 100644
--- a/server/victory-chart-renderer/scripts/rnStubPlugin.ts
+++ b/server/plugins/rnStubPlugin.ts
@@ -1,5 +1,5 @@
 /*
- * Bun bundler plugin shared by dev.ts and build.ts.
+ * Bun bundler plugin used when bundling the victory-chart-renderer CLI.
  *
  * npm workspaces install victory-native at the App root. Bun would otherwise follow those
  * imports into Flow sources, native bindings, and Metro platform files. The headless chart
diff --git a/server/tsconfig.json b/server/tsconfig.json
index efcd93854907..a539ff4a0f6c 100644
--- a/server/tsconfig.json
+++ b/server/tsconfig.json
@@ -7,5 +7,5 @@
             "@src/*": ["../src/*"]
         }
     },
-    "include": ["stubs", "victory-chart-renderer/src", "victory-chart-renderer/tests", "victory-chart-renderer/scripts"]
+    "include": ["stubs", "plugins", "victory-chart-renderer/src", "victory-chart-renderer/tests", "victory-chart-renderer/scripts"]
 }
diff --git a/server/victory-chart-renderer/README.md b/server/victory-chart-renderer/README.md
index 83390d1057a9..9f29bfb8ab9f 100644
--- a/server/victory-chart-renderer/README.md
+++ b/server/victory-chart-renderer/README.md
@@ -2,13 +2,14 @@
 
 Standalone Bun CLI (`@expensify/victory-chart-renderer`) that renders Expensify charts to PNG using the same `victory-native` and Skia code paths as the App.
 
-This package is an [npm workspace](https://docs.npmjs.com/cli/using-npm/workspaces) child of the App root. React Native peer dependencies are declared as local `file:` stub packages under [`../stubs/`](../stubs/) (with `overrides` + `installConfig.hoistingLimits` so they stay in this workspace). At runtime, `scripts/dev.ts` and `scripts/build.ts` bundle the CLI with a Bun plugin that redirects `react-native`, `react-native-reanimated`, `react-native-gesture-handler`, and `react-native/*` imports to those stubs, because hoisted `victory-native` at the repo root would otherwise resolve the real native modules. The in-app Metro build is unaffected.
+This package is an [npm workspace](https://docs.npmjs.com/cli/using-npm/workspaces) child of the App root. React Native peer dependencies are declared as local `file:` stub packages under [`../stubs/`](../stubs/) (with `overrides` + `installConfig.hoistingLimits` so they stay in this workspace). At runtime, `scripts/dev.ts` and `scripts/build.ts` bundle the CLI with [`../plugins/rnStubPlugin.ts`](../plugins/rnStubPlugin.ts), which redirects `react-native`, `react-native-reanimated`, `react-native-gesture-handler`, and `react-native/*` imports to those stubs, because hoisted `victory-native` at the repo root would otherwise resolve the real native modules. The in-app Metro build is unaffected.
 
 ## Project layout
 
 - `src/` — CLI source
 - `../stubs/` — shared `file:` stub packages (`react-native`, `react-native-reanimated`, `react-native-gesture-handler`) for server-side Bun tooling
-- `scripts/` — Bun bundler entrypoints (`dev.ts`, `build.ts`, `rnStubPlugin.ts`)
+- `../plugins/` — shared Bun bundler plugins (`rnStubPlugin.ts`)
+- `scripts/` — dev and release entrypoints (`dev.ts`, `build.ts`)
 - `tests/` — Bun integration tests (`bun:test`)
 - `dist/` — compiled binaries (gitignored)
 
diff --git a/server/victory-chart-renderer/scripts/build.ts b/server/victory-chart-renderer/scripts/build.ts
index 69414765e494..1698a7cc9d90 100644
--- a/server/victory-chart-renderer/scripts/build.ts
+++ b/server/victory-chart-renderer/scripts/build.ts
@@ -11,7 +11,7 @@
  */
 import {join, resolve} from 'node:path';
 import CLI from '@scripts/utils/CLI';
-import createRnStubPlugin from './rnStubPlugin';
+import createRnStubPlugin from '../../plugins/rnStubPlugin';
 
 const packageRoot = resolve(import.meta.dir, '..');
 const stubRoot = resolve(packageRoot, '../stubs');
diff --git a/server/victory-chart-renderer/scripts/dev.ts b/server/victory-chart-renderer/scripts/dev.ts
index 1136f47f96a2..dd4b183e3a8c 100644
--- a/server/victory-chart-renderer/scripts/dev.ts
+++ b/server/victory-chart-renderer/scripts/dev.ts
@@ -14,7 +14,7 @@
 import {spawnSync} from 'node:child_process';
 import {mkdirSync} from 'node:fs';
 import {join, resolve} from 'node:path';
-import createRnStubPlugin from './rnStubPlugin';
+import createRnStubPlugin from '../../plugins/rnStubPlugin';
 
 const packageRoot = resolve(import.meta.dir, '..');
 const repoRoot = resolve(packageRoot, '../..');

From ddd6834a15d830fd7be0494844dfacc0d5450ce8 Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 1 Jun 2026 08:24:43 -0700
Subject: [PATCH 14/21] Remove redundant project layout from VCR README

Directory structure is evident from the repo; keep usage-focused sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 server/victory-chart-renderer/README.md | 9 ---------
 1 file changed, 9 deletions(-)

diff --git a/server/victory-chart-renderer/README.md b/server/victory-chart-renderer/README.md
index 9f29bfb8ab9f..01f83b77ae58 100644
--- a/server/victory-chart-renderer/README.md
+++ b/server/victory-chart-renderer/README.md
@@ -4,15 +4,6 @@ Standalone Bun CLI (`@expensify/victory-chart-renderer`) that renders Expensify
 
 This package is an [npm workspace](https://docs.npmjs.com/cli/using-npm/workspaces) child of the App root. React Native peer dependencies are declared as local `file:` stub packages under [`../stubs/`](../stubs/) (with `overrides` + `installConfig.hoistingLimits` so they stay in this workspace). At runtime, `scripts/dev.ts` and `scripts/build.ts` bundle the CLI with [`../plugins/rnStubPlugin.ts`](../plugins/rnStubPlugin.ts), which redirects `react-native`, `react-native-reanimated`, `react-native-gesture-handler`, and `react-native/*` imports to those stubs, because hoisted `victory-native` at the repo root would otherwise resolve the real native modules. The in-app Metro build is unaffected.
 
-## Project layout
-
-- `src/` — CLI source
-- `../stubs/` — shared `file:` stub packages (`react-native`, `react-native-reanimated`, `react-native-gesture-handler`) for server-side Bun tooling
-- `../plugins/` — shared Bun bundler plugins (`rnStubPlugin.ts`)
-- `scripts/` — dev and release entrypoints (`dev.ts`, `build.ts`)
-- `tests/` — Bun integration tests (`bun:test`)
-- `dist/` — compiled binaries (gitignored)
-
 ## Development
 
 From the App repository root:

From 64f73e72bdf4ce022457d95da697f0a817dba8b9 Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 1 Jun 2026 08:32:08 -0700
Subject: [PATCH 15/21] Validate VCR build --target and move parser to
 server/libs

Add parseCompileTarget with a satisfies-checked allowlist so build.ts
gets a typed CompileTarget without casting. Keep Bun plugins under
server/plugins only.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 server/libs/parseCompileTarget.ts             | 55 +++++++++++++++++++
 server/tsconfig.json                          |  2 +-
 .../victory-chart-renderer/scripts/build.ts   |  4 +-
 3 files changed, 59 insertions(+), 2 deletions(-)
 create mode 100644 server/libs/parseCompileTarget.ts

diff --git a/server/libs/parseCompileTarget.ts b/server/libs/parseCompileTarget.ts
new file mode 100644
index 000000000000..7331997be944
--- /dev/null
+++ b/server/libs/parseCompileTarget.ts
@@ -0,0 +1,55 @@
+/*
+ * Runtime validation for Bun.build compile --target strings.
+ *
+ * CompileTarget is a large template union in @types/bun; this list is checked with
+ * `satisfies` so additions stay aligned with Bun.Build.CompileTarget at compile time.
+ */
+const BUN_COMPILE_TARGETS = [
+    'bun-darwin-x64',
+    'bun-darwin-x64-baseline',
+    'bun-darwin-x64-modern',
+    'bun-darwin-arm64',
+    'bun-darwin-arm64-baseline',
+    'bun-darwin-arm64-modern',
+    'bun-darwin-aarch64',
+    'bun-darwin-aarch64-baseline',
+    'bun-darwin-aarch64-modern',
+    'bun-linux-x64',
+    'bun-linux-x64-baseline',
+    'bun-linux-x64-modern',
+    'bun-linux-x64-glibc',
+    'bun-linux-x64-musl',
+    'bun-linux-arm64',
+    'bun-linux-arm64-baseline',
+    'bun-linux-arm64-modern',
+    'bun-linux-arm64-glibc',
+    'bun-linux-arm64-musl',
+    'bun-linux-aarch64',
+    'bun-linux-aarch64-musl',
+    'bun-linux-x64-baseline-glibc',
+    'bun-linux-x64-modern-glibc',
+    'bun-linux-x64-baseline-musl',
+    'bun-linux-x64-modern-musl',
+    'bun-linux-arm64-baseline-musl',
+    'bun-linux-arm64-modern-musl',
+    'bun-windows-x64',
+    'bun-windows-x64-baseline',
+    'bun-windows-x64-modern',
+    'bun-windows-arm64',
+] as const satisfies readonly Bun.Build.CompileTarget[];
+
+const BUN_COMPILE_TARGET_SET: ReadonlySet<string> = new Set(BUN_COMPILE_TARGETS);
+
+function isCompileTarget(value: string): value is Bun.Build.CompileTarget {
+    return BUN_COMPILE_TARGET_SET.has(value);
+}
+
+function parseCompileTarget(value: string): Bun.Build.CompileTarget {
+    if (isCompileTarget(value)) {
+        return value;
+    }
+
+    throw new Error(`Invalid compile target "${value}". Expected one of: ${BUN_COMPILE_TARGETS.join(', ')}`);
+}
+
+export default parseCompileTarget;
diff --git a/server/tsconfig.json b/server/tsconfig.json
index a539ff4a0f6c..f05fe60144bf 100644
--- a/server/tsconfig.json
+++ b/server/tsconfig.json
@@ -7,5 +7,5 @@
             "@src/*": ["../src/*"]
         }
     },
-    "include": ["stubs", "plugins", "victory-chart-renderer/src", "victory-chart-renderer/tests", "victory-chart-renderer/scripts"]
+    "include": ["stubs", "plugins", "libs", "victory-chart-renderer/src", "victory-chart-renderer/tests", "victory-chart-renderer/scripts"]
 }
diff --git a/server/victory-chart-renderer/scripts/build.ts b/server/victory-chart-renderer/scripts/build.ts
index 1698a7cc9d90..c20d9452a176 100644
--- a/server/victory-chart-renderer/scripts/build.ts
+++ b/server/victory-chart-renderer/scripts/build.ts
@@ -11,6 +11,7 @@
  */
 import {join, resolve} from 'node:path';
 import CLI from '@scripts/utils/CLI';
+import parseCompileTarget from '../../libs/parseCompileTarget';
 import createRnStubPlugin from '../../plugins/rnStubPlugin';
 
 const packageRoot = resolve(import.meta.dir, '..');
@@ -20,6 +21,7 @@ const cli = new CLI({
     namedArgs: {
         target: {
             description: 'Bun compile target (e.g. bun-darwin-arm64, bun-linux-x64)',
+            parse: parseCompileTarget,
         },
         outfile: {
             description: 'Path for the compiled binary output',
@@ -32,7 +34,7 @@ const {target, outfile} = cli.namedArgs;
 const buildResult = await Bun.build({
     entrypoints: [join(packageRoot, 'src/cli.tsx')],
     compile: {
-        target: target as Bun.Build.CompileTarget,
+        target,
         outfile,
     },
     packages: 'bundle',

From d32b1769ea8985acda92315eb35294c361025048 Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 1 Jun 2026 08:35:58 -0700
Subject: [PATCH 16/21] Fix VCR imports for import/no-relative-packages

Export server libs and plugins from the root package and map them in
server/tsconfig so ESLint and TypeScript resolve new.expensify paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 package.json                                   | 4 ++++
 server/tsconfig.json                           | 4 +++-
 server/victory-chart-renderer/scripts/build.ts | 4 ++--
 server/victory-chart-renderer/scripts/dev.ts   | 2 +-
 4 files changed, 10 insertions(+), 4 deletions(-)

diff --git a/package.json b/package.json
index d9b8f9d81091..917068eb0dd9 100644
--- a/package.json
+++ b/package.json
@@ -436,6 +436,10 @@
       }
     }
   },
+  "exports": {
+    "./server/libs/parseCompileTarget": "./server/libs/parseCompileTarget.ts",
+    "./server/plugins/rnStubPlugin": "./server/plugins/rnStubPlugin.ts"
+  },
   "engines": {
     "bun": "1.3.14",
     "node": "20.20.0",
diff --git a/server/tsconfig.json b/server/tsconfig.json
index f05fe60144bf..1fbbeb9cd5ce 100644
--- a/server/tsconfig.json
+++ b/server/tsconfig.json
@@ -4,7 +4,9 @@
         "types": ["@types/bun"],
         "paths": {
             "@scripts/*": ["../scripts/*", "../.github/scripts/*"],
-            "@src/*": ["../src/*"]
+            "@src/*": ["../src/*"],
+            "new.expensify/server/libs/parseCompileTarget": ["./libs/parseCompileTarget.ts"],
+            "new.expensify/server/plugins/rnStubPlugin": ["./plugins/rnStubPlugin.ts"]
         }
     },
     "include": ["stubs", "plugins", "libs", "victory-chart-renderer/src", "victory-chart-renderer/tests", "victory-chart-renderer/scripts"]
diff --git a/server/victory-chart-renderer/scripts/build.ts b/server/victory-chart-renderer/scripts/build.ts
index c20d9452a176..71821ee29e6d 100644
--- a/server/victory-chart-renderer/scripts/build.ts
+++ b/server/victory-chart-renderer/scripts/build.ts
@@ -9,10 +9,10 @@
  * Required --target and --outfile select the Bun compile target and output path. The script
  * exits after writing the binary; it does not run it.
  */
+import parseCompileTarget from 'new.expensify/server/libs/parseCompileTarget';
+import createRnStubPlugin from 'new.expensify/server/plugins/rnStubPlugin';
 import {join, resolve} from 'node:path';
 import CLI from '@scripts/utils/CLI';
-import parseCompileTarget from '../../libs/parseCompileTarget';
-import createRnStubPlugin from '../../plugins/rnStubPlugin';
 
 const packageRoot = resolve(import.meta.dir, '..');
 const stubRoot = resolve(packageRoot, '../stubs');
diff --git a/server/victory-chart-renderer/scripts/dev.ts b/server/victory-chart-renderer/scripts/dev.ts
index dd4b183e3a8c..0d02c12e6c0f 100644
--- a/server/victory-chart-renderer/scripts/dev.ts
+++ b/server/victory-chart-renderer/scripts/dev.ts
@@ -11,10 +11,10 @@
  *
  * For a standalone executable instead of a Bun-run bundle, use build.ts.
  */
+import createRnStubPlugin from 'new.expensify/server/plugins/rnStubPlugin';
 import {spawnSync} from 'node:child_process';
 import {mkdirSync} from 'node:fs';
 import {join, resolve} from 'node:path';
-import createRnStubPlugin from '../../plugins/rnStubPlugin';
 
 const packageRoot = resolve(import.meta.dir, '..');
 const repoRoot = resolve(packageRoot, '../..');

From d5af563b3218f13b68e78c493d38e17e3b542792 Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 1 Jun 2026 08:39:58 -0700
Subject: [PATCH 17/21] Fix is2dArray generic inference for tsgo

Pass explicit type parameters so tsgo narrows 1D vs 2D arrays correctly.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 src/components/MapView/utils.ts                  | 2 +-
 src/libs/migrations/ConvertGpsPointsTo2DArray.ts | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/components/MapView/utils.ts b/src/components/MapView/utils.ts
index 772e03a30a14..9e1ca810b6e7 100644
--- a/src/components/MapView/utils.ts
+++ b/src/components/MapView/utils.ts
@@ -3,7 +3,7 @@ import is2dArray from '@libs/is2dArray';
 import type {Coordinate} from './MapViewTypes';
 
 function isSingleSegmentRoute(directionCoordinates: Coordinate[] | Coordinate[][]): directionCoordinates is Coordinate[] {
-    return is2dArray(directionCoordinates);
+    return is2dArray<Coordinate>(directionCoordinates);
 }
 
 function getBounds(waypoints: Coordinate[], directionCoordinates: undefined | Coordinate[]): {southWest: Coordinate; northEast: Coordinate} {
diff --git a/src/libs/migrations/ConvertGpsPointsTo2DArray.ts b/src/libs/migrations/ConvertGpsPointsTo2DArray.ts
index 2131b8565340..3178fb8d9971 100644
--- a/src/libs/migrations/ConvertGpsPointsTo2DArray.ts
+++ b/src/libs/migrations/ConvertGpsPointsTo2DArray.ts
@@ -23,7 +23,7 @@ export default function (): Promise<void> {
                 const gpsPoints = gpsDraftDetails?.gpsPoints;
 
                 // If gpsPoints is already in the correct format or is empty, skip the migration
-                if (!gpsPoints || is2dArray(gpsPoints)) {
+                if (!gpsPoints || is2dArray<GPSPoint>(gpsPoints)) {
                     Log.info('[Migrate Onyx] Skipped ConvertGpsPointsTo2DArray — already correct format or empty');
                     return resolve();
                 }

From f083b8079d0b898a6ae8bda8ff8823421e3cc149 Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 1 Jun 2026 08:43:17 -0700
Subject: [PATCH 18/21] Use @libs and @plugins paths for server tooling imports

Scope server/tsconfig aliases to ./libs and ./plugins instead of
new.expensify package subpaths; drop redundant root package exports.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 package.json                                   | 4 ----
 server/tsconfig.json                           | 6 +++---
 server/victory-chart-renderer/scripts/build.ts | 4 ++--
 server/victory-chart-renderer/scripts/dev.ts   | 2 +-
 4 files changed, 6 insertions(+), 10 deletions(-)

diff --git a/package.json b/package.json
index 917068eb0dd9..d9b8f9d81091 100644
--- a/package.json
+++ b/package.json
@@ -436,10 +436,6 @@
       }
     }
   },
-  "exports": {
-    "./server/libs/parseCompileTarget": "./server/libs/parseCompileTarget.ts",
-    "./server/plugins/rnStubPlugin": "./server/plugins/rnStubPlugin.ts"
-  },
   "engines": {
     "bun": "1.3.14",
     "node": "20.20.0",
diff --git a/server/tsconfig.json b/server/tsconfig.json
index 1fbbeb9cd5ce..913b297e623b 100644
--- a/server/tsconfig.json
+++ b/server/tsconfig.json
@@ -3,10 +3,10 @@
     "compilerOptions": {
         "types": ["@types/bun"],
         "paths": {
+            "@libs/*": ["./libs/*"],
+            "@plugins/*": ["./plugins/*"],
             "@scripts/*": ["../scripts/*", "../.github/scripts/*"],
-            "@src/*": ["../src/*"],
-            "new.expensify/server/libs/parseCompileTarget": ["./libs/parseCompileTarget.ts"],
-            "new.expensify/server/plugins/rnStubPlugin": ["./plugins/rnStubPlugin.ts"]
+            "@src/*": ["../src/*"]
         }
     },
     "include": ["stubs", "plugins", "libs", "victory-chart-renderer/src", "victory-chart-renderer/tests", "victory-chart-renderer/scripts"]
diff --git a/server/victory-chart-renderer/scripts/build.ts b/server/victory-chart-renderer/scripts/build.ts
index 71821ee29e6d..6734f06fcf3c 100644
--- a/server/victory-chart-renderer/scripts/build.ts
+++ b/server/victory-chart-renderer/scripts/build.ts
@@ -9,9 +9,9 @@
  * Required --target and --outfile select the Bun compile target and output path. The script
  * exits after writing the binary; it does not run it.
  */
-import parseCompileTarget from 'new.expensify/server/libs/parseCompileTarget';
-import createRnStubPlugin from 'new.expensify/server/plugins/rnStubPlugin';
+import createRnStubPlugin from '@plugins/rnStubPlugin';
 import {join, resolve} from 'node:path';
+import parseCompileTarget from '@libs/parseCompileTarget';
 import CLI from '@scripts/utils/CLI';
 
 const packageRoot = resolve(import.meta.dir, '..');
diff --git a/server/victory-chart-renderer/scripts/dev.ts b/server/victory-chart-renderer/scripts/dev.ts
index 0d02c12e6c0f..c46d65296af2 100644
--- a/server/victory-chart-renderer/scripts/dev.ts
+++ b/server/victory-chart-renderer/scripts/dev.ts
@@ -11,7 +11,7 @@
  *
  * For a standalone executable instead of a Bun-run bundle, use build.ts.
  */
-import createRnStubPlugin from 'new.expensify/server/plugins/rnStubPlugin';
+import createRnStubPlugin from '@plugins/rnStubPlugin';
 import {spawnSync} from 'node:child_process';
 import {mkdirSync} from 'node:fs';
 import {join, resolve} from 'node:path';

From 9707df458121c986d806230f4e982964d5f6ce0e Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 1 Jun 2026 08:47:01 -0700
Subject: [PATCH 19/21] Simplify server tsconfig include paths

Include victory-chart-renderer as a whole with dist and .dev excluded
instead of listing each source subdirectory.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 server/tsconfig.json | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/server/tsconfig.json b/server/tsconfig.json
index 913b297e623b..a5292928a2ab 100644
--- a/server/tsconfig.json
+++ b/server/tsconfig.json
@@ -9,5 +9,11 @@
             "@src/*": ["../src/*"]
         }
     },
-    "include": ["stubs", "plugins", "libs", "victory-chart-renderer/src", "victory-chart-renderer/tests", "victory-chart-renderer/scripts"]
+    "include": [
+        "libs",
+        "plugins",
+        "stubs",
+        "victory-chart-renderer"
+    ],
+    "exclude": ["victory-chart-renderer/dist", "victory-chart-renderer/.dev"]
 }

From edf6f8d64fe038a0072e6a244194741bb5a59c4f Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 1 Jun 2026 08:54:06 -0700
Subject: [PATCH 20/21] Forward CLI args through server:vcr npm scripts

Add a trailing -- on each server:vcr:* workspace delegate so flags
like --outPath reach the victory-chart-renderer scripts from the root.

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 package.json | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/package.json b/package.json
index 470cc12fa35c..ab32c54ba956 100644
--- a/package.json
+++ b/package.json
@@ -73,11 +73,11 @@
     "web:dist": "concurrently \"npm:web-proxy\" \"http-server ./dist --cors --port 8080 -P http://localhost:9000\"",
     "octokit": "cd scripts && npx ts-node -i -e \"$(cat ./octokit.ts)\"",
     "compress-svg": "npx ts-node scripts/compressSvg.ts --dir assets/images && npx ts-node scripts/compressSvg.ts --dir docs/assets/images",
-    "server:vcr:dev": "npm run dev -w @expensify/victory-chart-renderer",
-    "server:vcr:test": "npm run test -w @expensify/victory-chart-renderer",
-    "server:vcr:build:linux": "npm run build:linux -w @expensify/victory-chart-renderer",
-    "server:vcr:build:linux-arm": "npm run build:linux-arm -w @expensify/victory-chart-renderer",
-    "server:vcr:build:macos": "npm run build:macos -w @expensify/victory-chart-renderer"
+    "server:vcr:dev": "npm run dev -w @expensify/victory-chart-renderer --",
+    "server:vcr:test": "npm run test -w @expensify/victory-chart-renderer --",
+    "server:vcr:build:linux": "npm run build:linux -w @expensify/victory-chart-renderer --",
+    "server:vcr:build:linux-arm": "npm run build:linux-arm -w @expensify/victory-chart-renderer --",
+    "server:vcr:build:macos": "npm run build:macos -w @expensify/victory-chart-renderer --"
   },
   "dependencies": {
     "@babel/runtime": "^7.25.0",

From 948f24ff452a453891fe3b4ed75e9ede0623d391 Mon Sep 17 00:00:00 2001
From: rory <rory@expensify.com>
Date: Mon, 1 Jun 2026 09:02:36 -0700
Subject: [PATCH 21/21] Fix prettier

---
 server/tsconfig.json | 7 +------
 1 file changed, 1 insertion(+), 6 deletions(-)

diff --git a/server/tsconfig.json b/server/tsconfig.json
index a5292928a2ab..4e976697134d 100644
--- a/server/tsconfig.json
+++ b/server/tsconfig.json
@@ -9,11 +9,6 @@
             "@src/*": ["../src/*"]
         }
     },
-    "include": [
-        "libs",
-        "plugins",
-        "stubs",
-        "victory-chart-renderer"
-    ],
+    "include": ["libs", "plugins", "stubs", "victory-chart-renderer"],
     "exclude": ["victory-chart-renderer/dist", "victory-chart-renderer/.dev"]
 }
