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/README.md +1 -1
- package/docs/RIP-LANG.md +90 -3
- package/docs/RIP-TYPES.md +33 -0
- package/docs/demo/routes/card.rip +1 -1
- package/docs/dist/rip.js +524 -123
- package/docs/dist/rip.min.js +230 -203
- package/docs/dist/rip.min.js.br +0 -0
- package/docs/example/index.json +1 -1
- package/docs/example/index.json.br +0 -0
- package/docs/extensions/vscode/print/print-1.0.16.vsix +0 -0
- package/docs/extensions/vscode/print/print-latest.vsix +0 -0
- package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
- package/docs/extensions/vscode/rip/vscode-rip-0.8.0.vsix +0 -0
- package/docs/ui/bundle.json +40 -40
- package/docs/ui/bundle.json.br +0 -0
- package/docs/ui/index.css +8 -0
- package/docs/ui/index.html +4 -4
- package/package.json +5 -5
- package/src/AGENTS.md +43 -10
- package/src/compiler.js +38 -4
- package/src/components.js +474 -89
- package/src/dts.js +26 -28
- package/src/generated/dom-events.js +2 -1
- package/src/generated/dom-tags.js +2 -1
- package/src/grammar/grammar.rip +8 -0
- package/src/parser.js +28 -27
- package/src/typecheck.js +159 -35
- package/src/types.js +153 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
|
1200
|
+
const prevRefTargetMembers = this.refTargetMembers;
|
|
968
1201
|
const prevInheritsTag = this._inheritsTag;
|
|
969
1202
|
this.componentMembers = memberNames;
|
|
970
1203
|
this.reactiveMembers = reactiveMembers;
|
|
971
|
-
this.
|
|
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
|
-
|
|
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 —
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
3111
|
+
// Element ref: ref: cell → bind 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 =
|
|
2819
|
-
|
|
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
|
-
|
|
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); }
|