@barefootjs/jsx 0.33.2 → 0.33.4
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 +804 -445
- package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +2 -4
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/component-loop.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/control-flow.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 +63 -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__/fragment-body-loop-key.test.ts +95 -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__/multi-root-loop-body.test.ts +7 -3
- package/src/__tests__/preamble-declarations.test.ts +42 -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/build-references.ts +7 -0
- package/src/ir-to-client-js/collect-elements.ts +27 -5
- package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +19 -1
- package/src/ir-to-client-js/control-flow/plan/loop.ts +2 -4
- package/src/ir-to-client-js/control-flow/stringify/component-loop.ts +7 -0
- 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/control-flow.ts +12 -0
- package/src/ir-to-client-js/html-template.ts +174 -23
- 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 +592 -58
- package/src/props-binding.ts +51 -0
- package/src/types.ts +60 -13
|
@@ -266,4 +266,153 @@ describe('Signal Getter Not Called (BF044)', () => {
|
|
|
266
266
|
expect(bf044[0].severity).toBe('error')
|
|
267
267
|
})
|
|
268
268
|
})
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Nested descent (#2755 / #2751 upstream fix).
|
|
272
|
+
*
|
|
273
|
+
* The gate used to open with `if (!ts.isIdentifier(expr)) return`, so it saw
|
|
274
|
+
* only an expression's TOP-LEVEL node. Every shape below reaches a rendered
|
|
275
|
+
* position through some wrapper, and every one of them used to compile
|
|
276
|
+
* silently and then miscompile downstream — the accessor stringified into a
|
|
277
|
+
* DOM property (#2755) or referenced from a module-scope template thunk that
|
|
278
|
+
* cannot see it (#2751).
|
|
279
|
+
*
|
|
280
|
+
* The negative cases are the load-bearing half: descending EVERYWHERE would
|
|
281
|
+
* break the Context-Provider idiom, where handing a descendant an uncalled
|
|
282
|
+
* accessor is the whole point. The rule is "rendered position", not "nested".
|
|
283
|
+
*/
|
|
284
|
+
describe('nested descent into rendered positions', () => {
|
|
285
|
+
// `Child` is declared AFTER `Counter` deliberately: `analyzeComponent`
|
|
286
|
+
// analyzes the FIRST function in the module, so hoisting the child above
|
|
287
|
+
// would silently analyze `Child` instead and make every case below report
|
|
288
|
+
// zero diagnostics — the negative cases would then pass for the wrong
|
|
289
|
+
// reason. The positive block is the control that proves the walk is
|
|
290
|
+
// actually live in this exact module shape.
|
|
291
|
+
const wrap = (body: string) => `
|
|
292
|
+
'use client'
|
|
293
|
+
import { createSignal } from '@barefootjs/client'
|
|
294
|
+
|
|
295
|
+
export function Counter() {
|
|
296
|
+
const [count, setCount] = createSignal(0)
|
|
297
|
+
const [items, setItems] = createSignal([1, 2])
|
|
298
|
+
const obj: Record<string, unknown> = {}
|
|
299
|
+
return ${body}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function Child(props: { value?: unknown }) { return <span /> }
|
|
303
|
+
`
|
|
304
|
+
const bf044Of = (body: string) =>
|
|
305
|
+
compileToIR(wrap(body)).errors.filter(e => e.code === ErrorCodes.SIGNAL_GETTER_NOT_CALLED)
|
|
306
|
+
|
|
307
|
+
describe('fires — the getter reaches a rendered position', () => {
|
|
308
|
+
test.each([
|
|
309
|
+
['ternary condition', '<div className={count ? "on" : "off"} />'],
|
|
310
|
+
['template literal span', '<div className={`x-${count}`} />'],
|
|
311
|
+
['call argument', '<div className={String(count)} />'],
|
|
312
|
+
['array literal member', '<div className={[count].join("")} />'],
|
|
313
|
+
['style object property value', '<div style={{ color: count }} />'],
|
|
314
|
+
['JSX text child', '<div>{count ? "a" : "b"}</div>'],
|
|
315
|
+
])('%s', (_label, body) => {
|
|
316
|
+
const bf044 = bf044Of(body)
|
|
317
|
+
expect(bf044).toHaveLength(1)
|
|
318
|
+
expect(bf044[0].message).toContain("'count'")
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
describe('stays silent — the getter is handed onward, not rendered', () => {
|
|
323
|
+
test.each([
|
|
324
|
+
// The `<SelectContext.Provider value={{ open, ... }}>` shape: every
|
|
325
|
+
// member is an accessor BY CONTRACT. Calling it here would freeze the
|
|
326
|
+
// value at provider-render time and break every consumer.
|
|
327
|
+
['component prop, object literal member', '<Child value={{ x: count }} />'],
|
|
328
|
+
['component prop, ternary', '<Child value={count ? 1 : 2} />'],
|
|
329
|
+
['component prop, call argument', '<Child value={String(count)} />'],
|
|
330
|
+
])('%s', (_label, body) => {
|
|
331
|
+
expect(bf044Of(body)).toHaveLength(0)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('a loop-row param shadowing a same-named signal', () => {
|
|
335
|
+
// `count` here is the row item, not the signal — resolved through the
|
|
336
|
+
// ambient `BindingScope`, which sees bindings introduced OUTSIDE the
|
|
337
|
+
// checked expression.
|
|
338
|
+
expect(bf044Of('<ul>{items().map(count => <li className={count ? "a" : "b"} />)}</ul>')).toHaveLength(0)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test('correctly called getter in every nested shape', () => {
|
|
342
|
+
expect(bf044Of('<div className={count() ? "on" : "off"} />')).toHaveLength(0)
|
|
343
|
+
expect(bf044Of('<div style={{ color: count() }} />')).toHaveLength(0)
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
describe('binding and parameter defaults', () => {
|
|
348
|
+
// A default VALUE is an ordinary expression in the enclosing scope, but
|
|
349
|
+
// the walk used to visit only binding NAMES. Measured before the fix:
|
|
350
|
+
// both shapes compiled silently and emitted a module-scope `template`
|
|
351
|
+
// thunk referencing a component-scope binding — `ReferenceError` on CSR
|
|
352
|
+
// mount, i.e. #2751's mechanism surviving inside the very check meant to
|
|
353
|
+
// close it.
|
|
354
|
+
test('destructuring default in a rendered position', () => {
|
|
355
|
+
expect(bf044Of('<div className={(() => { const { x = count } = ({} as { x?: unknown }); return String(x) })()} />')).toHaveLength(1)
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('parameter default in a rendered position', () => {
|
|
359
|
+
expect(bf044Of('<div className={((f = count) => String(f))()} />')).toHaveLength(1)
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
test('a later default reading an EARLIER parameter stays silent', () => {
|
|
363
|
+
// JS binds parameters left to right: `(count, x = count) => …` reads
|
|
364
|
+
// the already-bound parameter, not the signal it shadows (verified
|
|
365
|
+
// against V8). Visiting every default before binding any parameter
|
|
366
|
+
// would flag this — a false positive on working code.
|
|
367
|
+
expect(bf044Of('<div className={((count2: unknown, x = count2) => String(x))(1)} />')).toHaveLength(0)
|
|
368
|
+
expect(bf044Of('<div className={((count: unknown, x = count) => String(x))(1)} />')).toHaveLength(0)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
test('a later default reading an EARLIER pattern element stays silent', () => {
|
|
372
|
+
// The same left-to-right rule applies WITHIN a pattern.
|
|
373
|
+
//
|
|
374
|
+
// The declaration sits inside a NESTED block on purpose. At a function
|
|
375
|
+
// body's top level, `collectBlockDeclarations` pre-scans the whole
|
|
376
|
+
// `VariableStatement` and binds every name in the pattern up front,
|
|
377
|
+
// regardless of order — so a top-level version of this case passes
|
|
378
|
+
// even with the sequential threading removed, and pins nothing.
|
|
379
|
+
// `collectBlockDeclarations` does not descend into an `if` block, so
|
|
380
|
+
// here the only thing that can bind `c` before `x`'s default is read
|
|
381
|
+
// is `visitBindingDefaults` itself.
|
|
382
|
+
expect(bf044Of('<div className={(() => { if (obj) { const { count, x = count } = obj; return String(x) } return "" })()} />')).toHaveLength(0)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
test('a literal default stays silent', () => {
|
|
386
|
+
// The overwhelmingly common shape (`{ size = 'md' }`): the default is
|
|
387
|
+
// not a reactive name, so widening the walk must not touch it.
|
|
388
|
+
expect(bf044Of(`<div className={(({ size = 'md' }: { size?: string }) => size)({})} />`)).toHaveLength(0)
|
|
389
|
+
})
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
test('does not misfire on a TYPE position', () => {
|
|
393
|
+
// A type is not a value. `({} as { count?: unknown })` in a rendered
|
|
394
|
+
// position used to read the type literal's property name as a bare
|
|
395
|
+
// reference to the same-named signal and refuse valid code.
|
|
396
|
+
expect(bf044Of('<div className={String(({} as { count?: unknown }).count)} />')).toHaveLength(0)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
test('does not misfire on a nested element ATTRIBUTE NAME', () => {
|
|
400
|
+
// The walk stops at a nested JSX boundary. Without that guard, descending
|
|
401
|
+
// into a `.map()` body that returns JSX read the nested element's own
|
|
402
|
+
// attribute NAME (`checked=`) as a bare reference to the same-named
|
|
403
|
+
// signal — `transformNode` re-walks that element independently anyway.
|
|
404
|
+
const source = `
|
|
405
|
+
'use client'
|
|
406
|
+
import { createSignal } from '@barefootjs/client'
|
|
407
|
+
|
|
408
|
+
export function Boxes() {
|
|
409
|
+
const [checked, setChecked] = createSignal(false)
|
|
410
|
+
const [items, setItems] = createSignal([1, 2])
|
|
411
|
+
return <ul>{items().map(n => <li><input checked={checked()} /></li>)}</ul>
|
|
412
|
+
}
|
|
413
|
+
`
|
|
414
|
+
const bf044 = compileToIR(source).errors.filter(e => e.code === ErrorCodes.SIGNAL_GETTER_NOT_CALLED)
|
|
415
|
+
expect(bf044).toHaveLength(0)
|
|
416
|
+
})
|
|
417
|
+
})
|
|
269
418
|
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2767 follow-up: the state-only-file client-JS path (a `.tsx` with no
|
|
3
|
+
* JSX return but an exported `/* @client *\/` module signal) used to filter
|
|
4
|
+
* OUT every default- or namespace-imported specifier when deciding which
|
|
5
|
+
* external imports to preserve (`s => !s.isDefault && !s.isNamespace`,
|
|
6
|
+
* `compiler.ts`'s single-component early return) — not just render them
|
|
7
|
+
* wrong, but drop them entirely. A signal initializer that references a
|
|
8
|
+
* default- or namespace-imported helper compiled with zero diagnostics
|
|
9
|
+
* into client JS that throws `ReferenceError` in the browser, since the
|
|
10
|
+
* import never made it into the bundle at all.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, test, expect } from 'bun:test'
|
|
13
|
+
import { compileJSX } from '../compiler'
|
|
14
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
15
|
+
|
|
16
|
+
const adapter = new TestAdapter()
|
|
17
|
+
|
|
18
|
+
describe('state-only file: default/namespace imports feeding a @client signal', () => {
|
|
19
|
+
test('preserves a default-imported helper referenced by the signal initializer', () => {
|
|
20
|
+
const source = `'use client'
|
|
21
|
+
import defaults from './defaults.json' with { type: 'json' }
|
|
22
|
+
import { createSignal } from '@barefootjs/client'
|
|
23
|
+
/* @client */
|
|
24
|
+
export const [count, setCount] = createSignal(defaults.start)
|
|
25
|
+
`
|
|
26
|
+
const result = compileJSX(source, 'store.tsx', { adapter })
|
|
27
|
+
expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
28
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
29
|
+
expect(clientJs).toBeDefined()
|
|
30
|
+
expect(clientJs!.content).toContain("import defaults from './defaults.json'")
|
|
31
|
+
expect(clientJs!.content).not.toContain('import { defaults }')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('preserves a namespace-imported helper referenced by the signal initializer', () => {
|
|
35
|
+
const source = `'use client'
|
|
36
|
+
import * as util from './util'
|
|
37
|
+
import { createSignal } from '@barefootjs/client'
|
|
38
|
+
/* @client */
|
|
39
|
+
export const [count, setCount] = createSignal(util.base())
|
|
40
|
+
`
|
|
41
|
+
const result = compileJSX(source, 'store2.tsx', { adapter })
|
|
42
|
+
expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
43
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
44
|
+
expect(clientJs).toBeDefined()
|
|
45
|
+
expect(clientJs!.content).toContain("import * as util from './util'")
|
|
46
|
+
})
|
|
47
|
+
})
|
package/src/analyzer.ts
CHANGED
|
@@ -4237,6 +4237,42 @@ export function listComponentFunctions(
|
|
|
4237
4237
|
ts.ScriptKind.TSX
|
|
4238
4238
|
)
|
|
4239
4239
|
|
|
4240
|
+
return listComponentFunctionsFromSourceFile(sourceFile)
|
|
4241
|
+
}
|
|
4242
|
+
|
|
4243
|
+
/**
|
|
4244
|
+
* One parse, two structural facts about a component file — the component
|
|
4245
|
+
* names it exports (today's `listComponentFunctions` result) and the
|
|
4246
|
+
* PascalCase JSX tags it instantiates (`collectJsxComponentTags`'s
|
|
4247
|
+
* out-edges). Used by `@barefootjs/vite`'s `discoverComponents` to build the
|
|
4248
|
+
* component-instantiation graph that decides which SERVER files also need a
|
|
4249
|
+
* client bundle because they transitively own a `'use client'` descendant
|
|
4250
|
+
* (issue #2767) — a property no single-file compile can answer, since it
|
|
4251
|
+
* depends on the whole discovered corpus.
|
|
4252
|
+
*/
|
|
4253
|
+
export interface ComponentFileScan {
|
|
4254
|
+
/** Component names this file exports (same result as `listComponentFunctions`). */
|
|
4255
|
+
exports: string[]
|
|
4256
|
+
/** PascalCase JSX tag identifiers this file references. */
|
|
4257
|
+
referencedComponents: string[]
|
|
4258
|
+
}
|
|
4259
|
+
|
|
4260
|
+
export function scanComponentFile(source: string, filePath: string): ComponentFileScan {
|
|
4261
|
+
const sourceFile = ts.createSourceFile(
|
|
4262
|
+
filePath,
|
|
4263
|
+
source,
|
|
4264
|
+
ts.ScriptTarget.Latest,
|
|
4265
|
+
true,
|
|
4266
|
+
ts.ScriptKind.TSX
|
|
4267
|
+
)
|
|
4268
|
+
|
|
4269
|
+
return {
|
|
4270
|
+
exports: listComponentFunctionsFromSourceFile(sourceFile),
|
|
4271
|
+
referencedComponents: [...collectJsxComponentTags(sourceFile)],
|
|
4272
|
+
}
|
|
4273
|
+
}
|
|
4274
|
+
|
|
4275
|
+
function listComponentFunctionsFromSourceFile(sourceFile: ts.SourceFile): string[] {
|
|
4240
4276
|
const componentNames: string[] = []
|
|
4241
4277
|
|
|
4242
4278
|
// 'use client' directive detection (controls whether multi-return JSX
|
package/src/compiler.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { stripClientBuiltinImports } from './builtins.ts'
|
|
|
19
19
|
import { generateClientJs, generateClientJsWithSourceMap, analyzeClientNeeds } from './ir-to-client-js/index.ts'
|
|
20
20
|
import { decideClientOnlyElision } from './ir-to-client-js/client-only-elision.ts'
|
|
21
21
|
import { emitModuleLevelDeclarations } from './ir-to-client-js/emit-module-level.ts'
|
|
22
|
-
import { RUNTIME_MODULE, detectUsedImports as detectUsedImportsFromCode, makeValueUsageTest } from './ir-to-client-js/imports.ts'
|
|
22
|
+
import { RUNTIME_MODULE, detectUsedImports as detectUsedImportsFromCode, makeValueUsageTest, renderUsedImportLines, mergeCompiledClientJsImports } from './ir-to-client-js/imports.ts'
|
|
23
23
|
import { setActiveComponentScope, computeFileScope } from './ir-to-client-js/component-scope.ts'
|
|
24
24
|
import { generateModuleExports, collectInlineExportedNames } from './module-exports.ts'
|
|
25
25
|
import { applyCssLayerPrefix, applyCssLayerPrefixToFile } from './css-layer-prefixer.ts'
|
|
@@ -43,11 +43,27 @@ export interface CompileOptionsWithAdapter extends CompileOptions {
|
|
|
43
43
|
* conflict-free block.
|
|
44
44
|
*
|
|
45
45
|
* Named value/type imports from the same source are folded into their first
|
|
46
|
-
* occurrence (preserving line order and first-seen symbol order);
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
46
|
+
* occurrence (preserving line order and first-seen symbol order); a default
|
|
47
|
+
* or namespace import is likewise folded by source rather than deduplicated
|
|
48
|
+
* by exact line — every sibling component that shares a module-scope import
|
|
49
|
+
* declaration compiles it independently, so the same default binding can
|
|
50
|
+
* legally arrive as `import cfg from 'x'` from one component and `import
|
|
51
|
+
* cfg, { helper } from 'x'` from another once only the SECOND one also uses
|
|
52
|
+
* a named specifier from the same statement. Folding by source (default
|
|
53
|
+
* name from the first occurrence that has one, named specifiers unioned)
|
|
54
|
+
* collapses both into one `import cfg, { helper } from 'x'` line; the two
|
|
55
|
+
* exact-line-deduplicated strings would otherwise both survive and
|
|
56
|
+
* redeclare `cfg` (#2767 follow-up — this shape was unreachable before a
|
|
57
|
+
* default/namespace-importing server component could become a real client
|
|
58
|
+
* bundle at all). A namespace specifier can't combine with named ones on
|
|
59
|
+
* one line, so it's folded to its own line, keyed by (source, local name)
|
|
60
|
+
* — see `renderUsedImportLines`'s docstring for the same split rendering
|
|
61
|
+
* rule `collectExternalImports` uses.
|
|
62
|
+
*
|
|
63
|
+
* Every other import form (side-effect) is kept in place and de-duplicated
|
|
64
|
+
* by exact line. This ensures a symbol is never imported twice across
|
|
65
|
+
* sibling components — a redeclaration that Bun tolerates but stricter ESM
|
|
66
|
+
* parsers (the Deno runtime that renders SSR templates) reject.
|
|
51
67
|
*
|
|
52
68
|
* For a single-component file the output is identical to the input order;
|
|
53
69
|
* only repeated sibling imports collapse.
|
|
@@ -62,38 +78,70 @@ export interface CompileOptionsWithAdapter extends CompileOptions {
|
|
|
62
78
|
export function mergeTemplateImports(lines: string[]): string {
|
|
63
79
|
const result: string[] = []
|
|
64
80
|
const valueIdx = new Map<string, number>()
|
|
81
|
+
const valueDefault = new Map<string, string>()
|
|
65
82
|
const valueNames = new Map<string, Set<string>>()
|
|
66
83
|
const typeIdx = new Map<string, number>()
|
|
67
84
|
const typeNames = new Map<string, Set<string>>()
|
|
68
85
|
const seenOther = new Set<string>()
|
|
69
86
|
|
|
70
|
-
const
|
|
71
|
-
src
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
names: Map<string, Set<string>>,
|
|
75
|
-
render: (src: string, names: Set<string>) => string,
|
|
76
|
-
) => {
|
|
77
|
-
if (!idx.has(src)) {
|
|
78
|
-
idx.set(src, result.length)
|
|
79
|
-
names.set(src, new Set())
|
|
87
|
+
const foldType = (src: string, rawNames: string) => {
|
|
88
|
+
if (!typeIdx.has(src)) {
|
|
89
|
+
typeIdx.set(src, result.length)
|
|
90
|
+
typeNames.set(src, new Set())
|
|
80
91
|
result.push('')
|
|
81
92
|
}
|
|
82
|
-
const set =
|
|
93
|
+
const set = typeNames.get(src)!
|
|
83
94
|
for (const n of rawNames.split(',').map(s => s.trim()).filter(Boolean)) set.add(n)
|
|
84
|
-
result[
|
|
95
|
+
result[typeIdx.get(src)!] = `import type { ${[...set].join(', ')} } from '${src}'`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const foldValue = (src: string, defaultName: string | null, rawNames: string | null) => {
|
|
99
|
+
if (!valueIdx.has(src)) {
|
|
100
|
+
valueIdx.set(src, result.length)
|
|
101
|
+
valueNames.set(src, new Set())
|
|
102
|
+
result.push('')
|
|
103
|
+
}
|
|
104
|
+
if (defaultName && !valueDefault.has(src)) valueDefault.set(src, defaultName)
|
|
105
|
+
if (rawNames) {
|
|
106
|
+
const set = valueNames.get(src)!
|
|
107
|
+
for (const n of rawNames.split(',').map(s => s.trim()).filter(Boolean)) set.add(n)
|
|
108
|
+
}
|
|
109
|
+
result[valueIdx.get(src)!] = renderUsedImportLines(
|
|
110
|
+
src,
|
|
111
|
+
valueDefault.get(src) ?? null,
|
|
112
|
+
null,
|
|
113
|
+
[...valueNames.get(src)!],
|
|
114
|
+
).join('\n')
|
|
85
115
|
}
|
|
86
116
|
|
|
87
117
|
for (const raw of lines) {
|
|
88
118
|
const line = raw.trim()
|
|
89
119
|
if (!line) continue
|
|
90
120
|
const typeMatch = line.match(/^import\s+type\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]\s*;?$/)
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
121
|
+
const namedMatch = line.match(/^import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]\s*;?$/)
|
|
122
|
+
const defaultNamedMatch = line.match(/^import\s+([A-Za-z_$][\w$]*)\s*,\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]\s*;?$/)
|
|
123
|
+
const defaultOnlyMatch = line.match(/^import\s+([A-Za-z_$][\w$]*)\s*from\s*['"]([^'"]+)['"]\s*;?$/)
|
|
124
|
+
|
|
125
|
+
if (typeMatch) {
|
|
126
|
+
foldType(typeMatch[2], typeMatch[1])
|
|
127
|
+
} else if (namedMatch) {
|
|
128
|
+
foldValue(namedMatch[2], null, namedMatch[1])
|
|
129
|
+
} else if (defaultNamedMatch) {
|
|
130
|
+
foldValue(defaultNamedMatch[3], defaultNamedMatch[1], defaultNamedMatch[2])
|
|
131
|
+
} else if (defaultOnlyMatch) {
|
|
132
|
+
foldValue(defaultOnlyMatch[2], defaultOnlyMatch[1], null)
|
|
96
133
|
} else if (!seenOther.has(line)) {
|
|
134
|
+
// Covers namespace imports (`import * as X from 'src'`) and
|
|
135
|
+
// side-effect imports alike — deduplicated by exact line, same as
|
|
136
|
+
// before. A namespace import can't combine with named specifiers on
|
|
137
|
+
// one line (see `renderUsedImportLines`), so two components that
|
|
138
|
+
// both import the SAME namespace binding from the SAME source
|
|
139
|
+
// always emit byte-identical lines and collapse here; two DIFFERENT
|
|
140
|
+
// local namespace names for the same source (unusual — would require
|
|
141
|
+
// sibling components to alias the same module-scope `import * as`
|
|
142
|
+
// declaration differently, which isn't possible for one shared
|
|
143
|
+
// declaration) are kept as separate lines rather than silently
|
|
144
|
+
// merged.
|
|
97
145
|
seenOther.add(line)
|
|
98
146
|
result.push(line)
|
|
99
147
|
}
|
|
@@ -446,30 +494,9 @@ function compileMultipleComponents(
|
|
|
446
494
|
}
|
|
447
495
|
const clientJsOutputs = allOutputs.map(o => o.clientJs).filter(Boolean) as string[]
|
|
448
496
|
if (clientJsOutputs.length > 0) {
|
|
449
|
-
const importsBySource = new Map<string, Set<string>>()
|
|
450
|
-
const otherImports: string[] = []
|
|
451
|
-
const allCode: string[] = []
|
|
452
|
-
for (const js of clientJsOutputs) {
|
|
453
|
-
for (const line of js.split('\n')) {
|
|
454
|
-
if (line.startsWith('import ')) {
|
|
455
|
-
const match = line.match(/^import \{ ([^}]+) \} from ['"]([^'"]+)['"]$/)
|
|
456
|
-
if (match) {
|
|
457
|
-
const source = match[2]
|
|
458
|
-
if (!importsBySource.has(source)) importsBySource.set(source, new Set())
|
|
459
|
-
for (const n of match[1].split(',').map(n => n.trim())) importsBySource.get(source)!.add(n)
|
|
460
|
-
} else if (!otherImports.includes(line)) {
|
|
461
|
-
otherImports.push(line)
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
allCode.push(js.replace(/^import .+\n/gm, '').trim())
|
|
466
|
-
}
|
|
467
|
-
const mergedClientImports = [...importsBySource].map(([src, names]) =>
|
|
468
|
-
`import { ${[...names].sort().join(', ')} } from '${src}'`
|
|
469
|
-
)
|
|
470
497
|
files.push({
|
|
471
498
|
path: filePath.replace(/\.tsx?$/, '.client.js'),
|
|
472
|
-
content:
|
|
499
|
+
content: mergeCompiledClientJsImports(clientJsOutputs),
|
|
473
500
|
type: 'clientJs',
|
|
474
501
|
})
|
|
475
502
|
}
|
|
@@ -557,63 +584,14 @@ function compileMultipleComponents(
|
|
|
557
584
|
})
|
|
558
585
|
}
|
|
559
586
|
|
|
560
|
-
// Combine client JS if any
|
|
587
|
+
// Combine client JS if any — see `mergeCompiledClientJsImports`'s
|
|
588
|
+
// docstring for why this AST-based merge (not a text/regex line scan)
|
|
589
|
+
// is required here specifically (#2767 follow-up).
|
|
561
590
|
const clientJsOutputs = allOutputs.map(o => o.clientJs).filter(Boolean) as string[]
|
|
562
591
|
if (clientJsOutputs.length > 0) {
|
|
563
|
-
// Separate imports from code and merge imports by source
|
|
564
|
-
const importsBySource = new Map<string, Set<string>>()
|
|
565
|
-
const otherImports: string[] = []
|
|
566
|
-
const allCode: string[] = []
|
|
567
|
-
|
|
568
|
-
for (const js of clientJsOutputs) {
|
|
569
|
-
const lines = js.split('\n')
|
|
570
|
-
const codeLines: string[] = []
|
|
571
|
-
|
|
572
|
-
for (const line of lines) {
|
|
573
|
-
if (line.startsWith('import ')) {
|
|
574
|
-
// Parse named imports: import { a, b } from 'source'
|
|
575
|
-
const match = line.match(/^import \{ ([^}]+) \} from ['"]([^'"]+)['"]$/)
|
|
576
|
-
if (match) {
|
|
577
|
-
const names = match[1].split(',').map(n => n.trim())
|
|
578
|
-
const source = match[2]
|
|
579
|
-
if (!importsBySource.has(source)) {
|
|
580
|
-
importsBySource.set(source, new Set())
|
|
581
|
-
}
|
|
582
|
-
const set = importsBySource.get(source)!
|
|
583
|
-
for (const name of names) {
|
|
584
|
-
set.add(name)
|
|
585
|
-
}
|
|
586
|
-
} else {
|
|
587
|
-
// Other import styles (default, namespace, etc.)
|
|
588
|
-
if (!otherImports.includes(line)) {
|
|
589
|
-
otherImports.push(line)
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
} else {
|
|
593
|
-
codeLines.push(line)
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
allCode.push(codeLines.join('\n').trim())
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
// Generate merged imports
|
|
601
|
-
const mergedImports: string[] = []
|
|
602
|
-
for (const [source, names] of importsBySource) {
|
|
603
|
-
const sortedNames = [...names].sort()
|
|
604
|
-
mergedImports.push(`import { ${sortedNames.join(', ')} } from '${source}'`)
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
const combinedClientJs = [
|
|
608
|
-
...mergedImports,
|
|
609
|
-
...otherImports,
|
|
610
|
-
'',
|
|
611
|
-
...allCode.filter(Boolean),
|
|
612
|
-
].join('\n')
|
|
613
|
-
|
|
614
592
|
files.push({
|
|
615
593
|
path: filePath.replace(/\.tsx?$/, '.client.js'),
|
|
616
|
-
content:
|
|
594
|
+
content: mergeCompiledClientJsImports(clientJsOutputs),
|
|
617
595
|
type: 'clientJs',
|
|
618
596
|
})
|
|
619
597
|
}
|
|
@@ -735,7 +713,9 @@ export function compileJSX(
|
|
|
735
713
|
|
|
736
714
|
// Preserve non-runtime user imports whose specifiers are referenced
|
|
737
715
|
// in the generated body (e.g. an initializer that calls an imported
|
|
738
|
-
// helper: `createSignal(defaultValue())`).
|
|
716
|
+
// helper: `createSignal(defaultValue())`). A default- or namespace-
|
|
717
|
+
// imported helper needs its own import syntax, not named braces —
|
|
718
|
+
// see `renderUsedImportLines`'s docstring.
|
|
739
719
|
const externalImportLines: string[] = []
|
|
740
720
|
const isUsedAsValue = makeValueUsageTest(body)
|
|
741
721
|
for (const imp of ctx.imports) {
|
|
@@ -745,12 +725,22 @@ export function compileJSX(
|
|
|
745
725
|
externalImportLines.push(`import '${imp.source}'`)
|
|
746
726
|
continue
|
|
747
727
|
}
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
728
|
+
const usedNamed: string[] = []
|
|
729
|
+
let usedDefault: string | null = null
|
|
730
|
+
let usedNamespace: string | null = null
|
|
731
|
+
for (const s of imp.specifiers) {
|
|
732
|
+
if (s.isTypeOnly) continue
|
|
733
|
+
const localName = s.alias || s.name
|
|
734
|
+
if (!isUsedAsValue(localName)) continue
|
|
735
|
+
if (s.isDefault) {
|
|
736
|
+
usedDefault = localName
|
|
737
|
+
} else if (s.isNamespace) {
|
|
738
|
+
usedNamespace = localName
|
|
739
|
+
} else {
|
|
740
|
+
usedNamed.push(s.alias ? `${s.name} as ${s.alias}` : s.name)
|
|
741
|
+
}
|
|
753
742
|
}
|
|
743
|
+
externalImportLines.push(...renderUsedImportLines(imp.source, usedDefault, usedNamespace, usedNamed))
|
|
754
744
|
}
|
|
755
745
|
|
|
756
746
|
const allImports = [runtimeImportLine, ...externalImportLines].filter(Boolean).join('\n')
|
package/src/index.ts
CHANGED
|
@@ -66,7 +66,7 @@ export type {
|
|
|
66
66
|
} from './types.ts'
|
|
67
67
|
|
|
68
68
|
// Analyzer
|
|
69
|
-
export { analyzeComponent, listComponentFunctions, listComponentFunctions as listExportedComponents, createProgramForFile, needsTypeBasedDetection, REACTIVE_PRIMITIVES, BROWSER_ONLY_CLIENT_APIS, type AnalyzerContext } from './analyzer.ts'
|
|
69
|
+
export { analyzeComponent, listComponentFunctions, listComponentFunctions as listExportedComponents, scanComponentFile, createProgramForFile, needsTypeBasedDetection, REACTIVE_PRIMITIVES, BROWSER_ONLY_CLIENT_APIS, type AnalyzerContext, type ComponentFileScan } from './analyzer.ts'
|
|
70
70
|
export { createProgramForCorpus, type SharedProgramOptions } from './shared-program.ts'
|
|
71
71
|
|
|
72
72
|
// JSX to IR transformer
|
|
@@ -302,6 +302,13 @@ export function buildReferencesGraph(ctx: ClientJsContext, irRoot: IRNode): Refe
|
|
|
302
302
|
if (v && attr.value.kind !== 'literal') addExprEdges(ROOT_SOURCE, v, 'template-closure')
|
|
303
303
|
}
|
|
304
304
|
for (const ev of el.events) addExprEdges(ROOT_SOURCE, ev.handler, 'init-body')
|
|
305
|
+
// A `ref` callback runs in `initX`'s closure, same context as an event
|
|
306
|
+
// handler — not the template closure. Without this edge, a ref inside
|
|
307
|
+
// a loop nested past the top level (`elem.innerLoops`, never walked by
|
|
308
|
+
// Phase 1's dedicated ref traces) is invisible to reachability, so
|
|
309
|
+
// `computeDeclarationScopes` (compute-scope.ts) drops its declaration
|
|
310
|
+
// as dead code while the emitter still emits the call site (#2750).
|
|
311
|
+
if (el.ref) addExprEdges(ROOT_SOURCE, el.ref, 'init-body')
|
|
305
312
|
descend()
|
|
306
313
|
},
|
|
307
314
|
component: ({ node: c, descend, descendJsxChildren }) => {
|
|
@@ -13,6 +13,7 @@ import { extractFreeIdentifiersFromText } from './csr-substitute.ts'
|
|
|
13
13
|
import { walkIR, stopAt } from './walker.ts'
|
|
14
14
|
import { buildLoopChainExpr } from '../loop-chain.ts'
|
|
15
15
|
import { identifierPattern } from '../identifier-pattern.ts'
|
|
16
|
+
import { classifyDOMProp } from '@barefootjs/shared'
|
|
16
17
|
|
|
17
18
|
/** Expressions that render nothing (0 DOM nodes) — `&&` / `?:` empty branches. */
|
|
18
19
|
const EMPTY_RENDER_EXPRS = new Set(['null', 'undefined', 'false', "''", '""', '``'])
|
|
@@ -581,11 +582,32 @@ function collectReactiveChildProps(node: IRComponent, ctx: ClientJsContext): voi
|
|
|
581
582
|
for (const prop of node.props) {
|
|
582
583
|
if (prop.name === '...' || prop.name.startsWith('...')) continue
|
|
583
584
|
if (prop.value.kind === 'jsx-children') continue
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
585
|
+
// `classifyDOMProp` is the single source of truth for "how does this prop
|
|
586
|
+
// reach the DOM?" (`packages/shared/src/dom-prop.ts`). `applyRestAttrs`
|
|
587
|
+
// reads it to build its own attribute set — everything whose kind is NOT
|
|
588
|
+
// `ref` / `event` / `skip`. This mirror is the compile-time twin of that
|
|
589
|
+
// loop, so it takes the same three exclusions from the same classifier
|
|
590
|
+
// rather than re-deciding them by hand (#2749: the hand-rolled `on[A-Z]`
|
|
591
|
+
// check covered events but not `ref`, so a `ref` on a child-component
|
|
592
|
+
// call site was mirrored as `setAttribute('ref', String(<fn source>))` —
|
|
593
|
+
// an attribute SSR never emits, failing the SSR-vs-hydrated snapshot and
|
|
594
|
+
// shadowing the real binding `initChild` was already passing correctly).
|
|
595
|
+
//
|
|
596
|
+
// Two small widenings come with reading the shared classifier, both
|
|
597
|
+
// intended. `skip` also covers a `children` prop reaching here as an
|
|
598
|
+
// expression rather than as the `jsx-children` variant handled above —
|
|
599
|
+
// `setAttribute('children', …)` was never a sensible mirror. And the
|
|
600
|
+
// classifier's event test requires an actual A-Z at index 2, where the
|
|
601
|
+
// old `name[2] === name[2].toUpperCase()` also matched non-letters
|
|
602
|
+
// (`on-foo`, `on1x`), which are not DOM events and now mirror normally.
|
|
603
|
+
//
|
|
604
|
+
// Why not also exclude `innerHTML` here: `dangerouslySetInnerHTML` as a
|
|
605
|
+
// reactive CHILD-COMPONENT prop is a separate, unmeasured shape — the
|
|
606
|
+
// element-level path is what the `dangerous-inner-html*` fixtures cover.
|
|
607
|
+
// Narrowing this mirror to the three kinds `applyRestAttrs` excludes keeps
|
|
608
|
+
// the change to the classification `ref` was already missing.
|
|
609
|
+
const domKind = classifyDOMProp(prop.name).kind
|
|
610
|
+
if (domKind === 'ref' || domKind === 'event' || domKind === 'skip') continue
|
|
589
611
|
// Only `expression` / `template` variants drive reactive prop forwarding.
|
|
590
612
|
if (prop.value.kind !== 'expression' && prop.value.kind !== 'template') continue
|
|
591
613
|
const valueExpr = attrValueToString(prop.value)!
|
|
@@ -26,9 +26,10 @@ import {
|
|
|
26
26
|
isTextOnlyConditional,
|
|
27
27
|
buildChildRefBindings,
|
|
28
28
|
} from '../shared.ts'
|
|
29
|
-
import { irChildrenToJsExpr } from '../../html-template.ts'
|
|
29
|
+
import { irChildrenToJsExpr, renderPreamble } from '../../html-template.ts'
|
|
30
30
|
import { buildReactiveEffectsPlan } from './build-reactive-effects.ts'
|
|
31
31
|
import type { ComponentLoopPlan, NestedComponentInit } from './types.ts'
|
|
32
|
+
import { internalInvariant } from '../../../errors.ts'
|
|
32
33
|
|
|
33
34
|
/** @internal — prefer `buildLoopPlan`. */
|
|
34
35
|
export function buildComponentLoopPlan(elem: TopLevelLoop, profileComponentName?: string): ComponentLoopPlan {
|
|
@@ -37,6 +38,22 @@ export function buildComponentLoopPlan(elem: TopLevelLoop, profileComponentName?
|
|
|
37
38
|
const keyExpr = wrapLoopParamAsAccessor(elem.key || '__idx', elem.param, elem.paramBindings)
|
|
38
39
|
const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(elem.param, elem.paramBindings)
|
|
39
40
|
|
|
41
|
+
// A component-root loop's preamble is JS-only by construction: Phase 1
|
|
42
|
+
// (jsx-to-ir.ts) already refuses and strips any preamble with a JSX leaf
|
|
43
|
+
// (`builderNames.length > 0`) on this shape, because a lowered HTML-string
|
|
44
|
+
// leaf passed as a prop would diverge from SSR's real JSX elements — see
|
|
45
|
+
// `rowConstruction: 'dom-ops'` below. `renderLeaf` is therefore never
|
|
46
|
+
// reachable here; if it ever fires, a Phase-1 refusal for the new shape
|
|
47
|
+
// that let a JSX-bearing preamble through is missing, not this line.
|
|
48
|
+
const mapPreambleWrapped = elem.preamble
|
|
49
|
+
? renderPreamble(elem.preamble, {
|
|
50
|
+
transformJs: text => wrapLoopParamAsAccessor(text, elem.param, elem.paramBindings),
|
|
51
|
+
renderLeaf: () => {
|
|
52
|
+
internalInvariant(false, 'component-root loop received a JSX-bearing preamble — Phase 1 should have refused it')
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
: ''
|
|
56
|
+
|
|
40
57
|
// Only init components at loopDepth 0 — inner-loop components are handled by their own loop
|
|
41
58
|
const outerNestedComps = (elem.nestedComponents ?? []).filter(c => !c.loopDepth)
|
|
42
59
|
const nestedComps: NestedComponentInit[] = outerNestedComps.map(comp => {
|
|
@@ -64,6 +81,7 @@ export function buildComponentLoopPlan(elem: TopLevelLoop, profileComponentName?
|
|
|
64
81
|
// prop would diverge from SSR (which passes real JSX elements). The plan
|
|
65
82
|
// dispatcher refuses a JSX-bearing preamble on this variant.
|
|
66
83
|
rowConstruction: 'dom-ops',
|
|
84
|
+
mapPreambleWrapped,
|
|
67
85
|
containerVar: `_${varSlotId(elem.slotId)}`,
|
|
68
86
|
markerId: elem.markerId,
|
|
69
87
|
arrayExpr: buildChainedArrayExpr(elem),
|
|
@@ -77,6 +77,8 @@ interface DynamicLoopCommon extends LoopPlanCommon {
|
|
|
77
77
|
paramHead: string
|
|
78
78
|
/** Statement to unwrap a destructured param at body entry. Empty when not needed. */
|
|
79
79
|
paramUnwrap: string
|
|
80
|
+
/** Pre-render preamble line (already wrapped with loop param accessor). Empty when none. */
|
|
81
|
+
mapPreambleWrapped: string
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
/**
|
|
@@ -117,8 +119,6 @@ export interface LoopChildRefBinding {
|
|
|
117
119
|
*/
|
|
118
120
|
interface PlainLoopVariant extends DynamicLoopCommon {
|
|
119
121
|
kind: 'plain'
|
|
120
|
-
/** Pre-render preamble line (already wrapped with loop param accessor). Empty when none. */
|
|
121
|
-
mapPreambleWrapped: string
|
|
122
122
|
/** HTML template string for one item. */
|
|
123
123
|
template: string
|
|
124
124
|
/**
|
|
@@ -225,8 +225,6 @@ interface ComponentLoopVariant extends DynamicLoopCommon {
|
|
|
225
225
|
*/
|
|
226
226
|
interface CompositeLoopVariant extends DynamicLoopCommon {
|
|
227
227
|
kind: 'composite'
|
|
228
|
-
/** Wrapped mapPreamble line, hoisted before the SSR/CSR split. Empty when none. */
|
|
229
|
-
mapPreambleWrapped: string
|
|
230
228
|
/** Inner template HTML for the loop body (single item). */
|
|
231
229
|
template: string
|
|
232
230
|
/** Outer-level child components (depth 0), with `insideConditional` ones already filtered out. */
|