Skip to content
Open
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
28 changes: 28 additions & 0 deletions packages/react-router/src/Asset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ function Script({
}) {
const router = useRouter()
const hydrated = useHydrated()
// Track whether this component instance went through the !hydrated (pre-hydration) phase.
// true → component was SSR-rendered; the inline script was already executed by the browser.
// false → component was mounted fresh on the client (client-side navigation); useEffect must
// inject an executable script because dangerouslySetInnerHTML never executes scripts.
const wentThroughNonHydratedPhase = React.useRef(!hydrated)
const dataScript =
typeof attrs?.type === 'string' &&
attrs.type !== '' &&
Expand Down Expand Up @@ -215,5 +220,28 @@ function Script({
}
}

// For inline scripts (children, no src) that went through SSR hydration, keep the element
// in the React tree so React doesn't unmount the SSR-rendered script from the DOM.
// The useEffect above detects the existing element via textContent match and skips
// re-injection, so the script won't execute a second time.
//
// For client-side navigation (wentThroughNonHydratedPhase === false), skip this path and
// fall through to return null — the useEffect handles imperative injection in that case.
// (dangerouslySetInnerHTML does not execute scripts, so we must not render an inert element
// that would fool the existingScript dedup check into returning early without executing.)
if (
!attrs?.src &&
typeof children === 'string' &&
wentThroughNonHydratedPhase.current
) {
return (
<script
{...attrs}
dangerouslySetInnerHTML={{ __html: children }}
suppressHydrationWarning
/>
)
}

return null
}