Third-party scripts

The <Script> component — declared loading strategies (afterInteractive, lazyOnload), process-wide dedupe, remount-safe onLoad, behavior per interactive mode, and the CSP story for client-injected tags.

<Script> loads third-party scripts declaratively instead of hand-rolled useEffect + createElement('script') blocks — with a decided answer for every mode the page can be in.

import { Script } from '@voltro/web'

// Analytics after hydration (the default strategy):
<Script
  src="https://eu.i.posthog.com/static/array.js"
  onLoad={() => {
    // The hand-written PostHog browser snippet — @voltro/plugin-posthog is a
    // SERVER-side track sink and ships no browser snippet; this is the
    // client half, wired the way PostHog's docs describe.
    const w = window as { posthog?: { init: (key: string, opts: { api_host: string }) => void } }
    w.posthog?.init('phc_your_project_key', { api_host: 'https://eu.i.posthog.com' })
  }}
/>

// GTM bootstrap — the inline variant (id is REQUIRED: it is the dedupe key):
<Script id="gtm-init">{`window.dataLayer = window.dataLayer || []`}</Script>

// A chat widget nobody needs before the browser is idle:
<Script src="https://widget.example.com/loader.js" strategy="lazyOnload" />

Strategies

  • afterInteractive (default) — injected after this component mounts, i.e. after hydration. Never render-blocking; the shell head stays clean.
  • lazyOnload — waits for browser idle (requestIdleCallback, with a setTimeout fallback for Safari).

There is no beforeInteractive. The honest alternative for a must-run-first script (a consent manager) is a literal <script> tag in the shell head — a <link rel="preload"> is not an answer: it fetches but never executes. And no worker strategy (the Partytown class is its own decision).

Dedupe + remount semantics

Scripts deduplicate process-wide by src (external) or id (inline) — the registry is global, so two <Script> tags for one widget produce one request and one execution, even across an islands page's separate bundle.

A script is never unloaded. Navigate away and back and the script does not re-execute and nothing is re-fetched — but onLoad fires again, answered from the registry (the classic next/script bug where a remounted component's onLoad never fires is pinned by test here). onError behaves the same for a failed load.

Behavior per interactive mode — decided, not accidental

  • full — as described above.
  • none — zero-JS means zero: the app bundle never ships, so a <Script> can never inject. The build warns by name instead of silently doing nothing.
  • islands — outside an island nothing mounts, so a <Script> in the page's static part never fires; the build warns. Inside an island it runs when that island hydrates — a visible island's script loads when it scrolls into view, which is often exactly the lazy behavior you want.

CSP

<Script> injects client-side, so the per-request nonce your middleware mints (CSP nonces) is stamped into server-rendered tags — not into tags created in the browser. The component's answer:

  • An explicit nonce prop always wins.
  • Otherwise the injector propagates the document's own nonce (read off an existing nonce'd script element). On an SSR page under a nonce'd CSP the injected tag therefore carries the request's nonce automatically.
  • On a static page there is no per-request nonce path at all — the documented options are 'strict-dynamic' (scripts injected by an allowed/nonce'd bootstrap are permitted, which is exactly this shape) or a hash-based policy.

The inline variant is covered by the same rules — under a strict CSP an inline snippet needs the nonce or 'strict-dynamic' like any other injected script.