Skip to content
Merged
2 changes: 2 additions & 0 deletions jest/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,8 @@ jest.mock('victory-native', () => ({
CartesianChart: jest.fn(
({children}: {children?: (args: Record<string, unknown>) => ReactNode}) => children?.({points: {y: []}, chartBounds: {left: 0, right: 0, top: 0, bottom: 0}}) ?? null,
),
Pie: {Chart: jest.fn(() => null)},
PolarChart: jest.fn(() => null),
}));

// Provide a default global fetch mock for tests that do not explicitly set it up
Expand Down
33 changes: 33 additions & 0 deletions server/libs/bundleWithRnStubs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import assertBuildSuccess from '@server/libs/assertBuildSuccess';
import createRnStubPlugin from '@server/plugins/rnStubPlugin';
import {basename, dirname, join, resolve} from 'node:path';

type BundleWithRnStubsOptions = {
packageRoot: string;
entrypoint: string;
outFile: string;
};

/** Every output is written beside outFile, because the bundle loads CanvasKit's wasm from beside itself. */
async function bundleWithRnStubs({packageRoot, entrypoint, outFile}: BundleWithRnStubsOptions): Promise<void> {
const buildResult = await Bun.build({
entrypoints: [entrypoint],
target: 'bun',
packages: 'bundle',
conditions: ['react-native'],
tsconfig: join(packageRoot, 'tsconfig.json'),
plugins: [createRnStubPlugin(resolve(packageRoot, '../stubs'))],
});

assertBuildSuccess(buildResult, `Failed to bundle ${entrypoint}`);

if (buildResult.outputs.length === 0) {
throw new Error(`Bundled output of ${entrypoint} is missing`);
}

for (const output of buildResult.outputs) {
await Bun.write(output.kind === 'entry-point' ? outFile : join(dirname(outFile), basename(output.path)), output);
}
}

export default bundleWithRnStubs;
35 changes: 4 additions & 31 deletions server/victory-chart-renderer/scripts/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,25 @@
* 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.
* resolve real react-native native sources that Bun cannot bundle.
*
* 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 assertBuildSuccess from '@server/libs/assertBuildSuccess';
import createRnStubPlugin from '@server/plugins/rnStubPlugin';
import bundleWithRnStubs from '@server/libs/bundleWithRnStubs';
import {spawnSync} from 'node:child_process';
import {mkdirSync} from 'node:fs';
import {basename, join, resolve} from 'node:path';
import {join, resolve} from 'node:path';

const packageRoot = resolve(import.meta.dir, '..');
const repoRoot = resolve(packageRoot, '../..');
const stubRoot = resolve(packageRoot, '../stubs');
const tsconfigPath = join(packageRoot, 'tsconfig.json');
const outFile = resolve(packageRoot, '.dev/cli.js');

mkdirSync(join(packageRoot, '.dev'), {recursive: true});

const buildResult = await Bun.build({
entrypoints: [resolve(packageRoot, 'src/bootstrap.tsx')],
target: 'bun',
packages: 'bundle',
conditions: ['react-native'],
tsconfig: tsconfigPath,
plugins: [createRnStubPlugin(stubRoot)],
});

assertBuildSuccess(buildResult, 'Failed to bundle victory-chart-renderer CLI');

if (buildResult.outputs.length === 0) {
throw new Error('Bundled CLI output is missing');
}

const devDir = join(packageRoot, '.dev');

for (const output of buildResult.outputs) {
const outputName = basename(output.path);
const destName = output.kind === 'entry-point' ? 'cli.js' : outputName;

await Bun.write(join(devDir, destName), output);
}
await bundleWithRnStubs({packageRoot, entrypoint: resolve(packageRoot, 'src/bootstrap.tsx'), outFile});

const runResult = spawnSync(process.execPath, [outFile, ...process.argv.slice(2)], {
cwd: repoRoot,
Expand Down
70 changes: 70 additions & 0 deletions server/victory-chart-renderer/tests/plotBoundsDrift.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {afterAll, beforeAll, describe, expect, test} from 'bun:test';

import type {ChartBounds} from 'victory-native';

import bundleWithRnStubs from '@server/libs/bundleWithRnStubs';
import {spawnSync} from 'node:child_process';
import {mkdtempSync, rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';

import {packageRoot} from './testUtils';

type PlotBounds = Pick<ChartBounds, 'left' | 'right'>;

type ProbeOutput = {
predicted: PlotBounds;
reported: PlotBounds;
};

const runDir = mkdtempSync(join(tmpdir(), 'vcr-plot-bounds-'));
const bundlePath = join(runDir, 'plotBoundsProbe.js');

function hasPlotBounds(value: unknown): value is PlotBounds {
return typeof value === 'object' && value !== null && 'left' in value && typeof value.left === 'number' && 'right' in value && typeof value.right === 'number';
}

function parseProbeOutput(raw: string): ProbeOutput {
const parsed: unknown = JSON.parse(raw);

if (typeof parsed !== 'object' || parsed === null || !('predicted' in parsed) || !('reported' in parsed) || !hasPlotBounds(parsed.predicted) || !hasPlotBounds(parsed.reported)) {
throw new Error(`Plot bounds probe printed unexpected output:\n${raw}`);
}

return {predicted: parsed.predicted, reported: parsed.reported};
}

let probeOutput: ProbeOutput;

describe('cartesian plot bounds', () => {
beforeAll(async () => {
await bundleWithRnStubs({
packageRoot,
entrypoint: fileURLToPath(import.meta.resolve('./probes/plotBoundsProbe.tsx')),
outFile: bundlePath,
});

const runResult = spawnSync(process.execPath, [bundlePath], {
cwd: resolve(packageRoot, '../..'),
encoding: 'utf8',
});

if (runResult.status !== 0) {
throw new Error(`Plot bounds probe failed:\n${runResult.stderr}\n${runResult.stdout}`);
}

probeOutput = parseProbeOutput(runResult.stdout);
}, 120_000);

afterAll(() => {
rmSync(runDir, {recursive: true, force: true});
});

test('should predict the plot bounds victory-native reports', () => {
// Given a real chart laid out the way the vertical bar chart is, whose padding and axes the line chart shares, drawn headless by the probe
// When its reported plot bounds are compared with the ones predicted from its width
// Then they match, so a victory-native upgrade that moves the plot fails here instead of misplacing labels
expect(probeOutput.predicted).toEqual(probeOutput.reported);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* Runs as a bundled child process rather than inside the test process, because victory-native resolves
* react-native, which only loads through the stubs rnStubPlugin substitutes at bundle time.
*/
import initSkiaForCli from '../../src/initSkiaForCli';

// Skia must be initialized before the chart module and its transitive imports are evaluated.
await initSkiaForCli();
await import('./plotBoundsProbeMain');
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {VERTICAL_BAR_DOMAIN_PADDING} from '@components/Charts/barChartConstants';
import {getCartesianPlotBounds} from '@components/Charts/utils';
import VictoryTheme, {CARTESIAN_Y_AXIS, CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING} from '@components/Charts/VictoryTheme';

import type {ChartBounds} from 'victory-native';

import {drawOffscreen, makeOffscreenSurface} from '@shopify/react-native-skia/lib/module/headless';
import {CartesianChart} from 'victory-native';

const CHART_WIDTH = 400;

const Y_AXIS_LABEL_WIDTH = 37;

const CHART_PADDING_RIGHT = Y_AXIS_LABEL_WIDTH + GLYPH_PADDING;

const DATA = [
{x: 0, y: 120},
{x: 1, y: 80},
{x: 2, y: 45},
];

const captured: {bounds: ChartBounds | null} = {bounds: null};

const chartElement = (
<CartesianChart
explicitSize={{width: CHART_WIDTH, height: CHART_CONTENT_MIN_HEIGHT}}
headless
xKey="x"
yKeys={['y']}
padding={{...VictoryTheme.axis.padding, right: CHART_PADDING_RIGHT}}
domainPadding={VERTICAL_BAR_DOMAIN_PADDING}
xAxis={{
tickCount: DATA.length,
lineWidth: VictoryTheme.axis.xLineWidth,
labelPosition: 'inset',
}}
yAxis={[CARTESIAN_Y_AXIS]}
frame={{lineWidth: 0}}
data={DATA}
>
{({chartBounds}) => {
// The bounds reach onChartBoundsChange from an effect, which the offscreen draw never flushes, so they
// are read here instead. Both come from the same value the chart computes for its own layout.
captured.bounds = chartBounds;
return null;
}}
</CartesianChart>
);

using surface = makeOffscreenSurface(CHART_WIDTH, CHART_CONTENT_MIN_HEIGHT);
using image = await drawOffscreen(surface, chartElement);

if (!image) {
throw new Error('Skia failed to draw the probe chart');
}

if (!captured.bounds) {
throw new Error('CartesianChart never reported its bounds');
}

const {left, right} = getCartesianPlotBounds(CHART_WIDTH, CHART_PADDING_RIGHT);

console.log(JSON.stringify({predicted: {left, right}, reported: {left: captured.bounds.left, right: captured.bounds.right}}));
4 changes: 2 additions & 2 deletions src/components/Charts/BarChart/BarChartContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import {ChartFontsProvider} from '@components/Charts/hooks';

import React from 'react';

import type BarChartProps from './types';
import type {BarChartContentProps} from './types';

import VerticalBarChartContentBody from './VerticalBarChartContent';

function BarChartContent(props: BarChartProps) {
function BarChartContent(props: BarChartContentProps) {
return (
<ChartFontsProvider>
<VerticalBarChartContentBody {...props} />
Expand Down
Loading
Loading