@sprlab/wccompiler 0.5.0 → 0.5.2

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.
package/lib/codegen.js CHANGED
@@ -530,9 +530,19 @@ export function generateComponent(parseResult, options = {}) {
530
530
 
531
531
  const lines = [];
532
532
 
533
+ // ── 0. Source comment ──
534
+ if (options.sourceFile) {
535
+ lines.push(`// Generated from: ${options.sourceFile} (wcCompiler)`);
536
+ }
537
+
533
538
  // ── 1. Reactive runtime (shared import or inline) ──
534
539
  if (options.runtimeImportPath) {
535
- lines.push(`import { __signal, __computed, __effect, __batch } from '${options.runtimeImportPath}';`);
540
+ // Tree-shake: only import what this component actually uses
541
+ const usedRuntime = new Set(['__signal']); // always need __signal
542
+ if (computeds.length > 0) usedRuntime.add('__computed');
543
+ if (effects.length > 0 || bindings.length > 0 || showBindings.length > 0 || modelBindings.length > 0 || attrBindings.length > 0 || ifBlocks.length > 0 || forBlocks.length > 0 || watchers.length > 0 || childComponents.length > 0 || slots.some(s => s.slotProps.length > 0)) usedRuntime.add('__effect');
544
+ const imports = [...usedRuntime].join(', ');
545
+ lines.push(`import { ${imports} } from '${options.runtimeImportPath}';`);
536
546
  } else {
537
547
  lines.push(reactiveRuntime.trim());
538
548
  }
@@ -546,12 +556,16 @@ export function generateComponent(parseResult, options = {}) {
546
556
  lines.push('');
547
557
  }
548
558
 
549
- // ── 2. CSS injection (scoped CSS into document.head, always) ──
559
+ // ── 2. CSS injection (scoped, deduplicated via id guard) ──
550
560
  if (style) {
551
561
  const scoped = scopeCSS(style, tagName);
552
- lines.push(`const __css_${className} = document.createElement('style');`);
553
- lines.push(`__css_${className}.textContent = \`${scoped}\`;`);
554
- lines.push(`document.head.appendChild(__css_${className});`);
562
+ const cssId = `__css_${className}`;
563
+ lines.push(`if (!document.getElementById('${cssId}')) {`);
564
+ lines.push(` const ${cssId} = document.createElement('style');`);
565
+ lines.push(` ${cssId}.id = '${cssId}';`);
566
+ lines.push(` ${cssId}.textContent = \`${scoped}\`;`);
567
+ lines.push(` document.head.appendChild(${cssId});`);
568
+ lines.push('}');
555
569
  lines.push('');
556
570
  }
557
571
 
@@ -724,28 +738,33 @@ export function generateComponent(parseResult, options = {}) {
724
738
  lines.push(' }');
725
739
  lines.push('');
726
740
 
727
- // connectedCallback
741
+ // connectedCallback (idempotent — safe for re-mount)
728
742
  lines.push(' connectedCallback() {');
743
+ lines.push(' if (this.__connected) return;');
744
+ lines.push(' this.__connected = true;');
745
+ lines.push(' this.__ac = new AbortController();');
746
+ lines.push(' this.__disposers = [];');
747
+ lines.push('');
729
748
 
730
749
  // Binding effects — one __effect per binding
731
750
  for (const b of bindings) {
732
751
  if (b.type === 'prop') {
733
- lines.push(' __effect(() => {');
752
+ lines.push(' this.__disposers.push(__effect(() => {');
734
753
  lines.push(` this.${b.varName}.textContent = this._s_${b.name}() ?? '';`);
735
- lines.push(' });');
754
+ lines.push(' }));');
736
755
  } else if (b.type === 'signal') {
737
- lines.push(' __effect(() => {');
756
+ lines.push(' this.__disposers.push(__effect(() => {');
738
757
  lines.push(` this.${b.varName}.textContent = this._${b.name}() ?? '';`);
739
- lines.push(' });');
758
+ lines.push(' }));');
740
759
  } else if (b.type === 'computed') {
741
- lines.push(' __effect(() => {');
760
+ lines.push(' this.__disposers.push(__effect(() => {');
742
761
  lines.push(` this.${b.varName}.textContent = this._c_${b.name}() ?? '';`);
743
- lines.push(' });');
762
+ lines.push(' }));');
744
763
  } else {
745
764
  // method type — call the method
746
- lines.push(' __effect(() => {');
765
+ lines.push(' this.__disposers.push(__effect(() => {');
747
766
  lines.push(` this.${b.varName}.textContent = this._${b.name}() ?? '';`);
748
- lines.push(' });');
767
+ lines.push(' }));');
749
768
  }
750
769
  }
751
770
 
@@ -784,22 +803,22 @@ export function generateComponent(parseResult, options = {}) {
784
803
  } else {
785
804
  ref = `this._${pb.expr}()`;
786
805
  }
787
- lines.push(' __effect(() => {');
806
+ lines.push(' this.__disposers.push(__effect(() => {');
788
807
  lines.push(` this.${cc.varName}.setAttribute('${pb.attr}', ${ref} ?? '');`);
789
- lines.push(' });');
808
+ lines.push(' }));');
790
809
  }
791
810
  }
792
811
 
793
812
  // User effects
794
813
  for (const eff of effects) {
795
814
  const body = transformMethodBody(eff.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames);
796
- lines.push(' __effect(() => {');
815
+ lines.push(' this.__disposers.push(__effect(() => {');
797
816
  // Indent each line of the body
798
817
  const bodyLines = body.split('\n');
799
818
  for (const line of bodyLines) {
800
819
  lines.push(` ${line}`);
801
820
  }
802
- lines.push(' });');
821
+ lines.push(' }));');
803
822
  }
804
823
 
805
824
  // Watcher effects
@@ -817,7 +836,7 @@ export function generateComponent(parseResult, options = {}) {
817
836
  } else {
818
837
  watchRef = `this._${w.target}()`;
819
838
  }
820
- lines.push(' __effect(() => {');
839
+ lines.push(' this.__disposers.push(__effect(() => {');
821
840
  lines.push(` const ${w.newParam} = ${watchRef};`);
822
841
  lines.push(` if (this.__prev_${w.target} !== undefined) {`);
823
842
  lines.push(` const ${w.oldParam} = this.__prev_${w.target};`);
@@ -827,12 +846,12 @@ export function generateComponent(parseResult, options = {}) {
827
846
  }
828
847
  lines.push(' }');
829
848
  lines.push(` this.__prev_${w.target} = ${w.newParam};`);
830
- lines.push(' });');
849
+ lines.push(' }));');
831
850
  } else {
832
851
  // kind === 'getter' — transform the getter expression and use it directly
833
852
  const getterExpr = transformMethodBody(w.target, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames);
834
853
  const prevName = `__prev_watch${idx}`;
835
- lines.push(' __effect(() => {');
854
+ lines.push(' this.__disposers.push(__effect(() => {');
836
855
  lines.push(` const ${w.newParam} = ${getterExpr};`);
837
856
  lines.push(` if (this.${prevName} !== undefined) {`);
838
857
  lines.push(` const ${w.oldParam} = this.${prevName};`);
@@ -842,55 +861,55 @@ export function generateComponent(parseResult, options = {}) {
842
861
  }
843
862
  lines.push(' }');
844
863
  lines.push(` this.${prevName} = ${w.newParam};`);
845
- lines.push(' });');
864
+ lines.push(' }));');
846
865
  }
847
866
  }
848
867
 
849
- // Event listeners
868
+ // Event listeners (with AbortController signal for cleanup)
850
869
  for (const e of events) {
851
870
  const handlerExpr = generateEventHandler(e.handler, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames);
852
- lines.push(` this.${e.varName}.addEventListener('${e.event}', ${handlerExpr});`);
871
+ lines.push(` this.${e.varName}.addEventListener('${e.event}', ${handlerExpr}, { signal: this.__ac.signal });`);
853
872
  }
854
873
 
855
874
  // Show effects — one __effect per ShowBinding
856
875
  for (const sb of showBindings) {
857
876
  const expr = transformExpr(sb.expression, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames);
858
- lines.push(' __effect(() => {');
877
+ lines.push(' this.__disposers.push(__effect(() => {');
859
878
  lines.push(` this.${sb.varName}.style.display = (${expr}) ? '' : 'none';`);
860
- lines.push(' });');
879
+ lines.push(' }));');
861
880
  }
862
881
 
863
882
  // Model effects — signal → DOM (one __effect per ModelBinding)
864
883
  for (const mb of modelBindings) {
865
884
  if (mb.prop === 'checked' && mb.radioValue !== null) {
866
885
  // Radio: compare signal value to radioValue
867
- lines.push(' __effect(() => {');
886
+ lines.push(' this.__disposers.push(__effect(() => {');
868
887
  lines.push(` this.${mb.varName}.checked = (this._${mb.signal}() === '${mb.radioValue}');`);
869
- lines.push(' });');
888
+ lines.push(' }));');
870
889
  } else if (mb.prop === 'checked') {
871
890
  // Checkbox: coerce to boolean
872
- lines.push(' __effect(() => {');
891
+ lines.push(' this.__disposers.push(__effect(() => {');
873
892
  lines.push(` this.${mb.varName}.checked = !!this._${mb.signal}();`);
874
- lines.push(' });');
893
+ lines.push(' }));');
875
894
  } else {
876
895
  // Value-based (text, number, textarea, select): nullish coalesce to ''
877
- lines.push(' __effect(() => {');
896
+ lines.push(' this.__disposers.push(__effect(() => {');
878
897
  lines.push(` this.${mb.varName}.value = this._${mb.signal}() ?? '';`);
879
- lines.push(' });');
898
+ lines.push(' }));');
880
899
  }
881
900
  }
882
901
 
883
- // Model event listeners — DOM → signal (one addEventListener per ModelBinding)
902
+ // Model event listeners — DOM → signal (with AbortController signal)
884
903
  for (const mb of modelBindings) {
885
904
  if (mb.prop === 'checked' && mb.radioValue === null) {
886
905
  // Checkbox: read e.target.checked
887
- lines.push(` this.${mb.varName}.addEventListener('${mb.event}', (e) => { this._${mb.signal}(e.target.checked); });`);
906
+ lines.push(` this.${mb.varName}.addEventListener('${mb.event}', (e) => { this._${mb.signal}(e.target.checked); }, { signal: this.__ac.signal });`);
888
907
  } else if (mb.coerce) {
889
908
  // Number input: wrap in Number()
890
- lines.push(` this.${mb.varName}.addEventListener('${mb.event}', (e) => { this._${mb.signal}(Number(e.target.value)); });`);
909
+ lines.push(` this.${mb.varName}.addEventListener('${mb.event}', (e) => { this._${mb.signal}(Number(e.target.value)); }, { signal: this.__ac.signal });`);
891
910
  } else {
892
911
  // All others: read e.target.value
893
- lines.push(` this.${mb.varName}.addEventListener('${mb.event}', (e) => { this._${mb.signal}(e.target.value); });`);
912
+ lines.push(` this.${mb.varName}.addEventListener('${mb.event}', (e) => { this._${mb.signal}(e.target.value); }, { signal: this.__ac.signal });`);
894
913
  }
895
914
  }
896
915
 
@@ -898,44 +917,44 @@ export function generateComponent(parseResult, options = {}) {
898
917
  for (const ab of attrBindings) {
899
918
  const expr = transformExpr(ab.expression, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames);
900
919
  if (ab.kind === 'attr') {
901
- lines.push(' __effect(() => {');
920
+ lines.push(' this.__disposers.push(__effect(() => {');
902
921
  lines.push(` const __v = ${expr};`);
903
922
  lines.push(` if (__v || __v === '') { this.${ab.varName}.setAttribute('${ab.attr}', __v); }`);
904
923
  lines.push(` else { this.${ab.varName}.removeAttribute('${ab.attr}'); }`);
905
- lines.push(' });');
924
+ lines.push(' }));');
906
925
  } else if (ab.kind === 'bool') {
907
- lines.push(' __effect(() => {');
926
+ lines.push(' this.__disposers.push(__effect(() => {');
908
927
  lines.push(` this.${ab.varName}.${ab.attr} = !!(${expr});`);
909
- lines.push(' });');
928
+ lines.push(' }));');
910
929
  } else if (ab.kind === 'class') {
911
930
  if (ab.expression.trimStart().startsWith('{')) {
912
931
  // Object expression: iterate entries, classList.add/remove
913
- lines.push(' __effect(() => {');
932
+ lines.push(' this.__disposers.push(__effect(() => {');
914
933
  lines.push(` const __obj = ${expr};`);
915
934
  lines.push(' for (const [__k, __val] of Object.entries(__obj)) {');
916
935
  lines.push(` __val ? this.${ab.varName}.classList.add(__k) : this.${ab.varName}.classList.remove(__k);`);
917
936
  lines.push(' }');
918
- lines.push(' });');
937
+ lines.push(' }));');
919
938
  } else {
920
939
  // String expression: set className
921
- lines.push(' __effect(() => {');
940
+ lines.push(' this.__disposers.push(__effect(() => {');
922
941
  lines.push(` this.${ab.varName}.className = ${expr};`);
923
- lines.push(' });');
942
+ lines.push(' }));');
924
943
  }
925
944
  } else if (ab.kind === 'style') {
926
945
  if (ab.expression.trimStart().startsWith('{')) {
927
946
  // Object expression: iterate entries, set style[key]
928
- lines.push(' __effect(() => {');
947
+ lines.push(' this.__disposers.push(__effect(() => {');
929
948
  lines.push(` const __obj = ${expr};`);
930
949
  lines.push(' for (const [__k, __val] of Object.entries(__obj)) {');
931
950
  lines.push(` this.${ab.varName}.style[__k] = __val;`);
932
951
  lines.push(' }');
933
- lines.push(' });');
952
+ lines.push(' }));');
934
953
  } else {
935
954
  // String expression: set cssText
936
- lines.push(' __effect(() => {');
955
+ lines.push(' this.__disposers.push(__effect(() => {');
937
956
  lines.push(` this.${ab.varName}.style.cssText = ${expr};`);
938
- lines.push(' });');
957
+ lines.push(' }));');
939
958
  }
940
959
  }
941
960
  }
@@ -943,7 +962,7 @@ export function generateComponent(parseResult, options = {}) {
943
962
  // ── if effects ──
944
963
  for (const ifBlock of ifBlocks) {
945
964
  const vn = ifBlock.varName;
946
- lines.push(' __effect(() => {');
965
+ lines.push(' this.__disposers.push(__effect(() => {');
947
966
  lines.push(' let __branch = null;');
948
967
  for (let i = 0; i < ifBlock.branches.length; i++) {
949
968
  const branch = ifBlock.branches[i];
@@ -982,7 +1001,7 @@ export function generateComponent(parseResult, options = {}) {
982
1001
  }
983
1002
  lines.push(' }');
984
1003
  lines.push(` this.${vn}_active = __branch;`);
985
- lines.push(' });');
1004
+ lines.push(' }));');
986
1005
  }
987
1006
 
988
1007
  // ── each effects ──
@@ -996,7 +1015,7 @@ export function generateComponent(parseResult, options = {}) {
996
1015
  // Transform the source expression
997
1016
  const sourceExpr = transformForExpr(source, itemVar, indexVar, propNames, signalNamesSet, computedNamesSet);
998
1017
 
999
- lines.push(' __effect(() => {');
1018
+ lines.push(' this.__disposers.push(__effect(() => {');
1000
1019
  lines.push(` const __source = ${sourceExpr};`);
1001
1020
  lines.push('');
1002
1021
  lines.push(" const __iter = typeof __source === 'number'");
@@ -1038,7 +1057,7 @@ export function generateComponent(parseResult, options = {}) {
1038
1057
  lines.push('');
1039
1058
  lines.push(` this.${vn}_nodes = __newNodes;`);
1040
1059
  lines.push(` this.${vn}_keyMap = __newMap;`);
1041
- lines.push(' });');
1060
+ lines.push(' }));');
1042
1061
  } else {
1043
1062
  // ── Non-keyed: destroy all and recreate (original behavior) ──
1044
1063
  lines.push(` for (const n of this.${vn}_nodes) n.remove();`);
@@ -1053,7 +1072,7 @@ export function generateComponent(parseResult, options = {}) {
1053
1072
  lines.push(` this.${vn}_anchor.parentNode.insertBefore(node, this.${vn}_anchor);`);
1054
1073
  lines.push(` this.${vn}_nodes.push(node);`);
1055
1074
  lines.push(' });');
1056
- lines.push(' });');
1075
+ lines.push(' }));');
1057
1076
  }
1058
1077
  }
1059
1078
 
@@ -1078,9 +1097,12 @@ export function generateComponent(parseResult, options = {}) {
1078
1097
  lines.push(' }');
1079
1098
  lines.push('');
1080
1099
 
1081
- // disconnectedCallback (only when destroy hooks exist)
1100
+ // disconnectedCallback (cleanup: abort listeners + dispose effects + user hooks)
1101
+ lines.push(' disconnectedCallback() {');
1102
+ lines.push(' this.__connected = false;');
1103
+ lines.push(' this.__ac.abort();');
1104
+ lines.push(' this.__disposers.forEach(d => d());');
1082
1105
  if (onDestroyHooks.length > 0) {
1083
- lines.push(' disconnectedCallback() {');
1084
1106
  for (const hook of onDestroyHooks) {
1085
1107
  const body = transformMethodBody(hook.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames);
1086
1108
  if (hook.async) {
@@ -1097,9 +1119,9 @@ export function generateComponent(parseResult, options = {}) {
1097
1119
  }
1098
1120
  }
1099
1121
  }
1100
- lines.push(' }');
1101
- lines.push('');
1102
1122
  }
1123
+ lines.push(' }');
1124
+ lines.push('');
1103
1125
 
1104
1126
  // attributeChangedCallback (if props exist)
1105
1127
  if (propDefs.length > 0) {
package/lib/compiler.js CHANGED
@@ -323,7 +323,8 @@ async function compileSFC(filePath, config) {
323
323
  parseResult.processedTemplate = rootEl.innerHTML;
324
324
 
325
325
  // 20. Generate component
326
- return generateComponent(parseResult, config);
326
+ const genOptions = { ...config, sourceFile: fileName };
327
+ return generateComponent(parseResult, genOptions);
327
328
  }
328
329
 
329
330
  /**
@@ -64,7 +64,9 @@ function __computed(fn) {
64
64
 
65
65
  function __effect(fn) {
66
66
  let _cleanup = null;
67
+ let _active = true;
67
68
  const run = () => {
69
+ if (!_active) return;
68
70
  if (typeof _cleanup === 'function') _cleanup();
69
71
  const prev = __currentEffect;
70
72
  __currentEffect = run;
@@ -72,6 +74,7 @@ function __effect(fn) {
72
74
  __currentEffect = prev;
73
75
  };
74
76
  run();
77
+ return () => { _active = false; if (typeof _cleanup === 'function') _cleanup(); };
75
78
  }
76
79
 
77
80
  function __batch(fn) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprlab/wccompiler",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Zero-runtime compiler that transforms .wcc single-file components into native web components with signals-based reactivity",
5
5
  "type": "module",
6
6
  "bin": {