Skip to content

Commit baf0ab7

Browse files
authored
fix: remote script (#346)
### Problem 1. **React "Encountered a script tag" warning** — Remote components returning `<script>` tags (e.g. import maps, `__flightStream__` globals) caused React to warn during RSC rendering, since React's Float system does not allow `<script>` elements in JSX. 2. **"Each child in a list should have a unique key prop" warning** — Live components (`"use live"`) emitted spurious missing-key warnings for every keyless element, even when keys weren't required. ### Root Cause 1. `dom-flight.mjs` was passing `<script>` nodes through as regular JSX elements. 2. The `@lazarv/rsc` custom Flight serializer had two issues: - It wrote `debugCounter` (an incrementing number) at position 6 of the dev element tuple, where React's Flight client expects `_store.validated`. This caused every deserialized element to have an incorrect validation state. - `toStream`/`toBuffer` never passed `debug: true` to the serializer, so it emitted 4-element production tuples. The dev-mode Flight client reads `tuple[6]` as `undefined` (= not validated), triggering key re-validation on all elements. ### Changes - **`server/dom-flight.mjs`** — Convert `<script>` nodes to `<template data-script-attrs>` during Flight serialization to avoid React's script tag warning. - **`client/ReactServerComponent.jsx`** — Activate script templates (create real `<script>` elements from templates) before Flight hydration and on navigation, supporting both document and shadow DOM roots. - **`packages/rsc/server/shared.mjs`** — Emit `element._store.validated` (not `debugCounter`) at tuple position 6 in dev mode. Mark keyless Fragment children with `validated = 2` to match React's `renderFragment` behavior. - **`cache/rsc.mjs` / `cache/rsc-browser.mjs`** — Pass `debug: true` in dev mode so the serializer emits full 7-element dev tuples with correct `_store.validated` values. - **`bin/commands/dev.mjs` / `lib/dev/create-server.mjs`** — Add `--origin` and `--trust-proxy` CLI options for reverse proxy support. - **`docs/`** — Document the new `--origin` and `--trust-proxy` CLI options (en + ja).
1 parent 19d12eb commit baf0ab7

9 files changed

Lines changed: 167 additions & 37 deletions

File tree

docs/src/pages/en/(pages)/features/cli.mdx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,6 @@ Use HTTPS protocol. Default is `false`.
6262

6363
When you want to use HTTPS for your development server. You need to specify your HTTPS configuration in your `react-server.config.mjs` or `vite.config.mjs` file. See details in the Vite documentation at [server.https](https://vitejs.dev/config/server-options.html#server-https).
6464

65-
<Link name="dev-open">
66-
### --open
67-
</Link>
68-
69-
Open browser on server start. Default is `false`. Opens your app in the default browser.
70-
7165
<Link name="dev-cors">
7266
### --cors
7367
</Link>
@@ -76,6 +70,24 @@ Enable CORS. Default is `false`.
7670

7771
This is useful when you want to allow cross-origin requests. If you need more detailed CORS configuration, you can define the CORS configuration in your `react-server.config.mjs` or `vite.config.mjs` file. See details in the Vite documentation at [server.cors](https://vitejs.dev/config/server-options.html#server-cors).
7872

73+
<Link name="dev-origin">
74+
### --origin
75+
</Link>
76+
77+
Specify the origin part of the URL to a constant value. Same as using the `ORIGIN` environment variable.
78+
79+
<Link name="dev-trust-proxy">
80+
### --trust-proxy
81+
</Link>
82+
83+
Trust `X-Forwarded-*` headers.
84+
85+
<Link name="dev-open">
86+
### --open
87+
</Link>
88+
89+
Open browser on server start. Default is `false`. Opens your app in the default browser.
90+
7991
<Link name="dev-force">
8092
### --force
8193
</Link>

docs/src/pages/ja/(pages)/features/cli.mdx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,6 @@ HTTPSプロトコルを有効にする場合に指定します。デフォルト
6262

6363
もし開発サーバーでHTTPSプロトコルを使う場合、`react-server.config.mjs`または`vite.config.mjs`でHTTPSの設定をする必要があります。詳しくはViteドキュメントの[server.https](https://ja.vite.dev/config/server-options#server-https)を参照してください。
6464

65-
<Link name="dev-open">
66-
### --open
67-
</Link>
68-
69-
サーバー起動時にデフォルトブラウザを開くか指定します。デフォルトは`false`です。デフォルトブラウザでアプリケーションにアクセスします。
70-
7165
<Link name="dev-cors">
7266
### --cors
7367
</Link>
@@ -76,6 +70,24 @@ CORSを有効にします。デフォルトは`false`です。
7670

7771
もしオリジン間リソース共有を有効化したい場合は便利です。より詳細なCORS設定が必要な場合は`react-server.config.mjs`または`vite.config.mjs`で設定することが出来ます。詳しくはViteドキュメントの[server.cors](https://ja.vite.dev/config/server-options#server-cors)を参照してください。
7872

73+
<Link name="dev-origin">
74+
### --origin
75+
</Link>
76+
77+
URLのオリジン部分を定数に指定します。環境変数`ORIGIN`を使っても同じ結果になります。
78+
79+
<Link name="dev-trust-proxy">
80+
### --trust-proxy
81+
</Link>
82+
83+
`X-Forwarded-*`ヘッダーを信頼します。
84+
85+
<Link name="dev-open">
86+
### --open
87+
</Link>
88+
89+
サーバー起動時にデフォルトブラウザを開くか指定します。デフォルトは`false`です。デフォルトブラウザでアプリケーションにアクセスします。
90+
7991
<Link name="dev-force">
8092
### --force
8193
</Link>

packages/react-server/bin/commands/dev.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ export default (cli) =>
1010
})
1111
.option("--port <port>", "[number] port to listen on", { default: 3000 })
1212
.option("--https", "[boolean] use HTTPS protocol", { default: false })
13+
.option("--cors", "enable CORS", { default: false })
14+
.option("--origin <origin>", "[string] origin", { default: "" })
15+
.option("--trust-proxy", "[boolean] trust proxy", { default: false })
1316
.option("--open [url]", "[boolean|string] open browser on server start", {
1417
default: false,
1518
})
16-
.option("--cors", "enable CORS", { default: false })
1719
.option("--force", "force optimize deps", { default: false })
1820
.option("--watch", "watch for config changes", { default: true })
1921
.option("--clear-screen", "clear screen on server start", {

packages/react-server/cache/rsc-browser.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ function concat(buffers) {
1616
return result;
1717
}
1818

19+
const isDev = typeof import.meta.env !== "undefined" && !!import.meta.env.DEV;
20+
1921
export function toBuffer(model, options = {}) {
2022
return new Promise(async (resolve, reject) => {
2123
const stream = renderToReadableStream(model, {
24+
debug: isDev,
2225
...options,
2326
onError(error) {
2427
reject(error);
@@ -35,7 +38,7 @@ export function toBuffer(model, options = {}) {
3538
}
3639

3740
export async function toStream(model, options = {}) {
38-
return renderToReadableStream(model, options);
41+
return renderToReadableStream(model, { debug: isDev, ...options });
3942
}
4043

4144
export function fromBuffer(payload, options = {}) {

packages/react-server/cache/rsc.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ import { renderToReadableStream } from "@lazarv/rsc/server";
33

44
import { concat, copyBytesFrom } from "../lib/sys.mjs";
55

6+
const isDev = typeof import.meta.env !== "undefined" && !!import.meta.env.DEV;
7+
68
export function toBuffer(model, options = {}) {
79
return new Promise(async (resolve, reject) => {
810
const { clientReferenceMap } =
911
await import("@lazarv/react-server/dist/server/client-reference-map");
1012
const map = clientReferenceMap();
1113
const stream = renderToReadableStream(model, {
14+
debug: isDev,
1215
...options,
1316
moduleResolver: {
1417
resolveClientReference(value) {
@@ -34,6 +37,7 @@ export async function toStream(model, options = {}) {
3437
await import("@lazarv/react-server/dist/server/client-reference-map");
3538
const map = clientReferenceMap();
3639
return renderToReadableStream(model, {
40+
debug: isDev,
3741
...options,
3842
moduleResolver: {
3943
resolveClientReference(value) {

packages/react-server/client/ReactServerComponent.jsx

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,26 @@ import {
1919
useClient,
2020
} from "./context.mjs";
2121

22+
// Execute scripts stored as <template data-script-attrs> by dom-flight.mjs
23+
// to avoid React's "Encountered a script tag" warning during SSR/RSC rendering.
24+
// We leave the template in the DOM so React can still reconcile its fiber tree.
25+
function activateScriptTemplates(root) {
26+
if (typeof document === "undefined") return;
27+
root.querySelectorAll("template[data-script-attrs]").forEach((template) => {
28+
if (template.dataset.activated) return;
29+
template.dataset.activated = "";
30+
const attrs = JSON.parse(template.dataset.scriptAttrs);
31+
const script = document.createElement("script");
32+
for (const [key, value] of Object.entries(attrs)) {
33+
script.setAttribute(key, value);
34+
}
35+
script.textContent = template.content.textContent;
36+
// Append to execute, then remove the script (not the template).
37+
document.head.appendChild(script);
38+
script.remove();
39+
});
40+
}
41+
2242
function FlightComponent({
2343
remote = false,
2444
defer = false,
@@ -39,23 +59,36 @@ function FlightComponent({
3959
createTemporaryReferenceSet,
4060
encodeReply,
4161
} = client;
42-
const [{ resourceKey, error, Component }, setComponent] = useState({
43-
resourceKey: 0,
44-
error: null,
45-
Component:
46-
children ||
47-
(outlet === PAGE_ROOT || remote
48-
? getFlightResponse?.(url, {
49-
outlet,
50-
remote,
51-
remoteProps,
52-
temporaryReferences: remoteProps
53-
? createRemoteTemporaryReferenceSet(remoteProps)
54-
: null,
55-
defer,
56-
request,
57-
})
58-
: null),
62+
const [{ resourceKey, error, Component }, setComponent] = useState(() => {
63+
// Activate script templates before first getFlightResponse so the
64+
// __flightStream__ globals are available for hydration.
65+
if (typeof document !== "undefined") {
66+
if (isolate) {
67+
const host = document.getElementById(`shadowroot_${outlet}`);
68+
if (host?.shadowRoot) {
69+
activateScriptTemplates(host.shadowRoot);
70+
}
71+
}
72+
activateScriptTemplates(document);
73+
}
74+
return {
75+
resourceKey: 0,
76+
error: null,
77+
Component:
78+
children ||
79+
(outlet === PAGE_ROOT || remote
80+
? getFlightResponse?.(url, {
81+
outlet,
82+
remote,
83+
remoteProps,
84+
temporaryReferences: remoteProps
85+
? createRemoteTemporaryReferenceSet(remoteProps)
86+
: null,
87+
defer,
88+
request,
89+
})
90+
: null),
91+
};
5992
});
6093
const errorRef = useRef(null);
6194
const componentPromiseRef = useRef(null);
@@ -184,6 +217,16 @@ function FlightComponent({
184217
})()
185218
: Promise.resolve({})
186219
).then(({ temporaryReferences, body }) => {
220+
// Activate any new script templates before reading flight stream
221+
if (typeof document !== "undefined") {
222+
if (isolate) {
223+
const host = document.getElementById(`shadowroot_${outlet}`);
224+
if (host?.shadowRoot) {
225+
activateScriptTemplates(host.shadowRoot);
226+
}
227+
}
228+
activateScriptTemplates(document);
229+
}
187230
getFlightResponse(url, {
188231
outlet,
189232
remote,

packages/react-server/lib/dev/create-server.mjs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -987,7 +987,20 @@ export default async function createServer(root, options) {
987987
: [...initialHandlers, ...(config.handlers ?? [])]
988988
);
989989

990-
viteDevServer.middlewares.use(createMiddleware(composedHandlers));
990+
viteDevServer.middlewares.use(
991+
createMiddleware(composedHandlers, {
992+
origin:
993+
options.origin ??
994+
sys.getEnv("ORIGIN") ??
995+
config.server?.origin ??
996+
`${
997+
config.server?.https || options.https ? "https" : "http"
998+
}://${options.host ?? sys.getEnv("HOST") ?? config.server?.host ?? "localhost"}:$${
999+
options.port ?? sys.getEnv("PORT") ?? config.server?.port ?? 3000
1000+
}`,
1001+
trustProxy: config.server?.trustProxy ?? options.trustProxy,
1002+
})
1003+
);
9911004

9921005
const localHostnames = new Set([
9931006
"localhost",

packages/react-server/server/dom-flight.mjs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,25 @@ export default function visit(node, context) {
1010
return node.value;
1111
case "#comment":
1212
return null;
13+
case "script":
14+
node.nodeName = "template";
15+
node.attrs = [
16+
{
17+
name: "data-script-attrs",
18+
value: JSON.stringify(
19+
node.attrs.reduce((attrs, attr) => {
20+
attrs[attr.name] = attr.value;
21+
return attrs;
22+
}, {})
23+
),
24+
},
25+
];
1326
default: {
14-
if (node.nodeName === "template" && context.defer) {
27+
if (
28+
node.nodeName === "template" &&
29+
context.defer &&
30+
!node.attrs?.some((attr) => attr.name === "data-script")
31+
) {
1532
return null;
1633
}
1734
const childNodes =

packages/rsc/server/shared.mjs

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1320,6 +1320,23 @@ function serializeElement(request, element) {
13201320
// Keyless Fragment - output children as plain array
13211321
const children = props?.children;
13221322
if (Array.isArray(children)) {
1323+
// Mark keyless element children as needing validation (validated=2)
1324+
// to match React's renderFragment behavior. This ensures the Flight
1325+
// client-side reconciler correctly warns about missing keys.
1326+
for (let i = 0; i < children.length; i++) {
1327+
const child = children[i];
1328+
if (
1329+
child !== null &&
1330+
typeof child === "object" &&
1331+
isReactElement(child) &&
1332+
child.key === null &&
1333+
!child._store?.validated
1334+
) {
1335+
if (child._store) {
1336+
child._store.validated = 2;
1337+
}
1338+
}
1339+
}
13231340
return children.map((child, i) =>
13241341
serializeValue(request, child, props, i)
13251342
);
@@ -1492,7 +1509,7 @@ function serializeElement(request, element) {
14921509

14931510
// Build the element tuple
14941511
// Production format: ["$", type, key, props]
1495-
// Dev format: ["$", type, key, props, owner?, debugStack?, debugCounter?]
1512+
// Dev format: ["$", type, key, props, owner?, debugStack?, validated?]
14961513
const tuple = [
14971514
"$",
14981515
serializedType,
@@ -1573,9 +1590,16 @@ function serializeElement(request, element) {
15731590
// Add debug stack reference (6th element)
15741591
tuple.push(debugStackRef);
15751592

1576-
// Add debug counter (7th element) - increments for each element
1577-
request.debugCounter++;
1578-
tuple.push(request.debugCounter);
1593+
// Add validated flag (7th element) - matches React's Flight protocol
1594+
// where position 6 carries `element._store.validated`:
1595+
// 0 = not yet validated
1596+
// 1 = already validated (key check passed or set by parent)
1597+
// 2 = needs validation (element is in an array without a key)
1598+
// The Flight client reads this into `_store.validated` on deserialized
1599+
// elements, and react-dom's reconciler uses it to decide whether to
1600+
// warn about missing keys.
1601+
const validated = element._store?.validated ?? 0;
1602+
tuple.push(validated);
15791603
}
15801604

15811605
return tuple;

0 commit comments

Comments
 (0)