@barefootjs/jsx 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/parsed-expr-emitter.d.ts +1 -1
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/combine-client-js.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +1 -1
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.js +330 -70
- package/dist/ir-to-client-js/collect-elements.d.ts +26 -14
- package/dist/ir-to-client-js/collect-elements.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 +8 -3
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/generate-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +30 -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/phases/provider-and-child-inits.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +3 -3
- package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +36 -4
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +19 -1
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +203 -203
- package/src/__tests__/child-components-in-map.test.ts +333 -0
- package/src/__tests__/combine-client-js.test.ts +47 -0
- package/src/__tests__/dangerously-set-inner-html.test.ts +82 -0
- package/src/__tests__/expression-parser.test.ts +167 -13
- package/src/__tests__/ir-to-client-js/reactivity.test.ts +1 -0
- package/src/__tests__/staged-ir/06-multi-stage-soak.test.ts +18 -3
- package/src/__tests__/static-loop-csr-materialize.test.ts +6 -4
- package/src/__tests__/text-slot-escaping.test.ts +56 -0
- package/src/adapters/parsed-expr-emitter.ts +7 -0
- package/src/combine-client-js.ts +66 -22
- package/src/expression-parser.ts +200 -17
- package/src/ir-to-client-js/collect-elements.ts +170 -32
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +1 -1
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -1
- package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +8 -3
- package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +3 -3
- package/src/ir-to-client-js/emit-reactive.ts +9 -0
- package/src/ir-to-client-js/emit-registration.ts +1 -1
- package/src/ir-to-client-js/generate-init.ts +16 -1
- package/src/ir-to-client-js/html-template.ts +238 -12
- package/src/ir-to-client-js/imports.ts +1 -1
- package/src/ir-to-client-js/index.ts +1 -0
- package/src/ir-to-client-js/phases/provider-and-child-inits.ts +12 -1
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +4 -8
- package/src/ir-to-client-js/plan/static-array-child-init.ts +3 -3
- package/src/ir-to-client-js/types.ts +37 -4
- package/src/ir-to-client-js/utils.ts +41 -1
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* sentinel before destructuring (#951 TDZ-safe).
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import { toDomEventName, varSlotId, substituteLoopBindings, DATA_KEY, keyAttrName } from '../../utils'
|
|
35
|
+
import { toDomEventName, varSlotId, substituteLoopBindings, buildLoopChildIndexSubtraction, DATA_KEY, keyAttrName } from '../../utils'
|
|
36
36
|
import type {
|
|
37
37
|
EventDelegationPlan,
|
|
38
38
|
KeyedItemLookup,
|
|
@@ -187,11 +187,11 @@ function emitStaticIndexLookup(
|
|
|
187
187
|
lookup: StaticIndexItemLookup,
|
|
188
188
|
containerVar: string,
|
|
189
189
|
): void {
|
|
190
|
-
const { arrayExpr, param, mapPreamble,
|
|
190
|
+
const { arrayExpr, param, mapPreamble, offset } = lookup
|
|
191
191
|
ls.push(` let __el = ${varSlotId(ev.childSlotId)}El`)
|
|
192
192
|
ls.push(` while (__el.parentElement && __el.parentElement !== ${containerVar}) __el = __el.parentElement`)
|
|
193
193
|
ls.push(` if (__el.parentElement === ${containerVar}) {`)
|
|
194
|
-
const idxOffset =
|
|
194
|
+
const idxOffset = buildLoopChildIndexSubtraction(offset ?? undefined)
|
|
195
195
|
ls.push(` const __idx = Array.from(${containerVar}.children).indexOf(__el)${idxOffset}`)
|
|
196
196
|
ls.push(` const ${param} = ${arrayExpr}[__idx]`)
|
|
197
197
|
if (mapPreamble) ls.push(` ${mapPreamble}`)
|
|
@@ -17,6 +17,15 @@ import { createTemplateAwareStringProtector } from './html-template'
|
|
|
17
17
|
*/
|
|
18
18
|
export function emitAttrUpdate(target: string, attrName: string, expression: string, meta: AttrMeta): string[] {
|
|
19
19
|
const htmlName = toHtmlAttrName(attrName)
|
|
20
|
+
if (attrName === 'dangerouslySetInnerHTML' || htmlName === 'dangerouslySetInnerHTML') {
|
|
21
|
+
// `{ __html }` is not an attribute — it replaces the element's content.
|
|
22
|
+
// Assign `innerHTML` (raw, intentional escape hatch) to mirror the SSR
|
|
23
|
+
// adapters' native `dangerouslySetInnerHTML` handling instead of
|
|
24
|
+
// stringifying the object into a bogus attribute.
|
|
25
|
+
return [
|
|
26
|
+
`{ const __v = ${expression}; ${target}.innerHTML = __v != null && __v.__html != null ? String(__v.__html) : '' }`,
|
|
27
|
+
]
|
|
28
|
+
}
|
|
20
29
|
if (htmlName === 'style') {
|
|
21
30
|
return [
|
|
22
31
|
`{ const __v = styleToCss(${expression}); if (__v != null) ${target}.setAttribute('style', __v); else ${target}.removeAttribute('style') }`,
|
|
@@ -168,7 +168,7 @@ export function emitRegistrationAndHydration(
|
|
|
168
168
|
// transformation runs at this layer (#1277).
|
|
169
169
|
const csrInlinableConstants = csrInlinableConstantsFromCtx(ctx)
|
|
170
170
|
const templateHtml = generateCsrTemplate(
|
|
171
|
-
_ir.root, csrInlinableConstants, ctx, undefined, restSpreadNames, ctx.propsObjectName, unsafeLocalNames
|
|
171
|
+
_ir.root, csrInlinableConstants, ctx, undefined, restSpreadNames, ctx.propsObjectName, unsafeLocalNames, ctx.deferredChildSlots
|
|
172
172
|
)
|
|
173
173
|
if (templateHtml) {
|
|
174
174
|
defParts.push(`template: (${PROPS_PARAM}) => \`${templateHtml}\``)
|
|
@@ -14,7 +14,8 @@ import { PROPS_PARAM } from './utils'
|
|
|
14
14
|
import { buildReferencesGraph } from './build-references'
|
|
15
15
|
import { computePropUsage } from './compute-prop-usage'
|
|
16
16
|
import { IMPORT_PLACEHOLDER, MODULE_CONSTANTS_PLACEHOLDER } from './imports'
|
|
17
|
-
import { emitRegistrationAndHydration } from './emit-registration'
|
|
17
|
+
import { emitRegistrationAndHydration, csrInlinableConstantsFromCtx } from './emit-registration'
|
|
18
|
+
import { computeDeferredChildSlots } from './html-template'
|
|
18
19
|
import { emitChildComponentImports } from './child-components'
|
|
19
20
|
import { classifyLocalDeclarations } from './init-declarations'
|
|
20
21
|
import { emitModuleLevelDeclarations, resolveFinalImports } from './emit-module-level'
|
|
@@ -55,6 +56,20 @@ export function generateInitFunction(
|
|
|
55
56
|
// duplicate warnings (#1247).
|
|
56
57
|
const inlinability = buildInlinableConstants(ctx, graph, ir.root)
|
|
57
58
|
|
|
59
|
+
// Decide which direct child components must defer their render to init
|
|
60
|
+
// because a forwarded prop resolves to an init-scope-only / non-inlinable
|
|
61
|
+
// local (dropped-prop fix). The child-init phase reads this set to emit
|
|
62
|
+
// `upsertChild` instead of `initChild`; `emitRegistrationAndHydration`
|
|
63
|
+
// reads it to emit a `data-bf-ph` placeholder instead of
|
|
64
|
+
// `renderChild(...)`. Computed here, once `unsafeLocalNames` is known.
|
|
65
|
+
ctx.deferredChildSlots = computeDeferredChildSlots(
|
|
66
|
+
ir.root,
|
|
67
|
+
ctx,
|
|
68
|
+
csrInlinableConstantsFromCtx(ctx),
|
|
69
|
+
inlinability.unsafeLocalNames,
|
|
70
|
+
ctx.propsObjectName,
|
|
71
|
+
)
|
|
72
|
+
|
|
58
73
|
// --- Emission: declarative phase pipeline. Each entry in `PHASES`
|
|
59
74
|
// declares its inputs (dependsOn) and emission action (run); the
|
|
60
75
|
// stable topological execution preserves the legacy by-position
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* IR → HTML template string generation and validation.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { AttrValue, IRAttribute, IRNode } from '../types'
|
|
5
|
+
import type { AttrValue, IRAttribute, IRNode, IRProp } from '../types'
|
|
6
6
|
import { isBooleanAttr } from '../html-constants'
|
|
7
7
|
import { toHtmlAttrName, attrValueToString, quotePropName, PROPS_PARAM, DATA_BF_PH, keyAttrName, loopStartMarker, loopEndMarker, loopItemMarker, freeIdsFromRefs, setIntersects, wrapExprWithLoopParams } from './utils'
|
|
8
8
|
import type { LoopParamSpec } from './utils'
|
|
@@ -192,11 +192,16 @@ const UNSAFE_TEMPLATE_EXPR = 'undefined'
|
|
|
192
192
|
* @param attr - Attribute metadata (only presenceOrUndefined flag is used)
|
|
193
193
|
*/
|
|
194
194
|
function templateAttrExpr(attrName: string, valExpr: string, presenceOrUndefined?: boolean): string {
|
|
195
|
+
// `dangerouslySetInnerHTML={{ __html }}` is not an attribute — its value
|
|
196
|
+
// becomes the element's raw innerHTML (emitted as element content). Never
|
|
197
|
+
// serialise the `{ __html }` object into a `dangerouslySetInnerHTML="…"`
|
|
198
|
+
// attribute.
|
|
199
|
+
if (attrName === 'dangerouslySetInnerHTML') return ''
|
|
195
200
|
if (isBooleanAttr(attrName) || presenceOrUndefined) {
|
|
196
201
|
return `\${${valExpr} ? '${attrName}' : ''}`
|
|
197
202
|
}
|
|
198
203
|
if (attrName === 'style') {
|
|
199
|
-
return `\${((v) => v != null ? 'style="' + v + '"' : '')(styleToCss(${valExpr}))}`
|
|
204
|
+
return `\${((v) => v != null ? 'style="' + ${escapeAttrValueExpr('v')} + '"' : '')(styleToCss(${valExpr}))}`
|
|
200
205
|
}
|
|
201
206
|
// `data-key` / `data-key-N` is a reconciliation contract — every loop item
|
|
202
207
|
// must carry one. Emit unconditionally; if the user passes `key={undefined}`
|
|
@@ -205,7 +210,60 @@ function templateAttrExpr(attrName: string, valExpr: string, presenceOrUndefined
|
|
|
205
210
|
if (attrName === 'data-key' || attrName.startsWith('data-key-')) {
|
|
206
211
|
return `${attrName}="\${${valExpr}}"`
|
|
207
212
|
}
|
|
208
|
-
return `\${(${valExpr}) != null ? '${attrName}="' +
|
|
213
|
+
return `\${(${valExpr}) != null ? '${attrName}="' + ${escapeAttrValueExpr(valExpr)} + '"' : ''}`
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Build a runtime expression that HTML-escapes an interpolated attribute
|
|
218
|
+
* value, matching the SSR adapters' attribute escaping (Hono escapes
|
|
219
|
+
* `& " ' < >`) via the `escapeAttr` runtime helper. The client template
|
|
220
|
+
* assembles an HTML string inserted via `innerHTML`, so an unescaped `"`
|
|
221
|
+
* / `<` / `>` in a value — e.g. UnoCSS arbitrary variants like
|
|
222
|
+
* `[class*="size-"]` or `has-[>svg]` — corrupts attribute parsing (and
|
|
223
|
+
* diverges from the SSR-rendered bytes). Escaping at interpolation time
|
|
224
|
+
* is the only correct layer: a post-assembly pass can't tell a delimiter
|
|
225
|
+
* `"` from a value `"`.
|
|
226
|
+
*/
|
|
227
|
+
function escapeAttrValueExpr(valExpr: string): string {
|
|
228
|
+
return `escapeAttr(${valExpr})`
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Build a runtime expression that HTML-escapes an interpolated **text
|
|
233
|
+
* content** slot, via the `escapeText` runtime helper. Only the
|
|
234
|
+
* `<!--bf:sN-->${expr}<!--/-->` text-marker form is text content: the
|
|
235
|
+
* runtime treats whatever sits between the markers as the slot's text, so
|
|
236
|
+
* a string value containing `<` / `&` (e.g. `{user.name}`) must be escaped
|
|
237
|
+
* to parse correctly under `innerHTML` and to match the SSR-rendered
|
|
238
|
+
* bytes. Bare `${...}` interpolations — `{children}` passthrough and
|
|
239
|
+
* `renderChild(...)` output — are pre-rendered HTML and must NOT be
|
|
240
|
+
* escaped, so this is applied only at the four text-marker emit sites.
|
|
241
|
+
* Hono escapes text content with the same set as attribute values
|
|
242
|
+
* (`& " ' < >`), so `escapeText` delegates to the same operation.
|
|
243
|
+
*/
|
|
244
|
+
function escapeTextSlotExpr(innerExpr: string): string {
|
|
245
|
+
return `escapeText(${innerExpr})`
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* `dangerouslySetInnerHTML={{ __html: E }}` makes the element's content its
|
|
250
|
+
* raw innerHTML — the intentional, React-style escape hatch. Returns the
|
|
251
|
+
* raw-content template expression to use *instead of* the element's normal
|
|
252
|
+
* children (emitted UNescaped by design, mirroring the SSR adapters'
|
|
253
|
+
* native handling), or `null` when the element carries no such attribute.
|
|
254
|
+
* The attribute itself is suppressed in `templateAttrExpr`, and the
|
|
255
|
+
* matching reactive update is emitted by `emitAttrUpdate` (assigns
|
|
256
|
+
* `innerHTML`). `toExpr` is the walker's value transform (`wrapExpr` /
|
|
257
|
+
* `transformExpr`) so the `{ __html }` object is lowered the same way an
|
|
258
|
+
* attribute value would be.
|
|
259
|
+
*/
|
|
260
|
+
function dangerouslyHtmlChildren(
|
|
261
|
+
attrs: ReadonlyArray<IRAttribute>,
|
|
262
|
+
toExpr: (v: { expr: string; templateExpr?: string }) => string,
|
|
263
|
+
): string | null {
|
|
264
|
+
const attr = attrs.find(a => a.name === 'dangerouslySetInnerHTML')
|
|
265
|
+
if (!attr || attr.value.kind !== 'expression') return null
|
|
266
|
+
return `\${((${toExpr(attr.value)}) ?? {}).__html ?? ''}`
|
|
209
267
|
}
|
|
210
268
|
|
|
211
269
|
/**
|
|
@@ -322,6 +380,12 @@ export interface MergeContext {
|
|
|
322
380
|
function isMergeableAttr(a: IRAttribute, ctx: MergeContext): boolean {
|
|
323
381
|
if (ctx.honorClientOnly && a.clientOnly) return false
|
|
324
382
|
if (a.name === 'key') return false
|
|
383
|
+
// `dangerouslySetInnerHTML` is not an attribute — its `{ __html }` value
|
|
384
|
+
// becomes the element's raw innerHTML (emitted as content, set via
|
|
385
|
+
// `innerHTML` in init). Keep it out of the `spreadAttrs({...})` merge so
|
|
386
|
+
// it isn't serialised back into a bogus `dangerouslySetInnerHTML="…"`
|
|
387
|
+
// attribute when the element also carries a spread.
|
|
388
|
+
if (a.name === 'dangerouslySetInnerHTML') return false
|
|
325
389
|
const v = a.value
|
|
326
390
|
if (v.kind === 'jsx-children') return false
|
|
327
391
|
if (v.kind === 'boolean-shorthand') return false
|
|
@@ -476,7 +540,7 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
|
|
|
476
540
|
|
|
477
541
|
const attrs = attrParts.join(' ')
|
|
478
542
|
const childrenRecurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, insideLoop, false)
|
|
479
|
-
const children = node.children.map(childrenRecurse).join('')
|
|
543
|
+
const children = dangerouslyHtmlChildren(node.attrs, v => wrapExpr(v.expr)) ?? node.children.map(childrenRecurse).join('')
|
|
480
544
|
|
|
481
545
|
// Non-void elements must use open+close tags (HTML parsers ignore self-closing on div, span, etc.)
|
|
482
546
|
if (children || !VOID_ELEMENTS.has(node.tag)) {
|
|
@@ -491,7 +555,15 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
|
|
|
491
555
|
case 'expression':
|
|
492
556
|
if (node.expr === 'null' || node.expr === 'undefined') return ''
|
|
493
557
|
if (node.slotId) {
|
|
494
|
-
|
|
558
|
+
const inner = wrapInterpolation(wrapExpr(node.expr))
|
|
559
|
+
// In branch-slot context `wrapInterpolation` routes the value
|
|
560
|
+
// through `__bfSlot`, which returns raw `<!--bf-slot:N-->` markers
|
|
561
|
+
// for live `Node` values (spliced back by `insert()`). Escaping
|
|
562
|
+
// would corrupt those markers and drop slotted content (#1694
|
|
563
|
+
// regression). `__bfSlot` owns coercion of its own value, so the
|
|
564
|
+
// text-escape applies only to the non-slot (plain text) form.
|
|
565
|
+
const slotted = branchSlotsVar ? inner : escapeTextSlotExpr(inner)
|
|
566
|
+
return `<!--bf:${node.slotId}-->\${${slotted}}<!--/-->`
|
|
495
567
|
}
|
|
496
568
|
return `\${${wrapInterpolation(wrapExpr(node.expr))}}`
|
|
497
569
|
|
|
@@ -656,7 +728,7 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
|
|
|
656
728
|
}
|
|
657
729
|
|
|
658
730
|
const attrs = attrParts.join(' ')
|
|
659
|
-
const children = node.children.map(recurse).join('')
|
|
731
|
+
const children = dangerouslyHtmlChildren(node.attrs, v => wrapExpr(v.expr)) ?? node.children.map(recurse).join('')
|
|
660
732
|
|
|
661
733
|
if (children || !VOID_ELEMENTS.has(node.tag)) {
|
|
662
734
|
return `<${node.tag}${attrs ? ' ' + attrs : ''}>${children}</${node.tag}>`
|
|
@@ -670,7 +742,7 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
|
|
|
670
742
|
case 'expression':
|
|
671
743
|
if (node.expr === 'null' || node.expr === 'undefined') return ''
|
|
672
744
|
if (node.slotId) {
|
|
673
|
-
return `<!--bf:${node.slotId}-->\${${wrapExpr(node.expr)}}<!--/-->`
|
|
745
|
+
return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(wrapExpr(node.expr))}}<!--/-->`
|
|
674
746
|
}
|
|
675
747
|
return `\${${wrapExpr(node.expr)}}`
|
|
676
748
|
|
|
@@ -890,6 +962,20 @@ export interface TemplateOptions {
|
|
|
890
962
|
loopDepth?: number
|
|
891
963
|
/** Emit `bf-s` placeholder on scoped elements inside a jsx-children prop (#1320). */
|
|
892
964
|
inHoistedChildren?: boolean
|
|
965
|
+
/**
|
|
966
|
+
* Slot ids of direct child components whose render must be DEFERRED to
|
|
967
|
+
* init because at least one forwarded (non-`/* @client *\/`) prop value
|
|
968
|
+
* references an init-scope-only / non-inlinable local — the module-scope
|
|
969
|
+
* template lambda can't supply it, so eagerly calling `renderChild` with
|
|
970
|
+
* the prop dropped would make the child template read `undefined`.
|
|
971
|
+
*
|
|
972
|
+
* For these slots the CSR `component` case emits a `data-bf-ph`
|
|
973
|
+
* placeholder instead of `renderChild(...)`; the parent init replaces it
|
|
974
|
+
* via `upsertChild` (→ `createComponent` with the complete getter props).
|
|
975
|
+
* Computed up front by `computeDeferredChildSlots` so the init phase and
|
|
976
|
+
* the template phase agree on which children defer (dropped-prop fix).
|
|
977
|
+
*/
|
|
978
|
+
deferredChildSlots?: ReadonlySet<string>
|
|
893
979
|
}
|
|
894
980
|
|
|
895
981
|
/**
|
|
@@ -1041,7 +1127,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
|
|
|
1041
1127
|
}
|
|
1042
1128
|
|
|
1043
1129
|
const attrs = attrParts.join(' ')
|
|
1044
|
-
const children = node.children.map(childrenRecurse).join('')
|
|
1130
|
+
const children = dangerouslyHtmlChildren(node.attrs, v => transformExpr(v.expr, v.templateExpr)) ?? node.children.map(childrenRecurse).join('')
|
|
1045
1131
|
|
|
1046
1132
|
if (children || !VOID_ELEMENTS.has(node.tag)) {
|
|
1047
1133
|
return `<${node.tag}${attrs ? ' ' + attrs : ''}>${children}</${node.tag}>`
|
|
@@ -1055,7 +1141,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
|
|
|
1055
1141
|
case 'expression':
|
|
1056
1142
|
if (node.expr === 'null' || node.expr === 'undefined') return ''
|
|
1057
1143
|
if (node.slotId) {
|
|
1058
|
-
return `<!--bf:${node.slotId}-->\${${transformExpr(node.expr, node.templateExpr)}}<!--/-->`
|
|
1144
|
+
return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(transformExpr(node.expr, node.templateExpr))}}<!--/-->`
|
|
1059
1145
|
}
|
|
1060
1146
|
return `\${${transformExpr(node.expr, node.templateExpr)}}`
|
|
1061
1147
|
|
|
@@ -1281,6 +1367,7 @@ export function generateCsrTemplate(
|
|
|
1281
1367
|
restSpreadNames?: Set<string>,
|
|
1282
1368
|
propsObjectName?: string | null,
|
|
1283
1369
|
unsafeLocalNames?: Set<string>,
|
|
1370
|
+
deferredChildSlots?: ReadonlySet<string>,
|
|
1284
1371
|
): string {
|
|
1285
1372
|
// Build the substitution env once per component. Signals + memos come
|
|
1286
1373
|
// from `buildSignalMemoEnv`; inlinable constants layer in here so
|
|
@@ -1295,7 +1382,134 @@ export function generateCsrTemplate(
|
|
|
1295
1382
|
}
|
|
1296
1383
|
}
|
|
1297
1384
|
}
|
|
1298
|
-
return generateCsrTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, csrEnv, insideLoop, unsafeLocalNames, loopDepth: -1 })
|
|
1385
|
+
return generateCsrTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, csrEnv, insideLoop, unsafeLocalNames, deferredChildSlots, loopDepth: -1 })
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* Build the per-component CSR substitution env (signals + memos + inlinable
|
|
1390
|
+
* constants), matching what `generateCsrTemplate` builds. Shared so the
|
|
1391
|
+
* deferred-child analysis and the template emit agree on substitution
|
|
1392
|
+
* results.
|
|
1393
|
+
*/
|
|
1394
|
+
function buildCsrEnvForCtx(
|
|
1395
|
+
ctx: ClientJsContext,
|
|
1396
|
+
inlinableConstants: Map<string, string> | undefined,
|
|
1397
|
+
propsObjectName?: string | null,
|
|
1398
|
+
): CsrEnv {
|
|
1399
|
+
const base = buildSignalMemoEnv(ctx.signals, ctx.memos, propsObjectName ?? null)
|
|
1400
|
+
const csrEnv: CsrEnv = { substitutions: new Map(base.substitutions), propsObjectName: base.propsObjectName }
|
|
1401
|
+
if (inlinableConstants) {
|
|
1402
|
+
for (const [name, value] of inlinableConstants) {
|
|
1403
|
+
if (!csrEnv.substitutions.has(name)) {
|
|
1404
|
+
csrEnv.substitutions.set(name, { kind: 'identifier', replacement: value, freeIdentifiers: new Set() })
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
return csrEnv
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* Decide whether a single forwarded component prop value would be DROPPED
|
|
1413
|
+
* by the CSR `component` emit — i.e. after `csrSubstitute` its expression
|
|
1414
|
+
* still references a name in `unsafeLocalNames`. Mirrors the
|
|
1415
|
+
* `transformExpr` UNSAFE gate so the deferral analysis matches the actual
|
|
1416
|
+
* template output exactly.
|
|
1417
|
+
*/
|
|
1418
|
+
function propResolvesUnsafe(
|
|
1419
|
+
prop: IRProp,
|
|
1420
|
+
env: CsrEnv,
|
|
1421
|
+
unsafeLocalNames: ReadonlySet<string>,
|
|
1422
|
+
): boolean {
|
|
1423
|
+
if (unsafeLocalNames.size === 0) return false
|
|
1424
|
+
let source: string | undefined
|
|
1425
|
+
switch (prop.value.kind) {
|
|
1426
|
+
case 'expression':
|
|
1427
|
+
case 'spread':
|
|
1428
|
+
source = prop.value.expr
|
|
1429
|
+
break
|
|
1430
|
+
case 'template':
|
|
1431
|
+
source = attrValueToString(prop.value, { useTemplate: true }) ?? undefined
|
|
1432
|
+
break
|
|
1433
|
+
default:
|
|
1434
|
+
// literal / boolean / jsx-children carry no init-scope identifiers.
|
|
1435
|
+
return false
|
|
1436
|
+
}
|
|
1437
|
+
if (!source) return false
|
|
1438
|
+
const { freeIdentifiers } = csrSubstitute(source, env)
|
|
1439
|
+
return setIntersects(freeIdentifiers, unsafeLocalNames)
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Walk the component IR and collect the slot ids of DIRECT child
|
|
1444
|
+
* components whose render must be deferred to init because at least one
|
|
1445
|
+
* forwarded (non-`/* @client *\/`, non-event) prop resolves to an
|
|
1446
|
+
* init-scope-only / non-inlinable local. The module-scope CSR template
|
|
1447
|
+
* lambda can't supply such a value, so `renderChild(...)` would drop the
|
|
1448
|
+
* prop and the child template would read `undefined` and throw.
|
|
1449
|
+
*
|
|
1450
|
+
* Only top-level (non-loop, non-clientOnly-conditional) children are
|
|
1451
|
+
* considered — those are the ones rendered via the `renderChild(...)` form
|
|
1452
|
+
* in the registration template and wired through `ctx.childInits`. Loop /
|
|
1453
|
+
* conditional-branch children already go through their own
|
|
1454
|
+
* placeholder + `createComponent` materialize paths.
|
|
1455
|
+
*/
|
|
1456
|
+
export function computeDeferredChildSlots(
|
|
1457
|
+
node: IRNode,
|
|
1458
|
+
ctx: ClientJsContext,
|
|
1459
|
+
inlinableConstants: Map<string, string> | undefined,
|
|
1460
|
+
unsafeLocalNames: ReadonlySet<string> | undefined,
|
|
1461
|
+
propsObjectName?: string | null,
|
|
1462
|
+
): Set<string> {
|
|
1463
|
+
const deferred = new Set<string>()
|
|
1464
|
+
if (!unsafeLocalNames || unsafeLocalNames.size === 0) return deferred
|
|
1465
|
+
const env = buildCsrEnvForCtx(ctx, inlinableConstants, propsObjectName)
|
|
1466
|
+
|
|
1467
|
+
const visit = (n: IRNode): void => {
|
|
1468
|
+
switch (n.type) {
|
|
1469
|
+
case 'component': {
|
|
1470
|
+
if (n.name === 'Portal') {
|
|
1471
|
+
n.children.forEach(visit)
|
|
1472
|
+
return
|
|
1473
|
+
}
|
|
1474
|
+
if (n.slotId) {
|
|
1475
|
+
const dropped = n.props.some(p => {
|
|
1476
|
+
// Spread props (`...`) are forwarded via the rest-spread path
|
|
1477
|
+
// (`restSpreadNames`), not the per-prop inline form, so they are
|
|
1478
|
+
// out of scope for this drop check; `key` and event handlers
|
|
1479
|
+
// (`onX`) likewise never carry init-scope render values. This
|
|
1480
|
+
// filter set MUST mirror the `propsEntries` filter in the CSR
|
|
1481
|
+
// `component` emit below so the deferral decision matches output.
|
|
1482
|
+
if (p.name === '...' || p.name.startsWith('...') || p.name === 'key') return false
|
|
1483
|
+
if (p.name.startsWith('on') && p.name.length > 2 && p.name[2] === p.name[2].toUpperCase()) return false
|
|
1484
|
+
if (p.clientOnly) return false
|
|
1485
|
+
return propResolvesUnsafe(p, env, unsafeLocalNames)
|
|
1486
|
+
})
|
|
1487
|
+
if (dropped) deferred.add(n.slotId)
|
|
1488
|
+
}
|
|
1489
|
+
// Do not descend into a component's JSX-children props here: those
|
|
1490
|
+
// children render in the parent scope only when hoisted, and the
|
|
1491
|
+
// deferral concern is the direct child component's own props.
|
|
1492
|
+
return
|
|
1493
|
+
}
|
|
1494
|
+
case 'element':
|
|
1495
|
+
n.children.forEach(visit)
|
|
1496
|
+
return
|
|
1497
|
+
case 'fragment':
|
|
1498
|
+
n.children.forEach(visit)
|
|
1499
|
+
return
|
|
1500
|
+
case 'conditional':
|
|
1501
|
+
// Conditional branch children are handled by the branch
|
|
1502
|
+
// materialize path, not the top-level renderChild form.
|
|
1503
|
+
return
|
|
1504
|
+
case 'loop':
|
|
1505
|
+
// Loop children go through the loop materialize path.
|
|
1506
|
+
return
|
|
1507
|
+
default:
|
|
1508
|
+
return
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
visit(node)
|
|
1512
|
+
return deferred
|
|
1299
1513
|
}
|
|
1300
1514
|
|
|
1301
1515
|
function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): string {
|
|
@@ -1417,7 +1631,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
|
|
|
1417
1631
|
}
|
|
1418
1632
|
|
|
1419
1633
|
const attrs = attrParts.join(' ')
|
|
1420
|
-
const children = node.children.map(childrenRecurse).join('')
|
|
1634
|
+
const children = dangerouslyHtmlChildren(node.attrs, v => transformExpr(v.expr, v.templateExpr)) ?? node.children.map(childrenRecurse).join('')
|
|
1421
1635
|
|
|
1422
1636
|
if (children || !VOID_ELEMENTS.has(node.tag)) {
|
|
1423
1637
|
return `<${node.tag}${attrs ? ' ' + attrs : ''}>${children}</${node.tag}>`
|
|
@@ -1440,7 +1654,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
|
|
|
1440
1654
|
// an empty placeholder instead.
|
|
1441
1655
|
const expr = transformed === UNSAFE_TEMPLATE_EXPR ? "''" : transformed
|
|
1442
1656
|
if (node.slotId) {
|
|
1443
|
-
return `<!--bf:${node.slotId}-->\${${expr}}<!--/-->`
|
|
1657
|
+
return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(expr)}}<!--/-->`
|
|
1444
1658
|
}
|
|
1445
1659
|
return `\${${expr}}`
|
|
1446
1660
|
}
|
|
@@ -1470,6 +1684,18 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
|
|
|
1470
1684
|
return node.children.map(recurse).join('')
|
|
1471
1685
|
}
|
|
1472
1686
|
|
|
1687
|
+
// Deferred child (dropped-prop fix): at least one forwarded prop
|
|
1688
|
+
// resolves to an init-scope-only local the module-scope template
|
|
1689
|
+
// lambda can't supply. Emitting `renderChild('Child', { /* prop
|
|
1690
|
+
// dropped */ })` would make the child template read `undefined` and
|
|
1691
|
+
// throw. Emit a `data-bf-ph` placeholder instead — the parent init
|
|
1692
|
+
// resolves it via `upsertChild` → `createComponent` with the full
|
|
1693
|
+
// getter props (mirrors the `irToPlaceholderTemplate` deferral and
|
|
1694
|
+
// the clientOnly-conditional empty-marker precedent).
|
|
1695
|
+
if (node.slotId && opts.deferredChildSlots?.has(node.slotId)) {
|
|
1696
|
+
return `<div ${DATA_BF_PH}="${node.slotId}"></div>`
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1473
1699
|
const propsEntries = node.props
|
|
1474
1700
|
.filter(p => p.name !== '...' && !p.name.startsWith('...') && p.name !== 'key')
|
|
1475
1701
|
.filter(p => !(p.name.startsWith('on') && p.name.length > 2 && p.name[2] === p.name[2].toUpperCase()))
|
|
@@ -11,7 +11,7 @@ export const RUNTIME_IMPORT_CANDIDATES = [
|
|
|
11
11
|
'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild', 'updateClientMarker',
|
|
12
12
|
'createPortal',
|
|
13
13
|
'provideContext', 'createContext', 'useContext',
|
|
14
|
-
'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss',
|
|
14
|
+
'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss', 'escapeAttr', 'escapeText',
|
|
15
15
|
'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot', '__bfText',
|
|
16
16
|
] as const
|
|
17
17
|
|
|
@@ -28,8 +28,19 @@ export function emitProviderAndChildInits(lines: string[], ctx: ClientJsContext)
|
|
|
28
28
|
lines.push('')
|
|
29
29
|
lines.push(` // Initialize child components with props`)
|
|
30
30
|
for (const child of ctx.childInits) {
|
|
31
|
+
const registryName = nameForRegistryRef(child.name)
|
|
32
|
+
// Deferred child (dropped-prop fix): the registration template emits
|
|
33
|
+
// a `data-bf-ph` placeholder for this slot rather than rendering it.
|
|
34
|
+
// `upsertChild` resolves both shapes — an existing SSR scope (→
|
|
35
|
+
// initChild) or the placeholder (→ createComponent with the full
|
|
36
|
+
// getter props). Use it so the child is created/initialised with
|
|
37
|
+
// complete props instead of running against a missing prop.
|
|
38
|
+
if (child.slotId && ctx.deferredChildSlots.has(child.slotId)) {
|
|
39
|
+
lines.push(` upsertChild(__scope, '${registryName}', '${child.slotId}', ${child.propsExpr})`)
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
31
42
|
const scopeRef = child.slotId ? `_${varSlotId(child.slotId)}` : '__scope'
|
|
32
|
-
lines.push(` initChild('${
|
|
43
|
+
lines.push(` initChild('${registryName}', ${scopeRef}, ${child.propsExpr})`)
|
|
33
44
|
}
|
|
34
45
|
}
|
|
35
46
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import type { IRLoopChildComponent } from '../../types'
|
|
17
17
|
import type { NestedLoop, TopLevelLoop } from '../types'
|
|
18
18
|
import type { ClientJsContext } from '../types'
|
|
19
|
-
import { quotePropName, varSlotId, attrValueToString } from '../utils'
|
|
19
|
+
import { quotePropName, varSlotId, attrValueToString, buildLoopChildIndexExpr } from '../utils'
|
|
20
20
|
import { irChildrenToJsExpr } from '../html-template'
|
|
21
21
|
import { buildCompSelector } from '../control-flow/shared'
|
|
22
22
|
|
|
@@ -98,7 +98,7 @@ function buildOuterNestedPlan(
|
|
|
98
98
|
arrayExpr: elem.array,
|
|
99
99
|
param: elem.param,
|
|
100
100
|
indexParam,
|
|
101
|
-
offsetExpr:
|
|
101
|
+
offsetExpr: buildLoopChildIndexExpr(indexParam, elem.offset),
|
|
102
102
|
outerPreludeStatements: elem.mapPreamble ? [elem.mapPreamble] : [],
|
|
103
103
|
propsExpr: buildStaticPropsExpr(comp.props),
|
|
104
104
|
}
|
|
@@ -122,16 +122,12 @@ function buildInnerLoopNestedPlan(
|
|
|
122
122
|
outerArrayExpr: elem.array,
|
|
123
123
|
outerParam: elem.param,
|
|
124
124
|
outerIndexParam,
|
|
125
|
-
outerOffsetExpr: elem.
|
|
126
|
-
? `${outerIndexParam} + ${elem.siblingOffset}`
|
|
127
|
-
: outerIndexParam,
|
|
125
|
+
outerOffsetExpr: buildLoopChildIndexExpr(outerIndexParam, elem.offset),
|
|
128
126
|
outerPreludeStatements: elem.mapPreamble ? [elem.mapPreamble] : [],
|
|
129
127
|
innerContainerSlotId: innerLoop.containerSlotId ?? null,
|
|
130
128
|
innerArrayExpr: innerLoop.array,
|
|
131
129
|
innerParam: innerLoop.param,
|
|
132
|
-
innerOffsetExpr: innerLoop.
|
|
133
|
-
? `__innerIdx + ${innerLoop.siblingOffset}`
|
|
134
|
-
: '__innerIdx',
|
|
130
|
+
innerOffsetExpr: buildLoopChildIndexExpr('__innerIdx', innerLoop.offset),
|
|
135
131
|
innerPreludeStatements: innerLoop.mapPreamble ? [innerLoop.mapPreamble] : [],
|
|
136
132
|
depth: innerLoop.depth,
|
|
137
133
|
comps,
|
|
@@ -75,7 +75,7 @@ export interface OuterNestedInitPlan {
|
|
|
75
75
|
arrayExpr: string
|
|
76
76
|
param: string
|
|
77
77
|
indexParam: string
|
|
78
|
-
/** `indexParam`
|
|
78
|
+
/** `indexParam` plus any sibling-offset terms (`+ 1 + (arr).length`) — already substituted. */
|
|
79
79
|
offsetExpr: string
|
|
80
80
|
/**
|
|
81
81
|
* Outer `.map()` callback preamble locals (#1064), emitted inside the
|
|
@@ -99,7 +99,7 @@ export interface InnerLoopNestedInitPlan {
|
|
|
99
99
|
outerArrayExpr: string
|
|
100
100
|
outerParam: string
|
|
101
101
|
outerIndexParam: string
|
|
102
|
-
/** Outer offset — `outerIndexParam`
|
|
102
|
+
/** Outer offset — `outerIndexParam` plus any sibling-offset terms. */
|
|
103
103
|
outerOffsetExpr: string
|
|
104
104
|
/**
|
|
105
105
|
* Outer `.map()` callback preamble locals, emitted after the
|
|
@@ -115,7 +115,7 @@ export interface InnerLoopNestedInitPlan {
|
|
|
115
115
|
innerContainerSlotId: string | null
|
|
116
116
|
innerArrayExpr: string
|
|
117
117
|
innerParam: string
|
|
118
|
-
/** Inner offset — `__innerIdx`
|
|
118
|
+
/** Inner offset — `__innerIdx` plus any sibling-offset terms. */
|
|
119
119
|
innerOffsetExpr: string
|
|
120
120
|
/**
|
|
121
121
|
* Inner `.map()` callback preamble locals, emitted after the
|
|
@@ -59,6 +59,16 @@ export interface ClientJsContext {
|
|
|
59
59
|
loopElements: TopLevelLoop[]
|
|
60
60
|
refElements: RefElement[]
|
|
61
61
|
childInits: ChildInit[]
|
|
62
|
+
/**
|
|
63
|
+
* Slot ids of direct child components whose render is DEFERRED to init
|
|
64
|
+
* (dropped-prop fix). For these, the CSR registration template emits a
|
|
65
|
+
* `data-bf-ph` placeholder instead of `renderChild(...)`, and the init
|
|
66
|
+
* body uses `upsertChild` (→ `createComponent` with full getter props)
|
|
67
|
+
* instead of `initChild`. Computed in `generateInitFunction` once
|
|
68
|
+
* `unsafeLocalNames` is known, then read by the child-init phase and the
|
|
69
|
+
* registration-template emit so both agree on which children defer.
|
|
70
|
+
*/
|
|
71
|
+
deferredChildSlots: Set<string>
|
|
62
72
|
reactiveProps: ReactiveComponentProp[]
|
|
63
73
|
reactiveChildProps: ReactiveChildProp[]
|
|
64
74
|
reactiveAttrs: ReactiveAttribute[]
|
|
@@ -166,6 +176,29 @@ export interface ConditionalBranchReactiveAttr extends AttrMeta {
|
|
|
166
176
|
expression: string
|
|
167
177
|
}
|
|
168
178
|
|
|
179
|
+
/**
|
|
180
|
+
* How many element children precede a loop's items in its container — the
|
|
181
|
+
* offset applied to `container.children[idx]` so each item resolves to the
|
|
182
|
+
* right element during hydration.
|
|
183
|
+
*
|
|
184
|
+
* Two contributions, kept distinct because they codegen differently:
|
|
185
|
+
* - `staticCount`: siblings of statically-known element count, folded to a
|
|
186
|
+
* compile-time integer (`+ 1`).
|
|
187
|
+
* - `dynamicTerms`: one JS expression per sibling whose element count is
|
|
188
|
+
* only known at runtime — a preceding `.map()` (`(arr).length`) or a
|
|
189
|
+
* preceding conditional (`(cond ? 1 : 0)`) — each added as `+ <term>`.
|
|
190
|
+
*
|
|
191
|
+
* Carried as one value object end-to-end (collect → IR loop → plan → codegen)
|
|
192
|
+
* so a future offset contributor is added in one place, not threaded as a new
|
|
193
|
+
* field through every layer (#1693).
|
|
194
|
+
*/
|
|
195
|
+
export interface LoopOffset {
|
|
196
|
+
/** Folded element count of statically-sized preceding siblings. `0` = none. */
|
|
197
|
+
staticCount: number
|
|
198
|
+
/** Runtime element-count expressions of dynamic preceding siblings; `[]` = none. */
|
|
199
|
+
dynamicTerms: readonly string[]
|
|
200
|
+
}
|
|
201
|
+
|
|
169
202
|
/**
|
|
170
203
|
* Fields shared by every flavour of collected loop (top-level, branch-scoped, nested).
|
|
171
204
|
* The three loop-info variants (`TopLevelLoop`, `BranchLoop`, `NestedLoop`) each extend
|
|
@@ -363,8 +396,8 @@ export interface NestedLoop extends LoopCore {
|
|
|
363
396
|
childComponents?: import('../types').IRLoopChildComponent[]
|
|
364
397
|
/** True when this loop is inside a conditional branch (handled by insert() bindEvents instead) */
|
|
365
398
|
insideConditional?: boolean
|
|
366
|
-
/**
|
|
367
|
-
|
|
399
|
+
/** Offset of this loop's items past its preceding container siblings (#1693). */
|
|
400
|
+
offset?: LoopOffset
|
|
368
401
|
// Per-item bindings (events / reactiveAttrs / reactiveTexts / refs / conditionals)
|
|
369
402
|
// now live on `LoopCore.bindings` — see issue #1244 §B.
|
|
370
403
|
}
|
|
@@ -468,8 +501,8 @@ export interface TopLevelLoop extends LoopCore {
|
|
|
468
501
|
useElementReconciliation?: boolean // True: reconcileElements + composite rendering (native root with child components)
|
|
469
502
|
/** Inner loop metadata for composite element reconciliation (array, param, key, container) */
|
|
470
503
|
innerLoops?: NestedLoop[]
|
|
471
|
-
/**
|
|
472
|
-
|
|
504
|
+
/** Offset of this loop's items past its preceding container siblings (#1693). */
|
|
505
|
+
offset?: LoopOffset
|
|
473
506
|
filterPredicate?: {
|
|
474
507
|
param: string
|
|
475
508
|
raw: string // Original filter predicate expression or block body
|