@barefootjs/jsx 0.26.3 → 0.27.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/compiler.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +735 -140
- package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
- package/dist/ir-to-client-js/client-only-elision.d.ts +103 -0
- package/dist/ir-to-client-js/client-only-elision.d.ts.map +1 -0
- 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 +13 -2
- package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts +54 -0
- package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts.map +1 -0
- 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/inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/insert.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +19 -3
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +63 -4
- package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow.d.ts +1 -1
- package/dist/ir-to-client-js/element-refs.d.ts +8 -3
- package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts +8 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +73 -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 +35 -2
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/types.d.ts +70 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +555 -678
- package/src/__tests__/child-components-in-map.test.ts +4 -4
- package/src/__tests__/client-js-generation.test.ts +26 -12
- package/src/__tests__/compiler-runtime-contract.test.ts +4 -4
- package/src/__tests__/composite-branch-loop.test.ts +2 -2
- package/src/__tests__/cross-file-client-signal.test.ts +5 -1
- package/src/__tests__/delegated-handler-preamble.test.ts +153 -0
- package/src/__tests__/early-return-scope-var-ref.test.ts +10 -11
- package/src/__tests__/flatmap-segments.test.ts +182 -0
- package/src/__tests__/inline-jsx-callback.test.ts +6 -4
- package/src/__tests__/ir-jsx-props.test.ts +6 -5
- package/src/__tests__/loop-fallback-wrap.test.ts +3 -4
- package/src/__tests__/map-body-no-silent-divergence.test.ts +45 -0
- package/src/__tests__/module-level-signal.audit.test.ts +7 -2
- package/src/__tests__/nested-loop-conditional.test.ts +9 -7
- package/src/__tests__/nested-loop-reactive-attrs.test.ts +2 -2
- package/src/__tests__/preamble-region-patch.test.ts +156 -0
- package/src/__tests__/profile-cond-binding-ids.test.ts +3 -2
- package/src/__tests__/profile-loop-binding-ids.test.ts +4 -3
- package/src/__tests__/profile-nested-binding-ids.test.ts +11 -6
- package/src/__tests__/reactive-attrs-in-map.test.ts +6 -4
- package/src/__tests__/static-loop-csr-materialize.test.ts +3 -2
- package/src/__tests__/template-closure.test.ts +1 -1
- package/src/compiler.ts +11 -0
- package/src/index.ts +1 -0
- package/src/ir-to-client-js/build-references.ts +16 -0
- package/src/ir-to-client-js/client-only-elision.ts +273 -0
- package/src/ir-to-client-js/collect-elements.ts +74 -14
- 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 +27 -2
- package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +34 -4
- package/src/ir-to-client-js/control-flow/stringify/claim-plan.ts +68 -0
- package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +67 -19
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +18 -9
- package/src/ir-to-client-js/control-flow/stringify/insert.ts +17 -11
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +35 -16
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +80 -14
- package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +241 -31
- package/src/ir-to-client-js/control-flow.ts +1 -1
- package/src/ir-to-client-js/element-refs.ts +9 -11
- package/src/ir-to-client-js/emit-reactive.ts +50 -14
- package/src/ir-to-client-js/html-template.ts +144 -15
- package/src/ir-to-client-js/imports.ts +7 -4
- package/src/ir-to-client-js/reactivity.ts +7 -0
- package/src/ir-to-client-js/types.ts +34 -2
- package/src/ir-to-client-js/utils.ts +1 -1
- package/src/jsx-to-ir.ts +382 -15
- package/src/types.ts +72 -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
|
|
|
@@ -4404,8 +4610,8 @@ function transformMapCall(
|
|
|
4404
4610
|
const bodyIsMultiRoot = loopBodyIsMultiRoot(children)
|
|
4405
4611
|
|
|
4406
4612
|
// Determine if array is static (prop) or dynamic (signal/memo).
|
|
4407
|
-
// Static arrays don't need
|
|
4408
|
-
// directly. Signal / memo arrays need
|
|
4613
|
+
// Static arrays don't need mapArray/mapArrayAnchored — SSR elements are
|
|
4614
|
+
// hydrated directly. Signal / memo arrays need mapArray for dynamic DOM
|
|
4409
4615
|
// updates.
|
|
4410
4616
|
//
|
|
4411
4617
|
// Solid-style wrap-by-default fallback (#943, follow-up to
|
|
@@ -4415,7 +4621,7 @@ function transformMapCall(
|
|
|
4415
4621
|
// where `getItems` is an imported helper previously silent-dropped
|
|
4416
4622
|
// into the static-render path, freezing the SSR-time list on the
|
|
4417
4623
|
// client. Over-reconciling an array that happens to contain a pure
|
|
4418
|
-
// call costs one extra `
|
|
4624
|
+
// call costs one extra `mapArray` per loop; under-reconciling
|
|
4419
4625
|
// is the silent-drop bug this closes.
|
|
4420
4626
|
//
|
|
4421
4627
|
// Destructured map params (`([, cfg]) => ...`, `({ a, b }) => ...`,
|
|
@@ -4441,10 +4647,22 @@ 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
|
-
// Dynamic arrays with native root + component descendants: enables
|
|
4447
|
-
// with composite rendering (placeholder +
|
|
4663
|
+
// Dynamic arrays with native root + component descendants: enables
|
|
4664
|
+
// mapArray/mapArrayAnchored with composite rendering (placeholder +
|
|
4665
|
+
// createComponent replacement).
|
|
4448
4666
|
const nestedComponents = collectNestedComponents(children).filter(c => c.name !== childComponent?.name)
|
|
4449
4667
|
|
|
4450
4668
|
return {
|
|
@@ -4483,6 +4701,7 @@ function transformMapCall(
|
|
|
4483
4701
|
depth,
|
|
4484
4702
|
clientOnly: isClientOnly || undefined,
|
|
4485
4703
|
preamble,
|
|
4704
|
+
preambleRegions: preambleRegions && preambleRegions.length > 0 ? preambleRegions : undefined,
|
|
4486
4705
|
paramType,
|
|
4487
4706
|
indexType,
|
|
4488
4707
|
paramBindings,
|
|
@@ -4527,15 +4746,19 @@ function containsJsx(node: ts.Node): boolean {
|
|
|
4527
4746
|
}
|
|
4528
4747
|
|
|
4529
4748
|
/**
|
|
4530
|
-
* Build a FlatMapCallback for complex flatMap
|
|
4531
|
-
*
|
|
4532
|
-
*
|
|
4533
|
-
*
|
|
4534
|
-
*
|
|
4749
|
+
* Build a FlatMapCallback for complex flatMap bodies (conditional returns,
|
|
4750
|
+
* variable-assigned JSX, etc.). Accepts a block body OR an expression body
|
|
4751
|
+
* (`t => t.tags.map(tag => <li/>)`) — segment reconstruction and the SSR
|
|
4752
|
+
* rawBody are position-based, so both shapes flow through identically (a
|
|
4753
|
+
* block's text keeps its braces; an expression's text is a valid arrow body
|
|
4754
|
+
* as-is). Walks the callback body AST and carries it as structured segments
|
|
4755
|
+
* — JS text (types stripped) interleaved with compiled JSX-leaf IR — never
|
|
4756
|
+
* as a sentinel-bearing string (the write-side rule in CLAUDE.md; same shape
|
|
4757
|
+
* as the map-preamble carrier).
|
|
4535
4758
|
*/
|
|
4536
4759
|
function buildFlatMapCallback(
|
|
4537
4760
|
callback: ts.ArrowFunction | ts.FunctionExpression,
|
|
4538
|
-
body: ts.Block,
|
|
4761
|
+
body: ts.Block | ts.Expression,
|
|
4539
4762
|
ctx: TransformContext,
|
|
4540
4763
|
): FlatMapCallback | undefined {
|
|
4541
4764
|
if (!containsJsx(body)) return undefined
|
|
@@ -4577,6 +4800,66 @@ function buildFlatMapCallback(
|
|
|
4577
4800
|
return undefined
|
|
4578
4801
|
}
|
|
4579
4802
|
|
|
4803
|
+
// A leaf that carries wiring the descriptor render path can't honour —
|
|
4804
|
+
// an event handler, a component, a nested loop, or a spread — would be
|
|
4805
|
+
// silently dead DOM on the client (the leaf renders as a keyed HTML
|
|
4806
|
+
// string, patched wholesale on change, with no per-slot wiring). Refuse
|
|
4807
|
+
// loudly (sound-or-loud), mirroring `preambleFragmentNeedsWiring` for map
|
|
4808
|
+
// preambles — but deliberately NOT refusing reactive expressions: whole-
|
|
4809
|
+
// leaf patching covers those.
|
|
4810
|
+
for (const leafIr of leafIrs) {
|
|
4811
|
+
// The descriptor path renders each leaf as ONE keyed element — the
|
|
4812
|
+
// renderItem adopts `template.content.firstElementChild` and `patchLeaf`
|
|
4813
|
+
// patches a single element root. A fragment (or any non-element) root
|
|
4814
|
+
// would silently drop siblings client-side while SSR renders them all —
|
|
4815
|
+
// the exact divergence class this carrier forbids. Refuse loudly.
|
|
4816
|
+
if (leafIr.type !== 'element') {
|
|
4817
|
+
const loc = 'loc' in leafIr && leafIr.loc
|
|
4818
|
+
? leafIr.loc
|
|
4819
|
+
: getSourceLocation(body, ctx.sourceFile, ctx.filePath)
|
|
4820
|
+
ctx.analyzer.errors.push(
|
|
4821
|
+
createError(
|
|
4822
|
+
ErrorCodes.UNSUPPORTED_JSX_PATTERN,
|
|
4823
|
+
loc,
|
|
4824
|
+
{
|
|
4825
|
+
message:
|
|
4826
|
+
'A JSX leaf produced by a .flatMap() callback must be a single ' +
|
|
4827
|
+
'element — a fragment or non-element root cannot ride the keyed ' +
|
|
4828
|
+
'descriptor path (each leaf hydrates and patches as one element).',
|
|
4829
|
+
suggestion: {
|
|
4830
|
+
message: 'Wrap the leaf content in a single keyed element.',
|
|
4831
|
+
},
|
|
4832
|
+
}
|
|
4833
|
+
)
|
|
4834
|
+
)
|
|
4835
|
+
return undefined
|
|
4836
|
+
}
|
|
4837
|
+
if (flatMapLeafNeedsWiring(leafIr)) {
|
|
4838
|
+
const loc = 'loc' in leafIr && leafIr.loc
|
|
4839
|
+
? leafIr.loc
|
|
4840
|
+
: getSourceLocation(body, ctx.sourceFile, ctx.filePath)
|
|
4841
|
+
ctx.analyzer.errors.push(
|
|
4842
|
+
createError(
|
|
4843
|
+
ErrorCodes.UNSUPPORTED_JSX_PATTERN,
|
|
4844
|
+
loc,
|
|
4845
|
+
{
|
|
4846
|
+
message:
|
|
4847
|
+
'A JSX element produced by a .flatMap() callback cannot carry ' +
|
|
4848
|
+
'event handlers, components, nested loops, or spreads — the ' +
|
|
4849
|
+
'leaf renders as a keyed HTML string with no per-element wiring.',
|
|
4850
|
+
suggestion: {
|
|
4851
|
+
message:
|
|
4852
|
+
'Restructure so the interactive element lives in a .map() body — ' +
|
|
4853
|
+
'the descriptor path has no per-element wiring on any backend, ' +
|
|
4854
|
+
'so /* @client */ does not lift this.',
|
|
4855
|
+
},
|
|
4856
|
+
}
|
|
4857
|
+
)
|
|
4858
|
+
)
|
|
4859
|
+
return undefined
|
|
4860
|
+
}
|
|
4861
|
+
}
|
|
4862
|
+
|
|
4580
4863
|
const pieces = reconstructAsSegments(body, ctx.sourceFile, ctx.analyzer.typeExcludeRanges, leafSpans)
|
|
4581
4864
|
const segments: PreambleSegment[] = pieces.map((piece) => {
|
|
4582
4865
|
if ('marker' in piece) return { kind: 'jsx', ir: leafIrs[piece.marker] }
|
|
@@ -4610,6 +4893,35 @@ interface PreambleCollection {
|
|
|
4610
4893
|
refusalNode?: ts.Node
|
|
4611
4894
|
}
|
|
4612
4895
|
|
|
4896
|
+
/**
|
|
4897
|
+
* Does a flatMap segment leaf carry wiring the descriptor render path can't
|
|
4898
|
+
* honour? Narrower than {@link preambleFragmentNeedsWiring}: reactive
|
|
4899
|
+
* expressions are ALLOWED (the client renders each leaf as a keyed
|
|
4900
|
+
* `{ k, h }` descriptor and patches the whole leaf when its HTML changes),
|
|
4901
|
+
* but events, components, nested loops, and spreads have no wiring on that
|
|
4902
|
+
* path and would be silently dead.
|
|
4903
|
+
*/
|
|
4904
|
+
function flatMapLeafNeedsWiring(ir: IRNode): boolean {
|
|
4905
|
+
switch (ir.type) {
|
|
4906
|
+
case 'component':
|
|
4907
|
+
case 'loop':
|
|
4908
|
+
return true
|
|
4909
|
+
case 'element':
|
|
4910
|
+
if (ir.events.length > 0) return true
|
|
4911
|
+
if (ir.attrs.some((a) => a.name.startsWith('...'))) return true
|
|
4912
|
+
return ir.children.some(flatMapLeafNeedsWiring)
|
|
4913
|
+
case 'conditional':
|
|
4914
|
+
return (
|
|
4915
|
+
flatMapLeafNeedsWiring(ir.whenTrue) ||
|
|
4916
|
+
(ir.whenFalse ? flatMapLeafNeedsWiring(ir.whenFalse) : false)
|
|
4917
|
+
)
|
|
4918
|
+
case 'fragment':
|
|
4919
|
+
return ir.children.some(flatMapLeafNeedsWiring)
|
|
4920
|
+
default:
|
|
4921
|
+
return false
|
|
4922
|
+
}
|
|
4923
|
+
}
|
|
4924
|
+
|
|
4613
4925
|
/**
|
|
4614
4926
|
* Does a compiled preamble JSX leaf carry wiring the verbatim render path can't
|
|
4615
4927
|
* honour? The path builds each leaf once as an interpolated HTML string with no
|
|
@@ -4677,6 +4989,61 @@ function flagArrayChildExpressions(nodes: IRNode[], declared: ReadonlySet<string
|
|
|
4677
4989
|
}
|
|
4678
4990
|
}
|
|
4679
4991
|
|
|
4992
|
+
/**
|
|
4993
|
+
* #2389 patch-on-update — walk loop-body children and classify each
|
|
4994
|
+
* `IRExpression` whose free identifiers intersect `declared` (the enclosing
|
|
4995
|
+
* `.map()` preamble's `declaredNames`) as a preamble-patched region. Unlike
|
|
4996
|
+
* {@link flagArrayChildExpressions} (which only recognizes the exact bare
|
|
4997
|
+
* `{out}` shape for the array-join coercion), this is deliberately broader —
|
|
4998
|
+
* ANY expression reading a preamble local needs the region-patch effect,
|
|
4999
|
+
* whether it's a builder array (`{cells}`, `joinArrayChild` already set by
|
|
5000
|
+
* `flagArrayChildExpressions`, called earlier for the same `children`) or a
|
|
5001
|
+
* plain value (`{stateLabel}`).
|
|
5002
|
+
*
|
|
5003
|
+
* Reuses an existing `slotId` when the node already has one (e.g. it also
|
|
5004
|
+
* reads the loop param and so already qualified via the ordinary reactive-
|
|
5005
|
+
* text path) rather than allocating a second slot — `node.preambleRegion`
|
|
5006
|
+
* is the single source of truth downstream, so `collectLoopChildReactiveTexts`
|
|
5007
|
+
* can unconditionally defer to it.
|
|
5008
|
+
*/
|
|
5009
|
+
function collectPreambleRegions(
|
|
5010
|
+
nodes: IRNode[],
|
|
5011
|
+
declared: ReadonlySet<string>,
|
|
5012
|
+
ctx: TransformContext,
|
|
5013
|
+
): PreambleRegionSource[] {
|
|
5014
|
+
const regions: PreambleRegionSource[] = []
|
|
5015
|
+
const visit = (list: IRNode[]): void => {
|
|
5016
|
+
for (const node of list) {
|
|
5017
|
+
switch (node.type) {
|
|
5018
|
+
case 'expression': {
|
|
5019
|
+
const refs = extractFreeIdentifiersFromText(node.expr)
|
|
5020
|
+
const usesPreambleLocal = [...refs].some((r) => declared.has(r))
|
|
5021
|
+
if (usesPreambleLocal) {
|
|
5022
|
+
if (!node.slotId) node.slotId = generateSlotId(ctx)
|
|
5023
|
+
node.preambleRegion = true
|
|
5024
|
+
node.reactive = true
|
|
5025
|
+
regions.push({
|
|
5026
|
+
slotId: node.slotId,
|
|
5027
|
+
expr: node.expr,
|
|
5028
|
+
joinArrayChild: node.joinArrayChild || undefined,
|
|
5029
|
+
})
|
|
5030
|
+
}
|
|
5031
|
+
break
|
|
5032
|
+
}
|
|
5033
|
+
case 'element':
|
|
5034
|
+
case 'fragment':
|
|
5035
|
+
visit(node.children)
|
|
5036
|
+
break
|
|
5037
|
+
case 'conditional':
|
|
5038
|
+
visit([node.whenTrue, ...(node.whenFalse ? [node.whenFalse] : [])])
|
|
5039
|
+
break
|
|
5040
|
+
}
|
|
5041
|
+
}
|
|
5042
|
+
}
|
|
5043
|
+
visit(nodes)
|
|
5044
|
+
return regions
|
|
5045
|
+
}
|
|
5046
|
+
|
|
4680
5047
|
/**
|
|
4681
5048
|
* Collect the names a binding introduces, recursing through object/array
|
|
4682
5049
|
* destructuring patterns (`const { id: k } = r`, `const [k, ...rest] = xs`) so
|
|
@@ -6198,7 +6565,7 @@ function isArrayExprDirectPropRef(arrayExpr: ts.Expression, ctx: TransformContex
|
|
|
6198
6565
|
|
|
6199
6566
|
/**
|
|
6200
6567
|
* Check if array expression is a signal or memo getter call.
|
|
6201
|
-
* Used to determine if a loop needs
|
|
6568
|
+
* Used to determine if a loop needs mapArray for dynamic DOM updates.
|
|
6202
6569
|
* Props and local constants are considered static (don't change at runtime).
|
|
6203
6570
|
*/
|
|
6204
6571
|
function isSignalOrMemoArray(array: string, ctx: TransformContext): boolean {
|
|
@@ -6401,7 +6768,7 @@ function attrValueReactivityProbe(value: AttrValue): string | null {
|
|
|
6401
6768
|
|
|
6402
6769
|
/**
|
|
6403
6770
|
* Propagate slotId to loop children that need it.
|
|
6404
|
-
* Loops need to use their parent element's slotId for
|
|
6771
|
+
* Loops need to use their parent element's slotId for mapArray.
|
|
6405
6772
|
* This handles loops directly in children or nested in fragments.
|
|
6406
6773
|
*/
|
|
6407
6774
|
function propagateSlotIdToLoops(children: IRNode[], slotId: string): void {
|
package/src/types.ts
CHANGED
|
@@ -380,6 +380,16 @@ 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 (a claimed 'markup' slot writer, slot
|
|
390
|
+
* unification A3) is its only wiring.
|
|
391
|
+
*/
|
|
392
|
+
preambleRegion?: boolean
|
|
383
393
|
/** When true, expression calls signal getters or memos (has reactive `foo()` pattern). */
|
|
384
394
|
callsReactiveGetters?: boolean
|
|
385
395
|
/** When true, expression contains function call(s) — any `identifier()` pattern (computed from AST). */
|
|
@@ -391,6 +401,28 @@ export interface IRExpression {
|
|
|
391
401
|
* source of truth for free-reference / reactivity classification.
|
|
392
402
|
*/
|
|
393
403
|
origin: OriginInfo
|
|
404
|
+
/**
|
|
405
|
+
* Slot unification Step B (`spec/slot-unification.md` §3(b), §5 Step B):
|
|
406
|
+
* true when this slot's `<!--bf:sN-->…<!--/-->` marker pair can be safely
|
|
407
|
+
* omitted from BOTH SSR and CSR output, because `elidedPath` already gives
|
|
408
|
+
* every claimer a real compile-time DOM path to the slot's position.
|
|
409
|
+
* Decided EXACTLY ONCE, by `client-only-elision.ts`, before either
|
|
410
|
+
* `adapter.generate()` (SSR) or `generateClientJs()` (CSR) run — every one
|
|
411
|
+
* of the nine SSR adapters' `renderExpression` and the CSR template
|
|
412
|
+
* emitters (`html-template.ts`) read this single flag and must never
|
|
413
|
+
* re-derive their own elision decision. Unset/false is always safe
|
|
414
|
+
* (keeps markers, today's behavior); only the compiler pass above may
|
|
415
|
+
* ever set it true.
|
|
416
|
+
*/
|
|
417
|
+
markerless?: boolean
|
|
418
|
+
/**
|
|
419
|
+
* Root-relative child-index path to this slot's position (parent index
|
|
420
|
+
* chain, LAST element = the index of the slot itself within its parent's
|
|
421
|
+
* `childNodes`). Valid, and required, only when `markerless` is true —
|
|
422
|
+
* see `SlotSpec.markerless` in `@barefootjs/client/runtime/claim-slots.ts`
|
|
423
|
+
* for the claim-time resolve-or-create semantics this path feeds.
|
|
424
|
+
*/
|
|
425
|
+
elidedPath?: readonly number[]
|
|
394
426
|
}
|
|
395
427
|
|
|
396
428
|
export interface IRConditional {
|
|
@@ -702,6 +734,29 @@ export interface IRLoop {
|
|
|
702
734
|
*/
|
|
703
735
|
preamble?: MapCallbackPreamble
|
|
704
736
|
|
|
737
|
+
/**
|
|
738
|
+
* Expression children of the loop body whose free identifiers intersect
|
|
739
|
+
* `preamble.declaredNames` (#2389, patch-on-update follow-up to the Stage 3
|
|
740
|
+
* root cure) — e.g. `{cells}` in `arr.map(t => { const cells = []; ...;
|
|
741
|
+
* return <tr>{cells}<td>{t.name}</td></tr> })`. `mapArray` reuses the same
|
|
742
|
+
* DOM node on a same-key item update via per-item `setItem`, re-running
|
|
743
|
+
* only the wired text/attr slots — a preamble-derived child has NEITHER
|
|
744
|
+
* (it's a bare interpolation, `joinArrayChild` array-join or otherwise),
|
|
745
|
+
* so without this it freezes at its mount-time value forever. Each entry's
|
|
746
|
+
* `slotId` is emitted as the usual `<!--bf:sN-->...<!--/-->` marker pair
|
|
747
|
+
* (same door as a reactive text slot — `irToHtmlTemplate` / Hono's
|
|
748
|
+
* `renderExpression` key off `slotId` alone), but the CLIENT wiring is a
|
|
749
|
+
* distinct claimed 'markup'-slot region-patch effect (`preambleRegions`
|
|
750
|
+
* in the client-JS loop plan), NOT a `reactiveTexts` entry — patching via
|
|
751
|
+
* `.textContent` would escape markup that a `joinArrayChild` region must
|
|
752
|
+
* render raw. `collectLoopChildReactiveTexts` excludes any node collected
|
|
753
|
+
* here (`IRExpression.preambleRegion`) so a qualifying expression is never
|
|
754
|
+
* double-wired. Populated only when `preamble` is set and the loop is NOT
|
|
755
|
+
* `isStaticArray` — a static array's SSR-rendered items are never
|
|
756
|
+
* recreated via signals, so there is nothing to patch.
|
|
757
|
+
*/
|
|
758
|
+
preambleRegions?: PreambleRegionSource[]
|
|
759
|
+
|
|
705
760
|
/**
|
|
706
761
|
* When `.map(callback)` destructures its item parameter (array or object
|
|
707
762
|
* pattern), this captures each destructured binding's name and the
|
|
@@ -809,6 +864,23 @@ export interface MapCallbackPreamble {
|
|
|
809
864
|
builderNames: string[]
|
|
810
865
|
}
|
|
811
866
|
|
|
867
|
+
/**
|
|
868
|
+
* One loop-body expression child classified as a preamble-patched region
|
|
869
|
+
* (#2389 — see `IRLoop.preambleRegions`). `expr` is the raw (unwrapped)
|
|
870
|
+
* expression text, exactly as carried on the source `IRExpression` node;
|
|
871
|
+
* emitters wrap it with the loop-param accessor at codegen time, matching
|
|
872
|
+
* every other loop-body expression. `joinArrayChild` mirrors the
|
|
873
|
+
* `IRExpression` field of the same name — the client-JS plan builder uses it
|
|
874
|
+
* to decide between the array-join value expression and a plain (escaped)
|
|
875
|
+
* text value, keeping the region's re-render byte-identical to the row
|
|
876
|
+
* template's own `irToHtmlTemplate` rendering of the same node.
|
|
877
|
+
*/
|
|
878
|
+
export interface PreambleRegionSource {
|
|
879
|
+
slotId: string
|
|
880
|
+
expr: string
|
|
881
|
+
joinArrayChild?: boolean
|
|
882
|
+
}
|
|
883
|
+
|
|
812
884
|
/**
|
|
813
885
|
* The preamble's JS text (js segments only, concatenated), for read-side
|
|
814
886
|
* analysis — free-identifier scans, reachability edges, rest-misuse checks.
|