rip-lang 3.15.4 → 3.16.1

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.
Files changed (112) hide show
  1. package/README.md +6 -4
  2. package/bin/rip +167 -12
  3. package/docs/AGENTS.md +1 -1
  4. package/docs/RIP-APP.md +808 -0
  5. package/docs/RIP-DUCKDB.md +477 -0
  6. package/docs/RIP-INTRO.md +396 -0
  7. package/docs/RIP-LANG.md +59 -5
  8. package/docs/RIP-SCHEMA.md +191 -8
  9. package/docs/RIP-TYPES.md +74 -103
  10. package/docs/demo/README.md +4 -3
  11. package/docs/dist/rip.js +3627 -1470
  12. package/docs/dist/rip.min.js +671 -244
  13. package/docs/dist/rip.min.js.br +0 -0
  14. package/docs/example/index.json +7 -7
  15. package/docs/example/index.json.br +0 -0
  16. package/docs/extensions/duckdb/manifest.json +1 -1
  17. package/docs/extensions/duckdb/v1.5.2/linux_amd64/ripdb.duckdb_extension.gz +0 -0
  18. package/docs/extensions/duckdb/v1.5.2/osx_arm64/ripdb.duckdb_extension.gz +0 -0
  19. package/docs/extensions/vscode/print/index.html +2 -1
  20. package/docs/extensions/vscode/print/print-1.0.13.vsix +0 -0
  21. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  22. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  23. package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
  24. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  25. package/docs/ui/bundle.json +61 -0
  26. package/docs/ui/bundle.json.br +0 -0
  27. package/docs/ui/hljs-rip.js +0 -7
  28. package/docs/ui/index.css +66 -23
  29. package/docs/ui/index.html +6 -6
  30. package/package.json +9 -3
  31. package/rip-loader.js +64 -2
  32. package/src/AGENTS.md +63 -36
  33. package/src/browser.js +96 -14
  34. package/src/compiler.js +960 -143
  35. package/src/components.js +794 -88
  36. package/src/{types-emit.js → dts.js} +181 -71
  37. package/src/grammar/README.md +1 -1
  38. package/src/grammar/grammar.rip +111 -97
  39. package/src/lexer.js +132 -18
  40. package/src/parser.js +203 -205
  41. package/src/repl.js +74 -6
  42. package/src/schema/runtime-orm.js +168 -4
  43. package/src/schema/runtime-validate.js +146 -2
  44. package/src/schema/runtime.generated.js +314 -6
  45. package/src/schema/schema.js +5 -5
  46. package/src/sourcemaps.js +277 -1
  47. package/src/stdlib.js +253 -0
  48. package/src/typecheck.js +2023 -106
  49. package/src/types.js +127 -7
  50. package/docs/ui/accordion.rip +0 -103
  51. package/docs/ui/alert-dialog.rip +0 -53
  52. package/docs/ui/autocomplete.rip +0 -115
  53. package/docs/ui/avatar.rip +0 -37
  54. package/docs/ui/badge.rip +0 -15
  55. package/docs/ui/breadcrumb.rip +0 -47
  56. package/docs/ui/button-group.rip +0 -26
  57. package/docs/ui/button.rip +0 -23
  58. package/docs/ui/card.rip +0 -25
  59. package/docs/ui/carousel.rip +0 -110
  60. package/docs/ui/checkbox-group.rip +0 -61
  61. package/docs/ui/checkbox.rip +0 -33
  62. package/docs/ui/collapsible.rip +0 -50
  63. package/docs/ui/combobox.rip +0 -130
  64. package/docs/ui/context-menu.rip +0 -88
  65. package/docs/ui/date-picker.rip +0 -206
  66. package/docs/ui/dialog.rip +0 -60
  67. package/docs/ui/drawer.rip +0 -58
  68. package/docs/ui/editable-value.rip +0 -82
  69. package/docs/ui/field.rip +0 -53
  70. package/docs/ui/fieldset.rip +0 -22
  71. package/docs/ui/form.rip +0 -39
  72. package/docs/ui/grid.rip +0 -901
  73. package/docs/ui/input-group.rip +0 -28
  74. package/docs/ui/input.rip +0 -36
  75. package/docs/ui/label.rip +0 -16
  76. package/docs/ui/menu.rip +0 -134
  77. package/docs/ui/menubar.rip +0 -151
  78. package/docs/ui/meter.rip +0 -36
  79. package/docs/ui/multi-select.rip +0 -203
  80. package/docs/ui/native-select.rip +0 -33
  81. package/docs/ui/nav-menu.rip +0 -126
  82. package/docs/ui/number-field.rip +0 -162
  83. package/docs/ui/otp-field.rip +0 -89
  84. package/docs/ui/pagination.rip +0 -123
  85. package/docs/ui/popover.rip +0 -93
  86. package/docs/ui/preview-card.rip +0 -75
  87. package/docs/ui/progress.rip +0 -25
  88. package/docs/ui/radio-group.rip +0 -57
  89. package/docs/ui/resizable.rip +0 -123
  90. package/docs/ui/scroll-area.rip +0 -145
  91. package/docs/ui/select.rip +0 -151
  92. package/docs/ui/separator.rip +0 -17
  93. package/docs/ui/skeleton.rip +0 -22
  94. package/docs/ui/slider.rip +0 -165
  95. package/docs/ui/spinner.rip +0 -17
  96. package/docs/ui/table.rip +0 -27
  97. package/docs/ui/tabs.rip +0 -113
  98. package/docs/ui/textarea.rip +0 -48
  99. package/docs/ui/toast.rip +0 -87
  100. package/docs/ui/toggle-group.rip +0 -71
  101. package/docs/ui/toggle.rip +0 -24
  102. package/docs/ui/toolbar.rip +0 -38
  103. package/docs/ui/tooltip.rip +0 -85
  104. package/src/app.rip +0 -1571
  105. package/src/sourcemap-merge.js +0 -287
  106. /package/docs/demo/{components → routes}/_layout.rip +0 -0
  107. /package/docs/demo/{components → routes}/about.rip +0 -0
  108. /package/docs/demo/{components → routes}/card.rip +0 -0
  109. /package/docs/demo/{components → routes}/counter.rip +0 -0
  110. /package/docs/demo/{components → routes}/index.rip +0 -0
  111. /package/docs/demo/{components → routes}/todos.rip +0 -0
  112. /package/src/schema/{dts-emit.js → dts.js} +0 -0
package/src/components.js CHANGED
@@ -17,7 +17,7 @@ import { HTML_TAGS, SVG_TAGS, TEMPLATE_TAGS } from './generated/dom-tags.js';
17
17
  const BIND_PREFIX = '__bind_';
18
18
  const BIND_SUFFIX = '__';
19
19
 
20
- const LIFECYCLE_HOOKS = new Set(['beforeMount', 'mounted', 'updated', 'beforeUnmount', 'unmounted', 'onError']);
20
+ const LIFECYCLE_HOOKS = new Set(['beforeMount', 'mounted', 'beforeUnmount', 'unmounted', 'onError']);
21
21
  const BOOLEAN_ATTRS = new Set([
22
22
  'disabled', 'hidden', 'readonly', 'required', 'checked', 'selected',
23
23
  'autofocus', 'autoplay', 'controls', 'loop', 'muted', 'multiple',
@@ -79,6 +79,17 @@ function getMemberType(target) {
79
79
  return null;
80
80
  }
81
81
 
82
+ /**
83
+ * Extract `?` optionality flag from s-expression target node.
84
+ * Set by the lexer as `.optional` on the prop-name String wrapper when
85
+ * the source wrote `@label?:: T` or similar.
86
+ */
87
+ function getMemberOptional(target) {
88
+ if (target instanceof String && target.optional) return true;
89
+ if (Array.isArray(target) && target[2] instanceof String && target[2].optional) return true;
90
+ return false;
91
+ }
92
+
82
93
  // ============================================================================
83
94
  // Prototype Installation
84
95
  // ============================================================================
@@ -575,9 +586,52 @@ export function installComponentSupport(CodeEmitter, Lexer) {
575
586
  const _transferMeta = (from, to) => {
576
587
  if (!(from instanceof String)) return to;
577
588
  const s = new String(to);
578
- if (from.predicate) s.predicate = true;
579
- if (from.await) s.await = true;
580
- return (s.predicate || s.await) ? s : to;
589
+ if (from.optional) s.optional = true;
590
+ if (from.bang) s.bang = true;
591
+ return (s.optional || s.bang) ? s : to;
592
+ };
593
+
594
+ // Inject `// @rip-src:N` markers onto each statement line of a method body
595
+ // emitted into a component stub. The emitter doesn't carry source-map data
596
+ // for stub method bodies, so without explicit markers, typecheck.js falls
597
+ // back to linear gap-fill interpolation which silently misaligns when stub
598
+ // sizes change. Walks the body s-expression to recover statement source
599
+ // lines and pairs them with the rendered body's non-trivial lines.
600
+ proto.addBodyRipSrcMarkers = function(bodyCode, bodySexpr) {
601
+ if (typeof bodyCode !== 'string' || !bodyCode) return bodyCode;
602
+ const stmts = Array.isArray(bodySexpr) && bodySexpr[0] === 'block'
603
+ ? bodySexpr.slice(1)
604
+ : (Array.isArray(bodySexpr) ? [bodySexpr] : []);
605
+ if (stmts.length === 0) return bodyCode;
606
+ const getLoc = (s) => {
607
+ if (s == null) return null;
608
+ if (!Array.isArray(s)) return s?.loc?.r ?? null;
609
+ if (s.loc?.r) return s.loc.r;
610
+ if (s[0]?.loc?.r) return s[0].loc.r;
611
+ for (const child of s) {
612
+ if (child?.loc?.r) return child.loc.r;
613
+ if (Array.isArray(child)) {
614
+ const l = getLoc(child);
615
+ if (l != null) return l;
616
+ }
617
+ }
618
+ return null;
619
+ };
620
+ const srcLines = stmts.map(getLoc);
621
+ if (srcLines.every(l => l == null)) return bodyCode;
622
+ const lines = bodyCode.split('\n');
623
+ let si = 0;
624
+ for (let i = 0; i < lines.length && si < srcLines.length; i++) {
625
+ const trimmed = lines[i].trim();
626
+ if (!trimmed) continue;
627
+ if (trimmed === '{' || trimmed === '}' || trimmed.startsWith('//')) continue;
628
+ if (lines[i].includes('@rip-src:')) { si++; continue; }
629
+ if (srcLines[si] != null) {
630
+ lines[i] = `${lines[i]} // @rip-src:${srcLines[si]}`;
631
+ }
632
+ si++;
633
+ }
634
+ return lines.join('\n');
581
635
  };
582
636
 
583
637
  proto.transformComponentMembers = function(sexpr, localScope = new Set()) {
@@ -707,31 +761,35 @@ export function installComponentSupport(CodeEmitter, Lexer) {
707
761
  reactiveMembers.add(varName);
708
762
  }
709
763
  } else if (op === '.' && stmt[1] === 'this' && getMemberName(stmt)) {
710
- // Required prop: (. this name) — no default value
764
+ // Bare prop form: `(. this name)` — no default value.
765
+ // `@name` → required (caller must pass)
766
+ // `@name?` → optional (caller may omit; value will be undefined)
767
+ // `@name?:: T`→ optional, typed
711
768
  const varName = (typeof stmt[2] === 'string' || stmt[2] instanceof String) ? stmt[2].valueOf() : null;
712
769
  if (varName) {
713
- stateVars.push({ name: varName, value: undefined, isPublic: true, type: stmt[2]?.type || null, required: true });
770
+ const optional = getMemberOptional(stmt);
771
+ stateVars.push({ name: varName, value: undefined, isPublic: true, type: stmt[2]?.type || null, required: !optional, optional, srcLine: stmt.loc?.r });
714
772
  memberNames.add(varName);
715
773
  reactiveMembers.add(varName);
716
774
  }
717
775
  } else if (op === 'state') {
718
776
  const varName = getMemberName(stmt[1]);
719
777
  if (varName) {
720
- stateVars.push({ name: varName, value: stmt[2], isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]) });
778
+ stateVars.push({ name: varName, value: stmt[2], isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]), optional: getMemberOptional(stmt[1]), srcLine: stmt.loc?.r });
721
779
  memberNames.add(varName);
722
780
  reactiveMembers.add(varName);
723
781
  }
724
782
  } else if (op === 'computed') {
725
783
  const varName = getMemberName(stmt[1]);
726
784
  if (varName) {
727
- derivedVars.push({ name: varName, expr: stmt[2], type: getMemberType(stmt[1]) });
785
+ derivedVars.push({ name: varName, expr: stmt[2], type: getMemberType(stmt[1]), srcLine: stmt.loc?.r });
728
786
  memberNames.add(varName);
729
787
  reactiveMembers.add(varName);
730
788
  }
731
789
  } else if (op === 'readonly') {
732
790
  const varName = getMemberName(stmt[1]);
733
791
  if (varName) {
734
- readonlyVars.push({ name: varName, value: stmt[2], isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]) });
792
+ readonlyVars.push({ name: varName, value: stmt[2], isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]), optional: getMemberOptional(stmt[1]), srcLine: stmt.loc?.r });
735
793
  memberNames.add(varName);
736
794
  }
737
795
  } else if (op === '=') {
@@ -745,7 +803,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
745
803
  methods.push({ name: varName, func: val });
746
804
  memberNames.add(varName);
747
805
  } else {
748
- stateVars.push({ name: varName, value: val, isPublic: isPublicProp(stmt[1]) });
806
+ stateVars.push({ name: varName, value: val, isPublic: isPublicProp(stmt[1]), srcLine: stmt.loc?.r });
749
807
  memberNames.add(varName);
750
808
  reactiveMembers.add(varName);
751
809
  }
@@ -784,6 +842,14 @@ export function installComponentSupport(CodeEmitter, Lexer) {
784
842
  for (const { name, isPublic } of stateVars) if (isPublic) publicPropNames.add(name);
785
843
  for (const { name, isPublic } of readonlyVars) if (isPublic) publicPropNames.add(name);
786
844
 
845
+ // When extends-ing a tag, expose `@rest` as a reactive view of caller props
846
+ // not consumed by declared @props. Reads (e.g. `@rest.disabled`) track via
847
+ // `this.rest.value`; `_setRestProp` mutates and calls `.touch()` to notify.
848
+ if (inheritsTag) {
849
+ memberNames.add('rest');
850
+ reactiveMembers.add('rest');
851
+ }
852
+
787
853
  // Save and set component context
788
854
  const prevComponentMembers = this.componentMembers;
789
855
  const prevReactiveMembers = this.reactiveMembers;
@@ -796,24 +862,44 @@ export function installComponentSupport(CodeEmitter, Lexer) {
796
862
 
797
863
  // --- Type-check stub: typed member declarations + body expressions, no DOM ---
798
864
  if (this.options.stubComponents) {
799
- // Inline type suffix expansion (mirrors types.js expandSuffixes)
800
- const expandType = (t) => t ? t.replace(/::/g, ':')
801
- .replace(/(\w+(?:<[^>]+>)?)\?\?/g, '$1 | null | undefined')
802
- .replace(/(\w+(?:<[^>]+>)?)\?(?![.:])/g, '$1 | undefined')
803
- .replace(/(\w+(?:<[^>]+>)?)\!/g, 'NonNullable<$1>') : null;
865
+ // Strip Rip's `::` annotation sigil to TypeScript's `:` separator.
866
+ const expandType = (t) => t ? t.replace(/::/g, ':') : null;
804
867
 
805
868
  const sl = [];
806
869
  const componentTypeParams = this._componentTypeParams || '';
807
870
  sl.push(`class ${componentTypeParams}{`);
808
- sl.push(' declare _root: Element | null; declare app: any;');
871
+ // Injected `this` shape for every component. The compiler stays
872
+ // context-free; `declare app: any` and `declare router: any` are
873
+ // rewritten by typecheck.js to typed shapes when the project anchor /
874
+ // stash file is discoverable. `params`, `query`, `children`, and the
875
+ // lifecycle hooks are stable across all projects, so they're typed
876
+ // here directly. User-defined hooks are emitted later as real methods;
877
+ // skip the optional-signature declaration for those names to avoid
878
+ // overload-optionality mismatches.
879
+ // Combine all injected declarations onto a single line to preserve the
880
+ // stub's prior line count. Source-map gap-fill interpolates linearly
881
+ // between marker lines (e.g. between @rip-src markers from `@count`
882
+ // and a downstream method), so adding stub header lines pushes the
883
+ // method bodies past where interpolation expects them — breaking
884
+ // @ts-expect-error injection. Keeping a single header line preserves
885
+ // the relative offsets.
886
+ sl.push(' declare _root: Element | null; declare app: any; declare router: any; declare params: Record<string, string>; declare query: URLSearchParams; declare children: any;');
887
+ const userHookNames = new Set(lifecycleHooks.map(h => h.name));
888
+ const hookDecls = [];
889
+ if (!userHookNames.has('beforeMount')) hookDecls.push('beforeMount?(): void;');
890
+ if (!userHookNames.has('mounted')) hookDecls.push('mounted?(): void;');
891
+ if (!userHookNames.has('beforeUnmount')) hookDecls.push('beforeUnmount?(): void;');
892
+ if (!userHookNames.has('unmounted')) hookDecls.push('unmounted?(): void;');
893
+ if (!userHookNames.has('onError')) hookDecls.push('onError?(err: { status?: number; message?: string; error?: Error; path?: string }): void;');
894
+ if (hookDecls.length) sl.push(' ' + hookDecls.join(' '));
809
895
  sl.push(' emit(_name: string, _detail?: any): void {}');
810
896
 
811
897
  // Constructor — typed props for public state/readonly (matches DTS)
812
898
  const propEntries = [];
813
- for (const { name, type, isPublic, required } of stateVars) {
899
+ for (const { name, type, isPublic, required, optional } of stateVars) {
814
900
  if (!isPublic) continue;
815
901
  const ts = expandType(type);
816
- const opt = required ? '' : '?';
902
+ const opt = (optional ?? !required) ? '?' : '';
817
903
  propEntries.push(`${name}${opt}: ${ts || 'any'}`);
818
904
  // Two-way binding: allow parent to pass Signal<T> for this prop
819
905
  propEntries.push(`__bind_${name}__?: Signal<${ts || 'any'}>`);
@@ -824,7 +910,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
824
910
  propEntries.push(`${name}?: ${ts || 'any'}`);
825
911
  }
826
912
  {
827
- const hasRequired = propEntries.length > 0 && stateVars.some(v => v.isPublic && v.required);
913
+ const hasRequired = propEntries.length > 0 && stateVars.some(v => v.isPublic && v.required && !v.optional);
828
914
  const propsOpt = hasRequired ? '' : '?';
829
915
  let propsType = propEntries.length > 0 ? `{${propEntries.join('; ')}}` : '{}';
830
916
  if (inheritsTag) propsType += ` & __RipProps<'${inheritsTag}'>`;
@@ -842,9 +928,18 @@ export function installComponentSupport(CodeEmitter, Lexer) {
842
928
  };
843
929
 
844
930
  // Property declarations (declare avoids definite-assignment errors)
845
- for (const { name, type, value } of stateVars) {
931
+ for (const { name, type, value, optional, srcLine } of stateVars) {
846
932
  const ts = expandType(type) || inferLiteralType(value);
847
- sl.push(ts ? ` declare ${name}: Signal<${ts}>;` : ` declare ${name}: Signal<any>;`);
933
+ // Optional prop with no default: field can be undefined at runtime
934
+ // (we don't synthesize a `?? null` fallback below), so the Signal's
935
+ // payload type must include undefined.
936
+ const optNoDefault = optional && value === undefined;
937
+ const wrapped = ts ? (optNoDefault ? `${ts} | undefined` : ts) : null;
938
+ const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
939
+ sl.push((wrapped ? ` declare ${name}: Signal<${wrapped}>;` : ` declare ${name}: Signal<any>;`) + marker);
940
+ }
941
+ if (inheritsTag) {
942
+ sl.push(` declare rest: Signal<__RipProps<'${inheritsTag}'>>;`);
848
943
  }
849
944
  for (const { name, type, value } of readonlyVars) {
850
945
  const ts = expandType(type) || inferLiteralType(value);
@@ -865,19 +960,21 @@ export function installComponentSupport(CodeEmitter, Lexer) {
865
960
 
866
961
  // _init body — readonly, state, computed assignments (skip accepted/offered)
867
962
  sl.push(' _init(props) {');
868
- for (const { name, value, isPublic } of readonlyVars) {
963
+ for (const { name, value, isPublic, srcLine } of readonlyVars) {
869
964
  const val = this.emitInComponent(value, 'value');
870
- sl.push(isPublic ? ` this.${name} = props.${name} ?? ${val};` : ` this.${name} = ${val};`);
965
+ const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
966
+ sl.push((isPublic ? ` this.${name} = props.${name} ?? ${val};` : ` this.${name} = ${val};`) + marker);
871
967
  }
872
- for (const { name, value, isPublic, required, type } of stateVars) {
873
- if (isPublic && required) {
874
- sl.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name});`);
968
+ for (const { name, value, isPublic, required, type, srcLine } of stateVars) {
969
+ const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
970
+ if (isPublic && (required || value === undefined)) {
971
+ sl.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name});` + marker);
875
972
  } else if (isPublic) {
876
973
  const val = this.emitInComponent(value, 'value');
877
- sl.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name} ?? ${val});`);
974
+ sl.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name} ?? ${val});` + marker);
878
975
  } else {
879
976
  const val = this.emitInComponent(value, 'value');
880
- sl.push(` this.${name} = __state(${val});`);
977
+ sl.push(` this.${name} = __state(${val});` + marker);
881
978
  }
882
979
  }
883
980
 
@@ -886,11 +983,16 @@ export function installComponentSupport(CodeEmitter, Lexer) {
886
983
  const isAsync = this.containsAwait(effectBody) ? 'async ' : '';
887
984
  if (this.is(effectBody, 'block')) {
888
985
  const transformed = this.transformComponentMembers(effectBody);
889
- const body = this.emitFunctionBody(transformed, [], true);
986
+ // sideEffectOnly: false so the body's last expression is
987
+ // returned. The reactive runtime treats a returned function as
988
+ // the effect's cleanup — that's how '~> ARIA.bindPopover ...'
989
+ // gets its disposer registered. Disposer is also auto-tracked
990
+ // via __getCurrentComponent for unmount cleanup.
991
+ const body = this.emitFunctionBody(transformed);
890
992
  sl.push(` __effect(${isAsync}() => ${body});`);
891
993
  } else {
892
994
  const effectCode = this.emitInComponent(effectBody, 'value');
893
- sl.push(` __effect(${isAsync}() => { ${effectCode}; });`);
995
+ sl.push(` __effect(${isAsync}() => { return ${effectCode}; });`);
894
996
  }
895
997
  }
896
998
  sl.push(' }');
@@ -956,7 +1058,12 @@ export function installComponentSupport(CodeEmitter, Lexer) {
956
1058
  }
957
1059
  const transformed = this.reactiveMembers ? this.transformComponentMembers(methodBody) : methodBody;
958
1060
  const isAsync = this.containsAwait(methodBody);
959
- const bodyCode = this.emitFunctionBody(transformed, params || []);
1061
+ let bodyCode = this.emitFunctionBody(transformed, params || []);
1062
+ // Inject @rip-src markers on each body statement line so that
1063
+ // @ts-expect-error injection and hover/diagnostics resolve to the
1064
+ // user's actual source line, instead of relying on linear gap-fill
1065
+ // interpolation across the stub (which is brittle to stub size).
1066
+ bodyCode = this.addBodyRipSrcMarkers(bodyCode, methodBody);
960
1067
  sl.push(` ${isAsync ? 'async ' : ''}${name}(${paramStr}) ${bodyCode}`);
961
1068
  }
962
1069
  }
@@ -968,7 +1075,8 @@ export function installComponentSupport(CodeEmitter, Lexer) {
968
1075
  const paramStr = Array.isArray(params) ? params.map(p => this.formatParam(p)).join(', ') : '';
969
1076
  const transformed = this.reactiveMembers ? this.transformComponentMembers(hookBody) : hookBody;
970
1077
  const isAsync = this.containsAwait(hookBody);
971
- const bodyCode = this.emitFunctionBody(transformed, params || []);
1078
+ let bodyCode = this.emitFunctionBody(transformed, params || []);
1079
+ bodyCode = this.addBodyRipSrcMarkers(bodyCode, hookBody);
972
1080
  sl.push(` ${isAsync ? 'async ' : ''}${name}(${paramStr}) ${bodyCode}`);
973
1081
  }
974
1082
  }
@@ -981,6 +1089,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
981
1089
  const sourceLines = this.options.source?.split('\n');
982
1090
  const extractProps = (args) => {
983
1091
  const props = [];
1092
+ const sideExprs = [];
984
1093
  for (const arg of args) {
985
1094
  let obj = null;
986
1095
  if (this.is(arg, 'object')) {
@@ -995,8 +1104,26 @@ export function installComponentSupport(CodeEmitter, Lexer) {
995
1104
  for (let j = 1; j < obj.length; j++) {
996
1105
  const pair = obj[j];
997
1106
  const [, key, value] = pair;
998
- if (typeof key === 'string' && !key.startsWith('@')) {
999
- const srcLine = pair.loc?.r ?? obj.loc?.r;
1107
+ const srcLine = pair.loc?.r ?? obj.loc?.r;
1108
+ // `@event` props on a component (parsed as `(. this name)`)
1109
+ // — not part of the declared prop type, but emit the value
1110
+ // as a bare expression so TS sees identifiers in the
1111
+ // handler body (hover, completion, identifier resolution).
1112
+ if (Array.isArray(key) && key[0] === '.' && key[1] === 'this') {
1113
+ try {
1114
+ const val = this.emitInComponent(value, 'value');
1115
+ sideExprs.push({ code: val, srcLine });
1116
+ } catch {}
1117
+ continue;
1118
+ }
1119
+ if (typeof key === 'string' && key.startsWith('@')) {
1120
+ try {
1121
+ const val = this.emitInComponent(value, 'value');
1122
+ sideExprs.push({ code: val, srcLine });
1123
+ } catch {}
1124
+ continue;
1125
+ }
1126
+ if (typeof key === 'string') {
1000
1127
  if (key.startsWith('__bind_') && key.endsWith('__')) {
1001
1128
  // Two-way binding: emit the Signal object (this.xxx), not this.xxx.value
1002
1129
  const member = typeof value === 'string' && this.reactiveMembers?.has(value) ? `this.${value}` : this.emitInComponent(value, 'value');
@@ -1009,10 +1136,25 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1009
1136
  }
1010
1137
  }
1011
1138
  }
1139
+ props.sideExprs = sideExprs;
1012
1140
  return props;
1013
1141
  };
1014
- const extractIntrinsicProps = (args) => {
1142
+ const extractIntrinsicProps = (args, tagName) => {
1015
1143
  const props = [];
1144
+ // For <a href: ...>, wrap interpolated route literals (templates
1145
+ // starting with `/...`) in __ripRoute so TS checks the dynamic
1146
+ // template against __RipRoutes. Static literals fall through to
1147
+ // the existing __ripEl Option-C conditional, which still gives
1148
+ // the "Did you mean ..." hint. Variables and external URLs are
1149
+ // left untouched.
1150
+ const wrapHrefVal = (val) => {
1151
+ if (tagName !== 'a') return val;
1152
+ if (typeof val !== 'string') return val;
1153
+ if (val.length < 2) return val;
1154
+ if (val[0] !== '`') return val;
1155
+ if (val[1] !== '/') return val;
1156
+ return `__ripRoute(${val})`;
1157
+ };
1016
1158
  for (const arg of args) {
1017
1159
  let obj = null;
1018
1160
  if (this.is(arg, 'object')) {
@@ -1049,7 +1191,8 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1049
1191
  props.push({ code: `${propName}: ${val}`, srcLine });
1050
1192
  } else {
1051
1193
  const val = this.emitInComponent(value, 'value');
1052
- props.push({ code: `${key}: ${val}`, srcLine });
1194
+ const finalVal = key === 'href' ? wrapHrefVal(val) : val;
1195
+ props.push({ code: `${key}: ${finalVal}`, srcLine });
1053
1196
  }
1054
1197
  }
1055
1198
  }
@@ -1059,7 +1202,55 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1059
1202
  };
1060
1203
  const walkRender = (node) => {
1061
1204
  if (!Array.isArray(node)) return;
1062
- const head = node[0]?.valueOf?.() ?? node[0];
1205
+ let head = node[0]?.valueOf?.() ?? node[0];
1206
+
1207
+ // Tag shorthand normalization: `button.outline` is parsed as a
1208
+ // member-access s-expression `(. button outline)`. For render-block
1209
+ // type-checking we want to treat it as a regular tag node with a
1210
+ // string head `"button.outline"` so the existing intrinsic-tag
1211
+ // logic (which splits on `[.#]`) picks it up. Without this the
1212
+ // entire subtree (including @event handler bodies) is silently
1213
+ // skipped from the type-check stub.
1214
+ if (Array.isArray(head) && head[0] === '.') {
1215
+ const parts = [];
1216
+ const collect = (h) => {
1217
+ if (typeof h === 'string') { parts.push(h); return true; }
1218
+ if (Array.isArray(h) && h[0] === '.' && parts.length === 0) {
1219
+ if (!collect(h[1])) return false;
1220
+ if (typeof h[2] !== 'string' || !/^[\w-]+$/.test(h[2])) return false;
1221
+ parts.push('.', h[2]);
1222
+ return true;
1223
+ }
1224
+ return false;
1225
+ };
1226
+ if (collect(head) && parts.length >= 3 && /^[a-z][\w-]*$/.test(parts[0])) {
1227
+ const flat = parts.join('');
1228
+ const reshaped = [flat];
1229
+ for (let i = 1; i < node.length; i++) reshaped.push(node[i]);
1230
+ if (node.loc) reshaped.loc = node.loc;
1231
+ // Carry an _astNode back-reference so emitBareIdent can attach
1232
+ // anchors (used by recordSubMappings) on the original AST node
1233
+ // rather than this throwaway reshape, since recordSubMappings
1234
+ // walks the original tree.
1235
+ reshaped._astNode = node;
1236
+ // Strip .loc from the original tag-shorthand `(. tag class ...)`
1237
+ // chain so collectSubExprs (used by recordSubMappings) does not
1238
+ // anchor source identifiers (the class names) onto unrelated
1239
+ // gen-side property accesses with the same name. The class
1240
+ // segments (e.g. `image` in `div.image`) are CSS classes, not
1241
+ // identifier references, and should not participate in
1242
+ // identifier-name source-mapping heuristics.
1243
+ const stripLoc = (h) => {
1244
+ if (Array.isArray(h) && h[0] === '.') {
1245
+ if (h.loc) delete h.loc;
1246
+ stripLoc(h[1]);
1247
+ }
1248
+ };
1249
+ stripLoc(head);
1250
+ node = reshaped;
1251
+ head = flat;
1252
+ }
1253
+ }
1063
1254
 
1064
1255
  // Object nodes are property bags (key-value pairs) — their values
1065
1256
  // are code expressions (event handlers, bindings, literals), not
@@ -1164,13 +1355,43 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1164
1355
  if (CodeEmitter.GENERATORS[child]) return;
1165
1356
  if (child === 'null' || child === 'undefined' || child === 'true' || child === 'false') return;
1166
1357
  let srcLine = parentNode.loc?.r;
1358
+ let srcCol = null;
1167
1359
  if (srcLine != null && sourceLines) {
1168
- const re = new RegExp(`\\b${child}\\b`);
1360
+ const re = new RegExp(`\\b${child}\\b`, 'g');
1169
1361
  for (let ln = srcLine; ln < sourceLines.length; ln++) {
1170
- if (re.test(sourceLines[ln])) { srcLine = ln; break; }
1362
+ const lineText = sourceLines[ln];
1363
+ if (!lineText) continue;
1364
+ let searchFrom = (ln === parentNode.loc.r) ? (parentNode.loc.c + 1) : 0;
1365
+ re.lastIndex = searchFrom;
1366
+ let m;
1367
+ let found = -1;
1368
+ while ((m = re.exec(lineText)) !== null) {
1369
+ // Skip tag-shorthand class/id matches like `.error`/`#id`
1370
+ // (e.g. the `error` in `p.error error` — the bare-ident
1371
+ // child is the *second* `error`, not the CSS class).
1372
+ const prev = m.index > 0 ? lineText[m.index - 1] : '';
1373
+ if (prev === '.' || prev === '#') continue;
1374
+ found = m.index;
1375
+ break;
1376
+ }
1377
+ if (found >= 0) {
1378
+ srcLine = ln;
1379
+ srcCol = found;
1380
+ break;
1381
+ }
1171
1382
  }
1172
1383
  }
1173
1384
  const srcMarker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
1385
+ // Attach an anchor on the parent so collectSubExprs (used by
1386
+ // recordSubMappings) can record a precise source-map entry for
1387
+ // this bare identifier. Without this, source clicks/hovers on
1388
+ // the bare ident never resolve to the generated `this.X;` /
1389
+ // `X;` stub line, and the LSP can't route a semantic token here.
1390
+ if (srcLine != null && srcCol != null) {
1391
+ const anchorTarget = parentNode._astNode || parentNode;
1392
+ if (!anchorTarget._anchors) anchorTarget._anchors = [];
1393
+ anchorTarget._anchors.push({ name: child, origLine: srcLine, origCol: srcCol });
1394
+ }
1174
1395
  if (this.componentMembers && this.componentMembers.has(child)) {
1175
1396
  constructions.push(` this.${child};${srcMarker}`);
1176
1397
  } else if (isTextChild) {
@@ -1185,7 +1406,13 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1185
1406
  // Bare lowercase identifiers inside a block or as children of tag nodes
1186
1407
  // — emit __ripEl so TS catches tag typos (e.g., slotz for slot), or
1187
1408
  // emit this.prop for component member text references.
1188
- const isTagHead = typeof head === 'string' && /^[a-z][\w-]*$/.test(head) &&
1409
+ // Allow tag-shorthand heads like `div.image` or `button#submit`
1410
+ // split on `.`/`#` and check the bare tag name. Without the
1411
+ // shorthand support here, expression children of `div.image x`
1412
+ // were silently dropped from the type-check stub even though the
1413
+ // lower `__ripEl` branch (which uses the same split) emitted the
1414
+ // tag itself.
1415
+ const isTagHead = typeof head === 'string' && /^[a-z][\w-]*(?:[.#][\w-]+)*$/.test(head) &&
1189
1416
  !CodeEmitter.GENERATORS[head] && TEMPLATE_TAGS.has(head.split(/[.#]/)[0]);
1190
1417
  if (head === 'block') {
1191
1418
  for (let i = 1; i < node.length; i++) emitBareIdent(node[i], node, false);
@@ -1238,10 +1465,18 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1238
1465
  constructions.push(` };`);
1239
1466
  }
1240
1467
  }
1468
+ // Emit @-event handler bodies as bare expression statements so
1469
+ // TS can resolve identifiers inside them (hover, completion).
1470
+ // They aren't part of the component's declared prop type.
1471
+ if (props.sideExprs) {
1472
+ for (const s of props.sideExprs) {
1473
+ constructions.push(` ${s.code};` + (s.srcLine != null ? ` // @rip-src:${s.srcLine}` : ''));
1474
+ }
1475
+ }
1241
1476
  } else if (typeof head === 'string' && !CodeEmitter.GENERATORS[head] && (TEMPLATE_TAGS.has(head.split(/[.#]/)[0]) ||
1242
1477
  (/^[a-z][\w-]*$/.test(head) && node.length > 1))) {
1243
1478
  const tagName = head.split(/[.#]/)[0];
1244
- const iProps = extractIntrinsicProps(node.slice(1));
1479
+ const iProps = extractIntrinsicProps(node.slice(1), tagName);
1245
1480
  const tagLine = node.loc?.r;
1246
1481
  const srcMarker = tagLine != null ? ` // @rip-src:${tagLine}` : '';
1247
1482
  if (iProps.length === 0) {
@@ -1309,7 +1544,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1309
1544
 
1310
1545
  // State variables (__state handles signal passthrough)
1311
1546
  for (const { name, value, isPublic, required } of stateVars) {
1312
- if (isPublic && required) {
1547
+ if (isPublic && (required || value === undefined)) {
1313
1548
  lines.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name});`);
1314
1549
  } else if (isPublic) {
1315
1550
  const val = this.emitInComponent(value, 'value');
@@ -1330,6 +1565,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1330
1565
  lines.push(" if (!__k.startsWith('__bind_')) this._rest[__k] = props[__k];");
1331
1566
  }
1332
1567
  lines.push(' }');
1568
+ lines.push(' this.rest = __state(this._rest);');
1333
1569
  }
1334
1570
 
1335
1571
  // Computed (derived)
@@ -1349,17 +1585,17 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1349
1585
  lines.push(` setContext('${name}', this.${name});`);
1350
1586
  }
1351
1587
 
1352
- // Effects
1588
+ // Effects (see comment on the parallel block above)
1353
1589
  for (const effect of effects) {
1354
1590
  const effectBody = effect[2];
1355
1591
  const isAsync = this.containsAwait(effectBody) ? 'async ' : '';
1356
1592
  if (this.is(effectBody, 'block')) {
1357
1593
  const transformed = this.transformComponentMembers(effectBody);
1358
- const body = this.emitFunctionBody(transformed, [], true);
1594
+ const body = this.emitFunctionBody(transformed);
1359
1595
  lines.push(` __effect(${isAsync}() => ${body});`);
1360
1596
  } else {
1361
1597
  const effectCode = this.emitInComponent(effectBody, 'value');
1362
- lines.push(` __effect(${isAsync}() => { ${effectCode}; });`);
1598
+ lines.push(` __effect(${isAsync}() => { return ${effectCode}; });`);
1363
1599
  }
1364
1600
  }
1365
1601
 
@@ -1371,6 +1607,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1371
1607
  lines.push(' this._rest || (this._rest = {});');
1372
1608
  lines.push(' if (value == null) delete this._rest[key];');
1373
1609
  lines.push(' else this._rest[key] = value;');
1610
+ lines.push(' this.rest.touch();');
1374
1611
  lines.push(' this._applyInheritedProp(this._inheritedEl, key, value);');
1375
1612
  lines.push(' }');
1376
1613
  lines.push(' _applyRestToInheritedEl() {');
@@ -1539,6 +1776,8 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1539
1776
  this._loopVarStack = [];
1540
1777
  this._factoryMode = false;
1541
1778
  this._factoryVars = null;
1779
+ this._renderLocalScope = new Set();
1780
+ this._renderTopLocals = new Set(); // class-mode (top-level _create) hoisted lets
1542
1781
  this._fragChildren = new Map();
1543
1782
  this._pendingAutoWire = false;
1544
1783
  this._autoWireEl = null;
@@ -1547,25 +1786,52 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1547
1786
 
1548
1787
  const statements = this.is(body, 'block') ? body.slice(1) : [body];
1549
1788
 
1789
+ // Pre-count renderable (non-binding) statements so the single-renderable
1790
+ // optimization survives the presence of leading binding statements like
1791
+ // `code = expr`. Bindings are emitted into _createLines as JS but
1792
+ // produce no DOM child to append.
1793
+ const renderableCount = statements.reduce(
1794
+ (n, s) => n + (this._isRenderBinding(s) ? 0 : 1), 0);
1795
+
1550
1796
  let rootVar;
1551
1797
  if (statements.length === 0 || (statements.length === 1 && statements[0] === 'null')) {
1552
1798
  rootVar = 'null';
1553
- } else if (statements.length === 1) {
1799
+ } else if (renderableCount === 0) {
1800
+ // All statements are bindings — emit them and return a comment placeholder
1801
+ for (const stmt of statements) this.emitNode(stmt);
1802
+ rootVar = this.newElementVar('empty');
1803
+ this._createLines.push(`${rootVar} = document.createComment('');`);
1804
+ } else if (renderableCount === 1) {
1554
1805
  this._pendingAutoWire = !!this._autoEventHandlers;
1555
- rootVar = this.emitNode(statements[0]);
1806
+ let onlyRenderable = null;
1807
+ for (const stmt of statements) {
1808
+ const v = this.emitNode(stmt);
1809
+ if (v != null) onlyRenderable = v;
1810
+ }
1556
1811
  this._pendingAutoWire = false;
1812
+ rootVar = onlyRenderable;
1557
1813
  } else {
1558
1814
  rootVar = this.newElementVar('frag');
1559
1815
  this._createLines.push(`${rootVar} = document.createDocumentFragment();`);
1560
1816
  const children = [];
1561
1817
  for (const stmt of statements) {
1562
1818
  const childVar = this.emitNode(stmt);
1819
+ if (childVar == null) continue;
1563
1820
  this._createLines.push(`${rootVar}.appendChild(${childVar});`);
1564
1821
  children.push(childVar);
1565
1822
  }
1566
1823
  this._fragChildren.set(rootVar, children);
1567
1824
  }
1568
1825
 
1826
+ // Hoist class-mode render-local declarations to the top of _create() so
1827
+ // a) duplicate `name = ...` statements in source don't generate `let name`
1828
+ // twice (strict-mode redeclaration error), and b) reads inside any
1829
+ // enclosing IIFE/closure can see the binding.
1830
+ if (this._renderTopLocals.size > 0) {
1831
+ const decl = `let ${[...this._renderTopLocals].join(', ')};`;
1832
+ this._createLines.unshift(decl);
1833
+ }
1834
+
1569
1835
  return {
1570
1836
  createLines: this._createLines,
1571
1837
  setupLines: this._setupLines,
@@ -1598,20 +1864,88 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1598
1864
  get() { return this._factoryMode ? 'ctx' : 'this'; }
1599
1865
  });
1600
1866
 
1601
- /** Push an effect line, wrapping with disposer tracking in factory mode */
1867
+ /** Push an effect line, wrapping with disposer tracking in factory mode.
1868
+ *
1869
+ * Factory effects opt out of the runtime's auto-registration with the
1870
+ * current component (skipRegister:true). The factory's own `disposers`
1871
+ * array owns these — they're already cleaned up by d(detaching) when
1872
+ * the block is removed. Without skipRegister, every block re-render
1873
+ * would leak a stale disposer onto the parent's _disposers (the local
1874
+ * factory disposers get overwritten on each p() call, but the parent
1875
+ * keeps the stale references until parent unmount). */
1602
1876
  proto._pushEffect = function(body) {
1603
1877
  if (this._factoryMode) {
1604
- this._setupLines.push(`disposers.push(__effect(() => { ${body} }));`);
1878
+ this._setupLines.push(`disposers.push(__effect(() => { ${body} }, {skipRegister: true}));`);
1605
1879
  } else {
1606
1880
  this._setupLines.push(`__effect(() => { ${body} });`);
1607
1881
  }
1608
1882
  };
1609
1883
 
1884
+ // --------------------------------------------------------------------------
1885
+ // Render-scope locals — declarations like `code = expr` inside render
1886
+ // --------------------------------------------------------------------------
1887
+ // Tracked per-factory so emitNode can (a) emit `code = expr` as a real JS
1888
+ // local instead of a text node, and (b) treat subsequent `span code`
1889
+ // references as value reads instead of `<code>` element emissions. Each
1890
+ // block factory (loop body, conditional branch) is its own JS function,
1891
+ // so render locals don't cross factory boundaries; only loop vars do
1892
+ // (threaded via positional parameters from _loopVarStack).
1893
+
1894
+ const _isPlainIdentifier = (s) =>
1895
+ typeof s === 'string' && /^[A-Za-z_$][\w$]*$/.test(s);
1896
+
1897
+ // Assignment-shape heads that emit as JS statements at render-block top
1898
+ // level instead of being wrapped in createTextNode. `=` declares a new
1899
+ // local; compound forms mutate an existing one.
1900
+ const _ASSIGN_HEADS = new Set([
1901
+ '=', '+=', '-=', '*=', '/=', '%=', '**=',
1902
+ '&&=', '||=', '?=', '??=',
1903
+ ]);
1904
+
1905
+ proto._isRenderBinding = function(stmt) {
1906
+ return Array.isArray(stmt) && _ASSIGN_HEADS.has(stmt[0]) && _isPlainIdentifier(stmt[1]);
1907
+ };
1908
+
1909
+ proto._addRenderLocal = function(name) {
1910
+ if (this._renderLocalScope) this._renderLocalScope.add(name);
1911
+ };
1912
+
1913
+ proto._isRenderLocal = function(name) {
1914
+ if (!name || typeof name !== 'string') return false;
1915
+ if (this._renderLocalScope && this._renderLocalScope.has(name)) return true;
1916
+ if (this._loopVarStack) {
1917
+ // Loop vars ARE threaded across nested factories as positional
1918
+ // parameters, so any ancestor loop var is in scope inside any
1919
+ // descendant factory function.
1920
+ for (const v of this._loopVarStack) {
1921
+ if (v.itemVar === name || v.indexVar === name) return true;
1922
+ }
1923
+ }
1924
+ return false;
1925
+ };
1926
+
1610
1927
  // --------------------------------------------------------------------------
1611
1928
  // emitNode — main dispatch for all render tree nodes
1612
1929
  // --------------------------------------------------------------------------
1613
1930
 
1614
1931
  proto.emitNode = function(sexpr) {
1932
+ // Render-scope assignment — `code = expr` and compound forms (`+=`,
1933
+ // `-=`, etc.) become JS statements at this position rather than text
1934
+ // nodes. Returning null tells the caller "no DOM child to append".
1935
+ // `=` also declares the local: hoisted via _factoryVars (factory mode)
1936
+ // or _renderTopLocals (class mode) and added to _renderLocalScope so
1937
+ // subsequent references resolve to the local instead of an HTML tag.
1938
+ if (this._isRenderBinding(sexpr)) {
1939
+ const [op, name, expr] = sexpr;
1940
+ const exprCode = this.emitInComponent(expr, 'value');
1941
+ if (op === '=') {
1942
+ (this._factoryMode ? this._factoryVars : this._renderTopLocals).add(name);
1943
+ this._addRenderLocal(name);
1944
+ }
1945
+ this._createLines.push(`${name} ${op} ${exprCode};`);
1946
+ return null;
1947
+ }
1948
+
1615
1949
  // String literal → text node (handle both primitive and String objects)
1616
1950
  if (typeof sexpr === 'string' || sexpr instanceof String) {
1617
1951
  const str = sexpr.valueOf();
@@ -1627,6 +1961,13 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1627
1961
  this._pushEffect(`${textVar}.data = ${this._self}.${str}.value;`);
1628
1962
  return textVar;
1629
1963
  }
1964
+ // Render-scope local (binding from `code = expr` or a loop var) — emit
1965
+ // as a value read, NOT a tag. Lexical bindings shadow HTML tag names.
1966
+ if (this._isRenderLocal(str)) {
1967
+ const textVar = this.newTextVar();
1968
+ this._createLines.push(`${textVar} = document.createTextNode(String(${str}));`);
1969
+ return textVar;
1970
+ }
1630
1971
  // Slot projection — bare <slot> tag → project @children
1631
1972
  if (str === 'slot' && this.componentMembers) {
1632
1973
  const s = this._self;
@@ -1697,8 +2038,14 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1697
2038
  return cv;
1698
2039
  }
1699
2040
 
1700
- // HTML tag (possibly with #id, e.g. div#content)
1701
- if (headStr && this.isHtmlTag(headStr) && !meta(head, 'text')) {
2041
+ // HTML tag (possibly with #id, e.g. div#content). A render-local with
2042
+ // the same name as an HTML tag wins — `code "hi"` after `code = fn`
2043
+ // is a function call, not a `<code>` element. Checking the full
2044
+ // headStr (not just the part before `#id`) means `div#main` naturally
2045
+ // dispatches as a tag even when a `div` local exists, since locals
2046
+ // are plain identifiers and can't contain `#`.
2047
+ if (headStr && this.isHtmlTag(headStr) && !meta(head, 'text') &&
2048
+ !this._isRenderLocal(headStr)) {
1702
2049
  let [tagName, id] = headStr.split('#');
1703
2050
  return this.emitTag(tagName || 'div', [], rest, id);
1704
2051
  }
@@ -1721,9 +2068,11 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1721
2068
  return slotVar;
1722
2069
  }
1723
2070
 
1724
- // HTML tag with classes (div.class) — skip if base is marked .text by = prefix
2071
+ // HTML tag with classes (div.class) — skip if base is marked .text by
2072
+ // = prefix, and skip if the root is a render-local (so `code.value`
2073
+ // after `code = obj` reads obj.value, not <code class="value">).
1725
2074
  const { tag, classes, id, base } = this.collectTemplateClasses(sexpr);
1726
- if (!meta(base, 'text') && tag && this.isHtmlTag(tag)) {
2075
+ if (!meta(base, 'text') && tag && this.isHtmlTag(tag) && !this._isRenderLocal(tag)) {
1727
2076
  return this.emitTag(tag, classes, [], id);
1728
2077
  }
1729
2078
 
@@ -1754,7 +2103,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1754
2103
  }
1755
2104
 
1756
2105
  const { tag, classes, id } = this.collectTemplateClasses(head);
1757
- if (tag && this.isHtmlTag(tag)) {
2106
+ if (tag && this.isHtmlTag(tag) && !this._isRenderLocal(tag)) {
1758
2107
  // Dynamic class syntax: div.("classes") or div.card.("classes")
1759
2108
  if (classes.length > 0 && classes[classes.length - 1] === '__clsx') {
1760
2109
  const staticClasses = classes.slice(0, -1);
@@ -1820,12 +2169,15 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1820
2169
  this.emitAttributes(elVar, child);
1821
2170
  } else {
1822
2171
  const childVar = this.emitNode(child);
2172
+ if (childVar == null) continue;
1823
2173
  this._createLines.push(`${elVar}.appendChild(${childVar});`);
1824
2174
  }
1825
2175
  }
1826
2176
  } else if (block) {
1827
2177
  const childVar = this.emitNode(block);
1828
- this._createLines.push(`${elVar}.appendChild(${childVar});`);
2178
+ if (childVar != null) {
2179
+ this._createLines.push(`${elVar}.appendChild(${childVar});`);
2180
+ }
1829
2181
  }
1830
2182
  }
1831
2183
  else if (this.is(arg, 'object')) {
@@ -1833,9 +2185,15 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1833
2185
  }
1834
2186
  else if (typeof arg === 'string' || arg instanceof String) {
1835
2187
  const val = arg.valueOf();
1836
- // Template tag appearing as a string arg (e.g., slot after multi-line attrs)
2188
+ // Template tag appearing as a string arg (e.g., slot after multi-line attrs).
2189
+ // Render-scope locals (and loop vars) take precedence over the HTML tag
2190
+ // sugar — `for code in items \n span code` should mean text content,
2191
+ // not a nested <code> element. JSX-equivalent: if `code` is a let
2192
+ // binding in scope, `<span>{code}</span>`, never `<span><code/></span>`.
2193
+ // (Render-local names are plain identifiers — no `#id` or `.class`
2194
+ // tail can ever be one — so checking the base name alone is enough.)
1837
2195
  const baseName = val.split(/[#.]/)[0];
1838
- if (this.isHtmlTag(baseName || 'div') || this.isComponent(baseName)) {
2196
+ if (!this._isRenderLocal(baseName) && (this.isHtmlTag(baseName || 'div') || this.isComponent(baseName))) {
1839
2197
  const childVar = this.emitNode(arg);
1840
2198
  this._createLines.push(`${elVar}.appendChild(${childVar});`);
1841
2199
  } else {
@@ -1855,7 +2213,9 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1855
2213
  }
1856
2214
  else if (arg) {
1857
2215
  const childVar = this.emitNode(arg);
1858
- this._createLines.push(`${elVar}.appendChild(${childVar});`);
2216
+ if (childVar != null) {
2217
+ this._createLines.push(`${elVar}.appendChild(${childVar});`);
2218
+ }
1859
2219
  }
1860
2220
  }
1861
2221
  };
@@ -2131,7 +2491,14 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2131
2491
 
2132
2492
  proto.emitTemplateBlock = function(body) {
2133
2493
  if (!Array.isArray(body) || body[0] !== 'block') {
2134
- return this.emitNode(body);
2494
+ const v = this.emitNode(body);
2495
+ if (v != null) return v;
2496
+ // Lone binding (e.g., `code = expr`) at the position where a child
2497
+ // node was expected — emit a placeholder so the caller has something
2498
+ // to insert. The binding itself was already pushed into _createLines.
2499
+ const commentVar = this.newElementVar('empty');
2500
+ this._createLines.push(`${commentVar} = document.createComment('');`);
2501
+ return commentVar;
2135
2502
  }
2136
2503
 
2137
2504
  const statements = body.slice(1);
@@ -2140,8 +2507,24 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2140
2507
  this._createLines.push(`${commentVar} = document.createComment('');`);
2141
2508
  return commentVar;
2142
2509
  }
2143
- if (statements.length === 1) {
2144
- return this.emitNode(statements[0]);
2510
+
2511
+ const renderableCount = statements.reduce(
2512
+ (n, s) => n + (this._isRenderBinding(s) ? 0 : 1), 0);
2513
+
2514
+ if (renderableCount === 0) {
2515
+ for (const stmt of statements) this.emitNode(stmt);
2516
+ const commentVar = this.newElementVar('empty');
2517
+ this._createLines.push(`${commentVar} = document.createComment('');`);
2518
+ return commentVar;
2519
+ }
2520
+
2521
+ if (renderableCount === 1) {
2522
+ let only = null;
2523
+ for (const stmt of statements) {
2524
+ const v = this.emitNode(stmt);
2525
+ if (v != null) only = v;
2526
+ }
2527
+ return only;
2145
2528
  }
2146
2529
 
2147
2530
  const fragVar = this.newElementVar('frag');
@@ -2149,6 +2532,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2149
2532
  const children = [];
2150
2533
  for (const stmt of statements) {
2151
2534
  const childVar = this.emitNode(stmt);
2535
+ if (childVar == null) continue;
2152
2536
  this._createLines.push(`${fragVar}.appendChild(${childVar});`);
2153
2537
  children.push(childVar);
2154
2538
  }
@@ -2199,8 +2583,12 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2199
2583
  setupLines.push(` const anchor = ${anchorVar};`);
2200
2584
  setupLines.push(` let currentBlock = null;`);
2201
2585
  setupLines.push(` let showing = null;`);
2586
+ // Factory effects skip auto-registration on the parent component
2587
+ // (their disposers live in the local `disposers` array and are
2588
+ // cleaned up by d(detaching)). Class-mode effects auto-register
2589
+ // on `this` via the runtime's __getCurrentComponent bridge.
2202
2590
  const effOpen = this._factoryMode ? 'disposers.push(__effect(() => {' : '__effect(() => {';
2203
- const effClose = this._factoryMode ? '}));' : '});';
2591
+ const effClose = this._factoryMode ? '}, {skipRegister: true}));' : '});';
2204
2592
  setupLines.push(` ${effOpen}`);
2205
2593
  setupLines.push(` const show = !!(${condCode});`);
2206
2594
  setupLines.push(` const want = show ? 'then' : ${elseBlock ? "'else'" : 'null'};`);
@@ -2231,8 +2619,19 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2231
2619
  setupLines.push(` }`);
2232
2620
  }
2233
2621
  setupLines.push(` ${effClose}`);
2622
+ // Block teardown: when this conditional's enclosing scope ends
2623
+ // (factory block detach, or parent component unmount), destroy
2624
+ // the currentBlock so its DOM, effects, and child components
2625
+ // are fully cleaned up. Without this, parent unmount would
2626
+ // dispose the reactive effect (preventing future re-runs) but
2627
+ // leave currentBlock alive — its child components, signal
2628
+ // subscriptions, and detached DOM stay pinned in memory.
2234
2629
  if (this._factoryMode) {
2235
2630
  setupLines.push(` disposers.push(() => { if (currentBlock) { currentBlock.d(true); currentBlock = null; } });`);
2631
+ } else {
2632
+ // Class mode: register on parent component's _disposers via the
2633
+ // __getCurrentComponent bridge (the same pathway __effect uses).
2634
+ setupLines.push(` { const __cur = globalThis.__ripComponent?.__getCurrentComponent?.(); if (__cur) (__cur._disposers ??= []).push(() => { if (currentBlock) { currentBlock.d(true); currentBlock = null; } }); }`);
2236
2635
  }
2237
2636
  setupLines.push(`}`);
2238
2637
 
@@ -2246,19 +2645,21 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2246
2645
  // --------------------------------------------------------------------------
2247
2646
 
2248
2647
  proto.emitConditionBranch = function(blockName, block) {
2249
- const saved = [this._createLines, this._setupLines, this._factoryMode, this._factoryVars];
2648
+ const saved = [this._createLines, this._setupLines, this._factoryMode, this._factoryVars, this._renderLocalScope];
2250
2649
 
2251
2650
  this._createLines = [];
2252
2651
  this._setupLines = [];
2253
2652
  this._factoryMode = true;
2254
2653
  this._factoryVars = new Set();
2654
+ // Fresh render-local scope per factory function (see emitTemplateLoop).
2655
+ this._renderLocalScope = new Set();
2255
2656
 
2256
2657
  const rootVar = this.emitTemplateBlock(block);
2257
2658
  const createLines = this._createLines;
2258
2659
  const setupLines = this._setupLines;
2259
2660
  const factoryVars = this._factoryVars;
2260
2661
 
2261
- [this._createLines, this._setupLines, this._factoryMode, this._factoryVars] = saved;
2662
+ [this._createLines, this._setupLines, this._factoryMode, this._factoryVars, this._renderLocalScope] = saved;
2262
2663
 
2263
2664
  const outerParams = this._loopVarStack.map(v => `${v.itemVar}, ${v.indexVar}`).join(', ');
2264
2665
  const extraParams = outerParams ? `, ${outerParams}` : '';
@@ -2282,6 +2683,15 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2282
2683
  if (hasEffects) {
2283
2684
  factoryLines.push(` let disposers = [];`);
2284
2685
  }
2686
+ // Per-factory list of child-component instances created inside this
2687
+ // block. d(detaching) calls .unmount() on each so child lifecycle
2688
+ // hooks fire and child effects get cleaned up when the block is
2689
+ // removed by reactive rendering (a `for` loop iteration changing,
2690
+ // an `if` branch flipping, etc.). Without this, child instances
2691
+ // were only ever unmounted when the OUTER component itself died,
2692
+ // which silently broke the `beforeUnmount` / `unmounted` contract
2693
+ // for any child rendered inside a conditional or loop.
2694
+ factoryLines.push(` let _factoryChildren = [];`);
2285
2695
 
2286
2696
  factoryLines.push(` return {`);
2287
2697
 
@@ -2320,6 +2730,12 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2320
2730
  factoryLines.push(` },`);
2321
2731
 
2322
2732
  factoryLines.push(` d(detaching) {`);
2733
+ // Unmount any child components inside this block FIRST, so their
2734
+ // lifecycle hooks and effect disposers fire before we drop our own
2735
+ // disposers and DOM. removeDOM:false because the parent DOM removal
2736
+ // (below) will detach the whole subtree in one shot.
2737
+ factoryLines.push(` for (const __c of _factoryChildren) { try { __c.unmount?.({removeDOM: false}); } catch (__e) { console.error('[Rip] factory child unmount error:', __e); } }`);
2738
+ factoryLines.push(` _factoryChildren = [];`);
2323
2739
  if (hasEffects) {
2324
2740
  factoryLines.push(` disposers.forEach(d => d());`);
2325
2741
  }
@@ -2355,12 +2771,27 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2355
2771
  const itemVar = varNames[0];
2356
2772
  let indexVar = varNames[1] || null;
2357
2773
  if (!indexVar) {
2774
+ // Pick a name that won't collide with:
2775
+ // * outer loop vars (already in _loopVarStack)
2776
+ // * the current item var
2777
+ // * any explicit `for` var or render-local declaration ANYWHERE in
2778
+ // the body subtree (any depth, through conditionals and nested
2779
+ // loops alike)
2780
+ // The third check is what closes the "duplicate-name" family of
2781
+ // strict-mode errors: the outer loop's auto-index becomes a
2782
+ // positional parameter of every nested factory's patch function, so
2783
+ // any `let i;` (from `i = ...`) or any explicit `for v, i in ...`
2784
+ // anywhere inside would clash with an auto-allocated `i`.
2358
2785
  const usedNames = new Set(this._loopVarStack.flatMap(v => [v.itemVar, v.indexVar]));
2359
2786
  usedNames.add(itemVar);
2787
+ this._collectBodyBindings(body, usedNames);
2360
2788
  for (const candidate of ['i', 'j', 'k', 'l', 'm', 'n']) {
2361
2789
  if (!usedNames.has(candidate)) { indexVar = candidate; break; }
2362
2790
  }
2363
- indexVar = indexVar || `_i${this._loopVarStack.length}`;
2791
+ // Last-resort fallback uses a mangled name no normal user identifier
2792
+ // collides with (double underscore prefix matches the convention used
2793
+ // by other compiler-internal names like __ripComponent, __reconcile).
2794
+ indexVar = indexVar || `__rip_idx${this._loopVarStack.length}`;
2364
2795
  }
2365
2796
 
2366
2797
  const collectionCode = this.emitInComponent(collection, 'value');
@@ -2385,24 +2816,34 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2385
2816
  }
2386
2817
  }
2387
2818
 
2388
- const saved = [this._createLines, this._setupLines, this._factoryMode, this._factoryVars];
2819
+ const saved = [this._createLines, this._setupLines, this._factoryMode, this._factoryVars, this._renderLocalScope];
2389
2820
 
2390
2821
  this._createLines = [];
2391
2822
  this._setupLines = [];
2392
2823
  this._factoryMode = true;
2393
2824
  this._factoryVars = new Set();
2825
+ // Loop body is its own JS factory function — render locals from the
2826
+ // surrounding scope are NOT visible here. Loop vars are explicitly
2827
+ // threaded as positional parameters via _loopVarStack.
2828
+ this._renderLocalScope = new Set();
2394
2829
 
2395
2830
  const outerParams = this._loopVarStack.map(v => `${v.itemVar}, ${v.indexVar}`).join(', ');
2396
2831
  const outerExtra = outerParams ? `, ${outerParams}` : '';
2397
2832
 
2398
- this._loopVarStack.push({ itemVar, indexVar });
2833
+ // If the iterated collection is reactive, treat direct member access
2834
+ // on the iter var (`item.qty`, `item[0]`, `item.a.b`) as reactive
2835
+ // inside the loop body. hasReactiveDeps consults this flag, so the
2836
+ // existing emit paths wrap those reads in __effect and the per-row
2837
+ // patch function gets populated.
2838
+ const reactiveSource = this.hasReactiveDeps(collection);
2839
+ this._loopVarStack.push({ itemVar, indexVar, reactiveSource });
2399
2840
  const itemNode = this.emitTemplateBlock(body);
2400
2841
  this._loopVarStack.pop();
2401
2842
  const itemCreateLines = this._createLines;
2402
2843
  const itemSetupLines = this._setupLines;
2403
2844
  const itemFactoryVars = this._factoryVars;
2404
2845
 
2405
- [this._createLines, this._setupLines, this._factoryMode, this._factoryVars] = saved;
2846
+ [this._createLines, this._setupLines, this._factoryMode, this._factoryVars, this._renderLocalScope] = saved;
2406
2847
 
2407
2848
  const isStatic = itemSetupLines.length === 0;
2408
2849
  const loopParams = `ctx, ${itemVar}, ${indexVar}${outerExtra}`;
@@ -2420,11 +2861,23 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2420
2861
  setupLines.push(`// Loop: ${blockName}`);
2421
2862
  setupLines.push(`{`);
2422
2863
  setupLines.push(` const __s = { blocks: [], keys: [] };`);
2864
+ // Same skipRegister contract as the conditional emitter above.
2423
2865
  const effOpen = this._factoryMode ? 'disposers.push(__effect(() => {' : '__effect(() => {';
2424
- const effClose = this._factoryMode ? '}));' : '});';
2866
+ const effClose = this._factoryMode ? '}, {skipRegister: true}));' : '});';
2425
2867
  setupLines.push(` ${effOpen}`);
2426
2868
  setupLines.push(` __reconcile(${anchorVar}, __s, ${collectionCode}, ${this._self}, ${blockName}, ${keyFnCode}${outerArgs});`);
2427
2869
  setupLines.push(` ${effClose}`);
2870
+ // Loop teardown: destroy every block in state.blocks on parent
2871
+ // unmount (or enclosing factory detach). __reconcile only destroys
2872
+ // blocks that are removed mid-render; blocks that exist at the
2873
+ // time the parent dies would otherwise leak their DOM, child
2874
+ // components, and effect subscriptions. Same shape as the
2875
+ // conditional emitter's block-teardown disposer.
2876
+ if (this._factoryMode) {
2877
+ setupLines.push(` disposers.push(() => { for (const __b of __s.blocks) { try { __b.d(true); } catch {} } __s.blocks = []; __s.keys = []; __s.items = []; });`);
2878
+ } else {
2879
+ setupLines.push(` { const __cur = globalThis.__ripComponent?.__getCurrentComponent?.(); if (__cur) (__cur._disposers ??= []).push(() => { for (const __b of __s.blocks) { try { __b.d(true); } catch {} } __s.blocks = []; __s.keys = []; __s.items = []; }); }`);
2880
+ }
2428
2881
  setupLines.push(`}`);
2429
2882
 
2430
2883
  this._setupLines.push(setupLines.join('\n '));
@@ -2443,21 +2896,97 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2443
2896
  const { propsCode, reactiveProps, eventBindings, childrenSetupLines } = this.buildComponentProps(args);
2444
2897
 
2445
2898
  const s = this._self;
2899
+ // Push parent (s) so its constructor-time __pushComponent stack is
2900
+ // preserved across child instantiation. Then push the CHILD around
2901
+ // its _create() / _setup() calls so any __effect those methods
2902
+ // create auto-registers on the child's _disposers, not the parent's.
2903
+ //
2904
+ // Partial-construction failure handling. Two cases produce a broken
2905
+ // child whose _create/_setup would crash:
2906
+ // 1. _init throws and a parent onError boundary handles it. The
2907
+ // constructor sets ._initFailed and returns the (broken)
2908
+ // instance. We detect via the flag.
2909
+ // 2. _init throws and no boundary handles it. __handleComponentError
2910
+ // re-throws from the constructor; OR _create itself throws
2911
+ // after a successful _init. The outer try/catch handles both.
2912
+ // In all failure modes we substitute a comment-node placeholder so
2913
+ // the parent's appendChild later still finds a valid node, log the
2914
+ // error, and don't push the broken instance onto _children (so the
2915
+ // unmount cascade doesn't walk into it).
2446
2916
  this._createLines.push(`{ const __prev = __pushComponent(${s}); try {`);
2917
+ this._createLines.push(`try {`);
2447
2918
  this._createLines.push(`${instVar} = new ${componentName}(${propsCode});`);
2448
- this._createLines.push(`${elVar} = ${instVar}._root = ${instVar}._create();`);
2449
- this._createLines.push(`(${s}._children || (${s}._children = [])).push(${instVar});`);
2919
+ this._createLines.push(`if (${instVar} && ${instVar}._initFailed) {`);
2920
+ // _init may have registered effects, contexts, or sub-children
2921
+ // before throwing. unmount({removeDOM:false}) releases them; the
2922
+ // instance never reached a usable state but its partial side
2923
+ // effects need cleanup. Idempotent so safe even when nothing was
2924
+ // registered yet.
2925
+ this._createLines.push(` try { ${instVar}.unmount({removeDOM: false}); } catch (__ue) { console.error('[Rip] partial-init unmount error:', __ue); }`);
2926
+ this._createLines.push(` ${instVar} = null;`);
2927
+ this._createLines.push(` ${elVar} = document.createComment('rip:child-init-failed: ${componentName}');`);
2928
+ this._createLines.push(`} else {`);
2929
+ this._createLines.push(` { const __cprev = __pushComponent(${instVar}); try {`);
2930
+ this._createLines.push(` ${elVar} = ${instVar}._root = ${instVar}._create();`);
2931
+ this._createLines.push(` } finally { __popComponent(__cprev); } }`);
2932
+ if (this._factoryMode) {
2933
+ // Factory blocks (for/if in render) own their child instances
2934
+ // exclusively. Don't pin them on the PARENT's _children — that
2935
+ // array would grow unboundedly on loop churn (every removed
2936
+ // iteration would leave a stale reference). The factory's
2937
+ // d(detaching) iterates _factoryChildren and unmounts them; on
2938
+ // parent unmount, the parent's own disposer chain destroys the
2939
+ // factory block, which cascades to these children via d().
2940
+ this._createLines.push(` _factoryChildren.push(${instVar});`);
2941
+ } else {
2942
+ // Class-mode children: parent's _children is the canonical owner.
2943
+ // Parent.unmount() cascades to _children for proper teardown.
2944
+ this._createLines.push(` (${s}._children || (${s}._children = [])).push(${instVar});`);
2945
+ }
2946
+ this._createLines.push(`}`);
2947
+ this._createLines.push(`} catch (__childErr) {`);
2948
+ this._createLines.push(` console.error('[Rip] ${componentName} construction failed:', __childErr);`);
2949
+ // If _init succeeded but _create threw, the partial instance has
2950
+ // _init-time effects, contexts, and possibly children registered.
2951
+ // Unmount it (with removeDOM:false; nothing was inserted into DOM
2952
+ // yet) so those resources release. unmount is idempotent so
2953
+ // redundant calls are safe.
2954
+ this._createLines.push(` if (${instVar}) { try { ${instVar}.unmount({removeDOM: false}); } catch (__ue) { console.error('[Rip] partial-child unmount error:', __ue); } }`);
2955
+ this._createLines.push(` ${instVar} = null;`);
2956
+ this._createLines.push(` ${elVar} = document.createComment('rip:child-error: ${componentName}');`);
2957
+ this._createLines.push(`}`);
2450
2958
  this._createLines.push(`} finally { __popComponent(__prev); } }`);
2451
2959
 
2452
2960
  for (const { event, value } of eventBindings) {
2453
2961
  const handlerCode = this.emitInComponent(value, 'value');
2454
- this._createLines.push(`${elVar}.addEventListener('${event}', (e) => __batch(() => (${handlerCode})(e)));`);
2455
- }
2456
-
2457
- this._setupLines.push(`try { if (${instVar}._setup) ${instVar}._setup(); if (${instVar}.mounted) ${instVar}.mounted(); } catch (__e) { __handleComponentError(__e, ${instVar}); }`);
2962
+ this._createLines.push(`if (${instVar}) ${elVar}.addEventListener('${event}', (e) => __batch(() => (${handlerCode})(e)));`);
2963
+ }
2964
+
2965
+ // Per-child push wrap for the full lifecycle invocation. ALL of
2966
+ // beforeMount, _setup, and mounted run with the child as the
2967
+ // current component, so any __effect they create auto-registers
2968
+ // on the child's _disposers (via the __getCurrentComponent
2969
+ // bridge), not the parent's. Guards:
2970
+ // - instVar null-check (failed-init placeholder branch above).
2971
+ // - _isSetup flag so the lifecycle runs ONCE even when this
2972
+ // setupLines block is re-executed by a factory's p() on every
2973
+ // reactive update. Class mode runs setupLines once anyway;
2974
+ // the flag is harmless there.
2975
+ // - Flag set BEFORE the calls so a recursive setup couldn't
2976
+ // re-enter and loop.
2977
+ // - beforeMount fires before _setup so user code has a hook
2978
+ // after construction but before reactive bindings activate
2979
+ // (matches the renderer's contract for page/layout
2980
+ // components).
2981
+ this._setupLines.push(`if (${instVar} && !${instVar}._isSetup) { ${instVar}._isSetup = true; const __cprev = __pushComponent(${instVar}); try { try { if (${instVar}.beforeMount) ${instVar}.beforeMount(); if (${instVar}._setup) ${instVar}._setup(); if (${instVar}.mounted) ${instVar}.mounted(); } catch (__e) { __handleComponentError(__e, ${instVar}); } } finally { __popComponent(__cprev); } }`);
2458
2982
 
2459
2983
  for (const { key, valueCode } of reactiveProps) {
2460
- this._pushEffect(`if (${instVar}.${key} && typeof ${instVar}.${key} === 'object' && 'value' in ${instVar}.${key}) ${instVar}.${key}.value = ${valueCode}; else if (${instVar}._setRestProp) ${instVar}._setRestProp('${key}', ${valueCode});`);
2984
+ // Outer instVar guard: if _init / _create failed and the
2985
+ // placeholder branch substituted null for the instance, this
2986
+ // prop-updater effect would otherwise dereference null on every
2987
+ // signal change. The guard mirrors the event-listener emission
2988
+ // a few lines above.
2989
+ this._pushEffect(`if (${instVar}) { if (${instVar}.${key} && typeof ${instVar}.${key} === 'object' && 'value' in ${instVar}.${key}) ${instVar}.${key}.value = ${valueCode}; else if (${instVar}._setRestProp) ${instVar}._setRestProp('${key}', ${valueCode}); }`);
2461
2990
  }
2462
2991
 
2463
2992
  for (const line of childrenSetupLines) {
@@ -2512,9 +3041,24 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2512
3041
  }
2513
3042
  };
2514
3043
 
3044
+ // Bare identifier args become boolean prop shorthand:
3045
+ // Button outline, link, "Save"
3046
+ // is equivalent to:
3047
+ // Button outline: true, link: true, "Save"
3048
+ // Matches JSX semantics — the bare identifier is ALWAYS a literal `true`
3049
+ // prop key, never a variable reference, even if a same-named local
3050
+ // binding exists in scope. To pass a variable, write `outline: outline`.
3051
+ // Scoped to PascalCase component calls (this function's only caller is
3052
+ // emitChildComponent), so DOM element calls and non-render imperative
3053
+ // calls are unaffected.
3054
+ const BARE_IDENT_RE = /^[a-zA-Z_$][\w$]*$/;
3055
+ const isBareIdent = (a) => typeof a === 'string' && BARE_IDENT_RE.test(a);
3056
+
2515
3057
  for (const arg of args) {
2516
3058
  if (this.is(arg, 'object')) {
2517
3059
  addObjectProps(arg);
3060
+ } else if (isBareIdent(arg)) {
3061
+ addProp(arg, 'true');
2518
3062
  } else if (Array.isArray(arg) && (arg[0] === '->' || arg[0] === '=>')) {
2519
3063
  let block = arg[2];
2520
3064
  if (block) {
@@ -2531,6 +3075,10 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2531
3075
  }
2532
3076
 
2533
3077
  if (block) {
3078
+ // Save _createLines/_setupLines only — _factoryVars and
3079
+ // _renderLocalScope are intentionally shared because the
3080
+ // children-block JS is spliced back into the parent factory's
3081
+ // body (same JS function, same lexical scope).
2534
3082
  const savedCreateLines = this._createLines;
2535
3083
  const savedSetupLines = this._setupLines;
2536
3084
  this._createLines = [];
@@ -2558,7 +3106,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2558
3106
  this._createLines.push(`${textVar} = document.createTextNode('');`);
2559
3107
  const body = `${textVar}.data = ${exprCode};`;
2560
3108
  const effect = this._factoryMode
2561
- ? `disposers.push(__effect(() => { ${body} }));`
3109
+ ? `disposers.push(__effect(() => { ${body} }, {skipRegister: true}));`
2562
3110
  : `__effect(() => { ${body} });`;
2563
3111
  childrenSetupLines.push(effect);
2564
3112
  } else {
@@ -2598,6 +3146,16 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2598
3146
  return true;
2599
3147
  }
2600
3148
 
3149
+ // Property chain rooted at a `for`-loop iter var whose source is
3150
+ // reactive (e.g. `for item in cart.items` → `item.qty`, `item[0]`,
3151
+ // `item.foo.bar`). Same rationale as the this-rooted case: the source
3152
+ // proxy is reactive at runtime, so any member read may track. Limited
3153
+ // to direct member access on the iter var — aliases and destructuring
3154
+ // are explicitly out of scope.
3155
+ if ((sexpr[0] === '.' || sexpr[0] === '[]') && this._rootsAtReactiveLoopVar(sexpr)) {
3156
+ return true;
3157
+ }
3158
+
2601
3159
  // Method call on component: [['.', 'this', method], ...args]
2602
3160
  // Methods may read reactive state internally — treat as reactive so the
2603
3161
  // call gets wrapped in __effect and re-runs when dependencies change.
@@ -2613,6 +3171,32 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2613
3171
  return false;
2614
3172
  };
2615
3173
 
3174
+ // _collectBodyBindings — gather every name bound inside a render subtree
3175
+ // --------------------------------------------------------------------------
3176
+ // Used by emitTemplateLoop to skip auto-allocating an outer index name
3177
+ // that would later show up as either a positional parameter (explicit
3178
+ // `for-in`/`for-of`/`for-as` var at any depth) or a hoisted `let`
3179
+ // (`name = expr` at any depth) inside a descendant factory function. A
3180
+ // collision at any nesting depth is real because the outer index is
3181
+ // threaded as a positional parameter of EVERY descendant factory.
3182
+
3183
+ proto._collectBodyBindings = function(node, set) {
3184
+ if (!Array.isArray(node)) return;
3185
+ const head = node[0];
3186
+ if (head === 'for-in' || head === 'for-of' || head === 'for-as') {
3187
+ const vars = node[1];
3188
+ const names = Array.isArray(vars) ? vars : [vars];
3189
+ for (const n of names) {
3190
+ if (typeof n === 'string') set.add(n);
3191
+ }
3192
+ } else if (head === '=' && _isPlainIdentifier(node[1])) {
3193
+ set.add(node[1]);
3194
+ }
3195
+ for (let i = 1; i < node.length; i++) {
3196
+ this._collectBodyBindings(node[i], set);
3197
+ }
3198
+ };
3199
+
2616
3200
  // isSimpleAssignable — check if value is a plain reactive member (assignable target)
2617
3201
  // --------------------------------------------------------------------------
2618
3202
 
@@ -2653,6 +3237,26 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2653
3237
  return this._rootsAtThis(sexpr[1]);
2654
3238
  };
2655
3239
 
3240
+ // _rootsAtReactiveLoopVar — true when a `.` / `[]` access chain bottoms
3241
+ // out at a string identifier that matches the itemVar of some frame on
3242
+ // _loopVarStack whose `reactiveSource` is true. See emitTemplateLoop.
3243
+ proto._rootsAtReactiveLoopVar = function(sexpr) {
3244
+ if (typeof sexpr === 'string') {
3245
+ if (!this._loopVarStack || this._loopVarStack.length === 0) return false;
3246
+ // Iterate innermost-first so a shadowed name resolves to its nearest
3247
+ // binding: `for item in reactive` containing `for item in [1,2,3]`
3248
+ // must treat inner reads of `item` as non-reactive.
3249
+ for (let i = this._loopVarStack.length - 1; i >= 0; i--) {
3250
+ const v = this._loopVarStack[i];
3251
+ if (v.itemVar === sexpr) return !!v.reactiveSource;
3252
+ }
3253
+ return false;
3254
+ }
3255
+ if (!Array.isArray(sexpr)) return false;
3256
+ if (sexpr[0] === '.' || sexpr[0] === '[]') return this._rootsAtReactiveLoopVar(sexpr[1]);
3257
+ return false;
3258
+ };
3259
+
2656
3260
  // ==========================================================================
2657
3261
  // Component Runtime
2658
3262
  // ==========================================================================
@@ -2670,8 +3274,21 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2670
3274
  let __currentComponent = null;
2671
3275
 
2672
3276
  function __pushComponent(component) {
2673
- component._parent = __currentComponent;
3277
+ // The component stack tracks the currently-active scope (so __effect
3278
+ // and friends can find it). Parent assignment happens ONCE — on the
3279
+ // first push that has a non-self predecessor. Later pushes (mount,
3280
+ // beforeMount, factory re-entry) preserve the existing chain.
3281
+ //
3282
+ // Without the "set once" guard, the renderer's careful threading of
3283
+ // outer-layout -> inner-layout -> page would survive construction
3284
+ // but get clobbered the moment any of those components got re-pushed
3285
+ // for its own lifecycle — a subsequent push with prev=null would
3286
+ // overwrite the construction-time parent. Cross-layout context
3287
+ // (offer in outer / accept in page) was silently broken.
2674
3288
  const prev = __currentComponent;
3289
+ if (component && component._parent == null && prev && prev !== component) {
3290
+ component._parent = prev;
3291
+ }
2675
3292
  __currentComponent = component;
2676
3293
  return prev;
2677
3294
  }
@@ -2680,6 +3297,13 @@ function __popComponent(prev) {
2680
3297
  __currentComponent = prev;
2681
3298
  }
2682
3299
 
3300
+ // Bridge for the reactive runtime in compiler.js. __effect calls this to
3301
+ // auto-register its disposer with the component currently being
3302
+ // constructed/mounted, so the disposer fires on component unmount.
3303
+ function __getCurrentComponent() {
3304
+ return __currentComponent;
3305
+ }
3306
+
2683
3307
  function setContext(key, value) {
2684
3308
  if (!__currentComponent) throw new Error('setContext must be called during component initialization');
2685
3309
  if (!__currentComponent._context) __currentComponent._context = new Map();
@@ -2688,7 +3312,11 @@ function setContext(key, value) {
2688
3312
 
2689
3313
  function getContext(key) {
2690
3314
  let component = __currentComponent;
2691
- while (component) {
3315
+ // Cycle guard: see __handleComponentError above. A buggy _parent
3316
+ // chain shouldn't hang lookup of a missing key.
3317
+ const visited = new Set();
3318
+ while (component && !visited.has(component)) {
3319
+ visited.add(component);
2692
3320
  if (component._context && component._context.has(key)) return component._context.get(key);
2693
3321
  component = component._parent;
2694
3322
  }
@@ -2697,7 +3325,9 @@ function getContext(key) {
2697
3325
 
2698
3326
  function hasContext(key) {
2699
3327
  let component = __currentComponent;
2700
- while (component) {
3328
+ const visited = new Set();
3329
+ while (component && !visited.has(component)) {
3330
+ visited.add(component);
2701
3331
  if (component._context && component._context.has(key)) return true;
2702
3332
  component = component._parent;
2703
3333
  }
@@ -2744,6 +3374,7 @@ function __reconcile(anchor, state, items, ctx, factory, keyFn, ...outer) {
2744
3374
  if (!parent) return;
2745
3375
 
2746
3376
  const oldKeys = state.keys;
3377
+ const oldItems = state.items || [];
2747
3378
  const oldBlocks = state.blocks;
2748
3379
  const oldLen = oldKeys.length;
2749
3380
  const newLen = items.length;
@@ -2765,14 +3396,24 @@ function __reconcile(anchor, state, items, ctx, factory, keyFn, ...outer) {
2765
3396
  parent.insertBefore(frag, anchor);
2766
3397
  }
2767
3398
  state.keys = hasKeyFn ? newKeys : items.slice();
3399
+ state.items = items.slice();
2768
3400
  state.blocks = newBlocks;
2769
3401
  return;
2770
3402
  }
2771
3403
 
2772
- // Phase 1: prefix scan — skip p() (item+index identical, effects already live)
3404
+ // Phase 1: prefix scan — skip p() ONLY when key AND item identity
3405
+ // match. With a custom keyFn, a stable key can be reused across
3406
+ // different item references (e.g. when the user replaces an item
3407
+ // object with a new one that has the same id but different fields);
3408
+ // skipping p() in that case would leave the block displaying stale
3409
+ // data. Reference identity guards this.
2773
3410
  let start = 0;
2774
3411
  const minLen = oldLen < newLen ? oldLen : newLen;
2775
3412
  while (start < minLen && oldKeys[start] === newKeys[start]) {
3413
+ if (oldItems[start] !== items[start]) {
3414
+ const block = oldBlocks[start];
3415
+ if (!block._s) block.p(ctx, items[start], start, ...outer);
3416
+ }
2776
3417
  newBlocks[start] = oldBlocks[start];
2777
3418
  start++;
2778
3419
  }
@@ -2842,6 +3483,7 @@ function __reconcile(anchor, state, items, ctx, factory, keyFn, ...outer) {
2842
3483
  }
2843
3484
 
2844
3485
  state.keys = hasKeyFn ? newKeys : items.slice();
3486
+ state.items = items.slice();
2845
3487
  state.blocks = newBlocks;
2846
3488
  }
2847
3489
 
@@ -2886,7 +3528,13 @@ function __transition(el, name, dir, done) {
2886
3528
 
2887
3529
  function __handleComponentError(error, component) {
2888
3530
  let current = component;
2889
- while (current) {
3531
+ // Defensive cycle guard: if the parent chain is corrupted (e.g. by
3532
+ // a buggy _parent assignment), we still terminate. The fix is in
3533
+ // __pushComponent above, but cycle detection here is cheap belt-
3534
+ // and-suspenders so a bad _parent never hangs the runtime.
3535
+ const visited = new Set();
3536
+ while (current && !visited.has(current)) {
3537
+ visited.add(current);
2890
3538
  if (current.onError) {
2891
3539
  try { current.onError(error, component); return; } catch (_) {}
2892
3540
  }
@@ -2900,13 +3548,36 @@ class __Component {
2900
3548
  Object.assign(this, props);
2901
3549
  if (!this.app && globalThis.__ripApp) this.app = globalThis.__ripApp;
2902
3550
  const prev = __pushComponent(this);
2903
- try { this._init(props); } catch (e) { __popComponent(prev); __handleComponentError(e, this); return; }
3551
+ try {
3552
+ this._init(props);
3553
+ } catch (e) {
3554
+ __popComponent(prev);
3555
+ // Mark this instance as having failed initialization so parent
3556
+ // emit-sites (emitChildComponent) can substitute a placeholder
3557
+ // instead of running _create / _setup on a broken instance.
3558
+ this._initFailed = true;
3559
+ // Run the user's error hook (parent-onError walk). If a boundary
3560
+ // handles it, control returns here and we leave the broken
3561
+ // instance to be handled via _initFailed. If no boundary exists,
3562
+ // __handleComponentError re-throws and the caller's outer
3563
+ // try/catch in emitChildComponent will substitute the same
3564
+ // placeholder.
3565
+ __handleComponentError(e, this);
3566
+ return;
3567
+ }
2904
3568
  __popComponent(prev);
2905
3569
  }
2906
3570
  _init() {}
2907
3571
  mount(target) {
2908
3572
  if (typeof target === "string") target = document.querySelector(target);
2909
3573
  this._target = target;
3574
+ // _create / _setup are wrapped with __pushComponent so any __effect
3575
+ // created inside (reactive attribute bindings, reactive text nodes,
3576
+ // child components, user '~>' effects) auto-registers its disposer
3577
+ // with this component via __getCurrentComponent in the runtime.
3578
+ // Without this wrapping, effects created here lived forever and
3579
+ // their cleanup functions never fired on unmount.
3580
+ const prev = __pushComponent(this);
2910
3581
  try {
2911
3582
  this._root = this._create();
2912
3583
  if (this._root) target.appendChild(this._root);
@@ -2914,17 +3585,52 @@ class __Component {
2914
3585
  if (this.mounted) this.mounted();
2915
3586
  } catch (error) {
2916
3587
  __handleComponentError(error, this);
3588
+ } finally {
3589
+ __popComponent(prev);
2917
3590
  }
2918
3591
  return this;
2919
3592
  }
2920
- unmount() {
3593
+ unmount({ removeDOM = true } = {}) {
3594
+ // Symmetric to mount: tear down lifecycle hooks, all auto-registered
3595
+ // effect disposers, child components, and (optionally) the DOM.
3596
+ //
3597
+ // beforeUnmount - user hook; runs while signals/effects are still
3598
+ // live, so user code can read final state.
3599
+ // children - cascade BEFORE this instance's disposers so
3600
+ // children can react to parent state during their
3601
+ // own teardown.
3602
+ // _disposers - effect disposers auto-registered by __effect
3603
+ // (cleared eagerly so a re-mount starts fresh).
3604
+ // unmounted - user hook; final notification.
3605
+ // DOM removal - skipped when caller wants to keep the old DOM
3606
+ // visible until replacement (route transitions).
3607
+ //
3608
+ // Idempotent: a child can be unmounted by its enclosing factory's
3609
+ // d(detaching) AND later by the parent's unmount cascade. Without
3610
+ // the _unmounted guard, beforeUnmount/unmounted hooks would re-fire
3611
+ // and cleanup would walk an already-empty graph for no benefit.
3612
+ if (this._unmounted) return;
3613
+ this._unmounted = true;
3614
+ try {
3615
+ if (this.beforeUnmount) this.beforeUnmount();
3616
+ } catch (e) { console.error('[Rip] beforeUnmount error:', e); }
2921
3617
  if (this._children) {
2922
3618
  for (const child of this._children) {
2923
- child.unmount();
3619
+ try { child.unmount({ removeDOM }); }
3620
+ catch (e) { console.error('[Rip] child unmount error:', e); }
2924
3621
  }
3622
+ this._children = null;
2925
3623
  }
2926
- if (this.unmounted) this.unmounted();
2927
- if (this._root && this._root.parentNode) {
3624
+ if (this._disposers) {
3625
+ for (const d of this._disposers) {
3626
+ try { d(); } catch (e) { console.error('[Rip] effect disposer error:', e); }
3627
+ }
3628
+ this._disposers = null;
3629
+ }
3630
+ try {
3631
+ if (this.unmounted) this.unmounted();
3632
+ } catch (e) { console.error('[Rip] unmounted error:', e); }
3633
+ if (removeDOM && this._root && this._root.parentNode) {
2928
3634
  this._root.parentNode.removeChild(this._root);
2929
3635
  }
2930
3636
  }
@@ -2940,7 +3646,7 @@ class __Component {
2940
3646
 
2941
3647
  // Register on globalThis for runtime deduplication
2942
3648
  if (typeof globalThis !== 'undefined') {
2943
- globalThis.__ripComponent = { __pushComponent, __popComponent, setContext, getContext, hasContext, __clsx, __lis, __reconcile, __transition, __handleComponentError, __Component };
3649
+ globalThis.__ripComponent = { __pushComponent, __popComponent, __getCurrentComponent, setContext, getContext, hasContext, __clsx, __lis, __reconcile, __transition, __handleComponentError, __Component };
2944
3650
  }
2945
3651
 
2946
3652
  `;