From e4b1537f2994ba003ee719df211068eed372e0c1 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 28 Jul 2026 22:53:08 -0600 Subject: [PATCH 1/9] test charts catalog content contracts --- tests/charts-catalog-assets.test.ts | 90 ++++++++++++ tests/charts-catalog-frame-embedding.test.ts | 67 +++++++++ tests/charts-catalog-manifest.test.ts | 140 +++++++++++++++++++ tests/charts-catalog-raw-content.test.ts | 41 ++++++ tests/charts-catalog-search.test.ts | 40 ++++++ tests/charts-catalog-site-contracts.test.ts | 58 ++++++++ tests/charts-catalog-test-fixture.ts | 111 +++++++++++++++ 7 files changed, 547 insertions(+) create mode 100644 tests/charts-catalog-assets.test.ts create mode 100644 tests/charts-catalog-frame-embedding.test.ts create mode 100644 tests/charts-catalog-manifest.test.ts create mode 100644 tests/charts-catalog-raw-content.test.ts create mode 100644 tests/charts-catalog-search.test.ts create mode 100644 tests/charts-catalog-site-contracts.test.ts create mode 100644 tests/charts-catalog-test-fixture.ts diff --git a/tests/charts-catalog-assets.test.ts b/tests/charts-catalog-assets.test.ts new file mode 100644 index 000000000..0a8882037 --- /dev/null +++ b/tests/charts-catalog-assets.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + getChartsCatalogAssetHeaders, + parseChartsCatalogAssetRequest, +} from '../src/utils/charts-catalog-assets' +import { + getChartsCatalogAssetUrl, + parseChartsCatalogManifest, +} from '../src/utils/charts-catalog' +import { + artifactRevision, + createChartsCatalogManifest, + tanstackAsset, +} from './charts-catalog-test-fixture' + +const manifest = parseChartsCatalogManifest(createChartsCatalogManifest()) + +test('catalog assets resolve only through an immutable artifact revision', () => { + const request = parseChartsCatalogAssetRequest({ + artifactRevision, + assetPath: tanstackAsset, + manifest, + }) + + assert.equal(request.repo, 'tanstack/charts') + assert.equal(request.repoPath, tanstackAsset) + assert.equal( + getChartsCatalogAssetUrl(artifactRevision, tanstackAsset), + `/charts/catalog/assets/${artifactRevision}/${tanstackAsset}`, + ) +}) + +for (const artifactRevision of [ + 'catalog-dist', + 'main', + 'A'.repeat(40), + '1'.repeat(39), + '1'.repeat(41), +]) { + test(`catalog assets reject mutable or malformed revision ${artifactRevision}`, () => { + assert.throws(() => + parseChartsCatalogAssetRequest({ + artifactRevision, + assetPath: tanstackAsset, + manifest, + }), + ) + }) +} + +for (const assetPath of [ + '../catalog.json', + 'assets/../catalog.json', + 'assets/%2e%2e/catalog.json', + 'assets/%2Fcatalog.json', + 'assets/%5ccatalog.json', + 'assets\\catalog.js', + '/assets/tanstack-AbC_1.js', + 'assets//tanstack-AbC_1.js', + 'assets/not-allowlisted.js', + 'catalog.json', +]) { + test(`catalog assets reject unsafe or unlisted path ${assetPath}`, () => { + assert.throws(() => + parseChartsCatalogAssetRequest({ + artifactRevision, + assetPath, + manifest, + }), + ) + }) +} + +test('catalog JavaScript uses immutable same-origin response headers', () => { + const headers = new Headers(getChartsCatalogAssetHeaders()) + + assert.equal( + headers.get('Cache-Control'), + 'public, max-age=31536000, immutable', + ) + assert.equal( + headers.get('Cloudflare-CDN-Cache-Control'), + 'public, max-age=31536000, immutable', + ) + assert.match(headers.get('Content-Type') ?? '', /javascript/) + assert.equal(headers.get('Cross-Origin-Resource-Policy'), 'same-origin') + assert.equal(headers.get('X-Content-Type-Options'), 'nosniff') + assert.equal(headers.get('Access-Control-Allow-Origin'), null) +}) diff --git a/tests/charts-catalog-frame-embedding.test.ts b/tests/charts-catalog-frame-embedding.test.ts new file mode 100644 index 000000000..7fde9277d --- /dev/null +++ b/tests/charts-catalog-frame-embedding.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + isChartsCatalogEmbedPath, + parseChartsCatalogEmbed, +} from '../src/utils/charts-catalog-embed' +import { isFrameEmbeddingAllowed } from '../src/utils/frame-embedding' + +test('only exact catalog embed documents bypass the global frame denial', () => { + assert.equal(isFrameEmbeddingAllowed('/charts/catalog/embed/01-line/'), true) + assert.equal(isFrameEmbeddingAllowed('/stats/npm/embed'), true) + + for (const pathname of [ + '/charts/catalog/', + '/charts/catalog/charts/01-line/', + '/charts/catalog/embed/', + '/charts/catalog/embed/01-line', + '/charts/catalog/embed/01-line/source/', + '/charts/catalog/embed-malicious/01-line/', + '/charts/catalog/embed/../admin/', + '/charts/catalog/embed/%2e%2e/', + '/charts/catalog/embed/01_line/', + '/charts/catalog/embed/01-line/?compare=1', + ]) { + assert.equal( + isFrameEmbeddingAllowed(pathname), + false, + `${pathname} must retain frame denial`, + ) + } +}) + +test('catalog embed source validation is same-origin and parameter bounded', () => { + assert.deepEqual( + parseChartsCatalogEmbed( + 'https://tanstack.com/charts/catalog/embed/01-line/?theme=dark&height=420&revision=2', + ), + { + caseId: '01-line', + origin: 'https://tanstack.com', + }, + ) + + for (const source of [ + 'http://tanstack.com/charts/catalog/embed/01-line/', + 'https://charts.tanstack.com/charts/catalog/embed/01-line/', + 'https://tanstack.com:444/charts/catalog/embed/01-line/', + 'https://tanstack.com/charts/catalog/embed/01-line/?compare=1', + 'https://tanstack.com/charts/catalog/embed/01-line/?height=119', + 'https://tanstack.com/charts/catalog/embed/01-line/?revision=10001', + 'https://tanstack.com/charts/catalog/embed/01-line/#fragment', + ]) { + assert.equal(parseChartsCatalogEmbed(source), null, source) + } +}) + +test('embed path validation uses the producer case-id grammar', () => { + assert.equal(isChartsCatalogEmbedPath('/charts/catalog/embed/01-line/'), true) + assert.equal( + isChartsCatalogEmbedPath('/charts/catalog/embed/01_line/'), + false, + ) + assert.equal( + isChartsCatalogEmbedPath('/charts/catalog/embed/01.line/'), + false, + ) +}) diff --git a/tests/charts-catalog-manifest.test.ts b/tests/charts-catalog-manifest.test.ts new file mode 100644 index 000000000..802543d40 --- /dev/null +++ b/tests/charts-catalog-manifest.test.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { parseChartsCatalogManifest } from '../src/utils/charts-catalog' +import { + comparisonAsset, + createChartsCatalogManifest, + sharedAsset, + sourceRevision, + tanstackAsset, +} from './charts-catalog-test-fixture' + +function expectRejected( + label: string, + mutate: (manifest: Record) => void, +) { + test(`catalog manifest rejects ${label}`, () => { + const manifest = createChartsCatalogManifest() + mutate(manifest) + assert.throws(() => parseChartsCatalogManifest(manifest)) + }) +} + +test('catalog manifest accepts the generated v2 contract', () => { + const manifest = parseChartsCatalogManifest(createChartsCatalogManifest()) + + assert.equal(manifest.schemaVersion, 2) + assert.equal(manifest.revision, sourceRevision) + assert.equal(manifest.cases[0]?.id, '01-line') + assert.equal(manifest.cases[0]?.modules.tanstack.path, tanstackAsset) + assert.deepEqual(Object.keys(manifest.assets).sort(), [ + comparisonAsset, + sharedAsset, + tanstackAsset, + ]) +}) + +expectRejected('an unsupported schema version', (manifest) => { + manifest.schemaVersion = 1 +}) + +expectRejected('a mutable or malformed source revision', (manifest) => { + manifest.revision = 'main' + manifest.source.ref = 'main' +}) + +expectRejected('an untrusted source repository', (manifest) => { + manifest.source.repo = 'someone/charts' +}) + +expectRejected('a source ref that differs from its revision', (manifest) => { + manifest.source.ref = '2'.repeat(40) +}) + +expectRejected('a different runtime export', (manifest) => { + manifest.runtime.export = 'default' +}) + +expectRejected('a different site origin', (manifest) => { + manifest.site.origin = 'https://example.com' +}) + +expectRejected('an altered embed protocol', (manifest) => { + manifest.embed.protocol.version = 2 +}) + +expectRejected('asset traversal', (manifest) => { + manifest.assets['assets/../catalog.json'] = manifest.assets[tanstackAsset] + delete manifest.assets[tanstackAsset] + manifest.cases[0].modules.tanstack.path = 'assets/../catalog.json' +}) + +expectRejected('an asset with a malformed digest', (manifest) => { + manifest.assets[tanstackAsset].sha256 = 'not-a-sha256' +}) + +expectRejected('an import outside the asset allowlist', (manifest) => { + manifest.assets[tanstackAsset].imports = ['assets/missing-AbC_4.js'] +}) + +expectRejected('a case module outside the asset allowlist', (manifest) => { + manifest.cases[0].modules.tanstack.path = 'assets/missing-AbC_4.js' +}) + +expectRejected('an invalid static preload closure', (manifest) => { + manifest.cases[0].modules.tanstack.preload = [] +}) + +expectRejected('a non-debug comparison module', (manifest) => { + manifest.cases[0].modules.comparison.visibility = 'public' +}) + +expectRejected('source-code traversal', (manifest) => { + manifest.cases[0].code.tanstack = '../private.ts' +}) + +expectRejected('duplicate case ids', (manifest) => { + manifest.cases.push(structuredClone(manifest.cases[0])) +}) + +expectRejected('a case id outside the producer format', (manifest) => { + manifest.cases[0].id = '01_line' + manifest.cases[0].routes = { + page: '/charts/catalog/charts/01_line/', + embed: '/charts/catalog/embed/01_line/', + } +}) + +expectRejected('a TanStack source path for another case', (manifest) => { + manifest.cases[0].code.tanstack = + 'benchmarks/conformance/cases/02-area/tanstack.ts' +}) + +expectRejected('a source path that disagrees with its renderer', (manifest) => { + manifest.cases[0].code.reference = + 'benchmarks/conformance/cases/01-line/recharts.ts' +}) + +expectRejected( + 'a comparison renderer that disagrees with metadata', + (manifest) => { + manifest.cases[0].modules.comparison.renderer = 'recharts' + }, +) + +expectRejected('a missing debug comparison module', (manifest) => { + delete manifest.cases[0].modules.comparison +}) + +expectRejected('an asset larger than the producer limit', (manifest) => { + manifest.assets[tanstackAsset].bytes = 1024 * 1024 + 1 +}) + +expectRejected('an asset outside every implementation closure', (manifest) => { + manifest.assets['assets/orphan-AbC_4.js'] = { + bytes: 1, + sha256: 'd'.repeat(64), + imports: [], + dynamicImports: [], + } +}) diff --git a/tests/charts-catalog-raw-content.test.ts b/tests/charts-catalog-raw-content.test.ts new file mode 100644 index 000000000..aa4ddacc8 --- /dev/null +++ b/tests/charts-catalog-raw-content.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { fetchRepoFile, fetchRepoRawFile } from '../src/utils/documents.server' +import { resetGitHubContentCacheForTest } from '../src/utils/github-content-cache.server' + +test('raw GitHub content remains byte-for-byte text-equivalent', async () => { + resetGitHubContentCacheForTest() + + const originalFetch = globalThis.fetch + const revision = '3'.repeat(40) + const path = 'assets/raw-contract-AbC_1.js' + const source = `const marker = "![chart](https://raw.githubusercontent.com/tanstack/charts/main/chart.svg)"\n` + let requestUrl = '' + let originCalls = 0 + + globalThis.fetch = async (input) => { + originCalls += 1 + requestUrl = String(input) + return new Response(source, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + }, + }) + } + + try { + const transformed = await fetchRepoFile('tanstack/charts', revision, path) + assert.notEqual(transformed, source) + + const result = await fetchRepoRawFile('tanstack/charts', revision, path) + + assert.equal(result, source) + assert.equal(originCalls, 2) + assert.equal( + requestUrl, + `https://raw.githubusercontent.com/tanstack/charts/${revision}/${path}`, + ) + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/tests/charts-catalog-search.test.ts b/tests/charts-catalog-search.test.ts new file mode 100644 index 000000000..8edf20aa6 --- /dev/null +++ b/tests/charts-catalog-search.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { parseChartsCatalogSearch } from '../src/utils/charts-catalog' + +test('catalog comparison mode requires one exact compare=1 parameter', () => { + for (const search of ['?compare=1', 'compare=1', '?view=all&compare=1']) { + assert.equal( + parseChartsCatalogSearch(search).comparison, + true, + `${search} should enable comparison mode`, + ) + } + + for (const search of [ + '', + '?', + '?compare', + '?compare=', + '?compare=0', + '?compare=true', + '?compare=01', + '?Compare=1', + '?compare=1&compare=1', + '?compare=0&compare=1', + '?compare=1&compare=0', + ]) { + assert.equal( + parseChartsCatalogSearch(search).comparison, + false, + `${search || '(empty)'} should keep comparisons disabled`, + ) + } +}) + +test('catalog embeds never enable comparison modules', () => { + assert.equal( + parseChartsCatalogSearch('?compare=1', { embed: true }).comparison, + false, + ) +}) diff --git a/tests/charts-catalog-site-contracts.test.ts b/tests/charts-catalog-site-contracts.test.ts new file mode 100644 index 000000000..9ea4de85e --- /dev/null +++ b/tests/charts-catalog-site-contracts.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + chartsCatalogPublicationCacheTag, + getChartsCatalogSitemapEntries, + parseChartsCatalogManifest, +} from '../src/utils/charts-catalog' +import { + docsWebhookSources, + isWatchedDocsWebhookSource, + type DocsWebhookSource, +} from '../src/utils/docs-webhook-sources' +import { createChartsCatalogManifest } from './charts-catalog-test-fixture' + +test('sitemap exposes catalog discovery routes but not executable resources', () => { + const manifest = parseChartsCatalogManifest(createChartsCatalogManifest()) + const entries = getChartsCatalogSitemapEntries(manifest) + const paths = entries.map((entry) => entry.path) + + assert.deepEqual(paths, [ + '/charts/catalog/', + '/charts/catalog/all/', + '/charts/catalog/charts/01-line/', + ]) + assert.equal( + paths.some((path) => path.includes('/embed/')), + false, + ) + assert.equal( + paths.some((path) => path.includes('/assets/')), + false, + ) + assert.equal( + paths.some((path) => path.includes('?compare=1')), + false, + ) +}) + +test('catalog publication pushes invalidate the GitHub content pipeline', () => { + assert.equal( + chartsCatalogPublicationCacheTag, + 'charts-catalog:tanstack/charts:catalog-dist', + ) + assert.equal( + isWatchedDocsWebhookSource('tanstack/charts', 'catalog-dist'), + true, + ) + assert.equal( + isWatchedDocsWebhookSource('tanstack/charts', 'catalog-dist-other'), + false, + ) + + const chartsSource = (docsWebhookSources as Array).find( + (source) => source.repo === 'tanstack/charts', + ) + assert.ok(chartsSource) + assert.ok(chartsSource.refs.includes('catalog-dist')) +}) diff --git a/tests/charts-catalog-test-fixture.ts b/tests/charts-catalog-test-fixture.ts new file mode 100644 index 000000000..df2da1e37 --- /dev/null +++ b/tests/charts-catalog-test-fixture.ts @@ -0,0 +1,111 @@ +export const sourceRevision = '1'.repeat(40) +export const artifactRevision = '2'.repeat(40) +export const tanstackAsset = 'assets/tanstack-AbC_1.js' +export const sharedAsset = 'assets/shared-XyZ_2.js' +export const comparisonAsset = 'assets/plot-QrS_3.js' + +export function createChartsCatalogManifest(): Record { + return { + schemaVersion: 2, + revision: sourceRevision, + source: { + repo: 'tanstack/charts', + ref: sourceRevision, + }, + runtime: { + contractVersion: 1, + export: 'mount', + }, + site: { + origin: 'https://tanstack.com', + basePath: '/charts/catalog/', + assetBasePath: '/charts/catalog/assets/', + }, + embed: { + protocol: { + type: 'tanstack-charts:embed', + version: 1, + statuses: ['ready', 'resize', 'error'], + commands: ['set-theme'], + }, + parameters: { + theme: { + values: ['system', 'light', 'dark'], + default: 'system', + }, + height: { + minimum: 120, + maximum: 1_200, + default: 360, + }, + revision: { + minimum: 0, + maximum: 10_000, + default: 0, + }, + }, + }, + assets: { + [tanstackAsset]: { + bytes: 21, + sha256: 'a'.repeat(64), + imports: [sharedAsset], + dynamicImports: [], + }, + [sharedAsset]: { + bytes: 13, + sha256: 'b'.repeat(64), + imports: [], + dynamicImports: [], + }, + [comparisonAsset]: { + bytes: 17, + sha256: 'c'.repeat(64), + imports: [], + dynamicImports: [], + }, + }, + cases: [ + { + schemaVersion: 1, + referenceRenderer: 'observable-plot', + order: 10, + id: '01-line', + title: 'Line', + family: 'cartesian', + intent: 'Show a line.', + support: 'native', + features: [], + geometry: [], + source: { + title: 'Observable Plot line', + url: 'https://observablehq.com/plot/marks/line', + }, + ai: { + create: 'Create a line.', + maintain: 'Keep the line stable.', + }, + routes: { + page: '/charts/catalog/charts/01-line/', + embed: '/charts/catalog/embed/01-line/', + }, + code: { + tanstack: 'benchmarks/conformance/cases/01-line/tanstack.ts', + reference: 'benchmarks/conformance/cases/01-line/plot.ts', + }, + modules: { + tanstack: { + path: tanstackAsset, + preload: [sharedAsset], + }, + comparison: { + renderer: 'observable-plot', + path: comparisonAsset, + preload: [], + visibility: 'debug', + }, + }, + }, + ], + } +} From 0aa5b9a53044455cf9a6598ccce6f5681c4f353f Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 28 Jul 2026 22:58:09 -0600 Subject: [PATCH 2/9] Add native Charts catalog content pipeline --- src/components/charts/ChartsCatalogChart.tsx | 191 ++++++++ src/components/charts/ChartsCatalogEmbed.tsx | 121 +++++ src/components/charts/ChartsCatalogPages.tsx | 384 +++++++++++++++ src/routeTree.gen.ts | 160 ++++++ src/routes/api/github/webhook.ts | 4 + src/routes/charts.catalog.all.tsx | 29 ++ src/routes/charts.catalog.charts.$caseId.tsx | 37 ++ src/routes/charts.catalog.index.tsx | 20 + src/routes/charts.catalog.tsx | 54 +++ ...rts.catalog_.assets.$artifactRevision.$.ts | 53 ++ src/routes/charts.catalog_.catalog[.]json.ts | 24 + src/routes/charts.catalog_.embed.$caseId.tsx | 163 +++++++ src/server.ts | 3 +- src/styles/app.css | 18 + src/utils/charts-catalog-assets.ts | 41 ++ src/utils/charts-catalog-embed.ts | 80 +++ src/utils/charts-catalog.functions.ts | 157 ++++++ src/utils/charts-catalog.server.ts | 131 +++++ src/utils/charts-catalog.ts | 454 ++++++++++++++++++ src/utils/docs-webhook-sources.ts | 5 + src/utils/documents.server.ts | 135 ++++++ src/utils/frame-embedding.ts | 7 + src/utils/sitemap.ts | 23 +- 23 files changed, 2290 insertions(+), 4 deletions(-) create mode 100644 src/components/charts/ChartsCatalogChart.tsx create mode 100644 src/components/charts/ChartsCatalogEmbed.tsx create mode 100644 src/components/charts/ChartsCatalogPages.tsx create mode 100644 src/routes/charts.catalog.all.tsx create mode 100644 src/routes/charts.catalog.charts.$caseId.tsx create mode 100644 src/routes/charts.catalog.index.tsx create mode 100644 src/routes/charts.catalog.tsx create mode 100644 src/routes/charts.catalog_.assets.$artifactRevision.$.ts create mode 100644 src/routes/charts.catalog_.catalog[.]json.ts create mode 100644 src/routes/charts.catalog_.embed.$caseId.tsx create mode 100644 src/utils/charts-catalog-assets.ts create mode 100644 src/utils/charts-catalog-embed.ts create mode 100644 src/utils/charts-catalog.functions.ts create mode 100644 src/utils/charts-catalog.server.ts create mode 100644 src/utils/charts-catalog.ts create mode 100644 src/utils/frame-embedding.ts diff --git a/src/components/charts/ChartsCatalogChart.tsx b/src/components/charts/ChartsCatalogChart.tsx new file mode 100644 index 000000000..400e335bd --- /dev/null +++ b/src/components/charts/ChartsCatalogChart.tsx @@ -0,0 +1,191 @@ +import * as React from 'react' +import { getChartsCatalogAssetUrl } from '~/utils/charts-catalog' + +export type ChartsCatalogModuleReference = { + path: string + preload: Array +} + +type ChartMountInput = { + width: number + height: number + revision: number + interactive?: boolean +} + +type ChartMountHandle = { + update(input: ChartMountInput): void + destroy(): void +} + +type ChartRuntimeModule = { + mount(container: HTMLElement, input: ChartMountInput): ChartMountHandle +} + +export function ChartsCatalogChart({ + artifactRevision, + caseId, + defer = false, + height = 360, + interactive = true, + module, + onStatus, + revision = 0, +}: { + artifactRevision: string + caseId: string + defer?: boolean + height?: number + interactive?: boolean + module: ChartsCatalogModuleReference + onStatus?: (status: 'ready' | 'resize' | 'error') => void + revision?: number +}) { + const containerRef = React.useRef(null) + const [visible, setVisible] = React.useState(!defer) + const [failed, setFailed] = React.useState(false) + + React.useEffect(() => { + const container = containerRef.current + if (!defer || visible || !container) return + + if (!('IntersectionObserver' in window)) { + setVisible(true) + return + } + + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return + setVisible(true) + observer.disconnect() + }, + { rootMargin: '480px 0px' }, + ) + observer.observe(container) + return () => observer.disconnect() + }, [defer, visible]) + + React.useEffect(() => { + const container = containerRef.current + if (!container || !visible) return + + let cancelled = false + let handle: ChartMountHandle | undefined + let width = measureWidth(container) + const preloadLinks = module.preload.map((assetPath) => { + const link = document.createElement('link') + link.rel = 'modulepreload' + link.href = getChartsCatalogAssetUrl(artifactRevision, assetPath) + document.head.appendChild(link) + return link + }) + + setFailed(false) + + void import( + /* @vite-ignore */ + getChartsCatalogAssetUrl(artifactRevision, module.path) + ) + .then((loaded: unknown) => { + if (cancelled) return + if (!isChartRuntimeModule(loaded)) { + throw new TypeError('Invalid Charts catalog runtime module') + } + + const mounted = loaded.mount(container, { + width, + height, + revision, + interactive, + }) + if (!isChartMountHandle(mounted)) { + throw new TypeError('Invalid Charts catalog mount handle') + } + handle = mounted + requestAnimationFrame(() => onStatus?.('ready')) + }) + .catch((error: unknown) => { + if (cancelled) return + console.error(`Unable to mount Charts catalog case ${caseId}`, error) + setFailed(true) + onStatus?.('error') + }) + + const resizeObserver = new ResizeObserver(() => { + const nextWidth = measureWidth(container) + if (nextWidth === width || nextWidth < 1) return + width = nextWidth + handle?.update({ + width, + height, + revision, + interactive, + }) + onStatus?.('resize') + }) + resizeObserver.observe(container) + + return () => { + cancelled = true + resizeObserver.disconnect() + handle?.destroy() + for (const link of preloadLinks) link.remove() + container.replaceChildren() + } + }, [ + artifactRevision, + caseId, + height, + interactive, + module, + onStatus, + revision, + visible, + ]) + + return ( +
+
+ {!visible || failed ? ( +
+ {failed ? 'Render failed' : null} +
+ ) : null} +
+ ) +} + +function measureWidth(container: HTMLElement) { + return Math.max(1, Math.floor(container.getBoundingClientRect().width)) +} + +function isChartRuntimeModule(value: unknown): value is ChartRuntimeModule { + return ( + typeof value === 'object' && + value !== null && + 'mount' in value && + typeof value.mount === 'function' + ) +} + +function isChartMountHandle(value: unknown): value is ChartMountHandle { + return ( + typeof value === 'object' && + value !== null && + 'update' in value && + typeof value.update === 'function' && + 'destroy' in value && + typeof value.destroy === 'function' + ) +} diff --git a/src/components/charts/ChartsCatalogEmbed.tsx b/src/components/charts/ChartsCatalogEmbed.tsx new file mode 100644 index 000000000..68f215e7f --- /dev/null +++ b/src/components/charts/ChartsCatalogEmbed.tsx @@ -0,0 +1,121 @@ +import * as React from 'react' +import { useTheme } from '~/components/ThemeProvider' +import { parseChartsCatalogEmbed } from '~/utils/charts-catalog-embed' + +type ChartsCatalogEmbedProps = Omit< + React.IframeHTMLAttributes, + 'src' +> & { + deferUntilVisible?: boolean + src: string + theme?: 'dark' | 'light' | 'system' +} + +export function ChartsCatalogEmbed({ + className, + deferUntilVisible = false, + loading = 'lazy', + onLoad, + src, + theme = 'system', + title, + ...iframeProps +}: ChartsCatalogEmbedProps) { + const { resolvedTheme } = useTheme() + const frameRef = React.useRef(null) + const [shouldLoad, setShouldLoad] = React.useState(!deferUntilVisible) + const chartEmbed = React.useMemo(() => parseChartsCatalogEmbed(src), [src]) + const iframeTitle = title?.trim() || 'TanStack Charts example' + const resolvedEmbedTheme = theme === 'system' ? resolvedTheme : theme + + React.useEffect(() => { + const frame = frameRef.current + if (!deferUntilVisible || shouldLoad || !frame) return + + if (!('IntersectionObserver' in window)) { + setShouldLoad(true) + return + } + + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return + setShouldLoad(true) + observer.disconnect() + }, + { rootMargin: '480px 0px' }, + ) + observer.observe(frame) + return () => observer.disconnect() + }, [deferUntilVisible, shouldLoad]) + + const postChartTheme = React.useCallback(() => { + const target = frameRef.current?.contentWindow + if (!chartEmbed || !target || !shouldLoad) return + + target.postMessage( + { + type: 'tanstack-charts:embed', + version: 1, + command: 'set-theme', + caseId: chartEmbed.caseId, + theme: resolvedEmbedTheme, + }, + chartEmbed.origin, + ) + }, [chartEmbed, resolvedEmbedTheme, shouldLoad]) + + React.useEffect(() => { + const target = frameRef.current?.contentWindow + if (!chartEmbed || !target || !shouldLoad) return + + const handleMessage = (event: MessageEvent) => { + if ( + event.origin !== chartEmbed.origin || + event.source !== target || + !isReadyChartEmbedMessage(event.data, chartEmbed.caseId) + ) { + return + } + postChartTheme() + } + + window.addEventListener('message', handleMessage) + postChartTheme() + return () => window.removeEventListener('message', handleMessage) + }, [chartEmbed, postChartTheme, shouldLoad]) + + if (!chartEmbed) return null + + return ( +