@reckona/mreact-compiler 0.0.143 → 0.0.145

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);
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,149 @@ 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(component: ComponentIr): DirectTextBinding[] {
564
+ const candidates: DirectTextBinding[] = [];
565
+
566
+ for (const statement of component.bodyStatements) {
567
+ const match = statement.match(
568
+ /^\s*const\s+\[\s*(?<stateName>[A-Za-z_$][\w$]*)\s*,\s*[A-Za-z_$][\w$]*\s*\]\s*=\s*useState\(.+\);\s*$/,
569
+ );
570
+ const stateName = match?.groups?.stateName;
571
+
572
+ if (stateName === undefined) {
573
+ continue;
574
+ }
575
+
576
+ candidates.push({
577
+ stateName,
578
+ tupleName: `_${stateName}StateTuple`,
579
+ bindingName: `_${stateName}TextBinding`,
580
+ });
581
+ }
582
+
583
+ return candidates.filter((candidate) => directTextBindingIsSafe(component, candidate));
584
+ }
585
+
586
+ function directTextBindingIsSafe(
587
+ component: ComponentIr,
588
+ candidate: DirectTextBinding,
589
+ ): boolean {
590
+ let directTextUses = 0;
591
+ let unsafe = false;
592
+
593
+ visit(component.root, (node) => {
594
+ if (node.kind === "expr" && node.code === candidate.stateName) {
595
+ directTextUses += 1;
596
+ return;
597
+ }
598
+
599
+ if (node.kind === "expr" && containsIdentifier(node.code, candidate.stateName)) {
600
+ unsafe = true;
601
+ return;
602
+ }
603
+
604
+ if (node.kind === "element") {
605
+ for (const attr of node.attributes) {
606
+ if (attr.kind === "static-attr") {
607
+ continue;
608
+ }
609
+
610
+ if (containsIdentifier(attr.code, candidate.stateName)) {
611
+ unsafe = true;
612
+ }
613
+ }
614
+ }
615
+ });
616
+
617
+ for (const statement of component.bodyStatements) {
618
+ if (isDirectTextBindingDeclaration(statement, candidate.stateName)) {
619
+ continue;
620
+ }
621
+
622
+ if (containsIdentifier(statement, candidate.stateName)) {
623
+ unsafe = true;
624
+ }
625
+ }
626
+
627
+ return directTextUses === 1 && !unsafe && hasDirectTextBindingHost(component.root, candidate);
628
+ }
629
+
630
+ function isDirectTextBindingDeclaration(statement: string, stateName: string): boolean {
631
+ return new RegExp(
632
+ `^\\s*const\\s+\\[\\s*${stateName}\\s*,\\s*[A-Za-z_$][\\w$]*\\s*\\]\\s*=\\s*useState\\(.+\\);\\s*$`,
633
+ ).test(statement);
634
+ }
635
+
636
+ function hasDirectTextBindingHost(
637
+ node: JsxNodeIr,
638
+ candidate: DirectTextBinding,
639
+ ): boolean {
640
+ let found = false;
641
+
642
+ visit(node, (current) => {
643
+ if (
644
+ current.kind === "element" &&
645
+ findDirectTextBindingForChildren(current.children, [candidate]) !== undefined
646
+ ) {
647
+ found = true;
648
+ }
649
+ });
650
+
651
+ return found;
652
+ }
653
+
654
+ function rewriteDirectTextBindingStatement(
655
+ statement: string,
656
+ directTextBindings: readonly DirectTextBinding[],
657
+ helperNames: CompatHelperNames,
658
+ ): string {
659
+ for (const binding of directTextBindings) {
660
+ const match = statement.match(
661
+ /^\s*const\s+\[\s*(?<stateName>[A-Za-z_$][\w$]*)\s*,\s*(?<setterName>[A-Za-z_$][\w$]*)\s*\]\s*=\s*(?<initializer>useState\(.+\));\s*$/,
662
+ );
663
+
664
+ if (match?.groups?.stateName !== binding.stateName) {
665
+ continue;
666
+ }
667
+
668
+ const metadataName = helperNames.REACTIVE_TEXT_BINDING_META ?? "_REACTIVE_TEXT_BINDING_META";
669
+ return [
670
+ `const ${binding.tupleName} = ${match.groups.initializer};`,
671
+ ` const [${binding.stateName}, ${match.groups.setterName}] = ${binding.tupleName};`,
672
+ ` const ${binding.bindingName} = ${binding.tupleName}[${metadataName}];`,
673
+ ].join("\n");
674
+ }
675
+
676
+ return statement;
677
+ }
678
+
679
+ function findDirectTextBindingForChildren(
680
+ children: readonly JsxNodeIr[],
681
+ directTextBindings: readonly DirectTextBinding[],
682
+ ): DirectTextBinding | undefined {
683
+ if (children.length !== 1) {
684
+ return undefined;
685
+ }
686
+
687
+ const child = children[0];
688
+
689
+ if (child?.kind !== "expr") {
690
+ return undefined;
691
+ }
692
+
693
+ return directTextBindings.find((binding) => binding.stateName === child.code);
694
+ }
695
+
696
+ function containsIdentifier(code: string, name: string): boolean {
697
+ return new RegExp(`(^|[^A-Za-z_$\\d])${name}([^A-Za-z_$\\d]|$)`).test(code);
698
+ }
699
+
518
700
  function emitPropName(name: string): string {
519
701
  return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
520
702
  }
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