@barefootjs/cli 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.
@@ -12,6 +12,7 @@
12
12
 
13
13
  ## Reactivity
14
14
 
15
+ - [batch](https://barefootjs.dev/docs/reactivity/batch.md): Groups multiple signal writes so dependent effects and memos run once, after all writes complete.
15
16
  - [createEffect](https://barefootjs.dev/docs/reactivity/create-effect.md): Runs a function and re-runs it whenever its tracked signal dependencies change.
16
17
  - [createMemo](https://barefootjs.dev/docs/reactivity/create-memo.md): Creates a cached derived value that recomputes only when its dependencies change.
17
18
  - [createSignal](https://barefootjs.dev/docs/reactivity/create-signal.md): Creates a reactive getter/setter pair for managing state.
@@ -0,0 +1,135 @@
1
+ ---
2
+ title: batch
3
+ description: Groups multiple signal writes so dependent effects and memos run once, after all writes complete.
4
+ ---
5
+
6
+ # batch
7
+
8
+ Groups multiple signal writes so that dependent effects and memos run **once**,
9
+ after all the writes inside the batch complete — instead of once per write.
10
+
11
+ ```tsx
12
+ import { batch } from '@barefootjs/client'
13
+
14
+ batch<T>(fn: () => T): T
15
+ ```
16
+
17
+ Returns the value produced by `fn`.
18
+
19
+ ## Default behavior (no batch)
20
+
21
+ BarefootJS propagates updates **synchronously**: each setter call immediately
22
+ re-runs every subscriber. This keeps reads-after-writes predictable — after a
23
+ setter returns, derived memos, effects, and the DOM already reflect the new value.
24
+
25
+ The cost is that writing N signals that share a subscriber re-runs that
26
+ subscriber N times, and the subscriber briefly observes intermediate states
27
+ where some signals are updated and others are not:
28
+
29
+ ```tsx
30
+ const [x, setX] = createSignal(40)
31
+ const [y, setY] = createSignal(60)
32
+
33
+ createEffect(() => {
34
+ // depends on both x and y
35
+ send({ x: x(), y: y() })
36
+ })
37
+
38
+ setX(70) // effect runs — observes x=70, y=60 (intermediate)
39
+ setY(30) // effect runs again — observes x=70, y=30
40
+ ```
41
+
42
+ Beyond its initial run on creation, the effect ran twice more — once per write —
43
+ and saw a transient `x=70, y=60` state.
44
+
45
+ ## With batch
46
+
47
+ ```tsx
48
+ batch(() => {
49
+ setX(70)
50
+ setY(30)
51
+ })
52
+ // effect runs once, observing x=70, y=30
53
+ ```
54
+
55
+ Inside `batch`, writes are collected and dependent subscribers are de-duplicated,
56
+ so each runs **exactly once** after the batch ends — and never observes an
57
+ intermediate, half-updated state.
58
+
59
+ ## When to use
60
+
61
+ When a single handler updates several signals that feed shared effects/memos,
62
+ `batch` collapses the work into one update pass — and keeps the subscriber from
63
+ running while a cross-field invariant is temporarily broken:
64
+
65
+ ```tsx
66
+ const reset = () => {
67
+ batch(() => {
68
+ setName('')
69
+ setEmail('')
70
+ setAge(0)
71
+ // ...20 more fields
72
+ })
73
+ // every subscriber ran once, not once-per-field
74
+ }
75
+ ```
76
+
77
+ ## Caveats
78
+
79
+ ### Derived values are stale *inside* the batch
80
+
81
+ `batch` defers the work that recomputes derived values. Plain signal reads return
82
+ the new value immediately, but **memos and effect-driven values stay stale until
83
+ the batch ends**:
84
+
85
+ ```tsx
86
+ const [n, setN] = createSignal(1)
87
+ const doubled = createMemo(() => n() * 2)
88
+
89
+ batch(() => {
90
+ setN(10)
91
+ n() // 10 — plain signal read is fresh
92
+ doubled() // 2 — STALE; the memo hasn't recomputed yet
93
+ })
94
+ doubled() // 20 — recomputed after the batch ends
95
+ ```
96
+
97
+ If you need the recomputed value, read it after the batch.
98
+
99
+ ### `await` escapes the batch
100
+
101
+ `batch` only covers the **synchronous** portion of `fn`. Wrapping an async
102
+ function in `batch` groups only the writes before the first `await` — everything
103
+ after runs ungrouped, and the promise `batch` returns is easy to leave floating.
104
+
105
+ Instead, wrap each synchronous group of writes in its own `batch`, with `await`
106
+ between the groups:
107
+
108
+ ```tsx
109
+ const onSubmit = async () => {
110
+ batch(() => {
111
+ setLoading(true)
112
+ setError(null)
113
+ })
114
+
115
+ try {
116
+ const result = await save()
117
+ batch(() => {
118
+ setLoading(false)
119
+ setResult(result)
120
+ })
121
+ } catch (err) {
122
+ batch(() => {
123
+ setLoading(false)
124
+ setError(err)
125
+ })
126
+ }
127
+ }
128
+ ```
129
+
130
+ ## Note
131
+
132
+ `batch` is an **opt-in** optimization. Forgetting it is never a correctness bug —
133
+ code still works, just with extra subscriber runs. Reach for `batch` when a
134
+ handler writes many signals that share subscribers, or when an effect must not
135
+ observe a partially-updated state.
@@ -9,7 +9,7 @@ All reactive primitives are imported from `@barefootjs/client`:
9
9
 
10
10
  ```tsx
11
11
  "use client"
12
- import { createSignal, createEffect, createMemo, onMount, onCleanup, untrack } from '@barefootjs/client'
12
+ import { createSignal, createEffect, createMemo, onMount, onCleanup, untrack, batch } from '@barefootjs/client'
13
13
  ```
14
14
 
15
15
  ## API Reference
@@ -22,6 +22,7 @@ import { createSignal, createEffect, createMemo, onMount, onCleanup, untrack } f
22
22
  | [`onMount`](./reactivity/on-mount.md) | Run once on component initialization |
23
23
  | [`onCleanup`](./reactivity/on-cleanup.md) | Register cleanup for effects and lifecycle |
24
24
  | [`untrack`](./reactivity/untrack.md) | Read signals without tracking dependencies |
25
+ | [`batch`](./reactivity/batch.md) | Group signal writes so subscribers run once |
25
26
 
26
27
  ## Guides
27
28
 
package/dist/index.js CHANGED
@@ -359,13 +359,16 @@ var init_js_scanner = __esm({
359
359
  });
360
360
 
361
361
  // ../shared/src/markers.ts
362
+ function loopItemMarker(key) {
363
+ return `${BF_LOOP_ITEM}:${key}`;
364
+ }
362
365
  function loopStartMarker(markerId) {
363
366
  return `${BF_LOOP_START}:${markerId}`;
364
367
  }
365
368
  function loopEndMarker(markerId) {
366
369
  return `${BF_LOOP_END}:${markerId}`;
367
370
  }
368
- var BF_SCOPE, BF_SLOT, BF_HOST, BF_AT, BF_COND, BF_LOOP_START, BF_LOOP_END, BF_KEY, BF_KEY_PREFIX, BF_PLACEHOLDER, BF_PARENT_SCOPE_PLACEHOLDER;
371
+ var BF_SCOPE, BF_SLOT, BF_HOST, BF_AT, BF_COND, BF_LOOP_START, BF_LOOP_END, BF_LOOP_ITEM, BF_KEY, BF_KEY_PREFIX, BF_PLACEHOLDER, BF_PARENT_SCOPE_PLACEHOLDER;
369
372
  var init_markers = __esm({
370
373
  "../shared/src/markers.ts"() {
371
374
  "use strict";
@@ -376,6 +379,7 @@ var init_markers = __esm({
376
379
  BF_COND = "bf-c";
377
380
  BF_LOOP_START = "bf-loop";
378
381
  BF_LOOP_END = "bf-/loop";
382
+ BF_LOOP_ITEM = "bf-loop-i";
379
383
  BF_KEY = "data-key";
380
384
  BF_KEY_PREFIX = "data-key-";
381
385
  BF_PLACEHOLDER = "data-bf-ph";
@@ -1376,6 +1380,9 @@ function buildSpreadAttrsMergeCall(args2) {
1376
1380
  }
1377
1381
  return `\${spreadAttrs({${objMembers.join(", ")}})}`;
1378
1382
  }
1383
+ function itemAnchorTemplate(keyExpr) {
1384
+ return `<!--${loopItemMarker("${" + keyExpr + "}")}-->`;
1385
+ }
1379
1386
  function irToHtmlTemplate(node, restSpreadNames, loopDepth = 0, loopParams, branchSlotsVar, insideLoop = false, inHoistedChildren = false) {
1380
1387
  const recurse = (n) => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, insideLoop, inHoistedChildren);
1381
1388
  const wrapExpr = (expr) => wrapExprWithLoopParams(expr, loopParams);
@@ -1467,7 +1474,10 @@ function irToHtmlTemplate(node, restSpreadNames, loopDepth = 0, loopParams, bran
1467
1474
  }
1468
1475
  case "loop": {
1469
1476
  const innerRecurse = (n) => irToHtmlTemplate(n, restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop);
1470
- const childTemplate = node.children.map(innerRecurse).join("");
1477
+ let childTemplate = node.children.map(innerRecurse).join("");
1478
+ if (node.bodyIsItemConditional && node.key) {
1479
+ childTemplate = `${itemAnchorTemplate(node.key)}${childTemplate}`;
1480
+ }
1471
1481
  const indexParam = node.index ? `, ${node.index}` : "";
1472
1482
  const rawChainedArray = applyLoopChain(node);
1473
1483
  const { array: iterArray, callbackParam } = applyIterationShape(node, rawChainedArray, indexParam);
@@ -2030,7 +2040,10 @@ function generateCsrTemplateWithOpts(node, opts) {
2030
2040
  return `\${renderChild('${nameForRegistryRef(node.name)}', ${propsExpr}${keyArg || (slotArg ? ", undefined" : "")}${slotArg})}`;
2031
2041
  }
2032
2042
  case "loop": {
2033
- const childTemplate = node.children.map(recurseInLoop).join("");
2043
+ let childTemplate = node.children.map(recurseInLoop).join("");
2044
+ if (node.bodyIsItemConditional && node.key) {
2045
+ childTemplate = `${itemAnchorTemplate(node.key)}${childTemplate}`;
2046
+ }
2034
2047
  const indexParam = node.index ? `, ${node.index}` : "";
2035
2048
  const chainedTemplateArray = node.sortComparator || node.filterPredicate ? applyLoopChain(node, node.templateArray) : node.templateArray;
2036
2049
  const rawArrayExpr = transformExpr(node.array, chainedTemplateArray);
@@ -2437,6 +2450,24 @@ function getSourceLocation(node, sourceFile, filePath) {
2437
2450
  }
2438
2451
  };
2439
2452
  }
2453
+ function membersToProperties(members, sourceFile) {
2454
+ return members.filter(ts6.isPropertySignature).map((member) => ({
2455
+ name: propertyNameText(member.name, sourceFile),
2456
+ type: typeNodeToTypeInfo(member.type, sourceFile) ?? {
2457
+ kind: "unknown",
2458
+ raw: "unknown"
2459
+ },
2460
+ optional: !!member.questionToken,
2461
+ readonly: !!member.modifiers?.some(
2462
+ (m) => m.kind === ts6.SyntaxKind.ReadonlyKeyword
2463
+ )
2464
+ }));
2465
+ }
2466
+ function propertyNameText(name, sourceFile) {
2467
+ if (!name) return "";
2468
+ if (ts6.isStringLiteral(name) || ts6.isNumericLiteral(name)) return name.text;
2469
+ return name.getText(sourceFile);
2470
+ }
2440
2471
  function typeNodeToTypeInfo(typeNode, sourceFile) {
2441
2472
  if (!typeNode) return null;
2442
2473
  const raw = typeNode.getText(sourceFile);
@@ -2475,20 +2506,21 @@ function typeNodeToTypeInfo(typeNode, sourceFile) {
2475
2506
  return {
2476
2507
  kind: "object",
2477
2508
  raw,
2478
- properties: typeNode.members.filter(ts6.isPropertySignature).map((member) => ({
2479
- name: member.name?.getText(sourceFile) ?? "",
2480
- type: typeNodeToTypeInfo(member.type, sourceFile) ?? {
2481
- kind: "unknown",
2482
- raw: "unknown"
2483
- },
2484
- optional: !!member.questionToken,
2485
- readonly: !!member.modifiers?.some(
2486
- (m) => m.kind === ts6.SyntaxKind.ReadonlyKeyword
2487
- )
2488
- }))
2509
+ properties: membersToProperties(typeNode.members, sourceFile)
2489
2510
  };
2490
2511
  }
2491
2512
  if (ts6.isTypeReferenceNode(typeNode)) {
2513
+ const refName = ts6.isIdentifier(typeNode.typeName) ? typeNode.typeName.text : "";
2514
+ if ((refName === "Array" || refName === "ReadonlyArray") && typeNode.typeArguments?.length === 1) {
2515
+ return {
2516
+ kind: "array",
2517
+ raw,
2518
+ elementType: typeNodeToTypeInfo(typeNode.typeArguments[0], sourceFile) ?? {
2519
+ kind: "unknown",
2520
+ raw: "unknown"
2521
+ }
2522
+ };
2523
+ }
2492
2524
  return {
2493
2525
  kind: "interface",
2494
2526
  raw
@@ -3666,14 +3698,17 @@ function collectInterfaceDefinition(node, ctx2) {
3666
3698
  kind: "interface",
3667
3699
  name: node.name.text,
3668
3700
  definition: node.getText(ctx2.sourceFile),
3701
+ properties: membersToProperties(node.members, ctx2.sourceFile),
3669
3702
  loc: getSourceLocation(node, ctx2.sourceFile, ctx2.filePath)
3670
3703
  });
3671
3704
  }
3672
3705
  function collectTypeAliasDefinition(node, ctx2) {
3706
+ const properties = ts7.isTypeLiteralNode(node.type) ? membersToProperties(node.type.members, ctx2.sourceFile) : void 0;
3673
3707
  ctx2.typeDefinitions.push({
3674
3708
  kind: "type",
3675
3709
  name: node.name.text,
3676
3710
  definition: node.getText(ctx2.sourceFile),
3711
+ properties,
3677
3712
  loc: getSourceLocation(node, ctx2.sourceFile, ctx2.filePath)
3678
3713
  });
3679
3714
  }
@@ -5869,7 +5904,7 @@ function checkSupport(expr) {
5869
5904
  return {
5870
5905
  supported: false,
5871
5906
  level: "L5_UNSUPPORTED",
5872
- reason: `Higher-order method '${methodName}()' requires client-side evaluation. Use @client directive or pre-compute in Go.`
5907
+ 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.`
5873
5908
  };
5874
5909
  }
5875
5910
  }
@@ -6159,7 +6194,7 @@ var init_expression_parser = __esm({
6159
6194
  "some",
6160
6195
  "forEach",
6161
6196
  "flatMap",
6162
- "flat"
6197
+ "flat",
6163
6198
  // #1448 Tier A — Array methods. Each method PR adds the lowering
6164
6199
  // (typically a new `array-method` variant or runtime helper) and
6165
6200
  // removes its row here. See packages/adapter-tests/fixtures/methods/.
@@ -6189,6 +6224,34 @@ var init_expression_parser = __esm({
6189
6224
  // `bf_lower` / `bf_upper` (Go) and Perl's native `lc` / `uc` (Mojo).
6190
6225
  // `trim` lowers via the `array-method` IR + `bf_trim` (Go) and a
6191
6226
  // Perl regex strip (Mojo).
6227
+ //
6228
+ // #1448 follow-up — String methods that have NO lowering yet. These
6229
+ // were previously absent from this gate, so `isSupported` reported
6230
+ // them "supported" and the adapters emitted a raw method call
6231
+ // (`{{.Name.StartsWith "a"}}` on Go, `$name->{startsWith}('a')` on
6232
+ // Mojo) with no build diagnostic — a silent footgun that only
6233
+ // surfaced as a crash at template-render time. Listing them here
6234
+ // makes the build fail loudly with BF101 (the same treatment the
6235
+ // unsupported array methods above get), pointing users at the
6236
+ // `/* @client */` escape hatch. Each name drops off as its lowering
6237
+ // lands. See #1448 "Unsupported string methods" Tier B / Tier C.
6238
+ "split",
6239
+ "startsWith",
6240
+ "endsWith",
6241
+ "replace",
6242
+ "replaceAll",
6243
+ "repeat",
6244
+ "padStart",
6245
+ "padEnd",
6246
+ "charAt",
6247
+ "charCodeAt",
6248
+ "codePointAt",
6249
+ "normalize",
6250
+ "substring",
6251
+ "substr",
6252
+ "match",
6253
+ "matchAll",
6254
+ "search"
6192
6255
  ]);
6193
6256
  }
6194
6257
  });
@@ -7385,6 +7448,22 @@ function containsJsxInExpression(node) {
7385
7448
  }
7386
7449
  return ts11.forEachChild(node, containsJsxInExpression) ?? false;
7387
7450
  }
7451
+ function callsJsxHelper(node, ctx2) {
7452
+ let found = false;
7453
+ const visit3 = (n) => {
7454
+ if (found) return;
7455
+ if (ts11.isCallExpression(n) && ts11.isIdentifier(n.expression)) {
7456
+ const name = n.expression.text;
7457
+ if (ctx2.analyzer.jsxFunctions.has(name) || ctx2.analyzer.jsxMultiReturnFunctions.has(name)) {
7458
+ found = true;
7459
+ return;
7460
+ }
7461
+ }
7462
+ ts11.forEachChild(n, visit3);
7463
+ };
7464
+ visit3(node);
7465
+ return found;
7466
+ }
7388
7467
  function containsAwaitExpression(node) {
7389
7468
  if (ts11.isAwaitExpression(node)) return true;
7390
7469
  if (ts11.isFunctionDeclaration(node) || ts11.isFunctionExpression(node) || ts11.isArrowFunction(node)) return false;
@@ -7466,7 +7545,7 @@ function transformJsxExpression(expr, ctx2, isClientOnly = false) {
7466
7545
  if (node.operatorToken.kind === ts11.SyntaxKind.AmpersandAmpersandToken) {
7467
7546
  return transformLogicalAnd(node, ctx2);
7468
7547
  }
7469
- if ((node.operatorToken.kind === ts11.SyntaxKind.QuestionQuestionToken || node.operatorToken.kind === ts11.SyntaxKind.BarBarToken) && containsJsxInExpression(node.right)) {
7548
+ if ((node.operatorToken.kind === ts11.SyntaxKind.QuestionQuestionToken || node.operatorToken.kind === ts11.SyntaxKind.BarBarToken) && (containsJsxInExpression(node.right) || callsJsxHelper(node.right, ctx2))) {
7470
7549
  return transformNullishCoalescing(node, ctx2);
7471
7550
  }
7472
7551
  return null;
@@ -7906,17 +7985,20 @@ function checkLoopKey(callback, ctx2, isNested) {
7906
7985
  body = ret.expression;
7907
7986
  }
7908
7987
  while (ts11.isParenthesizedExpression(body)) body = body.expression;
7988
+ function checkJsxOperand(node) {
7989
+ let n = node;
7990
+ while (ts11.isParenthesizedExpression(n)) n = n.expression;
7991
+ if (ts11.isJsxElement(n)) checkOpening(n.openingElement);
7992
+ else if (ts11.isJsxSelfClosingElement(n)) checkOpening(n);
7993
+ }
7909
7994
  if (ts11.isConditionalExpression(body)) {
7910
- const whenTrue = body.whenTrue;
7911
- const whenFalse = body.whenFalse;
7912
- let wt = whenTrue;
7913
- let wf = whenFalse;
7914
- while (ts11.isParenthesizedExpression(wt)) wt = wt.expression;
7915
- while (ts11.isParenthesizedExpression(wf)) wf = wf.expression;
7916
- if (ts11.isJsxElement(wt)) checkOpening(wt.openingElement);
7917
- else if (ts11.isJsxSelfClosingElement(wt)) checkOpening(wt);
7918
- if (ts11.isJsxElement(wf)) checkOpening(wf.openingElement);
7919
- else if (ts11.isJsxSelfClosingElement(wf)) checkOpening(wf);
7995
+ checkJsxOperand(body.whenTrue);
7996
+ checkJsxOperand(body.whenFalse);
7997
+ return;
7998
+ }
7999
+ if (ts11.isBinaryExpression(body) && (body.operatorToken.kind === ts11.SyntaxKind.AmpersandAmpersandToken || body.operatorToken.kind === ts11.SyntaxKind.BarBarToken || body.operatorToken.kind === ts11.SyntaxKind.QuestionQuestionToken)) {
8000
+ checkJsxOperand(body.left);
8001
+ checkJsxOperand(body.right);
7920
8002
  return;
7921
8003
  }
7922
8004
  if (ts11.isJsxElement(body)) {
@@ -7938,6 +8020,39 @@ function loopBodyIsMultiRoot(children) {
7938
8020
  if (only.type !== "fragment") return false;
7939
8021
  return loopBodyIsMultiRoot(only.children);
7940
8022
  }
8023
+ function branchHasNoElement(node) {
8024
+ if (node.type === "element" || node.type === "component") return false;
8025
+ if (node.type === "conditional") {
8026
+ return branchHasNoElement(node.whenTrue) || branchHasNoElement(node.whenFalse);
8027
+ }
8028
+ if (node.type === "fragment") {
8029
+ const real = node.children.filter(
8030
+ (c) => !(c.type === "text" && typeof c.value === "string" && !c.value.trim())
8031
+ );
8032
+ return real.length !== 1 || branchHasNoElement(real[0]);
8033
+ }
8034
+ return true;
8035
+ }
8036
+ function loopBodyItemConditional(children) {
8037
+ const real = children.filter(
8038
+ (c) => !(c.type === "text" && typeof c.value === "string" && !c.value.trim())
8039
+ );
8040
+ if (real.length !== 1) return null;
8041
+ const only = real[0];
8042
+ if (only.type !== "conditional") return null;
8043
+ if (branchHasNoElement(only.whenTrue) || branchHasNoElement(only.whenFalse)) {
8044
+ return only;
8045
+ }
8046
+ return null;
8047
+ }
8048
+ function extractItemConditionalKey(cond) {
8049
+ const a = branchHasNoElement(cond.whenTrue) ? null : extractLoopKey(cond.whenTrue);
8050
+ const b = branchHasNoElement(cond.whenFalse) ? null : extractLoopKey(cond.whenFalse);
8051
+ if (a !== null && b !== null) {
8052
+ return normalizeKeyExpr(a) === normalizeKeyExpr(b) ? a : null;
8053
+ }
8054
+ return a ?? b;
8055
+ }
7941
8056
  function transformMapCall(node, ctx2, isClientOnly = false, method = "map") {
7942
8057
  const isNested = ctx2.loopParams.size > 0;
7943
8058
  const propAccess = node.expression;
@@ -8134,6 +8249,16 @@ function transformMapCall(node, ctx2, isClientOnly = false, method = "map") {
8134
8249
  ctx2.loopParams.add(param);
8135
8250
  }
8136
8251
  if (index) ctx2.loopParams.add(index);
8252
+ const tryTransformRenderableBody = (expr) => {
8253
+ if (!ts11.isBinaryExpression(expr)) return;
8254
+ const op = expr.operatorToken.kind;
8255
+ if (op !== ts11.SyntaxKind.AmpersandAmpersandToken && op !== ts11.SyntaxKind.BarBarToken && op !== ts11.SyntaxKind.QuestionQuestionToken) {
8256
+ return;
8257
+ }
8258
+ if (!containsJsxInExpression(expr) && !callsJsxHelper(expr, ctx2)) return;
8259
+ const transformed = transformJsxExpression(expr, ctx2, isClientOnly);
8260
+ if (transformed) children = [transformed];
8261
+ };
8137
8262
  const body = callback.body;
8138
8263
  if (ts11.isJsxElement(body) || ts11.isJsxSelfClosingElement(body) || ts11.isJsxFragment(body)) {
8139
8264
  const transformed = transformNode(body, ctx2);
@@ -8156,6 +8281,8 @@ function transformMapCall(node, ctx2, isClientOnly = false, method = "map") {
8156
8281
  children = [transformConditional(inner, ctx2)];
8157
8282
  } else if (method === "flatMap" && ts11.isArrayLiteralExpression(inner)) {
8158
8283
  children = transformArrayLiteralChildren(inner, ctx2);
8284
+ } else {
8285
+ tryTransformRenderableBody(inner);
8159
8286
  }
8160
8287
  } else if (method === "flatMap" && ts11.isArrayLiteralExpression(body)) {
8161
8288
  children = transformArrayLiteralChildren(body, ctx2);
@@ -8203,6 +8330,8 @@ function transformMapCall(node, ctx2, isClientOnly = false, method = "map") {
8203
8330
  if (method === "flatMap" && children.length === 0) {
8204
8331
  flatMapCallback = buildFlatMapCallback(callback, body, ctx2);
8205
8332
  }
8333
+ } else {
8334
+ tryTransformRenderableBody(body);
8206
8335
  }
8207
8336
  if (paramBindings) {
8208
8337
  for (const b of paramBindings) ctx2.loopParams.delete(b.name);
@@ -8217,7 +8346,9 @@ function transformMapCall(node, ctx2, isClientOnly = false, method = "map") {
8217
8346
  if (ts11.isArrowFunction(node.arguments[0]) && children.length > 0) {
8218
8347
  checkLoopKey(node.arguments[0], ctx2, isNested);
8219
8348
  }
8220
- const key = children.length > 0 ? extractLoopKey(children[0]) : null;
8349
+ const itemConditional = children.length > 0 ? loopBodyItemConditional(children) : null;
8350
+ const bodyIsItemConditional = itemConditional !== null;
8351
+ const key = bodyIsItemConditional ? extractItemConditionalKey(itemConditional) : children.length > 0 ? extractLoopKey(children[0]) : null;
8221
8352
  let childComponent;
8222
8353
  if (children.length === 1 && children[0].type === "component") {
8223
8354
  const comp = children[0];
@@ -8263,6 +8394,7 @@ function transformMapCall(node, ctx2, isClientOnly = false, method = "map") {
8263
8394
  callsReactiveGetters: callsReactive || void 0,
8264
8395
  hasFunctionCalls: hasCalls || void 0,
8265
8396
  bodyIsMultiRoot: bodyIsMultiRoot || void 0,
8397
+ bodyIsItemConditional: bodyIsItemConditional || void 0,
8266
8398
  childComponent,
8267
8399
  nestedComponents,
8268
8400
  filterPredicate,
@@ -9433,7 +9565,8 @@ function collectLoopChildReactiveAttrs(node, ctx2, loopParam, loopParamBindings)
9433
9565
  const valueStr = attrValueToString(attr.value);
9434
9566
  if (!valueStr) continue;
9435
9567
  const expanded = expandConstantForReactivity(valueStr, ctx2, attr.freeIdentifiers);
9436
- if (!attr.clientOnly && classifyReactivity(expanded.expr, ctx2, loopParam, loopParamBindings, expanded.freeIds).kind === "none") continue;
9568
+ const reactive = classifyReactivity(expanded.expr, ctx2, loopParam, loopParamBindings, expanded.freeIds).kind !== "none" || attr.callsReactiveGetters || attr.hasFunctionCalls;
9569
+ if (!attr.clientOnly && !reactive) continue;
9437
9570
  attrs.push({
9438
9571
  childSlotId: el.slotId,
9439
9572
  attrName: attr.name,
@@ -9462,21 +9595,30 @@ function producesDomChild(node) {
9462
9595
  }
9463
9596
  function computeLoopSiblingOffsets(root) {
9464
9597
  const offsets = /* @__PURE__ */ new Map();
9465
- walkIR(root, null, {
9466
- element: ({ node: el, descend }) => {
9467
- let nonLoopCount = 0;
9468
- for (const child of el.children) {
9469
- if (child.type === "loop") {
9470
- if (nonLoopCount > 0) offsets.set(child, nonLoopCount);
9471
- } else if (producesDomChild(child)) {
9472
- nonLoopCount++;
9473
- }
9598
+ const recordChildren = (children) => {
9599
+ let nonLoopCount = 0;
9600
+ for (const child of children) {
9601
+ if (child.type === "loop") {
9602
+ if (nonLoopCount > 0) offsets.set(child, nonLoopCount);
9603
+ } else if (producesDomChild(child)) {
9604
+ nonLoopCount++;
9474
9605
  }
9475
- descend();
9476
9606
  }
9477
- // All container kinds (fragment / component / provider / async / loop /
9478
- // conditional / if-statement) rely on walkIR's default descent with the
9479
- // same scope. Leaves (text / expression / slot) are no-ops.
9607
+ };
9608
+ const containerVisit = ({ node, descend }) => {
9609
+ recordChildren(node.children);
9610
+ descend();
9611
+ };
9612
+ walkIR(root, null, {
9613
+ element: containerVisit,
9614
+ component: containerVisit,
9615
+ fragment: containerVisit,
9616
+ provider: containerVisit,
9617
+ async: containerVisit
9618
+ // `loop` / `conditional` / `if-statement` are not flat sibling
9619
+ // containers (their children are item bodies / branches), and leaves
9620
+ // (text / expression / slot) have no children — all rely on walkIR's
9621
+ // default descent with the same scope.
9480
9622
  });
9481
9623
  return offsets;
9482
9624
  }
@@ -9557,6 +9699,7 @@ function collectInnerLoops(nodes, siblingOffsets, outerLoopParam, ctx2, options)
9557
9699
  key: n.key,
9558
9700
  markerId: n.markerId,
9559
9701
  bodyIsMultiRoot: n.bodyIsMultiRoot,
9702
+ bodyIsItemConditional: n.bodyIsItemConditional,
9560
9703
  iterationShape: n.iterationShape,
9561
9704
  containerSlotId: scope.parentSlotId,
9562
9705
  template,
@@ -9763,6 +9906,7 @@ function collectElements(node, ctx2, siblingOffsets, insideConditional = false)
9763
9906
  key: l.key,
9764
9907
  markerId: l.markerId,
9765
9908
  bodyIsMultiRoot: l.bodyIsMultiRoot,
9909
+ bodyIsItemConditional: l.bodyIsItemConditional,
9766
9910
  iterationShape: l.iterationShape,
9767
9911
  template,
9768
9912
  staticItemTemplate,
@@ -9956,6 +10100,7 @@ function collectBranchLoops(node, ctx2, siblingOffsets) {
9956
10100
  key: n.key,
9957
10101
  markerId: n.markerId,
9958
10102
  bodyIsMultiRoot: n.bodyIsMultiRoot,
10103
+ bodyIsItemConditional: n.bodyIsItemConditional,
9959
10104
  iterationShape: n.iterationShape,
9960
10105
  template: childTemplate,
9961
10106
  containerSlotId: containerSlot,
@@ -10624,6 +10769,7 @@ var init_imports = __esm({
10624
10769
  "getLoopChildren",
10625
10770
  "getLoopNodes",
10626
10771
  "mapArray",
10772
+ "mapArrayAnchored",
10627
10773
  "createDisposableEffect",
10628
10774
  "createComponent",
10629
10775
  "renderChild",
@@ -10647,7 +10793,8 @@ var init_imports = __esm({
10647
10793
  "qsaChildScopes",
10648
10794
  "upsertChildItem",
10649
10795
  "__slot",
10650
- "__bfSlot"
10796
+ "__bfSlot",
10797
+ "__bfText"
10651
10798
  ];
10652
10799
  RUNTIME_MODULE = "@barefootjs/client/runtime";
10653
10800
  IMPORT_PLACEHOLDER = "/* __BAREFOOTJS_DOM_IMPORTS__ */";
@@ -13528,17 +13675,21 @@ function emitDynamicTextUpdates(lines, ctx2) {
13528
13675
  const conditionalElems = elems.filter((e) => e.insideConditional);
13529
13676
  const normalElems = elems.filter((e) => !e.insideConditional);
13530
13677
  if (normalElems.length > 0 || conditionalElems.length > 0) {
13678
+ for (const elem of normalElems) {
13679
+ const v = varSlotId(elem.slotId);
13680
+ lines.push(` let __anchor_${v} = _${v}`);
13681
+ }
13531
13682
  lines.push(` createEffect(() => {`);
13532
13683
  if (normalElems.length > 0) {
13533
13684
  lines.push(` const __val = ${expr}`);
13534
13685
  for (const elem of normalElems) {
13535
13686
  const v = varSlotId(elem.slotId);
13536
- lines.push(` if (_${v} && !__val?.__isSlot) _${v}.nodeValue = String(__val ?? '')`);
13687
+ lines.push(` __anchor_${v} = __bfText(__anchor_${v}, __val)`);
13537
13688
  }
13538
13689
  for (const elem of conditionalElems) {
13539
13690
  const v = varSlotId(elem.slotId);
13540
13691
  lines.push(` const [__el_${v}] = $t(__scope, '${elem.slotId}')`);
13541
- lines.push(` if (__el_${v} && !__val?.__isSlot) __el_${v}.nodeValue = String(__val ?? '')`);
13692
+ lines.push(` __bfText(__el_${v}, __val)`);
13542
13693
  }
13543
13694
  } else {
13544
13695
  lines.push(` let __val`);
@@ -13546,7 +13697,7 @@ function emitDynamicTextUpdates(lines, ctx2) {
13546
13697
  for (const elem of conditionalElems) {
13547
13698
  const v = varSlotId(elem.slotId);
13548
13699
  lines.push(` const [__el_${v}] = $t(__scope, '${elem.slotId}')`);
13549
- lines.push(` if (__el_${v} && !__val?.__isSlot) __el_${v}.nodeValue = String(__val ?? '')`);
13700
+ lines.push(` __bfText(__el_${v}, __val)`);
13550
13701
  }
13551
13702
  }
13552
13703
  lines.push(` })`);
@@ -14079,8 +14230,14 @@ function stringifyPlainLoop(lines, plan, topIndent = " ") {
14079
14230
  template,
14080
14231
  reactiveEffects,
14081
14232
  childRefs,
14082
- bodyIsMultiRoot
14233
+ bodyIsMultiRoot,
14234
+ anchored,
14235
+ anchorKeyExpr
14083
14236
  } = plan;
14237
+ if (anchored) {
14238
+ stringifyAnchoredLoop(lines, plan, topIndent, anchorKeyExpr);
14239
+ return;
14240
+ }
14084
14241
  if (reactiveEffects === null && !bodyIsMultiRoot && childRefs.length === 0) {
14085
14242
  const unwrapInline = paramUnwrap ? `${paramUnwrap} ` : "";
14086
14243
  const preamble = mapPreambleWrapped ? `${mapPreambleWrapped}; ` : "";
@@ -14107,6 +14264,39 @@ function stringifyPlainLoop(lines, plan, topIndent = " ") {
14107
14264
  lines.push(`${bodyIndent}return __el`);
14108
14265
  lines.push(`${topIndent}}, '${markerId}')`);
14109
14266
  }
14267
+ function stringifyAnchoredLoop(lines, plan, topIndent, anchorKeyExpr) {
14268
+ const {
14269
+ containerVar,
14270
+ markerId,
14271
+ arrayExpr,
14272
+ keyFn,
14273
+ paramHead,
14274
+ paramUnwrap,
14275
+ indexParam,
14276
+ mapPreambleWrapped,
14277
+ reactiveEffects
14278
+ } = plan;
14279
+ const condSlot = reactiveEffects?.conditionals[0]?.slotId ?? null;
14280
+ lines.push(`${topIndent}mapArrayAnchored(() => ${arrayExpr}, ${containerVar}, ${keyFn}, (${paramHead}, ${indexParam}, __existing) => {`);
14281
+ const bodyIndent = topIndent + " ";
14282
+ if (paramUnwrap) lines.push(`${bodyIndent}${paramUnwrap}`);
14283
+ if (mapPreambleWrapped) lines.push(`${bodyIndent}${mapPreambleWrapped}`);
14284
+ lines.push(`${bodyIndent}const __anchor = __existing ?? document.createComment(\`bf-loop-i:\${${anchorKeyExpr}}\`)`);
14285
+ lines.push(`${bodyIndent}let __frag = null`);
14286
+ lines.push(`${bodyIndent}if (!__existing) {`);
14287
+ lines.push(`${bodyIndent} __frag = document.createDocumentFragment()`);
14288
+ lines.push(`${bodyIndent} __frag.appendChild(__anchor)`);
14289
+ if (condSlot) {
14290
+ lines.push(`${bodyIndent} __frag.appendChild(document.createComment('bf-cond-start:${condSlot}'))`);
14291
+ lines.push(`${bodyIndent} __frag.appendChild(document.createComment('bf-cond-end:${condSlot}'))`);
14292
+ }
14293
+ lines.push(`${bodyIndent}}`);
14294
+ if (reactiveEffects !== null) {
14295
+ stringifyReactiveEffects(lines, reactiveEffects, { indent: bodyIndent, elVar: "__anchor", bodyIsMultiRoot: false });
14296
+ }
14297
+ lines.push(`${bodyIndent}return __frag ?? __anchor`);
14298
+ lines.push(`${topIndent}}, '${markerId}')`);
14299
+ }
14110
14300
  function stringifyStaticLoop(lines, plan) {
14111
14301
  const { containerVar, arrayExpr, param, indexParam, childIndexExpr, attrsBySlot, texts, childRefs, csrMaterialize } = plan;
14112
14302
  const hasAttrs = attrsBySlot.length > 0;
@@ -14667,10 +14857,10 @@ function emitArmBody2(lines, body, mode, indent) {
14667
14857
  }
14668
14858
  for (const te of body.textEffects) {
14669
14859
  const v = varSlotId(te.slotId);
14670
- lines.push(`${indent}const [__el_${v}] = $t(__branchScope, '${te.slotId}')`);
14860
+ lines.push(`${indent}let __anchor_${v} = $t(__branchScope, '${te.slotId}')[0]`);
14671
14861
  lines.push(`${indent}__disposers.push(createDisposableEffect(() => {`);
14672
14862
  lines.push(`${indent} const __val = ${te.expression}`);
14673
- lines.push(`${indent} if (__el_${v} && !__val?.__isSlot) __el_${v}.nodeValue = String(__val ?? '')`);
14863
+ lines.push(`${indent} __anchor_${v} = __bfText(__anchor_${v}, __val)`);
14674
14864
  lines.push(`${indent}}))`);
14675
14865
  }
14676
14866
  if (body.loops.length > 0) {
@@ -14767,6 +14957,9 @@ var init_build_component_loop = __esm({
14767
14957
 
14768
14958
  // ../jsx/src/ir-to-client-js/control-flow/plan/build-loop.ts
14769
14959
  function buildLoopPlan(elem, opts) {
14960
+ if (elem.bodyIsItemConditional) {
14961
+ return buildPlainLoopPlan(elem);
14962
+ }
14770
14963
  if (elem.isStaticArray) {
14771
14964
  return buildStaticLoopPlan(elem, opts.unsafeLocalNames);
14772
14965
  }
@@ -14796,7 +14989,15 @@ function buildPlainLoopPlan(elem) {
14796
14989
  template: elem.template,
14797
14990
  reactiveEffects: hasReactive2 ? buildLoopReactiveEffectsPlan(elem) : null,
14798
14991
  childRefs: buildChildRefBindings(elem.bindings.refs, elem.param, elem.paramBindings),
14799
- bodyIsMultiRoot: elem.bodyIsMultiRoot ?? false
14992
+ bodyIsMultiRoot: elem.bodyIsMultiRoot ?? false,
14993
+ anchored: elem.bodyIsItemConditional ?? false,
14994
+ // Fall back to the iteration index when the loop has no key. A whole-item
14995
+ // conditional without a key is a BF023 error, but the emitted client JS
14996
+ // must still parse — an empty `anchorKeyExpr` would produce
14997
+ // `createComment(`bf-loop-i:${}`)` (a SyntaxError that breaks the whole
14998
+ // bundle). `elem.index || '__idx'` matches `indexParam` above, so the
14999
+ // anchor value stays consistent with the renderItem's own index param.
15000
+ anchorKeyExpr: elem.key ? wrap(elem.key) : elem.index || "__idx"
14800
15001
  };
14801
15002
  }
14802
15003
  function buildStaticLoopPlan(elem, unsafeLocalNames) {
@@ -15888,17 +16089,23 @@ function runSinglePass(source, filePath, startingCounter) {
15888
16089
  }
15889
16090
  function visit3(node) {
15890
16091
  if (ts15.isJsxAttribute(node) && node.initializer && ts15.isJsxExpression(node.initializer) && node.initializer.expression) {
15891
- let expr = node.initializer.expression;
15892
- while (ts15.isParenthesizedExpression(expr)) expr = expr.expression;
15893
- if (ts15.isArrowFunction(expr) && arrowBodyContainsJsx(expr)) {
15894
- const handled = handleInlineArrow(expr);
15895
- if (handled) {
15896
- return;
15897
- }
16092
+ if (tryHandleArrowValue(node.initializer.expression)) {
16093
+ return;
15898
16094
  }
15899
16095
  }
16096
+ if (ts15.isPropertyAssignment(node) && node.initializer) {
16097
+ if (tryHandleArrowValue(node.initializer)) return;
16098
+ }
15900
16099
  ts15.forEachChild(node, visit3);
15901
16100
  }
16101
+ function tryHandleArrowValue(initializer) {
16102
+ let expr = initializer;
16103
+ while (ts15.isParenthesizedExpression(expr)) expr = expr.expression;
16104
+ if (ts15.isArrowFunction(expr) && arrowBodyContainsJsx(expr)) {
16105
+ return handleInlineArrow(expr);
16106
+ }
16107
+ return false;
16108
+ }
15902
16109
  function handleInlineArrow(arrow) {
15903
16110
  const paramNames = collectArrowParamNames(arrow);
15904
16111
  const free = collectFreeIdentifiers(arrow);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "CLI for agent-driven UI component discovery and scaffolding",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -31,7 +31,7 @@
31
31
  "typescript": "^5.0.0"
32
32
  },
33
33
  "devDependencies": {
34
- "@barefootjs/jsx": "0.5.0",
34
+ "@barefootjs/jsx": "0.5.2",
35
35
  "@types/node": "^22.0.0"
36
36
  }
37
37
  }