@barefootjs/jsx 0.26.3 → 0.26.4
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/index.js +433 -55
- package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +12 -1
- package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +16 -1
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +27 -0
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/shared.d.ts +12 -1
- package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +19 -0
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +49 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +26 -1
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +187 -37
- package/src/__tests__/client-js-generation.test.ts +8 -2
- package/src/__tests__/compiler-runtime-contract.test.ts +4 -4
- package/src/__tests__/delegated-handler-preamble.test.ts +153 -0
- package/src/__tests__/flatmap-segments.test.ts +182 -0
- package/src/__tests__/map-body-no-silent-divergence.test.ts +45 -0
- package/src/__tests__/preamble-region-patch.test.ts +150 -0
- package/src/__tests__/static-loop-csr-materialize.test.ts +3 -2
- package/src/ir-to-client-js/build-references.ts +16 -0
- package/src/ir-to-client-js/collect-elements.ts +66 -10
- package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +12 -1
- package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +11 -3
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +19 -3
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +36 -0
- package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +16 -1
- package/src/ir-to-client-js/control-flow/plan/loop.ts +28 -0
- package/src/ir-to-client-js/control-flow/shared.ts +26 -1
- package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +25 -3
- package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +67 -19
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +65 -1
- package/src/ir-to-client-js/html-template.ts +115 -6
- package/src/ir-to-client-js/imports.ts +1 -1
- package/src/ir-to-client-js/reactivity.ts +6 -0
- package/src/ir-to-client-js/types.ts +24 -0
- package/src/jsx-to-ir.ts +374 -8
- package/src/types.ts +49 -0
package/src/jsx-to-ir.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type FlatMapCallback,
|
|
29
29
|
type MapCallbackPreamble,
|
|
30
30
|
type PreambleSegment,
|
|
31
|
+
type PreambleRegionSource,
|
|
31
32
|
tsxSourceText,
|
|
32
33
|
type SourceLocation,
|
|
33
34
|
type TypeInfo,
|
|
@@ -3637,6 +3638,99 @@ function checkLoopKey(
|
|
|
3637
3638
|
* item; multi-root bodies (e.g. `<><path/><path/></>`) need per-item
|
|
3638
3639
|
* boundary markers and multi-root template cloning (#1212).
|
|
3639
3640
|
*/
|
|
3641
|
+
/**
|
|
3642
|
+
* Recognize a flatMap PROJECTION body: a bare map-like call
|
|
3643
|
+
* (`it.tags.map(...)` / nested `.flatMap(...)`) as the whole body — either
|
|
3644
|
+
* an expression body (parenthesized or not) or a block whose ONLY statement
|
|
3645
|
+
* is `return <call>`. Anything carrying additional statements does not
|
|
3646
|
+
* qualify (it rides the structured-segments carrier instead). Returns the
|
|
3647
|
+
* call expression to lower as the loop's nested-loop child, or null.
|
|
3648
|
+
*/
|
|
3649
|
+
function flatMapProjectionCall(body: ts.ConciseBody): ts.CallExpression | null {
|
|
3650
|
+
let expr: ts.Expression | undefined
|
|
3651
|
+
if (ts.isBlock(body)) {
|
|
3652
|
+
const real = body.statements
|
|
3653
|
+
if (real.length !== 1 || !ts.isReturnStatement(real[0]) || !real[0].expression) return null
|
|
3654
|
+
expr = real[0].expression
|
|
3655
|
+
} else {
|
|
3656
|
+
expr = body
|
|
3657
|
+
}
|
|
3658
|
+
while (ts.isParenthesizedExpression(expr)) expr = expr.expression
|
|
3659
|
+
if (!ts.isCallExpression(expr)) return null
|
|
3660
|
+
if (!getMapLikeMethod(expr)) return null
|
|
3661
|
+
// The descriptor synthesis renders the inner callback's raw params into
|
|
3662
|
+
// the client accessor (`.map((tag, i) => ({ k, h }))`), which is only
|
|
3663
|
+
// sound for plain-identifier params — a destructure pattern rides the
|
|
3664
|
+
// segments carrier instead.
|
|
3665
|
+
const cb = expr.arguments[0]
|
|
3666
|
+
if (!cb || (!ts.isArrowFunction(cb) && !ts.isFunctionExpression(cb))) return null
|
|
3667
|
+
for (const p of cb.parameters) {
|
|
3668
|
+
if (!ts.isIdentifier(p.name)) return null
|
|
3669
|
+
}
|
|
3670
|
+
// The client descriptor is one element per flattened leaf, so the inner
|
|
3671
|
+
// callback must produce a single element root (a JSX element, or a ternary
|
|
3672
|
+
// whose branches are elements). Fragments / array literals ride the
|
|
3673
|
+
// segments carrier.
|
|
3674
|
+
let innerBody: ts.Node = cb.body
|
|
3675
|
+
if (ts.isBlock(innerBody)) {
|
|
3676
|
+
const ret = innerBody.statements.find(
|
|
3677
|
+
(s): s is ts.ReturnStatement => ts.isReturnStatement(s) && s.expression != null,
|
|
3678
|
+
)
|
|
3679
|
+
if (innerBody.statements.length !== 1 || !ret?.expression) return null
|
|
3680
|
+
innerBody = ret.expression
|
|
3681
|
+
}
|
|
3682
|
+
while (ts.isParenthesizedExpression(innerBody)) innerBody = innerBody.expression
|
|
3683
|
+
const isElementish = (n: ts.Node): boolean => {
|
|
3684
|
+
let m = n
|
|
3685
|
+
while (ts.isParenthesizedExpression(m)) m = m.expression
|
|
3686
|
+
if (ts.isJsxElement(m) || ts.isJsxSelfClosingElement(m)) return leafIsWirelessElement(m)
|
|
3687
|
+
if (ts.isConditionalExpression(m)) return isElementish(m.whenTrue) && isElementish(m.whenFalse)
|
|
3688
|
+
return false
|
|
3689
|
+
}
|
|
3690
|
+
if (!isElementish(innerBody)) return null
|
|
3691
|
+
return expr
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
/**
|
|
3695
|
+
* Syntactic pre-check that a projection leaf carries no per-element wiring —
|
|
3696
|
+
* no event handlers, spreads, component tags, or nested JSX-bearing loops.
|
|
3697
|
+
* Runs BEFORE the leaf is transformed (the accept/reject decision must not
|
|
3698
|
+
* leave transform side effects — slot ids, collected events — behind when
|
|
3699
|
+
* the shape falls back to the segments carrier, whose own transform would
|
|
3700
|
+
* then double-register them).
|
|
3701
|
+
*/
|
|
3702
|
+
function leafIsWirelessElement(el: ts.JsxElement | ts.JsxSelfClosingElement): boolean {
|
|
3703
|
+
let ok = true
|
|
3704
|
+
const visit = (n: ts.Node): void => {
|
|
3705
|
+
if (!ok) return
|
|
3706
|
+
if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
|
|
3707
|
+
// Only a lowercase plain identifier (or a namespaced name like
|
|
3708
|
+
// `svg:path`) is an intrinsic element. A member/this tag
|
|
3709
|
+
// (`<icons.Tag/>`, `<this.Tag/>`) is a component per JSX semantics
|
|
3710
|
+
// regardless of case — the case test alone would let it through.
|
|
3711
|
+
const tagNode = n.tagName
|
|
3712
|
+
const isIntrinsic = ts.isIdentifier(tagNode)
|
|
3713
|
+
? !/^[A-Z]/.test(tagNode.text)
|
|
3714
|
+
: ts.isJsxNamespacedName(tagNode)
|
|
3715
|
+
if (!isIntrinsic) { ok = false; return }
|
|
3716
|
+
for (const attr of n.attributes.properties) {
|
|
3717
|
+
if (ts.isJsxSpreadAttribute(attr)) { ok = false; return }
|
|
3718
|
+
if (ts.isJsxAttribute(attr)) {
|
|
3719
|
+
const name = attr.name.getText()
|
|
3720
|
+
if (/^on[A-Z]/.test(name)) { ok = false; return }
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
}
|
|
3724
|
+
if (ts.isCallExpression(n) && getMapLikeMethod(n) && containsJsxInExpression(n)) {
|
|
3725
|
+
ok = false
|
|
3726
|
+
return
|
|
3727
|
+
}
|
|
3728
|
+
ts.forEachChild(n, visit)
|
|
3729
|
+
}
|
|
3730
|
+
visit(el)
|
|
3731
|
+
return ok
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3640
3734
|
function loopBodyIsMultiRoot(children: IRNode[]): boolean {
|
|
3641
3735
|
const real = children.filter(
|
|
3642
3736
|
(c) => !(c.type === 'text' && typeof c.value === 'string' && !c.value.trim())
|
|
@@ -3720,6 +3814,10 @@ function transformMapCall(
|
|
|
3720
3814
|
// Capture nesting depth before we register this map's own params.
|
|
3721
3815
|
// ctx.loopParams is populated by the *outer* map; if non-empty we are inside one.
|
|
3722
3816
|
const isNested = ctx.loopParams.size > 0
|
|
3817
|
+
// Diagnostic count at entry — the structural net at the scalar fallthrough
|
|
3818
|
+
// de-dups against refusals fired DURING this call (leaf-wiring, DSL gates),
|
|
3819
|
+
// never against unrelated diagnostics recorded before it.
|
|
3820
|
+
const diagCountAtEntry = ctx.analyzer.errors.length
|
|
3723
3821
|
// This loop's own depth (0 = outermost) is however many enclosing
|
|
3724
3822
|
// loops are already active, captured before `ctx.loopDepth` below is
|
|
3725
3823
|
// bumped for THIS loop's own descendants.
|
|
@@ -4271,14 +4369,85 @@ function transformMapCall(
|
|
|
4271
4369
|
}
|
|
4272
4370
|
|
|
4273
4371
|
// flatMap block body fallback: compile JSX inline when children
|
|
4274
|
-
// couldn't be extracted via the standard single-return path.
|
|
4275
|
-
|
|
4372
|
+
// couldn't be extracted via the standard single-return path. A pure
|
|
4373
|
+
// single-`return <call>` projection is NOT taken here — it lowers to
|
|
4374
|
+
// neutral IR below (nested-loop child), which DSL adapters templatize.
|
|
4375
|
+
if (method === 'flatMap' && children.length === 0 && !flatMapProjectionCall(body)) {
|
|
4276
4376
|
flatMapCallback = buildFlatMapCallback(callback, body, ctx)
|
|
4277
4377
|
}
|
|
4278
4378
|
} else {
|
|
4279
4379
|
tryTransformRenderableBody(body)
|
|
4280
4380
|
}
|
|
4281
4381
|
|
|
4382
|
+
// flatMap PROJECTION body — the canonical `flatMap(it => it.tags.map(tag
|
|
4383
|
+
// => <li key={...}/>))`, as an expression body or a single-`return` block
|
|
4384
|
+
// (parenthesized or not). This is a pure nested-loop projection, so it
|
|
4385
|
+
// lowers to NEUTRAL IR — an inner IRLoop as the loop's only child — which
|
|
4386
|
+
// every SSR adapter can templatize (nested `{{range}}` on DSL backends),
|
|
4387
|
+
// per spec/callback-fidelity.md's fidelity table ("single expr" row) and
|
|
4388
|
+
// the array-literal flatMap precedent. Only bodies carrying STATEMENTS
|
|
4389
|
+
// (early returns, consts) fall through to the segments carrier below and
|
|
4390
|
+
// its DSL gate. The client reconciles the flattened leaves through the
|
|
4391
|
+
// descriptor mapArray path, synthesized from this same neutral IR
|
|
4392
|
+
// (`renderFlatMapProjectionClientBody`); the leaf `key` renders as the
|
|
4393
|
+
// usual nested-loop `data-key-1` on both the SSR and client string
|
|
4394
|
+
// sides, and the flattened reconciliation identity (`data-key`) is
|
|
4395
|
+
// stamped by mapArray from the inner loop's `key` field.
|
|
4396
|
+
if (method === 'flatMap' && children.length === 0 && !flatMapCallback) {
|
|
4397
|
+
const projection = flatMapProjectionCall(body)
|
|
4398
|
+
if (projection) {
|
|
4399
|
+
const transformed = transformJsxExpression(projection, ctx, isClientOnly)
|
|
4400
|
+
if (transformed && transformed.type === 'loop') {
|
|
4401
|
+
children = [transformed]
|
|
4402
|
+
}
|
|
4403
|
+
}
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4406
|
+
// flatMap EXPRESSION body containing JSX in a non-projection shape —
|
|
4407
|
+
// e.g. a call wrapping JSX. The block-body form already rides the
|
|
4408
|
+
// structured-segments carrier in the isBlock branch above; an unbraced
|
|
4409
|
+
// body is the same shape minus the braces, so route it through the same
|
|
4410
|
+
// door. Without this the dispatch falls to the IRExpression scalar path,
|
|
4411
|
+
// which splices the raw callback — JSX included — verbatim into the
|
|
4412
|
+
// client bundle: a silent SyntaxError that kills the whole component's
|
|
4413
|
+
// hydration.
|
|
4414
|
+
if (method === 'flatMap' && children.length === 0 && !flatMapCallback && !ts.isBlock(body)) {
|
|
4415
|
+
flatMapCallback = buildFlatMapCallback(callback, body, ctx)
|
|
4416
|
+
}
|
|
4417
|
+
|
|
4418
|
+
// A flatMapCallback carries the WHOLE body (statements included) as
|
|
4419
|
+
// structured segments — the generic statements-before-return `preamble`
|
|
4420
|
+
// the block-body path may have collected above is a duplicate carrier.
|
|
4421
|
+
// Worse, it isn't one: spliced into a mapArray renderItem it re-runs the
|
|
4422
|
+
// body's control flow against the wrong binding shape (the audited
|
|
4423
|
+
// `if (t().tags.length > maxTags) return [];` renderItem residue).
|
|
4424
|
+
// The segments carrier is the single door; drop the shadow copy.
|
|
4425
|
+
if (flatMapCallback) preamble = undefined
|
|
4426
|
+
|
|
4427
|
+
// Adapter gate (spec/callback-fidelity.md fidelity model): a flatMap body
|
|
4428
|
+
// carried as structured segments runs verbatim on a JS runtime; a DSL
|
|
4429
|
+
// template runtime cannot execute it at SSR — pre-gate, e.g. the Go
|
|
4430
|
+
// adapter emitted `{{range …}}{{end}}` with an EMPTY body (silent
|
|
4431
|
+
// divergence, the exact failure class the sound-or-loud invariant
|
|
4432
|
+
// forbids). Refuse loudly with the /* @client */ escape, mirroring the
|
|
4433
|
+
// const-preamble and array-builder gates above.
|
|
4434
|
+
if (flatMapCallback && !isClientOnly && !(ctx.analyzer.acceptsCallbackBody?.('flatMap') ?? false)) {
|
|
4435
|
+
ctx.analyzer.errors.push(
|
|
4436
|
+
createError(
|
|
4437
|
+
ErrorCodes.UNSUPPORTED_JSX_PATTERN,
|
|
4438
|
+
getSourceLocation(body, ctx.sourceFile, ctx.filePath),
|
|
4439
|
+
{
|
|
4440
|
+
message:
|
|
4441
|
+
'A .flatMap() callback body with statements or a nested projection ' +
|
|
4442
|
+
'cannot be lowered to a template on this backend.',
|
|
4443
|
+
suggestion: {
|
|
4444
|
+
message: 'Add /* @client */ to render this loop on the client only',
|
|
4445
|
+
},
|
|
4446
|
+
}
|
|
4447
|
+
)
|
|
4448
|
+
)
|
|
4449
|
+
}
|
|
4450
|
+
|
|
4282
4451
|
// Unregister loop params
|
|
4283
4452
|
if (paramBindings) {
|
|
4284
4453
|
for (const b of paramBindings) ctx.loopParams.delete(b.name)
|
|
@@ -4293,6 +4462,43 @@ function transformMapCall(
|
|
|
4293
4462
|
// fall back to treating the entire expression as an IRExpression — unless
|
|
4294
4463
|
// flatMap already built a compiled callback (flatMapCallback).
|
|
4295
4464
|
if (children.length === 0 && !flatMapCallback) {
|
|
4465
|
+
// Structural net (sound-or-loud, spec/callback-fidelity.md): a callback
|
|
4466
|
+
// body that carries an INLINE JSX literal but produced no loop lowering
|
|
4467
|
+
// must never fall through to the IRExpression scalar path — `ctx.getJS`
|
|
4468
|
+
// strips types, not JSX, so the raw `<li …>` would splice verbatim into
|
|
4469
|
+
// the emitted client bundle as a silent SyntaxError. Any recognizer gap
|
|
4470
|
+
// for a JSX-bearing shape becomes a loud diagnostic here instead of a
|
|
4471
|
+
// leak. (A JSX-helper CALL — `map(t => renderItem(t))` — carries no
|
|
4472
|
+
// inline JSX literal and legitimately stays on the reactive-text path.)
|
|
4473
|
+
const cb = node.arguments[0]
|
|
4474
|
+
const cbBody = cb && (ts.isArrowFunction(cb) || ts.isFunctionExpression(cb)) ? cb.body : undefined
|
|
4475
|
+
// Entry-count gate: a more specific refusal already fired for THIS call
|
|
4476
|
+
// (e.g. the flatMap leaf-wiring check) — don't stack the generic message
|
|
4477
|
+
// on top of it. Comparing against the count captured at entry (not
|
|
4478
|
+
// `=== 0`) keeps the net armed when an unrelated diagnostic was recorded
|
|
4479
|
+
// earlier in the file — a warning elsewhere must never silence the leak
|
|
4480
|
+
// guard for this callback.
|
|
4481
|
+
if (cbBody && containsJsxInExpression(cbBody) && ctx.analyzer.errors.length === diagCountAtEntry) {
|
|
4482
|
+
ctx.analyzer.errors.push(
|
|
4483
|
+
createError(
|
|
4484
|
+
ErrorCodes.UNSUPPORTED_JSX_PATTERN,
|
|
4485
|
+
getSourceLocation(cbBody, ctx.sourceFile, ctx.filePath),
|
|
4486
|
+
{
|
|
4487
|
+
message:
|
|
4488
|
+
`A .${method}() callback that builds JSX in this shape cannot be ` +
|
|
4489
|
+
'compiled — the JSX would leak verbatim into the client bundle. ' +
|
|
4490
|
+
'Recognized bodies: a JSX element/fragment, a ternary or ' +
|
|
4491
|
+
'&& / || / ?? expression, an array literal (flatMap), or a block ' +
|
|
4492
|
+
'body whose return the compiler can lower.',
|
|
4493
|
+
suggestion: {
|
|
4494
|
+
message:
|
|
4495
|
+
'Restructure the callback to return the JSX element directly ' +
|
|
4496
|
+
'(or via a block body with a plain `return`).',
|
|
4497
|
+
},
|
|
4498
|
+
}
|
|
4499
|
+
)
|
|
4500
|
+
)
|
|
4501
|
+
}
|
|
4296
4502
|
return null
|
|
4297
4503
|
}
|
|
4298
4504
|
|
|
@@ -4441,6 +4647,17 @@ function transformMapCall(
|
|
|
4441
4647
|
// already handles correctly.
|
|
4442
4648
|
&& !objectIteration
|
|
4443
4649
|
|
|
4650
|
+
// #2389 patch-on-update — a signal-backed (non-static) loop whose body has
|
|
4651
|
+
// a preamble may reference a preamble-declared local directly as a child
|
|
4652
|
+
// expression (`{cells}`, `{stateLabel}`). `mapArray` reuses the same DOM
|
|
4653
|
+
// node on a same-key update, re-running only the wired text/attr slots —
|
|
4654
|
+
// this child has neither, so without a dedicated region-patch effect it
|
|
4655
|
+
// freezes at its mount-time value. Static arrays are never recreated via
|
|
4656
|
+
// signals, so there is nothing to patch; skip them entirely.
|
|
4657
|
+
const preambleRegions = preamble && !isStaticArray
|
|
4658
|
+
? collectPreambleRegions(children, new Set(preamble.declaredNames), ctx)
|
|
4659
|
+
: undefined
|
|
4660
|
+
|
|
4444
4661
|
// Collect nested components for both static and dynamic arrays.
|
|
4445
4662
|
// Static arrays: needed for initChild hydration.
|
|
4446
4663
|
// Dynamic arrays with native root + component descendants: enables reconcileElements
|
|
@@ -4483,6 +4700,7 @@ function transformMapCall(
|
|
|
4483
4700
|
depth,
|
|
4484
4701
|
clientOnly: isClientOnly || undefined,
|
|
4485
4702
|
preamble,
|
|
4703
|
+
preambleRegions: preambleRegions && preambleRegions.length > 0 ? preambleRegions : undefined,
|
|
4486
4704
|
paramType,
|
|
4487
4705
|
indexType,
|
|
4488
4706
|
paramBindings,
|
|
@@ -4527,15 +4745,19 @@ function containsJsx(node: ts.Node): boolean {
|
|
|
4527
4745
|
}
|
|
4528
4746
|
|
|
4529
4747
|
/**
|
|
4530
|
-
* Build a FlatMapCallback for complex flatMap
|
|
4531
|
-
*
|
|
4532
|
-
*
|
|
4533
|
-
*
|
|
4534
|
-
*
|
|
4748
|
+
* Build a FlatMapCallback for complex flatMap bodies (conditional returns,
|
|
4749
|
+
* variable-assigned JSX, etc.). Accepts a block body OR an expression body
|
|
4750
|
+
* (`t => t.tags.map(tag => <li/>)`) — segment reconstruction and the SSR
|
|
4751
|
+
* rawBody are position-based, so both shapes flow through identically (a
|
|
4752
|
+
* block's text keeps its braces; an expression's text is a valid arrow body
|
|
4753
|
+
* as-is). Walks the callback body AST and carries it as structured segments
|
|
4754
|
+
* — JS text (types stripped) interleaved with compiled JSX-leaf IR — never
|
|
4755
|
+
* as a sentinel-bearing string (the write-side rule in CLAUDE.md; same shape
|
|
4756
|
+
* as the map-preamble carrier).
|
|
4535
4757
|
*/
|
|
4536
4758
|
function buildFlatMapCallback(
|
|
4537
4759
|
callback: ts.ArrowFunction | ts.FunctionExpression,
|
|
4538
|
-
body: ts.Block,
|
|
4760
|
+
body: ts.Block | ts.Expression,
|
|
4539
4761
|
ctx: TransformContext,
|
|
4540
4762
|
): FlatMapCallback | undefined {
|
|
4541
4763
|
if (!containsJsx(body)) return undefined
|
|
@@ -4577,6 +4799,66 @@ function buildFlatMapCallback(
|
|
|
4577
4799
|
return undefined
|
|
4578
4800
|
}
|
|
4579
4801
|
|
|
4802
|
+
// A leaf that carries wiring the descriptor render path can't honour —
|
|
4803
|
+
// an event handler, a component, a nested loop, or a spread — would be
|
|
4804
|
+
// silently dead DOM on the client (the leaf renders as a keyed HTML
|
|
4805
|
+
// string, patched wholesale on change, with no per-slot wiring). Refuse
|
|
4806
|
+
// loudly (sound-or-loud), mirroring `preambleFragmentNeedsWiring` for map
|
|
4807
|
+
// preambles — but deliberately NOT refusing reactive expressions: whole-
|
|
4808
|
+
// leaf patching covers those.
|
|
4809
|
+
for (const leafIr of leafIrs) {
|
|
4810
|
+
// The descriptor path renders each leaf as ONE keyed element — the
|
|
4811
|
+
// renderItem adopts `template.content.firstElementChild` and `patchLeaf`
|
|
4812
|
+
// patches a single element root. A fragment (or any non-element) root
|
|
4813
|
+
// would silently drop siblings client-side while SSR renders them all —
|
|
4814
|
+
// the exact divergence class this carrier forbids. Refuse loudly.
|
|
4815
|
+
if (leafIr.type !== 'element') {
|
|
4816
|
+
const loc = 'loc' in leafIr && leafIr.loc
|
|
4817
|
+
? leafIr.loc
|
|
4818
|
+
: getSourceLocation(body, ctx.sourceFile, ctx.filePath)
|
|
4819
|
+
ctx.analyzer.errors.push(
|
|
4820
|
+
createError(
|
|
4821
|
+
ErrorCodes.UNSUPPORTED_JSX_PATTERN,
|
|
4822
|
+
loc,
|
|
4823
|
+
{
|
|
4824
|
+
message:
|
|
4825
|
+
'A JSX leaf produced by a .flatMap() callback must be a single ' +
|
|
4826
|
+
'element — a fragment or non-element root cannot ride the keyed ' +
|
|
4827
|
+
'descriptor path (each leaf hydrates and patches as one element).',
|
|
4828
|
+
suggestion: {
|
|
4829
|
+
message: 'Wrap the leaf content in a single keyed element.',
|
|
4830
|
+
},
|
|
4831
|
+
}
|
|
4832
|
+
)
|
|
4833
|
+
)
|
|
4834
|
+
return undefined
|
|
4835
|
+
}
|
|
4836
|
+
if (flatMapLeafNeedsWiring(leafIr)) {
|
|
4837
|
+
const loc = 'loc' in leafIr && leafIr.loc
|
|
4838
|
+
? leafIr.loc
|
|
4839
|
+
: getSourceLocation(body, ctx.sourceFile, ctx.filePath)
|
|
4840
|
+
ctx.analyzer.errors.push(
|
|
4841
|
+
createError(
|
|
4842
|
+
ErrorCodes.UNSUPPORTED_JSX_PATTERN,
|
|
4843
|
+
loc,
|
|
4844
|
+
{
|
|
4845
|
+
message:
|
|
4846
|
+
'A JSX element produced by a .flatMap() callback cannot carry ' +
|
|
4847
|
+
'event handlers, components, nested loops, or spreads — the ' +
|
|
4848
|
+
'leaf renders as a keyed HTML string with no per-element wiring.',
|
|
4849
|
+
suggestion: {
|
|
4850
|
+
message:
|
|
4851
|
+
'Restructure so the interactive element lives in a .map() body — ' +
|
|
4852
|
+
'the descriptor path has no per-element wiring on any backend, ' +
|
|
4853
|
+
'so /* @client */ does not lift this.',
|
|
4854
|
+
},
|
|
4855
|
+
}
|
|
4856
|
+
)
|
|
4857
|
+
)
|
|
4858
|
+
return undefined
|
|
4859
|
+
}
|
|
4860
|
+
}
|
|
4861
|
+
|
|
4580
4862
|
const pieces = reconstructAsSegments(body, ctx.sourceFile, ctx.analyzer.typeExcludeRanges, leafSpans)
|
|
4581
4863
|
const segments: PreambleSegment[] = pieces.map((piece) => {
|
|
4582
4864
|
if ('marker' in piece) return { kind: 'jsx', ir: leafIrs[piece.marker] }
|
|
@@ -4610,6 +4892,35 @@ interface PreambleCollection {
|
|
|
4610
4892
|
refusalNode?: ts.Node
|
|
4611
4893
|
}
|
|
4612
4894
|
|
|
4895
|
+
/**
|
|
4896
|
+
* Does a flatMap segment leaf carry wiring the descriptor render path can't
|
|
4897
|
+
* honour? Narrower than {@link preambleFragmentNeedsWiring}: reactive
|
|
4898
|
+
* expressions are ALLOWED (the client renders each leaf as a keyed
|
|
4899
|
+
* `{ k, h }` descriptor and patches the whole leaf when its HTML changes),
|
|
4900
|
+
* but events, components, nested loops, and spreads have no wiring on that
|
|
4901
|
+
* path and would be silently dead.
|
|
4902
|
+
*/
|
|
4903
|
+
function flatMapLeafNeedsWiring(ir: IRNode): boolean {
|
|
4904
|
+
switch (ir.type) {
|
|
4905
|
+
case 'component':
|
|
4906
|
+
case 'loop':
|
|
4907
|
+
return true
|
|
4908
|
+
case 'element':
|
|
4909
|
+
if (ir.events.length > 0) return true
|
|
4910
|
+
if (ir.attrs.some((a) => a.name.startsWith('...'))) return true
|
|
4911
|
+
return ir.children.some(flatMapLeafNeedsWiring)
|
|
4912
|
+
case 'conditional':
|
|
4913
|
+
return (
|
|
4914
|
+
flatMapLeafNeedsWiring(ir.whenTrue) ||
|
|
4915
|
+
(ir.whenFalse ? flatMapLeafNeedsWiring(ir.whenFalse) : false)
|
|
4916
|
+
)
|
|
4917
|
+
case 'fragment':
|
|
4918
|
+
return ir.children.some(flatMapLeafNeedsWiring)
|
|
4919
|
+
default:
|
|
4920
|
+
return false
|
|
4921
|
+
}
|
|
4922
|
+
}
|
|
4923
|
+
|
|
4613
4924
|
/**
|
|
4614
4925
|
* Does a compiled preamble JSX leaf carry wiring the verbatim render path can't
|
|
4615
4926
|
* honour? The path builds each leaf once as an interpolated HTML string with no
|
|
@@ -4677,6 +4988,61 @@ function flagArrayChildExpressions(nodes: IRNode[], declared: ReadonlySet<string
|
|
|
4677
4988
|
}
|
|
4678
4989
|
}
|
|
4679
4990
|
|
|
4991
|
+
/**
|
|
4992
|
+
* #2389 patch-on-update — walk loop-body children and classify each
|
|
4993
|
+
* `IRExpression` whose free identifiers intersect `declared` (the enclosing
|
|
4994
|
+
* `.map()` preamble's `declaredNames`) as a preamble-patched region. Unlike
|
|
4995
|
+
* {@link flagArrayChildExpressions} (which only recognizes the exact bare
|
|
4996
|
+
* `{out}` shape for the array-join coercion), this is deliberately broader —
|
|
4997
|
+
* ANY expression reading a preamble local needs the region-patch effect,
|
|
4998
|
+
* whether it's a builder array (`{cells}`, `joinArrayChild` already set by
|
|
4999
|
+
* `flagArrayChildExpressions`, called earlier for the same `children`) or a
|
|
5000
|
+
* plain value (`{stateLabel}`).
|
|
5001
|
+
*
|
|
5002
|
+
* Reuses an existing `slotId` when the node already has one (e.g. it also
|
|
5003
|
+
* reads the loop param and so already qualified via the ordinary reactive-
|
|
5004
|
+
* text path) rather than allocating a second slot — `node.preambleRegion`
|
|
5005
|
+
* is the single source of truth downstream, so `collectLoopChildReactiveTexts`
|
|
5006
|
+
* can unconditionally defer to it.
|
|
5007
|
+
*/
|
|
5008
|
+
function collectPreambleRegions(
|
|
5009
|
+
nodes: IRNode[],
|
|
5010
|
+
declared: ReadonlySet<string>,
|
|
5011
|
+
ctx: TransformContext,
|
|
5012
|
+
): PreambleRegionSource[] {
|
|
5013
|
+
const regions: PreambleRegionSource[] = []
|
|
5014
|
+
const visit = (list: IRNode[]): void => {
|
|
5015
|
+
for (const node of list) {
|
|
5016
|
+
switch (node.type) {
|
|
5017
|
+
case 'expression': {
|
|
5018
|
+
const refs = extractFreeIdentifiersFromText(node.expr)
|
|
5019
|
+
const usesPreambleLocal = [...refs].some((r) => declared.has(r))
|
|
5020
|
+
if (usesPreambleLocal) {
|
|
5021
|
+
if (!node.slotId) node.slotId = generateSlotId(ctx)
|
|
5022
|
+
node.preambleRegion = true
|
|
5023
|
+
node.reactive = true
|
|
5024
|
+
regions.push({
|
|
5025
|
+
slotId: node.slotId,
|
|
5026
|
+
expr: node.expr,
|
|
5027
|
+
joinArrayChild: node.joinArrayChild || undefined,
|
|
5028
|
+
})
|
|
5029
|
+
}
|
|
5030
|
+
break
|
|
5031
|
+
}
|
|
5032
|
+
case 'element':
|
|
5033
|
+
case 'fragment':
|
|
5034
|
+
visit(node.children)
|
|
5035
|
+
break
|
|
5036
|
+
case 'conditional':
|
|
5037
|
+
visit([node.whenTrue, ...(node.whenFalse ? [node.whenFalse] : [])])
|
|
5038
|
+
break
|
|
5039
|
+
}
|
|
5040
|
+
}
|
|
5041
|
+
}
|
|
5042
|
+
visit(nodes)
|
|
5043
|
+
return regions
|
|
5044
|
+
}
|
|
5045
|
+
|
|
4680
5046
|
/**
|
|
4681
5047
|
* Collect the names a binding introduces, recursing through object/array
|
|
4682
5048
|
* destructuring patterns (`const { id: k } = r`, `const [k, ...rest] = xs`) so
|
package/src/types.ts
CHANGED
|
@@ -380,6 +380,15 @@ export interface IRExpression {
|
|
|
380
380
|
* flag (their JSX runtime renders an array child natively).
|
|
381
381
|
*/
|
|
382
382
|
joinArrayChild?: boolean
|
|
383
|
+
/**
|
|
384
|
+
* True when this expression was classified as a loop's preamble-patched
|
|
385
|
+
* region (#2389 — see `IRLoop.preambleRegions`): its free identifiers
|
|
386
|
+
* intersect the enclosing loop's `preamble.declaredNames`. Excludes the
|
|
387
|
+
* node from `collectLoopChildReactiveTexts` (which patches via
|
|
388
|
+
* `.textContent`, wrong for markup) regardless of `reactive` / `slotId` —
|
|
389
|
+
* the region-patch effect (`patchSlotRange`) is its only wiring.
|
|
390
|
+
*/
|
|
391
|
+
preambleRegion?: boolean
|
|
383
392
|
/** When true, expression calls signal getters or memos (has reactive `foo()` pattern). */
|
|
384
393
|
callsReactiveGetters?: boolean
|
|
385
394
|
/** When true, expression contains function call(s) — any `identifier()` pattern (computed from AST). */
|
|
@@ -702,6 +711,29 @@ export interface IRLoop {
|
|
|
702
711
|
*/
|
|
703
712
|
preamble?: MapCallbackPreamble
|
|
704
713
|
|
|
714
|
+
/**
|
|
715
|
+
* Expression children of the loop body whose free identifiers intersect
|
|
716
|
+
* `preamble.declaredNames` (#2389, patch-on-update follow-up to the Stage 3
|
|
717
|
+
* root cure) — e.g. `{cells}` in `arr.map(t => { const cells = []; ...;
|
|
718
|
+
* return <tr>{cells}<td>{t.name}</td></tr> })`. `mapArray` reuses the same
|
|
719
|
+
* DOM node on a same-key item update via per-item `setItem`, re-running
|
|
720
|
+
* only the wired text/attr slots — a preamble-derived child has NEITHER
|
|
721
|
+
* (it's a bare interpolation, `joinArrayChild` array-join or otherwise),
|
|
722
|
+
* so without this it freezes at its mount-time value forever. Each entry's
|
|
723
|
+
* `slotId` is emitted as the usual `<!--bf:sN-->...<!--/-->` marker pair
|
|
724
|
+
* (same door as a reactive text slot — `irToHtmlTemplate` / Hono's
|
|
725
|
+
* `renderExpression` key off `slotId` alone), but the CLIENT wiring is a
|
|
726
|
+
* distinct `patchSlotRange`-based region-patch effect (`preambleRegions`
|
|
727
|
+
* in the client-JS loop plan), NOT a `reactiveTexts` entry — patching via
|
|
728
|
+
* `.textContent` would escape markup that a `joinArrayChild` region must
|
|
729
|
+
* render raw. `collectLoopChildReactiveTexts` excludes any node collected
|
|
730
|
+
* here (`IRExpression.preambleRegion`) so a qualifying expression is never
|
|
731
|
+
* double-wired. Populated only when `preamble` is set and the loop is NOT
|
|
732
|
+
* `isStaticArray` — a static array's SSR-rendered items are never
|
|
733
|
+
* recreated via signals, so there is nothing to patch.
|
|
734
|
+
*/
|
|
735
|
+
preambleRegions?: PreambleRegionSource[]
|
|
736
|
+
|
|
705
737
|
/**
|
|
706
738
|
* When `.map(callback)` destructures its item parameter (array or object
|
|
707
739
|
* pattern), this captures each destructured binding's name and the
|
|
@@ -809,6 +841,23 @@ export interface MapCallbackPreamble {
|
|
|
809
841
|
builderNames: string[]
|
|
810
842
|
}
|
|
811
843
|
|
|
844
|
+
/**
|
|
845
|
+
* One loop-body expression child classified as a preamble-patched region
|
|
846
|
+
* (#2389 — see `IRLoop.preambleRegions`). `expr` is the raw (unwrapped)
|
|
847
|
+
* expression text, exactly as carried on the source `IRExpression` node;
|
|
848
|
+
* emitters wrap it with the loop-param accessor at codegen time, matching
|
|
849
|
+
* every other loop-body expression. `joinArrayChild` mirrors the
|
|
850
|
+
* `IRExpression` field of the same name — the client-JS plan builder uses it
|
|
851
|
+
* to decide between the array-join value expression and a plain (escaped)
|
|
852
|
+
* text value, keeping the region's re-render byte-identical to the row
|
|
853
|
+
* template's own `irToHtmlTemplate` rendering of the same node.
|
|
854
|
+
*/
|
|
855
|
+
export interface PreambleRegionSource {
|
|
856
|
+
slotId: string
|
|
857
|
+
expr: string
|
|
858
|
+
joinArrayChild?: boolean
|
|
859
|
+
}
|
|
860
|
+
|
|
812
861
|
/**
|
|
813
862
|
* The preamble's JS text (js segments only, concatenated), for read-side
|
|
814
863
|
* analysis — free-identifier scans, reachability edges, rest-misuse checks.
|