@barefootjs/jsx 0.33.0 → 0.33.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.
Files changed (75) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/errors.d.ts +1 -0
  3. package/dist/errors.d.ts.map +1 -1
  4. package/dist/expression-parser.d.ts +14 -0
  5. package/dist/expression-parser.d.ts.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +268 -79
  9. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +8 -0
  12. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +12 -0
  15. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +1 -0
  17. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/html-template.d.ts +7 -7
  23. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/imports.d.ts +2 -2
  25. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/phases/provider-and-child-inits.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/prop-handling.d.ts +33 -0
  29. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/reactivity.d.ts +5 -0
  31. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/rewrite-props-object.d.ts +36 -8
  33. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  34. package/dist/ir-to-client-js/types.d.ts +21 -0
  35. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  36. package/dist/types.d.ts +14 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +2 -2
  39. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +23 -6
  40. package/src/__tests__/binding-scope-ratchet.test.ts +5 -1
  41. package/src/__tests__/child-components-in-map.test.ts +11 -3
  42. package/src/__tests__/client-js-generation.test.ts +48 -1
  43. package/src/__tests__/inline-jsx-callback.test.ts +55 -0
  44. package/src/__tests__/ir-jsx-props.test.ts +148 -0
  45. package/src/__tests__/issue-2705-branch-inner-loop-container.test.ts +91 -0
  46. package/src/__tests__/issue-2723-prop-alias-reactivity.test.ts +124 -0
  47. package/src/__tests__/markup-prop-brand.test.ts +49 -0
  48. package/src/__tests__/nested-loop-conditional.test.ts +20 -11
  49. package/src/__tests__/return-through-local-var.test.ts +269 -0
  50. package/src/__tests__/rewrite-props-object.test.ts +41 -4
  51. package/src/analyzer.ts +71 -0
  52. package/src/errors.ts +17 -1
  53. package/src/expression-parser.ts +26 -0
  54. package/src/index.ts +1 -1
  55. package/src/ir-to-client-js/collect-elements.ts +49 -45
  56. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +19 -0
  57. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +22 -3
  58. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +5 -2
  59. package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +12 -0
  60. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +9 -0
  61. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +7 -1
  62. package/src/ir-to-client-js/element-refs.ts +8 -0
  63. package/src/ir-to-client-js/emit-reactive.ts +91 -23
  64. package/src/ir-to-client-js/emit-registration.ts +26 -7
  65. package/src/ir-to-client-js/generate-init.ts +1 -1
  66. package/src/ir-to-client-js/html-template.ts +8 -8
  67. package/src/ir-to-client-js/imports.ts +5 -0
  68. package/src/ir-to-client-js/index.ts +10 -4
  69. package/src/ir-to-client-js/phases/provider-and-child-inits.ts +5 -1
  70. package/src/ir-to-client-js/prop-handling.ts +94 -0
  71. package/src/ir-to-client-js/reactivity.ts +59 -0
  72. package/src/ir-to-client-js/rewrite-props-object.ts +50 -10
  73. package/src/ir-to-client-js/types.ts +21 -0
  74. package/src/jsx-to-ir.ts +204 -9
  75. package/src/types.ts +14 -0
package/src/jsx-to-ir.ts CHANGED
@@ -1066,7 +1066,37 @@ function buildIRRoot(analyzer: AnalyzerContext): IRNode | null {
1066
1066
  // scope; the inner IR must not double-mark a nested element as root.
1067
1067
  ctx.isRoot = false
1068
1068
  const ir = transformJsxExpression(jsxReturn, ctx)
1069
- if (ir === null) return null
1069
+ if (ir === null) {
1070
+ // BF027 (#2720): a bare identifier at return position that names a
1071
+ // local `const`/`let` PROVEN to hold JSX (`jsxConstants` — pure JSX
1072
+ // literal; `inlineableJsxConsts` — a JSX-shaped ternary/`&&`/`||`/`??`)
1073
+ // is the "returned JSX through a local variable" shape. Return position
1074
+ // deliberately does not resolve identifiers the way JSX-child position
1075
+ // does (see the #547/#1409 inlining above), so the scalar-leaf fallback
1076
+ // would otherwise drop this component with zero files and zero
1077
+ // diagnostics. Scoped to identifiers already proven JSX-holding by
1078
+ // those two maps so an ordinary non-JSX return (`return 42`, `return
1079
+ // someHelperResult`) — including from a PascalCase-but-not-a-component
1080
+ // export the analyzer's syntactic component detector still matches —
1081
+ // stays silent exactly as before.
1082
+ if (
1083
+ ts.isIdentifier(jsxReturn) &&
1084
+ (analyzer.jsxConstants.has(jsxReturn.text) || analyzer.inlineableJsxConsts.has(jsxReturn.text))
1085
+ ) {
1086
+ analyzer.errors.push(createError(
1087
+ ErrorCodes.RETURN_VALUE_NOT_JSX,
1088
+ getSourceLocation(jsxReturn, analyzer.sourceFile, analyzer.filePath),
1089
+ {
1090
+ message:
1091
+ `Component '${analyzer.componentName ?? '(unknown)'}' return value is not recognized ` +
1092
+ `as JSX — return the JSX expression directly instead of binding it to a local variable ` +
1093
+ `first (\`return ${jsxReturn.text}\` after \`const ${jsxReturn.text} = <jsx/>\` is not ` +
1094
+ `resolved at return position).`,
1095
+ },
1096
+ ))
1097
+ }
1098
+ return null
1099
+ }
1070
1100
  return wrapInScopeElement(ir)
1071
1101
  }
1072
1102
 
@@ -1914,6 +1944,52 @@ function unwrapHoistedFragment(node: IRNode): IRNode {
1914
1944
  return { ...only, needsScope: true }
1915
1945
  }
1916
1946
 
1947
+ // #2732: a `needsScopeComment` fragment's five hydration markers move to
1948
+ // the wrapping comment, but `data-key` needs to stay on an element (see
1949
+ // `IRElement.carriesDataKey`'s docstring for why). Mark the first ELEMENT
1950
+ // among `children` — immutably (`{ ...el, carriesDataKey: true }`), matching
1951
+ // the rest of this file's post-hoc IR tagging (e.g. `unwrapHoistedFragment`)
1952
+ // rather than mutating the child in place.
1953
+ function markDataKeyCarrier(children: IRNode[]): IRNode[] {
1954
+ for (let i = 0; i < children.length; i++) {
1955
+ const marked = markCarrierIn(children[i])
1956
+ if (!marked) continue
1957
+ const out = children.slice()
1958
+ out[i] = marked
1959
+ return out
1960
+ }
1961
+ return children
1962
+ }
1963
+
1964
+ /**
1965
+ * The marked copy of `node` if this subtree can render an element in first
1966
+ * position, or null if it cannot.
1967
+ *
1968
+ * Descends through `conditional` because a fragment whose only top-level
1969
+ * child is a ternary or `&&` is still a single-visual-root row —
1970
+ * `{done ? <li class="done"/> : <li/>}` renders exactly one `<li>`. A flat
1971
+ * `children.findIndex(c => c.type === 'element')` returns -1 there and
1972
+ * silently reproduces #2732's own symptom for a shape the fix was supposed
1973
+ * to cover.
1974
+ *
1975
+ * BOTH branches are marked, not just one: they are mutually exclusive at
1976
+ * render time, so whichever is taken carries the key, and marking only
1977
+ * `whenTrue` would drop it exactly when the condition is false.
1978
+ */
1979
+ function markCarrierIn(node: IRNode): IRNode | null {
1980
+ if (node.type === 'element') {
1981
+ return { ...(node as IRElement), carriesDataKey: true }
1982
+ }
1983
+ if (node.type === 'conditional') {
1984
+ const cond = node as IRConditional
1985
+ const whenTrue = markCarrierIn(cond.whenTrue)
1986
+ const whenFalse = markCarrierIn(cond.whenFalse)
1987
+ if (!whenTrue && !whenFalse) return null
1988
+ return { ...cond, whenTrue: whenTrue ?? cond.whenTrue, whenFalse: whenFalse ?? cond.whenFalse }
1989
+ }
1990
+ return null
1991
+ }
1992
+
1917
1993
  function transformFragment(
1918
1994
  node: ts.JsxFragment,
1919
1995
  ctx: TransformContext
@@ -1938,7 +2014,7 @@ function transformFragment(
1938
2014
 
1939
2015
  return {
1940
2016
  type: 'fragment',
1941
- children,
2017
+ children: needsScopeComment ? markDataKeyCarrier(children) : children,
1942
2018
  transparent: isTransparent || undefined,
1943
2019
  needsScopeComment,
1944
2020
  loc: getSourceLocation(node, ctx.sourceFile, ctx.filePath),
@@ -6999,6 +7075,100 @@ function getStringValue(node: ts.Expression): string | null {
6999
7075
  // Component Props Processing
7000
7076
  // =============================================================================
7001
7077
 
7078
+ /**
7079
+ * Strip every legal-in-.tsx TRANSPARENT TS wrapper around an expression —
7080
+ * parens, `as`, `satisfies`, and postfix non-null `!` — repeatedly, so a
7081
+ * stack of them (`(x as any)!`) unwraps in one call. None of these change
7082
+ * the RUNTIME value; they are compile-time-only annotations TypeScript
7083
+ * erases, so a caller checking "is this JSX" (or "does this ternary/array
7084
+ * wrap JSX") must see through all of them to avoid false negatives (#2703
7085
+ * Copilot review on #2667: `header={cond ? (<a/> as any) : (<b/> as any)}`
7086
+ * unwrapped only parens, so the `as`-wrapped shape slipped past the naked-
7087
+ * wrapper refusal and still spliced raw JSX into the client bundle).
7088
+ * Angle-bracket type assertions (`<any>x`) are illegal in `.tsx` — the
7089
+ * `<` is always JSX — so there is no fourth wrapper kind to handle here.
7090
+ */
7091
+ function unwrapTransparentTsWrappers(node: ts.Expression): ts.Expression {
7092
+ let n = node
7093
+ while (
7094
+ ts.isParenthesizedExpression(n) ||
7095
+ ts.isAsExpression(n) ||
7096
+ ts.isSatisfiesExpression(n) ||
7097
+ ts.isNonNullExpression(n)
7098
+ ) {
7099
+ n = n.expression
7100
+ }
7101
+ return n
7102
+ }
7103
+
7104
+ /**
7105
+ * Whether `node` (a `ConditionalExpression` or `ArrayLiteralExpression`
7106
+ * already confirmed by the caller) has JSX syntax somewhere inside it —
7107
+ * a ternary arm that is (or recursively resolves to) a JSX element/
7108
+ * fragment, or an array element that is one. Used by #2667's naked-
7109
+ * wrapper refusal: a ternary/array prop initializer with NO JSX inside
7110
+ * (`disabled={cond ? a : b}`, `items={[a, b]}`) is ordinary and must keep
7111
+ * compiling exactly as before — only the JSX-carrying shape is refused.
7112
+ *
7113
+ * Deliberately narrow, mirroring `expressionContainsJsx`'s sibling scope
7114
+ * in `analyzer.ts`-adjacent code: only descends through transparent TS
7115
+ * wrappers (`unwrapTransparentTsWrappers`), `ConditionalExpression` arms,
7116
+ * and `ArrayLiteralExpression` elements (spread elements unwrapped too)
7117
+ * — the two wrapper shapes the issue names, each itself possibly
7118
+ * TS-wrapped (`(cond ? <a/> : <b/>) as any`, `cond ? (<a/> as any) : <b/>`,
7119
+ * #2703). It does not chase JSX through arbitrary call arguments, object
7120
+ * literals, or logical expressions; those are out of this refusal's
7121
+ * scope and stay on the pre-existing (working, non-JSX) expression path.
7122
+ */
7123
+ function expressionWrapsJsx(node: ts.Expression): boolean {
7124
+ const n = unwrapTransparentTsWrappers(node)
7125
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) return true
7126
+ if (ts.isConditionalExpression(n)) {
7127
+ return expressionWrapsJsx(n.whenTrue) || expressionWrapsJsx(n.whenFalse)
7128
+ }
7129
+ if (ts.isArrayLiteralExpression(n)) {
7130
+ return n.elements.some((el) => expressionWrapsJsx(ts.isSpreadElement(el) ? el.expression : el))
7131
+ }
7132
+ return false
7133
+ }
7134
+
7135
+ /**
7136
+ * Refuse a component prop whose initializer is a ternary/array literally
7137
+ * wrapping JSX (#2667) — see `expressionWrapsJsx`'s docstring and the call
7138
+ * site above for why this can't fall through to the plain `expression`
7139
+ * AttrValue path (raw JSX syntax would splice into the emitted client
7140
+ * JS) and why the fragment-wrap escape is NOT offered here (BF021 message
7141
+ * below explains the unsoundness inline; see #2667's tracking issue for
7142
+ * the full door-inventory finding).
7143
+ */
7144
+ function reportNakedJsxWrapperProp(
7145
+ ctx: TransformContext,
7146
+ attr: ts.JsxAttribute,
7147
+ propName: string,
7148
+ jsxExpr: ts.Expression,
7149
+ ): void {
7150
+ const shape = ts.isConditionalExpression(jsxExpr) ? 'a ternary' : 'an array literal'
7151
+ ctx.analyzer.errors.push(
7152
+ createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN, getSourceLocation(attr, ctx.sourceFile, ctx.filePath), {
7153
+ message:
7154
+ `Prop '${propName}' is ${shape} wrapping JSX (${jsxExpr.getText(ctx.sourceFile)}). ` +
7155
+ `This shape is not compiled — only a JSX element/fragment given DIRECTLY as the prop value is.`,
7156
+ suggestion: {
7157
+ message:
7158
+ `Move the conditional/array out of the prop position: compute it in a local ` +
7159
+ `const and pass it as the component's children instead of a named prop ` +
7160
+ `(e.g. const ${propName} = ${jsxExpr.getText(ctx.sourceFile)}; <Comp>{${propName}}</Comp>). ` +
7161
+ `Wrapping the ternary/array in a fragment at the prop position ` +
7162
+ `(${propName}={<>{${jsxExpr.getText(ctx.sourceFile)}}</>}) is NOT a safe escape here: it compiles, ` +
7163
+ `but the child's own reactive prop getter receives the branch's HTML unbranded and re-escapes it as ` +
7164
+ `text on the child's very next reactive run, corrupting the DOM (a narrower gap #2651's door ` +
7165
+ `inventory left open — tracked separately).`,
7166
+ escape: [{ kind: 'rewrite' }],
7167
+ },
7168
+ }),
7169
+ )
7170
+ }
7171
+
7002
7172
  function processComponentProps(
7003
7173
  attributes: ts.JsxAttributes,
7004
7174
  ctx: TransformContext
@@ -7015,14 +7185,14 @@ function processComponentProps(
7015
7185
 
7016
7186
  const name = attr.name.getText(ctx.sourceFile)
7017
7187
 
7018
- // JSX element/fragment as prop value: controls={<select />} or
7019
- // controls={(<div/>)}. Carried as a `jsx-children` AttrValue variant
7020
- // so adapters render the JSX inline rather than passing a string.
7188
+ // JSX element/fragment as prop value: controls={<select />},
7189
+ // controls={(<div/>)}, or controls={<div/> as any} (#2703 the
7190
+ // entity is still directly JSX; `as`/`satisfies`/`!` are type-only
7191
+ // and erased, so this must classify identically to the bare form).
7192
+ // Carried as a `jsx-children` AttrValue variant so adapters render
7193
+ // the JSX inline rather than passing a string.
7021
7194
  if (attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression) {
7022
- let jsxExpr = attr.initializer.expression
7023
- while (ts.isParenthesizedExpression(jsxExpr)) {
7024
- jsxExpr = jsxExpr.expression
7025
- }
7195
+ const jsxExpr = unwrapTransparentTsWrappers(attr.initializer.expression)
7026
7196
  if (ts.isJsxElement(jsxExpr) || ts.isJsxSelfClosingElement(jsxExpr) || ts.isJsxFragment(jsxExpr)) {
7027
7197
  const prevInsideComponentChildren = ctx.insideComponentChildren
7028
7198
  ctx.insideComponentChildren = true
@@ -7037,6 +7207,31 @@ function processComponentProps(
7037
7207
  continue
7038
7208
  }
7039
7209
  }
7210
+
7211
+ // #2667: a ternary or array LITERALLY WRAPPING JSX at this prop
7212
+ // position (`header={cond ? <a/> : <b/>}`, `header={[<a/>, <b/>]}`)
7213
+ // is neither the direct-element shape above nor a plain value
7214
+ // expression — it only classifies as `jsx-children` when the JSX is
7215
+ // hoisted behind a fragment (`header={<>{cond ? <a/> : <b/>}</>}`,
7216
+ // `unwrapHoistedFragment` above). Left undetected, this falls to the
7217
+ // plain `expression` path below, which stringifies the initializer's
7218
+ // SOURCE TEXT — literal JSX syntax spliced into the emitted client
7219
+ // JS (invalid at runtime; the #2651 door inventory's discovery).
7220
+ // Refuse loudly instead of guessing a lowering: the fragment-wrap
7221
+ // escape this diagnostic once considered recommending turns out to
7222
+ // be unsound for the same shape (`isSingleElementJsxChildren`'s
7223
+ // docstring in `ir-to-client-js/collect-elements.ts` — a
7224
+ // conditional-in-fragment reaches `initChild`'s getter UNbranded,
7225
+ // corrupting the child's DOM the moment its own reactive effect
7226
+ // first reads the prop), so only the children-passthrough escape is
7227
+ // offered.
7228
+ if (
7229
+ (ts.isConditionalExpression(jsxExpr) || ts.isArrayLiteralExpression(jsxExpr)) &&
7230
+ expressionWrapsJsx(jsxExpr)
7231
+ ) {
7232
+ reportNakedJsxWrapperProp(ctx, attr, name, jsxExpr)
7233
+ continue
7234
+ }
7040
7235
  }
7041
7236
 
7042
7237
  let value = getAttributeValue(attr, ctx)
package/src/types.ts CHANGED
@@ -347,6 +347,20 @@ export interface IRElement {
347
347
  children: IRNode[]
348
348
  slotId: string | null
349
349
  needsScope: boolean
350
+ /**
351
+ * Set on the first ELEMENT among a `needsScopeComment` fragment root's own
352
+ * top-level children (#2732) — the fragment's five hydration markers
353
+ * (`bf-s`/`bf-h`/`bf-m`/`bf-r`/`bf-p`) move to the wrapping
354
+ * `<!--bf-scope:...-->` comment instead of an element attribute, but
355
+ * `data-key` has to stay on an element because the client runtime's
356
+ * `mapArray` adopt loop reads it as a DOM attribute
357
+ * (`primaryEl.dataset.key`, map-array.ts). "First element, not first
358
+ * node" mirrors the CSR runtime's own resolution of the identical
359
+ * ambiguity (`component.ts`'s `roots.find(isElement)`, #2735) rather than
360
+ * inventing a second answer. Always `undefined` when `needsScope` is
361
+ * true — the two are mutually exclusive ways of carrying the same key.
362
+ */
363
+ carriesDataKey?: boolean
350
364
  /**
351
365
  * Page-lifecycle boundary id for an element lowered from `<Region>`
352
366
  * (spec/router.md). Set only on region host elements; adapters emit it as