@astrojs/react still strips the component's own <link> tags from island HTML (#16987 was closed without the fix) · Issue #18418 · withastro/astro · GitHub
You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
@astrojs/react still strips the component's own <link> tags from island HTML (#16987 was closed without the fix) #18418
It is meant for the preloads React 19 injects (#16212), but it also removes the <link> tags the component renders itself. The server HTML ships without them while the client render still has them, so:
before hydration, or without JS, the page is painted without the component's stylesheet;
hydration fails. With React 18.3.1 it logs React errors #418 twice and #423, and React throws away the server HTML and client-renders the island. With React 19.3.1 it logs #418.
This was reported in #16987, and #16988 fixed it. But the author closed #16987 when they confirmed that the PR fixed it, and #16988 was then closed without merging because its issue was closed. @astrojs/react 7.1.0 still has the same regex.
#16988 also only took stylesheet out of the list, so a component's own preload, modulepreload, preconnect and dns-prefetch links would still be stripped. React 18 shares server.js with React 19 (dist/version.js) and injects none of these, so with React 18 the strip only ever removes the component's own links.
astro build writes <astro-island …><div id="widget">Widget</div><!--astro:end--></astro-island>: both links are gone, with React 18 and with React 19.
What's the expected result?
The island HTML keeps the <link> tags the component renders, as react-dom/server writes them. The server HTML then matches the client render, and the page is styled before hydration. Only the hints React injects by itself should be removed. At the least, stylesheet should come out of the list, as in #16988, and React 18, which injects none, could skip the strip.
Unit Test: Yes — packages/integrations/react/test/react-19-preloads.test.ts
Priority:P3: minor bug. It only affects React components that render their own <link> tags inside an island. Those links get dropped from the server HTML, which leaves the island unstyled before hydration and can cause a hydration mismatch.
The cause is in packages/integrations/react/src/server.ts (~L132-138). A regex added in #16224 strips every <link rel="preload|modulepreload|stylesheet|preconnect|dns-prefetch"> from island output. It matches on rel only, so it can't tell React 19's auto-injected Float preloads apart from links the component renders. It also runs for React 18, which injects nothing, because version.ts maps React 18 and React 19 to the same server.js.
I found a potential fix for this issue. The strip now removes only preload and modulepreload links, which are the hints React 19 injects on its own (for example <link rel="preload" as="image"> for an <img>). It is skipped entirely on React 18, so the component's own stylesheet, preconnect and dns-prefetch links stay in the island HTML. This needs human review. One tradeoff remains: a <link rel="preload"> that the user writes by hand is still stripped on React 19, because the string output doesn't show who added it.
The only test covered the <img> preload case. Nobody had considered or tested links that the component renders itself.
Verification
Verdict: bug (high confidence).
The stated intent covers only hints that React generates.
Removing links the user wrote is an accidental side effect.
The strip also runs on React 18, even though the comment talks only about React 19.
Fix
- // Strip React 19 auto-injected resource hints (preloads, etc.) from island output.+ // Strip the preloads React 19 auto-injects (e.g. for <img>) from island output.
// These should be in <head>, not inside the island.
// See: https://github.com/facebook/react/issues/27910
- html = html.replace(- /<link\s[^>]*rel="(?:preload|modulepreload|stylesheet|preconnect|dns-prefetch)"[^>]*>/g,- '',- );+ // React never emits other <link> tags (stylesheet, preconnect, ...) on its own, so those come+ // from the component and must be kept to match the client render. React 18 injects nothing.+ // See: https://github.com/withastro/astro/issues/18418+ if (!React.version.startsWith('18.')) {+ html = html.replace(/<link\s[^>]*rel="(?:preload|modulepreload)"[^>]*>/g, '');+ }
Test: the react-19-preloads fixture component now also renders a stylesheet link and a preconnect link. A new test, "should keep tags rendered by the component", fails without the fix and passes with it. The existing check that the preload is stripped still passes.
Repro after the fix: the island is <link rel="preconnect" .../><link rel="stylesheet" href="/widget.css"/><div id="widget">Widget</div>.
Test suite: each react test file passes when run on its own (5/5, 2/2, 7/7, 11/11). Running all the files together in one process gives intermittent dispatcher.getOwner is not a function failures. That happens without the change too, so it is unrelated.
This report was made by an LLM. The analysis may be wrong, and the potential fix might not work, but is intended as a starting point for exploring the issue.
Activity
astro-factory commentedon Oct 16, 2026
Triage complete
triage: needs triagepackages/integrations/react/test/react-19-preloads.test.ts<link>tags inside an island. Those links get dropped from the server HTML, which leaves the island unstyled before hydration and can cause a hydration mismatch.The cause is in
packages/integrations/react/src/server.ts(~L132-138). A regex added in #16224 strips every<link rel="preload|modulepreload|stylesheet|preconnect|dns-prefetch">from island output. It matches onrelonly, so it can't tell React 19's auto-injected Float preloads apart from links the component renders. It also runs for React 18, which injects nothing, becauseversion.tsmaps React 18 and React 19 to the sameserver.js.I found a potential fix for this issue. The strip now removes only
preloadandmodulepreloadlinks, which are the hints React 19 injects on its own (for example<link rel="preload" as="image">for an<img>). It is skipped entirely on React 18, so the component's ownstylesheet,preconnectanddns-prefetchlinks stay in the island HTML. This needs human review. One tradeoff remains: a<link rel="preload">that the user writes by hand is still stripped on React 19, because the string output doesn't show who added it.Full Triage Report
Environment
@astrojs/react7.1.0 (monorepomain), react / react-dom 19.3.0, Node, pnpmSteps to reproduce
<link rel="stylesheet" href="/widget.css" />,<link rel="preconnect" href="https://example.com" />and<div id="widget">Widget</div>.client:load.astro buildand inspectdist/repro/index.html.Expected vs actual
<link>tags, the same wayreact-dom/serverwrites them.<div id="widget">Widget</div><!--astro:end-->and both links are gone.Raw
ReactDOM.renderToReadableStreamoutput for the same tree plus an<img>:React injected only the
rel="preload" as="image"link. The other two come from the component.Control: with
rel="icon", which is not in the regex list, the link is kept.Root cause
<img>preload case. Nobody had considered or tested links that the component renders itself.Verification
Verdict: bug (high confidence).
Fix
react-19-preloadsfixture component now also renders a stylesheet link and a preconnect link. A new test, "should keep tags rendered by the component", fails without the fix and passes with it. The existing check that the preload is stripped still passes..changeset/empty-colts-glow.md(@astrojs/reactpatch).<link rel="preconnect" .../><link rel="stylesheet" href="/widget.css"/><div id="widget">Widget</div>.dispatcher.getOwner is not a functionfailures. That happens without the change too, so it is unrelated.tscare clean.react-dom/serveremits.Tradeoffs
<link rel="preload">ormodulepreloadis still stripped on React 19.stylesheetfrom the list. This change also keepspreconnectanddns-prefetch, and skips the strip on React 18.This report was made by an LLM. The analysis may be wrong, and the potential fix might not work, but is intended as a starting point for exploring the issue.