@barefootjs/jsx 0.31.10 → 0.33.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/adapters/dangerous-inner-html.d.ts.map +1 -1
- package/dist/adapters/parsed-expr-emitter.d.ts +22 -0
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +29 -6
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +129 -108
- package/dist/query-href-lowering.d.ts.map +1 -1
- package/dist/ssr-seed-plan.d.ts.map +1 -1
- package/dist/static-literal.d.ts.map +1 -1
- package/dist/to-locale-date-lowering.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/expression-parser.test.ts +43 -6
- package/src/__tests__/serialize-parsed-expr.test.ts +17 -3
- package/src/__tests__/ssr-defaults.test.ts +41 -0
- package/src/__tests__/ssr-seed-plan.test.ts +12 -2
- package/src/adapters/dangerous-inner-html.ts +1 -0
- package/src/adapters/parsed-expr-emitter.ts +49 -3
- package/src/expression-parser.ts +200 -89
- package/src/index.ts +2 -3
- package/src/jsx-to-ir.ts +4 -1
- package/src/query-href-lowering.ts +4 -0
- package/src/rich-type-refusal.ts +1 -1
- package/src/ssr-defaults.ts +51 -2
- package/src/ssr-seed-plan.ts +20 -3
- package/src/static-literal.ts +15 -1
- package/src/to-locale-date-lowering.ts +4 -0
- 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-seed-plan.ts
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
extractArrowBodyExpression,
|
|
37
37
|
freeIdentifiers,
|
|
38
38
|
inlineBinding,
|
|
39
|
-
|
|
39
|
+
isSupportedValue,
|
|
40
40
|
parseExpression,
|
|
41
41
|
type ParsedExpr,
|
|
42
42
|
} from './expression-parser.ts'
|
|
@@ -81,6 +81,11 @@ export interface SsrSeedPlan {
|
|
|
81
81
|
* opaque). The scope check runs over the parsed SOURCE tree, so a shadowed
|
|
82
82
|
* name (`items.filter((p) => p.ok) && p`, where the trailing `p` is a
|
|
83
83
|
* different, unbound reference from the callback's own param) is rejected.
|
|
84
|
+
*
|
|
85
|
+
* Uses `isSupportedValue`, not `isSupported`: a signal/memo initializer is an
|
|
86
|
+
* ASSIGNMENT, never a render, so it's checked at VALUE position — this is
|
|
87
|
+
* what admits e.g. `createSignal([{ a: 'p' }].map(t => t.a).join(','))`
|
|
88
|
+
* (an object-literal array-method receiver) as `derived` instead of opaque.
|
|
84
89
|
*/
|
|
85
90
|
function classify(
|
|
86
91
|
name: string,
|
|
@@ -89,7 +94,7 @@ function classify(
|
|
|
89
94
|
parsed: ParsedExpr,
|
|
90
95
|
available: ReadonlySet<string>,
|
|
91
96
|
): SsrSeedStep {
|
|
92
|
-
if (!
|
|
97
|
+
if (!isSupportedValue(parsed).supported) return { kind: 'opaque', name, origin }
|
|
93
98
|
const frees = freeIdentifiers(parsed)
|
|
94
99
|
if (frees === null) return { kind: 'opaque', name, origin }
|
|
95
100
|
for (const free of frees) {
|
|
@@ -198,7 +203,19 @@ export function computeSsrSeedPlan(metadata: IRMetadata): SsrSeedPlan {
|
|
|
198
203
|
signal.getter,
|
|
199
204
|
'signal',
|
|
200
205
|
expr,
|
|
201
|
-
|
|
206
|
+
// Prefer the analyzer's already-parenthesised parse (`analyzer.ts`'s
|
|
207
|
+
// `parseExpression(\`(${signal.initialValue})\`)`, Roadmap A) over
|
|
208
|
+
// re-parsing the bare `expr` string here — re-parsing WITHOUT the
|
|
209
|
+
// wrap misreads a bare object-literal initializer
|
|
210
|
+
// (`createSignal({ ...base, done: true })`) as a block statement
|
|
211
|
+
// (`parseExpression`'s documented block-vs-expression-statement
|
|
212
|
+
// rule), silently opaquing an otherwise-derivable signal (#2696
|
|
213
|
+
// Step 2 follow-up: this only became observable once `object-
|
|
214
|
+
// literal` could classify `derived` at all). Falling back to a
|
|
215
|
+
// parenthesised re-parse (not the bare string) keeps a signal
|
|
216
|
+
// whose shape the analyzer's pass left unparsed (`signal.parsed`
|
|
217
|
+
// undefined) equally safe.
|
|
218
|
+
resolveThroughLocalConsts(signal.parsed ?? parseExpression(`(${expr})`), localConsts),
|
|
202
219
|
available,
|
|
203
220
|
),
|
|
204
221
|
)
|
package/src/static-literal.ts
CHANGED
|
@@ -60,9 +60,23 @@ export function evaluateStaticLiteral(
|
|
|
60
60
|
// Shorthand (`{ a }`) and explicit (`{ a: value }`) properties both
|
|
61
61
|
// carry their resolved tree in `value` (shorthand's is an
|
|
62
62
|
// `identifier`) — recursing here handles both uniformly: a shorthand
|
|
63
|
-
// property only resolves when `bindings` supplies it.
|
|
63
|
+
// property only resolves when `bindings` supplies it. A spread
|
|
64
|
+
// (`{ ...base, a: 1 }`, #2696 Step 2) resolves its source and merges
|
|
65
|
+
// in source order — the same shallow-merge, later-wins, null/undefined-
|
|
66
|
+
// is-a-no-op semantics as the runtime evaluator (`toEvalNode`'s
|
|
67
|
+
// `object-literal` case) and JS itself; a spread source that resolves
|
|
68
|
+
// to anything other than a plain object/null/undefined isn't
|
|
69
|
+
// statically mergeable here, so the whole literal declines.
|
|
64
70
|
const out: Record<string, unknown> = {}
|
|
65
71
|
for (const prop of expr.properties) {
|
|
72
|
+
if (prop.kind === 'spread') {
|
|
73
|
+
const resolved = evaluateStaticLiteral(prop.expr, bindings)
|
|
74
|
+
if (!resolved) return null
|
|
75
|
+
if (resolved.value === null || resolved.value === undefined) continue
|
|
76
|
+
if (typeof resolved.value !== 'object' || Array.isArray(resolved.value)) return null
|
|
77
|
+
Object.assign(out, resolved.value)
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
66
80
|
const resolved = evaluateStaticLiteral(prop.value, bindings)
|
|
67
81
|
if (!resolved) return null
|
|
68
82
|
out[prop.key] = resolved.value
|
|
@@ -504,6 +504,10 @@ export function matchToLocaleDateStringCall(
|
|
|
504
504
|
let tz: string | null = null
|
|
505
505
|
const probeOptions: Record<string, string> = {}
|
|
506
506
|
for (const prop of options.properties) {
|
|
507
|
+
// A spread (`{ ...opts, timeZone: 'UTC' }`, #2696 Step 2) isn't a
|
|
508
|
+
// literal-keyed entry this probe can read without evaluating the spread
|
|
509
|
+
// source — decline rather than silently skipping its keys.
|
|
510
|
+
if (prop.kind === 'spread') return null
|
|
507
511
|
if (prop.value.kind !== 'literal' || prop.value.literalType !== 'string') return null
|
|
508
512
|
const value = String(prop.value.value)
|
|
509
513
|
if (prop.key === 'timeZone') {
|
|
@@ -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
|
-
})
|
package/src/signal-init-eval.ts
DELETED
|
@@ -1,165 +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
|
-
|
|
60
|
-
const BLOCKED_GLOBALS = [
|
|
61
|
-
'globalThis',
|
|
62
|
-
'window',
|
|
63
|
-
'document',
|
|
64
|
-
'Date',
|
|
65
|
-
'Math',
|
|
66
|
-
'crypto',
|
|
67
|
-
'performance',
|
|
68
|
-
'fetch',
|
|
69
|
-
'setTimeout',
|
|
70
|
-
'setInterval',
|
|
71
|
-
'require',
|
|
72
|
-
'process',
|
|
73
|
-
'Function',
|
|
74
|
-
] as const
|
|
75
|
-
|
|
76
|
-
export type SignalInitEvalResult = { ok: true; value: unknown } | { ok: false }
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* A value the harness's downstream language serializers (JSON/Python/PHP/
|
|
80
|
-
* Perl/Ruby literal builders) can actually marshal: `null`, a boolean, a
|
|
81
|
-
* number (including non-finite — several serializers already special-case
|
|
82
|
-
* NaN/Infinity), a string, a dense array with no holes and no `undefined`
|
|
83
|
-
* elements, or a plain object (`Object.prototype` or null prototype only —
|
|
84
|
-
* rejects class instances, functions, Maps/Sets, etc). Rejects genuine
|
|
85
|
-
* cycles; a shared (non-cyclic) reference appearing more than once — e.g.
|
|
86
|
-
* the same object at two array indices — is fine (JSON-equivalent
|
|
87
|
-
* behavior just duplicates it), so `ancestors` tracks only the current
|
|
88
|
-
* recursion path, not every value ever visited.
|
|
89
|
-
*/
|
|
90
|
-
function isTransportable(value: unknown, ancestors: Set<unknown> = new Set()): boolean {
|
|
91
|
-
if (value === null) return true
|
|
92
|
-
const t = typeof value
|
|
93
|
-
if (t === 'boolean' || t === 'number' || t === 'string') return true
|
|
94
|
-
if (t !== 'object') return false
|
|
95
|
-
if (ancestors.has(value)) return false // a real cycle (value is its own ancestor)
|
|
96
|
-
ancestors.add(value)
|
|
97
|
-
try {
|
|
98
|
-
if (Array.isArray(value)) {
|
|
99
|
-
// `Array.prototype.every` silently skips holes (`[1, , 3]`), so a
|
|
100
|
-
// sparse array would otherwise pass — compare against the own
|
|
101
|
-
// enumerable key count (holes aren't own keys) to catch that.
|
|
102
|
-
if (Object.keys(value).length !== value.length) return false
|
|
103
|
-
return value.every(el => el !== undefined && isTransportable(el, ancestors))
|
|
104
|
-
}
|
|
105
|
-
const proto = Object.getPrototypeOf(value)
|
|
106
|
-
if (proto !== Object.prototype && proto !== null) return false
|
|
107
|
-
return Object.values(value as Record<string, unknown>).every(v => isTransportable(v, ancestors))
|
|
108
|
-
} finally {
|
|
109
|
-
ancestors.delete(value)
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Evaluate `expr` (a JS-subset source expression) against `props`. Returns
|
|
115
|
-
* `{ ok: false }` when the expression fails to parse, throws at evaluation
|
|
116
|
-
* time (e.g. a `ReferenceError` for an unbound identifier), or evaluates to
|
|
117
|
-
* something the downstream serializers can't marshal (see
|
|
118
|
-
* {@link isTransportable}).
|
|
119
|
-
*/
|
|
120
|
-
export function tryEvaluateSignalInit(
|
|
121
|
-
expr: string,
|
|
122
|
-
props?: Record<string, unknown>,
|
|
123
|
-
): SignalInitEvalResult {
|
|
124
|
-
const src = expr.trim()
|
|
125
|
-
if (src === '') return { ok: false }
|
|
126
|
-
let fn: (props: Record<string, unknown>, ...blocked: undefined[]) => unknown
|
|
127
|
-
try {
|
|
128
|
-
// `props` plus the blocked-globals shadows are the only bindings
|
|
129
|
-
// passed in — best-effort determinism, not containment; see the file
|
|
130
|
-
// docstring for the trust-boundary rationale (test-harness-only,
|
|
131
|
-
// never a build path).
|
|
132
|
-
fn = new Function(
|
|
133
|
-
'props',
|
|
134
|
-
...BLOCKED_GLOBALS,
|
|
135
|
-
`'use strict'; return (\n${src}\n);`,
|
|
136
|
-
) as typeof fn
|
|
137
|
-
} catch {
|
|
138
|
-
return { ok: false }
|
|
139
|
-
}
|
|
140
|
-
try {
|
|
141
|
-
const value = fn(props ?? {})
|
|
142
|
-
// `undefined` at the TOP level is a genuine, distinguishable result
|
|
143
|
-
// (e.g. an explicit `undefined` initializer, or `props.x` with no `x`
|
|
144
|
-
// and no `??` fallback) — not a marshal failure. `evaluateSignalInit`'s
|
|
145
|
-
// wrapper still collapses it to the "skip" outcome; callers that need
|
|
146
|
-
// the distinction use this function directly.
|
|
147
|
-
if (value === undefined) return { ok: true, value: undefined }
|
|
148
|
-
return isTransportable(value) ? { ok: true, value } : { ok: false }
|
|
149
|
-
} catch {
|
|
150
|
-
return { ok: false }
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Drop-in replacement for the harnesses' former per-adapter regex
|
|
156
|
-
* evaluator: `null` means "could not evaluate, or evaluated to
|
|
157
|
-
* `undefined` — leave the signal/default unseeded", matching every prior
|
|
158
|
-
* evaluator's convention (an explicit JS `null` initializer also maps to
|
|
159
|
-
* `null` — the same "skip" outcome, since none of these harnesses
|
|
160
|
-
* distinguish "explicitly null" from "unset" downstream).
|
|
161
|
-
*/
|
|
162
|
-
export function evaluateSignalInit(expr: string, props?: Record<string, unknown>): unknown {
|
|
163
|
-
const result = tryEvaluateSignalInit(expr, props)
|
|
164
|
-
return result.ok && result.value !== undefined ? result.value : null
|
|
165
|
-
}
|