@barefootjs/jsx 0.5.0 → 0.5.2
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/test-adapter.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts +8 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +250 -59
- package/dist/ir-to-client-js/collect-elements.d.ts +11 -1
- 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/loop.d.ts +14 -0
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +0 -14
- 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 +7 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +2 -2
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +310 -188
- package/src/__tests__/adapter-output.test.ts +49 -0
- package/src/__tests__/child-components-in-map.test.ts +43 -0
- package/src/__tests__/client-js-generation.test.ts +5 -2
- package/src/__tests__/inline-jsx-callback.test.ts +95 -0
- package/src/__tests__/ir-jsx-props.test.ts +5 -2
- package/src/__tests__/loop-item-conditional-codegen.test.ts +81 -0
- package/src/__tests__/map-logical-jsx-helper.test.ts +159 -0
- package/src/__tests__/missing-key-in-list.test.ts +49 -0
- package/src/__tests__/reactive-attrs-in-map.test.ts +41 -0
- package/src/adapters/test-adapter.ts +16 -1
- package/src/analyzer-context.ts +59 -13
- package/src/analyzer.ts +8 -0
- package/src/expression-parser.ts +16 -1
- package/src/index.ts +2 -0
- package/src/ir-to-client-js/collect-elements.ts +37 -15
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +17 -0
- package/src/ir-to-client-js/control-flow/plan/loop.ts +14 -0
- package/src/ir-to-client-js/control-flow/stringify/insert.ts +7 -2
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +60 -0
- package/src/ir-to-client-js/emit-reactive.ts +12 -3
- package/src/ir-to-client-js/html-template.ts +29 -3
- package/src/ir-to-client-js/imports.ts +2 -2
- package/src/ir-to-client-js/reactivity.ts +17 -1
- package/src/ir-to-client-js/types.ts +7 -0
- package/src/ir-to-client-js/utils.ts +2 -1
- package/src/jsx-to-ir.ts +161 -12
- package/src/preprocess-inline-jsx-callbacks.ts +28 -10
- package/src/scanner/__tests__/js-scanner.fuzz.test.ts +202 -0
- package/src/types.ts +18 -0
package/src/analyzer-context.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
CompilerError,
|
|
21
21
|
SourceLocation,
|
|
22
22
|
ParamInfo,
|
|
23
|
+
PropertyInfo,
|
|
23
24
|
ReactiveFactoryInfo,
|
|
24
25
|
} from './types'
|
|
25
26
|
import { type ExcludeRange, collectAllTypeRanges, reconstructWithoutTypes } from './strip-types'
|
|
@@ -301,6 +302,46 @@ export function getSourceLocation(
|
|
|
301
302
|
// Type Helpers
|
|
302
303
|
// =============================================================================
|
|
303
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Extract structured {@link PropertyInfo} entries from the members of an object
|
|
307
|
+
* type literal or interface declaration. Shared so the type-literal branch of
|
|
308
|
+
* {@link typeNodeToTypeInfo} and interface-definition collection produce
|
|
309
|
+
* identical field shapes.
|
|
310
|
+
*/
|
|
311
|
+
export function membersToProperties(
|
|
312
|
+
members: ts.NodeArray<ts.TypeElement>,
|
|
313
|
+
sourceFile: ts.SourceFile
|
|
314
|
+
): PropertyInfo[] {
|
|
315
|
+
return members
|
|
316
|
+
.filter(ts.isPropertySignature)
|
|
317
|
+
.map((member) => ({
|
|
318
|
+
name: propertyNameText(member.name, sourceFile),
|
|
319
|
+
type: typeNodeToTypeInfo(member.type, sourceFile) ?? {
|
|
320
|
+
kind: 'unknown' as const,
|
|
321
|
+
raw: 'unknown',
|
|
322
|
+
},
|
|
323
|
+
optional: !!member.questionToken,
|
|
324
|
+
readonly: !!member.modifiers?.some(
|
|
325
|
+
(m) => m.kind === ts.SyntaxKind.ReadonlyKeyword
|
|
326
|
+
),
|
|
327
|
+
}))
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The source-level name of an object/interface member. String- and numeric-
|
|
332
|
+
* literal keys are returned unquoted (`{ "id": ... }` → `id`), so consumers see
|
|
333
|
+
* the same name whether the key was written as an identifier or a string —
|
|
334
|
+
* `getText()` would otherwise keep the quotes.
|
|
335
|
+
*/
|
|
336
|
+
function propertyNameText(
|
|
337
|
+
name: ts.PropertyName | undefined,
|
|
338
|
+
sourceFile: ts.SourceFile
|
|
339
|
+
): string {
|
|
340
|
+
if (!name) return ''
|
|
341
|
+
if (ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text
|
|
342
|
+
return name.getText(sourceFile)
|
|
343
|
+
}
|
|
344
|
+
|
|
304
345
|
export function typeNodeToTypeInfo(
|
|
305
346
|
typeNode: ts.TypeNode | undefined,
|
|
306
347
|
sourceFile: ts.SourceFile
|
|
@@ -352,24 +393,29 @@ export function typeNodeToTypeInfo(
|
|
|
352
393
|
return {
|
|
353
394
|
kind: 'object',
|
|
354
395
|
raw,
|
|
355
|
-
properties: typeNode.members
|
|
356
|
-
.filter(ts.isPropertySignature)
|
|
357
|
-
.map((member) => ({
|
|
358
|
-
name: member.name?.getText(sourceFile) ?? '',
|
|
359
|
-
type: typeNodeToTypeInfo(member.type, sourceFile) ?? {
|
|
360
|
-
kind: 'unknown',
|
|
361
|
-
raw: 'unknown',
|
|
362
|
-
},
|
|
363
|
-
optional: !!member.questionToken,
|
|
364
|
-
readonly: !!member.modifiers?.some(
|
|
365
|
-
(m) => m.kind === ts.SyntaxKind.ReadonlyKeyword
|
|
366
|
-
),
|
|
367
|
-
})),
|
|
396
|
+
properties: membersToProperties(typeNode.members, sourceFile),
|
|
368
397
|
}
|
|
369
398
|
}
|
|
370
399
|
|
|
371
400
|
// Type reference (named type)
|
|
372
401
|
if (ts.isTypeReferenceNode(typeNode)) {
|
|
402
|
+
// Normalise the generic array forms `Array<T>` / `ReadonlyArray<T>` to the
|
|
403
|
+
// same `kind: 'array'` shape as `T[]`, so every consumer sees one array
|
|
404
|
+
// representation regardless of how the source spelled it.
|
|
405
|
+
const refName = ts.isIdentifier(typeNode.typeName) ? typeNode.typeName.text : ''
|
|
406
|
+
if (
|
|
407
|
+
(refName === 'Array' || refName === 'ReadonlyArray') &&
|
|
408
|
+
typeNode.typeArguments?.length === 1
|
|
409
|
+
) {
|
|
410
|
+
return {
|
|
411
|
+
kind: 'array',
|
|
412
|
+
raw,
|
|
413
|
+
elementType: typeNodeToTypeInfo(typeNode.typeArguments[0], sourceFile) ?? {
|
|
414
|
+
kind: 'unknown',
|
|
415
|
+
raw: 'unknown',
|
|
416
|
+
},
|
|
417
|
+
}
|
|
418
|
+
}
|
|
373
419
|
return {
|
|
374
420
|
kind: 'interface',
|
|
375
421
|
raw,
|
package/src/analyzer.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
createAnalyzerContext,
|
|
17
17
|
getSourceLocation,
|
|
18
18
|
typeNodeToTypeInfo,
|
|
19
|
+
membersToProperties,
|
|
19
20
|
isComponentFunction,
|
|
20
21
|
isArrowComponentFunction,
|
|
21
22
|
} from './analyzer-context'
|
|
@@ -1782,6 +1783,7 @@ function collectInterfaceDefinition(
|
|
|
1782
1783
|
kind: 'interface',
|
|
1783
1784
|
name: node.name.text,
|
|
1784
1785
|
definition: node.getText(ctx.sourceFile),
|
|
1786
|
+
properties: membersToProperties(node.members, ctx.sourceFile),
|
|
1785
1787
|
loc: getSourceLocation(node, ctx.sourceFile, ctx.filePath),
|
|
1786
1788
|
})
|
|
1787
1789
|
}
|
|
@@ -1790,10 +1792,16 @@ function collectTypeAliasDefinition(
|
|
|
1790
1792
|
node: ts.TypeAliasDeclaration,
|
|
1791
1793
|
ctx: AnalyzerContext
|
|
1792
1794
|
): void {
|
|
1795
|
+
// Only object-type aliases carry structured fields; other aliases
|
|
1796
|
+
// (string-literal unions, etc.) have no field set to record.
|
|
1797
|
+
const properties = ts.isTypeLiteralNode(node.type)
|
|
1798
|
+
? membersToProperties(node.type.members, ctx.sourceFile)
|
|
1799
|
+
: undefined
|
|
1793
1800
|
ctx.typeDefinitions.push({
|
|
1794
1801
|
kind: 'type',
|
|
1795
1802
|
name: node.name.text,
|
|
1796
1803
|
definition: node.getText(ctx.sourceFile),
|
|
1804
|
+
properties,
|
|
1797
1805
|
loc: getSourceLocation(node, ctx.sourceFile, ctx.filePath),
|
|
1798
1806
|
})
|
|
1799
1807
|
}
|
package/src/expression-parser.ts
CHANGED
|
@@ -214,6 +214,21 @@ const UNSUPPORTED_METHODS = new Set([
|
|
|
214
214
|
// `bf_lower` / `bf_upper` (Go) and Perl's native `lc` / `uc` (Mojo).
|
|
215
215
|
// `trim` lowers via the `array-method` IR + `bf_trim` (Go) and a
|
|
216
216
|
// Perl regex strip (Mojo).
|
|
217
|
+
//
|
|
218
|
+
// #1448 follow-up — String methods that have NO lowering yet. These
|
|
219
|
+
// were previously absent from this gate, so `isSupported` reported
|
|
220
|
+
// them "supported" and the adapters emitted a raw method call
|
|
221
|
+
// (`{{.Name.StartsWith "a"}}` on Go, `$name->{startsWith}('a')` on
|
|
222
|
+
// Mojo) with no build diagnostic — a silent footgun that only
|
|
223
|
+
// surfaced as a crash at template-render time. Listing them here
|
|
224
|
+
// makes the build fail loudly with BF101 (the same treatment the
|
|
225
|
+
// unsupported array methods above get), pointing users at the
|
|
226
|
+
// `/* @client */` escape hatch. Each name drops off as its lowering
|
|
227
|
+
// lands. See #1448 "Unsupported string methods" Tier B / Tier C.
|
|
228
|
+
'split', 'startsWith', 'endsWith', 'replace', 'replaceAll',
|
|
229
|
+
'repeat', 'padStart', 'padEnd',
|
|
230
|
+
'charAt', 'charCodeAt', 'codePointAt', 'normalize',
|
|
231
|
+
'substring', 'substr', 'match', 'matchAll', 'search',
|
|
217
232
|
])
|
|
218
233
|
|
|
219
234
|
// =============================================================================
|
|
@@ -1724,7 +1739,7 @@ function checkSupport(expr: ParsedExpr): SupportResult {
|
|
|
1724
1739
|
return {
|
|
1725
1740
|
supported: false,
|
|
1726
1741
|
level: 'L5_UNSUPPORTED',
|
|
1727
|
-
reason: `
|
|
1742
|
+
reason: `Method '${methodName}()' has no template lowering and requires client-side evaluation. Wrap the expression in /* @client */ to defer it to hydration, or pre-compute the value before rendering.`,
|
|
1728
1743
|
}
|
|
1729
1744
|
}
|
|
1730
1745
|
}
|
package/src/index.ts
CHANGED
|
@@ -20,31 +20,50 @@ function producesDomChild(node: IRNode): boolean {
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Pre-pass: for every loop node in the IR tree, record the number of non-loop
|
|
23
|
-
* DOM siblings that appear before it in its parent
|
|
23
|
+
* DOM siblings that appear before it in its parent container. Read when
|
|
24
24
|
* constructing TopLevelLoop and NestedLoop so the client JS can offset
|
|
25
25
|
* children[idx] access past statically-rendered siblings.
|
|
26
26
|
*
|
|
27
|
+
* Counting must happen for every container whose children render as a
|
|
28
|
+
* contiguous run of DOM siblings into the same parent — not just `element`.
|
|
29
|
+
* A loop nested directly inside a component (`<Wrapper><span/>{xs.map(...)}`
|
|
30
|
+
* </Wrapper>`), fragment, provider, or async boundary has its preceding
|
|
31
|
+
* static sibling rendered as a sibling of the loop's items too, so
|
|
32
|
+
* `children[idx]` access is shifted exactly as it is under an element parent
|
|
33
|
+
* (#1688). Before this, a static sibling before a `.map()` inside a
|
|
34
|
+
* (self-portaling) component dropped the first item's nested child component
|
|
35
|
+
* during hydration because the offset was silently zero.
|
|
36
|
+
*
|
|
27
37
|
* Computed once up front (instead of during collection) so the offset data
|
|
28
38
|
* lives in an explicit value rather than a module-level WeakMap mutated by
|
|
29
39
|
* two separate traversals.
|
|
30
40
|
*/
|
|
31
41
|
export function computeLoopSiblingOffsets(root: IRNode): Map<IRLoop, number> {
|
|
32
42
|
const offsets = new Map<IRLoop, number>()
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
nonLoopCount++
|
|
41
|
-
}
|
|
43
|
+
const recordChildren = (children: IRNode[]): void => {
|
|
44
|
+
let nonLoopCount = 0
|
|
45
|
+
for (const child of children) {
|
|
46
|
+
if (child.type === 'loop') {
|
|
47
|
+
if (nonLoopCount > 0) offsets.set(child, nonLoopCount)
|
|
48
|
+
} else if (producesDomChild(child)) {
|
|
49
|
+
nonLoopCount++
|
|
42
50
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const containerVisit = ({ node, descend }: { node: { children: IRNode[] }; descend: () => void }): void => {
|
|
54
|
+
recordChildren(node.children)
|
|
55
|
+
descend()
|
|
56
|
+
}
|
|
57
|
+
walkIR(root, null, {
|
|
58
|
+
element: containerVisit,
|
|
59
|
+
component: containerVisit,
|
|
60
|
+
fragment: containerVisit,
|
|
61
|
+
provider: containerVisit,
|
|
62
|
+
async: containerVisit,
|
|
63
|
+
// `loop` / `conditional` / `if-statement` are not flat sibling
|
|
64
|
+
// containers (their children are item bodies / branches), and leaves
|
|
65
|
+
// (text / expression / slot) have no children — all rely on walkIR's
|
|
66
|
+
// default descent with the same scope.
|
|
48
67
|
})
|
|
49
68
|
return offsets
|
|
50
69
|
}
|
|
@@ -231,6 +250,7 @@ export function collectInnerLoops(
|
|
|
231
250
|
key: n.key,
|
|
232
251
|
markerId: n.markerId,
|
|
233
252
|
bodyIsMultiRoot: n.bodyIsMultiRoot,
|
|
253
|
+
bodyIsItemConditional: n.bodyIsItemConditional,
|
|
234
254
|
iterationShape: n.iterationShape,
|
|
235
255
|
containerSlotId: scope.parentSlotId,
|
|
236
256
|
template,
|
|
@@ -564,6 +584,7 @@ export function collectElements(
|
|
|
564
584
|
key: l.key,
|
|
565
585
|
markerId: l.markerId,
|
|
566
586
|
bodyIsMultiRoot: l.bodyIsMultiRoot,
|
|
587
|
+
bodyIsItemConditional: l.bodyIsItemConditional,
|
|
567
588
|
iterationShape: l.iterationShape,
|
|
568
589
|
template,
|
|
569
590
|
staticItemTemplate,
|
|
@@ -898,6 +919,7 @@ function collectBranchLoops(
|
|
|
898
919
|
key: n.key,
|
|
899
920
|
markerId: n.markerId,
|
|
900
921
|
bodyIsMultiRoot: n.bodyIsMultiRoot,
|
|
922
|
+
bodyIsItemConditional: n.bodyIsItemConditional,
|
|
901
923
|
iterationShape: n.iterationShape,
|
|
902
924
|
template: childTemplate,
|
|
903
925
|
containerSlotId: containerSlot,
|
|
@@ -56,6 +56,15 @@ export interface BuildLoopPlanOptions {
|
|
|
56
56
|
* described above and returns the discriminated `LoopPlan`.
|
|
57
57
|
*/
|
|
58
58
|
export function buildLoopPlan(elem: TopLevelLoop, opts: BuildLoopPlanOptions): LoopPlan {
|
|
59
|
+
// Whole-item conditional bodies (#1665) render 0-or-1 element per item, so
|
|
60
|
+
// they need anchored `mapArrayAnchored` emission regardless of whether the
|
|
61
|
+
// array is static or dynamic. Routing both through the plain (anchored)
|
|
62
|
+
// path keeps `const arr` and `signal()` behaviour identical — a static
|
|
63
|
+
// array's per-item conditional still toggles reactively instead of freezing
|
|
64
|
+
// in the SSR-time `forEach` (which has no conditional handling at all).
|
|
65
|
+
if (elem.bodyIsItemConditional) {
|
|
66
|
+
return buildPlainLoopPlan(elem)
|
|
67
|
+
}
|
|
59
68
|
if (elem.isStaticArray) {
|
|
60
69
|
return buildStaticLoopPlan(elem, opts.unsafeLocalNames)
|
|
61
70
|
}
|
|
@@ -92,6 +101,14 @@ export function buildPlainLoopPlan(elem: TopLevelLoop): PlainLoopPlan {
|
|
|
92
101
|
reactiveEffects: hasReactive ? buildLoopReactiveEffectsPlan(elem) : null,
|
|
93
102
|
childRefs: buildChildRefBindings(elem.bindings.refs, elem.param, elem.paramBindings),
|
|
94
103
|
bodyIsMultiRoot: elem.bodyIsMultiRoot ?? false,
|
|
104
|
+
anchored: elem.bodyIsItemConditional ?? false,
|
|
105
|
+
// Fall back to the iteration index when the loop has no key. A whole-item
|
|
106
|
+
// conditional without a key is a BF023 error, but the emitted client JS
|
|
107
|
+
// must still parse — an empty `anchorKeyExpr` would produce
|
|
108
|
+
// `createComment(`bf-loop-i:${}`)` (a SyntaxError that breaks the whole
|
|
109
|
+
// bundle). `elem.index || '__idx'` matches `indexParam` above, so the
|
|
110
|
+
// anchor value stays consistent with the renderItem's own index param.
|
|
111
|
+
anchorKeyExpr: elem.key ? wrap(elem.key) : (elem.index || '__idx'),
|
|
95
112
|
}
|
|
96
113
|
}
|
|
97
114
|
|
|
@@ -95,6 +95,20 @@ interface PlainLoopVariant extends DynamicLoopCommon {
|
|
|
95
95
|
* `<!--bf-loop-i-->` marker emission, and `qsaItem` slot lookups (#1212).
|
|
96
96
|
*/
|
|
97
97
|
bodyIsMultiRoot: boolean
|
|
98
|
+
/**
|
|
99
|
+
* True when the loop body is a whole-item conditional (#1665). Switches
|
|
100
|
+
* emission to `mapArrayAnchored`: the renderItem returns a fragment headed
|
|
101
|
+
* by a `<!--bf-loop-i:KEY-->` anchor and seeded with the conditional's
|
|
102
|
+
* markers, and `insert(anchor, …)` (not `insert(__el, …)`) owns the
|
|
103
|
+
* possibly-empty content.
|
|
104
|
+
*/
|
|
105
|
+
anchored: boolean
|
|
106
|
+
/**
|
|
107
|
+
* Key expression wrapped as a loop-param accessor (`t().id`), used to bake
|
|
108
|
+
* the per-item `bf-loop-i:KEY` anchor value inside the anchored renderItem.
|
|
109
|
+
* Empty when the loop has no key (only meaningful when `anchored`).
|
|
110
|
+
*/
|
|
111
|
+
anchorKeyExpr: string
|
|
98
112
|
}
|
|
99
113
|
|
|
100
114
|
/**
|
|
@@ -163,10 +163,15 @@ function emitArmBody(
|
|
|
163
163
|
|
|
164
164
|
for (const te of body.textEffects) {
|
|
165
165
|
const v = varSlotId(te.slotId)
|
|
166
|
-
|
|
166
|
+
// Route through `__bfText` so a JSX-valued expression (`{cond && logo(id)}`)
|
|
167
|
+
// re-splices the live element by identity instead of stringifying it to
|
|
168
|
+
// "[object HTMLElement]" — the branch template already spliced it via
|
|
169
|
+
// `__bfSlot`, and this effect re-renders it when its deps change (#1663).
|
|
170
|
+
// The `let` tracker carries the replaced node across reactive re-runs.
|
|
171
|
+
lines.push(`${indent}let __anchor_${v} = $t(__branchScope, '${te.slotId}')[0]`)
|
|
167
172
|
lines.push(`${indent}__disposers.push(createDisposableEffect(() => {`)
|
|
168
173
|
lines.push(`${indent} const __val = ${te.expression}`)
|
|
169
|
-
lines.push(`${indent}
|
|
174
|
+
lines.push(`${indent} __anchor_${v} = __bfText(__anchor_${v}, __val)`)
|
|
170
175
|
lines.push(`${indent}}))`)
|
|
171
176
|
}
|
|
172
177
|
|
|
@@ -120,8 +120,19 @@ export function stringifyPlainLoop(
|
|
|
120
120
|
reactiveEffects,
|
|
121
121
|
childRefs,
|
|
122
122
|
bodyIsMultiRoot,
|
|
123
|
+
anchored,
|
|
124
|
+
anchorKeyExpr,
|
|
123
125
|
} = plan
|
|
124
126
|
|
|
127
|
+
// Whole-item conditional loops (#1665) render 0-or-1 element per item, so
|
|
128
|
+
// they route through `mapArrayAnchored`. The renderItem returns a fragment
|
|
129
|
+
// headed by a `<!--bf-loop-i:KEY-->` anchor and seeded with the
|
|
130
|
+
// conditional's markers; `insert(__anchor, …)` then owns the content.
|
|
131
|
+
if (anchored) {
|
|
132
|
+
stringifyAnchoredLoop(lines, plan, topIndent, anchorKeyExpr)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
125
136
|
// `childRefs` need `__el` as a handle to invoke the user's callback inside
|
|
126
137
|
// the factory, so non-empty refs force the multi-line layout the same way
|
|
127
138
|
// reactive effects do (#1244).
|
|
@@ -155,6 +166,55 @@ export function stringifyPlainLoop(
|
|
|
155
166
|
lines.push(`${topIndent}}, '${markerId}')`)
|
|
156
167
|
}
|
|
157
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Emit a whole-item conditional loop via `mapArrayAnchored` (#1665).
|
|
171
|
+
*
|
|
172
|
+
* The renderItem identifies each item by an always-present
|
|
173
|
+
* `<!--bf-loop-i:KEY-->` anchor instead of a root element (which the item may
|
|
174
|
+
* not have). On CSR it returns a `DocumentFragment` of
|
|
175
|
+
* `[anchor, bf-cond-start, bf-cond-end]` so `insert()`'s first run has the
|
|
176
|
+
* markers to populate; on hydration (`__existing` is the SSR anchor Comment)
|
|
177
|
+
* it returns that anchor and `insert()` adopts the SSR-rendered content. The
|
|
178
|
+
* conditional itself is emitted by the shared reactive-effects stringifier
|
|
179
|
+
* with `elVar: '__anchor'`, so `insert(__anchor, …)` range-scopes the
|
|
180
|
+
* toggle to this item.
|
|
181
|
+
*/
|
|
182
|
+
function stringifyAnchoredLoop(
|
|
183
|
+
lines: string[],
|
|
184
|
+
plan: PlainLoopPlan,
|
|
185
|
+
topIndent: string,
|
|
186
|
+
anchorKeyExpr: string,
|
|
187
|
+
): void {
|
|
188
|
+
const {
|
|
189
|
+
containerVar, markerId, arrayExpr, keyFn,
|
|
190
|
+
paramHead, paramUnwrap, indexParam, mapPreambleWrapped, reactiveEffects,
|
|
191
|
+
} = plan
|
|
192
|
+
|
|
193
|
+
// The single whole-item conditional supplies the slot id used to seed the
|
|
194
|
+
// CSR markers so `insert()`'s first run can find and populate them.
|
|
195
|
+
const condSlot = reactiveEffects?.conditionals[0]?.slotId ?? null
|
|
196
|
+
|
|
197
|
+
lines.push(`${topIndent}mapArrayAnchored(() => ${arrayExpr}, ${containerVar}, ${keyFn}, (${paramHead}, ${indexParam}, __existing) => {`)
|
|
198
|
+
const bodyIndent = topIndent + ' '
|
|
199
|
+
if (paramUnwrap) lines.push(`${bodyIndent}${paramUnwrap}`)
|
|
200
|
+
if (mapPreambleWrapped) lines.push(`${bodyIndent}${mapPreambleWrapped}`)
|
|
201
|
+
lines.push(`${bodyIndent}const __anchor = __existing ?? document.createComment(\`bf-loop-i:\${${anchorKeyExpr}}\`)`)
|
|
202
|
+
lines.push(`${bodyIndent}let __frag = null`)
|
|
203
|
+
lines.push(`${bodyIndent}if (!__existing) {`)
|
|
204
|
+
lines.push(`${bodyIndent} __frag = document.createDocumentFragment()`)
|
|
205
|
+
lines.push(`${bodyIndent} __frag.appendChild(__anchor)`)
|
|
206
|
+
if (condSlot) {
|
|
207
|
+
lines.push(`${bodyIndent} __frag.appendChild(document.createComment('bf-cond-start:${condSlot}'))`)
|
|
208
|
+
lines.push(`${bodyIndent} __frag.appendChild(document.createComment('bf-cond-end:${condSlot}'))`)
|
|
209
|
+
}
|
|
210
|
+
lines.push(`${bodyIndent}}`)
|
|
211
|
+
if (reactiveEffects !== null) {
|
|
212
|
+
stringifyReactiveEffects(lines, reactiveEffects, { indent: bodyIndent, elVar: '__anchor', bodyIsMultiRoot: false })
|
|
213
|
+
}
|
|
214
|
+
lines.push(`${bodyIndent}return __frag ?? __anchor`)
|
|
215
|
+
lines.push(`${topIndent}}, '${markerId}')`)
|
|
216
|
+
}
|
|
217
|
+
|
|
158
218
|
export function stringifyStaticLoop(lines: string[], plan: StaticLoopPlan): void {
|
|
159
219
|
const { containerVar, arrayExpr, param, indexParam, childIndexExpr, attrsBySlot, texts, childRefs, csrMaterialize } = plan
|
|
160
220
|
const hasAttrs = attrsBySlot.length > 0
|
|
@@ -92,18 +92,27 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
|
|
|
92
92
|
const normalElems = elems.filter(e => !e.insideConditional)
|
|
93
93
|
|
|
94
94
|
if (normalElems.length > 0 || conditionalElems.length > 0) {
|
|
95
|
+
// Persistent slot trackers for non-conditional elements. `__bfText`
|
|
96
|
+
// returns the node now occupying the slot; a JSX-valued expression
|
|
97
|
+
// (`{themeLogo(id)}`) replaces the text node with a live element, so
|
|
98
|
+
// the next reactive run must operate on that element, not the stale
|
|
99
|
+
// text node (#1663). Primitive values keep the same text node.
|
|
100
|
+
for (const elem of normalElems) {
|
|
101
|
+
const v = varSlotId(elem.slotId)
|
|
102
|
+
lines.push(` let __anchor_${v} = _${v}`)
|
|
103
|
+
}
|
|
95
104
|
lines.push(` createEffect(() => {`)
|
|
96
105
|
if (normalElems.length > 0) {
|
|
97
106
|
// Expression is always evaluated for non-conditional elements
|
|
98
107
|
lines.push(` const __val = ${expr}`)
|
|
99
108
|
for (const elem of normalElems) {
|
|
100
109
|
const v = varSlotId(elem.slotId)
|
|
101
|
-
lines.push(`
|
|
110
|
+
lines.push(` __anchor_${v} = __bfText(__anchor_${v}, __val)`)
|
|
102
111
|
}
|
|
103
112
|
for (const elem of conditionalElems) {
|
|
104
113
|
const v = varSlotId(elem.slotId)
|
|
105
114
|
lines.push(` const [__el_${v}] = $t(__scope, '${elem.slotId}')`)
|
|
106
|
-
lines.push(`
|
|
115
|
+
lines.push(` __bfText(__el_${v}, __val)`)
|
|
107
116
|
}
|
|
108
117
|
} else {
|
|
109
118
|
// Only conditional elements — evaluate expression unconditionally
|
|
@@ -118,7 +127,7 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
|
|
|
118
127
|
for (const elem of conditionalElems) {
|
|
119
128
|
const v = varSlotId(elem.slotId)
|
|
120
129
|
lines.push(` const [__el_${v}] = $t(__scope, '${elem.slotId}')`)
|
|
121
|
-
lines.push(`
|
|
130
|
+
lines.push(` __bfText(__el_${v}, __val)`)
|
|
122
131
|
}
|
|
123
132
|
}
|
|
124
133
|
lines.push(` })`)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { AttrValue, IRAttribute, IRNode } from '../types'
|
|
6
6
|
import { isBooleanAttr } from '../html-constants'
|
|
7
|
-
import { toHtmlAttrName, attrValueToString, quotePropName, PROPS_PARAM, DATA_BF_PH, keyAttrName, loopStartMarker, loopEndMarker, freeIdsFromRefs, setIntersects, wrapExprWithLoopParams } from './utils'
|
|
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'
|
|
9
9
|
import { nameForRegistryRef } from './component-scope'
|
|
10
10
|
import { assertNever } from './walker'
|
|
@@ -411,6 +411,17 @@ function buildSpreadAttrsMergeCall(args: {
|
|
|
411
411
|
* `generateCsrTemplate` (case `'component'`). Set to `true` when generating
|
|
412
412
|
* the per-iteration `staticItemTemplate` for static loops.
|
|
413
413
|
*/
|
|
414
|
+
/**
|
|
415
|
+
* Build the per-item `<!--bf-loop-i:KEY-->` anchor comment for a whole-item
|
|
416
|
+
* conditional loop (#1665), where `keyExpr` is the loop's per-item key
|
|
417
|
+
* expression (e.g. `t.id`). Emits a live `${keyExpr}` interpolation so each
|
|
418
|
+
* rendered item carries its own key — `loopItemMarker` is reserved for
|
|
419
|
+
* already-evaluated key strings (runtime / static contexts).
|
|
420
|
+
*/
|
|
421
|
+
function itemAnchorTemplate(keyExpr: string): string {
|
|
422
|
+
return `<!--${loopItemMarker('${' + keyExpr + '}')}-->`
|
|
423
|
+
}
|
|
424
|
+
|
|
414
425
|
export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>, branchSlotsVar?: string, insideLoop = false, inHoistedChildren = false): string {
|
|
415
426
|
const recurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, insideLoop, inHoistedChildren)
|
|
416
427
|
const wrapExpr = (expr: string) => wrapExprWithLoopParams(expr, loopParams)
|
|
@@ -558,7 +569,16 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
|
|
|
558
569
|
// Case 1 — childComponent body materialize); propagating it through
|
|
559
570
|
// every nested loop regressed form-builder's inner-loop Select wiring.
|
|
560
571
|
const innerRecurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop)
|
|
561
|
-
|
|
572
|
+
let childTemplate = node.children.map(innerRecurse).join('')
|
|
573
|
+
// Whole-item conditional loops (#1665): prepend an always-present
|
|
574
|
+
// `<!--bf-loop-i:KEY-->` anchor before each item's (possibly empty)
|
|
575
|
+
// conditional content. `mapArrayAnchored` tracks items by this anchor,
|
|
576
|
+
// so an item that renders no element still keeps its identity and slot.
|
|
577
|
+
// The key is a per-item expression, so the marker carries a live
|
|
578
|
+
// `${KEY}` interpolation (not the literal key text).
|
|
579
|
+
if (node.bodyIsItemConditional && node.key) {
|
|
580
|
+
childTemplate = `${itemAnchorTemplate(node.key)}${childTemplate}`
|
|
581
|
+
}
|
|
562
582
|
const indexParam = node.index ? `, ${node.index}` : ''
|
|
563
583
|
// Apply chained sort / filter for the SSR-mirror template (#1448
|
|
564
584
|
// Tier B). Pre-Tier-B this just used `node.array` directly,
|
|
@@ -1499,7 +1519,13 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
|
|
|
1499
1519
|
}
|
|
1500
1520
|
|
|
1501
1521
|
case 'loop': {
|
|
1502
|
-
|
|
1522
|
+
let childTemplate = node.children.map(recurseInLoop).join('')
|
|
1523
|
+
// Whole-item conditional loops (#1665): prepend the per-item
|
|
1524
|
+
// `<!--bf-loop-i:KEY-->` anchor so `mapArrayAnchored` can track items
|
|
1525
|
+
// that render no element. Mirrors the `irToHtmlTemplate` loop case.
|
|
1526
|
+
if (node.bodyIsItemConditional && node.key) {
|
|
1527
|
+
childTemplate = `${itemAnchorTemplate(node.key)}${childTemplate}`
|
|
1528
|
+
}
|
|
1503
1529
|
const indexParam = node.index ? `, ${node.index}` : ''
|
|
1504
1530
|
// An init-scope-only array would `undefined.map(...)` ⇒ TypeError.
|
|
1505
1531
|
// Substitute an empty array; init's reconcile pass populates the loop
|
|
@@ -7,12 +7,12 @@ import type { ComponentIR, IRNode } from '../types'
|
|
|
7
7
|
// All exports from @barefootjs/client/runtime that may be used in generated code
|
|
8
8
|
export const RUNTIME_IMPORT_CANDIDATES = [
|
|
9
9
|
'createSignal', 'createMemo', 'createEffect', 'onCleanup', 'onMount',
|
|
10
|
-
'hydrate', 'insert', 'reconcileElements', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'createDisposableEffect',
|
|
10
|
+
'hydrate', 'insert', 'reconcileElements', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'createDisposableEffect',
|
|
11
11
|
'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild', 'updateClientMarker',
|
|
12
12
|
'createPortal',
|
|
13
13
|
'provideContext', 'createContext', 'useContext',
|
|
14
14
|
'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss',
|
|
15
|
-
'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot',
|
|
15
|
+
'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot', '__bfText',
|
|
16
16
|
] as const
|
|
17
17
|
|
|
18
18
|
/** @deprecated Use RUNTIME_IMPORT_CANDIDATES */
|
|
@@ -574,7 +574,23 @@ export function collectLoopChildReactiveAttrs(
|
|
|
574
574
|
// SSR template strips the attribute (html-template) and no
|
|
575
575
|
// hydrate-time binding is emitted, leaving the per-item
|
|
576
576
|
// attribute permanently unset.
|
|
577
|
-
|
|
577
|
+
//
|
|
578
|
+
// `classifyReactivity` only proves reactivity for the loop item
|
|
579
|
+
// accessor or a *directly* read signal/memo/prop. It does NOT see
|
|
580
|
+
// through an opaque helper that reads an outer signal by index
|
|
581
|
+
// (e.g. `widthAt(i)` where `const widthAt = (i) => items()[i].w`).
|
|
582
|
+
// The top-level attribute path (`decideWrapForAttr`) wraps those
|
|
583
|
+
// anyway via the Solid-style AST-flag fallback (#940); without the
|
|
584
|
+
// same fallback here, the identical binding on a per-item element
|
|
585
|
+
// freezes at its SSR value (#1673). Apply the same `callsReactiveGetters`
|
|
586
|
+
// / `hasFunctionCalls` fallback so the loop-child path matches the
|
|
587
|
+
// top-level one — a harmless over-wrap at worst (an effect that
|
|
588
|
+
// subscribes to nothing runs once).
|
|
589
|
+
const reactive =
|
|
590
|
+
classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind !== 'none'
|
|
591
|
+
|| attr.callsReactiveGetters
|
|
592
|
+
|| attr.hasFunctionCalls
|
|
593
|
+
if (!attr.clientOnly && !reactive) continue
|
|
578
594
|
attrs.push({
|
|
579
595
|
childSlotId: el.slotId,
|
|
580
596
|
attrName: attr.name,
|
|
@@ -200,6 +200,13 @@ export interface LoopCore {
|
|
|
200
200
|
* key tracks all of its DOM nodes (#1212).
|
|
201
201
|
*/
|
|
202
202
|
bodyIsMultiRoot?: boolean
|
|
203
|
+
/**
|
|
204
|
+
* True when the loop body is a single whole-item conditional whose at
|
|
205
|
+
* least one branch renders no element (#1665). Routes the loop through
|
|
206
|
+
* the anchored emission path (`mapArrayAnchored` + per-item
|
|
207
|
+
* `<!--bf-loop-i:KEY-->` anchors) so 0-or-1-element items reconcile.
|
|
208
|
+
*/
|
|
209
|
+
bodyIsItemConditional?: boolean
|
|
203
210
|
/**
|
|
204
211
|
* Pre-computed free identifiers referenced by the `array` expression
|
|
205
212
|
* (#1267). Populated during IR build from the originating AST node so
|
|
@@ -21,10 +21,11 @@ import {
|
|
|
21
21
|
BF_LOOP_END,
|
|
22
22
|
loopStartMarker,
|
|
23
23
|
loopEndMarker,
|
|
24
|
+
loopItemMarker,
|
|
24
25
|
toHTMLAttrName as toHtmlAttrName,
|
|
25
26
|
} from '@barefootjs/shared'
|
|
26
27
|
|
|
27
|
-
export { DATA_KEY, DATA_KEY_PREFIX, DATA_BF_PH, BF_LOOP_START, BF_LOOP_END, loopStartMarker, loopEndMarker, toHtmlAttrName }
|
|
28
|
+
export { DATA_KEY, DATA_KEY_PREFIX, DATA_BF_PH, BF_LOOP_START, BF_LOOP_END, loopStartMarker, loopEndMarker, loopItemMarker, toHtmlAttrName }
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Parameter name for the props object in generated init/template functions.
|