@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.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +20 -0
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +99 -77
- package/dist/ssr-defaults.d.ts +10 -0
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/ssr-seed-plan.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/date-lowering.test.ts +1 -1
- package/src/__tests__/props-destructuring.test.ts +40 -3
- package/src/__tests__/ssr-defaults.test.ts +230 -0
- package/src/__tests__/ssr-seed-plan.test.ts +72 -0
- package/src/analyzer.ts +83 -26
- package/src/expression-parser.ts +10 -1
- package/src/index.ts +0 -1
- package/src/ssr-defaults.ts +171 -6
- package/src/ssr-seed-plan.ts +82 -2
- package/dist/signal-init-eval.d.ts +0 -82
- package/dist/signal-init-eval.d.ts.map +0 -1
- package/src/__tests__/signal-init-eval.test.ts +0 -138
- package/src/signal-init-eval.ts +0 -165
package/src/ssr-defaults.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
539
|
+
true,
|
|
375
540
|
ts.ScriptKind.TS,
|
|
376
541
|
)
|
|
377
542
|
const stmt = sf.statements[0]
|
package/src/ssr-seed-plan.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
})
|