@barefootjs/jsx 0.33.2 → 0.33.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.
- package/dist/analyzer.d.ts +17 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/compiler.d.ts +21 -5
- package/dist/compiler.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +707 -431
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +60 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/prop-handling.d.ts +4 -7
- package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +26 -2
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/props-binding.d.ts +35 -0
- package/dist/props-binding.d.ts.map +1 -1
- package/dist/types.d.ts +50 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +145 -97
- package/src/__tests__/child-component-ref-not-mirrored.test.ts +90 -0
- package/src/__tests__/ir-to-client-js/imports.test.ts +107 -0
- package/src/__tests__/ir-to-client-js/merge-compiled-client-js-imports.test.ts +138 -0
- package/src/__tests__/issue-2754-rest-spread-needs-slot.test.ts +85 -0
- package/src/__tests__/issue-2756-loop-row-honors-client-only.test.ts +173 -0
- package/src/__tests__/merge-template-imports.test.ts +41 -1
- package/src/__tests__/multi-component-shared-default-import.test.ts +55 -0
- package/src/__tests__/root-key-relay.test.ts +170 -0
- package/src/__tests__/signal-getter-not-called.test.ts +149 -0
- package/src/__tests__/state-only-file-default-import.test.ts +47 -0
- package/src/analyzer.ts +36 -0
- package/src/compiler.ts +94 -104
- package/src/index.ts +1 -1
- package/src/ir-to-client-js/collect-elements.ts +27 -5
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +6 -2
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -2
- package/src/ir-to-client-js/html-template.ts +122 -12
- package/src/ir-to-client-js/imports.ts +178 -5
- package/src/ir-to-client-js/index.ts +5 -0
- package/src/ir-to-client-js/prop-handling.ts +6 -17
- package/src/ir-to-client-js/utils.ts +30 -2
- package/src/jsx-to-ir.ts +480 -52
- package/src/props-binding.ts +51 -0
- package/src/types.ts +47 -13
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Import detection and DOM import management.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import ts from 'typescript'
|
|
5
6
|
import type { ComponentIR, IRNode } from '../types.ts'
|
|
6
7
|
import { isClientBuiltinName } from '../builtins.ts'
|
|
7
8
|
import { collectValueReferencedNames } from '../value-references.ts'
|
|
@@ -29,6 +30,9 @@ export const RUNTIME_IMPORT_CANDIDATES = [
|
|
|
29
30
|
// the compiler-built HTML at the producer (renderChild / initChild props);
|
|
30
31
|
// `escapeTextOrMarkup` unwraps it at the claim-plan-'markup' template slot.
|
|
31
32
|
'bfMarkup', 'escapeTextOrMarkup',
|
|
33
|
+
// Nullish guard for a bare `{children}` passthrough splice (#2775) — the
|
|
34
|
+
// value is already-stringified markup, never escaped, just nullish-safe.
|
|
35
|
+
'markupOrEmpty',
|
|
32
36
|
'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot', '__bfText',
|
|
33
37
|
// Claim-plan interpreter (slot unification A2/A3, spec/slot-unification.md)
|
|
34
38
|
// — the "one claim mechanism" that replaced `patchSlotRange` and
|
|
@@ -148,6 +152,168 @@ export function makeValueUsageTest(generatedCode: string): (localName: string) =
|
|
|
148
152
|
}
|
|
149
153
|
}
|
|
150
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Render already-filtered-to-used specifier fragments for one import source
|
|
157
|
+
* into one or two legal import declaration lines. Shared by every call site
|
|
158
|
+
* that re-serializes an `ImportInfo`'s specifiers into client-JS import
|
|
159
|
+
* text — `collectExternalImports` below and the state-only-file client-JS
|
|
160
|
+
* path (`compiler.ts`'s single-component early return for a `.tsx` with no
|
|
161
|
+
* JSX return but exported `@client` module signals) — so the
|
|
162
|
+
* default/namespace handling lives in exactly one place.
|
|
163
|
+
*
|
|
164
|
+
* A default or namespace specifier needs its own import syntax
|
|
165
|
+
* (`import X from '...'` / `import * as X from '...'`), never the
|
|
166
|
+
* named-import braces a plain specifier gets — a plain `import { lock }
|
|
167
|
+
* from '...'` for a DEFAULT-imported `lock` compiles to a real, silently-
|
|
168
|
+
* wrong ESM import (no such named export) that only surfaces once a
|
|
169
|
+
* bundler actually resolves it (#2767 follow-up: a server component's own
|
|
170
|
+
* compiled init previously never reached a real Rollup graph, so this was
|
|
171
|
+
* unreachable until that gap closed).
|
|
172
|
+
*
|
|
173
|
+
* `import Default, { a, b } from '...'` is the only legal single-line
|
|
174
|
+
* pairing — a namespace specifier can't combine with named ones, but
|
|
175
|
+
* multiple import declarations for the same source are legal ESM, so a
|
|
176
|
+
* used namespace specifier always gets its own line.
|
|
177
|
+
*/
|
|
178
|
+
export function renderUsedImportLines(
|
|
179
|
+
source: string,
|
|
180
|
+
usedDefault: string | null,
|
|
181
|
+
usedNamespace: string | null,
|
|
182
|
+
usedNamed: string[],
|
|
183
|
+
): string[] {
|
|
184
|
+
const lines: string[] = []
|
|
185
|
+
const defaultAndNamed = [
|
|
186
|
+
usedDefault,
|
|
187
|
+
usedNamed.length > 0 ? `{ ${usedNamed.join(', ')} }` : null,
|
|
188
|
+
].filter((part): part is string => part !== null).join(', ')
|
|
189
|
+
if (defaultAndNamed) lines.push(`import ${defaultAndNamed} from '${source}'`)
|
|
190
|
+
if (usedNamespace) lines.push(`import * as ${usedNamespace} from '${source}'`)
|
|
191
|
+
return lines
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Merge multiple sibling components' compiled client-JS blobs (one file
|
|
196
|
+
* with several `export function`s, e.g. `compileMultipleComponents`'s two
|
|
197
|
+
* `.client.js` outputs) into one conflict-free block.
|
|
198
|
+
*
|
|
199
|
+
* Real top-level `ImportDeclaration` statements are found via a
|
|
200
|
+
* `ts.createSourceFile` AST walk — never a text/regex line scan — so a
|
|
201
|
+
* string or template-literal VALUE that merely contains a line starting
|
|
202
|
+
* with `import ` (a docs component embedding a code sample, say) can never
|
|
203
|
+
* be torn out of its literal and hoisted into the imports block. This
|
|
204
|
+
* mirrors `combine-client-js.ts`'s `parseAndMerge`, the established
|
|
205
|
+
* precedent for exactly this shape of parse (see that file's docstring
|
|
206
|
+
* and issue #1702, the regression it exists to prevent) — CLAUDE.md
|
|
207
|
+
* requires it for "compiled client JS" specifically. Reaching a bundler
|
|
208
|
+
* for the FIRST time is precisely what a plain server component newly
|
|
209
|
+
* promoted to a Rollup entry by `needsClientEntry` (#2767) now does, so a
|
|
210
|
+
* line-based scan here carries real risk, not just a style violation.
|
|
211
|
+
*
|
|
212
|
+
* Differs from `parseAndMerge` in two ways required by this call site:
|
|
213
|
+
* (1) default and named specifiers from the same source fold into ONE
|
|
214
|
+
* declaration via `renderUsedImportLines`'s rule, rather than surviving
|
|
215
|
+
* as separate verbatim, exact-string-deduped lines — the fold is what
|
|
216
|
+
* prevents the duplicate-binding `SyntaxError` two sibling components can
|
|
217
|
+
* otherwise produce for a shared default import (#2767 follow-up); (2) an
|
|
218
|
+
* unresolved `@bf-child:` placeholder import is KEPT (deduped by exact
|
|
219
|
+
* text, same as any other side-effect import), never dropped — unlike
|
|
220
|
+
* `parseAndMerge`'s parent-child inlining case, this merge runs inside
|
|
221
|
+
* `compileMultipleComponents`, BEFORE `@barefootjs/vite`'s `resolveId`
|
|
222
|
+
* gets a chance to rewrite the placeholder into a real module reference.
|
|
223
|
+
*
|
|
224
|
+
* Returns the fully assembled `<imports>\n\n<code…>` block ready to use
|
|
225
|
+
* as a `.client.js` file's content.
|
|
226
|
+
*/
|
|
227
|
+
export function mergeCompiledClientJsImports(codeBlobs: string[]): string {
|
|
228
|
+
const sourceOrder: string[] = []
|
|
229
|
+
const namedBySource = new Map<string, Set<string>>()
|
|
230
|
+
const defaultBySource = new Map<string, string>()
|
|
231
|
+
const otherImports: string[] = []
|
|
232
|
+
const seenOther = new Set<string>()
|
|
233
|
+
const codeSections: string[] = []
|
|
234
|
+
|
|
235
|
+
const ensureSource = (source: string): Set<string> => {
|
|
236
|
+
if (!namedBySource.has(source)) {
|
|
237
|
+
namedBySource.set(source, new Set())
|
|
238
|
+
sourceOrder.push(source)
|
|
239
|
+
}
|
|
240
|
+
return namedBySource.get(source)!
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (const content of codeBlobs) {
|
|
244
|
+
const sourceFile = ts.createSourceFile(
|
|
245
|
+
'combine.js',
|
|
246
|
+
content,
|
|
247
|
+
ts.ScriptTarget.Latest,
|
|
248
|
+
/*setParentNodes*/ false,
|
|
249
|
+
ts.ScriptKind.JS,
|
|
250
|
+
)
|
|
251
|
+
const importSpans: Array<[number, number]> = []
|
|
252
|
+
|
|
253
|
+
for (const stmt of sourceFile.statements) {
|
|
254
|
+
if (!ts.isImportDeclaration(stmt)) continue
|
|
255
|
+
const start = stmt.getStart(sourceFile)
|
|
256
|
+
const end = stmt.getEnd()
|
|
257
|
+
importSpans.push([start, end])
|
|
258
|
+
|
|
259
|
+
const clause = stmt.importClause
|
|
260
|
+
const bindings = clause?.namedBindings
|
|
261
|
+
const specifier = ts.isStringLiteral(stmt.moduleSpecifier) ? stmt.moduleSpecifier.text : ''
|
|
262
|
+
const isNamespace = !!bindings && ts.isNamespaceImport(bindings)
|
|
263
|
+
const isNamed = !!bindings && ts.isNamedImports(bindings)
|
|
264
|
+
|
|
265
|
+
// A namespace binding (`import * as NS from '…'`, or a combined
|
|
266
|
+
// `import Default, * as NS from '…'`) is never folded — it always
|
|
267
|
+
// falls through to the verbatim-keep branch below, same as
|
|
268
|
+
// `parseAndMerge`'s. Checking `isNamespace` FIRST (not just `!isNamed`)
|
|
269
|
+
// matters for the combined-with-default shape specifically: a naive
|
|
270
|
+
// `clause?.name || isNamed` would route it into the fold branch below
|
|
271
|
+
// on the strength of the default clause alone and silently drop the
|
|
272
|
+
// namespace half, since only `isNamed` is read there. No current
|
|
273
|
+
// producer of `clientJs` output emits that combined shape
|
|
274
|
+
// (`renderUsedImportLines` always splits a used default+namespace
|
|
275
|
+
// pair into two separate lines), but the classification must stay
|
|
276
|
+
// correct independent of that invariant.
|
|
277
|
+
if (!isNamespace && (clause?.name || isNamed)) {
|
|
278
|
+
// Default and/or named specifiers — fold by source.
|
|
279
|
+
const set = ensureSource(specifier)
|
|
280
|
+
if (clause?.name && !defaultBySource.has(specifier)) {
|
|
281
|
+
defaultBySource.set(specifier, clause.name.text)
|
|
282
|
+
}
|
|
283
|
+
if (isNamed) {
|
|
284
|
+
for (const el of (bindings as ts.NamedImports).elements) {
|
|
285
|
+
set.add(el.propertyName ? `${el.propertyName.text} as ${el.name.text}` : el.name.text)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
// Namespace or side-effect import (including an unresolved
|
|
290
|
+
// `@bf-child:` placeholder) — kept verbatim, deduped by exact text.
|
|
291
|
+
const stmtText = content.slice(start, end)
|
|
292
|
+
if (!seenOther.has(stmtText)) {
|
|
293
|
+
seenOther.add(stmtText)
|
|
294
|
+
otherImports.push(stmtText)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let code = ''
|
|
300
|
+
let cursor = 0
|
|
301
|
+
for (const [start, end] of importSpans) {
|
|
302
|
+
code += content.slice(cursor, start)
|
|
303
|
+
cursor = end
|
|
304
|
+
}
|
|
305
|
+
code += content.slice(cursor)
|
|
306
|
+
code = code.trim()
|
|
307
|
+
if (code) codeSections.push(code)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const mergedImports = sourceOrder.flatMap(source =>
|
|
311
|
+
renderUsedImportLines(source, defaultBySource.get(source) ?? null, null, [...namedBySource.get(source)!]),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
return [...mergedImports, ...otherImports, '', ...codeSections].join('\n')
|
|
315
|
+
}
|
|
316
|
+
|
|
151
317
|
/**
|
|
152
318
|
* Collect external (non-DOM, non-component) imports that are used in generated code.
|
|
153
319
|
* These are third-party libraries like @barefootjs/form, zod, etc. that need to be
|
|
@@ -171,23 +337,30 @@ export function collectExternalImports(ir: ComponentIR, generatedCode: string, l
|
|
|
171
337
|
|
|
172
338
|
// Check which specifiers are actually used in the generated code.
|
|
173
339
|
// Skip component names — they are rendered via initChild(), not imported directly.
|
|
174
|
-
const
|
|
340
|
+
const usedNamed: string[] = []
|
|
341
|
+
let usedDefault: string | null = null
|
|
342
|
+
let usedNamespace: string | null = null
|
|
175
343
|
for (const spec of imp.specifiers) {
|
|
176
344
|
// Per-specifier `import { type Foo }` has no value binding — #2432.
|
|
177
345
|
if (spec.isTypeOnly) continue
|
|
178
346
|
const localName = spec.alias || spec.name
|
|
179
347
|
if (componentNames.has(localName)) continue
|
|
180
|
-
if (isUsedAsValue(localName))
|
|
181
|
-
|
|
348
|
+
if (!isUsedAsValue(localName)) continue
|
|
349
|
+
if (spec.isDefault) {
|
|
350
|
+
usedDefault = localName
|
|
351
|
+
} else if (spec.isNamespace) {
|
|
352
|
+
usedNamespace = localName
|
|
353
|
+
} else {
|
|
354
|
+
usedNamed.push(spec.alias ? `${spec.name} as ${spec.alias}` : spec.name)
|
|
182
355
|
}
|
|
183
356
|
}
|
|
184
357
|
|
|
185
|
-
if (
|
|
358
|
+
if (usedDefault || usedNamespace || usedNamed.length > 0) {
|
|
186
359
|
let source = imp.source
|
|
187
360
|
if (ir.metadata.clientSignalImportSources?.has(source)) {
|
|
188
361
|
source = source.replace(/\.tsx?$/, '') + '.client.js'
|
|
189
362
|
}
|
|
190
|
-
importLines.push(
|
|
363
|
+
importLines.push(...renderUsedImportLines(source, usedDefault, usedNamespace, usedNamed))
|
|
191
364
|
}
|
|
192
365
|
}
|
|
193
366
|
return importLines
|
|
@@ -217,6 +217,11 @@ function needsClientJs(ctx: ClientJsContext): boolean {
|
|
|
217
217
|
ctx.conditionalElements.length > 0 ||
|
|
218
218
|
ctx.loopElements.length > 0 ||
|
|
219
219
|
ctx.refElements.length > 0 ||
|
|
220
|
+
// An element forwarding the caller's leftover props needs `init` to
|
|
221
|
+
// run `applyRestAttrs` against it — that call is the ONLY thing that
|
|
222
|
+
// applies those attributes on a pure CSR mount, since neither template
|
|
223
|
+
// can carry a bag whose keys are unknown at compile time (#2754).
|
|
224
|
+
ctx.restAttrElements.length > 0 ||
|
|
220
225
|
ctx.childInits.length > 0 ||
|
|
221
226
|
ctx.reactiveAttrs.length > 0 ||
|
|
222
227
|
ctx.clientOnlyElements.length > 0 ||
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { ParamInfo, SignalInfo } from '../types.ts'
|
|
6
6
|
import type { ClientJsContext } from './types.ts'
|
|
7
7
|
import type { BindingScope } from '../scope/binding-scope.ts'
|
|
8
|
+
import { resolveRestSpreadOriginCore } from '../props-binding.ts'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Which of the component's two "forwards the caller's leftover props"
|
|
@@ -29,25 +30,13 @@ import type { BindingScope } from '../scope/binding-scope.ts'
|
|
|
29
30
|
* `spreadAttrs({...})` call keyed by the alias name instead of the
|
|
30
31
|
* runtime-visible one.
|
|
31
32
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* value isn't a bare identifier already on the chain (e.g. a real computed
|
|
37
|
-
* object) stops the walk, so a genuinely different spread expression is
|
|
38
|
-
* never mistaken for the rest object.
|
|
33
|
+
* The walk itself lives in `props-binding.ts`'s `resolveRestSpreadOriginCore`,
|
|
34
|
+
* shared with Phase 1's slot-id decision so both phases agree on which
|
|
35
|
+
* spreads forward the caller's leftover props (#2754). This wrapper only
|
|
36
|
+
* supplies the `ClientJsContext`-shaped inputs.
|
|
39
37
|
*/
|
|
40
38
|
export function resolveRestSpreadOrigin(ctx: ClientJsContext, name: string): 'rest' | 'props' | null {
|
|
41
|
-
|
|
42
|
-
const visited = new Set<string>()
|
|
43
|
-
let current: string | undefined = name.trim()
|
|
44
|
-
while (current !== undefined && !visited.has(current)) {
|
|
45
|
-
if (ctx.restPropsName && current === ctx.restPropsName) return 'rest'
|
|
46
|
-
if (ctx.propsObjectName && current === ctx.propsObjectName) return 'props'
|
|
47
|
-
visited.add(current)
|
|
48
|
-
current = byName.get(current)?.trim()
|
|
49
|
-
}
|
|
50
|
-
return null
|
|
39
|
+
return resolveRestSpreadOriginCore(ctx, localConstantValues(ctx), name)
|
|
51
40
|
}
|
|
52
41
|
|
|
53
42
|
/**
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
loopEndMarker,
|
|
26
26
|
loopItemMarker,
|
|
27
27
|
toHTMLAttrName as toHtmlAttrName,
|
|
28
|
+
keyAttrName as sharedKeyAttrName,
|
|
28
29
|
} from '@barefootjs/shared'
|
|
29
30
|
|
|
30
31
|
export { DATA_KEY, DATA_KEY_PREFIX, DATA_BF_PH, BF_LOOP_START, BF_LOOP_END, loopStartMarker, loopEndMarker, loopItemMarker, toHtmlAttrName }
|
|
@@ -39,9 +40,36 @@ export const PROPS_PARAM = '_p'
|
|
|
39
40
|
* Get the data-key attribute name for a given loop depth.
|
|
40
41
|
* Outer loop (depth 0): 'data-key'
|
|
41
42
|
* Nested loops (depth N): 'data-key-N'
|
|
43
|
+
*
|
|
44
|
+
* Re-exported from `@barefootjs/shared` (the single source of truth also
|
|
45
|
+
* used by `jsx-to-ir.ts`'s `IRElement.keyAttr` resolution) so existing
|
|
46
|
+
* imports of this module keep working.
|
|
42
47
|
*/
|
|
43
|
-
export
|
|
44
|
-
|
|
48
|
+
export const keyAttrName = sharedKeyAttrName
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the trailing `, <bfId>, <keyAttrName>` arguments for a NESTED
|
|
52
|
+
* (`loopDepth > 0`) `mapArray(...)` call — `inner-loop.ts` / `loop-child-arm.ts`,
|
|
53
|
+
* the only two stringify sites whose loop can be nested (#2753 Shape B: the
|
|
54
|
+
* runtime otherwise has no way to know it isn't the outermost loop, and
|
|
55
|
+
* always stamped the plain `data-key` name).
|
|
56
|
+
*
|
|
57
|
+
* A depth-0 (or unkeyed) loop needs no change at all: `mapArray`'s own
|
|
58
|
+
* default (`BF_KEY`, `'data-key'`) is already correct there, and an unkeyed
|
|
59
|
+
* loop's runtime never stamps a key attribute regardless of the name — so
|
|
60
|
+
* this returns `bfIdArg` UNCHANGED, keeping every other call site (and every
|
|
61
|
+
* existing depth-0 call here) byte-identical.
|
|
62
|
+
*
|
|
63
|
+
* `bfIdArg` is the existing profiling-id suffix (e.g. `profileBindingId(...)`,
|
|
64
|
+
* either `''` or `, "<id>"`) already threaded through these two call sites —
|
|
65
|
+
* an empty one is widened to an explicit `, undefined` placeholder so the
|
|
66
|
+
* name lands in the right positional slot (`mapArray`'s 6th parameter is
|
|
67
|
+
* `bfId`, not `keyAttrName`).
|
|
68
|
+
*/
|
|
69
|
+
export function mapArrayKeyArgs(bfIdArg: string, keyed: boolean, loopDepth: number): string {
|
|
70
|
+
if (!keyed || loopDepth <= 0) return bfIdArg
|
|
71
|
+
const bfIdSlot = bfIdArg || ', undefined'
|
|
72
|
+
return `${bfIdSlot}, ${JSON.stringify(keyAttrName(loopDepth))}`
|
|
45
73
|
}
|
|
46
74
|
|
|
47
75
|
/**
|