@barefootjs/jsx 0.31.9 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,6 +30,7 @@
30
30
  // `undef`, which Mojo renders as empty string).
31
31
 
32
32
  import ts from 'typescript'
33
+ import { extractFreeIdentifiersFromNode } from './analyzer.ts'
33
34
  import type { IRMetadata } from './types.ts'
34
35
 
35
36
  /**
@@ -52,6 +53,16 @@ export interface SsrDefault {
52
53
  * (`{ variant = 'default' }`) where the template variable maps 1:1
53
54
  * to a caller-supplied prop. Omitted for signal / memo entries that
54
55
  * are internal to the component.
56
+ *
57
+ * Invariant (#2669): a signal / memo entry carries `propName` IF AND
58
+ * ONLY IF it's a self-derivation collision — the signal/memo's own
59
+ * template variable shares a name with the prop its initializer
60
+ * derives from (`const [label] = createSignal(props.label ?? 'x')`).
61
+ * Every OTHER signal/memo entry is `propName`-less. Consumers (e.g.
62
+ * every template-stash adapter's conformance harness, `test-render.ts`)
63
+ * rely on this to know a signal/memo's stash seed must defer to the
64
+ * already-derived prop value rather than re-seeding from its own
65
+ * evaluated initial — see `extractSsrDefaults`'s signal/memo loops.
55
66
  */
56
67
  propName?: string
57
68
  /**
@@ -204,6 +215,11 @@ export function extractSsrDefaults(metadata: IRMetadata): Record<string, SsrDefa
204
215
  // fed into the bindings map so subsequent memos can reference earlier
205
216
  // signals (Counter's `doubled = createMemo(() => count() * 2)`).
206
217
  const bindings: Record<string, EvalResult> = {}
218
+ // Component-scope const locals, for the transitive prop-reference
219
+ // resolution both `referencesOwnProp` calls below and the bare-props
220
+ // safety net (further down) use — computed once so every caller shares
221
+ // the same table.
222
+ const localConsts = localConstValuesByName(metadata)
207
223
 
208
224
  // (#checkbox) Seed module-scope constants so a memo template-literal that
209
225
  // references them resolves to a concrete string. Checkbox's `classes` memo
@@ -225,14 +241,73 @@ export function extractSsrDefaults(metadata: IRMetadata): Record<string, SsrDefa
225
241
  // baked initial value.
226
242
  if (sig.envReader) continue
227
243
  const value = tryStaticEval(sig.initialValue, { bindings, propsLike })
228
- out[sig.getter] = { value: resultToJsonable(value) }
244
+ // Self-derivation collision (#2669): a signal whose getter shares its
245
+ // name with the prop its OWN initializer derives from
246
+ // (`const [label] = createSignal(props.label ?? 'Default')`) gets a
247
+ // template-stash variable that the emitted template both READS (as the
248
+ // raw caller prop) and OVERWRITES (with the derived signal value) under
249
+ // that SAME name — see the Mojo/Jinja/etc. emitter's `{% set label =
250
+ // (label if label is defined else 'Default') %}`. Seeding the stash
251
+ // with the DERIVED value here would either permanently lock out a
252
+ // caller-supplied prop (idempotent derivations like `?? 'Default'` hide
253
+ // this — a caller's real value can never win) or double-apply a
254
+ // non-idempotent derivation (`(props.count ?? 1) * 2` seeded with the
255
+ // evaluated `2` re-derives to `2 * 2 = 4`). The correct seed is the RAW
256
+ // prop, so the entry must be a PROP entry (`propName` set, `value:
257
+ // null`) instead of a plain signal-value entry — the template's own
258
+ // `?? <default>` / `is not none` guard performs the real derivation,
259
+ // exactly like the bare-props safety net below.
260
+ //
261
+ // NOT self-derived (the initializer references a DIFFERENT prop, or no
262
+ // prop at all) keeps today's behavior: the signal's evaluated value
263
+ // wins the entry outright. This also correctly leaves alone the
264
+ // separate (out-of-scope) case where the signal's OWN initializer does
265
+ // NOT reference the same-named prop but the JSX body separately reads
266
+ // both `label()` and `props.label` — that's a template-variable-
267
+ // aliasing defect, not this one, and must render byte-identically.
268
+ //
269
+ // Invariant this establishes (relied on by every template-stash
270
+ // conformance harness's seeding loops — see `test-render.ts`): a
271
+ // signal/memo entry carries `propName` IF AND ONLY IF it went through
272
+ // this collision path. An ordinary signal/memo entry never has
273
+ // `propName` — that's exclusively how a harness (or a production
274
+ // manifest consumer) can tell "this local's stash seed must come from
275
+ // the caller-facing prop, not the local's own evaluated value" apart
276
+ // from "this is an internal signal/memo the caller cannot override".
277
+ if (metadata.propsObjectName !== null && referencesOwnProp(sig.initialValue, metadata.propsObjectName, sig.getter, localConsts)) {
278
+ // Pass 1 above already wrote the prop entry when the prop is
279
+ // *declared* on the props type — leave it as-is. An undeclared
280
+ // (untyped / inline-typed) prop has no pass-1 entry yet; create it
281
+ // here so the collision is still resolved for that shape.
282
+ if (!(sig.getter in out)) {
283
+ out[sig.getter] = { propName: sig.getter, value: null }
284
+ }
285
+ } else {
286
+ out[sig.getter] = { value: resultToJsonable(value) }
287
+ }
288
+ // `bindings` always gets the EVALUATED value regardless of which stash
289
+ // seed the entry above ended up with — a later memo referencing this
290
+ // getter (`createMemo(() => label() + '!')`) must keep resolving
291
+ // through the chain exactly as before; only the manifest's own seed
292
+ // choice changes, not the static-eval semantics.
229
293
  bindings[sig.getter] = value
230
294
  }
231
295
 
232
296
  for (const memo of metadata.memos) {
233
297
  if (memo.isModule) continue
234
298
  const value = tryStaticEval(memo.computation, { bindings, propsLike })
235
- out[memo.name] = { value: resultToJsonable(value) }
299
+ // Same self-derivation collision as signals above, for a memo whose
300
+ // computation derives from a same-named prop
301
+ // (`createMemo(() => (props.label ?? 'Default') + n())`). See the
302
+ // signal loop's comment for the full mechanism and the `propName`
303
+ // invariant this relies on.
304
+ if (metadata.propsObjectName !== null && referencesOwnProp(memo.computation, metadata.propsObjectName, memo.name, localConsts)) {
305
+ if (!(memo.name in out)) {
306
+ out[memo.name] = { propName: memo.name, value: null }
307
+ }
308
+ } else {
309
+ out[memo.name] = { value: resultToJsonable(value) }
310
+ }
236
311
  bindings[memo.name] = value
237
312
  }
238
313
 
@@ -251,11 +326,11 @@ export function extractSsrDefaults(metadata: IRMetadata): Record<string, SsrDefa
251
326
  const referenced = new Set<string>()
252
327
  for (const sig of metadata.signals) {
253
328
  if (!sig.getter || sig.isModule || sig.envReader) continue
254
- collectPropRefs(sig.initialValue, metadata.propsObjectName, referenced)
329
+ collectPropRefsTransitive(sig.initialValue, metadata.propsObjectName, localConsts, referenced)
255
330
  }
256
331
  for (const memo of metadata.memos) {
257
332
  if (memo.isModule) continue
258
- collectPropRefs(memo.computation, metadata.propsObjectName, referenced)
333
+ collectPropRefsTransitive(memo.computation, metadata.propsObjectName, localConsts, referenced)
259
334
  }
260
335
  for (const name of referenced) {
261
336
  // Don't clobber a signal / memo (or already-seeded prop) of the same
@@ -268,6 +343,47 @@ export function extractSsrDefaults(metadata: IRMetadata): Record<string, SsrDefa
268
343
  return Object.keys(out).length === 0 ? undefined : out
269
344
  }
270
345
 
346
+ /**
347
+ * Component-scope (non-module) `const` locals, by name → value source text.
348
+ * `referencesOwnProp` and `collectPropRefsTransitive` (below) use this to
349
+ * see PAST one hop of pure indirection between a prop read and the
350
+ * signal/memo that derives from it — `const mid = props.label;
351
+ * createSignal(mid ?? 'Default')` (#2685 review, one hop past #2669's
352
+ * direct-access-only detection). Module-scope consts are excluded: a
353
+ * MODULE const's value is fixed at compile time and isn't a `props.X` read
354
+ * (a module const referencing `props` isn't legal JS — `props` is a
355
+ * function parameter), so it can never contribute a prop reference here.
356
+ */
357
+ function localConstValuesByName(metadata: IRMetadata): ReadonlyMap<string, string> {
358
+ const out = new Map<string, string>()
359
+ for (const c of metadata.localConstants ?? []) {
360
+ if (c.isModule || c.value === undefined) continue
361
+ out.set(c.name, c.value)
362
+ }
363
+ return out
364
+ }
365
+
366
+ /**
367
+ * Does `expr` (a signal initializer or memo computation) read
368
+ * `propsObjectName.<name>` where `name` is that SAME signal's getter / that
369
+ * SAME memo's name — directly, or through a chain of component-scope
370
+ * `const` locals? This is the #2669 self-derivation test (widened by
371
+ * #2685 review to see through indirection): reuses `collectPropRefsTransitive`
372
+ * (never a bespoke walker — see the CLAUDE.md rule this file already
373
+ * follows for `props.X` collection) and just checks membership of the one
374
+ * name we care about.
375
+ */
376
+ function referencesOwnProp(
377
+ expr: string | undefined,
378
+ propsObjectName: string,
379
+ name: string,
380
+ localConsts: ReadonlyMap<string, string>,
381
+ ): boolean {
382
+ const referenced = new Set<string>()
383
+ collectPropRefsTransitive(expr, propsObjectName, localConsts, referenced)
384
+ return referenced.has(name)
385
+ }
386
+
271
387
  /**
272
388
  * Collect the first-level property name of every `propsObjectName.X` access
273
389
  * within `expr` (e.g. `props.initial ?? 0` → `initial`). Used to seed the
@@ -276,6 +392,11 @@ export function extractSsrDefaults(metadata: IRMetadata): Record<string, SsrDefa
276
392
  * prop `a`: adapters lower that to `$a->{b}`, so the bare `$a` needs
277
393
  * seeding just the same — the walk stops at the first
278
394
  * `propsObjectName.<name>` match and collects `a` (not `b`).
395
+ *
396
+ * Direct-access only — does NOT look through a component-scope `const`
397
+ * that itself reads `propsObjectName.X`. Callers that need to see past
398
+ * that indirection use `collectPropRefsTransitive` below, which wraps this
399
+ * function rather than duplicating its walk.
279
400
  */
280
401
  function collectPropRefs(
281
402
  expr: string | undefined,
@@ -304,6 +425,46 @@ function collectPropRefs(
304
425
  visit(node)
305
426
  }
306
427
 
428
+ /**
429
+ * `collectPropRefs`, widened to look THROUGH component-scope `const`
430
+ * locals (#2685 review): a signal/memo initializer often reads a prop one
431
+ * hop removed — `const mid = props.label; createSignal(mid ?? 'Default')`
432
+ * — rather than `propsObjectName.X` directly. Runs the existing
433
+ * direct-access walk over `expr` first (unchanged behavior for the common
434
+ * case), then finds every VALUE-POSITION identifier `expr` references
435
+ * (via the analyzer's `extractFreeIdentifiersFromNode` — never a bespoke
436
+ * identifier scan, so a property-access tail like `foo.mid` or an
437
+ * object-literal key like `{ mid: 1 }` is correctly NOT mistaken for a
438
+ * read of a local named `mid`) and, for each one that names a local
439
+ * const, recurses into THAT const's own value expression the same way.
440
+ *
441
+ * `visited` guards re-entering the same const twice — both for the
442
+ * (source-impossible, since JS TDZ forbids a const referencing itself)
443
+ * pathological-cycle case, and for the ordinary diamond case (two
444
+ * branches of `expr` both reading the same local) so it isn't walked
445
+ * twice. Fresh per top-level call via the default parameter, so unrelated
446
+ * calls for different signals/memos don't share state.
447
+ */
448
+ function collectPropRefsTransitive(
449
+ expr: string | undefined,
450
+ propsObjectName: string,
451
+ localConsts: ReadonlyMap<string, string>,
452
+ out: Set<string>,
453
+ visited: Set<string> = new Set(),
454
+ ): void {
455
+ if (!expr || !expr.trim()) return
456
+ collectPropRefs(expr, propsObjectName, out)
457
+ const node = parseExpression(expr)
458
+ if (!node) return
459
+ for (const id of extractFreeIdentifiersFromNode(node)) {
460
+ if (visited.has(id)) continue
461
+ const constValue = localConsts.get(id)
462
+ if (constValue === undefined) continue
463
+ visited.add(id)
464
+ collectPropRefsTransitive(constValue, propsObjectName, localConsts, out, visited)
465
+ }
466
+ }
467
+
307
468
  function resultToJsonable(v: EvalResult): unknown {
308
469
  if (v === UNRESOLVED) return null
309
470
  if (v === undefined) return null
@@ -366,12 +527,16 @@ function evalStatementsForReturn(
366
527
 
367
528
  function parseExpression(expr: string): ts.Expression | null {
368
529
  // Wrap in parens so a leading `{}` parses as an object literal rather
369
- // than an empty block statement.
530
+ // than an empty block statement. Parent nodes ARE set (unlike a bare
531
+ // parse) so `collectPropRefsTransitive` can call the analyzer's
532
+ // `extractFreeIdentifiersFromNode` — which needs `.parent` to tell a
533
+ // value-position identifier from a property-access tail / object-literal
534
+ // key / parameter name — on the result.
370
535
  const sf = ts.createSourceFile(
371
536
  '__ssr_default__.ts',
372
537
  `(${expr})`,
373
538
  ts.ScriptTarget.Latest,
374
- false,
539
+ true,
375
540
  ts.ScriptKind.TS,
376
541
  )
377
542
  const stmt = sf.statements[0]
@@ -35,6 +35,7 @@ import { envSignalReaderFor, type EnvSignalReader } from './adapters/env-signal.
35
35
  import {
36
36
  extractArrowBodyExpression,
37
37
  freeIdentifiers,
38
+ inlineBinding,
38
39
  isSupported,
39
40
  parseExpression,
40
41
  type ParsedExpr,
@@ -97,6 +98,72 @@ function classify(
97
98
  return { kind: 'derived', name, origin, expr, parsed, frees: [...frees] }
98
99
  }
99
100
 
101
+ /**
102
+ * Component-scope (non-module) `const` locals, by name, parsed to their
103
+ * `ParsedExpr` value — the substitution table `resolveThroughLocalConsts`
104
+ * inlines through. Module-scope consts are excluded: they're already part
105
+ * of `baseScope` (every adapter compile-time-inlines them to their literal
106
+ * value, so a reference to one is never a template-variable read — see the
107
+ * module doc), and a `let` has no stable value to substitute.
108
+ */
109
+ function localConstExprsByName(metadata: IRMetadata): ReadonlyMap<string, ParsedExpr> {
110
+ const out = new Map<string, ParsedExpr>()
111
+ for (const c of metadata.localConstants ?? []) {
112
+ if (c.isModule || c.declarationKind !== 'const' || c.value === undefined) continue
113
+ out.set(c.name, c.parsed ?? parseExpression(c.value.trim()))
114
+ }
115
+ return out
116
+ }
117
+
118
+ /**
119
+ * Resolve a signal/memo's parsed expression through any component-scope
120
+ * `const` locals it references, so a hop of pure indirection between a
121
+ * prop read and its derived signal/memo — `const mid = props.label;
122
+ * createSignal(mid ?? 'Default')` — classifies from the SAME scope its
123
+ * fully-inlined form (`props.label ?? 'Default'`) would (#2685 review, the
124
+ * jsx-side twin of `ssr-defaults.ts`'s own `collectPropRefs` closure).
125
+ *
126
+ * Structural substitution only (never string splicing, per CLAUDE.md):
127
+ * reuses `inlineBinding`, the exact "let-inline" step `foldBlockToExpr`
128
+ * already performs for a block-bodied memo's own internal `const`s. A
129
+ * chained const (`const a = props.x; const b = a; createSignal(b ?? 1)`)
130
+ * closes the same way — each pass substitutes whatever local consts are
131
+ * still free in the current form, so `b` resolves to `a` first and `a`
132
+ * resolves to `props.x` on the next pass. Bounded to `localConsts.size + 1`
133
+ * iterations (the longest possible chain) purely as a cycle guard; a real
134
+ * cycle is impossible in valid source (JS TDZ forbids a const referencing
135
+ * itself, directly or transitively).
136
+ *
137
+ * `inlineBinding` returns `null` only on a capture hazard (the const's own
138
+ * free variable would be shadowed by a nested callback parameter of the
139
+ * same name inside the current form) — that occurrence is left un-inlined,
140
+ * so its name simply stays free and later fails `classify`'s availability
141
+ * check like any other out-of-scope reference (fails safe to `opaque`,
142
+ * never a wrong substitution).
143
+ */
144
+ function resolveThroughLocalConsts(
145
+ parsed: ParsedExpr,
146
+ localConsts: ReadonlyMap<string, ParsedExpr>,
147
+ ): ParsedExpr {
148
+ let current = parsed
149
+ const maxIter = localConsts.size + 1
150
+ for (let i = 0; i < maxIter; i++) {
151
+ const frees = freeIdentifiers(current)
152
+ if (frees === null) break
153
+ let changed = false
154
+ for (const name of frees) {
155
+ const value = localConsts.get(name)
156
+ if (!value) continue
157
+ const inlined = inlineBinding(current, name, value)
158
+ if (inlined === null) continue
159
+ current = inlined
160
+ changed = true
161
+ }
162
+ if (!changed) break
163
+ }
164
+ return current
165
+ }
166
+
100
167
  /**
101
168
  * Compute the component's SSR seed plan from its metadata. See the module
102
169
  * doc for the contract. Memo steps are gated to EXPRESSION-BODIED memos
@@ -111,6 +178,7 @@ export function computeSsrSeedPlan(metadata: IRMetadata): SsrSeedPlan {
111
178
  }
112
179
 
113
180
  const available = new Set<string>(baseScope)
181
+ const localConsts = localConstExprsByName(metadata)
114
182
  const steps: SsrSeedStep[] = []
115
183
 
116
184
  for (const signal of metadata.signals) {
@@ -126,7 +194,13 @@ export function computeSsrSeedPlan(metadata: IRMetadata): SsrSeedPlan {
126
194
  steps.push(
127
195
  expr === ''
128
196
  ? { kind: 'opaque', name: signal.getter, origin: 'signal' }
129
- : classify(signal.getter, 'signal', expr, parseExpression(expr), available),
197
+ : classify(
198
+ signal.getter,
199
+ 'signal',
200
+ expr,
201
+ resolveThroughLocalConsts(parseExpression(expr), localConsts),
202
+ available,
203
+ ),
130
204
  )
131
205
  available.add(signal.getter)
132
206
  }
@@ -137,7 +211,13 @@ export function computeSsrSeedPlan(metadata: IRMetadata): SsrSeedPlan {
137
211
  steps.push(
138
212
  expr === ''
139
213
  ? { kind: 'opaque', name: memo.name, origin: 'memo' }
140
- : classify(memo.name, 'memo', expr, memo.parsed ?? parseExpression(expr), available),
214
+ : classify(
215
+ memo.name,
216
+ 'memo',
217
+ expr,
218
+ resolveThroughLocalConsts(memo.parsed ?? parseExpression(expr), localConsts),
219
+ available,
220
+ ),
141
221
  )
142
222
  available.add(memo.name)
143
223
  }
@@ -1,82 +0,0 @@
1
- /**
2
- * TEST-HARNESS ONLY. Evaluate a signal initializer / prop-default source
3
- * expression against a mock `props` object by EXECUTING it (`new
4
- * Function`, with `undefined` shadowing the globals listed below), the
5
- * same way the Hono/CSR conformance reference produces its value — by
6
- * actually running the component. Never call this from a build path (`bf
7
- * build`, `compileJSX`, any adapter's `generate`); it exists only so each
8
- * adapter's `test-render.ts` conformance harness can seed a signal/
9
- * prop-default for a non-JS-runtime SSR render (PHP/Ruby/Python/Perl/Go)
10
- * that matches what Hono would compute. (#2209)
11
- *
12
- * NOT a security sandbox — the global shadowing below is a determinism
13
- * tripwire (catches the common accidental-nondeterminism case, e.g.
14
- * `Date.now()`), not containment: a sufficiently creative expression can
15
- * still reach the real global scope (`new Function` bodies execute
16
- * unlexically-scoped, so e.g. `[].constructor.constructor('return
17
- * Date')()` recovers `Date` even though the bare name is shadowed, and
18
- * `eval` can't be shadowed at all — JS forbids declaring or binding a
19
- * parameter literally named `eval` in strict-mode code). Do not widen this
20
- * module's use to anything but first-party fixture source. The actual
21
- * trust boundary is that the evaluated text is first-party fixture
22
- * source, already compiled by this same process — the conformance harness
23
- * already executes fixture-derived code far more invasively (spawning
24
- * `ruby`/`php`/`perl`/`go run` on generated programs). This module is
25
- * never reachable from anything a real end user's untrusted input could
26
- * influence.
27
- *
28
- * Why real execution instead of a hand-rolled evaluator: the initializer
29
- * source is an arbitrary JS-subset expression over `props` (e.g. `(props.x
30
- * ?? []).map(t => ({ ...t, editing: false }))`, #2209's actual repro) —
31
- * every previous approach here was a regex/pattern-match over a small
32
- * catalogue of recognized shapes (`props.x`, `props.x ?? default`, a bare
33
- * literal), and #2209 is literally "the catalogue missed a shape" (the
34
- * THIRD such miss on this codebase, per the superseded
35
- * `evaluate-signal-init.test.ts`'s own #1672 pins). A hand-written
36
- * evaluator over `ParsedExpr` would face the same drift, and can't
37
- * represent object spread (`{ ...t, editing: false }`) without extending
38
- * `ParsedExpr` — a production-compiler change that ripples into every
39
- * adapter's exhaustive switch, disproportionate for a test-only need.
40
- * `new Function` delegates parsing to the JS engine itself — CLAUDE.md's
41
- * regex-parsing ban exists precisely to avoid the false-match/missed-shape
42
- * failure mode this replaces.
43
- *
44
- * Shadowed as `undefined` (best-effort determinism, not containment — see
45
- * above): `globalThis`, `window`, `document`, `Date`, `Math`, `crypto`,
46
- * `performance`, `fetch`, `setTimeout`, `setInterval`, `require`,
47
- * `process`, `Function`. (`eval` is deliberately absent from this list —
48
- * it cannot be shadowed as a parameter name in strict-mode code; see
49
- * above.)
50
- *
51
- * `props` bare-identifier destructured params (`createSignal(count)` where
52
- * `count` is a destructured prop, not a `props.x` member) are NOT bound —
53
- * the evaluator only exposes `props` — so such an initializer throws
54
- * `ReferenceError` and falls back to "unset", matching every prior
55
- * evaluator's behavior for that shape. Extending the environment with
56
- * `ir.metadata.propsParams` bindings is a natural follow-up if a fixture
57
- * ever needs it.
58
- */
59
- export type SignalInitEvalResult = {
60
- ok: true;
61
- value: unknown;
62
- } | {
63
- ok: false;
64
- };
65
- /**
66
- * Evaluate `expr` (a JS-subset source expression) against `props`. Returns
67
- * `{ ok: false }` when the expression fails to parse, throws at evaluation
68
- * time (e.g. a `ReferenceError` for an unbound identifier), or evaluates to
69
- * something the downstream serializers can't marshal (see
70
- * {@link isTransportable}).
71
- */
72
- export declare function tryEvaluateSignalInit(expr: string, props?: Record<string, unknown>): SignalInitEvalResult;
73
- /**
74
- * Drop-in replacement for the harnesses' former per-adapter regex
75
- * evaluator: `null` means "could not evaluate, or evaluated to
76
- * `undefined` — leave the signal/default unseeded", matching every prior
77
- * evaluator's convention (an explicit JS `null` initializer also maps to
78
- * `null` — the same "skip" outcome, since none of these harnesses
79
- * distinguish "explicitly null" from "unset" downstream).
80
- */
81
- export declare function evaluateSignalInit(expr: string, props?: Record<string, unknown>): unknown;
82
- //# sourceMappingURL=signal-init-eval.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"signal-init-eval.d.ts","sourceRoot":"","sources":["../src/signal-init-eval.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;AAkBH,MAAM,MAAM,oBAAoB,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAA;CAAE,CAAA;AAqC/E;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EACZ,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,oBAAoB,CA6BtB;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAGzF"}
@@ -1,138 +0,0 @@
1
- import { describe, test, expect } from 'bun:test'
2
- import { evaluateSignalInit, tryEvaluateSignalInit } from '../signal-init-eval'
3
-
4
- // #2209: replaces 7 near-duplicate regex-based `evaluateSignalInit`
5
- // evaluators (one per template-string adapter's test-render.ts harness)
6
- // with a single sandboxed real-JS evaluator. Absorbs and supersedes
7
- // `packages/adapter-mojolicious/src/__tests__/evaluate-signal-init.test.ts`
8
- // (#1672's pins) plus the actual #2209 repro shape.
9
- describe('evaluateSignalInit (#2209)', () => {
10
- test('the #2209 repro: (props.x ?? []).map(t => ({ ...t, editing: false }))', () => {
11
- const expr = `(props.initialTodos ?? []).map(t => ({ ...t, editing: false }))`
12
- expect(evaluateSignalInit(expr, { initialTodos: [{ id: 1, done: false }] })).toEqual([
13
- { id: 1, done: false, editing: false },
14
- ])
15
- expect(evaluateSignalInit(expr, undefined)).toEqual([])
16
- expect(evaluateSignalInit(expr, {})).toEqual([])
17
- })
18
-
19
- test('an object-spread override applies after the spread (matches JS semantics)', () => {
20
- const expr = `(props.items ?? []).map(t => ({ ...t, done: true }))`
21
- expect(evaluateSignalInit(expr, { items: [{ id: 1, done: false }] })).toEqual([
22
- { id: 1, done: true },
23
- ])
24
- })
25
-
26
- // #1672 pins, absorbed from the deleted mojolicious-local test file.
27
- test('parses an inline object-array initial value', () => {
28
- expect(evaluateSignalInit(`[{ id: 'a' }, { id: 'b' }, { id: 'c' }]`)).toEqual([
29
- { id: 'a' },
30
- { id: 'b' },
31
- { id: 'c' },
32
- ])
33
- })
34
-
35
- test('parses scalar and mixed arrays, including nested objects', () => {
36
- expect(evaluateSignalInit(`['x', 'y']`)).toEqual(['x', 'y'])
37
- expect(evaluateSignalInit(`[1, 2, 3]`)).toEqual([1, 2, 3])
38
- expect(evaluateSignalInit(`[{ id: 'a', n: 1, ok: true }]`)).toEqual([
39
- { id: 'a', n: 1, ok: true },
40
- ])
41
- })
42
-
43
- test('still parses scalars, empty array, and props passthrough', () => {
44
- expect(evaluateSignalInit(`'b'`)).toBe('b')
45
- expect(evaluateSignalInit(`5`)).toBe(5)
46
- expect(evaluateSignalInit(`[]`)).toEqual([])
47
- expect(evaluateSignalInit(`props.value`, { value: 42 })).toBe(42)
48
- })
49
-
50
- test('bails to null for arrays with non-literal, unbound elements', () => {
51
- expect(evaluateSignalInit(`[foo(), bar]`)).toBeNull()
52
- })
53
-
54
- // Additional shapes the old regex evaluators supported.
55
- test('props.x ?? default with x present, absent, or explicitly null', () => {
56
- expect(evaluateSignalInit(`props.x ?? 7`, { x: 3 })).toBe(3)
57
- expect(evaluateSignalInit(`props.x ?? 7`, {})).toBe(7)
58
- expect(evaluateSignalInit(`props.x ?? 7`, undefined)).toBe(7)
59
- // Fix over the old regex evaluators: JS `??` also falls through on an
60
- // explicit `null`/`undefined` prop value — the old regex only checked
61
- // `propName in props` and returned the raw (null) value, which was
62
- // actually a bug relative to real JS `??` semantics.
63
- expect(evaluateSignalInit(`props.x ?? 7`, { x: null })).toBe(7)
64
- })
65
-
66
- test('negative and decimal number literals', () => {
67
- expect(evaluateSignalInit(`-7.6`)).toBe(-7.6)
68
- expect(evaluateSignalInit(`42`)).toBe(42)
69
- })
70
-
71
- test('booleans', () => {
72
- expect(evaluateSignalInit(`true`)).toBe(true)
73
- expect(evaluateSignalInit(`false`)).toBe(false)
74
- })
75
-
76
- test('an explicit null initializer maps to null (the "skip" outcome)', () => {
77
- expect(evaluateSignalInit(`null`)).toBeNull()
78
- })
79
-
80
- // New coverage for #2209's sandboxing contract.
81
- test('blocked globals fail deterministically and fall back to null', () => {
82
- expect(evaluateSignalInit(`Date.now()`)).toBeNull()
83
- expect(evaluateSignalInit(`Math.random()`)).toBeNull()
84
- expect(evaluateSignalInit(`typeof window`)).not.toBe('object')
85
- })
86
-
87
- test('a TS-only remnant that fails to parse as an expression falls back to null', () => {
88
- expect(evaluateSignalInit(`foo as Bar`)).toBeNull()
89
- })
90
-
91
- test('a non-transportable value (class instance) falls back to null', () => {
92
- expect(evaluateSignalInit(`new Set([1, 2])`)).toBeNull()
93
- })
94
-
95
- test('a bare destructured-prop identifier (not props.x) is unbound and falls back to null', () => {
96
- expect(evaluateSignalInit(`count`, { count: 5 })).toBeNull()
97
- })
98
-
99
- test('tryEvaluateSignalInit distinguishes an explicit undefined from a real ok:false', () => {
100
- expect(tryEvaluateSignalInit(`undefined`)).toEqual({ ok: true, value: undefined })
101
- expect(tryEvaluateSignalInit(`foo()`)).toEqual({ ok: false })
102
- // evaluateSignalInit's wrapper collapses both to null:
103
- expect(evaluateSignalInit(`undefined`)).toBeNull()
104
- expect(evaluateSignalInit(`foo()`)).toBeNull()
105
- })
106
-
107
- // Copilot review (#2229): `Function` is now shadowed too — closes the
108
- // most direct bypass of the blocked-globals list.
109
- test('the Function constructor is shadowed', () => {
110
- expect(evaluateSignalInit(`Function('return 1')()`)).toBeNull()
111
- })
112
-
113
- // Copilot review (#2229): `isTransportable` must reject a sparse array
114
- // (a hole is not the same as an element that's explicitly `undefined` —
115
- // `Array.prototype.every` silently skips holes, which previously let
116
- // one through undetected).
117
- test('a sparse array (a hole, not an explicit undefined) falls back to null', () => {
118
- // biome-ignore lint: intentional sparse-array literal for the hole test
119
- expect(evaluateSignalInit(`[1, , 3]`)).toBeNull()
120
- })
121
-
122
- // Copilot review (#2229): the same object appearing twice in a
123
- // non-cyclic shape (shared by reference, not by cycle) is transportable
124
- // — JSON-equivalent behavior would just duplicate it — and must NOT be
125
- // rejected by an overly-global "seen" set.
126
- test('a shared (non-cyclic) reference is transportable', () => {
127
- expect(evaluateSignalInit(`(() => { const shared = { id: 1 }; return [shared, shared] })()`)).toEqual([
128
- { id: 1 },
129
- { id: 1 },
130
- ])
131
- })
132
-
133
- test('a genuine cycle still falls back to null', () => {
134
- expect(
135
- evaluateSignalInit(`(() => { const o = { id: 1 }; o.self = o; return o })()`),
136
- ).toBeNull()
137
- })
138
- })