@barefootjs/jsx 0.35.2 → 0.35.3

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.
@@ -58,6 +58,10 @@ import type {
58
58
  StaticArrayChildInitsPlan,
59
59
  } from '../plan/static-array-child-init.ts'
60
60
  import { nameForRegistryRef } from '../component-scope.ts'
61
+ import { varSlotId } from '../utils.ts'
62
+ import { emitDedupedAttrUpdate, DEDUP_STORE_DECL } from '../emit-reactive.ts'
63
+ import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from '../control-flow/stringify/claim-plan.ts'
64
+ import { emitLoopChildRefs } from '../control-flow/stringify/loop.ts'
61
65
 
62
66
  export function stringifyStaticArrayChildInits(
63
67
  lines: string[],
@@ -142,6 +146,9 @@ function emitInnerLoopNested(lines: string[], plan: InnerLoopNestedInitPlan): vo
142
146
  innerPreludeStatements,
143
147
  depth,
144
148
  comps,
149
+ attrsBySlot,
150
+ texts,
151
+ refs,
145
152
  } = plan
146
153
  lines.push(` // Initialize inner-loop components in static array (depth ${depth})`)
147
154
  lines.push(` if (${containerVar}) {`)
@@ -174,6 +181,38 @@ function emitInnerLoopNested(lines: string[], plan: InnerLoopNestedInitPlan): vo
174
181
  lines.push(` const ${compElVar} = qsaChildScope(__innerEl, ${comp.selector})`)
175
182
  lines.push(` if (${compElVar}) initChild('${nameForRegistryRef(comp.componentName)}', ${compElVar}, ${comp.propsExpr})`)
176
183
  })
184
+ // Plain-element reactive attrs / texts / refs inside this inner loop's
185
+ // body (#2798) — mirrors `stringifyStaticLoop`'s own per-row wiring for
186
+ // the OUTER static loop, one nesting level in. `forEach` binds
187
+ // `innerParam` as the raw item value, so `texts`/`attrsBySlot`
188
+ // expressions stay unwrapped, same as the outer row.
189
+ if (attrsBySlot.length > 0) lines.push(` ${DEDUP_STORE_DECL}`)
190
+ let ordinal = 0
191
+ for (const [slotId, attrs] of attrsBySlot) {
192
+ const varName = `__t_${varSlotId(slotId)}`
193
+ lines.push(` const ${varName} = qsa(__innerEl, '[bf="${slotId}"]')`)
194
+ lines.push(` if (${varName}) {`)
195
+ for (const attr of attrs) {
196
+ lines.push(` createEffect(() => {`)
197
+ for (const stmt of emitDedupedAttrUpdate(varName, attr.attrName, attr.expression, attr, ordinal++)) {
198
+ lines.push(` ${stmt}`)
199
+ }
200
+ lines.push(` })`)
201
+ }
202
+ lines.push(` }`)
203
+ }
204
+ if (texts.length > 0) {
205
+ const slots: ClaimSlotSpec[] = texts.map(t => ({ id: t.slotId, kind: 'text', path: [] }))
206
+ const writer = claimWriterVarName(slots, varSlotId)
207
+ lines.push(` const ${writer} = lazySlots(__innerEl, ${claimPlanLiteral(slots)})`)
208
+ for (const text of texts) {
209
+ lines.push(` createEffect(() => { ${writer}('${text.slotId}', String(${text.expression})) })`)
210
+ }
211
+ }
212
+ // Ref callbacks fire on every forEach iteration — initial mount and any
213
+ // future array-change-driven re-iteration. For a static array the array
214
+ // is non-reactive, so refs effectively fire once per item (#1244).
215
+ emitLoopChildRefs(lines, refs, { indent: ' ', elVar: '__innerEl', bodyIsMultiRoot: false })
177
216
  lines.push(` })`)
178
217
  lines.push(` })`)
179
218
  lines.push(` }`)
package/src/jsx-to-ir.ts CHANGED
@@ -49,6 +49,7 @@ import { containsReactiveExpression, collectReactiveBrandLeaves } from './reacti
49
49
  import {
50
50
  rewriteBarePropRefs as rewriteBarePropRefsCore,
51
51
  collectAstPropRefs,
52
+ rewriteScopedValueRefs,
52
53
  } from './prop-rewrite.ts'
53
54
  import { boundPropLocalNames, buildPropAliasMap, resolveAliasOrigin, resolveRestSpreadOriginCore } from './props-binding.ts'
54
55
  import { resolveFreeRefs, isNameBound as isNameBoundInEnv, type BindingEnvironment } from './free-refs.ts'
@@ -8456,10 +8457,19 @@ function inferExpressionType(
8456
8457
 
8457
8458
  /**
8458
8459
  * Substitute branch-local identifier references in `text` with the
8459
- * value returned by `resolve(name)`. Skips occurrences inside string
8460
- * / regex / template-body / comment tokens via the shared
8461
- * `replaceInExprContexts` scanner, so a string like `'mergedClass'`
8462
- * never gets rewritten into invalid JS. Identifier boundaries use
8460
+ * value returned by `resolve(name)`. Tries the AST-based, scope-aware
8461
+ * `rewriteScopedValueRefs` (`prop-rewrite.ts`) first it correctly
8462
+ * expands an object-literal shorthand property (`{ local }`) to
8463
+ * `{ local: <resolved> }` instead of corrupting it to `{ (<resolved>) }`
8464
+ * (#2856), and understands inner-scope shadowing. `allowStatements`
8465
+ * covers raw-captured text that isn't guaranteed to be a bare
8466
+ * expression (e.g. a `.map()`-callback preamble statement).
8467
+ *
8468
+ * Falls back to the legacy `replaceInExprContexts` scanner — which
8469
+ * skips occurrences inside string / regex / template-body / comment
8470
+ * tokens, so a string like `'mergedClass'` never gets rewritten into
8471
+ * invalid JS, but has no notion of AST position — only when `text`
8472
+ * doesn't parse under either attempt. Identifier boundaries use
8463
8473
  * `[\w$]` lookarounds rather than `\b`, since JS regex's word-char
8464
8474
  * class excludes `$` — a bare `\b` would mis-match the `foo` inside
8465
8475
  * `$foo`. Branch-local names are syntactically `[A-Za-z_$][A-Za-z0-9_$]*`
@@ -8478,6 +8488,8 @@ function replaceBranchLocalRefs(
8478
8488
  resolve: (name: string) => string,
8479
8489
  ): string {
8480
8490
  if (branchNames.length === 0) return text
8491
+ const astResult = rewriteScopedValueRefs(text, new Set(branchNames), resolve, { allowStatements: true })
8492
+ if (astResult !== null) return astResult
8481
8493
  const pattern = new RegExp(`(?<![\\w$])(${branchNames.join('|')})(?![\\w$])`, 'g')
8482
8494
  return replaceInExprContexts(text, pattern, (_match, name) => resolve(name))
8483
8495
  }
@@ -89,8 +89,16 @@ function walkWithScope(
89
89
  /**
90
90
  * True when `n` sits in a non-value position where a prop rewrite must
91
91
  * never apply: an object-literal key, a member-access name, a binding
92
- * position (parameter / variable / binding-element name), or a type
93
- * reference.
92
+ * position (parameter / variable / binding-element name), a destructuring
93
+ * declaration's SOURCE key (`propertyName`, e.g. the `local` in
94
+ * `const { local: renamed } = obj`), or a type reference.
95
+ *
96
+ * The `propertyName` check matters even though `BindingElement.name` is
97
+ * already excluded above it: a renamed destructuring pattern has BOTH —
98
+ * `propertyName` is the source key (`local`) and `name` is the local
99
+ * binding (`renamed`) — and pullfrog review on #2889 caught that only the
100
+ * latter was excluded, so a substituted name colliding with the SOURCE key
101
+ * still corrupted to invalid JS (`const { (_p.tag): renamed } = obj`).
94
102
  */
95
103
  function isNonValuePosition(n: ts.Identifier, parent: ts.Node | undefined): boolean {
96
104
  if (!parent) return false
@@ -98,6 +106,7 @@ function isNonValuePosition(n: ts.Identifier, parent: ts.Node | undefined): bool
98
106
  if (ts.isPropertyAccessExpression(parent) && parent.name === n) return true
99
107
  if (ts.isQualifiedName(parent) && parent.right === n) return true
100
108
  if ((ts.isParameter(parent) || ts.isVariableDeclaration(parent) || ts.isBindingElement(parent)) && parent.name === n) return true
109
+ if (ts.isBindingElement(parent) && parent.propertyName === n) return true
101
110
  if (ts.isTypeReferenceNode(parent)) return true
102
111
  return false
103
112
  }
@@ -130,49 +139,54 @@ export function collectAstPropRefs(
130
139
 
131
140
  /**
132
141
  * Scope-aware rewrite: parse `text` as an expression, walk it with the
133
- * binding stack, and splice `${PROPS_PARAM}.` onto exactly the
134
- * identifier references that are (a) in `propRefs` and (b) not
135
- * shadowed by a binding inside the text. Shorthand properties expand
136
- * (`{ org }` → `{ org: _p.org }`) so the result stays syntactically
137
- * valid.
142
+ * binding stack, and splice `replacementFor(name)` onto exactly the
143
+ * identifier references that are (a) in `names` and (b) not shadowed by
144
+ * a binding inside the text. Shorthand properties expand
145
+ * (`{ org }` → `{ org: <replacement> }`) so the result stays
146
+ * syntactically valid — this is the one place in the compiler that
147
+ * correctly distinguishes a value reference from an object-literal key
148
+ * / property-access name / shorthand property / binding name, so every
149
+ * caller doing this class of substitution (prop refs, #1425 branch-local
150
+ * refs, …) should route through this rather than growing its own
151
+ * regex-based text splice (#2856 — a second, regex-based substitution
152
+ * mechanism doesn't know shorthand is simultaneously a key and a value,
153
+ * and drops the key).
138
154
  *
139
- * `replacementFor` overrides the substitution text for a value
140
- * reference (default `${PROPS_PARAM}.<callerKey>`) e.g. the reactive
141
- * client-JS emit path (`emit-reactive.ts`'s `rewriteDestructuredPropsInExpr`)
142
- * wraps it in a `(_p.x ?? <default>)` fallback for a prop with a
143
- * destructure default. Kept as a caller override rather than a
144
- * duplicate AST walk in that module, per the "one decision, one
145
- * implementation" rule (CLAUDE.md) — this walk is already the one place
146
- * that correctly distinguishes a value reference from an object-literal
147
- * key / property-access name / shorthand property / binding name.
155
+ * With `allowStatements`, a `text` that fails to parse as a bare
156
+ * expression is retried as a standalone statement list (no wrapping
157
+ * parens) for callers whose raw-captured text isn't guaranteed to be
158
+ * expression-shaped (e.g. a `.map()`-callback preamble statement).
148
159
  *
149
- * Returns null when `text` does not parse cleanly as an expression
150
- * the caller falls back to the legacy regex rewrite.
160
+ * Returns null when `text` doesn't parse cleanly under either attempt
161
+ * the caller falls back to its own legacy mechanism.
151
162
  */
152
- function applyScopedPropRefRewrite(
163
+ export function rewriteScopedValueRefs(
153
164
  text: string,
154
- propRefs: Set<string>,
155
- propAliases?: ReadonlyMap<string, string>,
156
- replacementFor?: (localName: string, callerKey: string) => string,
165
+ names: ReadonlySet<string>,
166
+ replacementFor: (name: string) => string,
167
+ opts?: { allowStatements?: boolean },
157
168
  ): string | null {
158
169
  // Wrap in parens so object literals and arrows parse as expressions.
159
170
  const prefix = '('
160
- const sf = ts.createSourceFile('__bf_prop_rewrite.ts', `${prefix}${text}\n)`, ts.ScriptTarget.Latest, true)
161
- const parseDiagnostics = (sf as unknown as { parseDiagnostics?: unknown[] }).parseDiagnostics
162
- if (parseDiagnostics && parseDiagnostics.length > 0) return null
171
+ let sf = ts.createSourceFile('__bf_scoped_rewrite.ts', `${prefix}${text}\n)`, ts.ScriptTarget.Latest, true)
172
+ let parseDiagnostics = (sf as unknown as { parseDiagnostics?: unknown[] }).parseDiagnostics
173
+ let usedPrefix = prefix
174
+ if (parseDiagnostics && parseDiagnostics.length > 0) {
175
+ if (!opts?.allowStatements) return null
176
+ sf = ts.createSourceFile('__bf_scoped_rewrite.ts', text, ts.ScriptTarget.Latest, true)
177
+ parseDiagnostics = (sf as unknown as { parseDiagnostics?: unknown[] }).parseDiagnostics
178
+ if (parseDiagnostics && parseDiagnostics.length > 0) return null
179
+ usedPrefix = ''
180
+ }
163
181
 
164
182
  const edits: Array<{ start: number; end: number; replacement: string }> = []
165
183
  walkWithScope(sf, (n, parent, shadowed) => {
166
- if (shadowed || !propRefs.has(n.text)) return
184
+ if (shadowed || !names.has(n.text)) return
167
185
  if (isNonValuePosition(n, parent)) return
168
- const start = n.getStart(sf) - prefix.length
169
- const end = n.getEnd() - prefix.length
186
+ const start = n.getStart(sf) - usedPrefix.length
187
+ const end = n.getEnd() - usedPrefix.length
170
188
  if (start < 0 || end > text.length) return
171
- // `_p` is always keyed by the caller-facing name (`sourceName ?? name`
172
- // — #2524 CSR half); the local binding (`n.text`) only survives on the
173
- // left of a shorthand expansion.
174
- const callerKey = propAliases?.get(n.text) ?? n.text
175
- const value = replacementFor ? replacementFor(n.text, callerKey) : `${PROPS_PARAM}.${callerKey}`
189
+ const value = replacementFor(n.text)
176
190
  if (parent && ts.isShorthandPropertyAssignment(parent) && parent.name === n) {
177
191
  edits.push({ start, end, replacement: `${n.text}: ${value}` })
178
192
  return
@@ -188,6 +202,33 @@ function applyScopedPropRefRewrite(
188
202
  return result
189
203
  }
190
204
 
205
+ /**
206
+ * `applyScopedPropRefRewrite` specializes `rewriteScopedValueRefs` for
207
+ * the destructured-prop case: the substitution defaults to
208
+ * `${PROPS_PARAM}.<callerKey>`, where the caller-facing key comes from
209
+ * `propAliases` (`_p` is always keyed by the caller-facing name —
210
+ * `sourceName ?? name`, #2524 CSR half — the local binding only
211
+ * survives on the left of a shorthand expansion).
212
+ *
213
+ * `replacementFor` overrides the substitution text for a value
214
+ * reference — e.g. the reactive client-JS emit path
215
+ * (`emit-reactive.ts`'s `rewriteDestructuredPropsInExpr`) wraps it in a
216
+ * `(_p.x ?? <default>)` fallback for a prop with a destructure default.
217
+ * Kept as a caller override rather than a duplicate AST walk in that
218
+ * module, per the "one decision, one implementation" rule (CLAUDE.md).
219
+ */
220
+ function applyScopedPropRefRewrite(
221
+ text: string,
222
+ propRefs: Set<string>,
223
+ propAliases?: ReadonlyMap<string, string>,
224
+ replacementFor?: (localName: string, callerKey: string) => string,
225
+ ): string | null {
226
+ return rewriteScopedValueRefs(text, propRefs, (name) => {
227
+ const callerKey = propAliases?.get(name) ?? name
228
+ return replacementFor ? replacementFor(name, callerKey) : `${PROPS_PARAM}.${callerKey}`
229
+ })
230
+ }
231
+
191
232
  /**
192
233
  * Apply the targeted regex rewrite for one or more prop names on a
193
234
  * type-stripped expression text. Idempotent under `_p.X` (negative