rip-lang 3.17.4 → 3.17.5

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/src/components.js CHANGED
@@ -221,6 +221,11 @@ export function installComponentSupport(CodeEmitter, Lexer) {
221
221
  let renderIndentLevel = 0;
222
222
  let currentIndent = 0;
223
223
  let pendingCallEnds = [];
224
+ // Indent levels at which an element's attribute/child block is directly
225
+ // open. A bare `@event` at one of these levels (statement start) is a
226
+ // continuation directive of that element. Pushed when implicit nesting
227
+ // opens an element body; popped on OUTDENT past the level.
228
+ let elementBodyLevels = [];
224
229
 
225
230
  let isHtmlTag = (name) => {
226
231
  let tagPart = name.split('#')[0];
@@ -292,6 +297,53 @@ export function installComponentSupport(CodeEmitter, Lexer) {
292
297
  (j === 0 || tokens[j - 1][0] === 'INDENT' || tokens[j - 1][0] === 'TERMINATOR' || tokens[j - 1][0] === 'RENDER'));
293
298
  };
294
299
 
300
+ // Net unmatched-opener test: is token `at` nested inside an explicit
301
+ // ()/[]/{}/CALL/INDEX/interpolation/string group (depth > 0), or directly
302
+ // at the element-head level (depth 0)? Walks back to the line/block
303
+ // boundary at the current nesting level.
304
+ let explicitDepthAt = (tokens, at) => {
305
+ let depth = 0;
306
+ for (let k = at - 1; k >= 0; k--) {
307
+ let t = tokens[k][0];
308
+ if (t === ')' || t === 'CALL_END' || t === ']' || t === 'INDEX_END' ||
309
+ t === '}' || t === 'MAP_END' || t === 'INTERPOLATION_END' || t === 'STRING_END') {
310
+ depth++;
311
+ } else if (t === '(' || t === 'CALL_START' || t === '[' || t === 'INDEX_START' ||
312
+ t === '{' || t === 'MAP_START' || t === 'INTERPOLATION_START' || t === 'STRING_START') {
313
+ if (depth === 0) return 1;
314
+ depth--;
315
+ } else if (t === 'TERMINATOR' || t === 'RENDER' || t === 'INDENT' || t === 'OUTDENT') {
316
+ break;
317
+ }
318
+ }
319
+ return 0;
320
+ };
321
+
322
+ // Is the `@` token at index `at` a bare event directive — i.e. a directive in
323
+ // its own right, NOT a `@member` buried inside an attribute value? It fires
324
+ // only at a genuine argument/attribute boundary, never inside a value
325
+ // expression. Requires explicit-bracket depth 0, and one of:
326
+ // (1) tag-head argument: `@event` is a NEW arg, so its previous token is
327
+ // the tag-expression end (IDENTIFIER like `button`/`div#id`, or a
328
+ // `.class`/`#id` tail PROPERTY) or a `,` separator — AND startsWithTag
329
+ // confirms tag context. A value token before it (`:`, `!`, `?`, an
330
+ // operator, `(`, a string/number…) means it's an attribute VALUE
331
+ // (e.g. `aria-pressed: !!@pressed`, `value: @x`) — NOT a directive.
332
+ // (2) element-body statement start: a line beginning `@click` at the
333
+ // direct attribute/child indent of an open element.
334
+ // Bare `@member` is no longer a text child anywhere — `= @member` renders
335
+ // text — so a bare `@name` in a directive slot is unconditionally a directive
336
+ // (the emitter errors if `name` isn't a real DOM event).
337
+ let isBareEventAttr = (tokens, at) => {
338
+ let prev = at > 0 ? tokens[at - 1] : null;
339
+ if (!prev) return false;
340
+ if (explicitDepthAt(tokens, at) !== 0) return false;
341
+ let pt = prev[0];
342
+ if ((pt === ',' || pt === 'IDENTIFIER' || pt === 'PROPERTY') && startsWithTag(tokens, at)) return true;
343
+ if ((pt === 'INDENT' || pt === 'TERMINATOR') && elementBodyLevels.includes(currentIndent)) return true;
344
+ return false;
345
+ };
346
+
295
347
  this.scanTokens(function(token, i, tokens) {
296
348
  let tag = token[0];
297
349
  let nextToken = i < tokens.length - 1 ? tokens[i + 1] : null;
@@ -312,6 +364,10 @@ export function installComponentSupport(CodeEmitter, Lexer) {
312
364
  if (tag === 'OUTDENT') {
313
365
  currentIndent--;
314
366
 
367
+ while (elementBodyLevels.length > 0 && elementBodyLevels[elementBodyLevels.length - 1] > currentIndent) {
368
+ elementBodyLevels.pop();
369
+ }
370
+
315
371
  // Insert pending CALL_END(s) after this OUTDENT
316
372
  let inserted = 0;
317
373
  while (pendingCallEnds.length > 0 && pendingCallEnds[pendingCallEnds.length - 1] > currentIndent) {
@@ -467,10 +523,27 @@ export function installComponentSupport(CodeEmitter, Lexer) {
467
523
  if (tag === '@') {
468
524
  let j = i + 1;
469
525
  if (j < tokens.length && tokens[j][0] === 'PROPERTY') {
526
+ let eventName = String(tokens[j][1]);
470
527
  j++;
471
528
  while (j + 1 < tokens.length && tokens[j][0] === '.' && tokens[j + 1][0] === 'PROPERTY') {
472
529
  j += 2;
473
530
  }
531
+ let hadChain = j > i + 2; // a `.PROPERTY` chain — a member path (`@toast.title`) or modifier form
532
+ // Bare event shorthand: a plain `@click` (no `.` chain, no `: handler`)
533
+ // in a directive position (tag head or element-body statement) desugars
534
+ // to `@click: @onClick` — a `this`-member reference, so it resolves only
535
+ // to a component method, never a local. A `.`-chain is left untouched:
536
+ // `@toast.title` is a member path (use `= @toast.title` for text), and
537
+ // `@click.prevent: h` is the explicit modifier form handled below. Not
538
+ // DOM-gated here: any plain name desugars; the emitter validates it's a
539
+ // real DOM event (else a clear "use `= @name`" error) and that the
540
+ // on<Name> method exists. The generated handler name carries a marker.
541
+ if (!hadChain && (j >= tokens.length || tokens[j][0] !== ':') && isBareEventAttr(tokens, i)) {
542
+ let handlerName = 'on' + eventName[0].toUpperCase() + eventName.slice(1);
543
+ let nameObj = new String(handlerName);
544
+ nameObj.generatedBareEvent = eventName;
545
+ tokens.splice(j, 0, gen(':', ':', token), gen('@', '@', token), gen('PROPERTY', nameObj, token));
546
+ }
474
547
  if (j > i + 2 && j < tokens.length && tokens[j][0] === ':') {
475
548
  let openBracket = gen('[', '[', token);
476
549
  tokens.splice(i, 0, openBracket);
@@ -584,12 +657,14 @@ export function installComponentSupport(CodeEmitter, Lexer) {
584
657
  arrowToken.newLine = true;
585
658
  tokens.splice(i + 1, 0, callStartToken, arrowToken);
586
659
  pendingCallEnds.push(currentIndent + 1);
660
+ elementBodyLevels.push(currentIndent + 1);
587
661
  return 3;
588
662
  } else {
589
663
  let commaToken = gen(',', ',', token);
590
664
  let arrowToken = gen('->', '->', token);
591
665
  arrowToken.newLine = true;
592
666
  tokens.splice(i + 1, 0, commaToken, arrowToken);
667
+ elementBodyLevels.push(currentIndent + 1);
593
668
  return 3;
594
669
  }
595
670
  }
@@ -782,7 +857,12 @@ export function installComponentSupport(CodeEmitter, Lexer) {
782
857
  const item = sexpr[i];
783
858
  if (Array.isArray(item) && item[0] === '=') {
784
859
  const targetName = _str(item[1]);
785
- if (targetName && !(this.reactiveMembers && this.reactiveMembers.has(targetName))) {
860
+ // A bare `name = value` introduces a local ONLY when `name` is not a
861
+ // component member. Plain (`=`) members are componentMembers but not
862
+ // reactiveMembers — a write to one must become `this.name = value`
863
+ // (handled by the fallthrough map), not a shadowing local.
864
+ const isMember = targetName && this.componentMembers && this.componentMembers.has(targetName);
865
+ if (targetName && !isMember) {
786
866
  items.push(['=', item[1], this.transformComponentMembers(item[2], scope)]);
787
867
  scope.add(targetName);
788
868
  continue;
@@ -812,6 +892,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
812
892
 
813
893
  // Categorize statements
814
894
  const stateVars = [];
895
+ const plainVars = [];
815
896
  const derivedVars = [];
816
897
  const gateVars = [];
817
898
  const readonlyVars = [];
@@ -903,9 +984,15 @@ export function installComponentSupport(CodeEmitter, Lexer) {
903
984
  methods.push({ name: varName, func: val });
904
985
  memberNames.add(varName);
905
986
  } else {
906
- stateVars.push({ name: varName, value: val, isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]), optional: getMemberOptional(stmt[1]), srcLine: stmt.loc?.r });
987
+ // Uniform `=` semantics: a `=`-declared member is a PLAIN,
988
+ // non-reactive field (`this.name = value`), everywhere — including
989
+ // component members. Reactive state uses `:=`. A plain member is
990
+ // NOT a reactive member and NOT a valid `ref:` target.
991
+ //
992
+ // `@name = v` (public) is a rare plain public field sourced once
993
+ // from props; reactive props use `@name :=` or bare `@name`.
994
+ plainVars.push({ name: varName, value: val, isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]), node: stmt, srcLine: stmt.loc?.r });
907
995
  memberNames.add(varName);
908
- reactiveMembers.add(varName);
909
996
  }
910
997
  }
911
998
  }
@@ -939,15 +1026,152 @@ export function installComponentSupport(CodeEmitter, Lexer) {
939
1026
  }
940
1027
  }
941
1028
 
942
- // Auto-event map: onClick 'click', onKeydown 'keydown', etc.
943
- const autoEventHandlers = new Map();
944
- for (const { name } of methods) {
945
- if (/^on[A-Z]/.test(name) && !LIFECYCLE_HOOKS.has(name)) {
946
- const eventName = name[2].toLowerCase() + name.slice(3);
947
- if (DOM_EVENTS.has(eventName)) autoEventHandlers.set(eventName, name);
1029
+ // ── Safety diagnostic: silent-freeze guard for now-plain `=` members ──
1030
+ // Under uniform semantics a private `=` field is plain (non-reactive). If
1031
+ // such a field is READ in a *display* position (render / `~=` computed)
1032
+ // AND reassigned anywhere in the component, the UI would read it once and
1033
+ // never update — the classic silent-freeze. Steer to `:=`. Reads inside
1034
+ // `~>` effect bodies don't count: an effect re-runs on its own tracked
1035
+ // deps and legitimately drives plain imperative fields.
1036
+ if (plainVars.length > 0) {
1037
+ const ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '**=', '//=', '%%=', '&&=', '||=', '??=', '?=', '&=', '|=', '^=', '<<=', '>>=', '>>>=']);
1038
+ const occursRead = (node, name) => {
1039
+ if (node == null) return false;
1040
+ if (!Array.isArray(node)) {
1041
+ const s = node.valueOf ? node.valueOf() : node;
1042
+ return s === name;
1043
+ }
1044
+ const h = node[0]?.valueOf?.() ?? node[0];
1045
+ // `@name` member access reads `name`.
1046
+ if (h === '.' && node[1] === 'this') {
1047
+ const p = node[2]?.valueOf?.() ?? node[2];
1048
+ return p === name;
1049
+ }
1050
+ // A pure `=` writes its LHS (not a read); compound ops also read it.
1051
+ if (ASSIGN_OPS.has(h)) {
1052
+ const rhs = occursRead(node[2], name);
1053
+ return h === '=' ? rhs : (rhs || occursRead(node[1], name));
1054
+ }
1055
+ return node.some(c => occursRead(c, name));
1056
+ };
1057
+ const collectAssigned = (node, out) => {
1058
+ if (!Array.isArray(node)) return;
1059
+ const h = node[0]?.valueOf?.() ?? node[0];
1060
+ if (ASSIGN_OPS.has(h) || h === '++' || h === '--') {
1061
+ const tn = getMemberName(node[1]);
1062
+ if (tn) out.add(tn);
1063
+ }
1064
+ for (const c of node) collectAssigned(c, out);
1065
+ };
1066
+
1067
+ // Reassignments anywhere EXCEPT the declarations themselves: scan method,
1068
+ // effect, lifecycle, computed, and render (event-handler) bodies.
1069
+ const reassigned = new Set();
1070
+ for (const m of methods) collectAssigned(m.func, reassigned);
1071
+ for (const e of effects) collectAssigned(e[2], reassigned);
1072
+ for (const h of lifecycleHooks) collectAssigned(h.value, reassigned);
1073
+ for (const d of derivedVars) collectAssigned(d.expr, reassigned);
1074
+ if (renderBlock) collectAssigned(renderBlock, reassigned);
1075
+
1076
+ // Only render and `~=` computed bodies are true "reactive reads" — they
1077
+ // produce values the DOM displays and must re-derive when the source
1078
+ // changes. `~>` effects are deliberately EXCLUDED: an effect re-runs on
1079
+ // its own tracked deps and legitimately reads/writes plain imperative
1080
+ // fields (RAF handles, drag flags, memo caches), so a plain `=` field
1081
+ // touched only in effects is not a silent-freeze.
1082
+ const readReactively = (name) => {
1083
+ if (renderBlock && occursRead(renderBlock, name)) return true;
1084
+ for (const d of derivedVars) if (occursRead(d.expr, name)) return true;
1085
+ return false;
1086
+ };
1087
+
1088
+ for (const pv of plainVars) {
1089
+ // Public `@name = v` is an explicit rare opt-in to a plain public field.
1090
+ if (pv.isPublic) continue;
1091
+ if (reassigned.has(pv.name) && readReactively(pv.name)) {
1092
+ this.error(
1093
+ `'${pv.name}' is declared with '=' (a plain, non-reactive field) but it is read in render/computed AND reassigned — the UI will read it once and never update.`,
1094
+ pv.node,
1095
+ { suggestion: `Declare it as reactive state: \`${pv.name} := ...\`` }
1096
+ );
1097
+ }
948
1098
  }
949
1099
  }
950
1100
 
1101
+ // ── Const-member diagnostic: `=!` members are genuine consts ──
1102
+ // A `=!` member is a SHALLOW const (like JS `const`): rebinding the member
1103
+ // is a compile error; content mutation (`c.push(...)`, `c.foo = 1`) is
1104
+ // allowed. This Rip-native check is authoritative and runs in the erasure
1105
+ // core — it does not depend on type-checking (the shadow-TS view also marks
1106
+ // the member `readonly`, surfacing TS2540, but that's a redundant layer).
1107
+ // A rebind is `@c = …` / `c = …` whose target *directly names* the member;
1108
+ // `c.foo = 1` / `@c.foo = 1` target a property of the value, not the member,
1109
+ // so they pass (getMemberName returns null for those).
1110
+ if (readonlyVars.length > 0) {
1111
+ const constNames = new Set(readonlyVars.map(r => r.name));
1112
+ const CONST_ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '**=', '//=', '%%=', '&&=', '||=', '??=', '?=', '&=', '|=', '^=', '<<=', '>>=', '>>>=']);
1113
+ const paramName = (p) => _str(Array.isArray(p) && (p[0] === 'default' || p[0] === 'rest') ? p[1] : p);
1114
+ const reportConstAssign = (name, node) => this.error(
1115
+ `cannot assign to const member '${name}' (declared with =!); use '=' for a mutable field or ':=' for reactive state`,
1116
+ node
1117
+ );
1118
+ // Walk a body s-expression tracking local bindings (params + `=` locals)
1119
+ // that legitimately SHADOW a member name — only an unshadowed bare write,
1120
+ // or any `@member` write, is a member rebind.
1121
+ const checkConst = (node, scope) => {
1122
+ if (!Array.isArray(node)) return;
1123
+ const h = node[0]?.valueOf?.() ?? node[0];
1124
+ if (h === '->' || h === '=>') {
1125
+ const child = new Set(scope);
1126
+ const params = node[1];
1127
+ if (Array.isArray(params)) for (const p of params) {
1128
+ const pn = paramName(p);
1129
+ if (pn) child.add(pn);
1130
+ }
1131
+ checkConst(node[2], child);
1132
+ return;
1133
+ }
1134
+ if (h === 'block' || h === 'program') {
1135
+ const child = new Set(scope);
1136
+ for (let i = 1; i < node.length; i++) {
1137
+ const item = node[i];
1138
+ checkConst(item, child);
1139
+ // A bare `name = value` introduces a shadowing local only when the
1140
+ // name is NOT a const member (a write to a const member is the
1141
+ // rebind we just flagged, never a new local).
1142
+ if (Array.isArray(item) && item[0] === '=') {
1143
+ const tn = _str(item[1]);
1144
+ if (tn && !constNames.has(tn)) child.add(tn);
1145
+ }
1146
+ }
1147
+ return;
1148
+ }
1149
+ if (CONST_ASSIGN_OPS.has(h) || h === '++' || h === '--') {
1150
+ const target = node[1];
1151
+ if (Array.isArray(target) && target[0] === '.' && target[1] === 'this') {
1152
+ const mn = _str(target[2]);
1153
+ if (mn && constNames.has(mn)) reportConstAssign(mn, node);
1154
+ } else {
1155
+ const tn = _str(target);
1156
+ if (tn && constNames.has(tn) && !scope.has(tn)) reportConstAssign(tn, node);
1157
+ }
1158
+ }
1159
+ for (let i = 1; i < node.length; i++) checkConst(node[i], scope);
1160
+ };
1161
+ // Scope model MUST mirror transformComponentMembers exactly: a method's
1162
+ // own top-level params do NOT shadow members (only `methodBody` is
1163
+ // member-transformed, not the whole arrow — a bare member-named read
1164
+ // becomes `this.member`), so we walk the BODY with an empty scope. Nested
1165
+ // `->`/`=>` params DO shadow (the walker's arrow branch seeds them), which
1166
+ // matches the transform's own arrow recursion.
1167
+ const bodyOf = (fn) => (Array.isArray(fn) && (fn[0] === '->' || fn[0] === '=>')) ? fn[2] : fn;
1168
+ for (const m of methods) checkConst(bodyOf(m.func), new Set());
1169
+ for (const e of effects) checkConst(e[2], new Set());
1170
+ for (const h of lifecycleHooks) checkConst(bodyOf(h.value), new Set());
1171
+ for (const d of derivedVars) checkConst(d.expr, new Set());
1172
+ if (renderBlock) checkConst(renderBlock, new Set());
1173
+ }
1174
+
951
1175
  const inheritsTag = rest[0]?.valueOf?.() ?? null;
952
1176
  const publicPropNames = new Set();
953
1177
  for (const { name, isPublic } of stateVars) if (isPublic) publicPropNames.add(name);
@@ -961,14 +1185,23 @@ export function installComponentSupport(CodeEmitter, Lexer) {
961
1185
  reactiveMembers.add('rest');
962
1186
  }
963
1187
 
1188
+ // Writable state cells — the only valid `ref:` targets. stateVars covers
1189
+ // `:=` state and bare `@props` (each emitted as a writable `__state(...)`
1190
+ // signal). Computed (`~=`), readonly (`=!`), and plain (`=`) members are
1191
+ // deliberately excluded: a computed has a get-only `.value`, a readonly is
1192
+ // a plain const field, and a plain `=` member is a non-reactive property —
1193
+ // none can hold a live reactive ref. (`rest` is reactive but not a cell.)
1194
+ const refTargetMembers = new Set();
1195
+ for (const { name } of stateVars) refTargetMembers.add(name);
1196
+
964
1197
  // Save and set component context
965
1198
  const prevComponentMembers = this.componentMembers;
966
1199
  const prevReactiveMembers = this.reactiveMembers;
967
- const prevAutoEventHandlers = this._autoEventHandlers;
1200
+ const prevRefTargetMembers = this.refTargetMembers;
968
1201
  const prevInheritsTag = this._inheritsTag;
969
1202
  this.componentMembers = memberNames;
970
1203
  this.reactiveMembers = reactiveMembers;
971
- this._autoEventHandlers = autoEventHandlers.size > 0 ? autoEventHandlers : null;
1204
+ this.refTargetMembers = refTargetMembers;
972
1205
  this._inheritsTag = inheritsTag || null;
973
1206
 
974
1207
  // --- Type-check stub: typed member declarations + body expressions, no DOM ---
@@ -1026,6 +1259,11 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1026
1259
  const ts = expandType(type) || 'any';
1027
1260
  baseEntries.push(`${name}?: ${ts}`);
1028
1261
  }
1262
+ for (const { name, type, isPublic } of plainVars) {
1263
+ if (!isPublic) continue;
1264
+ const ts = expandType(type) || 'any';
1265
+ baseEntries.push(`${name}?: ${ts}`);
1266
+ }
1029
1267
  {
1030
1268
  const propsOpt = requiredBindUnions.length > 0 ? '' : '?';
1031
1269
  const parts = [];
@@ -1033,7 +1271,25 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1033
1271
  parts.push(...requiredBindUnions);
1034
1272
  let propsType = parts.length ? parts.join(' & ') : '{}';
1035
1273
  if (inheritsTag) propsType += ` & __RipProps<'${inheritsTag}'>`;
1036
- sl.push(` constructor(_props${propsOpt}: ${propsType}) {}`);
1274
+ // Synthetic constructor the ONLY place TypeScript permits assigning a
1275
+ // `readonly` member. The runtime keeps the real readonly inits in `_init`
1276
+ // (unchanged); we mirror JUST the readonly assignments here so that
1277
+ // `declare readonly name: T` is satisfied without tripping TS2540, and we
1278
+ // deliberately do NOT re-emit them in the shadow `_init` below (that would
1279
+ // re-trigger TS2540 — assignment to a readonly outside the constructor).
1280
+ const ctorInits = [];
1281
+ for (const { name, value, isPublic, srcLine } of readonlyVars) {
1282
+ const val = this.emitInComponent(value, 'value');
1283
+ const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
1284
+ ctorInits.push((isPublic ? ` this.${name} = _props?.${name} ?? ${val};` : ` this.${name} = ${val};`) + marker);
1285
+ }
1286
+ if (ctorInits.length) {
1287
+ sl.push(` constructor(_props${propsOpt}: ${propsType}) {`);
1288
+ for (const line of ctorInits) sl.push(line);
1289
+ sl.push(' }');
1290
+ } else {
1291
+ sl.push(` constructor(_props${propsOpt}: ${propsType}) {}`);
1292
+ }
1037
1293
  }
1038
1294
 
1039
1295
  // Infer type from literal initializer when no explicit annotation
@@ -1099,7 +1355,18 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1099
1355
  if (inheritsTag) {
1100
1356
  sl.push(` declare rest: Signal<__RipProps<'${inheritsTag}'>>;`);
1101
1357
  }
1358
+ // `=!` members are genuine consts: surface them as `readonly` so a
1359
+ // reassignment in a method/effect/handler is TS2540 (the Rip-native check
1360
+ // above is authoritative; this keeps editors/consumers consistent). The
1361
+ // single init assignment lives in the synthetic constructor above — NOT in
1362
+ // the shadow `_init` — because TS only allows writing a readonly there.
1102
1363
  for (const { name, type, value } of readonlyVars) {
1364
+ const ts = expandType(type) || inferLiteralType(value);
1365
+ sl.push(ts ? ` declare readonly ${name}: ${ts};` : ` declare readonly ${name}: any;`);
1366
+ }
1367
+ // Plain (`=`) fields: reassignable, non-reactive. Declared like readonly
1368
+ // (no Signal wrapper); assigned in `_init` below.
1369
+ for (const { name, type, value } of plainVars) {
1103
1370
  const ts = expandType(type) || inferLiteralType(value);
1104
1371
  sl.push(ts ? ` declare ${name}: ${ts};` : ` declare ${name}: any;`);
1105
1372
  }
@@ -1136,9 +1403,12 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1136
1403
  sl.push(` ${name} = __state(${val});` + marker);
1137
1404
  }
1138
1405
 
1139
- // _init body — readonly, state, computed assignments (skip accepted/offered)
1406
+ // _init body — plain, state, computed assignments (skip accepted/offered).
1407
+ // Readonly (`=!`) inits are emitted in the synthetic constructor above, NOT
1408
+ // here: assigning a `readonly` member outside the constructor is TS2540.
1409
+ // (The RUNTIME `_init` still assigns them — see the non-stub path below.)
1140
1410
  sl.push(' _init(props) {');
1141
- for (const { name, value, isPublic, srcLine } of readonlyVars) {
1411
+ for (const { name, value, isPublic, srcLine } of plainVars) {
1142
1412
  const val = this.emitInComponent(value, 'value');
1143
1413
  const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
1144
1414
  sl.push((isPublic ? ` this.${name} = props.${name} ?? ${val};` : ` this.${name} = ${val};`) + marker);
@@ -1178,9 +1448,6 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1178
1448
 
1179
1449
  // Pre-scan render block for @event: @method bindings to type method params
1180
1450
  const eventMethodTypes = new Map();
1181
- for (const [eventName, methodName] of autoEventHandlers) {
1182
- eventMethodTypes.set(methodName, eventName);
1183
- }
1184
1451
  if (renderBlock) {
1185
1452
  const scanEvents = (node) => {
1186
1453
  if (!Array.isArray(node)) return;
@@ -1199,10 +1466,18 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1199
1466
  const pair = obj[j];
1200
1467
  if (!Array.isArray(pair) || pair.length < 2) continue;
1201
1468
  const [, key, value] = pair;
1202
- if (Array.isArray(key) && key[0] === '.' && key[1] === 'this' &&
1203
- Array.isArray(value) && value[0] === '.' && value[1] === 'this') {
1469
+ if (Array.isArray(key) && key[0] === '.' && key[1] === 'this') {
1204
1470
  const eventName = typeof key[2] === 'string' ? key[2] : key[2]?.valueOf?.();
1205
- const methodName = typeof value[2] === 'string' ? value[2] : value[2]?.valueOf?.();
1471
+ // Handler ref may be `@event: @handler` (this-member) or the bare
1472
+ // `@event: handler` (member identifier) — accept either form.
1473
+ let methodName = null;
1474
+ if (Array.isArray(value) && value[0] === '.' && value[1] === 'this') {
1475
+ methodName = typeof value[2] === 'string' ? value[2] : value[2]?.valueOf?.();
1476
+ } else {
1477
+ const ident = (typeof value === 'string') ? value
1478
+ : (value instanceof String) ? value.valueOf() : null;
1479
+ if (ident && this.componentMembers?.has(ident)) methodName = ident;
1480
+ }
1206
1481
  if (eventName && methodName && !eventMethodTypes.has(methodName)) {
1207
1482
  eventMethodTypes.set(methodName, eventName);
1208
1483
  }
@@ -1455,6 +1730,18 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1455
1730
  constructions.push(` (${val});${marker}`);
1456
1731
  continue;
1457
1732
  }
1733
+ if (key === 'ref') {
1734
+ // ref: binds the element into a state cell. Type-check the
1735
+ // cell (the Signal OBJECT, not its `.value`) against the
1736
+ // tag's element type via __ripRef — mismatched element
1737
+ // types, non-nullable cells, computed (`~=`) and readonly
1738
+ // (`=!`) targets all error here.
1739
+ const refName = (typeof value === 'string' || value instanceof String) ? value.valueOf() : null;
1740
+ if (refName && /^[A-Za-z_$][\w$]*$/.test(refName) && this.reactiveMembers?.has(refName)) {
1741
+ (props.refChecks ??= []).push({ code: `__ripRef('${tagName}', this.${refName})`, srcLine });
1742
+ }
1743
+ continue;
1744
+ }
1458
1745
  if (key.startsWith('__bind_') && key.endsWith('__')) {
1459
1746
  const propName = key.slice(7, -2);
1460
1747
  const val = this.emitInComponent(value, 'value');
@@ -1820,6 +2107,18 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1820
2107
  const iProps = extractIntrinsicProps(node.slice(1), tagName, node.loc?.r);
1821
2108
  const tagLine = node.loc?.r;
1822
2109
  const srcMarker = tagLine != null ? ` // @rip-src:${tagLine}` : '';
2110
+ // Element refs check the cell against the element type separately
2111
+ // (the cell is the Signal object, not an HTML attribute value).
2112
+ // Emitted BEFORE the __ripEl call so that, when the ref shares its
2113
+ // source line with the element tag, the FIRST @rip-src marker for
2114
+ // that line is the __ripRef line — that's the statement that can
2115
+ // error, so a `@ts-expect-error` directive lands on it (not on the
2116
+ // never-erroring __ripEl).
2117
+ if (iProps.refChecks) {
2118
+ for (const r of iProps.refChecks) {
2119
+ constructions.push(` ${r.code};` + (r.srcLine != null ? ` // @rip-src:${r.srcLine}` : ''));
2120
+ }
2121
+ }
1823
2122
  if (iProps.length === 0) {
1824
2123
  constructions.push(` ${elFn}('${tagName}');${srcMarker}`);
1825
2124
  } else if (iProps.length === 1) {
@@ -1854,7 +2153,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1854
2153
 
1855
2154
  this.componentMembers = prevComponentMembers;
1856
2155
  this.reactiveMembers = prevReactiveMembers;
1857
- this._autoEventHandlers = prevAutoEventHandlers;
2156
+ this.refTargetMembers = prevRefTargetMembers;
1858
2157
  this._inheritsTag = prevInheritsTag;
1859
2158
  return sl.join('\n');
1860
2159
  }
@@ -1897,6 +2196,16 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1897
2196
  : ` this.${name} = ${val};`);
1898
2197
  }
1899
2198
 
2199
+ // Plain fields (=) — non-reactive, reassignable properties. A public
2200
+ // `@name = v` field reads its initial value from props once (parent
2201
+ // updates won't re-render — reactive props use `@name :=`/bare `@name`).
2202
+ for (const { name, value, isPublic } of plainVars) {
2203
+ const val = this.emitInComponent(value, 'value');
2204
+ lines.push(isPublic
2205
+ ? ` this.${name} = props.${name} ?? ${val};`
2206
+ : ` this.${name} = ${val};`);
2207
+ }
2208
+
1900
2209
  // Accepted vars (from ancestor context via getContext)
1901
2210
  for (const name of acceptedVars) {
1902
2211
  lines.push(` this.${name} = getContext('${name}');`);
@@ -2084,7 +2393,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2084
2393
  // Restore context
2085
2394
  this.componentMembers = prevComponentMembers;
2086
2395
  this.reactiveMembers = prevReactiveMembers;
2087
- this._autoEventHandlers = prevAutoEventHandlers;
2396
+ this.refTargetMembers = prevRefTargetMembers;
2088
2397
  this._inheritsTag = prevInheritsTag;
2089
2398
 
2090
2399
  // If block factories exist, wrap in IIFE so they're in scope
@@ -2148,9 +2457,6 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2148
2457
  this._renderLocalScope = new Set();
2149
2458
  this._renderTopLocals = new Set(); // class-mode (top-level _create) hoisted lets
2150
2459
  this._fragChildren = new Map();
2151
- this._pendingAutoWire = false;
2152
- this._autoWireEl = null;
2153
- this._autoWireExplicit = null;
2154
2460
  this._inheritsTargetBound = false;
2155
2461
 
2156
2462
  const statements = this.is(body, 'block') ? body.slice(1) : [body];
@@ -2171,13 +2477,11 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2171
2477
  rootVar = this.newElementVar('empty');
2172
2478
  this._createLines.push(`${rootVar} = document.createComment('');`);
2173
2479
  } else if (renderableCount === 1) {
2174
- this._pendingAutoWire = !!this._autoEventHandlers;
2175
2480
  let onlyRenderable = null;
2176
2481
  for (const stmt of statements) {
2177
2482
  const v = this.emitNode(stmt);
2178
2483
  if (v != null) onlyRenderable = v;
2179
2484
  }
2180
- this._pendingAutoWire = false;
2181
2485
  rootVar = onlyRenderable;
2182
2486
  } else {
2183
2487
  rootVar = this.newElementVar('frag');
@@ -2439,18 +2743,13 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2439
2743
  if (headStr === '.') {
2440
2744
  const [, obj, prop] = sexpr;
2441
2745
 
2442
- // Property access on this (e.g., @prop, @children)
2746
+ // Property access on this (e.g., @prop) standing alone as a render child.
2747
+ // A bare `@prop` is no longer an implicit text child — `= @prop` renders
2748
+ // text (and `slot` projects children). Directive-position `@event` shorthand
2749
+ // is rewritten to an attribute before reaching here, so anything that lands
2750
+ // here is a bare member in a non-directive child slot: error, never silent.
2443
2751
  if (obj === 'this' && typeof prop === 'string') {
2444
- const s = this._self;
2445
- if (this.reactiveMembers && this.reactiveMembers.has(prop)) {
2446
- const textVar = this.newTextVar();
2447
- this._createLines.push(`${textVar} = document.createTextNode('');`);
2448
- this._pushEffect(`${textVar}.data = ${s}.${prop}.value;`);
2449
- return textVar;
2450
- }
2451
- const slotVar = this.newElementVar('slot');
2452
- this._createLines.push(`${slotVar} = ${s}.${prop} instanceof Node ? ${s}.${prop} : (${s}.${prop} != null ? document.createTextNode(String(${s}.${prop})) : document.createComment(''));`);
2453
- return slotVar;
2752
+ this.error(`Bare \`@${prop}\` is not rendered as text — use \`= @${prop}\` to render it, or \`slot\` to project children`, sexpr);
2454
2753
  }
2455
2754
 
2456
2755
  // HTML tag with classes (div.class) — skip if base is marked .text by
@@ -2610,29 +2909,6 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2610
2909
  }
2611
2910
  };
2612
2911
 
2613
- // --------------------------------------------------------------------------
2614
- // Auto-wire event handlers — claim/emit helpers for on* convention
2615
- // --------------------------------------------------------------------------
2616
-
2617
- proto._claimAutoWire = function(elVar) {
2618
- if (!this._pendingAutoWire || !this._autoEventHandlers?.size) return false;
2619
- this._pendingAutoWire = false;
2620
- this._autoWireEl = elVar;
2621
- this._autoWireExplicit = new Set();
2622
- return true;
2623
- };
2624
-
2625
- proto._emitAutoWire = function(elVar, claimed) {
2626
- if (!claimed) return;
2627
- for (const [eventName, methodName] of this._autoEventHandlers) {
2628
- if (!this._autoWireExplicit.has(eventName)) {
2629
- this._createLines.push(`${elVar}.addEventListener('${eventName}', (e) => __batch(() => ${this._self}.${methodName}(e)));`);
2630
- }
2631
- }
2632
- this._autoWireEl = null;
2633
- this._autoWireExplicit = null;
2634
- };
2635
-
2636
2912
  proto._bindInheritedTarget = function(tag, elVar) {
2637
2913
  if (!this._inheritsTag || this._factoryMode || this._inheritsTargetBound) return;
2638
2914
  if (tag !== this._inheritsTag) return;
@@ -2663,8 +2939,6 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2663
2939
  this._createLines.push(`${elVar}.setAttribute('data-part', '${this._componentName}');`);
2664
2940
  }
2665
2941
 
2666
- const autoWireClaimed = this._claimAutoWire(elVar);
2667
-
2668
2942
  // Defer class emission when selector classes exist so class: attributes merge
2669
2943
  const prevClassArgs = this._pendingClassArgs;
2670
2944
  const prevClassEl = this._pendingClassEl;
@@ -2697,8 +2971,6 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2697
2971
  this._pendingClassEl = prevClassEl;
2698
2972
  }
2699
2973
 
2700
- this._emitAutoWire(elVar, autoWireClaimed);
2701
-
2702
2974
  return elVar;
2703
2975
  };
2704
2976
 
@@ -2716,8 +2988,6 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2716
2988
  if (id) this._createLines.push(`${elVar}.id = '${id}';`);
2717
2989
  this._bindInheritedTarget(tag, elVar);
2718
2990
 
2719
- const autoWireClaimed = this._claimAutoWire(elVar);
2720
-
2721
2991
  // Defer className emission so class: attributes can merge with .() classes
2722
2992
  const classArgs = [...(staticClassArgs || []), ...classExprs.map(e => this.emitInComponent(e, 'value'))];
2723
2993
  const prevClassArgs = this._pendingClassArgs;
@@ -2741,11 +3011,38 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2741
3011
  this._pendingClassArgs = prevClassArgs;
2742
3012
  this._pendingClassEl = prevClassEl;
2743
3013
 
2744
- this._emitAutoWire(elVar, autoWireClaimed);
2745
-
2746
3014
  return elVar;
2747
3015
  };
2748
3016
 
3017
+ // --------------------------------------------------------------------------
3018
+ // Bare event shorthand validation
3019
+ //
3020
+ // `button @click` desugars (in rewriteRender) to `button @click: @onClick`,
3021
+ // marking the generated `onClick` so resolution is method-only: it must name
3022
+ // a real component method, never a local. This is the single choke point that
3023
+ // enforces that contract and rejects the `@error`/onError-lifecycle clash.
3024
+ // --------------------------------------------------------------------------
3025
+
3026
+ proto._checkBareEventHandler = function(value, node) {
3027
+ if (!this.is(value, '.') || value[1] !== 'this') return;
3028
+ const handler = value[2];
3029
+ const bareEvent = (handler instanceof String) ? handler.generatedBareEvent : null;
3030
+ if (!bareEvent) return; // only generated bare shorthand is policed here
3031
+ const ev = String(bareEvent);
3032
+ if (!DOM_EVENTS.has(ev)) {
3033
+ this.error(`\`@${ev}\` is not a DOM event — use \`= @${ev}\` to render text, or \`@${ev}: handler\` for an explicit handler`, node);
3034
+ return;
3035
+ }
3036
+ if (ev === 'error') {
3037
+ this.error('Bare `@error` is ambiguous with the onError lifecycle hook — write `@error: handler` to bind a DOM error listener explicitly', node);
3038
+ return;
3039
+ }
3040
+ const name = String(handler);
3041
+ if (this.componentMembers && !this.componentMembers.has(name)) {
3042
+ this.error(`Bare \`@${ev}\` requires a component method \`${name}\` — define \`${name}\`, or use \`@${ev}: handler\` for an explicit handler`, node);
3043
+ }
3044
+ };
3045
+
2749
3046
  // --------------------------------------------------------------------------
2750
3047
  // emitAttributes — attributes, events, and bindings on an element
2751
3048
  // --------------------------------------------------------------------------
@@ -2759,9 +3056,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2759
3056
  // Event handler: @click or (. this eventName)
2760
3057
  if (this.is(key, '.') && key[1] === 'this') {
2761
3058
  const eventName = key[2];
2762
- if (this._autoWireExplicit && this._autoWireEl === elVar) {
2763
- this._autoWireExplicit.add(eventName);
2764
- }
3059
+ this._checkBareEventHandler(value, objExpr[i]);
2765
3060
  if (typeof value === 'string' && this.componentMembers?.has(value)) {
2766
3061
  this._createLines.push(`${elVar}.addEventListener('${eventName}', (e) => __batch(() => ${this._self}.${value}(e)));`);
2767
3062
  } else {
@@ -2813,10 +3108,46 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2813
3108
  continue;
2814
3109
  }
2815
3110
 
2816
- // Element ref: ref: "name"this.name = element
3111
+ // Element ref: ref: cellbind the live element into a STATE cell.
3112
+ // `cell` is a state member declared `name := null`; we pass the signal
3113
+ // OBJECT (never its .value) and write the element on mount / null on
3114
+ // detach, so effects reading the ref re-run as it appears/disappears.
2817
3115
  if (key === 'ref') {
2818
- const refName = String(value).replace(/^["']|["']$/g, '');
2819
- this._createLines.push(`${this._self}.${refName} = ${elVar};`);
3116
+ const refName = _str(value);
3117
+ if (!refName || !/^[A-Za-z_$][\w$]*$/.test(refName)) {
3118
+ this.error(
3119
+ 'ref: expects a state cell — declare `el := null` then write `ref: el` (string-name refs were removed)',
3120
+ value
3121
+ );
3122
+ }
3123
+ if (!(this.refTargetMembers && this.refTargetMembers.has(refName))) {
3124
+ // A computed (`~=`) has a get-only `.value`; a readonly (`=!`) is a
3125
+ // frozen plain field — neither can receive the live element.
3126
+ const isComputedOrReadonly = this.reactiveMembers && this.reactiveMembers.has(refName);
3127
+ this.error(
3128
+ isComputedOrReadonly
3129
+ ? `ref: target '${refName}' is not a writable state cell — declare it with ':=' (computed '~=' and readonly '=!' members can't hold a ref)`
3130
+ : `ref: target '${refName}' must be a state cell declared with ':=' (e.g. \`${refName} := null\`)`,
3131
+ value
3132
+ );
3133
+ }
3134
+ // The signal object itself — `this.name` / `ctx.name`, NOT `.value`.
3135
+ const cellExpr = `${this._self}.${refName}`;
3136
+ if (this._factoryMode) {
3137
+ // Dynamic ref (inside if/for): defer the write to AFTER the block
3138
+ // is inserted (the m() phase) so a synchronous flush can never let
3139
+ // an outer effect observe a not-yet-connected node. The enclosing
3140
+ // reconcile / branch-swap pass is wrapped in __batch by the
3141
+ // factory caller. Cleared on real detach (d(detaching)) only —
3142
+ // never on a keyed move.
3143
+ (this._refMounts ??= []).push({ cellExpr, elVar });
3144
+ } else {
3145
+ // Static ref: by mount order the whole subtree is built in
3146
+ // _create(), connected to the document, and only THEN do any
3147
+ // effects first run — so writing here is glitch-free.
3148
+ this._createLines.push(`${cellExpr}.value = ${elVar};`);
3149
+ this._createLines.push(`(this._refCleanups ??= []).push(() => __detachRef(${cellExpr}, ${elVar}));`);
3150
+ }
2820
3151
  continue;
2821
3152
  }
2822
3153
 
@@ -2942,8 +3273,6 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2942
3273
  // --------------------------------------------------------------------------
2943
3274
 
2944
3275
  proto.emitConditional = function(sexpr) {
2945
- this._pendingAutoWire = false;
2946
-
2947
3276
  // Fold flat else-if chains into nested structure.
2948
3277
  // Parser emits: ['if', c1, t1, ['if', c2, t2], ..., finalElse]
2949
3278
  // We need: ['if', c1, t1, ['if', c2, t2, [..., finalElse]]]
@@ -2966,12 +3295,12 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2966
3295
  const outerExtra = outerParams ? `, ${outerParams}` : '';
2967
3296
 
2968
3297
  const thenBlockName = this.newBlockVar();
2969
- this.emitConditionBranch(thenBlockName, thenBlock);
3298
+ let hasDynamicRef = this.emitConditionBranch(thenBlockName, thenBlock);
2970
3299
 
2971
3300
  let elseBlockName = null;
2972
3301
  if (elseBlock) {
2973
3302
  elseBlockName = this.newBlockVar();
2974
- this.emitConditionBranch(elseBlockName, elseBlock);
3303
+ hasDynamicRef = this.emitConditionBranch(elseBlockName, elseBlock) || hasDynamicRef;
2975
3304
  }
2976
3305
 
2977
3306
  const setupLines = [];
@@ -2987,6 +3316,11 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2987
3316
  const effOpen = this._factoryMode ? 'disposers.push(__effect(() => {' : '__effect(() => {';
2988
3317
  const effClose = this._factoryMode ? '}, {skipRegister: true}));' : '});';
2989
3318
  setupLines.push(` ${effOpen}`);
3319
+ // A dynamic ref inside a branch writes its cell in the block's m() phase.
3320
+ // Batching the whole create/insert/destroy pass means observers see only
3321
+ // the final cell value and the state setter's `notifying` guard can't drop
3322
+ // a framework write made while another subscriber is mid-flush.
3323
+ if (hasDynamicRef) setupLines.push(` __batch(() => {`);
2990
3324
  setupLines.push(` const show = !!(${condCode});`);
2991
3325
  setupLines.push(` const want = show ? 'then' : ${elseBlock ? "'else'" : 'null'};`);
2992
3326
  setupLines.push(` if (want === showing) return;`);
@@ -3015,6 +3349,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
3015
3349
  setupLines.push(` if (currentBlock._t) __transition(currentBlock._first, currentBlock._t, 'enter');`);
3016
3350
  setupLines.push(` }`);
3017
3351
  }
3352
+ if (hasDynamicRef) setupLines.push(` });`);
3018
3353
  setupLines.push(` ${effClose}`);
3019
3354
  // Block teardown: when this conditional's enclosing scope ends
3020
3355
  // (factory block detach, or parent component unmount), destroy
@@ -3042,7 +3377,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
3042
3377
  // --------------------------------------------------------------------------
3043
3378
 
3044
3379
  proto.emitConditionBranch = function(blockName, block) {
3045
- const saved = [this._createLines, this._setupLines, this._factoryMode, this._factoryVars, this._renderLocalScope];
3380
+ const saved = [this._createLines, this._setupLines, this._factoryMode, this._factoryVars, this._renderLocalScope, this._refMounts];
3046
3381
 
3047
3382
  this._createLines = [];
3048
3383
  this._setupLines = [];
@@ -3050,25 +3385,29 @@ export function installComponentSupport(CodeEmitter, Lexer) {
3050
3385
  this._factoryVars = new Set();
3051
3386
  // Fresh render-local scope per factory function (see emitTemplateLoop).
3052
3387
  this._renderLocalScope = new Set();
3388
+ this._refMounts = [];
3053
3389
 
3054
3390
  const rootVar = this.emitTemplateBlock(block);
3055
3391
  const createLines = this._createLines;
3056
3392
  const setupLines = this._setupLines;
3057
3393
  const factoryVars = this._factoryVars;
3394
+ const refMounts = this._refMounts;
3058
3395
 
3059
- [this._createLines, this._setupLines, this._factoryMode, this._factoryVars, this._renderLocalScope] = saved;
3396
+ [this._createLines, this._setupLines, this._factoryMode, this._factoryVars, this._renderLocalScope, this._refMounts] = saved;
3060
3397
 
3061
3398
  const outerParams = this._loopVarStack.map(v => `${v.itemVar}, ${v.indexVar}`).join(', ');
3062
3399
  const extraParams = outerParams ? `, ${outerParams}` : '';
3063
3400
 
3064
- this.emitBlockFactory(blockName, `ctx${extraParams}`, rootVar, createLines, setupLines, factoryVars);
3401
+ this.emitBlockFactory(blockName, `ctx${extraParams}`, rootVar, createLines, setupLines, factoryVars, false, refMounts);
3402
+ // Report dynamic-ref presence so emitConditional can __batch the swap.
3403
+ return refMounts.length > 0;
3065
3404
  };
3066
3405
 
3067
3406
  // --------------------------------------------------------------------------
3068
3407
  // emitBlockFactory — shared factory generation for conditionals and loops
3069
3408
  // --------------------------------------------------------------------------
3070
3409
 
3071
- proto.emitBlockFactory = function(blockName, params, rootVar, createLines, setupLines, factoryVars, isStatic) {
3410
+ proto.emitBlockFactory = function(blockName, params, rootVar, createLines, setupLines, factoryVars, isStatic, refMounts) {
3072
3411
  const factoryLines = [];
3073
3412
  factoryLines.push(`function ${blockName}(${params}) {`);
3074
3413
 
@@ -3114,6 +3453,16 @@ export function installComponentSupport(CodeEmitter, Lexer) {
3114
3453
  } else {
3115
3454
  factoryLines.push(` if (target) target.insertBefore(${rootVar}, anchor);`);
3116
3455
  }
3456
+ // Dynamic refs: write the live element into its cell AFTER insertion, so
3457
+ // the node is connected before any subscriber observes it. The enclosing
3458
+ // reconcile / branch-swap pass is __batch-wrapped (see emitConditional /
3459
+ // emitTemplateLoop). Idempotent on keyed-move re-inserts (the state setter
3460
+ // no-ops when newValue === value).
3461
+ if (refMounts && refMounts.length) {
3462
+ for (const r of refMounts) {
3463
+ factoryLines.push(` ${r.cellExpr}.value = ${r.elVar};`);
3464
+ }
3465
+ }
3117
3466
  factoryLines.push(` },`);
3118
3467
 
3119
3468
  factoryLines.push(` p(${params}) {`);
@@ -3140,6 +3489,17 @@ export function installComponentSupport(CodeEmitter, Lexer) {
3140
3489
  if (hasEffects) {
3141
3490
  factoryLines.push(` disposers.forEach(d => d());`);
3142
3491
  }
3492
+ // Dynamic refs: compare-and-clear only on a real destroy (detaching===true),
3493
+ // never on a keyed move (which reinserts via m() without calling d()). Runs
3494
+ // AFTER this block's own effects are disposed; batched so any live parent
3495
+ // subscriber (a forwarded ref) is notified exactly once.
3496
+ if (refMounts && refMounts.length) {
3497
+ factoryLines.push(` if (detaching) __batch(() => {`);
3498
+ for (const r of refMounts) {
3499
+ factoryLines.push(` __detachRef(${r.cellExpr}, ${r.elVar});`);
3500
+ }
3501
+ factoryLines.push(` });`);
3502
+ }
3143
3503
  if (fragChildren) {
3144
3504
  for (const child of fragChildren) {
3145
3505
  factoryLines.push(` if (detaching) __detach(${child});`);
@@ -3160,7 +3520,6 @@ export function installComponentSupport(CodeEmitter, Lexer) {
3160
3520
  // --------------------------------------------------------------------------
3161
3521
 
3162
3522
  proto.emitTemplateLoop = function(sexpr) {
3163
- this._pendingAutoWire = false;
3164
3523
  const [head, vars, collection, guard, step, body] = sexpr;
3165
3524
 
3166
3525
  const blockName = this.newBlockVar();
@@ -3217,7 +3576,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
3217
3576
  }
3218
3577
  }
3219
3578
 
3220
- const saved = [this._createLines, this._setupLines, this._factoryMode, this._factoryVars, this._renderLocalScope];
3579
+ const saved = [this._createLines, this._setupLines, this._factoryMode, this._factoryVars, this._renderLocalScope, this._refMounts];
3221
3580
 
3222
3581
  this._createLines = [];
3223
3582
  this._setupLines = [];
@@ -3227,6 +3586,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
3227
3586
  // surrounding scope are NOT visible here. Loop vars are explicitly
3228
3587
  // threaded as positional parameters via _loopVarStack.
3229
3588
  this._renderLocalScope = new Set();
3589
+ this._refMounts = [];
3230
3590
 
3231
3591
  const outerParams = this._loopVarStack.map(v => `${v.itemVar}, ${v.indexVar}`).join(', ');
3232
3592
  const outerExtra = outerParams ? `, ${outerParams}` : '';
@@ -3243,12 +3603,14 @@ export function installComponentSupport(CodeEmitter, Lexer) {
3243
3603
  const itemCreateLines = this._createLines;
3244
3604
  const itemSetupLines = this._setupLines;
3245
3605
  const itemFactoryVars = this._factoryVars;
3606
+ const itemRefMounts = this._refMounts;
3246
3607
 
3247
- [this._createLines, this._setupLines, this._factoryMode, this._factoryVars, this._renderLocalScope] = saved;
3608
+ [this._createLines, this._setupLines, this._factoryMode, this._factoryVars, this._renderLocalScope, this._refMounts] = saved;
3248
3609
 
3249
3610
  const isStatic = itemSetupLines.length === 0;
3250
3611
  const loopParams = `ctx, ${itemVar}, ${indexVar}${outerExtra}`;
3251
- this.emitBlockFactory(blockName, loopParams, itemNode, itemCreateLines, itemSetupLines, itemFactoryVars, isStatic);
3612
+ this.emitBlockFactory(blockName, loopParams, itemNode, itemCreateLines, itemSetupLines, itemFactoryVars, isStatic, itemRefMounts);
3613
+ const hasDynamicRef = itemRefMounts.length > 0;
3252
3614
 
3253
3615
  // Build key function argument (null = use item as key)
3254
3616
  const hasCustomKey = keyExpr !== itemVar;
@@ -3266,7 +3628,15 @@ export function installComponentSupport(CodeEmitter, Lexer) {
3266
3628
  const effOpen = this._factoryMode ? 'disposers.push(__effect(() => {' : '__effect(() => {';
3267
3629
  const effClose = this._factoryMode ? '}, {skipRegister: true}));' : '});';
3268
3630
  setupLines.push(` ${effOpen}`);
3269
- setupLines.push(` __reconcile(${anchorVar}, __s, ${collectionCode}, ${this._self}, ${blockName}, ${keyFnCode}${outerArgs});`);
3631
+ // Dynamic refs inside the loop body write their cells in each block's m()
3632
+ // phase. Batch the whole reconcile so create/insert/destroy settle to one
3633
+ // flush and the state setter's `notifying` guard can't drop a framework
3634
+ // ref write made while another subscriber is mid-flush.
3635
+ if (hasDynamicRef) {
3636
+ setupLines.push(` __batch(() => __reconcile(${anchorVar}, __s, ${collectionCode}, ${this._self}, ${blockName}, ${keyFnCode}${outerArgs}));`);
3637
+ } else {
3638
+ setupLines.push(` __reconcile(${anchorVar}, __s, ${collectionCode}, ${this._self}, ${blockName}, ${keyFnCode}${outerArgs});`);
3639
+ }
3270
3640
  setupLines.push(` ${effClose}`);
3271
3641
  // Loop teardown: destroy every block in state.blocks on parent
3272
3642
  // unmount (or enclosing factory detach). __reconcile only destroys
@@ -3291,7 +3661,6 @@ export function installComponentSupport(CodeEmitter, Lexer) {
3291
3661
  // --------------------------------------------------------------------------
3292
3662
 
3293
3663
  proto.emitChildComponent = function(componentName, args) {
3294
- this._pendingAutoWire = false;
3295
3664
  const instVar = this.newElementVar('inst');
3296
3665
  const elVar = this.newElementVar('el');
3297
3666
  const { propsCode, reactiveProps, eventBindings, childrenSetupLines } = this.buildComponentProps(args);
@@ -3359,6 +3728,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
3359
3728
  this._createLines.push(`} finally { __popComponent(__prev); } }`);
3360
3729
 
3361
3730
  for (const { event, value } of eventBindings) {
3731
+ this._checkBareEventHandler(value, value);
3362
3732
  const handlerCode = this.emitInComponent(value, 'value');
3363
3733
  this._createLines.push(`if (${instVar}) ${elVar}.addEventListener('${event}', (e) => __batch(() => (${handlerCode})(e)));`);
3364
3734
  }
@@ -4074,6 +4444,21 @@ class __Component {
4074
4444
  }
4075
4445
  this._disposers = null;
4076
4446
  }
4447
+ // Static template-ref cleanups run AFTER this component's own effects
4448
+ // are disposed (nulling a ref before disposing the effects that read it
4449
+ // could re-run them mid-teardown under the synchronous scheduler) but
4450
+ // before the DOM detaches. Batched so a forwarded ref (a parent-owned
4451
+ // cell filled by a child element) notifies the still-live parent
4452
+ // subscribers exactly once.
4453
+ if (this._refCleanups) {
4454
+ const __cleanups = this._refCleanups;
4455
+ this._refCleanups = null;
4456
+ __batch(() => {
4457
+ for (const c of __cleanups) {
4458
+ try { c(); } catch (e) { console.error('[Rip] ref cleanup error:', e); }
4459
+ }
4460
+ });
4461
+ }
4077
4462
  try {
4078
4463
  if (this.unmounted) this.unmounted();
4079
4464
  } catch (e) { console.error('[Rip] unmounted error:', e); }