@sprlab/wccompiler 0.2.1 → 0.3.0

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/README.md CHANGED
@@ -82,6 +82,26 @@ effect(() => {
82
82
  })
83
83
  ```
84
84
 
85
+ Effects support cleanup — return a function to run before re-execution:
86
+
87
+ ```js
88
+ effect(() => {
89
+ const id = setInterval(() => tick.set(tick() + 1), 1000)
90
+ return () => clearInterval(id) // called before re-run
91
+ })
92
+ ```
93
+
94
+ ### Watch
95
+
96
+ ```js
97
+ watch('count', (newVal, oldVal) => {
98
+ console.log(`Changed from ${oldVal} to ${newVal}`)
99
+ if (newVal > 10) api.save(newVal)
100
+ })
101
+ ```
102
+
103
+ `watch` observes a specific signal/prop/computed and provides both old and new values. The callback does not run on initial mount — only on subsequent changes.
104
+
85
105
  ### Constants
86
106
 
87
107
  ```js
@@ -238,11 +258,18 @@ onMount(() => {
238
258
  console.log('Component connected to DOM')
239
259
  })
240
260
 
261
+ onMount(async () => {
262
+ const data = await fetch('/api/items').then(r => r.json())
263
+ items.set(data)
264
+ })
265
+
241
266
  onDestroy(() => {
242
267
  console.log('Component removed from DOM')
243
268
  })
244
269
  ```
245
270
 
271
+ Async callbacks are wrapped in an IIFE — `connectedCallback` itself stays synchronous.
272
+
246
273
  ## CSS Scoping
247
274
 
248
275
  Styles are automatically scoped to the component using tag-name prefixing:
package/lib/codegen.js CHANGED
@@ -95,6 +95,14 @@ export function transformExpr(expr, signalNames, computedNames, propsObjectName
95
95
  });
96
96
  }
97
97
 
98
+ // Transform bare prop names → this._s_x() (for template expressions like :style="{ color: myProp }")
99
+ for (const propName of propNames) {
100
+ if (propsObjectName && propName === propsObjectName) continue;
101
+ if (emitsObjectName && propName === emitsObjectName) continue;
102
+ const bareRe = new RegExp(`\\b(${propName})\\b(?!\\.set\\()(?!\\()`, 'g');
103
+ result = result.replace(bareRe, `this._s_${propName}()`);
104
+ }
105
+
98
106
  // Transform computed names first (to avoid partial matches with signals)
99
107
  for (const name of computedNames) {
100
108
  // Skip propsObjectName and emitsObjectName
@@ -289,6 +297,103 @@ export function isStaticForExpr(expr, itemVar, indexVar, propsSet, rootVarNames,
289
297
  return true;
290
298
  }
291
299
 
300
+ /**
301
+ * Generate per-item setup code for bindings, events, show, attr, model, and slots.
302
+ * Used by both keyed and non-keyed each effects.
303
+ *
304
+ * @param {string[]} lines — Output lines array
305
+ * @param {object} forBlock — ForBlock with bindings, events, etc.
306
+ * @param {string} itemVar
307
+ * @param {string|null} indexVar
308
+ * @param {Set<string>} propNames
309
+ * @param {Set<string>} signalNamesSet
310
+ * @param {Set<string>} computedNamesSet
311
+ */
312
+ function generateItemSetup(lines, forBlock, itemVar, indexVar, propNames, signalNamesSet, computedNamesSet) {
313
+ const indent = ' ';
314
+
315
+ // Bindings
316
+ for (const b of forBlock.bindings) {
317
+ const nodeRef = pathExpr(b.path, 'node');
318
+ if (isStaticForBinding(b.name, itemVar, indexVar)) {
319
+ lines.push(`${indent} ${nodeRef}.textContent = ${b.name} ?? '';`);
320
+ } else {
321
+ const expr = transformForExpr(b.name, itemVar, indexVar, propNames, signalNamesSet, computedNamesSet);
322
+ lines.push(`${indent} __effect(() => { ${nodeRef}.textContent = ${expr} ?? ''; });`);
323
+ }
324
+ }
325
+
326
+ // Events
327
+ for (const e of forBlock.events) {
328
+ const nodeRef = pathExpr(e.path, 'node');
329
+ lines.push(`${indent} ${nodeRef}.addEventListener('${e.event}', this._${e.handler}.bind(this));`);
330
+ }
331
+
332
+ // Show
333
+ for (const sb of forBlock.showBindings) {
334
+ const nodeRef = pathExpr(sb.path, 'node');
335
+ if (isStaticForExpr(sb.expression, itemVar, indexVar, propNames, signalNamesSet, computedNamesSet)) {
336
+ lines.push(`${indent} ${nodeRef}.style.display = (${sb.expression}) ? '' : 'none';`);
337
+ } else {
338
+ const expr = transformForExpr(sb.expression, itemVar, indexVar, propNames, signalNamesSet, computedNamesSet);
339
+ lines.push(`${indent} __effect(() => { ${nodeRef}.style.display = (${expr}) ? '' : 'none'; });`);
340
+ }
341
+ }
342
+
343
+ // Attr bindings
344
+ for (const ab of forBlock.attrBindings) {
345
+ const nodeRef = pathExpr(ab.path, 'node');
346
+ if (isStaticForExpr(ab.expression, itemVar, indexVar, propNames, signalNamesSet, computedNamesSet)) {
347
+ lines.push(`${indent} const __val_${ab.varName} = ${ab.expression};`);
348
+ lines.push(`${indent} if (__val_${ab.varName} != null && __val_${ab.varName} !== false) { ${nodeRef}.setAttribute('${ab.attr}', __val_${ab.varName}); }`);
349
+ } else {
350
+ const expr = transformForExpr(ab.expression, itemVar, indexVar, propNames, signalNamesSet, computedNamesSet);
351
+ lines.push(`${indent} __effect(() => {`);
352
+ lines.push(`${indent} const __val = ${expr};`);
353
+ lines.push(`${indent} if (__val == null || __val === false) { ${nodeRef}.removeAttribute('${ab.attr}'); }`);
354
+ lines.push(`${indent} else { ${nodeRef}.setAttribute('${ab.attr}', __val); }`);
355
+ lines.push(`${indent} });`);
356
+ }
357
+ }
358
+
359
+ // Model bindings
360
+ for (const mb of (forBlock.modelBindings || [])) {
361
+ const nodeRef = pathExpr(mb.path, 'node');
362
+ lines.push(`${indent} __effect(() => {`);
363
+ if (mb.prop === 'checked' && mb.radioValue !== null) {
364
+ lines.push(`${indent} ${nodeRef}.checked = (this._${mb.signal}() === '${mb.radioValue}');`);
365
+ } else if (mb.prop === 'checked') {
366
+ lines.push(`${indent} ${nodeRef}.checked = !!this._${mb.signal}();`);
367
+ } else {
368
+ lines.push(`${indent} ${nodeRef}.value = this._${mb.signal}() ?? '';`);
369
+ }
370
+ lines.push(`${indent} });`);
371
+ if (mb.prop === 'checked' && mb.radioValue === null) {
372
+ lines.push(`${indent} ${nodeRef}.addEventListener('${mb.event}', (e) => { this._${mb.signal}(e.target.checked); });`);
373
+ } else if (mb.coerce) {
374
+ lines.push(`${indent} ${nodeRef}.addEventListener('${mb.event}', (e) => { this._${mb.signal}(Number(e.target.value)); });`);
375
+ } else {
376
+ lines.push(`${indent} ${nodeRef}.addEventListener('${mb.event}', (e) => { this._${mb.signal}(e.target.value); });`);
377
+ }
378
+ }
379
+
380
+ // Scoped slots
381
+ for (const s of (forBlock.slots || [])) {
382
+ if (s.slotProps.length > 0) {
383
+ const slotNodeRef = pathExpr(s.path, 'node');
384
+ const propsEntries = s.slotProps.map(sp => `'${sp.prop}': ${sp.source}`).join(', ');
385
+ lines.push(`${indent} { const __slotEl = ${slotNodeRef};`);
386
+ lines.push(`${indent} const __sp = { ${propsEntries} };`);
387
+ lines.push(`${indent} let __h = __slotEl.innerHTML;`);
388
+ lines.push(`${indent} for (const [k, v] of Object.entries(__sp)) {`);
389
+ lines.push(`${indent} __h = __h.replace(new RegExp('\\\\{\\\\{\\\\s*' + k + '\\\\s*\\\\}\\\\}', 'g'), v ?? '');`);
390
+ lines.push(`${indent} }`);
391
+ lines.push(`${indent} __slotEl.innerHTML = __h;`);
392
+ lines.push(`${indent} }`);
393
+ }
394
+ }
395
+ }
396
+
292
397
  /**
293
398
  * Generate a fully self-contained JS component from a ParseResult.
294
399
  *
@@ -320,6 +425,7 @@ export function generateComponent(parseResult) {
320
425
  attrBindings = [],
321
426
  slots = [],
322
427
  constantVars = [],
428
+ watchers = [],
323
429
  refs = [],
324
430
  refBindings = [],
325
431
  childComponents = [],
@@ -458,6 +564,11 @@ export function generateComponent(parseResult) {
458
564
  lines.push(` this._c_${c.name} = __computed(() => ${body});`);
459
565
  }
460
566
 
567
+ // Watcher prev-value initialization
568
+ for (const w of watchers) {
569
+ lines.push(` this.__prev_${w.target} = undefined;`);
570
+ }
571
+
461
572
  // ── if: template creation, anchor reference, state init ──
462
573
  for (const ifBlock of ifBlocks) {
463
574
  const vn = ifBlock.varName;
@@ -590,6 +701,31 @@ export function generateComponent(parseResult) {
590
701
  lines.push(' });');
591
702
  }
592
703
 
704
+ // Watcher effects
705
+ for (const w of watchers) {
706
+ // Determine the signal reference for the watch target
707
+ let watchRef;
708
+ if (propNames.has(w.target)) {
709
+ watchRef = `this._s_${w.target}()`;
710
+ } else if (computedNames.includes(w.target)) {
711
+ watchRef = `this._c_${w.target}()`;
712
+ } else {
713
+ watchRef = `this._${w.target}()`;
714
+ }
715
+ const body = transformMethodBody(w.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames);
716
+ lines.push(' __effect(() => {');
717
+ lines.push(` const ${w.newParam} = ${watchRef};`);
718
+ lines.push(` if (this.__prev_${w.target} !== undefined) {`);
719
+ lines.push(` const ${w.oldParam} = this.__prev_${w.target};`);
720
+ const bodyLines = body.split('\n');
721
+ for (const line of bodyLines) {
722
+ lines.push(` ${line}`);
723
+ }
724
+ lines.push(' }');
725
+ lines.push(` this.__prev_${w.target} = ${w.newParam};`);
726
+ lines.push(' });');
727
+ }
728
+
593
729
  // Event listeners
594
730
  for (const e of events) {
595
731
  lines.push(` this.${e.varName}.addEventListener('${e.event}', this._${e.handler}.bind(this));`);
@@ -731,7 +867,7 @@ export function generateComponent(parseResult) {
731
867
  // ── each effects ──
732
868
  for (const forBlock of forBlocks) {
733
869
  const vn = forBlock.varName;
734
- const { itemVar, indexVar, source } = forBlock;
870
+ const { itemVar, indexVar, source, keyExpr } = forBlock;
735
871
 
736
872
  const signalNamesSet = new Set(signalNames);
737
873
  const computedNamesSet = new Set(computedNames);
@@ -742,116 +878,79 @@ export function generateComponent(parseResult) {
742
878
  lines.push(' __effect(() => {');
743
879
  lines.push(` const __source = ${sourceExpr};`);
744
880
  lines.push('');
745
- lines.push(` for (const n of this.${vn}_nodes) n.remove();`);
746
- lines.push(` this.${vn}_nodes = [];`);
747
- lines.push('');
748
881
  lines.push(" const __iter = typeof __source === 'number'");
749
882
  lines.push(' ? Array.from({ length: __source }, (_, i) => i + 1)');
750
883
  lines.push(' : (__source || []);');
751
884
  lines.push('');
752
- lines.push(` __iter.forEach((${itemVar}, ${indexVar || '__idx'}) => {`);
753
- lines.push(` const clone = this.${vn}_tpl.content.cloneNode(true);`);
754
- lines.push(' const node = clone.firstChild;');
755
-
756
- // Setup bindings per item
757
- for (const b of forBlock.bindings) {
758
- const nodeRef = pathExpr(b.path, 'node');
759
- if (isStaticForBinding(b.name, itemVar, indexVar)) {
760
- // Static binding: reference only item/index, assign once
761
- lines.push(` ${nodeRef}.textContent = ${b.name} ?? '';`);
762
- } else {
763
- // Reactive binding: references component variables, wrap in effect
764
- const expr = transformForExpr(b.name, itemVar, indexVar, propNames, signalNamesSet, computedNamesSet);
765
- lines.push(` __effect(() => { ${nodeRef}.textContent = ${expr} ?? ''; });`);
766
- }
767
- }
768
885
 
769
- // Setup events per item
770
- for (const e of forBlock.events) {
771
- const nodeRef = pathExpr(e.path, 'node');
772
- lines.push(` ${nodeRef}.addEventListener('${e.event}', this._${e.handler}.bind(this));`);
773
- }
774
-
775
- // Setup show per item
776
- for (const sb of forBlock.showBindings) {
777
- const nodeRef = pathExpr(sb.path, 'node');
778
- if (isStaticForExpr(sb.expression, itemVar, indexVar, propNames, signalNamesSet, computedNamesSet)) {
779
- const expr = sb.expression;
780
- lines.push(` ${nodeRef}.style.display = (${expr}) ? '' : 'none';`);
781
- } else {
782
- const expr = transformForExpr(sb.expression, itemVar, indexVar, propNames, signalNamesSet, computedNamesSet);
783
- lines.push(` __effect(() => { ${nodeRef}.style.display = (${expr}) ? '' : 'none'; });`);
784
- }
785
- }
886
+ if (keyExpr) {
887
+ // ── Keyed reconciliation ──
888
+ lines.push(` const __oldMap = this.${vn}_keyMap || new Map();`);
889
+ lines.push(' const __newMap = new Map();');
890
+ lines.push(' const __newNodes = [];');
891
+ lines.push('');
892
+ lines.push(` __iter.forEach((${itemVar}, ${indexVar || '__idx'}) => {`);
893
+ lines.push(` const __key = ${keyExpr};`);
894
+ lines.push(' if (__oldMap.has(__key)) {');
895
+ lines.push(' const node = __oldMap.get(__key);');
896
+ lines.push(' __newMap.set(__key, node);');
897
+ lines.push(' __newNodes.push(node);');
898
+ lines.push(' __oldMap.delete(__key);');
899
+ lines.push(' } else {');
900
+ lines.push(` const clone = this.${vn}_tpl.content.cloneNode(true);`);
901
+ lines.push(' const node = clone.firstChild;');
902
+
903
+ // Setup bindings/events/show/attr/model/slots for NEW nodes only
904
+ // (reused nodes keep their existing bindings)
905
+ generateItemSetup(lines, forBlock, itemVar, indexVar, propNames, signalNamesSet, computedNamesSet);
906
+
907
+ lines.push(' __newMap.set(__key, node);');
908
+ lines.push(' __newNodes.push(node);');
909
+ lines.push(' }');
910
+ lines.push(' });');
911
+ lines.push('');
912
+ lines.push(' // Remove nodes no longer in the list');
913
+ lines.push(' for (const n of __oldMap.values()) n.remove();');
914
+ lines.push('');
915
+ lines.push(' // Reorder: insert all nodes in correct order before anchor');
916
+ lines.push(` for (const n of __newNodes) this.${vn}_anchor.parentNode.insertBefore(n, this.${vn}_anchor);`);
917
+ lines.push('');
918
+ lines.push(` this.${vn}_nodes = __newNodes;`);
919
+ lines.push(` this.${vn}_keyMap = __newMap;`);
920
+ lines.push(' });');
921
+ } else {
922
+ // ── Non-keyed: destroy all and recreate (original behavior) ──
923
+ lines.push(` for (const n of this.${vn}_nodes) n.remove();`);
924
+ lines.push(` this.${vn}_nodes = [];`);
925
+ lines.push('');
926
+ lines.push(` __iter.forEach((${itemVar}, ${indexVar || '__idx'}) => {`);
927
+ lines.push(` const clone = this.${vn}_tpl.content.cloneNode(true);`);
928
+ lines.push(' const node = clone.firstChild;');
786
929
 
787
- // Setup attr bindings per item
788
- for (const ab of forBlock.attrBindings) {
789
- const nodeRef = pathExpr(ab.path, 'node');
790
- if (isStaticForExpr(ab.expression, itemVar, indexVar, propNames, signalNamesSet, computedNamesSet)) {
791
- const expr = ab.expression;
792
- lines.push(` const __val_${ab.varName} = ${expr};`);
793
- lines.push(` if (__val_${ab.varName} != null && __val_${ab.varName} !== false) { ${nodeRef}.setAttribute('${ab.attr}', __val_${ab.varName}); }`);
794
- } else {
795
- const expr = transformForExpr(ab.expression, itemVar, indexVar, propNames, signalNamesSet, computedNamesSet);
796
- lines.push(` __effect(() => {`);
797
- lines.push(` const __val = ${expr};`);
798
- lines.push(` if (__val == null || __val === false) { ${nodeRef}.removeAttribute('${ab.attr}'); }`);
799
- lines.push(` else { ${nodeRef}.setAttribute('${ab.attr}', __val); }`);
800
- lines.push(` });`);
801
- }
802
- }
930
+ generateItemSetup(lines, forBlock, itemVar, indexVar, propNames, signalNamesSet, computedNamesSet);
803
931
 
804
- // Setup model bindings per item
805
- for (const mb of (forBlock.modelBindings || [])) {
806
- const nodeRef = pathExpr(mb.path, 'node');
807
- // Effect (signal → DOM) — always reactive since it references component variables
808
- lines.push(` __effect(() => {`);
809
- if (mb.prop === 'checked' && mb.radioValue !== null) {
810
- lines.push(` ${nodeRef}.checked = (this._${mb.signal}() === '${mb.radioValue}');`);
811
- } else if (mb.prop === 'checked') {
812
- lines.push(` ${nodeRef}.checked = !!this._${mb.signal}();`);
813
- } else {
814
- lines.push(` ${nodeRef}.value = this._${mb.signal}() ?? '';`);
815
- }
816
- lines.push(` });`);
817
- // Listener (DOM → signal)
818
- if (mb.prop === 'checked' && mb.radioValue === null) {
819
- lines.push(` ${nodeRef}.addEventListener('${mb.event}', (e) => { this._${mb.signal}(e.target.checked); });`);
820
- } else if (mb.coerce) {
821
- lines.push(` ${nodeRef}.addEventListener('${mb.event}', (e) => { this._${mb.signal}(Number(e.target.value)); });`);
822
- } else {
823
- lines.push(` ${nodeRef}.addEventListener('${mb.event}', (e) => { this._${mb.signal}(e.target.value); });`);
824
- }
825
- }
826
-
827
- // Setup scoped slot resolution per item
828
- for (const s of (forBlock.slots || [])) {
829
- if (s.slotProps.length > 0) {
830
- const slotNodeRef = pathExpr(s.path, 'node');
831
- const propsEntries = s.slotProps.map(sp => `'${sp.prop}': ${sp.source}`).join(', ');
832
- lines.push(` { const __slotEl = ${slotNodeRef};`);
833
- lines.push(` const __sp = { ${propsEntries} };`);
834
- lines.push(` let __h = __slotEl.innerHTML;`);
835
- lines.push(` for (const [k, v] of Object.entries(__sp)) {`);
836
- lines.push(` __h = __h.replace(new RegExp('\\\\{\\\\{\\\\s*' + k + '\\\\s*\\\\}\\\\}', 'g'), v ?? '');`);
837
- lines.push(` }`);
838
- lines.push(` __slotEl.innerHTML = __h;`);
839
- lines.push(` }`);
840
- }
932
+ lines.push(` this.${vn}_anchor.parentNode.insertBefore(node, this.${vn}_anchor);`);
933
+ lines.push(` this.${vn}_nodes.push(node);`);
934
+ lines.push(' });');
935
+ lines.push(' });');
841
936
  }
842
-
843
- lines.push(` this.${vn}_anchor.parentNode.insertBefore(node, this.${vn}_anchor);`);
844
- lines.push(` this.${vn}_nodes.push(node);`);
845
- lines.push(' });');
846
- lines.push(' });');
847
937
  }
848
938
 
849
939
  // Lifecycle: onMount hooks (at the very end of connectedCallback)
850
940
  for (const hook of onMountHooks) {
851
941
  const body = transformMethodBody(hook.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames);
852
- const bodyLines = body.split('\n');
853
- for (const line of bodyLines) {
854
- lines.push(` ${line}`);
942
+ if (hook.async) {
943
+ lines.push(' (async () => {');
944
+ const bodyLines = body.split('\n');
945
+ for (const line of bodyLines) {
946
+ lines.push(` ${line}`);
947
+ }
948
+ lines.push(' })();');
949
+ } else {
950
+ const bodyLines = body.split('\n');
951
+ for (const line of bodyLines) {
952
+ lines.push(` ${line}`);
953
+ }
855
954
  }
856
955
  }
857
956
 
@@ -863,9 +962,18 @@ export function generateComponent(parseResult) {
863
962
  lines.push(' disconnectedCallback() {');
864
963
  for (const hook of onDestroyHooks) {
865
964
  const body = transformMethodBody(hook.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames);
866
- const bodyLines = body.split('\n');
867
- for (const line of bodyLines) {
868
- lines.push(` ${line}`);
965
+ if (hook.async) {
966
+ lines.push(' (async () => {');
967
+ const bodyLines = body.split('\n');
968
+ for (const line of bodyLines) {
969
+ lines.push(` ${line}`);
970
+ }
971
+ lines.push(' })();');
972
+ } else {
973
+ const bodyLines = body.split('\n');
974
+ for (const line of bodyLines) {
975
+ lines.push(` ${line}`);
976
+ }
869
977
  }
870
978
  }
871
979
  lines.push(' }');