rip-lang 3.16.0 → 3.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +3 -4
  2. package/bin/rip +201 -18
  3. package/bin/rip-schema +175 -0
  4. package/docs/AGENTS.md +1 -1
  5. package/docs/RIP-APP.md +200 -19
  6. package/docs/RIP-DUCKDB.md +64 -1
  7. package/docs/RIP-INTRO.md +4 -4
  8. package/docs/RIP-LANG.md +36 -38
  9. package/docs/RIP-SCHEMA.md +1204 -364
  10. package/docs/RIP-TYPES.md +74 -103
  11. package/docs/demo/README.md +4 -3
  12. package/docs/dist/rip.js +4195 -966
  13. package/docs/dist/rip.min.js +1161 -284
  14. package/docs/dist/rip.min.js.br +0 -0
  15. package/docs/example/index.json +7 -7
  16. package/docs/example/index.json.br +0 -0
  17. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  18. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  19. package/docs/extensions/vscode/rip/index.html +2 -1
  20. package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
  21. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  22. package/docs/extensions/vscode/rip/vscode-rip-0.6.0.vsix +0 -0
  23. package/docs/index.html +1 -1
  24. package/docs/ui/bundle.json +55 -55
  25. package/docs/ui/bundle.json.br +0 -0
  26. package/docs/ui/hljs-rip.js +1 -1
  27. package/docs/ui/index.html +1 -1
  28. package/package.json +15 -7
  29. package/rip-loader.js +59 -2
  30. package/src/AGENTS.md +43 -12
  31. package/src/browser.js +52 -11
  32. package/src/compiler.js +538 -80
  33. package/src/components.js +488 -48
  34. package/src/dts.js +80 -50
  35. package/src/grammar/README.md +29 -170
  36. package/src/grammar/grammar.rip +17 -12
  37. package/src/grammar/solar.rip +4 -17
  38. package/src/lexer.js +82 -32
  39. package/src/parser.js +229 -229
  40. package/src/schema/dts.js +328 -54
  41. package/src/schema/loader-server.js +2 -1
  42. package/src/schema/runtime-browser-stubs.js +20 -9
  43. package/src/schema/runtime-ddl.js +161 -44
  44. package/src/schema/runtime-migrate.js +681 -0
  45. package/src/schema/runtime-orm.js +698 -54
  46. package/src/schema/runtime-validate.js +808 -24
  47. package/src/schema/runtime.generated.js +2395 -135
  48. package/src/schema/schema.js +1054 -94
  49. package/src/typecheck.js +1610 -127
  50. package/src/types.js +90 -6
  51. package/src/grammar/lunar.rip +0 -2412
  52. /package/docs/demo/{components → routes}/_layout.rip +0 -0
  53. /package/docs/demo/{components → routes}/about.rip +0 -0
  54. /package/docs/demo/{components → routes}/card.rip +0 -0
  55. /package/docs/demo/{components → routes}/counter.rip +0 -0
  56. /package/docs/demo/{components → routes}/index.rip +0 -0
  57. /package/docs/demo/{components → routes}/todos.rip +0 -0
package/src/components.js CHANGED
@@ -17,7 +17,13 @@ import { HTML_TAGS, SVG_TAGS, TEMPLATE_TAGS } from './generated/dom-tags.js';
17
17
  const BIND_PREFIX = '__bind_';
18
18
  const BIND_SUFFIX = '__';
19
19
 
20
- const LIFECYCLE_HOOKS = new Set(['beforeMount', 'mounted', 'updated', 'beforeUnmount', 'unmounted', 'onError']);
20
+ const LIFECYCLE_HOOKS = new Set(['beforeMount', 'mounted', 'beforeUnmount', 'unmounted', 'onError']);
21
+
22
+ // Canonical payload type for the onError lifecycle hook — the single source
23
+ // of truth for both the optional declaration emitted when a component does
24
+ // NOT define the hook and the shadow param type of a user-defined hook
25
+ // whose param is unannotated.
26
+ const ON_ERROR_PARAM_TYPE = '{ status?: number; message?: string; error?: Error; path?: string }';
21
27
  const BOOLEAN_ATTRS = new Set([
22
28
  'disabled', 'hidden', 'readonly', 'required', 'checked', 'selected',
23
29
  'autofocus', 'autoplay', 'controls', 'loop', 'muted', 'multiple',
@@ -79,6 +85,84 @@ function getMemberType(target) {
79
85
  return null;
80
86
  }
81
87
 
88
+ /**
89
+ * Extract `?` optionality flag from s-expression target node.
90
+ * Set by the lexer as `.optional` on the prop-name String wrapper when
91
+ * the source wrote `@label?:: T` or similar.
92
+ */
93
+ function getMemberOptional(target) {
94
+ if (target instanceof String && target.optional) return true;
95
+ if (Array.isArray(target) && target[2] instanceof String && target[2].optional) return true;
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * Flatten a pure `.` chain s-expression into its segment names.
101
+ * `(. (. this app) data)` → ['this', 'app', 'data']. Returns null when the
102
+ * node isn't a plain identifier chain (computed access, calls, etc.).
103
+ */
104
+ function chainSegments(node) {
105
+ const segs = [];
106
+ while (Array.isArray(node) && node[0] === '.') {
107
+ const prop = node[2];
108
+ if (!(typeof prop === 'string' || prop instanceof String)) return null;
109
+ segs.unshift(prop.valueOf());
110
+ node = node[1];
111
+ }
112
+ if (typeof node === 'string' || node instanceof String) segs.unshift(node.valueOf());
113
+ else return null;
114
+ return segs;
115
+ }
116
+
117
+ /**
118
+ * Parse a render gate's RHS (`user <~ @app.data.user`) into its hoistable
119
+ * parts. The RHS must be a literal `@app.data.…` chain, optionally applied
120
+ * to exactly one key argument (keyed source). Returns
121
+ * { path, keyExpr? } or { error }.
122
+ */
123
+ function parseGatePath(rhs) {
124
+ let keyExpr;
125
+ let chain = rhs;
126
+ // Application form: [calleeChain, keyArg]
127
+ if (Array.isArray(rhs) && Array.isArray(rhs[0]) && rhs[0][0] === '.') {
128
+ if (rhs.length !== 2) return { error: 'a keyed gate takes exactly one key argument' };
129
+ chain = rhs[0];
130
+ keyExpr = rhs[1];
131
+ }
132
+ const segs = chainSegments(chain);
133
+ if (!segs) return { error: "'<~' requires a literal @app.data.… path on the right-hand side" };
134
+ if (segs[0] === 'this') segs.shift();
135
+ if (segs[0] !== 'app' || segs[1] !== 'data' || segs.length < 3) {
136
+ return { error: "'<~' requires a literal @app.data.… path on the right-hand side" };
137
+ }
138
+ return { path: segs.slice(2).join('.'), keyExpr };
139
+ }
140
+
141
+ /**
142
+ * Parse a keyed gate's key expression. The key must be available
143
+ * to the renderer before construction, so it is restricted to a literal or
144
+ * a params/query access (`params.id`, `@query.tab`). Returns
145
+ * { code, stubCode } (runtime and type-stub spellings) or { error }.
146
+ */
147
+ function parseGateKey(expr) {
148
+ const KEY_ERROR = { error: 'a keyed gate key may only reference params or query (e.g. params.id)' };
149
+ const lit = (typeof expr === 'string' || expr instanceof String) ? expr.valueOf() : null;
150
+ if (lit != null) {
151
+ if (/^-?[\d.]/.test(lit) || lit.startsWith('"') || lit.startsWith("'")) {
152
+ return { code: lit, stubCode: lit };
153
+ }
154
+ return KEY_ERROR; // a bare identifier (incl. `params`/`query` alone) isn't a key
155
+ }
156
+ const segs = chainSegments(expr);
157
+ if (!segs) return KEY_ERROR;
158
+ if (segs[0] === 'this') segs.shift();
159
+ if ((segs[0] !== 'params' && segs[0] !== 'query') || segs.length < 2) return KEY_ERROR;
160
+ const code = segs.join('.');
161
+ // `params` and `query` are both Record<string, string> on the component, so
162
+ // the shadow reads the key the same way the runtime does — no cast needed.
163
+ return { code, stubCode: `this.${code}` };
164
+ }
165
+
82
166
  // ============================================================================
83
167
  // Prototype Installation
84
168
  // ============================================================================
@@ -575,9 +659,52 @@ export function installComponentSupport(CodeEmitter, Lexer) {
575
659
  const _transferMeta = (from, to) => {
576
660
  if (!(from instanceof String)) return to;
577
661
  const s = new String(to);
578
- if (from.predicate) s.predicate = true;
579
- if (from.await) s.await = true;
580
- return (s.predicate || s.await) ? s : to;
662
+ if (from.optional) s.optional = true;
663
+ if (from.bang) s.bang = true;
664
+ return (s.optional || s.bang) ? s : to;
665
+ };
666
+
667
+ // Inject `// @rip-src:N` markers onto each statement line of a method body
668
+ // emitted into a component stub. The emitter doesn't carry source-map data
669
+ // for stub method bodies, so without explicit markers, typecheck.js falls
670
+ // back to linear gap-fill interpolation which silently misaligns when stub
671
+ // sizes change. Walks the body s-expression to recover statement source
672
+ // lines and pairs them with the rendered body's non-trivial lines.
673
+ proto.addBodyRipSrcMarkers = function(bodyCode, bodySexpr) {
674
+ if (typeof bodyCode !== 'string' || !bodyCode) return bodyCode;
675
+ const stmts = Array.isArray(bodySexpr) && bodySexpr[0] === 'block'
676
+ ? bodySexpr.slice(1)
677
+ : (Array.isArray(bodySexpr) ? [bodySexpr] : []);
678
+ if (stmts.length === 0) return bodyCode;
679
+ const getLoc = (s) => {
680
+ if (s == null) return null;
681
+ if (!Array.isArray(s)) return s?.loc?.r ?? null;
682
+ if (s.loc?.r) return s.loc.r;
683
+ if (s[0]?.loc?.r) return s[0].loc.r;
684
+ for (const child of s) {
685
+ if (child?.loc?.r) return child.loc.r;
686
+ if (Array.isArray(child)) {
687
+ const l = getLoc(child);
688
+ if (l != null) return l;
689
+ }
690
+ }
691
+ return null;
692
+ };
693
+ const srcLines = stmts.map(getLoc);
694
+ if (srcLines.every(l => l == null)) return bodyCode;
695
+ const lines = bodyCode.split('\n');
696
+ let si = 0;
697
+ for (let i = 0; i < lines.length && si < srcLines.length; i++) {
698
+ const trimmed = lines[i].trim();
699
+ if (!trimmed) continue;
700
+ if (trimmed === '{' || trimmed === '}' || trimmed.startsWith('//')) continue;
701
+ if (lines[i].includes('@rip-src:')) { si++; continue; }
702
+ if (srcLines[si] != null) {
703
+ lines[i] = `${lines[i]} // @rip-src:${srcLines[si]}`;
704
+ }
705
+ si++;
706
+ }
707
+ return lines.join('\n');
581
708
  };
582
709
 
583
710
  proto.transformComponentMembers = function(sexpr, localScope = new Set()) {
@@ -676,6 +803,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
676
803
  // Categorize statements
677
804
  const stateVars = [];
678
805
  const derivedVars = [];
806
+ const gateVars = [];
679
807
  const readonlyVars = [];
680
808
  const methods = [];
681
809
  const lifecycleHooks = [];
@@ -707,31 +835,51 @@ export function installComponentSupport(CodeEmitter, Lexer) {
707
835
  reactiveMembers.add(varName);
708
836
  }
709
837
  } else if (op === '.' && stmt[1] === 'this' && getMemberName(stmt)) {
710
- // Required prop: (. this name) — no default value
838
+ // Bare prop form: `(. this name)` — no default value.
839
+ // `@name` → required (caller must pass)
840
+ // `@name?` → optional (caller may omit; value will be undefined)
841
+ // `@name?:: T`→ optional, typed
711
842
  const varName = (typeof stmt[2] === 'string' || stmt[2] instanceof String) ? stmt[2].valueOf() : null;
712
843
  if (varName) {
713
- stateVars.push({ name: varName, value: undefined, isPublic: true, type: stmt[2]?.type || null, required: true });
844
+ const optional = getMemberOptional(stmt);
845
+ stateVars.push({ name: varName, value: undefined, isPublic: true, type: stmt[2]?.type || null, required: !optional, optional, srcLine: stmt.loc?.r });
714
846
  memberNames.add(varName);
715
847
  reactiveMembers.add(varName);
716
848
  }
717
849
  } else if (op === 'state') {
718
850
  const varName = getMemberName(stmt[1]);
719
851
  if (varName) {
720
- stateVars.push({ name: varName, value: stmt[2], isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]) });
852
+ stateVars.push({ name: varName, value: stmt[2], isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]), optional: getMemberOptional(stmt[1]), srcLine: stmt.loc?.r });
721
853
  memberNames.add(varName);
722
854
  reactiveMembers.add(varName);
723
855
  }
724
856
  } else if (op === 'computed') {
725
857
  const varName = getMemberName(stmt[1]);
726
858
  if (varName) {
727
- derivedVars.push({ name: varName, expr: stmt[2], type: getMemberType(stmt[1]) });
859
+ derivedVars.push({ name: varName, expr: stmt[2], type: getMemberType(stmt[1]), srcLine: stmt.loc?.r });
860
+ memberNames.add(varName);
861
+ reactiveMembers.add(varName);
862
+ }
863
+ } else if (op === 'gate') {
864
+ // Render-ready binding: hoist the literal stash path into the
865
+ // component's static gate-set; bind the member as a reactive read.
866
+ const varName = getMemberName(stmt[1]);
867
+ if (varName) {
868
+ const parsed = parseGatePath(stmt[2]);
869
+ if (parsed.error) this.error(parsed.error, stmt);
870
+ let key = null;
871
+ if (parsed.keyExpr !== undefined) {
872
+ key = parseGateKey(parsed.keyExpr);
873
+ if (key.error) this.error(key.error, stmt);
874
+ }
875
+ gateVars.push({ name: varName, path: parsed.path, key, type: getMemberType(stmt[1]), srcLine: stmt.loc?.r });
728
876
  memberNames.add(varName);
729
877
  reactiveMembers.add(varName);
730
878
  }
731
879
  } else if (op === 'readonly') {
732
880
  const varName = getMemberName(stmt[1]);
733
881
  if (varName) {
734
- readonlyVars.push({ name: varName, value: stmt[2], isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]) });
882
+ readonlyVars.push({ name: varName, value: stmt[2], isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]), optional: getMemberOptional(stmt[1]), srcLine: stmt.loc?.r });
735
883
  memberNames.add(varName);
736
884
  }
737
885
  } else if (op === '=') {
@@ -745,7 +893,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
745
893
  methods.push({ name: varName, func: val });
746
894
  memberNames.add(varName);
747
895
  } else {
748
- stateVars.push({ name: varName, value: val, isPublic: isPublicProp(stmt[1]) });
896
+ stateVars.push({ name: varName, value: val, isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]), optional: getMemberOptional(stmt[1]), srcLine: stmt.loc?.r });
749
897
  memberNames.add(varName);
750
898
  reactiveMembers.add(varName);
751
899
  }
@@ -770,6 +918,17 @@ export function installComponentSupport(CodeEmitter, Lexer) {
770
918
  }
771
919
  }
772
920
 
921
+ // Record gate declarations on the generator so the compile result can
922
+ // expose them (compiler.js threads `_gateDecls` through as `gates`).
923
+ // `rip check` validates each path against app/stash.rip's source-key
924
+ // set — the compile-time mirror of the renderer's mount check.
925
+ if (gateVars.length > 0) {
926
+ if (!this._gateDecls) this._gateDecls = [];
927
+ for (const { path, key, srcLine } of gateVars) {
928
+ this._gateDecls.push({ path, keyed: !!key, line: (srcLine ?? 0) + 1 });
929
+ }
930
+ }
931
+
773
932
  // Auto-event map: onClick → 'click', onKeydown → 'keydown', etc.
774
933
  const autoEventHandlers = new Map();
775
934
  for (const { name } of methods) {
@@ -804,28 +963,44 @@ export function installComponentSupport(CodeEmitter, Lexer) {
804
963
 
805
964
  // --- Type-check stub: typed member declarations + body expressions, no DOM ---
806
965
  if (this.options.stubComponents) {
807
- // Inline type suffix expansion (mirrors types.js expandSuffixes)
808
- const expandType = (t) => t ? t.replace(/::/g, ':')
809
- .replace(/(\w+(?:<[^>]+>)?)\?\?/g, '$1 | null | undefined')
810
- .replace(/(\w+(?:<[^>]+>)?)\?(?![.:])/g, '$1 | undefined')
811
- .replace(/(\w+(?:<[^>]+>)?)\!/g, 'NonNullable<$1>') : null;
966
+ // Strip Rip's `::` annotation sigil to TypeScript's `:` separator.
967
+ const expandType = (t) => t ? t.replace(/::/g, ':') : null;
812
968
 
813
969
  const sl = [];
814
970
  const componentTypeParams = this._componentTypeParams || '';
815
971
  sl.push(`class ${componentTypeParams}{`);
816
- // `declare app: any` is rewritten to a typed shape by typecheck.js when
817
- // the project has a typed `<root>/app/stash.rip`. The compiler stays
818
- // context-free; the rewrite happens in the same pass that splices
819
- // function-overload signatures into the stub.
820
- sl.push(' declare _root: Element | null; declare app: any;');
972
+ // Injected `this` shape for every component. The compiler stays
973
+ // context-free; `declare app: any` and `declare router: any` are
974
+ // rewritten by typecheck.js to typed shapes when the project anchor /
975
+ // stash file is discoverable. `params`, `query`, `children`, and the
976
+ // lifecycle hooks are stable across all projects, so they're typed
977
+ // here directly. User-defined hooks are emitted later as real methods;
978
+ // skip the optional-signature declaration for those names to avoid
979
+ // overload-optionality mismatches.
980
+ // Combine all injected declarations onto a single line to preserve the
981
+ // stub's prior line count. Source-map gap-fill interpolates linearly
982
+ // between marker lines (e.g. between @rip-src markers from `@count`
983
+ // and a downstream method), so adding stub header lines pushes the
984
+ // method bodies past where interpolation expects them — breaking
985
+ // @ts-expect-error injection. Keeping a single header line preserves
986
+ // the relative offsets.
987
+ sl.push(' declare _root: Element | null; declare app: any; declare router: any; declare params: Record<string, string>; declare query: Record<string, string>; declare children: any;');
988
+ const userHookNames = new Set(lifecycleHooks.map(h => h.name));
989
+ const hookDecls = [];
990
+ if (!userHookNames.has('beforeMount')) hookDecls.push('beforeMount?(): void;');
991
+ if (!userHookNames.has('mounted')) hookDecls.push('mounted?(): void;');
992
+ if (!userHookNames.has('beforeUnmount')) hookDecls.push('beforeUnmount?(): void;');
993
+ if (!userHookNames.has('unmounted')) hookDecls.push('unmounted?(): void;');
994
+ if (!userHookNames.has('onError')) hookDecls.push(`onError?(err: ${ON_ERROR_PARAM_TYPE}): void;`);
995
+ if (hookDecls.length) sl.push(' ' + hookDecls.join(' '));
821
996
  sl.push(' emit(_name: string, _detail?: any): void {}');
822
997
 
823
998
  // Constructor — typed props for public state/readonly (matches DTS)
824
999
  const propEntries = [];
825
- for (const { name, type, isPublic, required } of stateVars) {
1000
+ for (const { name, type, isPublic, required, optional } of stateVars) {
826
1001
  if (!isPublic) continue;
827
1002
  const ts = expandType(type);
828
- const opt = required ? '' : '?';
1003
+ const opt = (optional ?? !required) ? '?' : '';
829
1004
  propEntries.push(`${name}${opt}: ${ts || 'any'}`);
830
1005
  // Two-way binding: allow parent to pass Signal<T> for this prop
831
1006
  propEntries.push(`__bind_${name}__?: Signal<${ts || 'any'}>`);
@@ -836,7 +1011,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
836
1011
  propEntries.push(`${name}?: ${ts || 'any'}`);
837
1012
  }
838
1013
  {
839
- const hasRequired = propEntries.length > 0 && stateVars.some(v => v.isPublic && v.required);
1014
+ const hasRequired = propEntries.length > 0 && stateVars.some(v => v.isPublic && v.required && !v.optional);
840
1015
  const propsOpt = hasRequired ? '' : '?';
841
1016
  let propsType = propEntries.length > 0 ? `{${propEntries.join('; ')}}` : '{}';
842
1017
  if (inheritsTag) propsType += ` & __RipProps<'${inheritsTag}'>`;
@@ -853,10 +1028,55 @@ export function installComponentSupport(CodeEmitter, Lexer) {
853
1028
  return null;
854
1029
  };
855
1030
 
1031
+ // A value references `this` synchronously if it reads `this`/`@…` or a
1032
+ // reactive member (which emits as `this.x.value`) OUTSIDE any nested
1033
+ // function. Refs inside a `->`/`=>` are deferred to call time, so they
1034
+ // don't run during field initialization. Used below to decide whether a
1035
+ // value is safe to emit as a field initializer (the injected `app`,
1036
+ // `router`, … are `declare`-only, so a synchronous `this.app` read in a
1037
+ // field initializer would trip TS2729 "used before initialization").
1038
+ const refsThisSync = (node) => {
1039
+ if (!Array.isArray(node)) {
1040
+ if (typeof node === 'string' || node instanceof String) {
1041
+ const s = node.valueOf();
1042
+ return s === 'this' || reactiveMembers.has(s);
1043
+ }
1044
+ return false;
1045
+ }
1046
+ const head = node[0]?.valueOf?.() ?? node[0];
1047
+ if (head === '->' || head === '=>') return false; // nested fn — deferred
1048
+ return node.some(refsThisSync);
1049
+ };
1050
+
856
1051
  // Property declarations (declare avoids definite-assignment errors)
857
- for (const { name, type, value } of stateVars) {
1052
+ const deferredStateInits = [];
1053
+ for (const { name, type, value, isPublic, optional, srcLine } of stateVars) {
858
1054
  const ts = expandType(type) || inferLiteralType(value);
859
- sl.push(ts ? ` declare ${name}: Signal<${ts}>;` : ` declare ${name}: Signal<any>;`);
1055
+ // Optional prop with no default: field can be undefined at runtime
1056
+ // (we don't synthesize a `?? null` fallback below), so the Signal's
1057
+ // payload type must include undefined.
1058
+ // `?` widens the Signal payload to `| undefined` when the member has
1059
+ // no initializer (`@label?:: T` — caller may omit) OR an explicit
1060
+ // `:= undefined` (`x?:: T := undefined` — starts empty). A `?` with a
1061
+ // real default stays `T`: the default fills the gap, so the field is
1062
+ // never undefined.
1063
+ const noValue = value === undefined || (value?.valueOf?.() ?? value) === 'undefined';
1064
+ const optNoDefault = optional && noValue;
1065
+ const wrapped = ts ? (optNoDefault ? `${ts} | undefined` : ts) : null;
1066
+ const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
1067
+ // No declared or literal-inferred type, but a private initializer is
1068
+ // present and reads nothing from `this` synchronously: emit a real
1069
+ // field initializer (deferred below) so TS infers the Signal payload
1070
+ // from the value — e.g. `x = createMutation(...)` → Signal<Mutation<R>>
1071
+ // — instead of falling back to Signal<any>. Mirrors how computed/gate
1072
+ // members already infer from their `__computed(...)` initializers.
1073
+ // Values that touch `this` synchronously keep the declare form (they
1074
+ // can't be field initializers — see refsThisSync above).
1075
+ if (!wrapped && !isPublic && !noValue && !refsThisSync(value)) {
1076
+ deferredStateInits.push({ name, value, marker });
1077
+ } else {
1078
+ sl.push((wrapped ? ` declare ${name}: Signal<${wrapped}>;` : ` declare ${name}: Signal<any>;`) + marker);
1079
+ }
860
1080
  }
861
1081
  if (inheritsTag) {
862
1082
  sl.push(` declare rest: Signal<__RipProps<'${inheritsTag}'>>;`);
@@ -865,6 +1085,19 @@ export function installComponentSupport(CodeEmitter, Lexer) {
865
1085
  const ts = expandType(type) || inferLiteralType(value);
866
1086
  sl.push(ts ? ` declare ${name}: ${ts};` : ` declare ${name}: any;`);
867
1087
  }
1088
+ // Gated bindings (<~): non-null by construction — the renderer loads
1089
+ // the source before the component exists. __ripGate is the generic
1090
+ // narrow `(v: T | null | undefined) => T`; soundness comes from the
1091
+ // runtime gate, not from the type system proving the load ran.
1092
+ for (const { name, path, key, type, srcLine } of gateVars) {
1093
+ const ts = expandType(type);
1094
+ const typeAnnot = ts ? `: Computed<${ts}>` : '';
1095
+ const access = key
1096
+ ? `this.app.data.${path}(${key.stubCode})`
1097
+ : `this.app.data.${path}`;
1098
+ const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
1099
+ sl.push(` ${name}${typeAnnot} = __computed(() => __ripGate(${access}));` + marker);
1100
+ }
868
1101
  for (const { name, expr, type } of derivedVars) {
869
1102
  const ts = expandType(type);
870
1103
  const typeAnnot = ts ? `: Computed<${ts}>` : '';
@@ -878,21 +1111,31 @@ export function installComponentSupport(CodeEmitter, Lexer) {
878
1111
  }
879
1112
  }
880
1113
 
1114
+ // Inferred-type private state vars: field initializers emitted after the
1115
+ // computed members above, so any member they read is already declared.
1116
+ for (const { name, value, marker } of deferredStateInits) {
1117
+ const val = this.emitInComponent(value, 'value');
1118
+ sl.push(` ${name} = __state(${val});` + marker);
1119
+ }
1120
+
881
1121
  // _init body — readonly, state, computed assignments (skip accepted/offered)
882
1122
  sl.push(' _init(props) {');
883
- for (const { name, value, isPublic } of readonlyVars) {
1123
+ for (const { name, value, isPublic, srcLine } of readonlyVars) {
884
1124
  const val = this.emitInComponent(value, 'value');
885
- sl.push(isPublic ? ` this.${name} = props.${name} ?? ${val};` : ` this.${name} = ${val};`);
886
- }
887
- for (const { name, value, isPublic, required, type } of stateVars) {
888
- if (isPublic && required) {
889
- sl.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name});`);
1125
+ const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
1126
+ sl.push((isPublic ? ` this.${name} = props.${name} ?? ${val};` : ` this.${name} = ${val};`) + marker);
1127
+ }
1128
+ for (const { name, value, isPublic, required, type, srcLine } of stateVars) {
1129
+ if (deferredStateInits.some(d => d.name === name)) continue;
1130
+ const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
1131
+ if (isPublic && (required || value === undefined)) {
1132
+ sl.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name});` + marker);
890
1133
  } else if (isPublic) {
891
1134
  const val = this.emitInComponent(value, 'value');
892
- sl.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name} ?? ${val});`);
1135
+ sl.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name} ?? ${val});` + marker);
893
1136
  } else {
894
1137
  const val = this.emitInComponent(value, 'value');
895
- sl.push(` this.${name} = __state(${val});`);
1138
+ sl.push(` this.${name} = __state(${val});` + marker);
896
1139
  }
897
1140
  }
898
1141
 
@@ -976,7 +1219,12 @@ export function installComponentSupport(CodeEmitter, Lexer) {
976
1219
  }
977
1220
  const transformed = this.reactiveMembers ? this.transformComponentMembers(methodBody) : methodBody;
978
1221
  const isAsync = this.containsAwait(methodBody);
979
- const bodyCode = this.emitFunctionBody(transformed, params || []);
1222
+ let bodyCode = this.emitFunctionBody(transformed, params || []);
1223
+ // Inject @rip-src markers on each body statement line so that
1224
+ // @ts-expect-error injection and hover/diagnostics resolve to the
1225
+ // user's actual source line, instead of relying on linear gap-fill
1226
+ // interpolation across the stub (which is brittle to stub size).
1227
+ bodyCode = this.addBodyRipSrcMarkers(bodyCode, methodBody);
980
1228
  sl.push(` ${isAsync ? 'async ' : ''}${name}(${paramStr}) ${bodyCode}`);
981
1229
  }
982
1230
  }
@@ -985,10 +1233,26 @@ export function installComponentSupport(CodeEmitter, Lexer) {
985
1233
  for (const { name, value } of lifecycleHooks) {
986
1234
  if (Array.isArray(value) && (value[0] === '->' || value[0] === '=>')) {
987
1235
  const [, params, hookBody] = value;
988
- const paramStr = Array.isArray(params) ? params.map(p => this.formatParam(p)).join(', ') : '';
1236
+ let paramStr = Array.isArray(params) ? params.map(p => this.formatParam(p)).join(', ') : '';
1237
+ // onError's payload shape is known to the framework, but its typed
1238
+ // optional declaration above is only emitted when the user does
1239
+ // NOT define the hook (a real method would collide with it) — so a
1240
+ // user-defined `onError: (err) ->` has no signature to inherit
1241
+ // from, and an unannotated param would be an implicit any in
1242
+ // strict projects. Type it from the same canonical constant the
1243
+ // declaration uses. Detection is structural (the parse tree's
1244
+ // type metadata, like getMemberType), and an explicit user
1245
+ // annotation always wins.
1246
+ if (name === 'onError' && Array.isArray(params) && params.length === 1) {
1247
+ const p = params[0];
1248
+ if ((typeof p === 'string' || p instanceof String) && !meta(p, 'type')) {
1249
+ paramStr = `${p.valueOf()}: ${ON_ERROR_PARAM_TYPE}`;
1250
+ }
1251
+ }
989
1252
  const transformed = this.reactiveMembers ? this.transformComponentMembers(hookBody) : hookBody;
990
1253
  const isAsync = this.containsAwait(hookBody);
991
- const bodyCode = this.emitFunctionBody(transformed, params || []);
1254
+ let bodyCode = this.emitFunctionBody(transformed, params || []);
1255
+ bodyCode = this.addBodyRipSrcMarkers(bodyCode, hookBody);
992
1256
  sl.push(` ${isAsync ? 'async ' : ''}${name}(${paramStr}) ${bodyCode}`);
993
1257
  }
994
1258
  }
@@ -999,6 +1263,18 @@ export function installComponentSupport(CodeEmitter, Lexer) {
999
1263
  const constructions = [];
1000
1264
  let constructionIdx = 0;
1001
1265
  const sourceLines = this.options.source?.split('\n');
1266
+ // Route-check any `href:` prop passed to a component the same way
1267
+ // intrinsic `<a href>` is checked. A component that forwards href to
1268
+ // an anchor (e.g. `component extends a`) exposes it via `@rest` typed
1269
+ // as plain `string`, so without this `ButtonLink href: "/typo"` would
1270
+ // escape route validation entirely. We can't see the component's tag
1271
+ // cross-module, so we key on the prop name — `href` is a URL wherever
1272
+ // it appears. Wrap slash-prefixed string and template literals in
1273
+ // `__ripRoute(...)` (strengthened to the route union when the project
1274
+ // has routes); external URLs and variables (no leading-slash literal)
1275
+ // pass through untouched.
1276
+ const wrapCompHref = (val) =>
1277
+ typeof val === 'string' && /^["'`]\//.test(val) ? `__ripRoute(${val})` : val;
1002
1278
  const extractProps = (args) => {
1003
1279
  const props = [];
1004
1280
  const sideExprs = [];
@@ -1042,7 +1318,8 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1042
1318
  props.push({ code: `${key}: ${member}`, srcLine });
1043
1319
  } else {
1044
1320
  const val = this.emitInComponent(value, 'value');
1045
- props.push({ code: `${key}: ${val}`, srcLine });
1321
+ const finalVal = key === 'href' ? wrapCompHref(val) : val;
1322
+ props.push({ code: `${key}: ${finalVal}`, srcLine });
1046
1323
  }
1047
1324
  }
1048
1325
  }
@@ -1051,8 +1328,22 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1051
1328
  props.sideExprs = sideExprs;
1052
1329
  return props;
1053
1330
  };
1054
- const extractIntrinsicProps = (args) => {
1331
+ const extractIntrinsicProps = (args, tagName) => {
1055
1332
  const props = [];
1333
+ // For <a href: ...>, wrap interpolated route literals (templates
1334
+ // starting with `/...`) in __ripRoute so TS checks the dynamic
1335
+ // template against __RipRoutes. Static literals fall through to
1336
+ // the existing __ripEl Option-C conditional, which still gives
1337
+ // the "Did you mean ..." hint. Variables and external URLs are
1338
+ // left untouched.
1339
+ const wrapHrefVal = (val) => {
1340
+ if (tagName !== 'a') return val;
1341
+ if (typeof val !== 'string') return val;
1342
+ if (val.length < 2) return val;
1343
+ if (val[0] !== '`') return val;
1344
+ if (val[1] !== '/') return val;
1345
+ return `__ripRoute(${val})`;
1346
+ };
1056
1347
  for (const arg of args) {
1057
1348
  let obj = null;
1058
1349
  if (this.is(arg, 'object')) {
@@ -1071,9 +1362,25 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1071
1362
  if (Array.isArray(key) && key[0] === '.' && key[1] === 'this') {
1072
1363
  let memberName = typeof key[2] === 'string' ? key[2] : key[2]?.valueOf?.();
1073
1364
  if (!memberName) continue;
1074
- const eventKey = '@' + memberName.split('.')[0];
1075
- const val = this.emitInComponent(value, 'value');
1076
- props.push({ code: `'${eventKey}': ${val}`, srcLine });
1365
+ const eventName = memberName.split('.')[0];
1366
+ let val = this.emitInComponent(value, 'value');
1367
+ // Inline arrow handler: explicitly annotate its untyped first
1368
+ // param with the event type. Letting TS contextually type it
1369
+ // through __ripEl's generic props is fragile — certain prop
1370
+ // combinations silently drop the param to `any`. This gives
1371
+ // inline handlers the same reliable typing the `@event: @method`
1372
+ // path already gets, matching __RipEvents' shape exactly.
1373
+ if (Array.isArray(value) && (value[0] === '->' || value[0] === '=>')) {
1374
+ const p0 = value[1]?.[0];
1375
+ const pName = typeof p0 === 'string' ? p0 : p0?.valueOf?.();
1376
+ if (pName && !(p0 && p0.type) && /^[A-Za-z_$][\w$]*$/.test(pName)) {
1377
+ const T = `RipEvent<HTMLElementEventMap['${eventName}'], __RipElementMap['${tagName}']>`;
1378
+ val = val[0] === '('
1379
+ ? val.replace(new RegExp('^\\(\\s*' + pName + '\\b'), `(${pName}: ${T}`)
1380
+ : val.replace(new RegExp('^' + pName + '\\b'), `(${pName}: ${T})`);
1381
+ }
1382
+ }
1383
+ props.push({ code: `'@${eventName}': ${val}`, srcLine });
1077
1384
  } else if (typeof key === 'string') {
1078
1385
  if (key === 'key') {
1079
1386
  // key: is not an HTML attribute, but emit its value
@@ -1089,7 +1396,8 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1089
1396
  props.push({ code: `${propName}: ${val}`, srcLine });
1090
1397
  } else {
1091
1398
  const val = this.emitInComponent(value, 'value');
1092
- props.push({ code: `${key}: ${val}`, srcLine });
1399
+ const finalVal = key === 'href' ? wrapHrefVal(val) : val;
1400
+ props.push({ code: `${key}: ${finalVal}`, srcLine });
1093
1401
  }
1094
1402
  }
1095
1403
  }
@@ -1166,14 +1474,38 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1166
1474
  const condCode = this.emitInComponent(condition, 'value');
1167
1475
  const srcLine = node.loc?.r;
1168
1476
  const srcMarker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
1169
- constructions.push(` ${condCode};${srcMarker}`);
1477
+ // Emit a real `if (...) { } else { }` (mirroring the for-loop
1478
+ // case below) instead of a bare condition statement with the
1479
+ // branches flattened as unguarded siblings. The structure is
1480
+ // what lets TS narrow the condition inside the then-branch —
1481
+ // `if order then order.total` type-checks against `T | null`
1482
+ // without `?.` laundering. Reads are plain property chains
1483
+ // (`this.order.value`), which TS keeps narrowed across the
1484
+ // intervening `__ripEl(...)` stub calls.
1485
+ const guard = head === 'unless' ? `!(${condCode})` : condCode;
1486
+ constructions.push(` if (${guard}) {${srcMarker}`);
1487
+ if (node[2] != null) walkRender(node[2]);
1488
+ const elses = node.slice(3).filter(n => n != null);
1489
+ if (elses.length) {
1490
+ constructions.push(` } else {`);
1491
+ for (const e of elses) walkRender(e);
1492
+ }
1493
+ constructions.push(` }`);
1494
+ return; // branches fully handled — don't re-walk children below
1170
1495
  }
1171
1496
  } else if (head === '?:') {
1172
- // Emit the full ternary so all branches are type-checked
1497
+ // Emit the full ternary so all branches are type-checked, then
1498
+ // return — like the `__text__`/`str` cases. The emitted expression
1499
+ // already covers every branch, so re-walking children is redundant
1500
+ // and actively harmful: a nested member access (e.g. `opt.label`
1501
+ // in `typeof opt is "string" ? opt : opt.label`) would hit the
1502
+ // bare member-access case below and be re-emitted *unguarded*,
1503
+ // stripping the `typeof` narrowing and producing a false error.
1173
1504
  const ternCode = this.emitInComponent(node, 'value');
1174
1505
  const srcLine = node.loc?.r;
1175
1506
  const srcMarker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
1176
1507
  constructions.push(` ${ternCode};${srcMarker}`);
1508
+ return;
1177
1509
  } else if (head === 'switch') {
1178
1510
  const discriminant = node[1];
1179
1511
  if (discriminant != null) {
@@ -1242,6 +1574,26 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1242
1574
  constructions.push(` ${exprCode};${srcMarker}`);
1243
1575
  } catch {}
1244
1576
  return;
1577
+ } else if (head === '.' || head === '?.') {
1578
+ // Bare member-access expression as element content
1579
+ // (e.g. `div config.site.theme.logoMark`). Walk to the chain's
1580
+ // base identifier: emit it for type-checking only when the base
1581
+ // is a value reference, NOT an HTML tag-shorthand like `div.image`
1582
+ // (whose base IS a tag). Without this, static text-content
1583
+ // expressions never reach the stub and escape type-checking —
1584
+ // only interpolated "#{...}" content was being checked.
1585
+ let base = node;
1586
+ while (Array.isArray(base) && (base[0] === '.' || base[0] === '?.')) base = base[1];
1587
+ const baseName = typeof base === 'string' ? base : base?.valueOf?.();
1588
+ if (typeof baseName === 'string' && !this.isHtmlTag(baseName) && !CodeEmitter.GENERATORS[baseName]) {
1589
+ try {
1590
+ const exprCode = this.emitInComponent(node, 'value');
1591
+ const srcLine = node.loc?.r;
1592
+ const srcMarker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
1593
+ constructions.push(` ${exprCode};${srcMarker}`);
1594
+ } catch {}
1595
+ }
1596
+ return;
1245
1597
  }
1246
1598
 
1247
1599
  // Emit a bare lowercase identifier as either a property access
@@ -1373,7 +1725,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1373
1725
  } else if (typeof head === 'string' && !CodeEmitter.GENERATORS[head] && (TEMPLATE_TAGS.has(head.split(/[.#]/)[0]) ||
1374
1726
  (/^[a-z][\w-]*$/.test(head) && node.length > 1))) {
1375
1727
  const tagName = head.split(/[.#]/)[0];
1376
- const iProps = extractIntrinsicProps(node.slice(1));
1728
+ const iProps = extractIntrinsicProps(node.slice(1), tagName);
1377
1729
  const tagLine = node.loc?.r;
1378
1730
  const srcMarker = tagLine != null ? ` // @rip-src:${tagLine}` : '';
1379
1731
  if (iProps.length === 0) {
@@ -1423,9 +1775,28 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1423
1775
 
1424
1776
  lines.push('class extends __Component {');
1425
1777
 
1778
+ // Static gate-set — readable by the renderer without
1779
+ // constructing the component. Strings are plain stash paths; keyed
1780
+ // gates carry a key fn the renderer evaluates with the matched
1781
+ // params/query before construction.
1782
+ if (gateVars.length > 0) {
1783
+ const items = gateVars.map(({ path, key }) => key
1784
+ ? `{ path: '${path}', key: (params, query) => ${key.code} }`
1785
+ : `'${path}'`);
1786
+ lines.push(` static __gates = [${items.join(', ')}];`);
1787
+ }
1788
+
1426
1789
  // --- Init (called by __Component constructor) ---
1427
1790
  lines.push(' _init(props) {');
1428
1791
 
1792
+ // Gated bindings (<~) — first, so any readonly/state initializer can
1793
+ // consume the loaded value synchronously (`form := { ...user }`).
1794
+ for (const { name, path, key } of gateVars) {
1795
+ lines.push(key
1796
+ ? ` this.${name} = __gateBind(this, '${path}', (params, query) => ${key.code});`
1797
+ : ` this.${name} = __gateBind(this, '${path}');`);
1798
+ }
1799
+
1429
1800
  // Constants (readonly)
1430
1801
  for (const { name, value, isPublic } of readonlyVars) {
1431
1802
  const val = this.emitInComponent(value, 'value');
@@ -1441,7 +1812,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1441
1812
 
1442
1813
  // State variables (__state handles signal passthrough)
1443
1814
  for (const { name, value, isPublic, required } of stateVars) {
1444
- if (isPublic && required) {
1815
+ if (isPublic && (required || value === undefined)) {
1445
1816
  lines.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name});`);
1446
1817
  } else if (isPublic) {
1447
1818
  const val = this.emitInComponent(value, 'value');
@@ -2727,7 +3098,13 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2727
3098
  const outerParams = this._loopVarStack.map(v => `${v.itemVar}, ${v.indexVar}`).join(', ');
2728
3099
  const outerExtra = outerParams ? `, ${outerParams}` : '';
2729
3100
 
2730
- this._loopVarStack.push({ itemVar, indexVar });
3101
+ // If the iterated collection is reactive, treat direct member access
3102
+ // on the iter var (`item.qty`, `item[0]`, `item.a.b`) as reactive
3103
+ // inside the loop body. hasReactiveDeps consults this flag, so the
3104
+ // existing emit paths wrap those reads in __effect and the per-row
3105
+ // patch function gets populated.
3106
+ const reactiveSource = this.hasReactiveDeps(collection);
3107
+ this._loopVarStack.push({ itemVar, indexVar, reactiveSource });
2731
3108
  const itemNode = this.emitTemplateBlock(body);
2732
3109
  this._loopVarStack.pop();
2733
3110
  const itemCreateLines = this._createLines;
@@ -3037,6 +3414,16 @@ export function installComponentSupport(CodeEmitter, Lexer) {
3037
3414
  return true;
3038
3415
  }
3039
3416
 
3417
+ // Property chain rooted at a `for`-loop iter var whose source is
3418
+ // reactive (e.g. `for item in cart.items` → `item.qty`, `item[0]`,
3419
+ // `item.foo.bar`). Same rationale as the this-rooted case: the source
3420
+ // proxy is reactive at runtime, so any member read may track. Limited
3421
+ // to direct member access on the iter var — aliases and destructuring
3422
+ // are explicitly out of scope.
3423
+ if ((sexpr[0] === '.' || sexpr[0] === '[]') && this._rootsAtReactiveLoopVar(sexpr)) {
3424
+ return true;
3425
+ }
3426
+
3040
3427
  // Method call on component: [['.', 'this', method], ...args]
3041
3428
  // Methods may read reactive state internally — treat as reactive so the
3042
3429
  // call gets wrapped in __effect and re-runs when dependencies change.
@@ -3118,6 +3505,26 @@ export function installComponentSupport(CodeEmitter, Lexer) {
3118
3505
  return this._rootsAtThis(sexpr[1]);
3119
3506
  };
3120
3507
 
3508
+ // _rootsAtReactiveLoopVar — true when a `.` / `[]` access chain bottoms
3509
+ // out at a string identifier that matches the itemVar of some frame on
3510
+ // _loopVarStack whose `reactiveSource` is true. See emitTemplateLoop.
3511
+ proto._rootsAtReactiveLoopVar = function(sexpr) {
3512
+ if (typeof sexpr === 'string') {
3513
+ if (!this._loopVarStack || this._loopVarStack.length === 0) return false;
3514
+ // Iterate innermost-first so a shadowed name resolves to its nearest
3515
+ // binding: `for item in reactive` containing `for item in [1,2,3]`
3516
+ // must treat inner reads of `item` as non-reactive.
3517
+ for (let i = this._loopVarStack.length - 1; i >= 0; i--) {
3518
+ const v = this._loopVarStack[i];
3519
+ if (v.itemVar === sexpr) return !!v.reactiveSource;
3520
+ }
3521
+ return false;
3522
+ }
3523
+ if (!Array.isArray(sexpr)) return false;
3524
+ if (sexpr[0] === '.' || sexpr[0] === '[]') return this._rootsAtReactiveLoopVar(sexpr[1]);
3525
+ return false;
3526
+ };
3527
+
3121
3528
  // ==========================================================================
3122
3529
  // Component Runtime
3123
3530
  // ==========================================================================
@@ -3387,6 +3794,28 @@ function __transition(el, name, dir, done) {
3387
3794
  });
3388
3795
  }
3389
3796
 
3797
+ // Render-ready binding (\`x <~ @app.data.x\`): a reactive read of a
3798
+ // stash source key that retains the last non-null value for the life of
3799
+ // this instance. A null write or reset() invalidates the CELL (ungated
3800
+ // readers see it immediately) without yanking mounted gates — the
3801
+ // live-binding invariant. Last-good lives in this closure, so it dies
3802
+ // with the instance.
3803
+ function __gateBind(self, path, keyFn) {
3804
+ // The component runtime ships both inlined after the reactive runtime
3805
+ // (where __computed is a local) and as a standalone bundle chunk (where
3806
+ // it is not) — resolve through globalThis.__rip in the latter case.
3807
+ const __c = typeof __computed !== 'undefined' ? __computed : globalThis.__rip.__computed;
3808
+ let last;
3809
+ return __c(() => {
3810
+ const data = self.app && self.app.data;
3811
+ if (!data) return last;
3812
+ const v = keyFn
3813
+ ? data[path](keyFn(self.params || {}, self.query || {}))
3814
+ : data[path]; // dotted paths route through the stash's path-key get
3815
+ return v != null ? (last = v) : last;
3816
+ });
3817
+ }
3818
+
3390
3819
  function __handleComponentError(error, component) {
3391
3820
  let current = component;
3392
3821
  // Defensive cycle guard: if the parent chain is corrupted (e.g. by
@@ -3408,6 +3837,17 @@ class __Component {
3408
3837
  constructor(props = {}) {
3409
3838
  Object.assign(this, props);
3410
3839
  if (!this.app && globalThis.__ripApp) this.app = globalThis.__ripApp;
3840
+ // A component with render gates (<~) is honored only via route
3841
+ // matching — the renderer sets __ripGateMount around each route/layout
3842
+ // construction. Constructed as an embedded child (a parent scope is
3843
+ // active but the renderer flag doesn't match), its gates were never
3844
+ // loaded, so the T-typed bindings would be silent nulls. Fail loud and
3845
+ // deterministically instead. Direct construction outside any component
3846
+ // scope (tests: seed the stash, then construct) stays allowed.
3847
+ const __g = this.constructor.__gates;
3848
+ if (__g && __g.length && globalThis.__ripGateMount !== this.constructor && __currentComponent) {
3849
+ throw new Error('[Rip] component declares render gates (<~) but was constructed as an embedded child; gates are honored only via route matching — lift the gate to the route/layout and pass the value as a prop');
3850
+ }
3411
3851
  const prev = __pushComponent(this);
3412
3852
  try {
3413
3853
  this._init(props);
@@ -3507,7 +3947,7 @@ class __Component {
3507
3947
 
3508
3948
  // Register on globalThis for runtime deduplication
3509
3949
  if (typeof globalThis !== 'undefined') {
3510
- globalThis.__ripComponent = { __pushComponent, __popComponent, __getCurrentComponent, setContext, getContext, hasContext, __clsx, __lis, __reconcile, __transition, __handleComponentError, __Component };
3950
+ globalThis.__ripComponent = { __pushComponent, __popComponent, __getCurrentComponent, setContext, getContext, hasContext, __clsx, __lis, __reconcile, __transition, __handleComponentError, __gateBind, __Component };
3511
3951
  }
3512
3952
 
3513
3953
  `;