@astrojs/react still strips the component's own <link> tags from island HTML (#16987 was closed without the fix) · Issue #18418 · withastro/astro · GitHub
Skip to content

@astrojs/react still strips the component's own <link> tags from island HTML (#16987 was closed without the fix) #18418

Open

Description

@FrancisVega

Astro Info

Astro                    v7.4.0
Node                     v24.12.0
System                   macOS (arm64)
Package Manager          npm
Output                   static
Adapter                  none
Integrations             @astrojs/react

If this issue only occurs in one browser, which browser is a problem?

No response

Describe the Bug

Since #16224, renderToStaticMarkup in @astrojs/react runs this over the island HTML:

html = html.replace(/<link\s[^>]*rel="(?:preload|modulepreload|stylesheet|preconnect|dns-prefetch)"[^>]*>/g, "");

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.

Component:

export default function Widget() {
	return (
		<>
			<link rel="stylesheet" href="/widget.css" />
			<link rel="preconnect" href="https://example.com" />
			<div id="widget">Widget</div>
		</>
	);
}

Page:

---
import Widget from "../components/Widget.jsx";
---

<html lang="en">
	<head><title>repro</title></head>
	<body><Widget client:load /></body>
</html>

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.

Link to Minimal Reproducible Example

https://github.com/FrancisVega/astro-react-island-links-repro

Participation

  • I am willing to submit a pull request for this issue.

Activity

  1. astro-factory commented on Oct 16, 2026

    @astro-factory
    Contributor

    Triage complete

    • Prepare the workspace: ready
    • Install dependencies: complete
    • Build the project: complete
    • Reproduce the issue: confirmed
    • Diagnose the cause: high confidence
    • Verify the diagnosis: bug
    • Attempt a fix: verified
    • Publish the result: set triage: needs triage

    • Reproduced: Yes
    • Exploration: Yes — View branch
    • 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.

    Full Triage Report

    Environment

    • Astro 7.4.x and @astrojs/react 7.1.0 (monorepo main), react / react-dom 19.3.0, Node, pnpm
    • Reporter: Astro v7.4.0, Node v24.12.0, macOS arm64, npm, static output, no adapter

    Steps to reproduce

    1. Add a React component that renders <link rel="stylesheet" href="/widget.css" />, <link rel="preconnect" href="https://example.com" /> and <div id="widget">Widget</div>.
    2. Render it on a page with client:load.
    3. Run astro build and inspect dist/repro/index.html.

    Expected vs actual

    • Expected: the island HTML contains the component's <link> tags, the same way react-dom/server writes them.
    • Actual: the island is <div id="widget">Widget</div><!--astro:end--> and both links are gone.

    Raw ReactDOM.renderToReadableStream output for the same tree plus an <img>:

    <link rel="preload" as="image" href="/a.png"/><link rel="preconnect" href="https://example.com"/><link rel="stylesheet" href="/widget.css"/><img src="/a.png"/><div id="widget">Widget</div>
    

    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

    html = html.replace(
    	/<link\s[^>]*rel="(?:preload|modulepreload|stylesheet|preconnect|dns-prefetch)"[^>]*>/g,
    	'',
    );

    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.
    • Changeset: .changeset/empty-colts-glow.md (@astrojs/react patch).
    • 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.
    • Lint and types: biome and tsc are clean.
    • Not checked: I couldn't confirm in a browser that the hydration errors (Version Packages #418/[Feature Request] Add Astro file icon to Astro VSCode extension #423) are gone, because no browser tool was available. The server markup now matches what react-dom/server emits.

    Tradeoffs

    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.

  2. added
    - P3: minor bugAn edge case that only affects very specific usage (priority)
    pkg: integrationRelated to any renderer integration (scope)
    pkg: reactRelated to React (scope)
    and removed on Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

- P3: minor bugAn edge case that only affects very specific usage (priority)pkg: integrationRelated to any renderer integration (scope)pkg: reactRelated to React (scope)triage: needs triageIssue needs to be triaged

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions