@barefootjs/jsx 0.33.2 → 0.33.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/analyzer.d.ts +17 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/compiler.d.ts +21 -5
- package/dist/compiler.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +804 -445
- 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/build-component-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +2 -4
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/component-loop.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.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +60 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/prop-handling.d.ts +4 -7
- package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +26 -2
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/props-binding.d.ts +35 -0
- package/dist/props-binding.d.ts.map +1 -1
- package/dist/types.d.ts +63 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +145 -97
- package/src/__tests__/child-component-ref-not-mirrored.test.ts +90 -0
- package/src/__tests__/fragment-body-loop-key.test.ts +95 -0
- package/src/__tests__/ir-to-client-js/imports.test.ts +107 -0
- package/src/__tests__/ir-to-client-js/merge-compiled-client-js-imports.test.ts +138 -0
- package/src/__tests__/issue-2754-rest-spread-needs-slot.test.ts +85 -0
- package/src/__tests__/issue-2756-loop-row-honors-client-only.test.ts +173 -0
- package/src/__tests__/merge-template-imports.test.ts +41 -1
- package/src/__tests__/multi-component-shared-default-import.test.ts +55 -0
- package/src/__tests__/multi-root-loop-body.test.ts +7 -3
- package/src/__tests__/preamble-declarations.test.ts +42 -0
- package/src/__tests__/root-key-relay.test.ts +170 -0
- package/src/__tests__/signal-getter-not-called.test.ts +149 -0
- package/src/__tests__/state-only-file-default-import.test.ts +47 -0
- package/src/analyzer.ts +36 -0
- package/src/compiler.ts +94 -104
- package/src/index.ts +1 -1
- package/src/ir-to-client-js/build-references.ts +7 -0
- package/src/ir-to-client-js/collect-elements.ts +27 -5
- package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +19 -1
- package/src/ir-to-client-js/control-flow/plan/loop.ts +2 -4
- package/src/ir-to-client-js/control-flow/stringify/component-loop.ts +7 -0
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +6 -2
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -2
- package/src/ir-to-client-js/control-flow.ts +12 -0
- package/src/ir-to-client-js/html-template.ts +174 -23
- package/src/ir-to-client-js/imports.ts +178 -5
- package/src/ir-to-client-js/index.ts +5 -0
- package/src/ir-to-client-js/prop-handling.ts +6 -17
- package/src/ir-to-client-js/utils.ts +30 -2
- package/src/jsx-to-ir.ts +592 -58
- package/src/props-binding.ts +51 -0
- package/src/types.ts +60 -13
|
@@ -49,11 +49,18 @@ export function stringifyComponentLoop(lines: string[], plan: ComponentLoopPlan)
|
|
|
49
49
|
nestedComps,
|
|
50
50
|
childConditionalEffects,
|
|
51
51
|
profileLoopId,
|
|
52
|
+
mapPreambleWrapped,
|
|
52
53
|
} = plan
|
|
53
54
|
|
|
54
55
|
const loopBfId = profileLoopId ? `, ${JSON.stringify(profileLoopId)}` : ''
|
|
55
56
|
lines.push(` mapArray(() => ${arrayExpr}, ${containerVar}, ${keyFn}, (${paramHead}, ${indexParam}, __existing) => {`)
|
|
56
57
|
if (paramUnwrap) lines.push(` ${paramUnwrap}`)
|
|
58
|
+
// The preamble's consts are referenced by componentPropsExpr's getters
|
|
59
|
+
// below, in BOTH the `initChild` (hydration-reuse) and `createComponent`
|
|
60
|
+
// (fresh row) branches — a row rebuild and a hydration-reused row must
|
|
61
|
+
// read the same preamble-computed value, so it can't be hoisted into only
|
|
62
|
+
// one branch.
|
|
63
|
+
if (mapPreambleWrapped) lines.push(` ${mapPreambleWrapped}`)
|
|
57
64
|
|
|
58
65
|
const scopedComp = nameForRegistryRef(componentName)
|
|
59
66
|
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
* <indent>}) }
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
-
import { keyAttrName, profileBindingId, varSlotId } from '../../utils.ts'
|
|
34
|
+
import { keyAttrName, mapArrayKeyArgs, profileBindingId, varSlotId } from '../../utils.ts'
|
|
35
35
|
import { emitComponentAndEventSetup } from '../shared.ts'
|
|
36
36
|
import { emitAttrUpdate } from '../../emit-reactive.ts'
|
|
37
37
|
import { emitMultiRootTemplateCloneLines, namespaceWrapForTemplate } from './template-parse.ts'
|
|
@@ -157,7 +157,11 @@ function emitReactive(lines: string[], inner: InnerLoopPlan, indent: string, pc:
|
|
|
157
157
|
bodyIsMultiRoot: emit.bodyIsMultiRoot,
|
|
158
158
|
})
|
|
159
159
|
lines.push(`${indent} return __innerEl${uid}`)
|
|
160
|
-
|
|
160
|
+
// #2753 Shape B: the runtime's own fallback stamp (a row whose renderItem
|
|
161
|
+
// didn't already set a key attribute — see `map-array.ts`) has no depth
|
|
162
|
+
// concept, so a nested keyed loop must tell it which name to check/write
|
|
163
|
+
// instead of the default `data-key`.
|
|
164
|
+
lines.push(`${indent}}, '${inner.markerId}'${mapArrayKeyArgs(profileBindingId(pc, inner.slotId), !!emit.wrappedKey, inner.keyDepth)}) }`)
|
|
161
165
|
}
|
|
162
166
|
|
|
163
167
|
function emitStatic(lines: string[], inner: InnerLoopPlan, indent: string, pc: string | undefined): void {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* every nesting depth.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { varSlotId, DATA_BF_PH, keyAttrName, profileBindingId } from '../../utils.ts'
|
|
14
|
+
import { varSlotId, DATA_BF_PH, keyAttrName, mapArrayKeyArgs, profileBindingId } from '../../utils.ts'
|
|
15
15
|
import { emitComponentAndEventSetup } from '../shared.ts'
|
|
16
16
|
import { emitAttrUpdate } from '../../emit-reactive.ts'
|
|
17
17
|
import { namespaceWrapForTemplate } from './template-parse.ts'
|
|
@@ -180,7 +180,10 @@ export function stringifyBranchInnerLoops(
|
|
|
180
180
|
stringifyLoopChildConditionals(lines, inner.nestedConditionals, `${indent} `, pc)
|
|
181
181
|
}
|
|
182
182
|
lines.push(`${indent} return __bel${uid}`)
|
|
183
|
-
|
|
183
|
+
// #2753 Shape B: see the identical comment in `inner-loop.ts` —
|
|
184
|
+
// `keyDepth` is always 1 for a branch-arm inner loop, so this only ever
|
|
185
|
+
// widens the trailing args when the loop is also keyed.
|
|
186
|
+
lines.push(`${indent}}, '${inner.markerId}'${mapArrayKeyArgs(profileBindingId(pc, inner.slotId), !!inner.wrappedKey, inner.keyDepth)}) }`)
|
|
184
187
|
}
|
|
185
188
|
}
|
|
186
189
|
|
|
@@ -86,6 +86,18 @@ export function emitLoopUpdates(lines: string[], ctx: ClientJsContext, unsafeLoc
|
|
|
86
86
|
!(elem.preamble && elem.preamble.builderNames.length > 0 && plan.rowConstruction === 'dom-ops'),
|
|
87
87
|
`loop variant '${plan.kind}' declares dom-ops row construction but received a JSX-bearing preamble — add a Phase-1 refusal (or wire renderPreamble support) for this shape`,
|
|
88
88
|
)
|
|
89
|
+
// #2797's hole, generalized: a JS-only preamble (no JSX leaf, so the
|
|
90
|
+
// check above doesn't fire) can still be silently dropped if a variant
|
|
91
|
+
// just never reads `elem.preamble` at all — which is exactly what
|
|
92
|
+
// happened to the 'component' variant before it grew
|
|
93
|
+
// `mapPreambleWrapped`. A variant that doesn't declare that field can't
|
|
94
|
+
// be checked here (nothing to read), so this only catches a variant
|
|
95
|
+
// that declares it but leaves it unpopulated for a preamble that has
|
|
96
|
+
// names to declare.
|
|
97
|
+
internalInvariant(
|
|
98
|
+
!(elem.preamble && elem.preamble.declaredNames.length > 0 && 'mapPreambleWrapped' in plan && plan.mapPreambleWrapped === ''),
|
|
99
|
+
`loop variant '${plan.kind}' has a preamble with declared names (${elem.preamble?.declaredNames.join(', ')}) but its plan's mapPreambleWrapped is empty — wire it up or the emitted call site references a name nothing declares`,
|
|
100
|
+
)
|
|
89
101
|
stringifyLoop(lines, plan)
|
|
90
102
|
emitLoopEventDelegation(lines, elem, plan.kind, ctx.profile ? ctx.componentName : undefined)
|
|
91
103
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* IR → HTML template string generation and validation.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { AttrValue, FlatMapCallback, IRAttribute, IRNode, IRProp, MapCallbackPreamble } from '../types.ts'
|
|
5
|
+
import type { AttrValue, FlatMapCallback, IRAttribute, IRElement, IRExpression, IRNode, IRProp, MapCallbackPreamble } from '../types.ts'
|
|
6
6
|
import { isBooleanAttr } from '../html-constants.ts'
|
|
7
7
|
import { toHtmlAttrName, attrValueToString, quotePropName, PROPS_PARAM, DATA_BF_PH, keyAttrName, loopStartMarker, loopEndMarker, loopItemMarker, freeIdsFromRefs, setIntersects, wrapExprWithLoopParams } from './utils.ts'
|
|
8
8
|
import type { LoopParamSpec } from './utils.ts'
|
|
@@ -319,6 +319,16 @@ function escapeAttrValueExpr(valExpr: string): string {
|
|
|
319
319
|
* bytes. Bare `${...}` interpolations — `{children}` passthrough and
|
|
320
320
|
* `renderChild(...)` output — are pre-rendered HTML and must NOT be
|
|
321
321
|
* escaped, so this is applied only at the four text-marker emit sites.
|
|
322
|
+
* The no-`slotId` fallthrough (every `case 'expression'` branch's final
|
|
323
|
+
* `return` in this file) is shared by several unrelated shapes besides
|
|
324
|
+
* `{children}` passthrough — an `escapeLeafTextExpressions`-wrapped
|
|
325
|
+
* preamble leaf, `lowerFormControlValueSsr`'s textarea initial value, an
|
|
326
|
+
* inlined constant, a `''`/`undefined` deferred placeholder — every one of
|
|
327
|
+
* which is either already escaped or a literal, and must reach the
|
|
328
|
+
* template untouched. `bareSpliceExpr` below is that branch's single door
|
|
329
|
+
* — the one place the fallthrough's decision is made — and it is what
|
|
330
|
+
* picks the genuine `{children}` reference back out for `markupOrEmpty`'s
|
|
331
|
+
* nullish guard (#2775); see its own docstring.
|
|
322
332
|
* Hono escapes text content with the same set as attribute values
|
|
323
333
|
* (`& " ' < >`), so `escapeText` delegates to the same operation.
|
|
324
334
|
*
|
|
@@ -339,6 +349,73 @@ function escapeTextSlotExpr(innerExpr: string, isMarkup = false): string {
|
|
|
339
349
|
return `${isMarkup ? 'escapeTextOrMarkup' : 'escapeText'}(${innerExpr})`
|
|
340
350
|
}
|
|
341
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Recognizes a JSX child-position expression that is exactly a reference to
|
|
354
|
+
* the reserved `children` prop — bare `children` (destructured) or
|
|
355
|
+
* `<receiver>.children` for any single-identifier receiver (`props.children`,
|
|
356
|
+
* a custom props-param name, a loop-scoped alias closing over props, ...).
|
|
357
|
+
* Checked against `node.expr` — the ORIGINAL source text, never a
|
|
358
|
+
* transformed/wrapped form — so it stays accurate regardless of which
|
|
359
|
+
* builder is asking, and regardless of any earlier pass
|
|
360
|
+
* (`escapeLeafTextExpressions`, `lowerFormControlValueSsr`) that may have
|
|
361
|
+
* wrapped an unrelated leaf.
|
|
362
|
+
*
|
|
363
|
+
* Deliberately LOOSER than `isTransparentFragment` (`jsx-to-ir.ts`), which
|
|
364
|
+
* answers the same underlying question one level up. That function runs on
|
|
365
|
+
* the TS AST and compares the expression text against an EXACT set —
|
|
366
|
+
* `children`, `props.children`, and the analyzer-resolved
|
|
367
|
+
* `${ctx.analyzer.propsObjectName}.children`. This layer works on IR and has
|
|
368
|
+
* no analyzer, so the resolved props name is not reachable here; matching any
|
|
369
|
+
* single-identifier receiver is the available approximation, chosen — not an
|
|
370
|
+
* inherited convention.
|
|
371
|
+
*
|
|
372
|
+
* The looseness costs nothing measurable. An unrelated `.children` member —
|
|
373
|
+
* a tree node's own `children` array, say — does not even arrive here: a
|
|
374
|
+
* reactive member expression is given a `slotId` and takes the escaped
|
|
375
|
+
* text-slot branch above, so it never reaches the bare-splice fallthrough
|
|
376
|
+
* this gates. And were one to arrive, the outcome is still benign: the
|
|
377
|
+
* branch never escaped its value either way, a non-nullish value is
|
|
378
|
+
* returned untouched, and a nullish one rendering `''` instead of the
|
|
379
|
+
* literal `"undefined"` is an improvement in its own right.
|
|
380
|
+
*
|
|
381
|
+
* Both the ORIGINAL source text and the RESOLVED expression are tested,
|
|
382
|
+
* because either one alone misses a shape. `node.expr` is the only form
|
|
383
|
+
* that does not vary between the four builders, so it stays the primary
|
|
384
|
+
* test; but it is the pre-substitution text, which for a
|
|
385
|
+
* destructured-and-renamed children (`const { children: kids } = props`)
|
|
386
|
+
* reads `kids` and matches nothing — while the resolved expression the
|
|
387
|
+
* emitter is about to splice already reads `(_p.children)`. Testing both
|
|
388
|
+
* closes that (#2786) without giving up `node.expr`'s stability.
|
|
389
|
+
*/
|
|
390
|
+
function isChildrenPassthroughExpr(expr: string): boolean {
|
|
391
|
+
return /^([A-Za-z_$][\w$]*\.)?children$/.test(expr.trim())
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The single door for the bare (no-`slotId`) expression splice — the
|
|
396
|
+
* counterpart to `escapeTextSlotExpr` for the branch that must NOT escape.
|
|
397
|
+
* All four `case 'expression'` builders in this file route through here so
|
|
398
|
+
* this decision exists in exactly one place: four copies that agree today
|
|
399
|
+
* are four that can drift apart tomorrow, and this file is where that has
|
|
400
|
+
* already happened (#2753 -> #2762).
|
|
401
|
+
*
|
|
402
|
+
* Only a genuine `{children}` passthrough gets `markupOrEmpty`'s nullish
|
|
403
|
+
* guard (#2775). Everything else this fallthrough hosts — an
|
|
404
|
+
* `escapeLeafTextExpressions`-wrapped preamble leaf,
|
|
405
|
+
* `lowerFormControlValueSsr`'s textarea initial value, an inlined constant,
|
|
406
|
+
* a `''`/`undefined` deferred placeholder — reaches the template exactly as
|
|
407
|
+
* it arrived, already escaped or a literal. Escaping is never correct here:
|
|
408
|
+
* the value is pre-rendered HTML, per `escapeTextSlotExpr`'s docstring.
|
|
409
|
+
*/
|
|
410
|
+
function bareSpliceExpr(node: IRExpression, valueExpr: string): string {
|
|
411
|
+
// Strip the parens the emitter wraps a substituted expression in, so the
|
|
412
|
+
// resolved form is comparable to the bare source text.
|
|
413
|
+
const resolved = valueExpr.trim().replace(/^\(+|\)+$/g, '')
|
|
414
|
+
const isChildren =
|
|
415
|
+
isChildrenPassthroughExpr(node.expr) || isChildrenPassthroughExpr(resolved)
|
|
416
|
+
return !node.joinArrayChild && isChildren ? `markupOrEmpty(${valueExpr})` : valueExpr
|
|
417
|
+
}
|
|
418
|
+
|
|
342
419
|
/**
|
|
343
420
|
* `dangerouslySetInnerHTML={{ __html: E }}` makes the element's content its
|
|
344
421
|
* raw innerHTML — the intentional, React-style escape hatch. Returns the
|
|
@@ -470,6 +547,25 @@ export interface MergeContext {
|
|
|
470
547
|
honorClientOnly: boolean
|
|
471
548
|
}
|
|
472
549
|
|
|
550
|
+
/**
|
|
551
|
+
* The client-template twin of `IRElement.keyAttr`'s SSR-side decision
|
|
552
|
+
* (`jsx-to-ir.ts`'s `extractLoopKey`/`applyLoopKeyAttr`) — #2763. A raw
|
|
553
|
+
* `key={}` JSX attribute survives on `.attrs` regardless of whether the
|
|
554
|
+
* loop/relay actually recognized it as a row key (a fragment-bodied loop
|
|
555
|
+
* row before #2763's fix, or heterogeneous keys across conditional
|
|
556
|
+
* branches) — baking `data-key` from the mere presence of that attribute
|
|
557
|
+
* would disagree with every SSR adapter, which all gate on `keyAttr`
|
|
558
|
+
* instead. Returns the resolved attribute name only when `node.keyAttr` is
|
|
559
|
+
* actually set; `null` otherwise, so the caller drops the raw `key`
|
|
560
|
+
* attribute entirely rather than emitting a `data-key` no SSR adapter
|
|
561
|
+
* agrees with. `keyAttr.name` and `keyAttrName(loopDepth)` are already the
|
|
562
|
+
* same value (both derive from `@barefootjs/shared`'s `keyAttrName`, see
|
|
563
|
+
* `IRElement.keyAttr`'s docstring) — this only decides WHETHER to use it.
|
|
564
|
+
*/
|
|
565
|
+
function resolvedKeyAttrName(node: IRElement, loopDepth: number): string | null {
|
|
566
|
+
return node.keyAttr ? keyAttrName(loopDepth) : null
|
|
567
|
+
}
|
|
568
|
+
|
|
473
569
|
/** Return true if this attribute should participate in the merge object. */
|
|
474
570
|
function isMergeableAttr(a: IRAttribute, ctx: MergeContext): boolean {
|
|
475
571
|
if (ctx.honorClientOnly && a.clientOnly) return false
|
|
@@ -757,13 +853,21 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
|
|
|
757
853
|
switch (node.type) {
|
|
758
854
|
case 'element': {
|
|
759
855
|
// Merge context shared with `irToComponentTemplate` /
|
|
760
|
-
// `generateCsrTemplate`.
|
|
761
|
-
// `
|
|
762
|
-
//
|
|
763
|
-
//
|
|
856
|
+
// `generateCsrTemplate`. Its spread rest-name detector uses
|
|
857
|
+
// `v.expr` directly (no `templateExpr` fallback — those live on
|
|
858
|
+
// the SSR template path).
|
|
859
|
+
//
|
|
860
|
+
// Why not path-local `clientOnly`: this builder emits the row /
|
|
861
|
+
// branch markup that a freshly built row gets, while a row REUSED
|
|
862
|
+
// by hydration carries the SSR adapter's markup instead. So the
|
|
863
|
+
// two representations must agree, and `clientOnly` ("SSR omits it;
|
|
864
|
+
// the effect owns it") is the same statement on both sides. Baking
|
|
865
|
+
// the attribute in here made a rebuilt row carry an attribute an
|
|
866
|
+
// SSR-reused row never has — visible the moment a row-count change
|
|
867
|
+
// makes reused and rebuilt rows coexist in one list (#2756).
|
|
764
868
|
const mergeCtx: MergeContext = {
|
|
765
869
|
isFilteredSpread: (v) => !!restSpreadNames?.has(v.expr),
|
|
766
|
-
honorClientOnly:
|
|
870
|
+
honorClientOnly: true,
|
|
767
871
|
}
|
|
768
872
|
const useMerge = shouldUseSpreadAttrsMerge(node.attrs, mergeCtx)
|
|
769
873
|
const firstMergeableIdx = useMerge
|
|
@@ -780,15 +884,22 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
|
|
|
780
884
|
|
|
781
885
|
const attrParts = node.attrs
|
|
782
886
|
.map((a, idx) => {
|
|
887
|
+
// Deferred to the row's own `createEffect`, which the loop-row
|
|
888
|
+
// reactive-attr collector already registers for every
|
|
889
|
+
// `clientOnly` attr (`collect-elements.ts`). Emitting it here
|
|
890
|
+
// too would be redundant on a rebuilt row and absent on a
|
|
891
|
+
// hydrate-reused one (#2756).
|
|
892
|
+
if (a.clientOnly) return ''
|
|
783
893
|
if (useMerge && isMergeableAttr(a, mergeCtx)) {
|
|
784
894
|
// Only the first mergeable attr emits the merge call; the
|
|
785
895
|
// others are already represented inside the merge object.
|
|
786
896
|
return idx === firstMergeableIdx ? mergeCall! : ''
|
|
787
897
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
898
|
+
if (a.name === 'key') {
|
|
899
|
+
const attrName = resolvedKeyAttrName(node, loopDepth)
|
|
900
|
+
return attrName ? renderTemplateAttrPart(a, attrName, wrapExpr, restSpreadNames) : ''
|
|
901
|
+
}
|
|
902
|
+
return renderTemplateAttrPart(a, toHtmlAttrName(a.name), wrapExpr, restSpreadNames)
|
|
792
903
|
})
|
|
793
904
|
.filter(Boolean)
|
|
794
905
|
|
|
@@ -853,11 +964,19 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
|
|
|
853
964
|
// separate rather than collapsed into a shared helper precisely
|
|
854
965
|
// because their `clientOnly` semantics differ — see that function's
|
|
855
966
|
// own comment (#2617).
|
|
967
|
+
|
|
968
|
+
// Escape only when the IR says so (`escapeInClientTemplate`) — most
|
|
969
|
+
// `${...}` here is already pre-rendered HTML. Never take
|
|
970
|
+
// `templateExpr` wholesale instead: it rebinds to `_p.xxx`, dropping
|
|
971
|
+
// the `?? {}` prop-defaulting guard in this builder's init scope
|
|
972
|
+
// (`client-js-generation.test.ts`).
|
|
973
|
+
const escapeForClient = (e: string): string =>
|
|
974
|
+
node.escapeInClientTemplate ? `escapeText(${e})` : e
|
|
856
975
|
if (node.markerless) {
|
|
857
|
-
const bare = wrapInterpolation(wrapExpr(node.expr))
|
|
976
|
+
const bare = escapeForClient(wrapInterpolation(wrapExpr(node.expr)))
|
|
858
977
|
return `\${${bare}}`
|
|
859
978
|
}
|
|
860
|
-
const inner = wrapInterpolation(wrapExpr(node.expr))
|
|
979
|
+
const inner = escapeForClient(wrapInterpolation(wrapExpr(node.expr)))
|
|
861
980
|
// Stage 3 / D4 — an element-array child ({out}) built by an arbitrary
|
|
862
981
|
// .map() preamble is an array of HTML strings; join it rather than let
|
|
863
982
|
// `${[...]}` `String`-comma-collapse it. Only reached on a JS-runtime
|
|
@@ -876,7 +995,8 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
|
|
|
876
995
|
const slotted = branchSlotsVar || node.joinArrayChild ? valueExpr : escapeTextSlotExpr(valueExpr)
|
|
877
996
|
return `<!--bf:${node.slotId}-->\${${slotted}}<!--/-->`
|
|
878
997
|
}
|
|
879
|
-
|
|
998
|
+
// Bare-splice fallthrough (no `slotId`, not an array-child join).
|
|
999
|
+
return `\${${bareSpliceExpr(node, valueExpr)}}`
|
|
880
1000
|
}
|
|
881
1001
|
|
|
882
1002
|
case 'conditional': {
|
|
@@ -1089,7 +1209,9 @@ export function buildLoopSkeletonTemplate(node: IRNode, safe: LoopSkeletonSafeSl
|
|
|
1089
1209
|
if (a.name === '...') return null
|
|
1090
1210
|
if (a.name === 'dangerouslySetInnerHTML') return null
|
|
1091
1211
|
if (a.name === 'key') {
|
|
1092
|
-
|
|
1212
|
+
if (resolvedKeyAttrName(node, 0)) {
|
|
1213
|
+
attrParts.push(`${keyAttrName(0)}=""`)
|
|
1214
|
+
}
|
|
1093
1215
|
continue
|
|
1094
1216
|
}
|
|
1095
1217
|
const v = a.value
|
|
@@ -1381,9 +1503,15 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Readonly
|
|
|
1381
1503
|
case 'element': {
|
|
1382
1504
|
const attrParts = node.attrs
|
|
1383
1505
|
.map((a) => {
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1506
|
+
// Same deferral as `irToHtmlTemplate` — this builder is the
|
|
1507
|
+
// composite-row twin of it, so a row it builds must carry the
|
|
1508
|
+
// same attributes a hydration-reused row does (#2756).
|
|
1509
|
+
if (a.clientOnly) return ''
|
|
1510
|
+
if (a.name === 'key') {
|
|
1511
|
+
const attrName = resolvedKeyAttrName(node, loopDepth)
|
|
1512
|
+
return attrName ? renderTemplateAttrPart(a, attrName, wrapExpr, restSpreadNames) : ''
|
|
1513
|
+
}
|
|
1514
|
+
const attrName = a.name === '...' ? '...' : toHtmlAttrName(a.name)
|
|
1387
1515
|
return renderTemplateAttrPart(a, attrName, wrapExpr, restSpreadNames)
|
|
1388
1516
|
})
|
|
1389
1517
|
.filter(Boolean)
|
|
@@ -1416,7 +1544,11 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Readonly
|
|
|
1416
1544
|
if (node.slotId) {
|
|
1417
1545
|
return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(wrapped)}}<!--/-->`
|
|
1418
1546
|
}
|
|
1419
|
-
|
|
1547
|
+
// Bare-splice fallthrough (no `slotId`) — this builder's composite-row
|
|
1548
|
+
// twin of `irToHtmlTemplate`'s `escapeForClient`, same "why not
|
|
1549
|
+
// templateExpr" reasoning (#2765).
|
|
1550
|
+
const spliced = bareSpliceExpr(node, value)
|
|
1551
|
+
return `\${${node.escapeInClientTemplate ? `escapeText(${spliced})` : spliced}}`
|
|
1420
1552
|
}
|
|
1421
1553
|
|
|
1422
1554
|
case 'conditional': {
|
|
@@ -1903,7 +2035,8 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
|
|
|
1903
2035
|
const isMarkup = opts.markupSlotIds?.has(node.slotId) ?? false
|
|
1904
2036
|
return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(wrapped, isMarkup)}}<!--/-->`
|
|
1905
2037
|
}
|
|
1906
|
-
|
|
2038
|
+
// Bare-splice fallthrough (no `slotId`).
|
|
2039
|
+
return `\${${bareSpliceExpr(node, value)}}`
|
|
1907
2040
|
}
|
|
1908
2041
|
|
|
1909
2042
|
case 'conditional': {
|
|
@@ -2441,9 +2574,26 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
|
|
|
2441
2574
|
if (mergeCtx.isFilteredSpread(v)) return ''
|
|
2442
2575
|
return `\${spreadAttrs(${transformExpr(v.expr, v.templateExpr)})}`
|
|
2443
2576
|
}
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2577
|
+
if (a.name === 'key') {
|
|
2578
|
+
const keyName = resolvedKeyAttrName(node, loopDepth)
|
|
2579
|
+
if (!keyName) return ''
|
|
2580
|
+
switch (v.kind) {
|
|
2581
|
+
case 'boolean-attr':
|
|
2582
|
+
return keyName
|
|
2583
|
+
case 'literal':
|
|
2584
|
+
return `${keyName}="${escapeHtml(v.value)}"`
|
|
2585
|
+
case 'expression':
|
|
2586
|
+
return templateAttrExpr(keyName, transformExpr(v.expr, v.templateExpr), v.presenceOrUndefined)
|
|
2587
|
+
case 'template': {
|
|
2588
|
+
const valueStr = attrValueToString(v, { useTemplate: true })
|
|
2589
|
+
return valueStr ? templateAttrExpr(keyName, transformExpr(valueStr)) : ''
|
|
2590
|
+
}
|
|
2591
|
+
case 'boolean-shorthand':
|
|
2592
|
+
case 'jsx-children':
|
|
2593
|
+
return ''
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
const attrName = toHtmlAttrName(a.name)
|
|
2447
2597
|
switch (v.kind) {
|
|
2448
2598
|
case 'boolean-attr':
|
|
2449
2599
|
return attrName
|
|
@@ -2527,7 +2677,8 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
|
|
|
2527
2677
|
const isMarkup = opts.markupSlotIds?.has(node.slotId) ?? false
|
|
2528
2678
|
return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(expr, isMarkup)}}<!--/-->`
|
|
2529
2679
|
}
|
|
2530
|
-
|
|
2680
|
+
// Bare-splice fallthrough (no `slotId`).
|
|
2681
|
+
return `\${${bareSpliceExpr(node, value)}}`
|
|
2531
2682
|
}
|
|
2532
2683
|
|
|
2533
2684
|
case 'conditional': {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Import detection and DOM import management.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import ts from 'typescript'
|
|
5
6
|
import type { ComponentIR, IRNode } from '../types.ts'
|
|
6
7
|
import { isClientBuiltinName } from '../builtins.ts'
|
|
7
8
|
import { collectValueReferencedNames } from '../value-references.ts'
|
|
@@ -29,6 +30,9 @@ export const RUNTIME_IMPORT_CANDIDATES = [
|
|
|
29
30
|
// the compiler-built HTML at the producer (renderChild / initChild props);
|
|
30
31
|
// `escapeTextOrMarkup` unwraps it at the claim-plan-'markup' template slot.
|
|
31
32
|
'bfMarkup', 'escapeTextOrMarkup',
|
|
33
|
+
// Nullish guard for a bare `{children}` passthrough splice (#2775) — the
|
|
34
|
+
// value is already-stringified markup, never escaped, just nullish-safe.
|
|
35
|
+
'markupOrEmpty',
|
|
32
36
|
'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot', '__bfText',
|
|
33
37
|
// Claim-plan interpreter (slot unification A2/A3, spec/slot-unification.md)
|
|
34
38
|
// — the "one claim mechanism" that replaced `patchSlotRange` and
|
|
@@ -148,6 +152,168 @@ export function makeValueUsageTest(generatedCode: string): (localName: string) =
|
|
|
148
152
|
}
|
|
149
153
|
}
|
|
150
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Render already-filtered-to-used specifier fragments for one import source
|
|
157
|
+
* into one or two legal import declaration lines. Shared by every call site
|
|
158
|
+
* that re-serializes an `ImportInfo`'s specifiers into client-JS import
|
|
159
|
+
* text — `collectExternalImports` below and the state-only-file client-JS
|
|
160
|
+
* path (`compiler.ts`'s single-component early return for a `.tsx` with no
|
|
161
|
+
* JSX return but exported `@client` module signals) — so the
|
|
162
|
+
* default/namespace handling lives in exactly one place.
|
|
163
|
+
*
|
|
164
|
+
* A default or namespace specifier needs its own import syntax
|
|
165
|
+
* (`import X from '...'` / `import * as X from '...'`), never the
|
|
166
|
+
* named-import braces a plain specifier gets — a plain `import { lock }
|
|
167
|
+
* from '...'` for a DEFAULT-imported `lock` compiles to a real, silently-
|
|
168
|
+
* wrong ESM import (no such named export) that only surfaces once a
|
|
169
|
+
* bundler actually resolves it (#2767 follow-up: a server component's own
|
|
170
|
+
* compiled init previously never reached a real Rollup graph, so this was
|
|
171
|
+
* unreachable until that gap closed).
|
|
172
|
+
*
|
|
173
|
+
* `import Default, { a, b } from '...'` is the only legal single-line
|
|
174
|
+
* pairing — a namespace specifier can't combine with named ones, but
|
|
175
|
+
* multiple import declarations for the same source are legal ESM, so a
|
|
176
|
+
* used namespace specifier always gets its own line.
|
|
177
|
+
*/
|
|
178
|
+
export function renderUsedImportLines(
|
|
179
|
+
source: string,
|
|
180
|
+
usedDefault: string | null,
|
|
181
|
+
usedNamespace: string | null,
|
|
182
|
+
usedNamed: string[],
|
|
183
|
+
): string[] {
|
|
184
|
+
const lines: string[] = []
|
|
185
|
+
const defaultAndNamed = [
|
|
186
|
+
usedDefault,
|
|
187
|
+
usedNamed.length > 0 ? `{ ${usedNamed.join(', ')} }` : null,
|
|
188
|
+
].filter((part): part is string => part !== null).join(', ')
|
|
189
|
+
if (defaultAndNamed) lines.push(`import ${defaultAndNamed} from '${source}'`)
|
|
190
|
+
if (usedNamespace) lines.push(`import * as ${usedNamespace} from '${source}'`)
|
|
191
|
+
return lines
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Merge multiple sibling components' compiled client-JS blobs (one file
|
|
196
|
+
* with several `export function`s, e.g. `compileMultipleComponents`'s two
|
|
197
|
+
* `.client.js` outputs) into one conflict-free block.
|
|
198
|
+
*
|
|
199
|
+
* Real top-level `ImportDeclaration` statements are found via a
|
|
200
|
+
* `ts.createSourceFile` AST walk — never a text/regex line scan — so a
|
|
201
|
+
* string or template-literal VALUE that merely contains a line starting
|
|
202
|
+
* with `import ` (a docs component embedding a code sample, say) can never
|
|
203
|
+
* be torn out of its literal and hoisted into the imports block. This
|
|
204
|
+
* mirrors `combine-client-js.ts`'s `parseAndMerge`, the established
|
|
205
|
+
* precedent for exactly this shape of parse (see that file's docstring
|
|
206
|
+
* and issue #1702, the regression it exists to prevent) — CLAUDE.md
|
|
207
|
+
* requires it for "compiled client JS" specifically. Reaching a bundler
|
|
208
|
+
* for the FIRST time is precisely what a plain server component newly
|
|
209
|
+
* promoted to a Rollup entry by `needsClientEntry` (#2767) now does, so a
|
|
210
|
+
* line-based scan here carries real risk, not just a style violation.
|
|
211
|
+
*
|
|
212
|
+
* Differs from `parseAndMerge` in two ways required by this call site:
|
|
213
|
+
* (1) default and named specifiers from the same source fold into ONE
|
|
214
|
+
* declaration via `renderUsedImportLines`'s rule, rather than surviving
|
|
215
|
+
* as separate verbatim, exact-string-deduped lines — the fold is what
|
|
216
|
+
* prevents the duplicate-binding `SyntaxError` two sibling components can
|
|
217
|
+
* otherwise produce for a shared default import (#2767 follow-up); (2) an
|
|
218
|
+
* unresolved `@bf-child:` placeholder import is KEPT (deduped by exact
|
|
219
|
+
* text, same as any other side-effect import), never dropped — unlike
|
|
220
|
+
* `parseAndMerge`'s parent-child inlining case, this merge runs inside
|
|
221
|
+
* `compileMultipleComponents`, BEFORE `@barefootjs/vite`'s `resolveId`
|
|
222
|
+
* gets a chance to rewrite the placeholder into a real module reference.
|
|
223
|
+
*
|
|
224
|
+
* Returns the fully assembled `<imports>\n\n<code…>` block ready to use
|
|
225
|
+
* as a `.client.js` file's content.
|
|
226
|
+
*/
|
|
227
|
+
export function mergeCompiledClientJsImports(codeBlobs: string[]): string {
|
|
228
|
+
const sourceOrder: string[] = []
|
|
229
|
+
const namedBySource = new Map<string, Set<string>>()
|
|
230
|
+
const defaultBySource = new Map<string, string>()
|
|
231
|
+
const otherImports: string[] = []
|
|
232
|
+
const seenOther = new Set<string>()
|
|
233
|
+
const codeSections: string[] = []
|
|
234
|
+
|
|
235
|
+
const ensureSource = (source: string): Set<string> => {
|
|
236
|
+
if (!namedBySource.has(source)) {
|
|
237
|
+
namedBySource.set(source, new Set())
|
|
238
|
+
sourceOrder.push(source)
|
|
239
|
+
}
|
|
240
|
+
return namedBySource.get(source)!
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (const content of codeBlobs) {
|
|
244
|
+
const sourceFile = ts.createSourceFile(
|
|
245
|
+
'combine.js',
|
|
246
|
+
content,
|
|
247
|
+
ts.ScriptTarget.Latest,
|
|
248
|
+
/*setParentNodes*/ false,
|
|
249
|
+
ts.ScriptKind.JS,
|
|
250
|
+
)
|
|
251
|
+
const importSpans: Array<[number, number]> = []
|
|
252
|
+
|
|
253
|
+
for (const stmt of sourceFile.statements) {
|
|
254
|
+
if (!ts.isImportDeclaration(stmt)) continue
|
|
255
|
+
const start = stmt.getStart(sourceFile)
|
|
256
|
+
const end = stmt.getEnd()
|
|
257
|
+
importSpans.push([start, end])
|
|
258
|
+
|
|
259
|
+
const clause = stmt.importClause
|
|
260
|
+
const bindings = clause?.namedBindings
|
|
261
|
+
const specifier = ts.isStringLiteral(stmt.moduleSpecifier) ? stmt.moduleSpecifier.text : ''
|
|
262
|
+
const isNamespace = !!bindings && ts.isNamespaceImport(bindings)
|
|
263
|
+
const isNamed = !!bindings && ts.isNamedImports(bindings)
|
|
264
|
+
|
|
265
|
+
// A namespace binding (`import * as NS from '…'`, or a combined
|
|
266
|
+
// `import Default, * as NS from '…'`) is never folded — it always
|
|
267
|
+
// falls through to the verbatim-keep branch below, same as
|
|
268
|
+
// `parseAndMerge`'s. Checking `isNamespace` FIRST (not just `!isNamed`)
|
|
269
|
+
// matters for the combined-with-default shape specifically: a naive
|
|
270
|
+
// `clause?.name || isNamed` would route it into the fold branch below
|
|
271
|
+
// on the strength of the default clause alone and silently drop the
|
|
272
|
+
// namespace half, since only `isNamed` is read there. No current
|
|
273
|
+
// producer of `clientJs` output emits that combined shape
|
|
274
|
+
// (`renderUsedImportLines` always splits a used default+namespace
|
|
275
|
+
// pair into two separate lines), but the classification must stay
|
|
276
|
+
// correct independent of that invariant.
|
|
277
|
+
if (!isNamespace && (clause?.name || isNamed)) {
|
|
278
|
+
// Default and/or named specifiers — fold by source.
|
|
279
|
+
const set = ensureSource(specifier)
|
|
280
|
+
if (clause?.name && !defaultBySource.has(specifier)) {
|
|
281
|
+
defaultBySource.set(specifier, clause.name.text)
|
|
282
|
+
}
|
|
283
|
+
if (isNamed) {
|
|
284
|
+
for (const el of (bindings as ts.NamedImports).elements) {
|
|
285
|
+
set.add(el.propertyName ? `${el.propertyName.text} as ${el.name.text}` : el.name.text)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
// Namespace or side-effect import (including an unresolved
|
|
290
|
+
// `@bf-child:` placeholder) — kept verbatim, deduped by exact text.
|
|
291
|
+
const stmtText = content.slice(start, end)
|
|
292
|
+
if (!seenOther.has(stmtText)) {
|
|
293
|
+
seenOther.add(stmtText)
|
|
294
|
+
otherImports.push(stmtText)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let code = ''
|
|
300
|
+
let cursor = 0
|
|
301
|
+
for (const [start, end] of importSpans) {
|
|
302
|
+
code += content.slice(cursor, start)
|
|
303
|
+
cursor = end
|
|
304
|
+
}
|
|
305
|
+
code += content.slice(cursor)
|
|
306
|
+
code = code.trim()
|
|
307
|
+
if (code) codeSections.push(code)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const mergedImports = sourceOrder.flatMap(source =>
|
|
311
|
+
renderUsedImportLines(source, defaultBySource.get(source) ?? null, null, [...namedBySource.get(source)!]),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
return [...mergedImports, ...otherImports, '', ...codeSections].join('\n')
|
|
315
|
+
}
|
|
316
|
+
|
|
151
317
|
/**
|
|
152
318
|
* Collect external (non-DOM, non-component) imports that are used in generated code.
|
|
153
319
|
* These are third-party libraries like @barefootjs/form, zod, etc. that need to be
|
|
@@ -171,23 +337,30 @@ export function collectExternalImports(ir: ComponentIR, generatedCode: string, l
|
|
|
171
337
|
|
|
172
338
|
// Check which specifiers are actually used in the generated code.
|
|
173
339
|
// Skip component names — they are rendered via initChild(), not imported directly.
|
|
174
|
-
const
|
|
340
|
+
const usedNamed: string[] = []
|
|
341
|
+
let usedDefault: string | null = null
|
|
342
|
+
let usedNamespace: string | null = null
|
|
175
343
|
for (const spec of imp.specifiers) {
|
|
176
344
|
// Per-specifier `import { type Foo }` has no value binding — #2432.
|
|
177
345
|
if (spec.isTypeOnly) continue
|
|
178
346
|
const localName = spec.alias || spec.name
|
|
179
347
|
if (componentNames.has(localName)) continue
|
|
180
|
-
if (isUsedAsValue(localName))
|
|
181
|
-
|
|
348
|
+
if (!isUsedAsValue(localName)) continue
|
|
349
|
+
if (spec.isDefault) {
|
|
350
|
+
usedDefault = localName
|
|
351
|
+
} else if (spec.isNamespace) {
|
|
352
|
+
usedNamespace = localName
|
|
353
|
+
} else {
|
|
354
|
+
usedNamed.push(spec.alias ? `${spec.name} as ${spec.alias}` : spec.name)
|
|
182
355
|
}
|
|
183
356
|
}
|
|
184
357
|
|
|
185
|
-
if (
|
|
358
|
+
if (usedDefault || usedNamespace || usedNamed.length > 0) {
|
|
186
359
|
let source = imp.source
|
|
187
360
|
if (ir.metadata.clientSignalImportSources?.has(source)) {
|
|
188
361
|
source = source.replace(/\.tsx?$/, '') + '.client.js'
|
|
189
362
|
}
|
|
190
|
-
importLines.push(
|
|
363
|
+
importLines.push(...renderUsedImportLines(source, usedDefault, usedNamespace, usedNamed))
|
|
191
364
|
}
|
|
192
365
|
}
|
|
193
366
|
return importLines
|
|
@@ -217,6 +217,11 @@ function needsClientJs(ctx: ClientJsContext): boolean {
|
|
|
217
217
|
ctx.conditionalElements.length > 0 ||
|
|
218
218
|
ctx.loopElements.length > 0 ||
|
|
219
219
|
ctx.refElements.length > 0 ||
|
|
220
|
+
// An element forwarding the caller's leftover props needs `init` to
|
|
221
|
+
// run `applyRestAttrs` against it — that call is the ONLY thing that
|
|
222
|
+
// applies those attributes on a pure CSR mount, since neither template
|
|
223
|
+
// can carry a bag whose keys are unknown at compile time (#2754).
|
|
224
|
+
ctx.restAttrElements.length > 0 ||
|
|
220
225
|
ctx.childInits.length > 0 ||
|
|
221
226
|
ctx.reactiveAttrs.length > 0 ||
|
|
222
227
|
ctx.clientOnlyElements.length > 0 ||
|