@reckona/mreact-compiler 0.0.66 → 0.0.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2558 @@
1
+ import type {
2
+ AsyncBoundaryIr,
3
+ AttributeIr,
4
+ ComponentPropIr,
5
+ ComponentIr,
6
+ JsxNodeIr,
7
+ ModuleIr,
8
+ } from "./ir.js";
9
+ import type { RuntimeImport, ServerBootstrapMode, ServerEscapeOptions } from "./types.js";
10
+ import { emitEscapeHtmlHelper } from "./emit-escape-helper.js";
11
+ import { escapeHtmlAttribute as escapeHtml } from "@reckona/mreact-shared/html-escape";
12
+ import {
13
+ htmlAttributeName,
14
+ isDangerousHtmlAttribute,
15
+ isStaticUrlValueUnsafe,
16
+ isUrlAttribute,
17
+ parseStaticStyleObjectLiteral,
18
+ parseStyleLiteralValue,
19
+ simpleSideEffectFreeExpression,
20
+ } from "./emit-server-shared.js";
21
+ import { oxcServerStringReactNodeRenderHelperPlaceholder } from "./oxc-runtime-emit.js";
22
+
23
+ export interface EmitServerStreamResult {
24
+ code: string;
25
+ imports: RuntimeImport[];
26
+ }
27
+
28
+ export interface EmitServerStreamOptions {
29
+ dynamicAttributes?: "drop" | "emit";
30
+ serverBootstrap?: ServerBootstrapMode;
31
+ serverBootstrapNonce?: string;
32
+ serverBootstrapSrc?: string;
33
+ serverHydration?: boolean;
34
+ serverAwaitHydration?: boolean;
35
+ escape?: ServerEscapeOptions | undefined;
36
+ reactSuspenseRevealScriptSrc?: string;
37
+ }
38
+
39
+ let currentUrlSafeHelperName: string = "_urlAttrSafe";
40
+ let currentClientBoundaryHelperName: string | undefined;
41
+ let currentSpreadAttributesHelperName: string = "_renderSpreadAttributes";
42
+ let currentStreamNodeHelperName: string = "_renderStreamNode";
43
+ let currentAsyncBoundaryHelperName: string = "_renderAsyncBoundary";
44
+ let currentOutOfOrderBoundaryHelperName: string = "_renderOutOfOrderBoundary";
45
+ let currentReactSuspenseBoundaryHelperName: string = "_renderReactSuspenseBoundary";
46
+ let currentReactSuspenseOutOfOrderBoundaryHelperName: string =
47
+ "_renderReactSuspenseOutOfOrderBoundary";
48
+ let currentCompatRenderToStringHelperName: string = "_renderCompatToString";
49
+ let currentPropChildrenCollectState: CollectHtmlState | undefined;
50
+
51
+ export function emitServerStream(
52
+ ir: ModuleIr,
53
+ options: EmitServerStreamOptions = {},
54
+ ): EmitServerStreamResult {
55
+ const serverBootstrap = options.serverBootstrap ?? "none";
56
+ const escapeHelperName = allocateHelperName(ir, "_escapeHtml");
57
+ const escapeBatchHelperName = options.escape === undefined
58
+ ? undefined
59
+ : allocateHelperName(ir, "_escapeHtmlBatch");
60
+ const asyncBoundaryHelperName = allocateHelperName(ir, "_renderAsyncBoundary");
61
+ const outOfOrderBoundaryHelperName = allocateHelperName(ir, "_renderOutOfOrderBoundary");
62
+ const reorderScriptHelperName = allocateHelperName(ir, "_renderOutOfOrderReorderScript");
63
+ const reactSuspenseBoundaryHelperName = allocateHelperName(ir, "_renderReactSuspenseBoundary");
64
+ const reactSuspenseOutOfOrderBoundaryHelperName = allocateHelperName(
65
+ ir,
66
+ "_renderReactSuspenseOutOfOrderBoundary",
67
+ );
68
+ const compatRenderToStringHelperName = allocateHelperName(ir, "_renderCompatToString");
69
+ const streamNodeHelperName = allocateHelperName(ir, "_renderStreamNode");
70
+ const clientBoundaryHelperName = usesClientBoundary(ir)
71
+ ? allocateHelperName(ir, "_renderClientBoundary")
72
+ : undefined;
73
+ const spreadAttributesHelperName = allocateHelperName(ir, "_renderSpreadAttributes");
74
+ const urlSafeHelperName = allocateHelperName(ir, "_urlAttrSafe");
75
+ currentUrlSafeHelperName = urlSafeHelperName;
76
+ currentClientBoundaryHelperName = clientBoundaryHelperName;
77
+ currentSpreadAttributesHelperName = spreadAttributesHelperName;
78
+ currentStreamNodeHelperName = streamNodeHelperName;
79
+ currentAsyncBoundaryHelperName = asyncBoundaryHelperName;
80
+ currentOutOfOrderBoundaryHelperName = outOfOrderBoundaryHelperName;
81
+ currentReactSuspenseBoundaryHelperName = reactSuspenseBoundaryHelperName;
82
+ currentReactSuspenseOutOfOrderBoundaryHelperName = reactSuspenseOutOfOrderBoundaryHelperName;
83
+ currentCompatRenderToStringHelperName = compatRenderToStringHelperName;
84
+ const helper = emitEscapeHtmlHelper(escapeHelperName);
85
+ const urlSafeHelper = [
86
+ `function ${urlSafeHelperName}(name, value) {`,
87
+ ` if (typeof value !== "string") return value;`,
88
+ ` const _canonical = value`,
89
+ ` .replace(/^[\\x00-\\x20]+/u, "")`,
90
+ ` .replace(/[\\t\\r\\n]/g, "");`,
91
+ ` const _match = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(_canonical);`,
92
+ ` if (_match === null) return value;`,
93
+ ` const _scheme = _match[1].toLowerCase();`,
94
+ ` if (_scheme !== "javascript" && _scheme !== "vbscript" && _scheme !== "livescript" && _scheme !== "mhtml" && _scheme !== "file" && _scheme !== "data") return value;`,
95
+ ` if (_scheme === "data" && (name === "src" || name === "poster") && /^data:image\\/(?!svg\\+xml(?:[;,]|$))/i.test(_canonical)) return value;`,
96
+ ` return undefined;`,
97
+ `}`,
98
+ ].join("\n");
99
+ const components = ir.components
100
+ .map((component) =>
101
+ emitComponent(
102
+ component,
103
+ escapeHelperName,
104
+ asyncBoundaryHelperName,
105
+ outOfOrderBoundaryHelperName,
106
+ reorderScriptHelperName,
107
+ reactSuspenseBoundaryHelperName,
108
+ reactSuspenseOutOfOrderBoundaryHelperName,
109
+ compatRenderToStringHelperName,
110
+ {
111
+ serverBootstrap,
112
+ ...(options.serverBootstrapNonce === undefined
113
+ ? {}
114
+ : { serverBootstrapNonce: options.serverBootstrapNonce }),
115
+ ...(options.serverBootstrapSrc === undefined
116
+ ? {}
117
+ : { serverBootstrapSrc: options.serverBootstrapSrc }),
118
+ ...(options.serverHydration === undefined
119
+ ? {}
120
+ : { serverHydration: options.serverHydration }),
121
+ ...(options.serverAwaitHydration === undefined
122
+ ? {}
123
+ : { serverAwaitHydration: options.serverAwaitHydration }),
124
+ ...(options.reactSuspenseRevealScriptSrc === undefined
125
+ ? {}
126
+ : { reactSuspenseRevealScriptSrc: options.reactSuspenseRevealScriptSrc }),
127
+ dynamicAttributes: options.dynamicAttributes ?? "emit",
128
+ ...(escapeBatchHelperName === undefined ? {} : { escapeBatchHelperName }),
129
+ },
130
+ ),
131
+ )
132
+ .join("\n\n");
133
+ // Emit batch escape import only when the helper is actually referenced
134
+ // (issue 048: dead-import elimination).
135
+ const escapeImport =
136
+ options.escape === undefined ||
137
+ escapeBatchHelperName === undefined ||
138
+ !components.includes(escapeBatchHelperName)
139
+ ? ""
140
+ : `import { ${options.escape.batchImportName} as ${escapeBatchHelperName} } from ${stringLiteral(options.escape.batchImportSource)};`;
141
+ const imports = collectImports(ir, serverBootstrap);
142
+ const importAliases: Record<string, string> = {
143
+ renderAsyncBoundary: asyncBoundaryHelperName,
144
+ renderOutOfOrderBoundary: outOfOrderBoundaryHelperName,
145
+ renderOutOfOrderReorderScript: reorderScriptHelperName,
146
+ renderReactSuspenseBoundary: reactSuspenseBoundaryHelperName,
147
+ renderReactSuspenseOutOfOrderBoundary: reactSuspenseOutOfOrderBoundaryHelperName,
148
+ renderToString: compatRenderToStringHelperName,
149
+ };
150
+ const importLine = imports
151
+ .map(
152
+ (runtimeImport) =>
153
+ `import { ${runtimeImport.specifiers
154
+ .map((specifier) => `${specifier} as ${importAliases[specifier]}`)
155
+ .join(", ")} } from "${runtimeImport.source}";`,
156
+ )
157
+ .join("\n");
158
+ const userImports = emitUserImports(ir);
159
+ const moduleStatements = emitModuleStatements(ir);
160
+ const importsBlock = [importLine, escapeImport, userImports, moduleStatements].filter(Boolean).join("\n");
161
+ const needsSpreadAttributesHelper = components.includes(spreadAttributesHelperName);
162
+ const urlSafeBlock =
163
+ components.includes(urlSafeHelperName) || needsSpreadAttributesHelper
164
+ ? `\n\n${urlSafeHelper}`
165
+ : "";
166
+ const clientBoundaryBlock =
167
+ clientBoundaryHelperName === undefined || !components.includes(clientBoundaryHelperName)
168
+ ? ""
169
+ : `\n\n${emitClientBoundaryHelper(clientBoundaryHelperName)}`;
170
+ const spreadAttributesBlock = needsSpreadAttributesHelper
171
+ ? `\n\n${emitSpreadAttributesHelper(spreadAttributesHelperName, escapeHelperName, urlSafeHelperName)}`
172
+ : "";
173
+ const streamNodeBlock = components.includes(streamNodeHelperName)
174
+ ? `\n\n${emitStreamNodeHelper(streamNodeHelperName)}`
175
+ : "";
176
+
177
+ return {
178
+ code: `${importsBlock === "" ? "" : `${importsBlock}\n\n`}${helper}${urlSafeBlock}${clientBoundaryBlock}${spreadAttributesBlock}${streamNodeBlock}\n\n${components}\n`,
179
+ imports,
180
+ };
181
+ }
182
+
183
+ function emitUserImports(ir: ModuleIr): string {
184
+ return ir.components.length === 0 ? "" : ir.userImports.join("\n");
185
+ }
186
+
187
+ function emitModuleStatements(ir: ModuleIr): string {
188
+ return ir.components.length === 0 ? "" : ir.moduleStatements.join("\n");
189
+ }
190
+
191
+ function collectImports(ir: ModuleIr, serverBootstrap: ServerBootstrapMode): RuntimeImport[] {
192
+ const serverSpecifiers = [
193
+ ...(hasInOrderAsyncBoundary(ir) ? ["renderAsyncBoundary"] : []),
194
+ ...(hasOutOfOrderAsyncBoundary(ir) ? ["renderOutOfOrderBoundary"] : []),
195
+ ...(serverBootstrap === "out-of-order-reorder" && hasOutOfOrderAsyncBoundary(ir)
196
+ ? ["renderOutOfOrderReorderScript"]
197
+ : []),
198
+ ...(hasReactSuspenseBoundary(ir) ? ["renderReactSuspenseBoundary"] : []),
199
+ ...(hasReactSuspenseOutOfOrderBoundary(ir) ? ["renderReactSuspenseOutOfOrderBoundary"] : []),
200
+ ];
201
+ const imports: RuntimeImport[] = [];
202
+
203
+ if (serverSpecifiers.length > 0) {
204
+ imports.push({
205
+ source: "@reckona/mreact-server",
206
+ specifiers: serverSpecifiers,
207
+ });
208
+ }
209
+
210
+ if (hasCompatComponentReference(ir) || hasReactNodeRender(ir) || hasRawJsxDynamicRender(ir)) {
211
+ imports.push({
212
+ source: "@reckona/mreact-compat",
213
+ specifiers: ["renderToString"],
214
+ });
215
+ }
216
+
217
+ return imports;
218
+ }
219
+
220
+ function hasInOrderAsyncBoundary(ir: ModuleIr): boolean {
221
+ return ir.components.some((component) => containsAsyncBoundary(component.root, false));
222
+ }
223
+
224
+ function hasOutOfOrderAsyncBoundary(ir: ModuleIr): boolean {
225
+ return ir.components.some((component) => containsAsyncBoundary(component.root, true));
226
+ }
227
+
228
+ function hasReactSuspenseBoundary(ir: ModuleIr): boolean {
229
+ return ir.components.some((component) => containsReactSuspense(component.root, false));
230
+ }
231
+
232
+ function hasReactSuspenseOutOfOrderBoundary(ir: ModuleIr): boolean {
233
+ return ir.components.some((component) => containsReactSuspense(component.root, true));
234
+ }
235
+
236
+ function hasCompatComponentReference(ir: ModuleIr): boolean {
237
+ return ir.components.some((component) => containsCompatComponent(component.root));
238
+ }
239
+
240
+ function hasReactNodeRender(ir: ModuleIr): boolean {
241
+ return ir.components.some((component) => containsReactNodeRender(component.root));
242
+ }
243
+
244
+ function hasRawJsxDynamicRender(ir: ModuleIr): boolean {
245
+ return ir.components.some((component) => containsRawJsxDynamicRender(component.root));
246
+ }
247
+
248
+ function usesClientBoundary(ir: ModuleIr): boolean {
249
+ return ir.components.some((component) => containsClientBoundary(component.root));
250
+ }
251
+
252
+ function emitClientBoundaryHelper(name: string): string {
253
+ const propsHelperName = `${name}$hasNonSerializableProps`;
254
+
255
+ return [
256
+ `function ${propsHelperName}(value) {`,
257
+ ` if (typeof value === "function" || typeof value === "symbol") return true;`,
258
+ ` if (value === null || typeof value !== "object") return false;`,
259
+ ` if (Array.isArray(value)) return value.some(${propsHelperName});`,
260
+ ` for (const key of Object.keys(value)) {`,
261
+ ` if (${propsHelperName}(value[key])) return true;`,
262
+ ` }`,
263
+ ` return false;`,
264
+ `}`,
265
+ `function ${name}(name, props) {`,
266
+ ` const _name = String(name);`,
267
+ ` const _escapedName = _name.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");`,
268
+ ` const _props = props ?? {};`,
269
+ ` const _nonSerializable = ${propsHelperName}(_props);`,
270
+ ` const _nonSerializableAttr = _nonSerializable ? ' data-mreact-client-boundary-nonserializable="true"' : "";`,
271
+ ` const _json = (JSON.stringify(_props) ?? "{}").replaceAll("<", "\\\\u003c");`,
272
+ ` return \`<template data-mreact-client-boundary="\${_escapedName}"\${_nonSerializableAttr}></template><script type="application/json" data-mreact-client-boundary-props="\${_escapedName}">\${_json}</script>\`;`,
273
+ `}`,
274
+ ].join("\n");
275
+ }
276
+
277
+ function emitSpreadAttributesHelper(
278
+ name: string,
279
+ escapeHelperName: string,
280
+ urlSafeHelperName: string,
281
+ ): string {
282
+ const aliases = JSON.stringify({
283
+ acceptCharset: "accept-charset",
284
+ autoFocus: "autofocus",
285
+ autoPlay: "autoplay",
286
+ charSet: "charset",
287
+ className: "class",
288
+ colSpan: "colspan",
289
+ contentEditable: "contenteditable",
290
+ crossOrigin: "crossorigin",
291
+ encType: "enctype",
292
+ formAction: "formaction",
293
+ frameBorder: "frameborder",
294
+ htmlFor: "for",
295
+ httpEquiv: "http-equiv",
296
+ maxLength: "maxlength",
297
+ minLength: "minlength",
298
+ noValidate: "novalidate",
299
+ playsInline: "playsinline",
300
+ readOnly: "readonly",
301
+ rowSpan: "rowspan",
302
+ spellCheck: "spellcheck",
303
+ srcDoc: "srcdoc",
304
+ srcSet: "srcset",
305
+ tabIndex: "tabindex",
306
+ useMap: "usemap",
307
+ });
308
+ const urlAttributes = JSON.stringify([
309
+ "href",
310
+ "src",
311
+ "action",
312
+ "formaction",
313
+ "xlink:href",
314
+ "ping",
315
+ "poster",
316
+ "background",
317
+ "manifest",
318
+ ]);
319
+ const dangerousAttributes = JSON.stringify(["srcdoc"]);
320
+
321
+ return [
322
+ `const ${name}$aliases = ${aliases};`,
323
+ `const ${name}$urlAttributes = new Set(${urlAttributes});`,
324
+ `const ${name}$dangerousAttributes = new Set(${dangerousAttributes});`,
325
+ `function ${name}$style(value) {`,
326
+ ` if (value == null || value === false) return "";`,
327
+ ` if (typeof value === "string") return value;`,
328
+ ` let _style = "";`,
329
+ ` for (const _styleName of Object.keys(value)) {`,
330
+ ` const _styleValue = value[_styleName];`,
331
+ ` if (_styleValue == null || _styleValue === false) continue;`,
332
+ ` const _cssName = String(_styleName).startsWith("--") ? String(_styleName) : String(_styleName).replace(/[A-Z]/g, (_char) => "-" + _char.toLowerCase());`,
333
+ ` _style += (_style === "" ? "" : ";") + _cssName + ":" + (_styleValue === true ? "" : String(_styleValue));`,
334
+ ` }`,
335
+ ` return _style;`,
336
+ `}`,
337
+ `function ${name}(tagName, props) {`,
338
+ ` if (props == null || props === false) return "";`,
339
+ ` let _out = "";`,
340
+ ` for (const _rawName of Object.keys(props)) {`,
341
+ ` let _value = props[_rawName];`,
342
+ ` if (_value == null || _value === false) continue;`,
343
+ ` if (_rawName === "key" || _rawName === "ref" || _rawName === "children") continue;`,
344
+ ` if (/^on[A-Za-z]/.test(_rawName)) continue;`,
345
+ ` let _name = tagName === "input" && _rawName === "defaultValue" ? "value" : tagName === "input" && _rawName === "defaultChecked" ? "checked" : (${name}$aliases[_rawName] ?? _rawName);`,
346
+ ` if (!/^[A-Za-z_:][A-Za-z0-9:_.-]*$/.test(_name)) continue;`,
347
+ ` if (_name === "style") {`,
348
+ ` const _style = ${name}$style(_value);`,
349
+ ` if (_style !== "") _out += " style=\\"" + ${escapeHelperName}(_style) + "\\"";`,
350
+ ` continue;`,
351
+ ` }`,
352
+ ` if (${name}$dangerousAttributes.has(_name)) {`,
353
+ ` if (typeof _value === "object" && _value !== null && typeof _value.__html === "string") {`,
354
+ ` _out += " " + _name + "=\\"" + ${escapeHelperName}(_value.__html) + "\\"";`,
355
+ ` }`,
356
+ ` continue;`,
357
+ ` }`,
358
+ ` if (${name}$urlAttributes.has(_name)) {`,
359
+ ` _value = ${urlSafeHelperName}(_name, _value === true ? "" : _value);`,
360
+ ` if (_value === undefined) continue;`,
361
+ ` }`,
362
+ ` _out += " " + _name + "=\\"" + ${escapeHelperName}(_value === true ? "" : _value) + "\\"";`,
363
+ ` }`,
364
+ ` return _out;`,
365
+ `}`,
366
+ ].join("\n");
367
+ }
368
+
369
+ function emitStreamNodeHelper(name: string): string {
370
+ return [
371
+ `async function ${name}($sink, value, escapeHtml) {`,
372
+ ` if (value == null || value === false) return;`,
373
+ ` if (typeof value === "function") { await value($sink); return; }`,
374
+ ` if (Array.isArray(value)) { for (const item of value) await ${name}($sink, item, escapeHtml); return; }`,
375
+ ` if (typeof value === "string") { $sink.append(value); return; }`,
376
+ ` $sink.append(escapeHtml(value === true ? "" : value));`,
377
+ `}`,
378
+ ].join("\n");
379
+ }
380
+
381
+ function emitComponent(
382
+ component: ComponentIr,
383
+ escapeHelperName: string,
384
+ asyncBoundaryHelperName: string,
385
+ outOfOrderBoundaryHelperName: string,
386
+ reorderScriptHelperName: string,
387
+ reactSuspenseBoundaryHelperName: string,
388
+ reactSuspenseOutOfOrderBoundaryHelperName: string,
389
+ compatRenderToStringHelperName: string,
390
+ options: Required<Pick<EmitServerStreamOptions, "serverBootstrap">> &
391
+ Omit<EmitServerStreamOptions, "serverBootstrap"> & {
392
+ dynamicAttributes: "drop" | "emit";
393
+ escapeBatchHelperName?: string;
394
+ },
395
+ ): string {
396
+ const { serverBootstrap, serverBootstrapNonce, serverBootstrapSrc } = options;
397
+ const sinkName = allocateComponentSinkName(component);
398
+ const parameters = [sinkName, ...component.parameters].join(", ");
399
+ const body = component.bodyStatements.map((statement) =>
400
+ ` ${statement.replaceAll(
401
+ oxcServerStringReactNodeRenderHelperPlaceholder,
402
+ compatRenderToStringHelperName,
403
+ )}`,
404
+ );
405
+ const markerId = encodeURIComponent(component.name);
406
+ const hydrationStartStatements =
407
+ options.serverHydration === true
408
+ ? [` ${sinkName}.append(${stringLiteral(`<!--mreact-h:start:${markerId}-->`)});`]
409
+ : [];
410
+ const appendStatements = emitAppendStatements(
411
+ component.root,
412
+ sinkName,
413
+ escapeHelperName,
414
+ asyncBoundaryHelperName,
415
+ outOfOrderBoundaryHelperName,
416
+ reactSuspenseBoundaryHelperName,
417
+ reactSuspenseOutOfOrderBoundaryHelperName,
418
+ compatRenderToStringHelperName,
419
+ options.serverBootstrapNonce,
420
+ options.reactSuspenseRevealScriptSrc,
421
+ options.serverHydration === true,
422
+ options.serverAwaitHydration === true,
423
+ options.dynamicAttributes,
424
+ options.escapeBatchHelperName,
425
+ );
426
+ const bootstrapStatements =
427
+ serverBootstrap === "out-of-order-reorder" && containsAsyncBoundary(component.root, true)
428
+ ? [
429
+ ` ${reorderScriptHelperName}(${sinkName}${emitBootstrapOptions(serverBootstrapNonce, serverBootstrapSrc)});`,
430
+ ]
431
+ : [];
432
+ const hydrationEndStatements =
433
+ options.serverHydration === true
434
+ ? [` ${sinkName}.append(${stringLiteral(`<!--mreact-h:end:${markerId}-->`)});`]
435
+ : [];
436
+ const exportPrefix =
437
+ component.exportDefault === true ? "export default " : component.exported === false ? "" : "export ";
438
+ const asyncPrefix = component.async === true || containsAnyAsyncBoundary(component.root) ? "async " : "";
439
+ const functionKeyword = `${exportPrefix}${asyncPrefix}function`;
440
+
441
+ return [
442
+ `${functionKeyword} ${component.name}(${parameters}) {`,
443
+ ...body,
444
+ ...hydrationStartStatements,
445
+ ...appendStatements,
446
+ ...hydrationEndStatements,
447
+ ...bootstrapStatements,
448
+ `}`,
449
+ ].join("\n");
450
+ }
451
+
452
+ function emitBootstrapOptions(nonce?: string, src?: string): string {
453
+ const entries = [
454
+ ...(nonce === undefined ? [] : [`nonce: ${stringLiteral(nonce)}`]),
455
+ ...(src === undefined ? [] : [`src: ${stringLiteral(src)}`]),
456
+ ];
457
+
458
+ return entries.length === 0 ? "" : `, { ${entries.join(", ")} }`;
459
+ }
460
+
461
+ function emitAppendStatements(
462
+ node: JsxNodeIr,
463
+ sinkName: string,
464
+ escapeHelperName: string,
465
+ asyncBoundaryHelperName: string,
466
+ outOfOrderBoundaryHelperName: string,
467
+ reactSuspenseBoundaryHelperName: string,
468
+ reactSuspenseOutOfOrderBoundaryHelperName: string,
469
+ compatRenderToStringHelperName: string,
470
+ reactSuspenseRevealScriptNonce: string | undefined,
471
+ reactSuspenseRevealScriptSrc: string | undefined,
472
+ hydration: boolean,
473
+ awaitHydration: boolean,
474
+ dynamicAttributes: "drop" | "emit",
475
+ escapeBatchHelperName: string | undefined,
476
+ ): string[] {
477
+ if (node.kind === "conditional") {
478
+ const emitBranch = (children: readonly JsxNodeIr[]): string[] =>
479
+ children.flatMap((child) =>
480
+ emitAppendStatements(
481
+ child,
482
+ sinkName,
483
+ escapeHelperName,
484
+ asyncBoundaryHelperName,
485
+ outOfOrderBoundaryHelperName,
486
+ reactSuspenseBoundaryHelperName,
487
+ reactSuspenseOutOfOrderBoundaryHelperName,
488
+ compatRenderToStringHelperName,
489
+ reactSuspenseRevealScriptNonce,
490
+ reactSuspenseRevealScriptSrc,
491
+ hydration,
492
+ awaitHydration,
493
+ dynamicAttributes,
494
+ escapeBatchHelperName,
495
+ ),
496
+ );
497
+ const indentBranch = (line: string) => ` ${line}`;
498
+ const whenTrue = emitBranch(node.whenTrue).map(indentBranch);
499
+ const whenFalse = emitBranch(node.whenFalse).map(indentBranch);
500
+
501
+ if (whenTrue.length === 0 && whenFalse.length === 0) {
502
+ return [];
503
+ }
504
+
505
+ if (whenFalse.length === 0) {
506
+ return [` if (${node.conditionCode}) {`, ...whenTrue, ` }`];
507
+ }
508
+
509
+ return [` if (${node.conditionCode}) {`, ...whenTrue, ` } else {`, ...whenFalse, ` }`];
510
+ }
511
+
512
+ const collectState: CollectHtmlState = {
513
+ dynamicAttributes,
514
+ ...(escapeBatchHelperName === undefined ? {} : { escapeBatchHelperName }),
515
+ hydration,
516
+ awaitHydration,
517
+ nextFragmentId: 0,
518
+ ...(reactSuspenseRevealScriptNonce === undefined ? {} : { reactSuspenseRevealScriptNonce }),
519
+ ...(reactSuspenseRevealScriptSrc === undefined ? {} : { reactSuspenseRevealScriptSrc }),
520
+ };
521
+ const collected = collectHtmlParts(
522
+ node,
523
+ escapeHelperName,
524
+ asyncBoundaryHelperName,
525
+ outOfOrderBoundaryHelperName,
526
+ reactSuspenseBoundaryHelperName,
527
+ reactSuspenseOutOfOrderBoundaryHelperName,
528
+ collectState,
529
+ );
530
+ const previousPropChildrenCollectState = currentPropChildrenCollectState;
531
+ currentPropChildrenCollectState = collectState;
532
+ try {
533
+ return coalesceAdjacentStaticParts(collected).map((part) => {
534
+ if (part.kind === "async-boundary") {
535
+ return emitAsyncBoundary(
536
+ part,
537
+ sinkName,
538
+ asyncBoundaryHelperName,
539
+ compatRenderToStringHelperName,
540
+ );
541
+ }
542
+
543
+ if (part.kind === "out-of-order-boundary") {
544
+ return emitOutOfOrderBoundary(
545
+ part,
546
+ sinkName,
547
+ outOfOrderBoundaryHelperName,
548
+ compatRenderToStringHelperName,
549
+ );
550
+ }
551
+
552
+ if (part.kind === "react-suspense-boundary") {
553
+ return emitReactSuspenseBoundary(
554
+ part,
555
+ sinkName,
556
+ reactSuspenseBoundaryHelperName,
557
+ compatRenderToStringHelperName,
558
+ );
559
+ }
560
+
561
+ if (part.kind === "react-suspense-out-of-order-boundary") {
562
+ return emitReactSuspenseOutOfOrderBoundary(
563
+ part,
564
+ sinkName,
565
+ reactSuspenseOutOfOrderBoundaryHelperName,
566
+ compatRenderToStringHelperName,
567
+ );
568
+ }
569
+
570
+ if (part.kind === "component") {
571
+ if (part.runtime === "compat") {
572
+ return emitCompatComponentAppendStatements(
573
+ part,
574
+ sinkName,
575
+ compatRenderToStringHelperName,
576
+ " ",
577
+ );
578
+ }
579
+
580
+ return ` await ${part.name}(${sinkName}, ${emitPropsObject(part.props, part.children, part.escapeHelperName)});`;
581
+ }
582
+
583
+ if (part.kind === "react-node") {
584
+ return ` ${sinkName}.append(${compatRenderToStringHelperName}(() => (${part.code})));`;
585
+ }
586
+
587
+ if (part.kind === "stream-node") {
588
+ return ` await ${currentStreamNodeHelperName}(${sinkName}, (${part.code}), ${part.escapeHelperName});`;
589
+ }
590
+
591
+ if (part.kind === "list") {
592
+ return emitListPart(part, sinkName, compatRenderToStringHelperName, " ");
593
+ }
594
+
595
+ if (part.kind === "dynamic" && looksLikeRawJsxExpression(part.code)) {
596
+ return emitDynamicHtmlAppendStatement(
597
+ part.code,
598
+ sinkName,
599
+ escapeHelperName,
600
+ compatRenderToStringHelperName,
601
+ " ",
602
+ );
603
+ }
604
+
605
+ const expression =
606
+ part.kind === "static"
607
+ ? stringLiteral(part.value)
608
+ : part.kind === "dynamic"
609
+ ? `${escapeHelperName}(${part.code})`
610
+ : part.code;
611
+
612
+ return ` ${sinkName}.append(${expression});`;
613
+ });
614
+ } finally {
615
+ currentPropChildrenCollectState = previousPropChildrenCollectState;
616
+ }
617
+ }
618
+
619
+ function isHtmlSyncPart(part: HtmlPart): part is HtmlSyncPart {
620
+ return (
621
+ part.kind !== "async-boundary" &&
622
+ part.kind !== "out-of-order-boundary" &&
623
+ part.kind !== "react-suspense-boundary" &&
624
+ part.kind !== "react-suspense-out-of-order-boundary"
625
+ );
626
+ }
627
+
628
+ // Issue 085: collapse runs of adjacent `static` parts into a single
629
+ // `static` part. Each part becomes one `sink.append(...)` call at emit
630
+ // time and `sink.append` goes through 2-3 function frames, so merging
631
+ // `["<span", ">"]` into `"<span>"` halves the per-iteration call count
632
+ // for tag-heavy lists.
633
+ //
634
+ // Only adjacent static-kind parts are merged; dynamic / boundary /
635
+ // component / list / react-node parts stay where they are.
636
+ function coalesceAdjacentStaticParts<T extends HtmlPart>(parts: T[]): T[] {
637
+ if (parts.length < 2) return parts;
638
+ const result: T[] = [];
639
+ let pending: { kind: "static"; value: string } | undefined;
640
+ for (const part of parts) {
641
+ if (part.kind === "static") {
642
+ pending =
643
+ pending === undefined
644
+ ? { kind: "static", value: part.value }
645
+ : { kind: "static", value: pending.value + part.value };
646
+ continue;
647
+ }
648
+ if (pending !== undefined) {
649
+ result.push(pending as T);
650
+ pending = undefined;
651
+ }
652
+ result.push(part);
653
+ }
654
+ if (pending !== undefined) {
655
+ result.push(pending as T);
656
+ }
657
+ return result;
658
+ }
659
+
660
+ function emitSyncPartAsAppendStatement(
661
+ part: HtmlSyncPart,
662
+ sinkName: string,
663
+ compatRenderToStringHelperName: string,
664
+ indent: string,
665
+ ): string {
666
+ if (part.kind === "component") {
667
+ if (part.runtime === "compat") {
668
+ return emitCompatComponentAppendStatements(
669
+ part,
670
+ sinkName,
671
+ compatRenderToStringHelperName,
672
+ indent,
673
+ );
674
+ }
675
+
676
+ return `${indent}await ${part.name}(${sinkName}, ${emitPropsObject(part.props, part.children, part.escapeHelperName)});`;
677
+ }
678
+
679
+ if (part.kind === "react-node") {
680
+ return `${indent}${sinkName}.append(${compatRenderToStringHelperName}(() => (${part.code})));`;
681
+ }
682
+
683
+ if (part.kind === "stream-node") {
684
+ return `${indent}await ${currentStreamNodeHelperName}(${sinkName}, (${part.code}), ${part.escapeHelperName});`;
685
+ }
686
+
687
+ if (part.kind === "list") {
688
+ return emitListPart(part, sinkName, compatRenderToStringHelperName, indent);
689
+ }
690
+
691
+ if (part.kind === "dynamic" && looksLikeRawJsxExpression(part.code)) {
692
+ return emitDynamicHtmlAppendStatement(
693
+ part.code,
694
+ sinkName,
695
+ part.escapeHelperName,
696
+ compatRenderToStringHelperName,
697
+ indent,
698
+ );
699
+ }
700
+
701
+ const expression =
702
+ part.kind === "static"
703
+ ? stringLiteral(part.value)
704
+ : part.kind === "dynamic"
705
+ ? `${part.escapeHelperName}(${part.code})`
706
+ : part.code;
707
+
708
+ return `${indent}${sinkName}.append(${expression});`;
709
+ }
710
+
711
+ function emitListPart(
712
+ part: Extract<HtmlPart, { kind: "list" }>,
713
+ sinkName: string,
714
+ compatRenderToStringHelperName: string,
715
+ indent: string,
716
+ ): string {
717
+ const innerIndent = indent + " ";
718
+ const itemBinding = `${innerIndent}const ${part.itemName} = _arr[_i];`;
719
+ const indexBinding =
720
+ part.indexName === undefined ? undefined : `${innerIndent}const ${part.indexName} = _i;`;
721
+ const bodyLines = part.bodyStatements.map(
722
+ (statement) => `${innerIndent}${statement}`,
723
+ );
724
+ const coalescedParts = coalesceAdjacentStaticParts(part.parts);
725
+
726
+ // Issue 085 follow-up: if every child part can be expressed as a
727
+ // pure string expression (no `sink.append`/`await` required), build
728
+ // up a local ConsString accumulator and emit a single
729
+ // `sink.append(_listOut)` at the end of the iteration. This matches
730
+ // the string backend's `_out +=` pattern, which V8 turns into a
731
+ // shallow cons-string tree (~3 ns per append). Otherwise (the list
732
+ // contains components / nested lists with components / etc) fall
733
+ // back to per-part `sink.append` inside the loop.
734
+ const syncCoalescedParts = coalescedParts.every(isHtmlSyncPart) ? coalescedParts : undefined;
735
+ const stringExpressions =
736
+ syncCoalescedParts?.map((child) =>
737
+ tryEmitPartAsStringExpression(child, compatRenderToStringHelperName),
738
+ ) ?? [];
739
+ const allStringSafe =
740
+ syncCoalescedParts !== undefined && stringExpressions.every((expr) => expr !== undefined);
741
+
742
+ if (allStringSafe) {
743
+ const accumulatorName = "_listOut";
744
+ const concatLines = stringExpressions.map(
745
+ (expr) => `${innerIndent}${accumulatorName} += ${expr};`,
746
+ );
747
+ return [
748
+ `${indent}{`,
749
+ `${indent} const _arr = (${part.itemsCode});`,
750
+ `${indent} let ${accumulatorName} = "";`,
751
+ `${indent} for (let _i = 0, _len = _arr.length; _i < _len; _i++) {`,
752
+ itemBinding,
753
+ ...(indexBinding === undefined ? [] : [indexBinding]),
754
+ ...bodyLines,
755
+ ...concatLines,
756
+ `${indent} }`,
757
+ `${indent} ${sinkName}.append(${accumulatorName});`,
758
+ `${indent}}`,
759
+ ].join("\n");
760
+ }
761
+
762
+ const childLines =
763
+ syncCoalescedParts === undefined
764
+ ? [
765
+ emitNestedStreamAppendStatements(
766
+ coalescedParts,
767
+ sinkName,
768
+ compatRenderToStringHelperName,
769
+ ),
770
+ ]
771
+ : syncCoalescedParts.map((child) =>
772
+ emitSyncPartAsAppendStatement(
773
+ child,
774
+ sinkName,
775
+ compatRenderToStringHelperName,
776
+ innerIndent,
777
+ ),
778
+ );
779
+
780
+ return [
781
+ `${indent}{`,
782
+ `${indent} const _arr = (${part.itemsCode});`,
783
+ `${indent} for (let _i = 0, _len = _arr.length; _i < _len; _i++) {`,
784
+ itemBinding,
785
+ ...(indexBinding === undefined ? [] : [indexBinding]),
786
+ ...bodyLines,
787
+ ...childLines,
788
+ `${indent} }`,
789
+ `${indent}}`,
790
+ ].join("\n");
791
+ }
792
+
793
+ // Returns a string-typed expression for `part` if it can be evaluated
794
+ // synchronously without writing to the sink, otherwise undefined.
795
+ // Used by `emitListPart` to choose between the cons-string accumulator
796
+ // path and the per-part `sink.append` path.
797
+ function tryEmitPartAsStringExpression(
798
+ part: HtmlSyncPart,
799
+ compatRenderToStringHelperName: string,
800
+ ): string | undefined {
801
+ if (part.kind === "static") return stringLiteral(part.value);
802
+ if (part.kind === "dynamic") {
803
+ return looksLikeRawJsxExpression(part.code)
804
+ ? undefined
805
+ : `${part.escapeHelperName}(${part.code})`;
806
+ }
807
+ if (part.kind === "raw-dynamic") return `(${part.code})`;
808
+ if (part.kind === "react-node") {
809
+ return `${compatRenderToStringHelperName}(() => (${part.code}))`;
810
+ }
811
+ if (part.kind === "stream-node") {
812
+ return undefined;
813
+ }
814
+ if (part.kind === "list" && part.parts.every(isHtmlSyncPart)) {
815
+ return emitListPartAsStringExpression(part, compatRenderToStringHelperName);
816
+ }
817
+ if (part.kind === "component" && part.runtime === "compat") {
818
+ const rendered = `${compatRenderToStringHelperName}(${part.name}, ${emitPropsObject(part.props, part.children, part.escapeHelperName)})`;
819
+ if (part.hydrationId === undefined) {
820
+ return rendered;
821
+ }
822
+
823
+ return `${stringLiteral(`<!--mreact-h:start:${encodeURIComponent(part.hydrationId)}-->`)} + ${rendered} + ${stringLiteral(`<!--mreact-h:end:${encodeURIComponent(part.hydrationId)}-->`)}`;
824
+ }
825
+ // Non-compat component parts require `await sink-write`; lists with
826
+ // sink-needing children also can't collapse. Signal fallback.
827
+ return undefined;
828
+ }
829
+
830
+ function emitListPartAsStringExpression(
831
+ part: Extract<HtmlPart, { kind: "list" }>,
832
+ compatRenderToStringHelperName: string,
833
+ ): string | undefined {
834
+ const coalescedParts = coalesceAdjacentStaticParts(part.parts);
835
+ if (!coalescedParts.every(isHtmlSyncPart)) {
836
+ return undefined;
837
+ }
838
+
839
+ const stringExpressions = coalescedParts.map((child) =>
840
+ tryEmitPartAsStringExpression(child, compatRenderToStringHelperName),
841
+ );
842
+
843
+ if (stringExpressions.some((expr) => expr === undefined)) {
844
+ return undefined;
845
+ }
846
+
847
+ const concatLines = stringExpressions.map((expr) => `_listOut += ${expr};`);
848
+ return `(() => { const _arr = (${part.itemsCode}); let _listOut = ""; for (let _i = 0, _len = _arr.length; _i < _len; _i++) { const ${part.itemName} = _arr[_i];${part.indexName === undefined ? "" : ` const ${part.indexName} = _i;`}${part.bodyStatements.length === 0 ? "" : ` ${part.bodyStatements.join(" ")}`} ${concatLines.join(" ")} } return _listOut; })()`;
849
+ }
850
+
851
+ function emitAsyncBoundary(
852
+ part: Extract<HtmlPart, { kind: "async-boundary" }>,
853
+ sinkName: string,
854
+ asyncBoundaryHelperName: string,
855
+ compatRenderToStringHelperName: string,
856
+ ): string {
857
+ const optionFields: string[] = [];
858
+
859
+ if (part.catchName !== undefined && part.catchParts !== undefined) {
860
+ optionFields.push(
861
+ `catch: (${sinkName}, ${part.catchName}) => {\n${emitNestedAppendStatements(part.catchParts, sinkName, compatRenderToStringHelperName)}\n }`,
862
+ );
863
+ }
864
+
865
+ if (part.awaitId !== undefined) {
866
+ optionFields.push(`hydrationAwaitId: ${JSON.stringify(part.awaitId)}`);
867
+ }
868
+
869
+ const optionsExpression = optionFields.length === 0
870
+ ? ""
871
+ : `, { ${optionFields.join(", ")} }`;
872
+
873
+ return [
874
+ ` await ${asyncBoundaryHelperName}(${sinkName}, (${part.valueCode}), async (${sinkName}, ${part.valueName}) => {`,
875
+ emitNestedAppendStatements(part.parts, sinkName, compatRenderToStringHelperName),
876
+ ` }${optionsExpression});`,
877
+ ].join("\n");
878
+ }
879
+
880
+ function emitOutOfOrderBoundary(
881
+ part: Extract<HtmlPart, { kind: "out-of-order-boundary" }>,
882
+ sinkName: string,
883
+ outOfOrderBoundaryHelperName: string,
884
+ compatRenderToStringHelperName: string,
885
+ ): string {
886
+ const catchOption =
887
+ part.catchName === undefined || part.catchParts === undefined
888
+ ? ""
889
+ : `,\n catch: (${sinkName}, ${part.catchName}) => {\n${emitNestedAppendStatements(part.catchParts, sinkName, compatRenderToStringHelperName)}\n }`;
890
+
891
+ const hydrationAwaitIdOption =
892
+ part.awaitId === undefined
893
+ ? ""
894
+ : `,\n hydrationAwaitId: ${JSON.stringify(part.awaitId)}`;
895
+ const placeholderTagOption =
896
+ part.placeholderTagCode === undefined
897
+ ? ""
898
+ : `,\n placeholderTag: (${part.placeholderTagCode})`;
899
+
900
+ return [
901
+ ` ${outOfOrderBoundaryHelperName}(${sinkName}, ${JSON.stringify(part.id)}, (${part.valueCode}), async (${sinkName}, ${part.valueName}) => {`,
902
+ emitNestedAppendStatements(part.parts, sinkName, compatRenderToStringHelperName),
903
+ ` }, {`,
904
+ ...(part.hydration ? [` hydration: true,`] : []),
905
+ ` placeholder: (${sinkName}) => {`,
906
+ emitNestedAppendStatements(part.placeholderParts, sinkName, compatRenderToStringHelperName),
907
+ ` }${catchOption}${hydrationAwaitIdOption}${placeholderTagOption}`,
908
+ ` });`,
909
+ ].join("\n");
910
+ }
911
+
912
+ function emitReactSuspenseBoundary(
913
+ part: Extract<HtmlPart, { kind: "react-suspense-boundary" }>,
914
+ sinkName: string,
915
+ reactSuspenseBoundaryHelperName: string,
916
+ compatRenderToStringHelperName: string,
917
+ ): string {
918
+ return [
919
+ ` await ${reactSuspenseBoundaryHelperName}(${sinkName}, async (${sinkName}) => {`,
920
+ emitNestedAppendStatements(part.parts, sinkName, compatRenderToStringHelperName),
921
+ ` });`,
922
+ ].join("\n");
923
+ }
924
+
925
+ function emitReactSuspenseOutOfOrderBoundary(
926
+ part: Extract<HtmlPart, { kind: "react-suspense-out-of-order-boundary" }>,
927
+ sinkName: string,
928
+ reactSuspenseOutOfOrderBoundaryHelperName: string,
929
+ compatRenderToStringHelperName: string,
930
+ ): string {
931
+ const options = [
932
+ ` fallback: (${sinkName}) => {`,
933
+ emitNestedAppendStatements(part.fallbackParts, sinkName, compatRenderToStringHelperName),
934
+ ` },`,
935
+ ...(part.catchName === undefined || part.catchParts === undefined
936
+ ? []
937
+ : [
938
+ ` catch: (${sinkName}, ${part.catchName}) => {`,
939
+ emitNestedAppendStatements(part.catchParts, sinkName, compatRenderToStringHelperName),
940
+ ` },`,
941
+ ]),
942
+ ...(part.nonce === undefined ? [] : [` nonce: ${stringLiteral(part.nonce)},`]),
943
+ ...(part.scriptSrc === undefined ? [] : [` src: ${stringLiteral(part.scriptSrc)},`]),
944
+ ];
945
+
946
+ return [
947
+ ` ${reactSuspenseOutOfOrderBoundaryHelperName}(${sinkName}, ${JSON.stringify(part.boundaryId)}, ${JSON.stringify(part.segmentId)}, (${part.valueCode}), async (${sinkName}, ${part.valueName}) => {`,
948
+ emitNestedAppendStatements(part.parts, sinkName, compatRenderToStringHelperName),
949
+ ` }, {`,
950
+ ...options,
951
+ ` });`,
952
+ ].join("\n");
953
+ }
954
+
955
+ function emitNestedAppendStatements(
956
+ parts: HtmlSyncPart[],
957
+ sinkName: string,
958
+ compatRenderToStringHelperName: string,
959
+ ): string {
960
+ return coalesceAdjacentStaticParts(parts)
961
+ .map((part) => emitSyncPartAsAppendStatement(part, sinkName, compatRenderToStringHelperName, " "))
962
+ .join("\n");
963
+ }
964
+
965
+ function emitNestedStreamAppendStatements(
966
+ parts: HtmlPart[],
967
+ sinkName: string,
968
+ compatRenderToStringHelperName: string,
969
+ ): string {
970
+ return coalesceAdjacentStaticParts(parts)
971
+ .map((part) => {
972
+ if (part.kind === "async-boundary") {
973
+ return emitAsyncBoundary(
974
+ part,
975
+ sinkName,
976
+ currentAsyncBoundaryHelperName,
977
+ compatRenderToStringHelperName,
978
+ ).replace(/^/gm, " ");
979
+ }
980
+
981
+ if (part.kind === "out-of-order-boundary") {
982
+ return emitOutOfOrderBoundary(
983
+ part,
984
+ sinkName,
985
+ currentOutOfOrderBoundaryHelperName,
986
+ compatRenderToStringHelperName,
987
+ ).replace(/^/gm, " ");
988
+ }
989
+
990
+ if (part.kind === "react-suspense-boundary") {
991
+ return emitReactSuspenseBoundary(
992
+ part,
993
+ sinkName,
994
+ currentReactSuspenseBoundaryHelperName,
995
+ compatRenderToStringHelperName,
996
+ ).replace(/^/gm, " ");
997
+ }
998
+
999
+ if (part.kind === "react-suspense-out-of-order-boundary") {
1000
+ return emitReactSuspenseOutOfOrderBoundary(
1001
+ part,
1002
+ sinkName,
1003
+ currentReactSuspenseOutOfOrderBoundaryHelperName,
1004
+ compatRenderToStringHelperName,
1005
+ ).replace(/^/gm, " ");
1006
+ }
1007
+
1008
+ return emitSyncPartAsAppendStatement(
1009
+ part,
1010
+ sinkName,
1011
+ compatRenderToStringHelperName,
1012
+ " ",
1013
+ );
1014
+ })
1015
+ .join("\n");
1016
+ }
1017
+
1018
+ function emitCompatComponentAppendStatements(
1019
+ part: Extract<HtmlPart, { kind: "component" }>,
1020
+ sinkName: string,
1021
+ compatRenderToStringHelperName: string,
1022
+ indent: string,
1023
+ ): string {
1024
+ const rendered = `${compatRenderToStringHelperName}(${part.name}, ${emitPropsObject(part.props, part.children, part.escapeHelperName)})`;
1025
+ const statements =
1026
+ part.hydrationId === undefined
1027
+ ? [`${sinkName}.append(${rendered});`]
1028
+ : [
1029
+ `${sinkName}.append(${stringLiteral(`<!--mreact-h:start:${encodeURIComponent(part.hydrationId)}-->`)});`,
1030
+ `${sinkName}.append(${rendered});`,
1031
+ `${sinkName}.append(${stringLiteral(`<!--mreact-h:end:${encodeURIComponent(part.hydrationId)}-->`)});`,
1032
+ ];
1033
+
1034
+ return statements.map((statement) => `${indent}${statement}`).join("\n");
1035
+ }
1036
+
1037
+ type HtmlPart =
1038
+ | {
1039
+ kind: "static";
1040
+ value: string;
1041
+ }
1042
+ | {
1043
+ kind: "dynamic";
1044
+ code: string;
1045
+ escapeHelperName: string;
1046
+ }
1047
+ | {
1048
+ kind: "raw-dynamic";
1049
+ code: string;
1050
+ }
1051
+ | {
1052
+ kind: "react-node";
1053
+ code: string;
1054
+ }
1055
+ | {
1056
+ kind: "stream-node";
1057
+ code: string;
1058
+ escapeHelperName: string;
1059
+ }
1060
+ | {
1061
+ kind: "async-boundary";
1062
+ valueCode: string;
1063
+ valueName: string;
1064
+ parts: HtmlSyncPart[];
1065
+ catchName?: string;
1066
+ catchParts?: HtmlSyncPart[];
1067
+ awaitId?: string;
1068
+ }
1069
+ | {
1070
+ kind: "out-of-order-boundary";
1071
+ id: string;
1072
+ hydration: boolean;
1073
+ valueCode: string;
1074
+ valueName: string;
1075
+ parts: HtmlSyncPart[];
1076
+ placeholderParts: HtmlSyncPart[];
1077
+ placeholderTagCode?: string;
1078
+ catchName?: string;
1079
+ catchParts?: HtmlSyncPart[];
1080
+ awaitId?: string;
1081
+ }
1082
+ | {
1083
+ kind: "react-suspense-boundary";
1084
+ parts: HtmlSyncPart[];
1085
+ }
1086
+ | {
1087
+ kind: "react-suspense-out-of-order-boundary";
1088
+ boundaryId: string;
1089
+ segmentId: string;
1090
+ valueCode: string;
1091
+ valueName: string;
1092
+ parts: HtmlSyncPart[];
1093
+ fallbackParts: HtmlSyncPart[];
1094
+ catchName?: string;
1095
+ catchParts?: HtmlSyncPart[];
1096
+ nonce?: string;
1097
+ scriptSrc?: string;
1098
+ }
1099
+ | {
1100
+ kind: "component";
1101
+ name: string;
1102
+ runtime?: "compat";
1103
+ async?: boolean;
1104
+ hydrationId?: string;
1105
+ props: ComponentPropIr[];
1106
+ children: JsxNodeIr[];
1107
+ escapeHelperName: string;
1108
+ }
1109
+ | {
1110
+ // Issue 085: list direct streaming. The list iterates
1111
+ // `itemsCode`, runs `bodyStatements` and then emits each inner
1112
+ // part per iteration. Sync-only lists still use the string
1113
+ // accumulator fast path; lists that contain async/oob/Suspense
1114
+ // boundaries keep those boundary parts visible to the stream
1115
+ // emitter instead of falling back to a raw `.map().join("")`.
1116
+ kind: "list";
1117
+ itemsCode: string;
1118
+ itemName: string;
1119
+ indexName?: string;
1120
+ bodyStatements: string[];
1121
+ parts: HtmlPart[];
1122
+ };
1123
+
1124
+ type HtmlSyncPart = Exclude<
1125
+ HtmlPart,
1126
+ {
1127
+ kind:
1128
+ | "async-boundary"
1129
+ | "out-of-order-boundary"
1130
+ | "react-suspense-boundary"
1131
+ | "react-suspense-out-of-order-boundary";
1132
+ }
1133
+ >;
1134
+
1135
+ interface CollectHtmlState {
1136
+ dynamicAttributes: "drop" | "emit";
1137
+ escapeBatchHelperName?: string;
1138
+ hydration: boolean;
1139
+ awaitHydration: boolean;
1140
+ nextFragmentId: number;
1141
+ reactSuspenseRevealScriptNonce?: string;
1142
+ reactSuspenseRevealScriptSrc?: string;
1143
+ selectedValueCode?: string;
1144
+ }
1145
+
1146
+ function collectHtmlParts(
1147
+ node: JsxNodeIr,
1148
+ escapeHelperName: string,
1149
+ asyncBoundaryHelperName: string,
1150
+ outOfOrderBoundaryHelperName: string,
1151
+ reactSuspenseBoundaryHelperName: string,
1152
+ reactSuspenseOutOfOrderBoundaryHelperName: string,
1153
+ state: CollectHtmlState,
1154
+ ): HtmlPart[] {
1155
+ void asyncBoundaryHelperName;
1156
+ void outOfOrderBoundaryHelperName;
1157
+ void reactSuspenseBoundaryHelperName;
1158
+ void reactSuspenseOutOfOrderBoundaryHelperName;
1159
+
1160
+ if (node.kind === "text") {
1161
+ return [{ kind: "static", value: escapeHtml(node.value) }];
1162
+ }
1163
+
1164
+ if (node.kind === "expr") {
1165
+ if (node.renderMode === "html" && isChildrenExpressionCode(node.code)) {
1166
+ return [{ kind: "stream-node", code: node.code, escapeHelperName }];
1167
+ }
1168
+
1169
+ if (node.renderMode === "html") {
1170
+ return [{ kind: "raw-dynamic", code: rawHtmlExpression(node.code) }];
1171
+ }
1172
+
1173
+ if (node.renderMode === "react-node") {
1174
+ return [{ kind: "react-node", code: node.code }];
1175
+ }
1176
+
1177
+ if (node.renderMode === "stream-node") {
1178
+ return [{ kind: "stream-node", code: node.code, escapeHelperName }];
1179
+ }
1180
+
1181
+ return [{ kind: "dynamic", code: node.code, escapeHelperName }];
1182
+ }
1183
+
1184
+ if (node.kind === "conditional") {
1185
+ return [
1186
+ {
1187
+ kind: "raw-dynamic",
1188
+ code: `((${node.conditionCode}) ? ${emitHtmlExpressionFromChildren(node.whenTrue, escapeHelperName)} : ${emitHtmlExpressionFromChildren(node.whenFalse, escapeHelperName)})`,
1189
+ },
1190
+ ];
1191
+ }
1192
+
1193
+ if (node.kind === "list") {
1194
+ // Keep mapped children in the stream emitter so direct `<Await>`
1195
+ // boundaries inside list renderers stay visible to out-of-order
1196
+ // lowering.
1197
+ const collectedChildParts: HtmlPart[] = node.children.flatMap((child) =>
1198
+ collectHtmlParts(
1199
+ child,
1200
+ escapeHelperName,
1201
+ asyncBoundaryHelperName,
1202
+ outOfOrderBoundaryHelperName,
1203
+ reactSuspenseBoundaryHelperName,
1204
+ reactSuspenseOutOfOrderBoundaryHelperName,
1205
+ state,
1206
+ ),
1207
+ );
1208
+
1209
+ return [
1210
+ {
1211
+ kind: "list",
1212
+ itemsCode: node.itemsCode,
1213
+ itemName: node.itemName,
1214
+ ...(node.indexName === undefined ? {} : { indexName: node.indexName }),
1215
+ bodyStatements: node.bodyStatements ?? [],
1216
+ parts: collectedChildParts,
1217
+ },
1218
+ ];
1219
+ }
1220
+
1221
+ if (node.kind === "async-boundary") {
1222
+ if (node.placeholderChildren !== undefined) {
1223
+ const id = `mreact-${state.nextFragmentId}`;
1224
+ state.nextFragmentId += 1;
1225
+
1226
+ return [
1227
+ {
1228
+ kind: "out-of-order-boundary",
1229
+ id,
1230
+ hydration: state.hydration,
1231
+ valueCode: node.valueCode,
1232
+ valueName: node.valueName,
1233
+ parts: node.children.flatMap((child) =>
1234
+ collectHtmlParts(
1235
+ child,
1236
+ escapeHelperName,
1237
+ asyncBoundaryHelperName,
1238
+ outOfOrderBoundaryHelperName,
1239
+ reactSuspenseBoundaryHelperName,
1240
+ reactSuspenseOutOfOrderBoundaryHelperName,
1241
+ state,
1242
+ ),
1243
+ ) as HtmlSyncPart[],
1244
+ placeholderParts: node.placeholderChildren.flatMap((child) =>
1245
+ collectHtmlParts(
1246
+ child,
1247
+ escapeHelperName,
1248
+ asyncBoundaryHelperName,
1249
+ outOfOrderBoundaryHelperName,
1250
+ reactSuspenseBoundaryHelperName,
1251
+ reactSuspenseOutOfOrderBoundaryHelperName,
1252
+ state,
1253
+ ),
1254
+ ) as HtmlSyncPart[],
1255
+ ...(node.placeholderTagCode === undefined
1256
+ ? {}
1257
+ : { placeholderTagCode: node.placeholderTagCode }),
1258
+ ...(state.awaitHydration && node.awaitId !== undefined
1259
+ ? { awaitId: node.awaitId }
1260
+ : {}),
1261
+ ...(node.catchName === undefined || node.catchChildren === undefined
1262
+ ? {}
1263
+ : {
1264
+ catchName: node.catchName,
1265
+ catchParts: node.catchChildren.flatMap((child) =>
1266
+ collectHtmlParts(
1267
+ child,
1268
+ escapeHelperName,
1269
+ asyncBoundaryHelperName,
1270
+ outOfOrderBoundaryHelperName,
1271
+ reactSuspenseBoundaryHelperName,
1272
+ reactSuspenseOutOfOrderBoundaryHelperName,
1273
+ state,
1274
+ ),
1275
+ ) as HtmlSyncPart[],
1276
+ }),
1277
+ },
1278
+ ];
1279
+ }
1280
+
1281
+ return [
1282
+ {
1283
+ kind: "async-boundary",
1284
+ valueCode: node.valueCode,
1285
+ valueName: node.valueName,
1286
+ parts: node.children.flatMap((child) =>
1287
+ collectHtmlParts(
1288
+ child,
1289
+ escapeHelperName,
1290
+ asyncBoundaryHelperName,
1291
+ outOfOrderBoundaryHelperName,
1292
+ reactSuspenseBoundaryHelperName,
1293
+ reactSuspenseOutOfOrderBoundaryHelperName,
1294
+ state,
1295
+ ),
1296
+ ) as HtmlSyncPart[],
1297
+ ...(state.awaitHydration && node.awaitId !== undefined
1298
+ ? { awaitId: node.awaitId }
1299
+ : {}),
1300
+ ...(node.catchName === undefined || node.catchChildren === undefined
1301
+ ? {}
1302
+ : {
1303
+ catchName: node.catchName,
1304
+ catchParts: node.catchChildren.flatMap((child) =>
1305
+ collectHtmlParts(
1306
+ child,
1307
+ escapeHelperName,
1308
+ asyncBoundaryHelperName,
1309
+ outOfOrderBoundaryHelperName,
1310
+ reactSuspenseBoundaryHelperName,
1311
+ reactSuspenseOutOfOrderBoundaryHelperName,
1312
+ state,
1313
+ ),
1314
+ ) as HtmlSyncPart[],
1315
+ }),
1316
+ },
1317
+ ];
1318
+ }
1319
+
1320
+ if (node.kind === "fragment") {
1321
+ return node.children.flatMap((child) =>
1322
+ collectHtmlParts(
1323
+ child,
1324
+ escapeHelperName,
1325
+ asyncBoundaryHelperName,
1326
+ outOfOrderBoundaryHelperName,
1327
+ reactSuspenseBoundaryHelperName,
1328
+ reactSuspenseOutOfOrderBoundaryHelperName,
1329
+ state,
1330
+ ),
1331
+ );
1332
+ }
1333
+
1334
+ if (node.kind === "component") {
1335
+ if (node.name === "Suspense") {
1336
+ const asyncBoundary = findSuspenseAsyncBoundary(node.children);
1337
+
1338
+ if (asyncBoundary !== undefined) {
1339
+ const id = state.nextFragmentId;
1340
+ state.nextFragmentId += 1;
1341
+
1342
+ return [
1343
+ {
1344
+ kind: "react-suspense-out-of-order-boundary",
1345
+ boundaryId: `B:${id}`,
1346
+ segmentId: `S:${id}`,
1347
+ valueCode: asyncBoundary.valueCode,
1348
+ valueName: asyncBoundary.valueName,
1349
+ parts: replaceSuspenseAsyncBoundary(
1350
+ node.children,
1351
+ asyncBoundary,
1352
+ asyncBoundary.children,
1353
+ ).flatMap((child) =>
1354
+ collectHtmlParts(
1355
+ child,
1356
+ escapeHelperName,
1357
+ asyncBoundaryHelperName,
1358
+ outOfOrderBoundaryHelperName,
1359
+ reactSuspenseBoundaryHelperName,
1360
+ reactSuspenseOutOfOrderBoundaryHelperName,
1361
+ state,
1362
+ ),
1363
+ ) as HtmlSyncPart[],
1364
+ fallbackParts: collectSuspenseFallbackParts(
1365
+ node.props,
1366
+ escapeHelperName,
1367
+ asyncBoundaryHelperName,
1368
+ outOfOrderBoundaryHelperName,
1369
+ reactSuspenseBoundaryHelperName,
1370
+ reactSuspenseOutOfOrderBoundaryHelperName,
1371
+ state,
1372
+ ),
1373
+ ...(state.reactSuspenseRevealScriptNonce === undefined
1374
+ ? {}
1375
+ : { nonce: state.reactSuspenseRevealScriptNonce }),
1376
+ ...(state.reactSuspenseRevealScriptSrc === undefined
1377
+ ? {}
1378
+ : { scriptSrc: state.reactSuspenseRevealScriptSrc }),
1379
+ ...(asyncBoundary.catchName === undefined || asyncBoundary.catchChildren === undefined
1380
+ ? {}
1381
+ : {
1382
+ catchName: asyncBoundary.catchName,
1383
+ catchParts: replaceSuspenseAsyncBoundary(
1384
+ node.children,
1385
+ asyncBoundary,
1386
+ asyncBoundary.catchChildren,
1387
+ ).flatMap((child) =>
1388
+ collectHtmlParts(
1389
+ child,
1390
+ escapeHelperName,
1391
+ asyncBoundaryHelperName,
1392
+ outOfOrderBoundaryHelperName,
1393
+ reactSuspenseBoundaryHelperName,
1394
+ reactSuspenseOutOfOrderBoundaryHelperName,
1395
+ state,
1396
+ ),
1397
+ ) as HtmlSyncPart[],
1398
+ }),
1399
+ },
1400
+ ];
1401
+ }
1402
+
1403
+ if (containsAsyncComponent(node.children)) {
1404
+ const id = state.nextFragmentId;
1405
+ state.nextFragmentId += 1;
1406
+
1407
+ return [
1408
+ {
1409
+ kind: "react-suspense-out-of-order-boundary",
1410
+ boundaryId: `B:${id}`,
1411
+ segmentId: `S:${id}`,
1412
+ valueCode: "undefined",
1413
+ valueName: "_",
1414
+ parts: node.children.flatMap((child) =>
1415
+ collectHtmlParts(
1416
+ child,
1417
+ escapeHelperName,
1418
+ asyncBoundaryHelperName,
1419
+ outOfOrderBoundaryHelperName,
1420
+ reactSuspenseBoundaryHelperName,
1421
+ reactSuspenseOutOfOrderBoundaryHelperName,
1422
+ state,
1423
+ ),
1424
+ ) as HtmlSyncPart[],
1425
+ fallbackParts: collectSuspenseFallbackParts(
1426
+ node.props,
1427
+ escapeHelperName,
1428
+ asyncBoundaryHelperName,
1429
+ outOfOrderBoundaryHelperName,
1430
+ reactSuspenseBoundaryHelperName,
1431
+ reactSuspenseOutOfOrderBoundaryHelperName,
1432
+ state,
1433
+ ),
1434
+ ...(state.reactSuspenseRevealScriptNonce === undefined
1435
+ ? {}
1436
+ : { nonce: state.reactSuspenseRevealScriptNonce }),
1437
+ ...(state.reactSuspenseRevealScriptSrc === undefined
1438
+ ? {}
1439
+ : { scriptSrc: state.reactSuspenseRevealScriptSrc }),
1440
+ },
1441
+ ];
1442
+ }
1443
+
1444
+ return [
1445
+ {
1446
+ kind: "react-suspense-boundary",
1447
+ parts: node.children.flatMap((child) =>
1448
+ collectHtmlParts(
1449
+ child,
1450
+ escapeHelperName,
1451
+ asyncBoundaryHelperName,
1452
+ outOfOrderBoundaryHelperName,
1453
+ reactSuspenseBoundaryHelperName,
1454
+ reactSuspenseOutOfOrderBoundaryHelperName,
1455
+ state,
1456
+ ),
1457
+ ) as HtmlSyncPart[],
1458
+ },
1459
+ ];
1460
+ }
1461
+
1462
+ if (isClientBoundaryPlaceholder(node)) {
1463
+ const helperName = currentClientBoundaryHelperName;
1464
+ if (helperName !== undefined) {
1465
+ return [
1466
+ {
1467
+ kind: "raw-dynamic",
1468
+ code: `${helperName}(${stringLiteral(node.name)}, ${emitPropsObject(
1469
+ node.props,
1470
+ node.children,
1471
+ escapeHelperName,
1472
+ )})`,
1473
+ },
1474
+ ];
1475
+ }
1476
+
1477
+ return [{ kind: "static", value: clientBoundaryPlaceholder(node) }];
1478
+ }
1479
+
1480
+ return [
1481
+ {
1482
+ kind: "component",
1483
+ name: node.name,
1484
+ ...(node.runtime === undefined ? {} : { runtime: node.runtime }),
1485
+ ...(node.async === undefined ? {} : { async: node.async }),
1486
+ ...(node.runtime === "compat" && state.hydration
1487
+ ? { hydrationId: `mreact-${state.nextFragmentId++}` }
1488
+ : {}),
1489
+ props: node.props,
1490
+ children: node.children,
1491
+ escapeHelperName,
1492
+ },
1493
+ ];
1494
+ }
1495
+
1496
+ const closeTag = `</${node.tagName}>`;
1497
+ if (node.tagName === "textarea") {
1498
+ const attributeScan = scanElementAttributes(node.tagName, node.attributes);
1499
+ return [
1500
+ { kind: "static", value: "<textarea" },
1501
+ ...collectElementAttributeParts(
1502
+ node.tagName,
1503
+ node.attributes,
1504
+ escapeHelperName,
1505
+ state,
1506
+ attributeScan,
1507
+ ),
1508
+ { kind: "static", value: ">" },
1509
+ ...collectTextareaValueParts(
1510
+ node,
1511
+ escapeHelperName,
1512
+ asyncBoundaryHelperName,
1513
+ outOfOrderBoundaryHelperName,
1514
+ reactSuspenseBoundaryHelperName,
1515
+ reactSuspenseOutOfOrderBoundaryHelperName,
1516
+ state,
1517
+ attributeScan,
1518
+ ),
1519
+ { kind: "static", value: closeTag },
1520
+ ];
1521
+ }
1522
+ const attributeScan = scanElementAttributes(node.tagName, node.attributes);
1523
+ const childSelectedValueCode = node.tagName === "select"
1524
+ ? attributeScan.formValueAttributeCode
1525
+ : undefined;
1526
+ const childState = childSelectedValueCode === undefined
1527
+ ? state
1528
+ : { ...state, selectedValueCode: childSelectedValueCode };
1529
+ const selectedAttributePart = collectOptionSelectedAttributePart(node, state.selectedValueCode);
1530
+ const dangerousInnerHtml = emitDangerouslySetInnerHtmlPart(node.attributes);
1531
+ const childrenParts =
1532
+ dangerousInnerHtml !== undefined
1533
+ ? [dangerousInnerHtml]
1534
+ : (childState.selectedValueCode === undefined
1535
+ ? collectBatchedSimpleChildrenParts(node.children, state.escapeBatchHelperName)
1536
+ : undefined) ??
1537
+ node.children.flatMap((child) =>
1538
+ collectHtmlParts(
1539
+ child,
1540
+ escapeHelperName,
1541
+ asyncBoundaryHelperName,
1542
+ outOfOrderBoundaryHelperName,
1543
+ reactSuspenseBoundaryHelperName,
1544
+ reactSuspenseOutOfOrderBoundaryHelperName,
1545
+ childState,
1546
+ ),
1547
+ );
1548
+
1549
+ return [
1550
+ { kind: "static", value: `<${node.tagName}` },
1551
+ ...collectElementAttributeParts(
1552
+ node.tagName,
1553
+ node.attributes,
1554
+ escapeHelperName,
1555
+ state,
1556
+ attributeScan,
1557
+ ),
1558
+ ...(selectedAttributePart === undefined ? [] : [selectedAttributePart]),
1559
+ { kind: "static", value: ">" },
1560
+ ...childrenParts,
1561
+ { kind: "static", value: closeTag },
1562
+ ];
1563
+ }
1564
+
1565
+ function emitDangerouslySetInnerHtmlPart(attrs: readonly AttributeIr[]): HtmlSyncPart | undefined {
1566
+ const attr = attrs.find(
1567
+ (candidate): candidate is Extract<AttributeIr, { kind: "dynamic-attr" }> =>
1568
+ candidate.kind === "dynamic-attr" && candidate.name === "dangerouslySetInnerHTML",
1569
+ );
1570
+
1571
+ if (attr === undefined) {
1572
+ return undefined;
1573
+ }
1574
+
1575
+ return {
1576
+ kind: "raw-dynamic",
1577
+ code: `(() => { const _value = (${attr.code}); return typeof _value === "object" && _value !== null && typeof _value.__html === "string" ? _value.__html : ""; })()`,
1578
+ };
1579
+ }
1580
+
1581
+ function isChildrenExpressionCode(code: string): boolean {
1582
+ const trimmed = code.trim();
1583
+ return (
1584
+ trimmed === "children" ||
1585
+ endsWithChildrenMemberAccess(trimmed) ||
1586
+ endsWithChildrenStringIndex(trimmed)
1587
+ );
1588
+ }
1589
+
1590
+ function endsWithChildrenMemberAccess(code: string): boolean {
1591
+ const propertyName = "children";
1592
+ if (!code.endsWith(propertyName)) {
1593
+ return false;
1594
+ }
1595
+
1596
+ return code[code.length - propertyName.length - 1] === ".";
1597
+ }
1598
+
1599
+ function endsWithChildrenStringIndex(code: string): boolean {
1600
+ return code.endsWith('["children"]') || code.endsWith("['children']");
1601
+ }
1602
+
1603
+ function collectHtmlAttributeParts(
1604
+ tagName: string,
1605
+ attr: AttributeIr,
1606
+ escapeHelperName: string,
1607
+ escapeBatchHelperName: string | undefined,
1608
+ dynamicAttributes: "drop" | "emit",
1609
+ ): HtmlSyncPart[] {
1610
+ if (attr.kind === "spread-attr") {
1611
+ return dynamicAttributes === "drop"
1612
+ ? []
1613
+ : [
1614
+ {
1615
+ kind: "raw-dynamic",
1616
+ code: `${currentSpreadAttributesHelperName}(${stringLiteral(tagName)}, (${attr.code}))`,
1617
+ },
1618
+ ];
1619
+ }
1620
+
1621
+ if (attr.kind === "event" || attr.name === "key" || attr.name === "dangerouslySetInnerHTML") {
1622
+ return [];
1623
+ }
1624
+
1625
+ if (attr.kind === "static-attr") {
1626
+ const htmlName = htmlAttributeNameForElement(tagName, attr.name);
1627
+ if (isUrlAttribute(htmlName) && isStaticUrlValueUnsafe(htmlName, attr.value)) {
1628
+ return [];
1629
+ }
1630
+ if (isDangerousHtmlAttribute(htmlName)) {
1631
+ // Issue 077: literal srcdoc strings cannot match the opt-in shape.
1632
+ return [];
1633
+ }
1634
+ return [
1635
+ {
1636
+ kind: "static",
1637
+ value: ` ${htmlName}="${escapeHtml(attr.value)}"`,
1638
+ },
1639
+ ];
1640
+ }
1641
+
1642
+ if (dynamicAttributes === "drop") {
1643
+ return [];
1644
+ }
1645
+
1646
+ if (attr.name === "style") {
1647
+ return [{ kind: "raw-dynamic", code: emitDynamicStyleAttributeExpression(attr.code, escapeHelperName, escapeBatchHelperName) }];
1648
+ }
1649
+
1650
+ const dynamicHtmlName = htmlAttributeNameForElement(tagName, attr.name);
1651
+ if (isDangerousHtmlAttribute(dynamicHtmlName)) {
1652
+ return [
1653
+ {
1654
+ kind: "raw-dynamic",
1655
+ code: `(() => { const _value = (${attr.code}); if (_value == null || _value === false) return ""; if (typeof _value === "object" && _value !== null && typeof _value.__html === "string") return ${stringLiteral(` ${dynamicHtmlName}="`)} + ${escapeHelperName}(_value.__html) + ${stringLiteral("\"")}; return ""; })()`,
1656
+ },
1657
+ ];
1658
+ }
1659
+
1660
+ return [
1661
+ {
1662
+ kind: "raw-dynamic",
1663
+ code: emitDynamicAttributeExpression(dynamicHtmlName, attr.code, escapeHelperName),
1664
+ },
1665
+ ];
1666
+ }
1667
+
1668
+ function collectElementAttributeParts(
1669
+ tagName: string,
1670
+ attrs: readonly AttributeIr[],
1671
+ escapeHelperName: string,
1672
+ state: CollectHtmlState,
1673
+ attributeScan = scanElementAttributes(tagName, attrs),
1674
+ ): HtmlSyncPart[] {
1675
+ const escapeBatchHelperName = state.escapeBatchHelperName;
1676
+
1677
+ return attrs.flatMap((attr) =>
1678
+ attr.kind !== "spread-attr" &&
1679
+ ((tagName === "input" &&
1680
+ ((attr.name === "defaultValue" && attributeScan.hasExplicitInputValue) ||
1681
+ (attr.name === "defaultChecked" && attributeScan.hasExplicitInputChecked))) ||
1682
+ ((tagName === "textarea" || tagName === "select") &&
1683
+ (attr.name === "value" || attr.name === "defaultValue")))
1684
+ ? []
1685
+ : collectHtmlAttributeParts(
1686
+ tagName,
1687
+ attr,
1688
+ escapeHelperName,
1689
+ escapeBatchHelperName,
1690
+ state.dynamicAttributes,
1691
+ ),
1692
+ );
1693
+ }
1694
+
1695
+ interface ElementAttributeScan {
1696
+ hasExplicitInputValue: boolean;
1697
+ hasExplicitInputChecked: boolean;
1698
+ formValueAttributeCode: string | undefined;
1699
+ }
1700
+
1701
+ function scanElementAttributes(
1702
+ tagName: string,
1703
+ attrs: readonly AttributeIr[],
1704
+ ): ElementAttributeScan {
1705
+ let hasExplicitInputValue = false;
1706
+ let hasExplicitInputChecked = false;
1707
+ let valueAttributeCode: string | undefined;
1708
+ let defaultValueAttributeCode: string | undefined;
1709
+
1710
+ for (const attr of attrs) {
1711
+ if (attr.kind === "spread-attr") {
1712
+ continue;
1713
+ }
1714
+
1715
+ if (tagName === "input") {
1716
+ if (attr.name === "value") {
1717
+ hasExplicitInputValue = true;
1718
+ } else if (attr.name === "checked") {
1719
+ hasExplicitInputChecked = true;
1720
+ }
1721
+ }
1722
+
1723
+ if ((tagName === "textarea" || tagName === "select") && attr.name === "value") {
1724
+ valueAttributeCode = readFormValueAttributeCode(attr);
1725
+ } else if (
1726
+ (tagName === "textarea" || tagName === "select") &&
1727
+ attr.name === "defaultValue"
1728
+ ) {
1729
+ defaultValueAttributeCode = readFormValueAttributeCode(attr);
1730
+ }
1731
+ }
1732
+
1733
+ return {
1734
+ hasExplicitInputValue,
1735
+ hasExplicitInputChecked,
1736
+ formValueAttributeCode: valueAttributeCode ?? defaultValueAttributeCode,
1737
+ };
1738
+ }
1739
+
1740
+ function readFormValueAttributeCode(
1741
+ attr: Exclude<AttributeIr, { kind: "spread-attr" }>,
1742
+ ): string | undefined {
1743
+ if (attr.kind === "event") {
1744
+ return undefined;
1745
+ }
1746
+
1747
+ return attr.kind === "static-attr" ? stringLiteral(attr.value) : `(${attr.code})`;
1748
+ }
1749
+
1750
+ function emitDynamicAttributeExpression(
1751
+ name: string,
1752
+ code: string,
1753
+ escapeHelperName: string,
1754
+ ): string {
1755
+ if (isUrlAttribute(name)) {
1756
+ return `(() => { const _value = (${code}); if (_value == null || _value === false) return ""; const _checked = ${currentUrlSafeHelperName}(${stringLiteral(name)}, _value === true ? "" : _value); return _checked === undefined ? "" : ${stringLiteral(` ${name}="`)} + ${escapeHelperName}(_checked) + ${stringLiteral("\"")}; })()`;
1757
+ }
1758
+
1759
+ const inlineExpr = simpleSideEffectFreeExpression(code);
1760
+
1761
+ if (inlineExpr !== undefined) {
1762
+ // Inline 3 evaluations to avoid per-attribute IIFE closure allocation.
1763
+ return `(${inlineExpr} == null || ${inlineExpr} === false ? "" : ${stringLiteral(` ${name}="`)} + ${escapeHelperName}(${inlineExpr} === true ? "" : ${inlineExpr}) + ${stringLiteral("\"")})`;
1764
+ }
1765
+
1766
+ return `(() => { const _value = (${code}); return _value == null || _value === false ? "" : ${stringLiteral(` ${name}="`)} + ${escapeHelperName}(_value === true ? "" : _value) + ${stringLiteral("\"")}; })()`;
1767
+ }
1768
+
1769
+ function emitDynamicStyleAttributeExpression(
1770
+ code: string,
1771
+ escapeHelperName: string,
1772
+ escapeBatchHelperName: string | undefined,
1773
+ ): string {
1774
+ const staticStyleExpression = emitStaticStyleObjectAttributeExpression(code, escapeHelperName);
1775
+
1776
+ if (staticStyleExpression !== undefined) {
1777
+ return staticStyleExpression;
1778
+ }
1779
+
1780
+ const escapedPair = escapeBatchHelperName === undefined
1781
+ ? `${escapeHelperName}(_cssName) + ":" + ${escapeHelperName}(_styleValue === true ? "" : _styleValue)`
1782
+ : `(() => { const _escaped = ${escapeBatchHelperName}([_cssName, _styleValue === true ? "" : _styleValue]); return _escaped[0] + ":" + _escaped[1]; })()`;
1783
+
1784
+ return `(() => { const _value = (${code}); if (_value == null || _value === false) return ""; if (typeof _value === "string") { const _style = ${escapeHelperName}(_value); return _style === "" ? "" : ${stringLiteral(" style=\"")} + _style + ${stringLiteral("\"")}; } const _style = Object.entries(_value).filter(([, _styleValue]) => _styleValue != null && _styleValue !== false).map(([_styleName, _styleValue]) => { const _cssName = String(_styleName).startsWith("--") ? String(_styleName) : String(_styleName).replace(/[A-Z]/g, (_char) => "-" + _char.toLowerCase()); return ${escapedPair}; }).join(";"); return _style === "" ? "" : ${stringLiteral(" style=\"")} + _style + ${stringLiteral("\"")}; })()`;
1785
+ }
1786
+
1787
+ function emitStaticStyleObjectAttributeExpression(
1788
+ code: string,
1789
+ escapeHelperName: string,
1790
+ ): string | undefined {
1791
+ const entries = parseStaticStyleObjectLiteral(code);
1792
+
1793
+ if (entries === undefined) {
1794
+ return undefined;
1795
+ }
1796
+
1797
+ if (entries.length === 0) {
1798
+ return `""`;
1799
+ }
1800
+
1801
+ const literalEntries = entries.map((entry) => ({
1802
+ cssName: entry.cssName,
1803
+ literal: parseStyleLiteralValue(entry.valueCode),
1804
+ }));
1805
+
1806
+ if (literalEntries.every((entry) => entry.literal !== undefined)) {
1807
+ const parts = literalEntries
1808
+ .filter((entry) => entry.literal !== null)
1809
+ .map((entry) => `${entry.cssName}:${escapeHtml(String(entry.literal))}`);
1810
+
1811
+ if (parts.length === 0) {
1812
+ return `""`;
1813
+ }
1814
+
1815
+ return stringLiteral(` style="${parts.join(";")}"`);
1816
+ }
1817
+
1818
+ const statements = entries.map((entry) =>
1819
+ `{ const _v = (${entry.valueCode}); if (_v != null && _v !== false) _style += (_style === "" ? "" : ";") + ${stringLiteral(`${entry.cssName}:`)} + ${escapeHelperName}(_v === true ? "" : _v); }`
1820
+ );
1821
+
1822
+ return `(() => { let _style = ""; ${statements.join(" ")} return _style === "" ? "" : ${stringLiteral(" style=\"")} + _style + ${stringLiteral("\"")}; })()`;
1823
+ }
1824
+
1825
+ function collectTextareaValueParts(
1826
+ node: Extract<JsxNodeIr, { kind: "element" }>,
1827
+ escapeHelperName: string,
1828
+ asyncBoundaryHelperName: string,
1829
+ outOfOrderBoundaryHelperName: string,
1830
+ reactSuspenseBoundaryHelperName: string,
1831
+ reactSuspenseOutOfOrderBoundaryHelperName: string,
1832
+ state: CollectHtmlState,
1833
+ attributeScan = scanElementAttributes(node.tagName, node.attributes),
1834
+ ): HtmlPart[] {
1835
+ const valueCode = attributeScan.formValueAttributeCode;
1836
+ if (valueCode !== undefined) {
1837
+ return [{ kind: "dynamic", code: valueCode, escapeHelperName }];
1838
+ }
1839
+
1840
+ return node.children.flatMap((child) =>
1841
+ collectHtmlParts(
1842
+ child,
1843
+ escapeHelperName,
1844
+ asyncBoundaryHelperName,
1845
+ outOfOrderBoundaryHelperName,
1846
+ reactSuspenseBoundaryHelperName,
1847
+ reactSuspenseOutOfOrderBoundaryHelperName,
1848
+ state,
1849
+ )
1850
+ );
1851
+ }
1852
+
1853
+ function collectOptionSelectedAttributePart(
1854
+ node: Extract<JsxNodeIr, { kind: "element" }>,
1855
+ selectedValueCode: string | undefined,
1856
+ ): HtmlSyncPart | undefined {
1857
+ if (selectedValueCode === undefined || node.tagName !== "option") {
1858
+ return undefined;
1859
+ }
1860
+
1861
+ const optionValueCode = findOptionValueCode(node);
1862
+ if (optionValueCode === undefined) {
1863
+ return undefined;
1864
+ }
1865
+
1866
+ return {
1867
+ kind: "raw-dynamic",
1868
+ code: `(() => { const _selected = (${selectedValueCode}); return _selected == null ? "" : String(_selected) === String(${optionValueCode}) ? ${stringLiteral(' selected=""')} : ""; })()`,
1869
+ };
1870
+ }
1871
+
1872
+ function findOptionValueCode(node: Extract<JsxNodeIr, { kind: "element" }>): string | undefined {
1873
+ const valueAttr = node.attributes.find((attr) => attr.kind !== "spread-attr" && attr.name === "value");
1874
+ if (valueAttr !== undefined && valueAttr.kind !== "event" && valueAttr.kind !== "spread-attr") {
1875
+ return valueAttr.kind === "static-attr" ? stringLiteral(valueAttr.value) : `(${valueAttr.code})`;
1876
+ }
1877
+
1878
+ return node.children.every((child) => child.kind === "text")
1879
+ ? stringLiteral(node.children.map((child) => child.value).join(""))
1880
+ : undefined;
1881
+ }
1882
+
1883
+ function htmlAttributeNameForElement(tagName: string, name: string): string {
1884
+ if (tagName === "input") {
1885
+ if (name === "defaultValue") {
1886
+ return "value";
1887
+ }
1888
+
1889
+ if (name === "defaultChecked") {
1890
+ return "checked";
1891
+ }
1892
+ }
1893
+
1894
+ return htmlAttributeName(name);
1895
+ }
1896
+
1897
+ function findSuspenseAsyncBoundary(children: readonly JsxNodeIr[]): AsyncBoundaryIr | undefined {
1898
+ for (const child of children) {
1899
+ if (child.kind === "async-boundary" && child.placeholderChildren === undefined) {
1900
+ return child;
1901
+ }
1902
+
1903
+ const nested =
1904
+ child.kind === "element" || child.kind === "fragment" || child.kind === "component"
1905
+ ? findSuspenseAsyncBoundary(child.children)
1906
+ : child.kind === "conditional"
1907
+ ? findSuspenseAsyncBoundary([...child.whenTrue, ...child.whenFalse])
1908
+ : child.kind === "list"
1909
+ ? findSuspenseAsyncBoundary(child.children)
1910
+ : undefined;
1911
+
1912
+ if (nested !== undefined) {
1913
+ return nested;
1914
+ }
1915
+ }
1916
+
1917
+ return undefined;
1918
+ }
1919
+
1920
+ function replaceSuspenseAsyncBoundary(
1921
+ children: readonly JsxNodeIr[],
1922
+ target: AsyncBoundaryIr,
1923
+ replacement: readonly JsxNodeIr[],
1924
+ ): JsxNodeIr[] {
1925
+ return children.flatMap((child): JsxNodeIr[] => {
1926
+ if (child === target) {
1927
+ return [...replacement];
1928
+ }
1929
+
1930
+ if (child.kind === "element") {
1931
+ return [
1932
+ {
1933
+ ...child,
1934
+ children: replaceSuspenseAsyncBoundary(child.children, target, replacement),
1935
+ },
1936
+ ];
1937
+ }
1938
+
1939
+ if (child.kind === "fragment") {
1940
+ return [
1941
+ {
1942
+ ...child,
1943
+ children: replaceSuspenseAsyncBoundary(child.children, target, replacement),
1944
+ },
1945
+ ];
1946
+ }
1947
+
1948
+ if (child.kind === "component") {
1949
+ return [
1950
+ {
1951
+ ...child,
1952
+ children: replaceSuspenseAsyncBoundary(child.children, target, replacement),
1953
+ },
1954
+ ];
1955
+ }
1956
+
1957
+ if (child.kind === "conditional") {
1958
+ return [
1959
+ {
1960
+ ...child,
1961
+ whenTrue: replaceSuspenseAsyncBoundary(child.whenTrue, target, replacement),
1962
+ whenFalse: replaceSuspenseAsyncBoundary(child.whenFalse, target, replacement),
1963
+ },
1964
+ ];
1965
+ }
1966
+
1967
+ if (child.kind === "list") {
1968
+ return [
1969
+ {
1970
+ ...child,
1971
+ children: replaceSuspenseAsyncBoundary(child.children, target, replacement),
1972
+ },
1973
+ ];
1974
+ }
1975
+
1976
+ return [child];
1977
+ });
1978
+ }
1979
+
1980
+ function collectSuspenseFallbackParts(
1981
+ props: readonly ComponentPropIr[],
1982
+ escapeHelperName: string,
1983
+ asyncBoundaryHelperName: string,
1984
+ outOfOrderBoundaryHelperName: string,
1985
+ reactSuspenseBoundaryHelperName: string,
1986
+ reactSuspenseOutOfOrderBoundaryHelperName: string,
1987
+ state: CollectHtmlState,
1988
+ ): HtmlSyncPart[] {
1989
+ for (const prop of props) {
1990
+ if (prop.kind === "render-prop" && prop.name === "fallback") {
1991
+ return prop.children.flatMap((child) =>
1992
+ collectHtmlParts(
1993
+ child,
1994
+ escapeHelperName,
1995
+ asyncBoundaryHelperName,
1996
+ outOfOrderBoundaryHelperName,
1997
+ reactSuspenseBoundaryHelperName,
1998
+ reactSuspenseOutOfOrderBoundaryHelperName,
1999
+ state,
2000
+ ),
2001
+ ) as HtmlSyncPart[];
2002
+ }
2003
+
2004
+ if (prop.kind === "prop" && prop.name === "fallback") {
2005
+ return [
2006
+ {
2007
+ kind: "dynamic",
2008
+ code: prop.code,
2009
+ escapeHelperName,
2010
+ },
2011
+ ];
2012
+ }
2013
+ }
2014
+
2015
+ return [];
2016
+ }
2017
+
2018
+ function rawHtmlExpression(code: string): string {
2019
+ return `(() => { const _render = (_value) => { if (_value == null) return ""; if (Array.isArray(_value)) return _value.map(_render).join(""); if (typeof _value === "object" && _value.$$typeof === Symbol.for("modular.react.element")) return ${currentCompatRenderToStringHelperName}(() => _value); return String(_value); }; return _render(${code}); })()`;
2020
+ }
2021
+
2022
+ function emitDynamicHtmlAppendStatement(
2023
+ code: string,
2024
+ sinkName: string,
2025
+ escapeHelperName: string,
2026
+ compatRenderToStringHelperName: string,
2027
+ indent: string,
2028
+ ): string {
2029
+ if (!looksLikeRawJsxExpression(code)) {
2030
+ return `${indent}${sinkName}.append(${escapeHelperName}(${code}));`;
2031
+ }
2032
+
2033
+ return [
2034
+ `${indent}{`,
2035
+ `${indent} const _appendDynamic = async (_value) => {`,
2036
+ `${indent} if (_value == null || _value === false) return;`,
2037
+ `${indent} if (Array.isArray(_value)) { for (const _item of _value) await _appendDynamic(_item); return; }`,
2038
+ `${indent} if (typeof _value === "object" && _value.$$typeof === Symbol.for("modular.react.element")) {`,
2039
+ `${indent} if (typeof _value.type === "function" && _value.type.length >= 2) {`,
2040
+ `${indent} await _value.type(${sinkName}, _value.props ?? {});`,
2041
+ `${indent} } else {`,
2042
+ `${indent} ${sinkName}.append(${compatRenderToStringHelperName}(() => _value));`,
2043
+ `${indent} }`,
2044
+ `${indent} return;`,
2045
+ `${indent} }`,
2046
+ `${indent} ${sinkName}.append(${escapeHelperName}(_value === true ? "" : _value));`,
2047
+ `${indent} };`,
2048
+ `${indent} await _appendDynamic(${code});`,
2049
+ `${indent}}`,
2050
+ ].join("\n");
2051
+ }
2052
+
2053
+ function looksLikeRawJsxExpression(code: string): boolean {
2054
+ return /<\s*(?:[A-Za-z]|>)/.test(code);
2055
+ }
2056
+
2057
+ function collectBatchedSimpleChildrenParts(
2058
+ children: readonly JsxNodeIr[],
2059
+ escapeBatchHelperName: string | undefined,
2060
+ ): HtmlSyncPart[] | undefined {
2061
+ if (escapeBatchHelperName === undefined) {
2062
+ return undefined;
2063
+ }
2064
+
2065
+ const dynamicChildren = children.filter(
2066
+ (child) => child.kind === "expr" && child.renderMode !== "html" && child.renderMode !== "react-node",
2067
+ ) as Array<Extract<JsxNodeIr, { kind: "expr" }>>;
2068
+
2069
+ if (dynamicChildren.length < 2) {
2070
+ return undefined;
2071
+ }
2072
+
2073
+ if (
2074
+ children.some(
2075
+ (child) =>
2076
+ child.kind !== "text" &&
2077
+ !(child.kind === "expr" && child.renderMode !== "html" && child.renderMode !== "react-node"),
2078
+ )
2079
+ ) {
2080
+ return undefined;
2081
+ }
2082
+
2083
+ const values = dynamicChildren.map((child) => child.code);
2084
+ let dynamicIndex = 0;
2085
+ const pieces = children.map((child) => {
2086
+ if (child.kind === "text") {
2087
+ return stringLiteral(escapeHtml(child.value));
2088
+ }
2089
+
2090
+ const index = dynamicIndex;
2091
+ dynamicIndex += 1;
2092
+
2093
+ return `_escaped[${index}]`;
2094
+ });
2095
+
2096
+ return [
2097
+ {
2098
+ kind: "raw-dynamic",
2099
+ code: `(() => { const _escaped = ${escapeBatchHelperName}([${values.join(", ")}]); return ${pieces.join(" + ")}; })()`,
2100
+ },
2101
+ ];
2102
+ }
2103
+
2104
+ function emitHtmlExpressionFromChildren(children: JsxNodeIr[], escapeHelperName: string): string {
2105
+ if (children.length === 0) {
2106
+ return '""';
2107
+ }
2108
+
2109
+ const parts = children.flatMap((child) =>
2110
+ collectHtmlParts(
2111
+ child,
2112
+ escapeHelperName,
2113
+ "_renderAsyncBoundary",
2114
+ "_renderOutOfOrderBoundary",
2115
+ "_renderReactSuspenseBoundary",
2116
+ "_renderReactSuspenseOutOfOrderBoundary",
2117
+ {
2118
+ dynamicAttributes: "emit",
2119
+ hydration: false,
2120
+ awaitHydration: false,
2121
+ nextFragmentId: 0,
2122
+ },
2123
+ ),
2124
+ );
2125
+ const expressions = parts.map((part) =>
2126
+ isHtmlSyncPart(part)
2127
+ ? tryEmitPartAsStringExpression(part, currentCompatRenderToStringHelperName) ?? '""'
2128
+ : '""'
2129
+ );
2130
+
2131
+ return expressions.length === 0 ? '""' : expressions.join(" + ");
2132
+ }
2133
+
2134
+ function emitStreamRendererFromChildren(
2135
+ children: JsxNodeIr[],
2136
+ escapeHelperName: string,
2137
+ ): string | undefined {
2138
+ if (children.length === 0) {
2139
+ return undefined;
2140
+ }
2141
+
2142
+ const parentState = currentPropChildrenCollectState;
2143
+ const childState: CollectHtmlState = {
2144
+ dynamicAttributes: "emit",
2145
+ hydration: false,
2146
+ awaitHydration: false,
2147
+ nextFragmentId: parentState?.nextFragmentId ?? 0,
2148
+ ...(parentState?.reactSuspenseRevealScriptNonce === undefined
2149
+ ? {}
2150
+ : { reactSuspenseRevealScriptNonce: parentState.reactSuspenseRevealScriptNonce }),
2151
+ ...(parentState?.reactSuspenseRevealScriptSrc === undefined
2152
+ ? {}
2153
+ : { reactSuspenseRevealScriptSrc: parentState.reactSuspenseRevealScriptSrc }),
2154
+ };
2155
+ const parts = children.flatMap((child) =>
2156
+ collectHtmlParts(
2157
+ child,
2158
+ escapeHelperName,
2159
+ currentAsyncBoundaryHelperName,
2160
+ currentOutOfOrderBoundaryHelperName,
2161
+ currentReactSuspenseBoundaryHelperName,
2162
+ currentReactSuspenseOutOfOrderBoundaryHelperName,
2163
+ childState,
2164
+ ),
2165
+ );
2166
+ if (parentState !== undefined) {
2167
+ parentState.nextFragmentId = childState.nextFragmentId;
2168
+ }
2169
+
2170
+ if (
2171
+ parts.every(
2172
+ (part) =>
2173
+ isHtmlSyncPart(part) &&
2174
+ tryEmitPartAsStringExpression(part, currentCompatRenderToStringHelperName) !== undefined,
2175
+ )
2176
+ ) {
2177
+ return undefined;
2178
+ }
2179
+
2180
+ return `async ($sink) => {\n${emitNestedStreamAppendStatements(parts, "$sink", currentCompatRenderToStringHelperName)}\n}`;
2181
+ }
2182
+
2183
+ function containsAsyncBoundary(node: JsxNodeIr, outOfOrder: boolean): boolean {
2184
+ if (node.kind === "async-boundary") {
2185
+ return outOfOrder
2186
+ ? node.placeholderChildren !== undefined
2187
+ : node.placeholderChildren === undefined;
2188
+ }
2189
+
2190
+ if (node.kind === "conditional") {
2191
+ return [...node.whenTrue, ...node.whenFalse].some((child) =>
2192
+ containsAsyncBoundary(child, outOfOrder),
2193
+ );
2194
+ }
2195
+
2196
+ if (node.kind === "list") {
2197
+ return node.children.some((child) => containsAsyncBoundary(child, outOfOrder));
2198
+ }
2199
+
2200
+ if (node.kind === "element" || node.kind === "fragment") {
2201
+ return node.children.some((child) => containsAsyncBoundary(child, outOfOrder));
2202
+ }
2203
+
2204
+ if (node.kind === "component") {
2205
+ if (isClientBoundaryPlaceholder(node)) {
2206
+ return false;
2207
+ }
2208
+
2209
+ return node.name === "Suspense" ? false : true;
2210
+ }
2211
+
2212
+ return false;
2213
+ }
2214
+
2215
+ function containsAnyAsyncBoundary(node: JsxNodeIr): boolean {
2216
+ if (node.kind === "async-boundary") {
2217
+ return true;
2218
+ }
2219
+
2220
+ if (node.kind === "expr") {
2221
+ return (
2222
+ node.renderMode === "stream-node" ||
2223
+ looksLikeRawJsxExpression(node.code) ||
2224
+ (node.renderMode === "html" && isChildrenExpressionCode(node.code))
2225
+ );
2226
+ }
2227
+
2228
+ if (node.kind === "conditional") {
2229
+ return [...node.whenTrue, ...node.whenFalse].some(containsAnyAsyncBoundary);
2230
+ }
2231
+
2232
+ if (node.kind === "list") {
2233
+ return node.children.some(containsAnyAsyncBoundary);
2234
+ }
2235
+
2236
+ if (node.kind === "element" || node.kind === "fragment") {
2237
+ return node.children.some(containsAnyAsyncBoundary);
2238
+ }
2239
+
2240
+ if (node.kind === "component") {
2241
+ if (isClientBoundaryPlaceholder(node)) {
2242
+ return false;
2243
+ }
2244
+
2245
+ return node.name === "Suspense" ? true : true;
2246
+ }
2247
+
2248
+ return false;
2249
+ }
2250
+
2251
+ function containsReactSuspense(node: JsxNodeIr, outOfOrder: boolean): boolean {
2252
+ if (node.kind === "component" && node.name === "Suspense") {
2253
+ return outOfOrder
2254
+ ? findSuspenseAsyncBoundary(node.children) !== undefined ||
2255
+ containsAsyncComponent(node.children)
2256
+ : findSuspenseAsyncBoundary(node.children) === undefined &&
2257
+ !containsAsyncComponent(node.children);
2258
+ }
2259
+
2260
+ if (node.kind === "conditional") {
2261
+ return [...node.whenTrue, ...node.whenFalse].some((child) =>
2262
+ containsReactSuspense(child, outOfOrder),
2263
+ );
2264
+ }
2265
+
2266
+ if (node.kind === "list") {
2267
+ return node.children.some((child) => containsReactSuspense(child, outOfOrder));
2268
+ }
2269
+
2270
+ if (node.kind === "element" || node.kind === "fragment") {
2271
+ return node.children.some((child) => containsReactSuspense(child, outOfOrder));
2272
+ }
2273
+
2274
+ if (node.kind === "async-boundary") {
2275
+ return [
2276
+ ...node.children,
2277
+ ...(node.placeholderChildren ?? []),
2278
+ ...(node.catchChildren ?? []),
2279
+ ].some((child) => containsReactSuspense(child, outOfOrder));
2280
+ }
2281
+
2282
+ return false;
2283
+ }
2284
+
2285
+ function containsAsyncComponent(children: readonly JsxNodeIr[]): boolean {
2286
+ return children.some((child) => {
2287
+ if (child.kind === "component") {
2288
+ if (isClientBoundaryPlaceholder(child)) {
2289
+ return false;
2290
+ }
2291
+
2292
+ return (
2293
+ child.async === true ||
2294
+ containsAsyncComponent(child.children) ||
2295
+ child.props.some(
2296
+ (prop) =>
2297
+ prop.kind === "render-prop" && containsAsyncComponent(prop.children),
2298
+ )
2299
+ );
2300
+ }
2301
+
2302
+ if (child.kind === "conditional") {
2303
+ return containsAsyncComponent([...child.whenTrue, ...child.whenFalse]);
2304
+ }
2305
+
2306
+ if (child.kind === "list") {
2307
+ return containsAsyncComponent(child.children);
2308
+ }
2309
+
2310
+ if (child.kind === "element" || child.kind === "fragment") {
2311
+ return containsAsyncComponent(child.children);
2312
+ }
2313
+
2314
+ if (child.kind === "async-boundary") {
2315
+ return containsAsyncComponent([
2316
+ ...child.children,
2317
+ ...(child.placeholderChildren ?? []),
2318
+ ...(child.catchChildren ?? []),
2319
+ ]);
2320
+ }
2321
+
2322
+ return false;
2323
+ });
2324
+ }
2325
+
2326
+ function containsCompatComponent(node: JsxNodeIr): boolean {
2327
+ if (node.kind === "component") {
2328
+ return (
2329
+ node.runtime === "compat" ||
2330
+ node.children.some(containsCompatComponent) ||
2331
+ node.props.some(
2332
+ (prop) => prop.kind === "render-prop" && prop.children.some(containsCompatComponent),
2333
+ )
2334
+ );
2335
+ }
2336
+
2337
+ if (node.kind === "conditional") {
2338
+ return [...node.whenTrue, ...node.whenFalse].some(containsCompatComponent);
2339
+ }
2340
+
2341
+ if (node.kind === "list") {
2342
+ return node.children.some(containsCompatComponent);
2343
+ }
2344
+
2345
+ if (node.kind === "element" || node.kind === "fragment") {
2346
+ return node.children.some(containsCompatComponent);
2347
+ }
2348
+
2349
+ if (node.kind === "async-boundary") {
2350
+ return [
2351
+ ...node.children,
2352
+ ...(node.placeholderChildren ?? []),
2353
+ ...(node.catchChildren ?? []),
2354
+ ].some(containsCompatComponent);
2355
+ }
2356
+
2357
+ return false;
2358
+ }
2359
+
2360
+ function containsClientBoundary(node: JsxNodeIr): boolean {
2361
+ if (node.kind === "component" && isClientBoundaryPlaceholder(node)) {
2362
+ return true;
2363
+ }
2364
+
2365
+ if (node.kind === "component") {
2366
+ return (
2367
+ node.children.some(containsClientBoundary) ||
2368
+ node.props.some(
2369
+ (prop) => prop.kind === "render-prop" && prop.children.some(containsClientBoundary),
2370
+ )
2371
+ );
2372
+ }
2373
+
2374
+ if (node.kind === "conditional") {
2375
+ return [...node.whenTrue, ...node.whenFalse].some(containsClientBoundary);
2376
+ }
2377
+
2378
+ if (node.kind === "list") {
2379
+ return node.children.some(containsClientBoundary);
2380
+ }
2381
+
2382
+ if (node.kind === "element" || node.kind === "fragment") {
2383
+ return node.children.some(containsClientBoundary);
2384
+ }
2385
+
2386
+ if (node.kind === "async-boundary") {
2387
+ return [
2388
+ ...node.children,
2389
+ ...(node.placeholderChildren ?? []),
2390
+ ...(node.catchChildren ?? []),
2391
+ ].some(containsClientBoundary);
2392
+ }
2393
+
2394
+ return false;
2395
+ }
2396
+
2397
+ function containsReactNodeRender(node: JsxNodeIr): boolean {
2398
+ if (node.kind === "expr") {
2399
+ return node.renderMode === "react-node";
2400
+ }
2401
+
2402
+ if (node.kind === "component") {
2403
+ if (node.runtime === "compat") {
2404
+ return true;
2405
+ }
2406
+
2407
+ return (
2408
+ node.children.some(containsReactNodeRender) ||
2409
+ node.props.some(
2410
+ (prop) => prop.kind === "render-prop" && prop.children.some(containsReactNodeRender),
2411
+ )
2412
+ );
2413
+ }
2414
+
2415
+ if (node.kind === "conditional") {
2416
+ return [...node.whenTrue, ...node.whenFalse].some(containsReactNodeRender);
2417
+ }
2418
+
2419
+ if (node.kind === "list") {
2420
+ return node.children.some(containsReactNodeRender);
2421
+ }
2422
+
2423
+ if (node.kind === "element" || node.kind === "fragment") {
2424
+ return node.children.some(containsReactNodeRender);
2425
+ }
2426
+
2427
+ if (node.kind === "async-boundary") {
2428
+ return [
2429
+ ...node.children,
2430
+ ...(node.placeholderChildren ?? []),
2431
+ ...(node.catchChildren ?? []),
2432
+ ].some(containsReactNodeRender);
2433
+ }
2434
+
2435
+ return false;
2436
+ }
2437
+
2438
+ function containsRawJsxDynamicRender(node: JsxNodeIr): boolean {
2439
+ if (node.kind === "expr") {
2440
+ return node.renderMode !== "react-node" && looksLikeRawJsxExpression(node.code);
2441
+ }
2442
+
2443
+ if (node.kind === "conditional") {
2444
+ return [...node.whenTrue, ...node.whenFalse].some(containsRawJsxDynamicRender);
2445
+ }
2446
+
2447
+ if (node.kind === "list") {
2448
+ return node.children.some(containsRawJsxDynamicRender);
2449
+ }
2450
+
2451
+ if (node.kind === "component") {
2452
+ return (
2453
+ node.children.some(containsRawJsxDynamicRender) ||
2454
+ node.props.some(
2455
+ (prop) => prop.kind === "render-prop" && prop.children.some(containsRawJsxDynamicRender),
2456
+ )
2457
+ );
2458
+ }
2459
+
2460
+ if (node.kind === "element" || node.kind === "fragment") {
2461
+ return node.children.some(containsRawJsxDynamicRender);
2462
+ }
2463
+
2464
+ if (node.kind === "async-boundary") {
2465
+ return [
2466
+ ...node.children,
2467
+ ...(node.placeholderChildren ?? []),
2468
+ ...(node.catchChildren ?? []),
2469
+ ].some(containsRawJsxDynamicRender);
2470
+ }
2471
+
2472
+ return false;
2473
+ }
2474
+
2475
+ function emitPropsObject(
2476
+ props: ComponentPropIr[],
2477
+ children: JsxNodeIr[] = [],
2478
+ escapeHelperName = "_escapeHtml",
2479
+ ): string {
2480
+ const entries = props.map((prop) => {
2481
+ if (prop.kind === "spread-prop") {
2482
+ return `...(${prop.code})`;
2483
+ }
2484
+
2485
+ if (prop.kind === "render-prop") {
2486
+ return `${emitPropName(prop.name)}: ${emitHtmlExpressionFromChildren(prop.children, escapeHelperName)}`;
2487
+ }
2488
+
2489
+ return `${emitPropName(prop.name)}: (${prop.code})`;
2490
+ });
2491
+
2492
+ if (children.length > 0) {
2493
+ entries.push(
2494
+ `children: ${
2495
+ emitStreamRendererFromChildren(children, escapeHelperName) ??
2496
+ emitHtmlExpressionFromChildren(children, escapeHelperName)
2497
+ }`,
2498
+ );
2499
+ }
2500
+
2501
+ return `{ ${entries.join(", ")} }`;
2502
+ }
2503
+
2504
+ function emitPropName(name: string): string {
2505
+ return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
2506
+ }
2507
+
2508
+ function isClientBoundaryPlaceholder(node: Extract<JsxNodeIr, { kind: "component" }>): boolean {
2509
+ return node.clientReference !== undefined && !isCompatClientReference(node);
2510
+ }
2511
+
2512
+ function isCompatClientReference(node: Extract<JsxNodeIr, { kind: "component" }>): boolean {
2513
+ return node.clientReference !== undefined && /\.(?:compat)\.[cm]?[jt]sx?$/.test(node.clientReference.moduleId);
2514
+ }
2515
+
2516
+ function clientBoundaryPlaceholder(node: Extract<JsxNodeIr, { kind: "component" }>): string {
2517
+ return `<!--mreact-client-boundary:${escapeHtml(node.name)}-->`;
2518
+ }
2519
+
2520
+ function allocateComponentSinkName(component: ComponentIr): string {
2521
+ const reservedNames = new Set([component.name, component.exportName, ...component.bindingNames]);
2522
+ let name = "$sink";
2523
+ let index = 1;
2524
+
2525
+ while (reservedNames.has(name)) {
2526
+ name = `$sink$${index}`;
2527
+ index += 1;
2528
+ }
2529
+
2530
+ return name;
2531
+ }
2532
+
2533
+ function allocateHelperName(ir: ModuleIr, baseName: string): string {
2534
+ const reservedNames = new Set<string>(ir.moduleBindingNames);
2535
+
2536
+ for (const component of ir.components) {
2537
+ reservedNames.add(component.name);
2538
+ reservedNames.add(component.exportName);
2539
+
2540
+ for (const bindingName of component.bindingNames) {
2541
+ reservedNames.add(bindingName);
2542
+ }
2543
+ }
2544
+
2545
+ let name = baseName;
2546
+ let index = 1;
2547
+
2548
+ while (reservedNames.has(name)) {
2549
+ name = `${baseName}$${index}`;
2550
+ index += 1;
2551
+ }
2552
+
2553
+ return name;
2554
+ }
2555
+
2556
+ function stringLiteral(value: string): string {
2557
+ return JSON.stringify(value);
2558
+ }