Skip to content

Commit 2c39ae3

Browse files
Render the Charts catalog as native site content (#1068)
* test charts catalog content contracts * Add native Charts catalog content pipeline * Fix catalog search loader isolation * Handle decoded catalog search values * Address Charts catalog review findings * Keep catalog comparison gating exact * Gate catalog assets to published revisions * Return HTTP 404 for missing catalog assets * Commit chart runtime ref updates
1 parent 6b61f4d commit 2c39ae3

31 files changed

Lines changed: 3677 additions & 4 deletions
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import * as React from 'react'
2+
import { getChartsCatalogAssetUrl } from '~/utils/charts-catalog'
3+
4+
export type ChartsCatalogModuleReference = {
5+
path: string
6+
preload: Array<string>
7+
}
8+
9+
type ChartMountInput = {
10+
width: number
11+
height: number
12+
revision: number
13+
interactive?: boolean
14+
}
15+
16+
type ChartMountHandle = {
17+
update(input: ChartMountInput): void
18+
destroy(): void
19+
}
20+
21+
type ChartRuntimeModule = {
22+
mount(container: HTMLElement, input: ChartMountInput): ChartMountHandle
23+
}
24+
25+
export function ChartsCatalogChart({
26+
artifactRevision,
27+
caseId,
28+
defer = false,
29+
height = 360,
30+
interactive = true,
31+
module,
32+
onStatus,
33+
revision = 0,
34+
}: {
35+
artifactRevision: string
36+
caseId: string
37+
defer?: boolean
38+
height?: number
39+
interactive?: boolean
40+
module: ChartsCatalogModuleReference
41+
onStatus?: (status: 'ready' | 'resize' | 'error') => void
42+
revision?: number
43+
}) {
44+
const containerRef = React.useRef<HTMLDivElement>(null)
45+
const handleRef = React.useRef<ChartMountHandle | undefined>(undefined)
46+
const inputRef = React.useRef({ height, interactive, revision })
47+
const onStatusRef = React.useRef(onStatus)
48+
const [visible, setVisible] = React.useState(!defer)
49+
const [failed, setFailed] = React.useState(false)
50+
51+
React.useEffect(() => {
52+
inputRef.current = { height, interactive, revision }
53+
}, [height, interactive, revision])
54+
55+
React.useEffect(() => {
56+
onStatusRef.current = onStatus
57+
}, [onStatus])
58+
59+
React.useEffect(() => {
60+
const container = containerRef.current
61+
if (!defer || visible || !container) return
62+
63+
if (!('IntersectionObserver' in window)) {
64+
setVisible(true)
65+
return
66+
}
67+
68+
const observer = new IntersectionObserver(
69+
(entries) => {
70+
if (!entries.some((entry) => entry.isIntersecting)) return
71+
setVisible(true)
72+
observer.disconnect()
73+
},
74+
{ rootMargin: '480px 0px' },
75+
)
76+
observer.observe(container)
77+
return () => observer.disconnect()
78+
}, [defer, visible])
79+
80+
React.useEffect(() => {
81+
const container = containerRef.current
82+
if (!container || !visible) return
83+
84+
let cancelled = false
85+
let mountedHandle: ChartMountHandle | undefined
86+
let width = measureWidth(container)
87+
const preloadLinks = module.preload.map((assetPath) => {
88+
const link = document.createElement('link')
89+
link.rel = 'modulepreload'
90+
link.href = getChartsCatalogAssetUrl(artifactRevision, assetPath)
91+
document.head.appendChild(link)
92+
return link
93+
})
94+
95+
setFailed(false)
96+
97+
void import(
98+
/* @vite-ignore */
99+
getChartsCatalogAssetUrl(artifactRevision, module.path)
100+
)
101+
.then((loaded: unknown) => {
102+
if (cancelled) return
103+
if (!isChartRuntimeModule(loaded)) {
104+
throw new TypeError('Invalid Charts catalog runtime module')
105+
}
106+
107+
const mounted = loaded.mount(container, {
108+
width,
109+
...inputRef.current,
110+
})
111+
if (!isChartMountHandle(mounted)) {
112+
throw new TypeError('Invalid Charts catalog mount handle')
113+
}
114+
mountedHandle = mounted
115+
handleRef.current = mounted
116+
requestAnimationFrame(() => {
117+
if (!cancelled) onStatusRef.current?.('ready')
118+
})
119+
})
120+
.catch((error: unknown) => {
121+
if (cancelled) return
122+
console.error(`Unable to mount Charts catalog case ${caseId}`, error)
123+
setFailed(true)
124+
onStatusRef.current?.('error')
125+
})
126+
127+
const resizeObserver = new ResizeObserver(() => {
128+
const nextWidth = measureWidth(container)
129+
if (nextWidth === width || nextWidth < 1) return
130+
width = nextWidth
131+
handleRef.current?.update({
132+
width,
133+
...inputRef.current,
134+
})
135+
onStatusRef.current?.('resize')
136+
})
137+
resizeObserver.observe(container)
138+
139+
return () => {
140+
cancelled = true
141+
resizeObserver.disconnect()
142+
mountedHandle?.destroy()
143+
if (handleRef.current === mountedHandle) {
144+
handleRef.current = undefined
145+
}
146+
for (const link of preloadLinks) link.remove()
147+
container.replaceChildren()
148+
}
149+
}, [artifactRevision, caseId, module, visible])
150+
151+
React.useEffect(() => {
152+
const container = containerRef.current
153+
const handle = handleRef.current
154+
if (!container || !handle) return
155+
156+
handle.update({
157+
width: measureWidth(container),
158+
height,
159+
interactive,
160+
revision,
161+
})
162+
}, [height, interactive, revision])
163+
164+
return (
165+
<div
166+
className="charts-catalog-chart relative w-full overflow-visible"
167+
data-chart-case={caseId}
168+
style={{ minHeight: height }}
169+
>
170+
<div ref={containerRef} className="h-full w-full" />
171+
{!visible || failed ? (
172+
<div
173+
className={`absolute inset-0 rounded-lg ${
174+
failed
175+
? 'grid place-items-center text-sm text-red-700 dark:text-red-300'
176+
: 'animate-pulse bg-gray-100 dark:bg-gray-900'
177+
}`}
178+
>
179+
{failed ? 'Render failed' : null}
180+
</div>
181+
) : null}
182+
</div>
183+
)
184+
}
185+
186+
function measureWidth(container: HTMLElement) {
187+
return Math.max(1, Math.floor(container.getBoundingClientRect().width))
188+
}
189+
190+
function isChartRuntimeModule(value: unknown): value is ChartRuntimeModule {
191+
return (
192+
typeof value === 'object' &&
193+
value !== null &&
194+
'mount' in value &&
195+
typeof value.mount === 'function'
196+
)
197+
}
198+
199+
function isChartMountHandle(value: unknown): value is ChartMountHandle {
200+
return (
201+
typeof value === 'object' &&
202+
value !== null &&
203+
'update' in value &&
204+
typeof value.update === 'function' &&
205+
'destroy' in value &&
206+
typeof value.destroy === 'function'
207+
)
208+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import * as React from 'react'
2+
import { useTheme } from '~/components/ThemeProvider'
3+
import { parseChartsCatalogEmbed } from '~/utils/charts-catalog-embed'
4+
5+
type ChartsCatalogEmbedProps = Omit<
6+
React.IframeHTMLAttributes<HTMLIFrameElement>,
7+
'src'
8+
> & {
9+
deferUntilVisible?: boolean
10+
src: string
11+
theme?: 'dark' | 'light' | 'system'
12+
}
13+
14+
export function ChartsCatalogEmbed({
15+
className,
16+
deferUntilVisible = false,
17+
loading = 'lazy',
18+
onLoad,
19+
src,
20+
theme = 'system',
21+
title,
22+
...iframeProps
23+
}: ChartsCatalogEmbedProps) {
24+
const { resolvedTheme } = useTheme()
25+
const frameRef = React.useRef<HTMLIFrameElement>(null)
26+
const [shouldLoad, setShouldLoad] = React.useState(!deferUntilVisible)
27+
const chartEmbed = React.useMemo(() => parseChartsCatalogEmbed(src), [src])
28+
const iframeTitle = title?.trim() || 'TanStack Charts example'
29+
const resolvedEmbedTheme = theme === 'system' ? resolvedTheme : theme
30+
31+
React.useEffect(() => {
32+
const frame = frameRef.current
33+
if (!deferUntilVisible || shouldLoad || !frame) return
34+
35+
if (!('IntersectionObserver' in window)) {
36+
setShouldLoad(true)
37+
return
38+
}
39+
40+
const observer = new IntersectionObserver(
41+
(entries) => {
42+
if (!entries.some((entry) => entry.isIntersecting)) return
43+
setShouldLoad(true)
44+
observer.disconnect()
45+
},
46+
{ rootMargin: '480px 0px' },
47+
)
48+
observer.observe(frame)
49+
return () => observer.disconnect()
50+
}, [deferUntilVisible, shouldLoad])
51+
52+
const postChartTheme = React.useCallback(() => {
53+
const target = frameRef.current?.contentWindow
54+
if (!chartEmbed || !target || !shouldLoad) return
55+
56+
target.postMessage(
57+
{
58+
type: 'tanstack-charts:embed',
59+
version: 1,
60+
command: 'set-theme',
61+
caseId: chartEmbed.caseId,
62+
theme: resolvedEmbedTheme,
63+
},
64+
chartEmbed.origin,
65+
)
66+
}, [chartEmbed, resolvedEmbedTheme, shouldLoad])
67+
68+
React.useEffect(() => {
69+
const target = frameRef.current?.contentWindow
70+
if (!chartEmbed || !target || !shouldLoad) return
71+
72+
const handleMessage = (event: MessageEvent<unknown>) => {
73+
if (
74+
event.origin !== chartEmbed.origin ||
75+
event.source !== target ||
76+
!isReadyChartEmbedMessage(event.data, chartEmbed.caseId)
77+
) {
78+
return
79+
}
80+
postChartTheme()
81+
}
82+
83+
window.addEventListener('message', handleMessage)
84+
postChartTheme()
85+
return () => window.removeEventListener('message', handleMessage)
86+
}, [chartEmbed, postChartTheme, shouldLoad])
87+
88+
if (!chartEmbed) return null
89+
90+
return (
91+
<iframe
92+
title={iframeTitle}
93+
{...iframeProps}
94+
ref={frameRef}
95+
src={shouldLoad ? src : undefined}
96+
loading={loading}
97+
referrerPolicy="strict-origin-when-cross-origin"
98+
className={`block w-full ${className ?? ''}`.trim()}
99+
data-chart-catalog-embed={chartEmbed.caseId}
100+
onLoad={(event) => {
101+
onLoad?.(event)
102+
postChartTheme()
103+
}}
104+
/>
105+
)
106+
}
107+
108+
function isReadyChartEmbedMessage(value: unknown, caseId: string) {
109+
return (
110+
value !== null &&
111+
typeof value === 'object' &&
112+
'type' in value &&
113+
value.type === 'tanstack-charts:embed' &&
114+
'version' in value &&
115+
value.version === 1 &&
116+
'status' in value &&
117+
value.status === 'ready' &&
118+
'caseId' in value &&
119+
value.caseId === caseId
120+
)
121+
}

0 commit comments

Comments
 (0)