Files
supabase/apps/studio/compat/next/script.tsx
T
Alaister Young 47b91d66d9 fix(studio): beef up next/script compat shim
Studio doesn't currently mount <Script>, but the surface is shimmed
comprehensively so the migration doesn't catch anyone out later.

- Forward onLoad / onError to the rendered <script> element.
- Approximate onReady: fire on first load, or on mount for inline
  scripts (Next fires it as soon as the body has executed).
- Inline body via children OR dangerouslySetInnerHTML, with children
  taking precedence (matches Next).
- strategy prop accepted-and-dropped — under Vite the browser handles
  load ordering via the standard <script> element; we don't reorder.
2026-05-08 15:31:39 +08:00

100 lines
2.9 KiB
TypeScript

import { useEffect, useRef, type ComponentPropsWithoutRef, type ReactNode } from 'react'
// Next/Script handles ordering, deduplication, and load callbacks for
// third-party scripts. Under Vite we have no orchestrator — render a
// `<script>` and approximate the callback contract. Studio doesn't
// currently mount any <Script>, but the surface is shimmed
// comprehensively so the migration doesn't catch anyone out later.
type Strategy = 'beforeInteractive' | 'afterInteractive' | 'lazyOnload' | 'worker'
interface ScriptProps extends Omit<ComponentPropsWithoutRef<'script'>, 'children'> {
// Accepted-and-dropped in this shim — the browser handles network
// priority via the regular `<script>` element; we don't reorder.
strategy?: Strategy
// Children as the script body (inline scripts) are also accepted via
// dangerouslySetInnerHTML; Next allows either. We honour both.
children?: ReactNode
onLoad?: (e: Event) => void
onReady?: () => void
onError?: (e: Event | string) => void
}
export default function Script({
strategy: _strategy,
children,
dangerouslySetInnerHTML,
onLoad,
onError,
onReady,
src,
id,
...rest
}: ScriptProps) {
const ref = useRef<HTMLScriptElement | null>(null)
const readyFiredRef = useRef(false)
// onReady fires once the script has loaded (or immediately on mount
// if it's already been loaded by a previous instance with the same
// id). Approximate by firing once after mount when the element is
// present and either has no src (inline) or has finished loading.
useEffect(() => {
const node = ref.current
if (!node || !onReady || readyFiredRef.current) return
if (!src) {
// Inline script — body executed synchronously on mount.
readyFiredRef.current = true
onReady()
return
}
if (node.dataset.loaded === 'true') {
readyFiredRef.current = true
onReady()
}
}, [onReady, src])
const handleLoad = (e: Event | React.SyntheticEvent<HTMLScriptElement>) => {
const node = ref.current
if (node) node.dataset.loaded = 'true'
onLoad?.(e as Event)
if (onReady && !readyFiredRef.current) {
readyFiredRef.current = true
onReady()
}
}
const handleError = (e: Event | React.SyntheticEvent<HTMLScriptElement>) => {
onError?.(e as Event)
}
// For inline scripts, `children` is preferred over
// dangerouslySetInnerHTML when both are present (matches Next).
if (children !== undefined) {
return (
<script
{...rest}
id={id}
ref={ref}
onLoad={handleLoad}
onError={handleError}
dangerouslySetInnerHTML={{
__html: typeof children === 'string' ? children : '',
}}
/>
)
}
return (
<script
{...rest}
id={id}
src={src}
ref={ref}
onLoad={handleLoad}
onError={handleError}
dangerouslySetInnerHTML={dangerouslySetInnerHTML}
/>
)
}