Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions src/components/charts/ChartsCatalogChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import * as React from 'react'
import { getChartsCatalogAssetUrl } from '~/utils/charts-catalog'

export type ChartsCatalogModuleReference = {
path: string
preload: Array<string>
}

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<HTMLDivElement>(null)
const handleRef = React.useRef<ChartMountHandle | undefined>(undefined)
const inputRef = React.useRef({ height, interactive, revision })
const onStatusRef = React.useRef(onStatus)
const [visible, setVisible] = React.useState(!defer)
const [failed, setFailed] = React.useState(false)

React.useEffect(() => {
inputRef.current = { height, interactive, revision }
}, [height, interactive, revision])

React.useEffect(() => {
onStatusRef.current = onStatus
}, [onStatus])

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 mountedHandle: 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,
...inputRef.current,
})
if (!isChartMountHandle(mounted)) {
throw new TypeError('Invalid Charts catalog mount handle')
}
mountedHandle = mounted
handleRef.current = mounted
requestAnimationFrame(() => {
if (!cancelled) onStatusRef.current?.('ready')
})
})
.catch((error: unknown) => {
if (cancelled) return
console.error(`Unable to mount Charts catalog case ${caseId}`, error)
setFailed(true)
onStatusRef.current?.('error')
})

const resizeObserver = new ResizeObserver(() => {
const nextWidth = measureWidth(container)
if (nextWidth === width || nextWidth < 1) return
width = nextWidth
handleRef.current?.update({
width,
...inputRef.current,
})
onStatusRef.current?.('resize')
})
resizeObserver.observe(container)

return () => {
cancelled = true
resizeObserver.disconnect()
mountedHandle?.destroy()
if (handleRef.current === mountedHandle) {
handleRef.current = undefined
}
for (const link of preloadLinks) link.remove()
container.replaceChildren()
}
}, [artifactRevision, caseId, module, visible])

React.useEffect(() => {
const container = containerRef.current
const handle = handleRef.current
if (!container || !handle) return

handle.update({
width: measureWidth(container),
height,
interactive,
revision,
})
}, [height, interactive, revision])

return (
<div
className="charts-catalog-chart relative w-full overflow-visible"
data-chart-case={caseId}
style={{ minHeight: height }}
>
<div ref={containerRef} className="h-full w-full" />
{!visible || failed ? (
<div
className={`absolute inset-0 rounded-lg ${
failed
? 'grid place-items-center text-sm text-red-700 dark:text-red-300'
: 'animate-pulse bg-gray-100 dark:bg-gray-900'
}`}
>
{failed ? 'Render failed' : null}
</div>
) : null}
</div>
)
}

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'
)
}
121 changes: 121 additions & 0 deletions src/components/charts/ChartsCatalogEmbed.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLIFrameElement>,
'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<HTMLIFrameElement>(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<unknown>) => {
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 (
<iframe
title={iframeTitle}
{...iframeProps}
ref={frameRef}
src={shouldLoad ? src : undefined}
loading={loading}
referrerPolicy="strict-origin-when-cross-origin"
className={`block w-full ${className ?? ''}`.trim()}
data-chart-catalog-embed={chartEmbed.caseId}
onLoad={(event) => {
onLoad?.(event)
postChartTheme()
}}
/>
)
Comment on lines +90 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Add a sandbox attribute to the embed iframe.

Static analysis flags the missing sandbox on this <iframe>. Even though src is restricted to tanstack.com catalog embed paths, sandboxing still adds defense-in-depth against a compromised chart artifact navigating the top-level page, opening popups, or submitting forms. postMessage works fine across sandboxed frames without allow-same-origin, so this shouldn't break the existing theme-sync protocol.

🔒 Proposed fix
     <iframe
       title={iframeTitle}
       {...iframeProps}
       ref={frameRef}
       src={shouldLoad ? src : undefined}
       loading={loading}
       referrerPolicy="strict-origin-when-cross-origin"
+      sandbox="allow-scripts"
       className={`block w-full ${className ?? ''}`.trim()}
       data-chart-catalog-embed={chartEmbed.caseId}
       onLoad={(event) => {
         onLoad?.(event)
         postChartTheme()
       }}
     />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return (
<iframe
title={iframeTitle}
{...iframeProps}
ref={frameRef}
src={shouldLoad ? src : undefined}
loading={loading}
referrerPolicy="strict-origin-when-cross-origin"
className={`block w-full ${className ?? ''}`.trim()}
data-chart-catalog-embed={chartEmbed.caseId}
onLoad={(event) => {
onLoad?.(event)
postChartTheme()
}}
/>
)
return (
<iframe
title={iframeTitle}
{...iframeProps}
ref={frameRef}
src={shouldLoad ? src : undefined}
loading={loading}
referrerPolicy="strict-origin-when-cross-origin"
sandbox="allow-scripts"
className={`block w-full ${className ?? ''}`.trim()}
data-chart-catalog-embed={chartEmbed.caseId}
onLoad={(event) => {
onLoad?.(event)
postChartTheme()
}}
/>
)
🧰 Tools
🪛 React Doctor (0.9.1)

[error] 91-91: An <iframe> with no sandbox is a security hole: the embedded page gets full access to your site.

Add sandbox="" or a curated value so embedded pages cannot get full access to your site by default.

(iframe-missing-sandbox)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/charts/ChartsCatalogEmbed.tsx` around lines 90 - 105, Add a
restrictive sandbox attribute to the iframe rendered by the ChartsCatalogEmbed
component, without granting same-origin, popups, top-level navigation, or form
submission permissions; preserve the existing postChartTheme message flow and
other iframe props.

Source: Linters/SAST tools

}

function isReadyChartEmbedMessage(value: unknown, caseId: string) {
return (
value !== null &&
typeof value === 'object' &&
'type' in value &&
value.type === 'tanstack-charts:embed' &&
'version' in value &&
value.version === 1 &&
'status' in value &&
value.status === 'ready' &&
'caseId' in value &&
value.caseId === caseId
)
}
Loading
Loading