diff --git a/.github/workflows/bootstrap-release-package.yml b/.github/workflows/bootstrap-release-package.yml new file mode 100644 index 00000000..76e993d9 --- /dev/null +++ b/.github/workflows/bootstrap-release-package.yml @@ -0,0 +1,52 @@ +name: Bootstrap missing npm package + +on: + workflow_dispatch: + inputs: + package: + description: Exact missing fixed-set package@version to publish + required: true + type: string + +permissions: + contents: read + +concurrency: + group: charts-npm-bootstrap + cancel-in-progress: false + +jobs: + bootstrap: + name: Bootstrap npm package + if: github.repository == 'TanStack/charts' && github.event_name == 'workflow_dispatch' && github.ref_type == 'branch' && github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + environment: npm-bootstrap + permissions: + contents: read + id-token: write + + steps: + - name: Checkout exact main revision + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + ref: ${{ github.sha }} + + - name: Setup + uses: ./.github/actions/setup + + - name: Build and identify the sole missing package + id: prepare + env: + BOOTSTRAP_PACKAGE_SPEC: ${{ inputs.package }} + RELEASE_REVISION: ${{ github.sha }} + run: node scripts/bootstrap-release-package.mjs prepare + + - name: Publish the confirmed package + if: steps.prepare.outputs.publish_needed == 'true' + env: + BOOTSTRAP_PACKAGE_SPEC: ${{ inputs.package }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_BOOTSTRAP_TOKEN }} + RELEASE_REVISION: ${{ github.sha }} + run: node scripts/bootstrap-release-package.mjs publish diff --git a/API-FRICTION.md b/API-FRICTION.md index 119cf02f..67f48f09 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -210,6 +210,7 @@ Each entry records: | F-172 | Metro skipped the fixture-owned Babel runtime | Tooling | resolved | | F-173 | Metro retained the complete universal barrel | API/Tooling | monitoring | | F-174 | OIDC release cannot claim a new npm package name | Tooling | monitoring | +| F-175 | Native SVG resource normalization collapsed authored IDs | Application | resolved | ## Findings @@ -4065,6 +4066,10 @@ Each entry records: overlays, tooltips, and external callbacks attached to old point objects after responsive geometry changed. Callback prop identity could also retrigger restoration and incorrectly change the focus source. + An equivalent definition authored inline could rebuild the same scene after + a parent callback update; restoration then treated the new point objects as + another focus change, called the parent again, and could sustain an update + loop. Restacking onto the visual grouped-tooltip ordering change then exposed the same drift again: the DOM host defaulted to visual order while the native copy still used color-domain order. @@ -4084,8 +4089,9 @@ Each entry records: overlay; authored focus marks and inline mark states remain unsupported until scene-state resolution is shared. It now preserves the original primary and focus group independently from sorted tooltip rows, refreshes restored point - objects and callbacks, and keeps callback refs out of restoration effect - dependencies. + objects, re-emits callbacks when public point values or geometry change, + silently refreshes equivalent point references, and keeps callback refs out + of restoration effect dependencies. - Verification: focused native tests cover strategy selection, grouping, restoration, navigation, axis and custom anchors, supported default content, placement, and extension ownership. The native type and Metro gates use the @@ -4096,8 +4102,10 @@ Each entry records: regression verifies inactive focus-layer paint is absent from the native scene. Component regressions cover a primary point that sorts after another series, restored coordinates and callbacks after resize, and stable focus - source when only callback props change. A grouped-tooltip regression now - verifies visual default order and explicit color-domain order in both hosts. + source when only callback props change. An inline-definition regression + verifies an equivalent restored scene does not re-emit focus into the + parent. A grouped-tooltip regression now verifies visual default order and + explicit color-domain order in both hosts. ### F-169 — CSS theme defaults reach the native scene compiler @@ -4196,15 +4204,21 @@ Each entry records: result included unused marks, data-transform families, and the environment-neutral static SVG string serializer. Against the same granular full-chart fixture, `/universal` added 119.06 KiB minified and 28.91 KiB gzip - on both iOS and Android, plus 102 modules per platform. + on both iOS and Android, plus 102 modules per platform. The esbuild boundary + policy also classified that environment-neutral serializer as browser-only, + contradicting the Metro contract whenever a native fixture retained it. - Current decision: keep `/universal` as the ergonomic cross-runtime authoring entry and make the full-chart Metro proof exercise it. Keep the native host's own imports granular, publish granular entries as the bundle-sensitive path, - and do not describe the broad barrel as cost-equivalent under Metro. + and do not describe the broad barrel as cost-equivalent under Metro. Keep the + static serializer in the SVG capability group, but remove it from the + browser-only rejection group. - Verification: the iOS and Android full-chart bundles require `packages/charts-core/src/universal.ts`, measure 103.00 and 103.05 KiB gzip over blank respectively, and exclude DOM hosts, browser adapters, Canvas, reconciliation, SVG resources/surface, web tooltip code, and `react-dom`. + A native-plus-universal boundary fixture retains both the native host and the + static serializer at 11.37 KiB gzip and passes the browser-module rejection. ### F-174 — OIDC release cannot claim a new npm package name @@ -4217,11 +4231,29 @@ Each entry records: an existing package's settings, so the normal tokenless workflow cannot be authorized for this package before its registry entry exists. - Current decision: keep the package in release artifacts and the fixed - changeset, but require a maintainer-controlled direct public publish of the - checked `0.4.0` tarball. Configure the repository's release workflow as the - trusted publisher immediately afterward; the aggregate changeset can then - publish `0.5.0` through OIDC. + changeset. Bootstrap the sole missing fixed-set package from a dedicated, + protected GitHub-hosted workflow using a short-lived granular token and npm + provenance. The workflow builds and validates release artifacts, publishes + only the missing tarball, and verifies its registry integrity and + attestations. Configure `release.yml` as the trusted publisher immediately + afterward; the aggregate changeset can then publish `0.5.0` through OIDC. - Verification: `npm view @tanstack/react-native-charts` currently returns `E404`. Close this entry only after the public package exists, its trusted publisher names the repository release workflow, and an aggregate release publishes it without a long-lived write token. + +### F-175 — Native SVG resource normalization collapsed authored IDs + +- Status: resolved +- Severity: medium +- Owner: Application +- Observed in: pre-publication review of custom native scene gradients +- Friction: the native SVG host removed every character outside an allowlist + from authored gradient IDs. Distinct public IDs such as `a.b`, `a:b`, and + `ab` therefore addressed the same native SVG resource. +- Decision: preserve letters, digits, and hyphens, and encode every other code + point with an unambiguous SVG-safe escape. The escape marker itself is + encoded, so an authored string cannot collide with an encoded character. +- Verification: the native scene regression renders the formerly colliding + IDs plus empty and delimiter-containing IDs, and checks matching definition + IDs and paint references. diff --git a/benchmarks/entries/charts-react-native-universal-boundary.ts b/benchmarks/entries/charts-react-native-universal-boundary.ts new file mode 100644 index 00000000..7c3dca6e --- /dev/null +++ b/benchmarks/entries/charts-react-native-universal-boundary.ts @@ -0,0 +1,2 @@ +export { renderChartSvg } from '@tanstack/charts/universal' +export { Chart } from '@tanstack/react-native-charts' diff --git a/docs/installation.md b/docs/installation.md index 809705c8..6ce1a884 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -18,7 +18,7 @@ Then add one adapter if the application needs it: pnpm add @tanstack/react-charts react react-dom # React Native -pnpm add @tanstack/react-native-charts react react-native react-native-svg +pnpm add @tanstack/react-native-charts react@^19.2.3 react-native@^0.86.0 react-native-svg@^15.15.4 # Preact pnpm add @tanstack/preact-charts preact @@ -80,7 +80,7 @@ pnpm exec expo install react-native-svg Bare React Native 0.86 applications install the renderer directly: ```sh -pnpm add @tanstack/charts @tanstack/react-native-charts d3-scale react-native-svg +pnpm add @tanstack/charts @tanstack/react-native-charts d3-scale react-native-svg@^15.15.4 ``` Run `bundle exec pod install` from `ios/` after adding it to a bare iOS @@ -97,7 +97,7 @@ Packed tarballs are typechecked and bundled through default bare React Native and Expo Metro configurations on iOS and Android. The workspace Expo 57 fixture also renders in Expo Go on an iOS simulator. Bare-native and Android simulators, physical devices, gestures, visual parity, and screen readers are -not yet support claims. +not currently supported. ## Install the D3 modules you import diff --git a/packages/charts-core/docs/installation.md b/packages/charts-core/docs/installation.md index 809705c8..6ce1a884 100644 --- a/packages/charts-core/docs/installation.md +++ b/packages/charts-core/docs/installation.md @@ -18,7 +18,7 @@ Then add one adapter if the application needs it: pnpm add @tanstack/react-charts react react-dom # React Native -pnpm add @tanstack/react-native-charts react react-native react-native-svg +pnpm add @tanstack/react-native-charts react@^19.2.3 react-native@^0.86.0 react-native-svg@^15.15.4 # Preact pnpm add @tanstack/preact-charts preact @@ -80,7 +80,7 @@ pnpm exec expo install react-native-svg Bare React Native 0.86 applications install the renderer directly: ```sh -pnpm add @tanstack/charts @tanstack/react-native-charts d3-scale react-native-svg +pnpm add @tanstack/charts @tanstack/react-native-charts d3-scale react-native-svg@^15.15.4 ``` Run `bundle exec pod install` from `ios/` after adding it to a bare iOS @@ -97,7 +97,7 @@ Packed tarballs are typechecked and bundled through default bare React Native and Expo Metro configurations on iOS and Android. The workspace Expo 57 fixture also renders in Expo Go on an iOS simulator. Bare-native and Android simulators, physical devices, gestures, visual parity, and screen readers are -not yet support claims. +not currently supported. ## Install the D3 modules you import diff --git a/packages/react-native-charts/README.md b/packages/react-native-charts/README.md index 9eb34c90..2c339efb 100644 --- a/packages/react-native-charts/README.md +++ b/packages/react-native-charts/README.md @@ -19,7 +19,7 @@ npx expo install react-native-svg Bare React Native applications can install it directly: ```sh -npm install react-native-svg +npm install react-native-svg@^15.15.4 ``` Run `bundle exec pod install` from `ios/` after adding it to a bare iOS @@ -51,8 +51,8 @@ export function RevenueChart() { } ``` -The package is tested with Expo 57 / React Native 0.86 and -`react-native-svg` 15.15.x. The workspace fixture also renders in Expo Go on an -iOS simulator. It remains experimental: bare-native and Android simulators, +The bare fixture uses React Native 0.86.2 with `react-native-svg` 15.15.5. The +Expo 57 fixture uses `react-native-svg` 15.15.4 and renders in Expo Go on an iOS +simulator. It remains experimental: bare-native and Android simulators, physical devices, gestures, accessibility, release builds, and performance still need validation. diff --git a/packages/react-native-charts/src/Chart.test.tsx b/packages/react-native-charts/src/Chart.test.tsx index 7900d967..a815b56d 100644 --- a/packages/react-native-charts/src/Chart.test.tsx +++ b/packages/react-native-charts/src/Chart.test.tsx @@ -251,6 +251,48 @@ describe('React Native Chart', () => { } }) + it('does not re-emit focus for an equivalent inline definition', async () => { + const container = document.createElement('div') + const root = createRoot(container) + let focusEvents = 0 + + function InlineDefinitionChart() { + const [, rerender] = React.useReducer((value) => value + 1, 0) + const inlineDefinition = defineChart({ + marks: [lineY(data, { x: 'month', y: 'value' })], + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }) + return ( + { + if (!point) return + focusEvents += 1 + if (focusEvents < 3) rerender() + }} + /> + ) + } + + try { + await React.act(() => root.render()) + const chart = container.firstElementChild + if (!chart) throw new Error('Expected the native chart root to render.') + + await React.act(() => { + chart.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + }) + + expect(focusEvents).toBe(1) + } finally { + await React.act(() => root.unmount()) + } + }) + it('rejects browser tooltip portal extensions', () => { const portalDefinition = defineChart({ marks: [lineY(data, { x: 'month', y: 'value' })], diff --git a/packages/react-native-charts/src/Chart.tsx b/packages/react-native-charts/src/Chart.tsx index 02bd7e8d..f636e9db 100644 --- a/packages/react-native-charts/src/Chart.tsx +++ b/packages/react-native-charts/src/Chart.tsx @@ -25,6 +25,7 @@ import { NativeChartFocusOverlay } from './FocusOverlay' import { adjacentFocusPoint, createNativeChartFocusModel, + samePointIdentity, samePointReferences, } from './interaction' import { resolveNativePaint, type NativePaintResolver } from './paint' @@ -174,8 +175,17 @@ export function Chart< if (!focusModel || !previous) return const restored = focusModel.restore(previous) if (restored) { + const next = focusModel.group(restored) + const current = focusedPointsRef.current + if (sameFocusedPointValues(next, current)) { + if (!samePointReferences(next, current)) { + focusedPointsRef.current = next + setFocusedPoints(next) + } + return + } setFocusSource('restored') - commitFocus(focusModel.group(restored)) + commitFocus(next) } else { setPinnedKey(null) commitFocus([]) @@ -364,6 +374,48 @@ export function Chart< ) } +function sameFocusedPointValues< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( + left: readonly ChartPoint[], + right: readonly ChartPoint[], +) { + return ( + left.length === right.length && + left.every((point, index) => { + const current = right[index] + return ( + current !== undefined && + samePointIdentity(point, current) && + Object.is(point.group, current.group) && + point.groupLabel === current.groupLabel && + sameChartValue(point.xValue, current.xValue) && + sameChartValue(point.yValue, current.yValue) && + sameChartValue(point.x1Value, current.x1Value) && + sameChartValue(point.x2Value, current.x2Value) && + sameChartValue(point.y1Value, current.y1Value) && + sameChartValue(point.y2Value, current.y2Value) && + point.xInterval === current.xInterval && + point.yInterval === current.yInterval && + Object.is(point.x, current.x) && + Object.is(point.y, current.y) && + point.color === current.color + ) + }) + ) +} + +function sameChartValue( + left: ChartValue | undefined, + right: ChartValue | undefined, +) { + return left instanceof Date && right instanceof Date + ? left.getTime() === right.getTime() + : Object.is(left, right) +} + function resolveNativeTooltipInput< TDatum, TXValue extends ChartValue, diff --git a/packages/react-native-charts/src/SvgScene.test.tsx b/packages/react-native-charts/src/SvgScene.test.tsx index 0ecc1569..30ce6c42 100644 --- a/packages/react-native-charts/src/SvgScene.test.tsx +++ b/packages/react-native-charts/src/SvgScene.test.tsx @@ -52,6 +52,43 @@ describe('React Native SVG scene renderer', () => { expect(resolveNativeLineJoin('miter-clip')).toBe('miter') expect(resolveNativeLineJoin('round')).toBe('round') }) + + it('keeps distinct authored gradient ids distinct after encoding', () => { + const collisionScene = scene() + collisionScene.gradients = ['a.b', 'a:b', 'ab', '', 'a)b'].map((id) => ({ + id, + stops: [{ offset: 0, color: '#2563eb' }], + })) + collisionScene.nodes = collisionScene.gradients.map((gradient, index) => ({ + kind: 'rect' as const, + key: gradient.id, + x: index * 10, + y: 0, + width: 10, + height: 10, + style: { fill: `url(#${gradient.id})` }, + })) + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('id="native-one-a_x2e_b"') + expect(markup).toContain('url(#native-one-a_x2e_b)') + expect(markup).toContain('id="native-one-a_x3a_b"') + expect(markup).toContain('url(#native-one-a_x3a_b)') + expect(markup).toContain('id="native-one-ab"') + expect(markup).toContain('url(#native-one-ab)') + expect(markup).toContain('id="native-one-_"') + expect(markup).toContain('url(#native-one-_)') + expect(markup).toContain('id="native-one-a_x29_b"') + expect(markup).toContain('url(#native-one-a_x29_b)') + }) }) function scene(): ChartScene { diff --git a/packages/react-native-charts/src/SvgScene.tsx b/packages/react-native-charts/src/SvgScene.tsx index d0c41631..7a5f3bc2 100644 --- a/packages/react-native-charts/src/SvgScene.tsx +++ b/packages/react-native-charts/src/SvgScene.tsx @@ -252,9 +252,11 @@ function resolveScenePaint( resolvePaint: NativePaintResolver, color: ColorValue, ) { - const match = /^url\(#([^)]+)\)$/.exec(value) + const match = /^url\(#([\s\S]*)\)$/.exec(value) const id = match?.[1] - if (id && gradientIds.has(id)) return `url(#${scopedId(idPrefix, id)})` + if (id !== undefined && gradientIds.has(id)) { + return `url(#${scopedId(idPrefix, id)})` + } return resolvePaint(value, { color }) } @@ -268,11 +270,17 @@ function pointsPath( } function scopedId(prefix: string, id: string) { - return prefix ? `${prefix}-${sanitizeId(id)}` : sanitizeId(id) + const encodedId = encodeResourceId(id) + return prefix ? `${prefix}-${encodedId}` : encodedId } -function sanitizeId(value: string) { - return value.replaceAll(/[^a-zA-Z0-9_-]/g, '') +function encodeResourceId(value: string) { + if (!value) return '_' + return Array.from(value, (character) => + /^[a-zA-Z0-9-]$/.test(character) + ? character + : `_x${character.codePointAt(0)!.toString(16)}_`, + ).join('') } function stableId(value: string) { diff --git a/packages/react-native-charts/src/Tooltip.test.ts b/packages/react-native-charts/src/Tooltip.test.ts index ef2b1078..7c5f3d42 100644 --- a/packages/react-native-charts/src/Tooltip.test.ts +++ b/packages/react-native-charts/src/Tooltip.test.ts @@ -15,10 +15,27 @@ import { } from './Tooltip' import type { NativeChartTooltipRenderContext } from './Tooltip' -vi.mock('react-native', () => ({ - Text: 'span', - View: 'div', -})) +vi.mock('react-native', async () => { + const ReactModule = await import('react') + return { + Text: 'span', + View: ({ + children, + style, + ...rest + }: React.HTMLAttributes & { style?: unknown }) => + ReactModule.createElement( + 'div', + { + ...rest, + style: (Array.isArray(style) + ? Object.assign({}, ...style.filter(Boolean)) + : style) as React.CSSProperties, + }, + children, + ), + } +}) describe('native tooltip model', () => { it('builds the supported shared-axis default content', () => { diff --git a/scripts/bootstrap-release-package.mjs b/scripts/bootstrap-release-package.mjs new file mode 100644 index 00000000..7e00b6b4 --- /dev/null +++ b/scripts/bootstrap-release-package.mjs @@ -0,0 +1,457 @@ +import assert from 'node:assert/strict' +import { execFile, spawn } from 'node:child_process' +import { appendFile, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import { promisify } from 'node:util' +import { + normalizeRegistryPackageMetadata, + validateReleaseArtifacts, +} from './release-artifacts.mjs' +import { + readReleasePackages, + releaseArtifactsDirectoryName, +} from './release-package-config.mjs' +import { validateTrustedPublishingNpmVersion } from './release-security.mjs' + +const execFileAsync = promisify(execFile) +const repositoryRoot = resolve(import.meta.dirname, '..') +const bootstrapPlanPath = resolve( + repositoryRoot, + releaseArtifactsDirectoryName, + 'bootstrap-plan.json', +) +const command = process.argv[2] + +if (process.argv[1] === import.meta.filename) { + assert.ok( + command === 'prepare' || command === 'publish', + 'Usage: node scripts/bootstrap-release-package.mjs ', + ) + assert.equal(process.argv.length, 3, 'Bootstrap command accepts no arguments') + validateBootstrapEnvironment(process.env) + await validateCheckedOutRevision() + + if (command === 'prepare') { + await prepareBootstrapPackage() + } else { + await publishBootstrapPackage() + } +} + +export function validateBootstrapEnvironment(env) { + assert.equal(env.GITHUB_ACTIONS, 'true', 'Bootstrap requires GitHub Actions') + assert.equal( + env.GITHUB_EVENT_NAME, + 'workflow_dispatch', + 'Bootstrap requires workflow_dispatch', + ) + assert.equal(env.GITHUB_REF_TYPE, 'branch', 'Bootstrap requires a branch') + assert.equal(env.GITHUB_REF_NAME, 'main', 'Bootstrap requires main') + assert.equal( + env.GITHUB_REF, + 'refs/heads/main', + 'Bootstrap requires refs/heads/main', + ) + assert.equal( + env.GITHUB_REPOSITORY, + 'TanStack/charts', + 'Bootstrap requires TanStack/charts', + ) + assert.match( + env.GITHUB_SHA ?? '', + /^[0-9a-f]{40}$/, + 'Bootstrap requires an exact GitHub revision', + ) + assert.equal( + env.RELEASE_REVISION, + env.GITHUB_SHA, + 'Bootstrap revision differs from GITHUB_SHA', + ) + assert.ok( + env.BOOTSTRAP_PACKAGE_SPEC, + 'Bootstrap requires an exact package@version confirmation', + ) +} + +export function selectBootstrapCandidate({ + artifacts, + expectedSpec, + registryPackages, +}) { + const expectedArtifact = artifacts.find( + (artifact) => packageSpec(artifact) === expectedSpec, + ) + assert.ok( + expectedArtifact, + `${expectedSpec} is not the exact fixed-set package and version`, + ) + + const missing = artifacts.filter( + (artifact) => registryPackages.get(artifact.name) === null, + ) + assert.ok( + missing.length <= 1, + `Bootstrap requires exactly one missing fixed-set package; missing: ${missing + .map(packageSpec) + .join(', ')}`, + ) + + for (const artifact of artifacts) { + const registry = registryPackages.get(artifact.name) + assert.notEqual( + registry, + undefined, + `Registry state is missing for ${packageSpec(artifact)}`, + ) + if (registry === null) continue + validatePublishedFixedPackage(artifact, registry) + } + + if (missing.length === 0) { + validatePublishedArtifact( + expectedArtifact, + registryPackages.get(expectedArtifact.name), + ) + validateBootstrapDependencies(expectedArtifact, artifacts, registryPackages) + return { artifact: expectedArtifact, publishNeeded: false } + } + + assert.equal( + packageSpec(missing[0]), + expectedSpec, + `Confirmation differs from the missing fixed-set package ${packageSpec(missing[0])}`, + ) + validateBootstrapDependencies(missing[0], artifacts, registryPackages) + return { artifact: missing[0], publishNeeded: true } +} + +export function validateFixedReleaseSet(releasePackages, changesetConfig) { + const packageNames = releasePackages.map((packageInfo) => packageInfo.name) + assert.deepEqual( + changesetConfig.fixed, + [packageNames], + 'Changesets fixed set differs from releasePackageConfigs', + ) + return packageNames +} + +async function prepareBootstrapPackage() { + await buildReleaseArtifacts() + const state = await readBootstrapState() + const selection = selectBootstrapCandidate({ + ...state, + expectedSpec: process.env.BOOTSTRAP_PACKAGE_SPEC, + }) + const plan = { + schemaVersion: 1, + revision: process.env.GITHUB_SHA, + package: selection.artifact.name, + version: selection.artifact.manifest.version, + filename: selection.artifact.artifactFilename, + integrity: selection.artifact.integrity, + } + await writeFile(bootstrapPlanPath, `${JSON.stringify(plan, null, 2)}\n`) + await writeWorkflowOutputs({ + package: plan.package, + version: plan.version, + publish_needed: String(selection.publishNeeded), + }) + + if (selection.publishNeeded) { + console.log( + `Prepared the sole missing package ${packageSpec(selection.artifact)}.`, + ) + } else { + console.log( + `Already published with matching integrity and provenance: ${packageSpec(selection.artifact)}`, + ) + } +} + +async function publishBootstrapPackage() { + const { artifacts } = await validateReleaseArtifacts(repositoryRoot) + await assertFixedReleaseSet() + const plan = JSON.parse(await readFile(bootstrapPlanPath, 'utf8')) + assert.deepEqual( + { + schemaVersion: plan.schemaVersion, + revision: plan.revision, + package: plan.package, + version: plan.version, + }, + { + schemaVersion: 1, + revision: process.env.GITHUB_SHA, + package: expectedPackage().name, + version: expectedPackage().manifest.version, + }, + 'Bootstrap plan does not match this exact workflow revision and confirmation', + ) + + const plannedArtifact = artifacts.find( + (artifact) => artifact.name === plan.package, + ) + assert.ok( + plannedArtifact, + `Bootstrap artifact is missing for ${plan.package}`, + ) + assert.equal( + plannedArtifact.artifactFilename, + plan.filename, + 'Bootstrap artifact filename changed after preparation', + ) + assert.equal( + plannedArtifact.integrity, + plan.integrity, + 'Bootstrap artifact changed after preparation', + ) + + const state = await readBootstrapState(artifacts) + const selection = selectBootstrapCandidate({ + ...state, + expectedSpec: process.env.BOOTSTRAP_PACKAGE_SPEC, + }) + assert.equal( + selection.artifact.name, + plan.package, + 'Bootstrap candidate changed after preparation', + ) + if (!selection.publishNeeded) { + console.log( + `Already published with matching integrity and provenance: ${packageSpec(selection.artifact)}`, + ) + return + } + + assert.ok(process.env.NODE_AUTH_TOKEN, 'Bootstrap npm token is missing') + validateTrustedPublishingNpmVersion((await runNpm(['--version'])).stdout) + await publishArtifact(selection.artifact, process.env.NODE_AUTH_TOKEN) + const registry = await waitForRegistryPackage(selection.artifact) + validatePublishedArtifact(selection.artifact, registry) + console.log( + `Published ${packageSpec(selection.artifact)} with verified integrity and provenance.`, + ) +} + +async function readBootstrapState(existingArtifacts) { + const artifacts = + existingArtifacts ?? + (await validateReleaseArtifacts(repositoryRoot)).artifacts + await assertFixedReleaseSet() + const registryPackages = new Map() + await Promise.all( + artifacts.map(async (artifact) => { + registryPackages.set( + artifact.name, + await readRegistryPackage(artifact.name, artifact.manifest.version), + ) + }), + ) + return { artifacts, registryPackages } +} + +async function assertFixedReleaseSet() { + const releasePackages = await readReleasePackages(repositoryRoot) + const changesetConfig = JSON.parse( + await readFile(resolve(repositoryRoot, '.changeset/config.json'), 'utf8'), + ) + validateFixedReleaseSet(releasePackages, changesetConfig) +} + +async function validateCheckedOutRevision() { + const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { + cwd: repositoryRoot, + env: publicRegistryEnvironment(process.env), + }) + assert.equal( + stdout.trim(), + process.env.GITHUB_SHA, + 'Checked-out revision differs from GITHUB_SHA', + ) +} + +function expectedPackage() { + const match = process.env.BOOTSTRAP_PACKAGE_SPEC.match( + /^(@[^/\s]+\/[^@/\s]+)@((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/, + ) + assert.ok( + match, + 'Bootstrap confirmation must be an exact scoped package@version', + ) + return { name: match[1], manifest: { version: match[2] } } +} + +function validateBootstrapDependencies(artifact, artifacts, registryPackages) { + const fixedPackages = new Map( + artifacts.map((candidate) => [candidate.name, candidate]), + ) + for (const [name, version] of Object.entries( + artifact.packedManifest.dependencies ?? {}, + )) { + const dependency = fixedPackages.get(name) + if (!dependency) continue + assert.equal( + version, + artifact.manifest.version, + `${packageSpec(artifact)} must pin ${name}@${artifact.manifest.version}`, + ) + const registry = registryPackages.get(name) + assert.ok( + registry, + `${packageSpec(artifact)} requires published dependency ${name}@${version}`, + ) + validatePublishedFixedPackage(dependency, registry) + } +} + +function validatePublishedFixedPackage(artifact, registry) { + assert.equal( + registry.name, + artifact.name, + `${artifact.name} registry name differs`, + ) + assert.equal( + registry.version, + artifact.manifest.version, + `${packageSpec(artifact)} registry version differs`, + ) + assert.ok( + hasAttestations(registry), + `${packageSpec(artifact)} lacks provenance attestations`, + ) +} + +function validatePublishedArtifact(artifact, registry) { + validatePublishedFixedPackage(artifact, registry) + assert.equal( + registry.dist?.integrity, + artifact.integrity, + `${packageSpec(artifact)} exists with different contents`, + ) +} + +async function readRegistryPackage(name, version) { + try { + const { stdout } = await runNpm([ + 'view', + `${name}@${version}`, + 'name', + 'version', + 'dist.integrity', + 'dist.attestations', + '--json', + ]) + return normalizeRegistryPackageMetadata(JSON.parse(stdout)) + } catch (error) { + if (error?.stderr?.includes('E404')) return null + throw error + } +} + +async function waitForRegistryPackage(artifact) { + let lastResult = null + for (let attempt = 0; attempt < 60; attempt += 1) { + lastResult = await readRegistryPackage( + artifact.name, + artifact.manifest.version, + ) + if ( + lastResult?.dist?.integrity === artifact.integrity && + hasAttestations(lastResult) + ) { + return lastResult + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 2_000)) + } + assert.fail( + `${packageSpec(artifact)} registry metadata did not stabilize after 120 seconds ` + + `(integrity: ${lastResult?.dist?.integrity ?? 'missing'}; provenance: ${hasAttestations(lastResult) ? 'present' : 'missing'})`, + ) +} + +async function publishArtifact(artifact, token) { + const npmDirectory = await mkdtemp(resolve(tmpdir(), 'charts-npm-bootstrap-')) + const userConfig = resolve(npmDirectory, 'npmrc') + try { + await writeFile(userConfig, `//registry.npmjs.org/:_authToken=${token}\n`, { + mode: 0o600, + }) + await runNpm( + [ + 'publish', + artifact.tarball, + '--access', + 'public', + '--tag', + 'latest', + '--provenance', + ], + { NPM_CONFIG_USERCONFIG: userConfig }, + ) + } finally { + await rm(npmDirectory, { recursive: true, force: true }) + } +} + +function runNpm(args, extraEnv = {}) { + return execFileAsync('npm', args, { + cwd: repositoryRoot, + env: { ...publicRegistryEnvironment(process.env), ...extraEnv }, + maxBuffer: 20 * 1024 * 1024, + }) +} + +function publicRegistryEnvironment(env) { + const sanitized = { ...env } + delete sanitized.NODE_AUTH_TOKEN + delete sanitized.NPM_TOKEN + delete sanitized.NPM_CONFIG_USERCONFIG + return sanitized +} + +function buildReleaseArtifacts() { + return new Promise((resolvePromise, reject) => { + const child = spawn( + process.execPath, + [resolve(repositoryRoot, 'scripts/build-release-artifacts.mjs')], + { + cwd: repositoryRoot, + env: { ...publicRegistryEnvironment(process.env), CI: 'true' }, + stdio: 'inherit', + }, + ) + child.once('error', reject) + child.once('exit', (code, signal) => { + if (code === 0) { + resolvePromise() + return + } + reject( + new Error( + `Release artifact build exited with ${code ?? `signal ${signal ?? 'unknown'}`}`, + ), + ) + }) + }) +} + +async function writeWorkflowOutputs(outputs) { + if (!process.env.GITHUB_OUTPUT) return + for (const [name, value] of Object.entries(outputs)) { + assert.match(name, /^[a-z_]+$/) + assert.doesNotMatch(value, /[\r\n]/) + await appendFile(process.env.GITHUB_OUTPUT, `${name}=${value}\n`) + } +} + +function packageSpec(artifact) { + return `${artifact.name}@${artifact.manifest.version}` +} + +function hasAttestations(registry) { + return ( + registry?.dist?.attestations !== undefined && + registry.dist.attestations !== null + ) +} diff --git a/scripts/bootstrap-release-package.test.mjs b/scripts/bootstrap-release-package.test.mjs new file mode 100644 index 00000000..4bb312e6 --- /dev/null +++ b/scripts/bootstrap-release-package.test.mjs @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest' +import { + selectBootstrapCandidate, + validateBootstrapEnvironment, + validateFixedReleaseSet, +} from './bootstrap-release-package.mjs' + +const version = '0.4.0' +const artifacts = [ + artifact('@tanstack/charts', 'sha512-core'), + artifact('@tanstack/react-native-charts', 'sha512-native', { + '@tanstack/charts': version, + }), +] + +describe('npm package bootstrap', () => { + it('derives and confirms the sole missing fixed-set package', () => { + const selection = selectBootstrapCandidate({ + artifacts, + expectedSpec: '@tanstack/react-native-charts@0.4.0', + registryPackages: new Map([ + ['@tanstack/charts', registry(artifacts[0])], + ['@tanstack/react-native-charts', null], + ]), + }) + + expect(selection).toEqual({ artifact: artifacts[1], publishNeeded: true }) + }) + + it('rejects publication when multiple fixed packages are missing', () => { + expect(() => + selectBootstrapCandidate({ + artifacts, + expectedSpec: '@tanstack/react-native-charts@0.4.0', + registryPackages: new Map([ + ['@tanstack/charts', null], + ['@tanstack/react-native-charts', null], + ]), + }), + ).toThrow(/exactly one missing fixed-set package/) + }) + + it('rejects a confirmation outside the fixed release set', () => { + expect(() => + selectBootstrapCandidate({ + artifacts, + expectedSpec: '@tanstack/unknown-charts@0.4.0', + registryPackages: new Map([ + ['@tanstack/charts', registry(artifacts[0])], + ['@tanstack/react-native-charts', null], + ]), + }), + ).toThrow(/is not the exact fixed-set package and version/) + }) + + it('rejects a confirmation that differs from the missing package', () => { + expect(() => + selectBootstrapCandidate({ + artifacts, + expectedSpec: '@tanstack/charts@0.4.0', + registryPackages: new Map([ + ['@tanstack/charts', registry(artifacts[0])], + ['@tanstack/react-native-charts', null], + ]), + }), + ).toThrow(/Confirmation differs/) + }) + + it('accepts an idempotent rerun only with exact integrity and provenance', () => { + const registryPackages = new Map( + artifacts.map((entry) => [entry.name, registry(entry)]), + ) + expect( + selectBootstrapCandidate({ + artifacts, + expectedSpec: '@tanstack/react-native-charts@0.4.0', + registryPackages, + }), + ).toEqual({ artifact: artifacts[1], publishNeeded: false }) + + registryPackages.set( + '@tanstack/react-native-charts', + registry(artifacts[1], { integrity: 'sha512-different' }), + ) + expect(() => + selectBootstrapCandidate({ + artifacts, + expectedSpec: '@tanstack/react-native-charts@0.4.0', + registryPackages, + }), + ).toThrow(/different contents/) + }) + + it('requires every existing fixed package and internal dependency to have provenance', () => { + expect(() => + selectBootstrapCandidate({ + artifacts, + expectedSpec: '@tanstack/react-native-charts@0.4.0', + registryPackages: new Map([ + ['@tanstack/charts', registry(artifacts[0], { attestations: null })], + ['@tanstack/react-native-charts', null], + ]), + }), + ).toThrow(/lacks provenance attestations/) + }) + + it('requires internal dependencies to use the fixed release version', () => { + const invalidArtifacts = [ + artifacts[0], + artifact('@tanstack/react-native-charts', 'sha512-native', { + '@tanstack/charts': '0.3.0', + }), + ] + expect(() => + selectBootstrapCandidate({ + artifacts: invalidArtifacts, + expectedSpec: '@tanstack/react-native-charts@0.4.0', + registryPackages: new Map([ + ['@tanstack/charts', registry(invalidArtifacts[0])], + ['@tanstack/react-native-charts', null], + ]), + }), + ).toThrow(/must pin @tanstack\/charts@0\.4\.0/) + }) + + it('requires the Changesets fixed group to exactly match release packages', () => { + const packages = artifacts.map(({ name }) => ({ name })) + expect( + validateFixedReleaseSet(packages, { + fixed: [packages.map(({ name }) => name)], + }), + ).toEqual(packages.map(({ name }) => name)) + expect(() => + validateFixedReleaseSet(packages, { + fixed: [['@tanstack/charts']], + }), + ).toThrow(/differs from releasePackageConfigs/) + }) + + it('requires an exact manual dispatch from the main branch of this repository', () => { + const env = { + GITHUB_ACTIONS: 'true', + GITHUB_EVENT_NAME: 'workflow_dispatch', + GITHUB_REF_TYPE: 'branch', + GITHUB_REF_NAME: 'main', + GITHUB_REF: 'refs/heads/main', + GITHUB_REPOSITORY: 'TanStack/charts', + GITHUB_SHA: 'a'.repeat(40), + RELEASE_REVISION: 'a'.repeat(40), + BOOTSTRAP_PACKAGE_SPEC: '@tanstack/react-native-charts@0.4.0', + } + expect(() => validateBootstrapEnvironment(env)).not.toThrow() + expect(() => + validateBootstrapEnvironment({ + ...env, + GITHUB_REF: 'refs/heads/feature', + }), + ).toThrow(/refs\/heads\/main/) + expect(() => + validateBootstrapEnvironment({ + ...env, + GITHUB_REPOSITORY: 'fork/charts', + }), + ).toThrow(/TanStack\/charts/) + }) +}) + +function artifact(name, integrity, dependencies = {}) { + return { + name, + integrity, + manifest: { version }, + packedManifest: { dependencies }, + } +} + +function registry(entry, overrides = {}) { + return { + name: entry.name, + version, + dist: { + integrity: overrides.integrity ?? entry.integrity, + attestations: + overrides.attestations === undefined + ? { url: 'https://registry.npmjs.org/-/npm/v1/attestations/example' } + : overrides.attestations, + }, + } +} diff --git a/scripts/bootstrap-release-workflow.test.mjs b/scripts/bootstrap-release-workflow.test.mjs new file mode 100644 index 00000000..f0187bfc --- /dev/null +++ b/scripts/bootstrap-release-workflow.test.mjs @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { describe, it } from 'vitest' + +const workflow = await readFile( + resolve( + import.meta.dirname, + '../.github/workflows/bootstrap-release-package.yml', + ), + 'utf8', +) +const normalReleaseWorkflow = await readFile( + resolve(import.meta.dirname, '../.github/workflows/release.yml'), + 'utf8', +) +const bootstrapScript = await readFile( + resolve(import.meta.dirname, './bootstrap-release-package.mjs'), + 'utf8', +) + +describe('npm bootstrap workflow contract', () => { + it('is a guarded one-shot workflow on exact main', () => { + assert.match(workflow, /workflow_dispatch:\s*\n\s+inputs:/) + assert.doesNotMatch(workflow, /\bpush:|pull_request:|schedule:/) + assert.match( + workflow, + /package:\s*\n\s+description: Exact missing fixed-set package@version/, + ) + assert.match(workflow, /github\.repository == 'TanStack\/charts'/) + assert.match(workflow, /github\.event_name == 'workflow_dispatch'/) + assert.match(workflow, /github\.ref_type == 'branch'/) + assert.match(workflow, /github\.ref == 'refs\/heads\/main'/) + assert.match(workflow, /environment:\s*npm-bootstrap/) + assert.match(workflow, /runs-on:\s*ubuntu-24\.04/) + assert.match(workflow, /group:\s*charts-npm-bootstrap/) + assert.match(workflow, /cancel-in-progress:\s*false/) + }) + + it('uses read-only contents, OIDC provenance, and no checkout credentials', () => { + assert.match(workflow, /^permissions:\s*\n\s+contents:\s*read\s*$/m) + assert.equal((workflow.match(/contents:\s*read/g) ?? []).length, 2) + assert.equal((workflow.match(/id-token:\s*write/g) ?? []).length, 1) + assert.doesNotMatch(workflow, /contents:\s*write|pull-requests:\s*write/) + assert.match(workflow, /persist-credentials:\s*false/) + assert.match(workflow, /ref:\s*\${{ github\.sha }}/) + assertPinnedExternalActions(workflow) + }) + + it('exposes the bootstrap token only to the conditional publish step', () => { + assert.equal((workflow.match(/NODE_AUTH_TOKEN/g) ?? []).length, 1) + assert.equal((workflow.match(/NPM_BOOTSTRAP_TOKEN/g) ?? []).length, 1) + const publishStep = step('Publish the confirmed package') + assert.match( + publishStep, + /if:\s*steps\.prepare\.outputs\.publish_needed == 'true'/, + ) + assert.match( + publishStep, + /NODE_AUTH_TOKEN:\s*\${{ secrets\.NPM_BOOTSTRAP_TOKEN }}/, + ) + assert.match( + publishStep, + /node scripts\/bootstrap-release-package\.mjs publish/, + ) + assert.doesNotMatch( + normalReleaseWorkflow, + /NPM_BOOTSTRAP_TOKEN|NODE_AUTH_TOKEN/, + ) + }) + + it('builds and derives the candidate before publication', () => { + const prepareStep = step('Build and identify the sole missing package') + assert.match(prepareStep, /id:\s*prepare/) + assert.match( + prepareStep, + /BOOTSTRAP_PACKAGE_SPEC:\s*\${{ inputs\.package }}/, + ) + assert.match( + prepareStep, + /node scripts\/bootstrap-release-package\.mjs prepare/, + ) + assert.ok( + workflow.indexOf('bootstrap-release-package.mjs prepare') < + workflow.indexOf('bootstrap-release-package.mjs publish'), + ) + }) + + it('publishes one prepared tarball and verifies registry provenance', () => { + assert.match( + bootstrapScript, + /'publish',\s*artifact\.tarball,\s*'--access',\s*'public',\s*'--tag',\s*'latest',\s*'--provenance'/, + ) + assert.match( + bootstrapScript, + /await waitForRegistryPackage\(selection\.artifact\)/, + ) + assert.match( + bootstrapScript, + /validatePublishedArtifact\(selection\.artifact, registry\)/, + ) + assert.match(bootstrapScript, /attempt < 60/) + assert.match(bootstrapScript, /setTimeout\(resolvePromise, 2_000\)/) + assert.match(bootstrapScript, /delete sanitized\.NODE_AUTH_TOKEN/) + assert.match(bootstrapScript, /'bootstrap-plan\.json'/) + assert.match(bootstrapScript, /revision: process\.env\.GITHUB_SHA/) + assert.match( + bootstrapScript, + /execFileAsync\('git', \['rev-parse', 'HEAD'\]/, + ) + assert.match( + bootstrapScript, + /'Checked-out revision differs from GITHUB_SHA'/, + ) + }) +}) + +function step(name) { + const lines = workflow.split(/\r?\n/) + const start = lines.findIndex((line) => line.trim() === `- name: ${name}`) + assert.notEqual(start, -1, `workflow must define step ${name}`) + const indent = indentation(lines[start]) + let end = lines.length + for (let index = start + 1; index < lines.length; index += 1) { + if ( + /^\s*- name:/.test(lines[index]) && + indentation(lines[index]) === indent + ) { + end = index + break + } + } + return lines.slice(start, end).join('\n') +} + +function assertPinnedExternalActions(source) { + const uses = [...source.matchAll(/^\s*uses:\s*([^\s#]+)\s*(?:#.*)?$/gm)].map( + (match) => match[1], + ) + assert.ok(uses.length > 0, 'workflow must use actions') + for (const action of uses) { + if (action.startsWith('./')) continue + assert.match(action, /@[0-9a-f]{40}$/, `${action} must be immutable`) + } +} + +function indentation(line) { + return line.match(/^\s*/)[0].length +} diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 8c9202be..7f20ed4d 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -42,10 +42,8 @@ const rendererBoundaryModules = { 'packages/charts-core/src/export.ts', 'packages/charts-core/src/reconcile.ts', 'packages/charts-core/src/renderer.ts', - 'packages/charts-core/src/svg-renderer.ts', 'packages/charts-core/src/svg-resources.ts', 'packages/charts-core/src/svg-surface.ts', - 'packages/charts-core/src/svg.ts', 'packages/react-charts/src/CanvasChart.tsx', 'packages/react-charts/src/Chart.tsx', 'packages/react-charts/src/RendererChart.tsx', @@ -383,6 +381,16 @@ const entries = [ conditions: ['react-native', 'import'], }, ), + measured( + 'React Native host + universal static SVG boundary', + 'benchmarks/entries/charts-react-native-universal-boundary.ts', + { + external: nativeExternals, + rendererBoundary: 'native', + platform: 'neutral', + conditions: ['react-native', 'import'], + }, + ), measured( 'React Native SVG host + tooltip', 'benchmarks/entries/charts-react-native-tooltip.ts', diff --git a/vitest.config.ts b/vitest.config.ts index 70428ac9..d6b02995 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,7 @@ import { configDefaults, defineConfig } from 'vitest/config' export default defineConfig({ test: { environment: 'jsdom', + setupFiles: ['./vitest.setup.ts'], include: [ 'benchmarks/comparison/**/*.test.ts', 'benchmarks/conformance/**/*.test.ts', diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 00000000..740c1eb1 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1 @@ +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })