@barefootjs/jsx 0.31.1 → 0.31.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/adapters/interface.d.ts +11 -0
- package/dist/adapters/interface.d.ts.map +1 -1
- package/dist/adapters/jsx-adapter.d.ts +92 -1
- package/dist/adapters/jsx-adapter.d.ts.map +1 -1
- package/dist/adapters/test-adapter.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/compiler.d.ts.map +1 -1
- package/dist/css-layer-prefixer.d.ts +16 -0
- package/dist/css-layer-prefixer.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/html-types.d.ts +19 -0
- package/dist/html-types.d.ts.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1531 -1155
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/phases/props-event-handlers.d.ts.map +1 -1
- package/dist/ir-to-client-js/phases/props-extraction.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +6 -4
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/jsx-runtime/index.d.ts +2 -8
- package/dist/jsx-runtime/index.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/module-exports.d.ts +9 -1
- package/dist/module-exports.d.ts.map +1 -1
- package/dist/prop-rewrite.d.ts +8 -3
- package/dist/prop-rewrite.d.ts.map +1 -1
- package/dist/props-binding.d.ts +40 -0
- package/dist/props-binding.d.ts.map +1 -0
- package/dist/relocate.d.ts +9 -0
- package/dist/relocate.d.ts.map +1 -1
- package/dist/scope/binding-scope.d.ts +179 -0
- package/dist/scope/binding-scope.d.ts.map +1 -0
- package/dist/ssr-defaults.d.ts +43 -0
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/template-parts.d.ts +53 -0
- package/dist/template-parts.d.ts.map +1 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/adapter-output.test.ts +8 -4
- package/src/__tests__/aliased-destructured-prop-csr.test.ts +112 -0
- package/src/__tests__/binding-scope-preamble-shadowing.test.ts +115 -0
- package/src/__tests__/binding-scope-ratchet.test.ts +194 -0
- package/src/__tests__/binding-scope.test.ts +200 -0
- package/src/__tests__/css-layer-prefixer.test.ts +72 -0
- package/src/__tests__/form-control-value-ssr.test.ts +48 -3
- package/src/__tests__/let-type-annotation.test.ts +208 -0
- package/src/__tests__/memo-deps-comments.test.ts +99 -0
- package/src/__tests__/multi-return-sibling-diagnostic.test.ts +241 -0
- package/src/__tests__/ssr-defaults.test.ts +124 -1
- package/src/__tests__/staged-ir/08-relocate-unit.test.ts +1 -0
- package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +1 -0
- package/src/adapters/interface.ts +11 -0
- package/src/adapters/jsx-adapter.ts +295 -5
- package/src/adapters/test-adapter.ts +13 -10
- package/src/analyzer.ts +70 -11
- package/src/compiler.ts +119 -18
- package/src/css-layer-prefixer.ts +80 -24
- package/src/errors.ts +18 -0
- package/src/html-types.ts +24 -0
- package/src/index.ts +11 -1
- package/src/ir-to-client-js/collect-elements.ts +4 -1
- package/src/ir-to-client-js/emit-reactive.ts +4 -2
- package/src/ir-to-client-js/phases/props-event-handlers.ts +4 -3
- package/src/ir-to-client-js/phases/props-extraction.ts +7 -4
- package/src/ir-to-client-js/plan/build-declaration-emit.ts +6 -3
- package/src/ir-to-client-js/utils.ts +5 -24
- package/src/jsx-runtime/index.ts +2 -7
- package/src/jsx-to-ir.ts +245 -107
- package/src/module-exports.ts +11 -2
- package/src/prop-rewrite.ts +26 -6
- package/src/props-binding.ts +70 -0
- package/src/relocate.ts +19 -2
- package/src/scope/binding-scope.ts +238 -0
- package/src/ssr-defaults.ts +70 -0
- package/src/template-parts.ts +81 -0
- package/src/types.ts +18 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `BindingScope` (#2482 Stage 0). See
|
|
3
|
+
* `packages/jsx/src/scope/binding-scope.ts` for the service itself — this
|
|
4
|
+
* file pins its observable behavior, especially the destructure semantics
|
|
5
|
+
* mirrored from `jsx-to-ir.ts`'s `ctx.loopParams` add site and the
|
|
6
|
+
* immutability win over that Set-based design's nested-same-name hazard.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect } from 'bun:test'
|
|
10
|
+
import { BindingScope } from '../scope/binding-scope.ts'
|
|
11
|
+
|
|
12
|
+
describe('BindingScope', () => {
|
|
13
|
+
test('EMPTY: nothing bound, lookup null, empty boundNames, shadow predicate false', () => {
|
|
14
|
+
const scope = BindingScope.EMPTY
|
|
15
|
+
expect(scope.isBound('x')).toBe(false)
|
|
16
|
+
expect(scope.lookup('x')).toBeNull()
|
|
17
|
+
expect(scope.boundNames().size).toBe(0)
|
|
18
|
+
expect(scope.asShadowPredicate()('x')).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('simple loop row: param and index bound with sources item/index', () => {
|
|
22
|
+
const scope = BindingScope.EMPTY.enterLoopRow({ param: 'item', index: 'i' })
|
|
23
|
+
expect(scope.isBound('item')).toBe(true)
|
|
24
|
+
expect(scope.isBound('i')).toBe(true)
|
|
25
|
+
expect(scope.lookup('item')).toEqual({
|
|
26
|
+
depth: 0,
|
|
27
|
+
frame: scope.lookup('item')!.frame,
|
|
28
|
+
binding: { source: 'item' },
|
|
29
|
+
})
|
|
30
|
+
expect(scope.lookup('i')?.binding).toEqual({ source: 'index' })
|
|
31
|
+
expect(scope.lookup('item')?.frame.kind).toBe('loop-row')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('loop row with no index: index name not bound', () => {
|
|
35
|
+
const scope = BindingScope.EMPTY.enterLoopRow({ param: 'item', index: null })
|
|
36
|
+
expect(scope.isBound('item')).toBe(true)
|
|
37
|
+
expect(scope.isBound('i')).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('destructured row: paramBindings names bound as destructure; raw param NOT bound', () => {
|
|
41
|
+
// Mirrors jsx-to-ir.ts:4330-4335 exactly: when paramBindings is
|
|
42
|
+
// non-empty, ONLY the destructured binding names are added to
|
|
43
|
+
// ctx.loopParams — the else branch that adds the raw `param` text is
|
|
44
|
+
// skipped entirely. For a destructured callback `param` holds the
|
|
45
|
+
// original pattern source text (e.g. "{ id, name }"), which is not a
|
|
46
|
+
// usable identifier anyway, so not binding it is correct, not just
|
|
47
|
+
// an accident of mirroring.
|
|
48
|
+
const scope = BindingScope.EMPTY.enterLoopRow({
|
|
49
|
+
param: '{ id, name }',
|
|
50
|
+
index: null,
|
|
51
|
+
paramBindings: [{ name: 'id' }, { name: 'name' }],
|
|
52
|
+
})
|
|
53
|
+
expect(scope.isBound('id')).toBe(true)
|
|
54
|
+
expect(scope.isBound('name')).toBe(true)
|
|
55
|
+
expect(scope.lookup('id')?.binding).toEqual({ source: 'destructure' })
|
|
56
|
+
expect(scope.lookup('name')?.binding).toEqual({ source: 'destructure' })
|
|
57
|
+
// The raw pattern text is not a bound name.
|
|
58
|
+
expect(scope.isBound('{ id, name }')).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('preamble locals: declaredNames bound as preamble', () => {
|
|
62
|
+
const scope = BindingScope.EMPTY.enterLoopRow({
|
|
63
|
+
param: 'row',
|
|
64
|
+
index: null,
|
|
65
|
+
preamble: { declaredNames: ['out', 'label'] },
|
|
66
|
+
})
|
|
67
|
+
expect(scope.isBound('row')).toBe(true)
|
|
68
|
+
expect(scope.lookup('out')?.binding).toEqual({ source: 'preamble' })
|
|
69
|
+
expect(scope.lookup('label')?.binding).toEqual({ source: 'preamble' })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('nested loops with the SAME param name: inner lookup is depth 0; discarding the inner scope still leaves the parent bound', () => {
|
|
73
|
+
// Pins the immutability win over the old `ctx.loopParams` Set design:
|
|
74
|
+
// that mechanism `.add`s a nested loop's param and `.delete`s it on
|
|
75
|
+
// the way back out, so a caller that captured "the current set"
|
|
76
|
+
// reference before entering the nested loop observes the SAME
|
|
77
|
+
// mutable object both inside and after — there is no way to ask "what
|
|
78
|
+
// was bound one loop level up" once the same name has been added
|
|
79
|
+
// twice and deleted once. BindingScope's immutable child scopes make
|
|
80
|
+
// that question well-formed: `outer` below is never touched by
|
|
81
|
+
// `enterLoopRow` on `inner`, so it still resolves `item` after we
|
|
82
|
+
// stop using (conceptually "pop") the inner scope by simply going
|
|
83
|
+
// back to holding the `outer` reference.
|
|
84
|
+
const outer = BindingScope.EMPTY.enterLoopRow({ param: 'item', index: null })
|
|
85
|
+
const inner = outer.enterLoopRow({ param: 'item', index: null })
|
|
86
|
+
|
|
87
|
+
expect(inner.lookup('item')?.depth).toBe(0)
|
|
88
|
+
expect(outer.lookup('item')?.depth).toBe(0)
|
|
89
|
+
// "Discarding" the inner scope is just no longer holding its
|
|
90
|
+
// reference — `outer` was never mutated and still has `item` bound.
|
|
91
|
+
expect(outer.isBound('item')).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('callback frame: enterCallback binds params as source param, frame kind callback; row bindings still visible beneath', () => {
|
|
95
|
+
const row = BindingScope.EMPTY.enterLoopRow({ param: 'item', index: null })
|
|
96
|
+
const withCallback = row.enterCallback(['a', 'b'])
|
|
97
|
+
|
|
98
|
+
expect(withCallback.lookup('a')).toEqual({
|
|
99
|
+
depth: 0,
|
|
100
|
+
frame: withCallback.lookup('a')!.frame,
|
|
101
|
+
binding: { source: 'param' },
|
|
102
|
+
})
|
|
103
|
+
expect(withCallback.lookup('a')?.frame.kind).toBe('callback')
|
|
104
|
+
expect(withCallback.lookup('b')?.binding).toEqual({ source: 'param' })
|
|
105
|
+
|
|
106
|
+
// The row binding is still visible, one frame further out.
|
|
107
|
+
const itemLookup = withCallback.lookup('item')
|
|
108
|
+
expect(itemLookup?.depth).toBe(1)
|
|
109
|
+
expect(itemLookup?.frame.kind).toBe('loop-row')
|
|
110
|
+
expect(itemLookup?.binding).toEqual({ source: 'item' })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('shadowing: inner binding wins lookup (depth 0) over a same-named outer binding', () => {
|
|
114
|
+
const outer = BindingScope.EMPTY.enterLoopRow({ param: 'x', index: null })
|
|
115
|
+
const inner = outer.enterCallback(['x'])
|
|
116
|
+
|
|
117
|
+
const found = inner.lookup('x')
|
|
118
|
+
expect(found?.depth).toBe(0)
|
|
119
|
+
expect(found?.binding).toEqual({ source: 'param' })
|
|
120
|
+
expect(found?.frame.kind).toBe('callback')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('boundNames unions across frames; asShadowPredicate matches isBound', () => {
|
|
124
|
+
const scope = BindingScope.EMPTY
|
|
125
|
+
.enterLoopRow({ param: 'item', index: 'i', preamble: { declaredNames: ['out'] } })
|
|
126
|
+
.enterCallback(['a'])
|
|
127
|
+
|
|
128
|
+
const names = scope.boundNames()
|
|
129
|
+
expect(names.has('item')).toBe(true)
|
|
130
|
+
expect(names.has('i')).toBe(true)
|
|
131
|
+
expect(names.has('out')).toBe(true)
|
|
132
|
+
expect(names.has('a')).toBe(true)
|
|
133
|
+
expect(names.size).toBe(4)
|
|
134
|
+
|
|
135
|
+
const shadowed = scope.asShadowPredicate()
|
|
136
|
+
for (const n of ['item', 'i', 'out', 'a', 'not-bound']) {
|
|
137
|
+
expect(shadowed(n)).toBe(scope.isBound(n))
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('valueBoundNames (#2482 Stage 1a Commit 2)', () => {
|
|
142
|
+
test('excludes preamble names that boundNames() includes', () => {
|
|
143
|
+
const scope = BindingScope.EMPTY.enterLoopRow({
|
|
144
|
+
param: 'item',
|
|
145
|
+
index: 'i',
|
|
146
|
+
preamble: { declaredNames: ['label', 'out'] },
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Shadow-guard query: sees everything, including preamble locals.
|
|
150
|
+
const all = scope.boundNames()
|
|
151
|
+
expect(all.has('item')).toBe(true)
|
|
152
|
+
expect(all.has('i')).toBe(true)
|
|
153
|
+
expect(all.has('label')).toBe(true)
|
|
154
|
+
expect(all.has('out')).toBe(true)
|
|
155
|
+
expect(all.size).toBe(4)
|
|
156
|
+
|
|
157
|
+
// Reactivity/slotId query: item/index/destructure only.
|
|
158
|
+
const values = scope.valueBoundNames()
|
|
159
|
+
expect(values.has('item')).toBe(true)
|
|
160
|
+
expect(values.has('i')).toBe(true)
|
|
161
|
+
expect(values.has('label')).toBe(false)
|
|
162
|
+
expect(values.has('out')).toBe(false)
|
|
163
|
+
expect(values.size).toBe(2)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('destructured bindings count as value bindings', () => {
|
|
167
|
+
const scope = BindingScope.EMPTY.enterLoopRow({
|
|
168
|
+
param: '{ id, name }',
|
|
169
|
+
index: null,
|
|
170
|
+
paramBindings: [{ name: 'id' }, { name: 'name' }],
|
|
171
|
+
preamble: { declaredNames: ['formatted'] },
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const values = scope.valueBoundNames()
|
|
175
|
+
expect(values.has('id')).toBe(true)
|
|
176
|
+
expect(values.has('name')).toBe(true)
|
|
177
|
+
expect(values.has('formatted')).toBe(false)
|
|
178
|
+
expect(values.size).toBe(2)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('excludes enterCallback param-sourced names too', () => {
|
|
182
|
+
// A filter/sort/nested-arrow frame's own parameters are a distinct
|
|
183
|
+
// binding shape from a loop row's item/index/destructure names
|
|
184
|
+
// (see the class doc comment) — valueBoundNames() must not surface
|
|
185
|
+
// them either, only boundNames() does.
|
|
186
|
+
const scope = BindingScope.EMPTY
|
|
187
|
+
.enterLoopRow({ param: 'item', index: null })
|
|
188
|
+
.enterCallback(['a', 'b'])
|
|
189
|
+
|
|
190
|
+
expect(scope.boundNames().has('a')).toBe(true)
|
|
191
|
+
expect(scope.valueBoundNames().has('a')).toBe(false)
|
|
192
|
+
expect(scope.valueBoundNames().has('item')).toBe(true)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('EMPTY scope: both queries return an empty set', () => {
|
|
196
|
+
expect(BindingScope.EMPTY.boundNames().size).toBe(0)
|
|
197
|
+
expect(BindingScope.EMPTY.valueBoundNames().size).toBe(0)
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
})
|
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
extractIdentifiers,
|
|
12
12
|
} from '../css-layer-prefixer'
|
|
13
13
|
import type { ComponentIR, IRElement, IRMetadata, IRTemplatePart, ConstantInfo, TemplateAttr, LiteralAttr } from '../types'
|
|
14
|
+
import { compileJSX } from '../compiler'
|
|
15
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
16
|
+
import { HonoAdapter } from '../../../../packages/adapter-hono/src/adapter/hono-adapter'
|
|
14
17
|
|
|
15
18
|
describe('prefixClass', () => {
|
|
16
19
|
test('prefixes a simple class', () => {
|
|
@@ -452,4 +455,73 @@ describe('applyCssLayerPrefix', () => {
|
|
|
452
455
|
const base = ir.metadata.localConstants.find(c => c.name === 'baseClasses')
|
|
453
456
|
expect(base?.value).toBe("'layer-components:bg-primary'")
|
|
454
457
|
})
|
|
458
|
+
|
|
459
|
+
test('multi-component file prefixes a shared const identically in every component IR (#2570)', () => {
|
|
460
|
+
// `sharedClasses` is used in CLASS position only by CompA; CompB reads it
|
|
461
|
+
// in a non-class expression. Per-IR application prefixed only CompA's
|
|
462
|
+
// copy, which — now that module-scope constants are hoisted to ONE
|
|
463
|
+
// module declaration — emitted the constant twice with different values
|
|
464
|
+
// ("tabsClasses has already been declared" in the site/ui build). The
|
|
465
|
+
// file-wide union must leave every IR's copy byte-identical.
|
|
466
|
+
const source = `
|
|
467
|
+
const sharedClasses = 'flex flex-col gap-2'
|
|
468
|
+
|
|
469
|
+
export function CompA() {
|
|
470
|
+
return <div className={sharedClasses + ' extra'}>a</div>
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function CompB() {
|
|
474
|
+
return <div data-cls={sharedClasses}>b</div>
|
|
475
|
+
}
|
|
476
|
+
`
|
|
477
|
+
const result = compileJSX(source, '/virtual/Tabs.tsx', {
|
|
478
|
+
adapter: new TestAdapter(),
|
|
479
|
+
cssLayerPrefix: 'components',
|
|
480
|
+
})
|
|
481
|
+
expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
482
|
+
const template = result.files.find(f => f.type === 'markedTemplate')!.content
|
|
483
|
+
|
|
484
|
+
const declarations = template.match(/^const sharedClasses = .*$/gm) ?? []
|
|
485
|
+
expect(declarations).toHaveLength(1)
|
|
486
|
+
expect(declarations[0]).toContain('layer-components:flex')
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
test('prefixes a `Record`-shaped `as const` class map constant\'s typedValue (#2575)', () => {
|
|
490
|
+
// `preserveTypes` emitters (Hono) prefer `typedValue ?? value` when
|
|
491
|
+
// emitting a module-scope constant so `.tsx` output stays type-checked
|
|
492
|
+
// — see `generateModuleScopeDeclarations` in
|
|
493
|
+
// `packages/adapter-hono/src/adapter/hono-adapter.ts`'s base class.
|
|
494
|
+
// `applyCssLayerPrefixToFile` used to rewrite only `value`, leaving
|
|
495
|
+
// `typedValue` — the string Hono actually emits — unprefixed for any
|
|
496
|
+
// constant whose initializer carries type syntax (`as const`, a
|
|
497
|
+
// `satisfies`/`Record<...>` annotation, …). Regression for the fix:
|
|
498
|
+
// both strings must be prefixed together, and the `as const` assertion
|
|
499
|
+
// must survive the round trip verbatim.
|
|
500
|
+
const source = `
|
|
501
|
+
const toneClasses: Record<'info' | 'warn', string> = {
|
|
502
|
+
info: 'text-blue-500',
|
|
503
|
+
warn: 'text-amber-500',
|
|
504
|
+
} as const
|
|
505
|
+
|
|
506
|
+
export function Banner() {
|
|
507
|
+
return <div className={toneClasses.info}>hi</div>
|
|
508
|
+
}
|
|
509
|
+
`
|
|
510
|
+
const result = compileJSX(source, '/virtual/Banner.tsx', {
|
|
511
|
+
adapter: new HonoAdapter(),
|
|
512
|
+
cssLayerPrefix: 'components',
|
|
513
|
+
})
|
|
514
|
+
expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
515
|
+
const template = result.files.find(f => f.type === 'markedTemplate')!.content
|
|
516
|
+
|
|
517
|
+
const declarations = [...template.matchAll(/^const toneClasses = [\s\S]*?\} as const$/gm)]
|
|
518
|
+
expect(declarations).toHaveLength(1)
|
|
519
|
+
const declaration = declarations[0][0]
|
|
520
|
+
|
|
521
|
+
// Classes are prefixed...
|
|
522
|
+
expect(declaration).toContain("info: 'layer-components:text-blue-500'")
|
|
523
|
+
expect(declaration).toContain("warn: 'layer-components:text-amber-500'")
|
|
524
|
+
// ...and the `as const` assertion survives the rewrite.
|
|
525
|
+
expect(declaration.trim().endsWith('} as const')).toBe(true)
|
|
526
|
+
})
|
|
455
527
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SSR projection of controlled form-control `value` (#2464 / #2465).
|
|
2
|
+
* SSR projection of controlled form-control `value` (#2464 / #2465 / #2466).
|
|
3
3
|
*
|
|
4
4
|
* `value` is not an attribute on `<textarea>` or `<select>` — emitting it
|
|
5
5
|
* verbatim ships invalid HTML that browsers ignore, so no-JS and
|
|
@@ -9,6 +9,15 @@
|
|
|
9
9
|
* and projects the value into element content (textarea) or per-option
|
|
10
10
|
* `selected` comparisons (select — the shape `select-option-selected`
|
|
11
11
|
* already proves across every adapter).
|
|
12
|
+
*
|
|
13
|
+
* `<option>`s rendered by a `.map()` loop get the same `selected`
|
|
14
|
+
* distribution, compared against the option's own value EXPRESSION rather
|
|
15
|
+
* than a literal (#2466). That makes `selected` an ordinary per-row
|
|
16
|
+
* reactive attribute, so it rides the loop's existing `applyItem` /
|
|
17
|
+
* `applyOuter` (or eager `mapArray` per-row effect) machinery — fixing the
|
|
18
|
+
* bug where an INDEX-KEYED reorder left `selected` attached to whichever
|
|
19
|
+
* physical `<option>` happened to have its `value` rewritten in place,
|
|
20
|
+
* instead of following the controlled signal's value.
|
|
12
21
|
*/
|
|
13
22
|
|
|
14
23
|
import { describe, test, expect } from 'bun:test'
|
|
@@ -65,7 +74,7 @@ export function Pick() {
|
|
|
65
74
|
expect(template).toContain(`((v()) === "b")`)
|
|
66
75
|
})
|
|
67
76
|
|
|
68
|
-
test('an authored selected wins;
|
|
77
|
+
test('an authored selected wins; a `.map()` loop option gets a distributed selected binding (#2466)', () => {
|
|
69
78
|
const { template } = compiled(`
|
|
70
79
|
"use client"
|
|
71
80
|
import { createSignal } from '@barefootjs/client'
|
|
@@ -89,7 +98,43 @@ export function Pick() {
|
|
|
89
98
|
expect(template).toContain('selected={(false) || undefined}')
|
|
90
99
|
expect(template).not.toContain('(v()) === "a"')
|
|
91
100
|
expect(template).not.toMatch(/<select[^>]*value=/)
|
|
92
|
-
|
|
101
|
+
// The loop-rendered option compares against its own value EXPRESSION
|
|
102
|
+
// (the loop param `o`), not a literal — this is the #2466 half.
|
|
103
|
+
expect(template).toContain('((v()) === (o))')
|
|
104
|
+
}, 20000)
|
|
105
|
+
|
|
106
|
+
test('index-keyed .map() loop option: selected rides applyItem AND applyOuter (#2466)', () => {
|
|
107
|
+
const { clientJs } = compiled(`
|
|
108
|
+
"use client"
|
|
109
|
+
import { createSignal } from '@barefootjs/client'
|
|
110
|
+
export function ReorderSelect() {
|
|
111
|
+
const [opts, setOpts] = createSignal([{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }])
|
|
112
|
+
const [val, setVal] = createSignal('b')
|
|
113
|
+
return (
|
|
114
|
+
<div>
|
|
115
|
+
<select value={val()} onChange={(e) => setVal(e.target.value)}>
|
|
116
|
+
{opts().map((o, i) => <option key={i} value={o.id}>{o.label}</option>)}
|
|
117
|
+
</select>
|
|
118
|
+
<button onClick={() => setOpts([...opts()].reverse())}>Reverse</button>
|
|
119
|
+
</div>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
`)
|
|
123
|
+
// `key={i}` is index-keyed: nothing MOVES on reorder, so `value`/text are
|
|
124
|
+
// rewritten in place by applyItem — `selected` must be recomputed there
|
|
125
|
+
// too (item changed under a stationary row), not just at row creation.
|
|
126
|
+
expect(clientJs).toContain('mapArrayLazy(')
|
|
127
|
+
expect(clientJs).toContain('applyItem:')
|
|
128
|
+
expect(clientJs).toContain('applyOuter:')
|
|
129
|
+
// Every apply body (createRow, applyItem, applyOuter) recomputes
|
|
130
|
+
// `selected` from the item AND the outer controlled signal…
|
|
131
|
+
const computed = clientJs.match(/const __x = \(val\(\)\) === \(o\(\)\.id\)/g) ?? []
|
|
132
|
+
expect(computed.length).toBe(3)
|
|
133
|
+
// …and writes it as a DOM PROPERTY (not just an HTML attribute
|
|
134
|
+
// presence, which would not affect a live, already-rendered <option>).
|
|
135
|
+
const writes = clientJs.match(/\.selected = !!\(__x\)/g) ?? []
|
|
136
|
+
expect(writes.length).toBe(3)
|
|
137
|
+
}, 20000)
|
|
93
138
|
|
|
94
139
|
test('textarea with explicit children is left untouched', () => {
|
|
95
140
|
const { template } = compiled(`
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for #2589: a `let` declaration's explicit type
|
|
3
|
+
* annotation was dropped in emitted `.tsx` SSR templates —
|
|
4
|
+
* `let x: HTMLTextAreaElement | null = null` emitted as `let x = null`,
|
|
5
|
+
* which TypeScript then infers as `null`/`never`, producing
|
|
6
|
+
* TS7034/TS7005 (and TS2339 via `never` narrowing) under strict mode.
|
|
7
|
+
* Runtime output (client JS) was always correct — this is a type-level
|
|
8
|
+
* emission defect in the SSR template only.
|
|
9
|
+
*
|
|
10
|
+
* `ConstantInfo.typeAnnotation` (verbatim `node.type.getText()`, only
|
|
11
|
+
* present when the author wrote an explicit annotation) is now threaded
|
|
12
|
+
* through and printed by the `HonoAdapter` (`JsxAdapter` base) for `let`
|
|
13
|
+
* declarations, both function-scope and module-scope, initialized and
|
|
14
|
+
* uninitialized. `const` declarations are deliberately left unchanged:
|
|
15
|
+
* their type always infers correctly from the (immutable) initializer,
|
|
16
|
+
* so emitting an annotation there would only churn output for no
|
|
17
|
+
* typecheck gain (see the design note in the class docstring / #2589).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, test, expect } from 'bun:test'
|
|
21
|
+
import { compileJSX } from '../compiler'
|
|
22
|
+
import { HonoAdapter } from '../../../../packages/adapter-hono/src/adapter/hono-adapter'
|
|
23
|
+
|
|
24
|
+
describe('let type annotation preservation in emitted templates (#2589)', () => {
|
|
25
|
+
test('function-scope initialized let keeps its explicit type annotation', () => {
|
|
26
|
+
const honoAdapter = new HonoAdapter()
|
|
27
|
+
// `status()` is called directly from the returned JSX (unlike an
|
|
28
|
+
// `onXxx` event-handler prop, which the SSR template stubs to a
|
|
29
|
+
// no-op), so its body — and transitively `textareaEl`, which the
|
|
30
|
+
// effect-guard shape (`syncScroll`, mirroring the issue) also reads —
|
|
31
|
+
// stays reachable and survives into the emitted template.
|
|
32
|
+
const source = `
|
|
33
|
+
'use client'
|
|
34
|
+
import { createSignal, createEffect } from '@barefootjs/client'
|
|
35
|
+
|
|
36
|
+
export function Textarea() {
|
|
37
|
+
let textareaEl: HTMLTextAreaElement | null = null
|
|
38
|
+
const [value, setValue] = createSignal('')
|
|
39
|
+
|
|
40
|
+
const syncScroll = () => {
|
|
41
|
+
if (textareaEl) {
|
|
42
|
+
textareaEl.scrollTop = textareaEl.scrollHeight
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const status = () => (textareaEl ? 'ready' : 'idle')
|
|
47
|
+
|
|
48
|
+
createEffect(() => {
|
|
49
|
+
value()
|
|
50
|
+
syncScroll()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
return <div>{status()}</div>
|
|
54
|
+
}
|
|
55
|
+
`
|
|
56
|
+
|
|
57
|
+
const result = compileJSX(source, 'Textarea.tsx', { adapter: honoAdapter })
|
|
58
|
+
expect(result.errors).toHaveLength(0)
|
|
59
|
+
|
|
60
|
+
const template = result.files.find((f) => f.type === 'markedTemplate')!
|
|
61
|
+
expect(template).toBeDefined()
|
|
62
|
+
expect(template.content).toContain('let textareaEl: HTMLTextAreaElement | null = null')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('module-scope initialized let keeps its explicit type annotation', () => {
|
|
66
|
+
const honoAdapter = new HonoAdapter()
|
|
67
|
+
const source = `
|
|
68
|
+
'use client'
|
|
69
|
+
import { createSignal } from '@barefootjs/client'
|
|
70
|
+
|
|
71
|
+
let exportModulePromise: Promise<{ x: number }> | null = null
|
|
72
|
+
|
|
73
|
+
export function Loader() {
|
|
74
|
+
const [status, setStatus] = createSignal('idle')
|
|
75
|
+
|
|
76
|
+
const load = () => {
|
|
77
|
+
exportModulePromise = Promise.resolve({ x: 1 })
|
|
78
|
+
setStatus('loaded')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return <button onClick={load}>{status()}</button>
|
|
82
|
+
}
|
|
83
|
+
`
|
|
84
|
+
|
|
85
|
+
const result = compileJSX(source, 'Loader.tsx', { adapter: honoAdapter })
|
|
86
|
+
expect(result.errors).toHaveLength(0)
|
|
87
|
+
|
|
88
|
+
const template = result.files.find((f) => f.type === 'markedTemplate')!
|
|
89
|
+
expect(template).toBeDefined()
|
|
90
|
+
expect(template.content).toContain(
|
|
91
|
+
'let exportModulePromise: Promise<{ x: number }> | null = null',
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('module-scope uninitialized let keeps its explicit type annotation', () => {
|
|
96
|
+
const honoAdapter = new HonoAdapter()
|
|
97
|
+
const source = `
|
|
98
|
+
'use client'
|
|
99
|
+
import { createSignal } from '@barefootjs/client'
|
|
100
|
+
|
|
101
|
+
let pending: number
|
|
102
|
+
|
|
103
|
+
export function Counter() {
|
|
104
|
+
const [count, setCount] = createSignal(0)
|
|
105
|
+
|
|
106
|
+
const bump = () => {
|
|
107
|
+
pending = count() + 1
|
|
108
|
+
setCount(pending)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return <button onClick={bump}>{count()}</button>
|
|
112
|
+
}
|
|
113
|
+
`
|
|
114
|
+
|
|
115
|
+
const result = compileJSX(source, 'Counter.tsx', { adapter: honoAdapter })
|
|
116
|
+
expect(result.errors).toHaveLength(0)
|
|
117
|
+
|
|
118
|
+
const template = result.files.find((f) => f.type === 'markedTemplate')!
|
|
119
|
+
expect(template).toBeDefined()
|
|
120
|
+
expect(template.content).toContain('let pending: number')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('unannotated let does NOT gain an inferred type annotation', () => {
|
|
124
|
+
const honoAdapter = new HonoAdapter()
|
|
125
|
+
const source = `
|
|
126
|
+
'use client'
|
|
127
|
+
import { createSignal } from '@barefootjs/client'
|
|
128
|
+
|
|
129
|
+
export function Toggle() {
|
|
130
|
+
let y = null
|
|
131
|
+
const [open, setOpen] = createSignal(false)
|
|
132
|
+
|
|
133
|
+
const flip = () => {
|
|
134
|
+
y = open() ? 1 : null
|
|
135
|
+
setOpen(!open())
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return <button onClick={flip}>{open() ? 'on' : 'off'}{String(y)}</button>
|
|
139
|
+
}
|
|
140
|
+
`
|
|
141
|
+
|
|
142
|
+
const result = compileJSX(source, 'Toggle.tsx', { adapter: honoAdapter })
|
|
143
|
+
expect(result.errors).toHaveLength(0)
|
|
144
|
+
|
|
145
|
+
const template = result.files.find((f) => f.type === 'markedTemplate')!
|
|
146
|
+
expect(template).toBeDefined()
|
|
147
|
+
// No annotation must be synthesized from inference — only an
|
|
148
|
+
// explicit source annotation is ever printed (`typeAnnotation`,
|
|
149
|
+
// never `type` for an initialized declaration).
|
|
150
|
+
expect(template.content).toContain('let y = null')
|
|
151
|
+
expect(template.content).not.toMatch(/let y\s*:/)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('const with an explicit annotation is unchanged (no annotation added at emit)', () => {
|
|
155
|
+
const honoAdapter = new HonoAdapter()
|
|
156
|
+
const source = `
|
|
157
|
+
'use client'
|
|
158
|
+
import { createSignal } from '@barefootjs/client'
|
|
159
|
+
|
|
160
|
+
export function Labelled() {
|
|
161
|
+
const label: string = 'hello'
|
|
162
|
+
const [count, setCount] = createSignal(0)
|
|
163
|
+
return <button onClick={() => setCount(count() + 1)}>{label}{count()}</button>
|
|
164
|
+
}
|
|
165
|
+
`
|
|
166
|
+
|
|
167
|
+
const result = compileJSX(source, 'Labelled.tsx', { adapter: honoAdapter })
|
|
168
|
+
expect(result.errors).toHaveLength(0)
|
|
169
|
+
|
|
170
|
+
const template = result.files.find((f) => f.type === 'markedTemplate')!
|
|
171
|
+
expect(template).toBeDefined()
|
|
172
|
+
// By design (#2589 scoping decision) only `let` gets its annotation
|
|
173
|
+
// re-emitted — `const` infers correctly from its initializer already,
|
|
174
|
+
// so this stays `const label = 'hello'` with no annotation added.
|
|
175
|
+
expect(template.content).toContain("const label = 'hello'")
|
|
176
|
+
expect(template.content).not.toContain('const label: string')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('ambient `declare let` is not re-emitted as a runtime binding', () => {
|
|
180
|
+
const honoAdapter = new HonoAdapter()
|
|
181
|
+
// `declare let` is a type-only contract: it has no initializer and
|
|
182
|
+
// carries NodeFlags.Let, so after the uninitialized-`let` collection
|
|
183
|
+
// fix it would match the module-scope collector unless ambient
|
|
184
|
+
// statements are excluded. Re-emitting it as a runtime `let` would
|
|
185
|
+
// shadow the real global with `undefined` in the SSR module.
|
|
186
|
+
const source = `
|
|
187
|
+
'use client'
|
|
188
|
+
import { createSignal } from '@barefootjs/client'
|
|
189
|
+
|
|
190
|
+
declare let __BF_AMBIENT__: string
|
|
191
|
+
|
|
192
|
+
export function Widget() {
|
|
193
|
+
const [n, setN] = createSignal(0)
|
|
194
|
+
const status = () => (__BF_AMBIENT__ ? 'set' : 'unset')
|
|
195
|
+
return <button onClick={() => setN(n() + 1)}>{status()}{n()}</button>
|
|
196
|
+
}
|
|
197
|
+
`
|
|
198
|
+
|
|
199
|
+
const result = compileJSX(source, 'Widget.tsx', { adapter: honoAdapter })
|
|
200
|
+
expect(result.errors).toHaveLength(0)
|
|
201
|
+
|
|
202
|
+
const template = result.files.find((f) => f.type === 'markedTemplate')!
|
|
203
|
+
expect(template).toBeDefined()
|
|
204
|
+
// The reference inside `status` may survive, but no runtime `let`
|
|
205
|
+
// declaration for the ambient name may be emitted.
|
|
206
|
+
expect(template.content).not.toMatch(/^\s*let __BF_AMBIENT__/m)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memo/effect dependency extraction must read the token stream, not the raw
|
|
3
|
+
* body text: a doc comment (or string literal) mentioning `otherSignal()`
|
|
4
|
+
* inside a computation body is not a read and must not become a dependency.
|
|
5
|
+
*
|
|
6
|
+
* Regression pin for the phantom deps surfaced in #2581's review: slider's
|
|
7
|
+
* `percentage` memo gained `internalValue`/`controlledValue` deps in the
|
|
8
|
+
* regenerated `ui/meta/slider.json` purely because an explanatory comment
|
|
9
|
+
* inside the memo body named those getters in call syntax.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, test, expect } from 'bun:test'
|
|
12
|
+
import { analyzeComponent } from '../analyzer'
|
|
13
|
+
|
|
14
|
+
describe('dependency extraction ignores comments and strings', () => {
|
|
15
|
+
test('a comment naming another getter in call syntax is not a dep', () => {
|
|
16
|
+
const source = `
|
|
17
|
+
"use client"
|
|
18
|
+
import { createSignal, createMemo } from '@barefootjs/client'
|
|
19
|
+
export function Slider() {
|
|
20
|
+
const [internalValue, setInternalValue] = createSignal(0)
|
|
21
|
+
const [controlledValue, setControlledValue] = createSignal<number | undefined>(undefined)
|
|
22
|
+
const [min, setMin] = createSignal(0)
|
|
23
|
+
const [max, setMax] = createSignal(100)
|
|
24
|
+
const currentValue = createMemo(() => controlledValue() ?? internalValue())
|
|
25
|
+
const percentage = createMemo(() => {
|
|
26
|
+
if (max() <= min()) return 0
|
|
27
|
+
// \`currentValue()\` mirrors \`controlledValue()\` and \`internalValue()\`
|
|
28
|
+
// in its type, so assert what's runtime-guaranteed.
|
|
29
|
+
return Math.max(0, Math.min(100, ((currentValue()! - min()) / (max() - min())) * 100))
|
|
30
|
+
})
|
|
31
|
+
return <div style={\`width: \${percentage()}%\`}>{currentValue()}</div>
|
|
32
|
+
}
|
|
33
|
+
`
|
|
34
|
+
const ctx = analyzeComponent(source, 'Slider.tsx')
|
|
35
|
+
const percentage = ctx.memos.find(m => m.name === 'percentage')
|
|
36
|
+
expect(percentage).toBeDefined()
|
|
37
|
+
expect(percentage!.deps.sort()).toEqual(['currentValue', 'max', 'min'])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('a string literal naming a getter in call syntax is not a dep', () => {
|
|
41
|
+
const source = `
|
|
42
|
+
"use client"
|
|
43
|
+
import { createSignal, createMemo } from '@barefootjs/client'
|
|
44
|
+
export function Label() {
|
|
45
|
+
const [count, setCount] = createSignal(0)
|
|
46
|
+
const [label, setLabel] = createSignal('x')
|
|
47
|
+
const hint = createMemo(() => label() + ' (see count() for the number)')
|
|
48
|
+
return <span>{hint()}</span>
|
|
49
|
+
}
|
|
50
|
+
`
|
|
51
|
+
const ctx = analyzeComponent(source, 'Label.tsx')
|
|
52
|
+
const hint = ctx.memos.find(m => m.name === 'hint')
|
|
53
|
+
expect(hint).toBeDefined()
|
|
54
|
+
expect(hint!.deps).toEqual(['label'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('a read AFTER a template-literal substitution still registers', () => {
|
|
58
|
+
// Trap for token-scan implementations: without a parser driving
|
|
59
|
+
// `reScanTemplateToken`, the template tail after `\${...}` is mis-lexed
|
|
60
|
+
// as a new template opener and swallows the following statement —
|
|
61
|
+
// xyflow's `visibleClass` shape, where `animated()` follows a
|
|
62
|
+
// substitution line and silently lost its dep.
|
|
63
|
+
const source = `
|
|
64
|
+
"use client"
|
|
65
|
+
import { createSignal, createMemo } from '@barefootjs/client'
|
|
66
|
+
export function Edge() {
|
|
67
|
+
const [selected, setSelected] = createSignal(false)
|
|
68
|
+
const [animated, setAnimated] = createSignal(false)
|
|
69
|
+
const visibleClass = createMemo(() => {
|
|
70
|
+
let cls = 'edge'
|
|
71
|
+
if (selected()) cls += \` \${'edge-selected'}\`
|
|
72
|
+
if (animated()) cls += \` \${'edge-animated'}\`
|
|
73
|
+
return cls
|
|
74
|
+
})
|
|
75
|
+
return <div class={visibleClass()}>x</div>
|
|
76
|
+
}
|
|
77
|
+
`
|
|
78
|
+
const ctx = analyzeComponent(source, 'Edge.tsx')
|
|
79
|
+
const visibleClass = ctx.memos.find(m => m.name === 'visibleClass')
|
|
80
|
+
expect(visibleClass).toBeDefined()
|
|
81
|
+
expect(visibleClass!.deps.sort()).toEqual(['animated', 'selected'])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('genuine reads inside template-literal substitutions still register', () => {
|
|
85
|
+
const source = `
|
|
86
|
+
"use client"
|
|
87
|
+
import { createSignal, createMemo } from '@barefootjs/client'
|
|
88
|
+
export function Badge() {
|
|
89
|
+
const [tone, setTone] = createSignal('info')
|
|
90
|
+
const cls = createMemo(() => \`badge badge-\${tone()}\`)
|
|
91
|
+
return <span class={cls()}>x</span>
|
|
92
|
+
}
|
|
93
|
+
`
|
|
94
|
+
const ctx = analyzeComponent(source, 'Badge.tsx')
|
|
95
|
+
const cls = ctx.memos.find(m => m.name === 'cls')
|
|
96
|
+
expect(cls).toBeDefined()
|
|
97
|
+
expect(cls!.deps).toEqual(['tone'])
|
|
98
|
+
})
|
|
99
|
+
})
|