@reckona/mreact-compiler 0.0.144 → 0.0.146

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.
@@ -69,6 +69,10 @@ function collectComponentImportSpecifiers(ir: ModuleIr, dev: boolean): string[]
69
69
  const specifiers = new Set<string>();
70
70
 
71
71
  for (const component of ir.components) {
72
+ if (collectDirectTextBindings(component).length > 0) {
73
+ specifiers.add("REACTIVE_TEXT_BINDING_META");
74
+ }
75
+
72
76
  visit(component.root, (node) => {
73
77
  if (node.kind === "fragment") {
74
78
  specifiers.add("Fragment");
@@ -89,11 +93,18 @@ function collectComponentImportSpecifiers(ir: ModuleIr, dev: boolean): string[]
89
93
 
90
94
  interface CompatHelperNames {
91
95
  Fragment?: string;
96
+ REACTIVE_TEXT_BINDING_META?: string;
92
97
  jsx?: string;
93
98
  jsxDEV?: string;
94
99
  jsxs?: string;
95
100
  }
96
101
 
102
+ interface DirectTextBinding {
103
+ stateName: string;
104
+ tupleName: string;
105
+ bindingName: string;
106
+ }
107
+
97
108
  function allocateHelperNames(
98
109
  ir: ModuleIr,
99
110
  specifiers: readonly string[],
@@ -107,6 +118,11 @@ function allocateHelperNames(
107
118
  continue;
108
119
  }
109
120
 
121
+ if (specifier === "REACTIVE_TEXT_BINDING_META") {
122
+ helperNames.REACTIVE_TEXT_BINDING_META = allocator("_REACTIVE_TEXT_BINDING_META");
123
+ continue;
124
+ }
125
+
110
126
  if (specifier === "jsx") {
111
127
  helperNames.jsx = allocator("_jsx");
112
128
  continue;
@@ -206,7 +222,7 @@ function parseCompatRuntimeImportLine(
206
222
  return specifierText.split(",").flatMap((rawSpecifier): CompatRuntimeImportSpecifier[] => {
207
223
  const specifier = rawSpecifier.trim();
208
224
  const aliasMatch = specifier.match(
209
- /^(?<importedName>Fragment|jsx|jsxDEV|jsxs)\s+as\s+(?<localName>[A-Za-z_$][\w$]*)$/,
225
+ /^(?<importedName>Fragment|REACTIVE_TEXT_BINDING_META|jsx|jsxDEV|jsxs)\s+as\s+(?<localName>[A-Za-z_$][\w$]*)$/,
210
226
  );
211
227
 
212
228
  if (aliasMatch?.groups !== undefined) {
@@ -223,7 +239,7 @@ function parseCompatRuntimeImportLine(
223
239
  }];
224
240
  }
225
241
 
226
- return /^(Fragment|jsx|jsxDEV|jsxs)$/.test(specifier)
242
+ return /^(Fragment|REACTIVE_TEXT_BINDING_META|jsx|jsxDEV|jsxs)$/.test(specifier)
227
243
  ? [{ importedName: specifier, localName: specifier, source }]
228
244
  : [];
229
245
  });
@@ -253,6 +269,12 @@ function createImportGroups(
253
269
  continue;
254
270
  }
255
271
 
272
+ if (specifier === "REACTIVE_TEXT_BINDING_META") {
273
+ const localName = helperNames.REACTIVE_TEXT_BINDING_META ?? "_REACTIVE_TEXT_BINDING_META";
274
+ addImportSpecifier(groups, componentImportSource, "REACTIVE_TEXT_BINDING_META", localName);
275
+ continue;
276
+ }
277
+
256
278
  const localName = helperNames[specifier as "jsx" | "jsxDEV" | "jsxs"] ?? `_${specifier}`;
257
279
  addImportSpecifier(groups, componentImportSource, specifier, localName);
258
280
  }
@@ -300,13 +322,19 @@ function emitComponent(
300
322
  helperNames: CompatHelperNames,
301
323
  dev: boolean,
302
324
  ): string {
303
- const body = component.bodyStatements.map((statement) => ` ${statement}`);
325
+ const directTextBindings = collectDirectTextBindings(component, helperNames);
326
+ const body = component.bodyStatements.map((statement) =>
327
+ ` ${rewriteDirectTextBindingStatement(statement, directTextBindings, helperNames)}`
328
+ );
304
329
  const parameters = component.parameters.join(", ");
330
+ const functionKeyword = `${component.exportDefault === true ? "export default " : component.exported === false ? "" : "export "}${
331
+ component.async === true ? "async " : ""
332
+ }function`;
305
333
 
306
334
  return [
307
- `${component.exportDefault === true ? "export default " : component.exported === false ? "" : "export "}function ${component.name}(${parameters}) {`,
335
+ `${functionKeyword} ${component.name}(${parameters}) {`,
308
336
  ...body,
309
- ` return ${emitJsxNode(component.root, helperNames, dev)};`,
337
+ ` return ${emitJsxNode(component.root, helperNames, dev, directTextBindings)};`,
310
338
  `}`,
311
339
  ].join("\n");
312
340
  }
@@ -315,6 +343,7 @@ function emitJsxNode(
315
343
  node: JsxNodeIr,
316
344
  helperNames: CompatHelperNames,
317
345
  dev: boolean,
346
+ directTextBindings: readonly DirectTextBinding[] = [],
318
347
  ): string {
319
348
  if (node.kind === "text") {
320
349
  return JSON.stringify(node.value);
@@ -325,8 +354,8 @@ function emitJsxNode(
325
354
  }
326
355
 
327
356
  if (node.kind === "conditional") {
328
- const whenTrue = emitCompatChildren(node.whenTrue, helperNames, dev);
329
- const whenFalse = emitCompatChildren(node.whenFalse, helperNames, dev);
357
+ const whenTrue = emitCompatChildren(node.whenTrue, helperNames, dev, directTextBindings);
358
+ const whenFalse = emitCompatChildren(node.whenFalse, helperNames, dev, directTextBindings);
330
359
 
331
360
  return node.conditionValueName === undefined
332
361
  ? `(${node.conditionCode}) ? ${whenTrue} : ${whenFalse}`
@@ -335,7 +364,7 @@ function emitJsxNode(
335
364
 
336
365
  if (node.kind === "list") {
337
366
  const parameters = emitListParameters(node);
338
- return `(${node.itemsCode}).map(${emitListRenderer(node, parameters, helperNames, dev)})`;
367
+ return `(${node.itemsCode}).map(${emitListRenderer(node, parameters, helperNames, dev, directTextBindings)})`;
339
368
  }
340
369
 
341
370
  if (node.kind === "fragment") {
@@ -345,7 +374,7 @@ function emitJsxNode(
345
374
  if (node.kind === "component") {
346
375
  const keyArgument =
347
376
  node.keyCode === undefined ? undefined : `(${node.keyCode})`;
348
- const props = emitComponentProps(node.props, node.children, helperNames, dev);
377
+ const props = emitComponentProps(node.props, node.children, helperNames, dev, directTextBindings);
349
378
  return dev
350
379
  ? emitJsxDevCall(helperNames.jsxDEV ?? "_jsxDEV", node.name, props, keyArgument, node.children.length > 1)
351
380
  : `${helperNames.jsx ?? "_jsx"}(${node.name}, ${props}${keyArgument === undefined ? "" : `, ${keyArgument}`})`;
@@ -355,23 +384,24 @@ function emitJsxNode(
355
384
  return "null";
356
385
  }
357
386
 
358
- return emitJsxCall(JSON.stringify(node.tagName), node, helperNames, dev);
387
+ return emitJsxCall(JSON.stringify(node.tagName), node, helperNames, dev, directTextBindings);
359
388
  }
360
389
 
361
390
  function emitCompatChildren(
362
391
  children: JsxNodeIr[],
363
392
  helperNames: CompatHelperNames,
364
393
  dev: boolean,
394
+ directTextBindings: readonly DirectTextBinding[] = [],
365
395
  ): string {
366
396
  if (children.length === 0) {
367
397
  return "null";
368
398
  }
369
399
 
370
400
  if (children.length === 1) {
371
- return emitJsxNode(children[0] as JsxNodeIr, helperNames, dev);
401
+ return emitJsxNode(children[0] as JsxNodeIr, helperNames, dev, directTextBindings);
372
402
  }
373
403
 
374
- return `[${children.map((child) => emitJsxNode(child, helperNames, dev)).join(", ")}]`;
404
+ return `[${children.map((child) => emitJsxNode(child, helperNames, dev, directTextBindings)).join(", ")}]`;
375
405
  }
376
406
 
377
407
  function emitListRenderer(
@@ -379,8 +409,9 @@ function emitListRenderer(
379
409
  parameters: string,
380
410
  helperNames: CompatHelperNames,
381
411
  dev: boolean,
412
+ directTextBindings: readonly DirectTextBinding[] = [],
382
413
  ): string {
383
- const valueExpression = emitCompatChildren(node.children, helperNames, dev);
414
+ const valueExpression = emitCompatChildren(node.children, helperNames, dev, directTextBindings);
384
415
 
385
416
  if (node.bodyStatements === undefined || node.bodyStatements.length === 0) {
386
417
  return `(${parameters}) => ${valueExpression}`;
@@ -400,6 +431,7 @@ function emitJsxCall(
400
431
  node: JsxElementIr | JsxFragmentIr,
401
432
  helperNames: CompatHelperNames,
402
433
  dev: boolean,
434
+ directTextBindings: readonly DirectTextBinding[] = [],
403
435
  ): string {
404
436
  if (dev) {
405
437
  const keyArgument =
@@ -409,7 +441,7 @@ function emitJsxCall(
409
441
  return emitJsxDevCall(
410
442
  helperNames.jsxDEV ?? "_jsxDEV",
411
443
  typeExpression,
412
- emitProps(node, helperNames, dev),
444
+ emitProps(node, helperNames, dev, directTextBindings),
413
445
  keyArgument,
414
446
  node.children.length > 1,
415
447
  );
@@ -424,7 +456,7 @@ function emitJsxCall(
424
456
  ? `, (${node.keyCode})`
425
457
  : "";
426
458
 
427
- return `${callee}(${typeExpression}, ${emitProps(node, helperNames, dev)}${keyArgument})`;
459
+ return `${callee}(${typeExpression}, ${emitProps(node, helperNames, dev, directTextBindings)}${keyArgument})`;
428
460
  }
429
461
 
430
462
  function emitJsxDevCall(
@@ -441,15 +473,25 @@ function emitProps(
441
473
  node: JsxElementIr | JsxFragmentIr,
442
474
  helperNames: CompatHelperNames,
443
475
  dev: boolean,
476
+ directTextBindings: readonly DirectTextBinding[] = [],
444
477
  ): string {
445
478
  const entries =
446
479
  node.kind === "element" ? node.attributes.map(emitAttribute) : [];
447
- const children = emitChildren(node.children, helperNames, dev);
480
+ const children = emitChildren(node.children, helperNames, dev, directTextBindings);
481
+ const directTextBinding = node.kind === "element"
482
+ ? findDirectTextBindingForChildren(node.children, directTextBindings)
483
+ : undefined;
448
484
 
449
485
  if (children !== undefined) {
450
486
  entries.push(`children: ${children}`);
451
487
  }
452
488
 
489
+ if (directTextBinding !== undefined) {
490
+ entries.push(
491
+ `[${helperNames.REACTIVE_TEXT_BINDING_META ?? "_REACTIVE_TEXT_BINDING_META"}]: ${directTextBinding.bindingName}`,
492
+ );
493
+ }
494
+
453
495
  return `{ ${entries.join(", ")} }`;
454
496
  }
455
497
 
@@ -457,16 +499,17 @@ function emitChildren(
457
499
  children: JsxNodeIr[],
458
500
  helperNames: CompatHelperNames,
459
501
  dev: boolean,
502
+ directTextBindings: readonly DirectTextBinding[] = [],
460
503
  ): string | undefined {
461
504
  if (children.length === 0) {
462
505
  return undefined;
463
506
  }
464
507
 
465
508
  if (children.length === 1) {
466
- return emitJsxNode(children[0] as JsxNodeIr, helperNames, dev);
509
+ return emitJsxNode(children[0] as JsxNodeIr, helperNames, dev, directTextBindings);
467
510
  }
468
511
 
469
- return `[${children.map((child) => emitJsxNode(child, helperNames, dev)).join(", ")}]`;
512
+ return `[${children.map((child) => emitJsxNode(child, helperNames, dev, directTextBindings)).join(", ")}]`;
470
513
  }
471
514
 
472
515
  function emitAttribute(attr: AttributeIr): string {
@@ -490,6 +533,7 @@ function emitComponentProps(
490
533
  children: JsxNodeIr[],
491
534
  helperNames: CompatHelperNames,
492
535
  dev: boolean,
536
+ directTextBindings: readonly DirectTextBinding[] = [],
493
537
  ): string {
494
538
  const entries = props
495
539
  .map((prop) => {
@@ -498,7 +542,8 @@ function emitComponentProps(
498
542
  }
499
543
 
500
544
  if (prop.kind === "render-prop") {
501
- const renderedChildren = emitChildren(prop.children, helperNames, dev) ?? "null";
545
+ const renderedChildren =
546
+ emitChildren(prop.children, helperNames, dev, directTextBindings) ?? "null";
502
547
  return prop.valueName === undefined
503
548
  ? `${emitPropName(prop.name)}: ${renderedChildren}`
504
549
  : `${emitPropName(prop.name)}: (${prop.valueName}) => ${renderedChildren}`;
@@ -509,12 +554,216 @@ function emitComponentProps(
509
554
  .filter(Boolean);
510
555
 
511
556
  if (children.length > 0) {
512
- entries.push(`children: ${emitChildren(children, helperNames, dev) ?? "null"}`);
557
+ entries.push(`children: ${emitChildren(children, helperNames, dev, directTextBindings) ?? "null"}`);
513
558
  }
514
559
 
515
560
  return `{ ${entries.join(", ")} }`;
516
561
  }
517
562
 
563
+ function collectDirectTextBindings(
564
+ component: ComponentIr,
565
+ helperNames?: CompatHelperNames,
566
+ ): DirectTextBinding[] {
567
+ const candidates: DirectTextBinding[] = [];
568
+ const allocator = createNameAllocator(collectReservedComponentLocalNames(component, helperNames));
569
+
570
+ for (const statement of component.bodyStatements) {
571
+ const match = statement.match(
572
+ /^\s*const\s+\[\s*(?<stateName>[A-Za-z_$][\w$]*)\s*,\s*[A-Za-z_$][\w$]*\s*\]\s*=\s*useState\(.+\);\s*$/,
573
+ );
574
+ const stateName = match?.groups?.stateName;
575
+
576
+ if (stateName === undefined) {
577
+ continue;
578
+ }
579
+
580
+ candidates.push({
581
+ stateName,
582
+ tupleName: allocator(`_${stateName}StateTuple`),
583
+ bindingName: allocator(`_${stateName}TextBinding`),
584
+ });
585
+ }
586
+
587
+ return candidates.filter((candidate) => directTextBindingIsSafe(component, candidate));
588
+ }
589
+
590
+ function collectReservedComponentLocalNames(
591
+ component: ComponentIr,
592
+ helperNames?: CompatHelperNames,
593
+ ): string[] {
594
+ return [
595
+ component.name,
596
+ component.exportName,
597
+ ...component.parameters,
598
+ ...component.bindingNames,
599
+ ...Object.values(helperNames ?? {}).filter((name): name is string => name !== undefined),
600
+ ];
601
+ }
602
+
603
+ function directTextBindingIsSafe(
604
+ component: ComponentIr,
605
+ candidate: DirectTextBinding,
606
+ ): boolean {
607
+ let directTextUses = 0;
608
+ let unsafe = false;
609
+
610
+ visit(component.root, (node) => {
611
+ if (node.kind === "expr" && node.code === candidate.stateName) {
612
+ directTextUses += 1;
613
+ return;
614
+ }
615
+
616
+ if (node.kind === "expr" && containsIdentifier(node.code, candidate.stateName)) {
617
+ unsafe = true;
618
+ return;
619
+ }
620
+
621
+ if (nodeHasStructuralIdentifierUse(node, candidate.stateName)) {
622
+ unsafe = true;
623
+ return;
624
+ }
625
+
626
+ if (node.kind === "element") {
627
+ for (const attr of node.attributes) {
628
+ if (attr.kind === "static-attr") {
629
+ continue;
630
+ }
631
+
632
+ if (containsIdentifier(attr.code, candidate.stateName)) {
633
+ unsafe = true;
634
+ }
635
+ }
636
+ }
637
+ });
638
+
639
+ for (const statement of component.bodyStatements) {
640
+ if (isDirectTextBindingDeclaration(statement, candidate.stateName)) {
641
+ continue;
642
+ }
643
+
644
+ if (containsIdentifier(statement, candidate.stateName)) {
645
+ unsafe = true;
646
+ }
647
+ }
648
+
649
+ return directTextUses === 1 && !unsafe && hasDirectTextBindingHost(component.root, candidate);
650
+ }
651
+
652
+ function nodeHasStructuralIdentifierUse(node: JsxNodeIr, stateName: string): boolean {
653
+ if (node.kind === "conditional") {
654
+ return containsIdentifier(node.conditionCode, stateName);
655
+ }
656
+
657
+ if (node.kind === "list") {
658
+ return [
659
+ node.itemsCode,
660
+ node.keyCode,
661
+ ...(node.bodyStatements ?? []),
662
+ ].some((code) => code !== undefined && containsIdentifier(code, stateName));
663
+ }
664
+
665
+ if (node.kind === "component") {
666
+ return (
667
+ (node.keyCode !== undefined && containsIdentifier(node.keyCode, stateName)) ||
668
+ node.props.some((prop) => {
669
+ if (prop.kind === "render-prop") {
670
+ return false;
671
+ }
672
+
673
+ return containsIdentifier(prop.code, stateName);
674
+ })
675
+ );
676
+ }
677
+
678
+ if (node.kind === "element") {
679
+ return node.keyCode !== undefined && containsIdentifier(node.keyCode, stateName);
680
+ }
681
+
682
+ if (node.kind === "async-boundary") {
683
+ return [
684
+ node.valueCode,
685
+ node.placeholderTagCode,
686
+ node.catchName,
687
+ ].some((code) => code !== undefined && containsIdentifier(code, stateName));
688
+ }
689
+
690
+ if (node.kind === "fragment") {
691
+ return (node.bodyStatements ?? []).some((statement) => containsIdentifier(statement, stateName));
692
+ }
693
+
694
+ return false;
695
+ }
696
+
697
+ function isDirectTextBindingDeclaration(statement: string, stateName: string): boolean {
698
+ return new RegExp(
699
+ `^\\s*const\\s+\\[\\s*${stateName}\\s*,\\s*[A-Za-z_$][\\w$]*\\s*\\]\\s*=\\s*useState\\(.+\\);\\s*$`,
700
+ ).test(statement);
701
+ }
702
+
703
+ function hasDirectTextBindingHost(
704
+ node: JsxNodeIr,
705
+ candidate: DirectTextBinding,
706
+ ): boolean {
707
+ let found = false;
708
+
709
+ visit(node, (current) => {
710
+ if (
711
+ current.kind === "element" &&
712
+ findDirectTextBindingForChildren(current.children, [candidate]) !== undefined
713
+ ) {
714
+ found = true;
715
+ }
716
+ });
717
+
718
+ return found;
719
+ }
720
+
721
+ function rewriteDirectTextBindingStatement(
722
+ statement: string,
723
+ directTextBindings: readonly DirectTextBinding[],
724
+ helperNames: CompatHelperNames,
725
+ ): string {
726
+ for (const binding of directTextBindings) {
727
+ const match = statement.match(
728
+ /^\s*const\s+\[\s*(?<stateName>[A-Za-z_$][\w$]*)\s*,\s*(?<setterName>[A-Za-z_$][\w$]*)\s*\]\s*=\s*(?<initializer>useState\(.+\));\s*$/,
729
+ );
730
+
731
+ if (match?.groups?.stateName !== binding.stateName) {
732
+ continue;
733
+ }
734
+
735
+ const metadataName = helperNames.REACTIVE_TEXT_BINDING_META ?? "_REACTIVE_TEXT_BINDING_META";
736
+ return [
737
+ `const ${binding.tupleName} = ${match.groups.initializer};`,
738
+ ` const [${binding.stateName}, ${match.groups.setterName}] = ${binding.tupleName};`,
739
+ ` const ${binding.bindingName} = ${binding.tupleName}[${metadataName}];`,
740
+ ].join("\n");
741
+ }
742
+
743
+ return statement;
744
+ }
745
+
746
+ function findDirectTextBindingForChildren(
747
+ children: readonly JsxNodeIr[],
748
+ directTextBindings: readonly DirectTextBinding[],
749
+ ): DirectTextBinding | undefined {
750
+ if (children.length !== 1) {
751
+ return undefined;
752
+ }
753
+
754
+ const child = children[0];
755
+
756
+ if (child?.kind !== "expr") {
757
+ return undefined;
758
+ }
759
+
760
+ return directTextBindings.find((binding) => binding.stateName === child.code);
761
+ }
762
+
763
+ function containsIdentifier(code: string, name: string): boolean {
764
+ return new RegExp(`(^|[^A-Za-z_$\\d])${name}([^A-Za-z_$\\d]|$)`).test(code);
765
+ }
766
+
518
767
  function emitPropName(name: string): string {
519
768
  return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
520
769
  }
@@ -625,7 +625,7 @@ function emitAppendStatements(
625
625
  );
626
626
  }
627
627
 
628
- return ` await ${part.name}(${sinkName}, ${emitPropsObject(part.props, part.children, part.escapeHelperName)});`;
628
+ return ` await ${part.name}(${sinkName}, ${emitPropsObject(part.props, part.children, part.escapeHelperName, part.name)});`;
629
629
  }
630
630
 
631
631
  if (part.kind === "react-node") {
@@ -721,7 +721,7 @@ function emitSyncPartAsAppendStatement(
721
721
  );
722
722
  }
723
723
 
724
- return `${indent}await ${part.name}(${sinkName}, ${emitPropsObject(part.props, part.children, part.escapeHelperName)});`;
724
+ return `${indent}await ${part.name}(${sinkName}, ${emitPropsObject(part.props, part.children, part.escapeHelperName, part.name)});`;
725
725
  }
726
726
 
727
727
  if (part.kind === "react-node") {
@@ -868,7 +868,7 @@ function tryEmitPartAsStringExpression(
868
868
  }
869
869
  if (part.kind === "component" && part.hydrationId === undefined) {
870
870
  return emitRenderableHtmlExpression(
871
- `${part.name}(${emitPropsObject(part.props, part.children, part.escapeHelperName)})`,
871
+ `${part.name}(${emitPropsObject(part.props, part.children, part.escapeHelperName, part.name)})`,
872
872
  );
873
873
  }
874
874
  // Non-compat component parts require `await sink-write`; lists with
@@ -2491,6 +2491,7 @@ function emitPropsObject(
2491
2491
  props: ComponentPropIr[],
2492
2492
  children: JsxNodeIr[] = [],
2493
2493
  escapeHelperName = "_escapeHtml",
2494
+ componentName?: string,
2494
2495
  ): string {
2495
2496
  const entries = props.map((prop) => {
2496
2497
  if (prop.kind === "spread-prop") {
@@ -2505,17 +2506,21 @@ function emitPropsObject(
2505
2506
  });
2506
2507
 
2507
2508
  if (children.length > 0) {
2509
+ const childrenExpression =
2510
+ emitStreamRendererFromChildren(children, escapeHelperName) ??
2511
+ emitHtmlExpressionFromChildren(children, escapeHelperName);
2508
2512
  entries.push(
2509
- `children: ${
2510
- emitStreamRendererFromChildren(children, escapeHelperName) ??
2511
- emitHtmlExpressionFromChildren(children, escapeHelperName)
2512
- }`,
2513
+ `children: ${isRouterLinkComponentName(componentName) ? `${componentName}.trustedHtml(${childrenExpression})` : childrenExpression}`,
2513
2514
  );
2514
2515
  }
2515
2516
 
2516
2517
  return `{ ${entries.join(", ")} }`;
2517
2518
  }
2518
2519
 
2520
+ function isRouterLinkComponentName(name: string | undefined): name is string {
2521
+ return name !== undefined && (name === "Link" || name.endsWith(".Link"));
2522
+ }
2523
+
2519
2524
  function emitCompatRuntimePropsObject(
2520
2525
  props: ComponentPropIr[],
2521
2526
  children: JsxNodeIr[] = [],
@@ -559,6 +559,7 @@ function collectHtmlStatements(
559
559
  contextProviderHelperName,
560
560
  contextConsumerHelperName,
561
561
  reactNodeRenderHelperName,
562
+ node.name,
562
563
  ),
563
564
  asyncComponentNames,
564
565
  )};`,
@@ -1467,6 +1468,7 @@ function emitPropsObject(
1467
1468
  contextProviderHelperName?: string,
1468
1469
  contextConsumerHelperName?: string,
1469
1470
  reactNodeRenderHelperName?: string,
1471
+ componentName?: string,
1470
1472
  ): string {
1471
1473
  const entries = props.map((prop) => {
1472
1474
  if (prop.kind === "spread-prop") {
@@ -1481,14 +1483,28 @@ function emitPropsObject(
1481
1483
  });
1482
1484
 
1483
1485
  if (children.length > 0) {
1486
+ const childrenExpression = emitHtmlExpressionFromChildren(
1487
+ children,
1488
+ escapeHelperName,
1489
+ escapeBatchHelperName,
1490
+ asyncComponentNames,
1491
+ dynamicAttributes,
1492
+ contextProviderHelperName,
1493
+ contextConsumerHelperName,
1494
+ reactNodeRenderHelperName,
1495
+ );
1484
1496
  entries.push(
1485
- `children: ${emitHtmlExpressionFromChildren(children, escapeHelperName, escapeBatchHelperName, asyncComponentNames, dynamicAttributes, contextProviderHelperName, contextConsumerHelperName, reactNodeRenderHelperName)}`,
1497
+ `children: ${isRouterLinkComponentName(componentName) ? `${componentName}.trustedHtml(${childrenExpression})` : childrenExpression}`,
1486
1498
  );
1487
1499
  }
1488
1500
 
1489
1501
  return `{ ${entries.join(", ")} }`;
1490
1502
  }
1491
1503
 
1504
+ function isRouterLinkComponentName(name: string | undefined): name is string {
1505
+ return name !== undefined && (name === "Link" || name.endsWith(".Link"));
1506
+ }
1507
+
1492
1508
  function emitCompatRuntimePropsObject(
1493
1509
  props: ComponentPropIr[],
1494
1510
  children: JsxNodeIr[] = [],
package/src/transform.ts CHANGED
@@ -3,6 +3,7 @@ import { emitCompat } from "./emit-compat.js";
3
3
  import { emitServer } from "./emit-server.js";
4
4
  import { emitServerStream } from "./emit-server-stream.js";
5
5
  import { analyzeCompilerModuleContextWithOxc, analyzeWithOxc } from "./oxc.js";
6
+ import { unsupportedClientAsyncComponentDiagnostic } from "./diagnostics.js";
6
7
  import type { ComponentIr, JsxNodeIr } from "./ir.js";
7
8
  import type { AnalyzeToIrInput, AnalyzeToIrOutput, CompilerModuleContext } from "./internal.js";
8
9
  import type { AnalyzeModuleOptions } from "./types.js";
@@ -74,6 +75,11 @@ function transformWithAnalyzer(
74
75
  } as const;
75
76
  const analyzed = analyze(analyzeTarget, analyzeOptions);
76
77
  const diagnostics = [...analyzed.diagnostics];
78
+
79
+ if (input.target === "client") {
80
+ diagnostics.push(...collectClientAsyncComponentDiagnostics(analyzed.ir.components));
81
+ }
82
+
77
83
  const emitted =
78
84
  mode === "compat" && input.target === "client"
79
85
  ? emitCompat(analyzed.ir, { dev: input.dev })
@@ -169,6 +175,14 @@ function transformWithAnalyzer(
169
175
  };
170
176
  }
171
177
 
178
+ function collectClientAsyncComponentDiagnostics(
179
+ components: readonly ComponentIr[],
180
+ ): TransformOutput["diagnostics"] {
181
+ return components
182
+ .filter((component) => component.async === true)
183
+ .map((component) => unsupportedClientAsyncComponentDiagnostic(component.name));
184
+ }
185
+
172
186
  function createSourceMap(input: TransformInput, outputCode: string): string {
173
187
  const generatedMap = createSegmentMappings(outputCode, input.code);
174
188