@kudzujs/core 0.5.1 → 0.5.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/README.md CHANGED
@@ -191,20 +191,22 @@ Kudzu resolves `current` when the handler reads it, so removed conditional eleme
191
191
  Create a context to pass static or reactive values through component layers without prop drilling:
192
192
 
193
193
  ```tsx
194
- const ThemeContext = createContext("light")
194
+ type ThemeValue = { theme: string; setTheme: (theme: string) => void }
195
+ const ThemeContext = createContext<ThemeValue | null>(null)
195
196
 
196
197
  function Toolbar() {
197
- const theme = useContext(ThemeContext)
198
- return <button className={`theme-${theme}`}>{theme}</button>
198
+ const value = useContext(ThemeContext)
199
+ if (!value) return null
200
+ return <button className={`theme-${value.theme}`} onClick={() => value.setTheme("light")}>{value.theme}</button>
199
201
  }
200
202
 
201
203
  function App() {
202
- const [theme] = useState("dark")
203
- return <ThemeContext.Provider value={theme}><Toolbar /></ThemeContext.Provider>
204
+ const [theme, setTheme] = useState("dark")
205
+ return <ThemeContext.Provider value={{ theme, setTheme }}><Toolbar /></ThemeContext.Provider>
204
206
  }
205
207
  ```
206
208
 
207
- The default value applies outside a Provider, nested Providers override their parent, and native handlers read the latest direct state value. Static Provider values must be serializable. Reactive Provider values must be a direct `useState` value; setters and objects containing reactive values are not supported.
209
+ Context values may contain state, setters, arrays, nested plain objects, and static serializable fields. Consumers can read reactive properties, destructure or rename them, and call setters from normal handlers. Kudzu serializes only state and setter IDs, then materializes live browser getters and batched setters; no function source, Provider tree, component tree, or hydration is shipped. The default applies outside a Provider and nested Providers resolve to independent concrete state IDs at build time. Arbitrary functions, accessors, cycles, symbols, and non-plain objects remain rejected at the browser capture boundary.
208
210
 
209
211
  ## Conditional DOM
210
212
 
@@ -227,7 +229,7 @@ Logical state persists across branch switches, while uncontrolled DOM state rese
227
229
 
228
230
  Reactive conditional DOM currently targets the HTML namespace and is rejected inside SVG or MathML.
229
231
 
230
- Top-level immutable JSX locals can hold static or state-dependent branches:
232
+ Top-level or block-scoped immutable JSX locals can hold static or state-dependent branches:
231
233
 
232
234
  ```tsx
233
235
  const menu = open ? <MenuBar /> : <p>Menu dormant</p>
@@ -236,7 +238,22 @@ const content = open && menu
236
238
  return <main>{content}</main>
237
239
  ```
238
240
 
239
- Kudzu compiles the local initializer to the same bounded DOM ranges as an inline condition. Reassigned, block-scoped, and keyed-list alias locals remain unsupported.
241
+ Kudzu compiles the local initializer to the same bounded DOM ranges as an inline condition. Terminal early returns and one adjacent exhaustive `let` assignment normalize to the same representation:
242
+
243
+ ```tsx
244
+ if (loading) return <Loading />
245
+ if (failed) return <ErrorView />
246
+ return <Content />
247
+
248
+ let view
249
+ if (open) view = <Menu />
250
+ else view = <p>Closed</p>
251
+ return view
252
+ ```
253
+
254
+ Branches may contain only the return or assignment being normalized. Effectful statements, non-exhaustive assignments, later reassignment, loops, `switch`, and `try` remain ordinary JavaScript and state-dependent render forms are rejected rather than evaluated against signal-object truthiness. Reactive branches are still both rendered into inert templates at build time.
255
+
256
+ A 1,000-component A/B build compared direct ternaries with an even mix of block locals, early returns, and exhaustive assignment. Both emitted 1,000 conditions and byte-identical runtime assets. The mixed source added 18 B gzip for three equivalent evaluator exports instead of one and built in 604 ms versus 590 ms (+2.24%).
240
257
 
241
258
  ## Keyed Lists
242
259
 
@@ -264,19 +281,32 @@ const rows = items.map(item =>
264
281
  return <ul>{rows}</ul>
265
282
  ```
266
283
 
267
- The root may also be a same-file row component that directly returns one intrinsic element and receives the whole item through one prop:
284
+ The root may also be a top-level same-file row component. Kudzu specializes each call at build time, so projected props, callback props, and simple local calculations compile to the same intrinsic list template:
268
285
 
269
286
  ```tsx
270
- function ItemRow({ item }: { item: Item }) {
271
- return <li>{item.name}</li>
287
+ function ItemRow({ name, done, onRemove }: {
288
+ name: string
289
+ done: boolean
290
+ onRemove: () => void
291
+ }) {
292
+ const className = done ? "done" : "active"
293
+ return <li className={className}>
294
+ {name}
295
+ <button onClick={() => onRemove()}>Remove</button>
296
+ </li>
272
297
  }
273
298
 
274
- const rows = items.map(item => <ItemRow key={item.id} item={item} />)
299
+ const rows = items.map(item => <ItemRow
300
+ key={item.id}
301
+ name={item.name}
302
+ done={item.done}
303
+ onRemove={() => setItems(items.filter(entry => entry.id !== item.id))}
304
+ />)
275
305
  ```
276
306
 
277
- Kudzu emits initial items as static HTML, then adds, removes, updates, styles, conditional branches, and moves keyed elements directly. The map may appear directly in JSX or in one top-level immutable `const` rendered once as a JSX child. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Single-level item-local `&&` and ternary JSX conditions patch only their bounded branch and mount or unmount its handlers. Item-local handlers use direct DOM listeners and receive the latest JSON-safe item for their key, including after updates, additions, and reorders. The item remains stored once in shared list state; handler descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
307
+ The original component remains reusable across multiple lists and ordinary JSX. No component function or component runtime is shipped to the browser. Kudzu emits initial items as static HTML, then adds, removes, updates, styles, conditional branches, and moves keyed elements directly. The map may appear directly in JSX or in one top-level immutable `const` rendered once as a JSX child. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Single-level item-local `&&` and ternary JSX conditions patch only their bounded branch and mount or unmount its handlers. Item-local handlers use direct DOM listeners and receive the latest JSON-safe item for their key, including after updates, additions, and reorders. The item remains stored once in shared list state; handler descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
278
308
 
279
- Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a local-state `.map`, one identifier callback parameter, one intrinsic JSX root or same-file row component, and `key={item.<field>}`. A row component must directly destructure the whole item prop, directly return one intrinsic element, and be used only by that list. A list alias may only be rendered once and cannot be read by other JavaScript. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, locals, imported helpers, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Nested item conditions, lists, or component tags, item spreads, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
309
+ Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a local-state `.map`, one identifier callback parameter, one intrinsic JSX root or top-level same-file row component, and `key={item.<field>}`. Row components accept destructured projected props and top-level single-`const` calculations before one intrinsic return. A list alias may only be rendered once and cannot be read by other JavaScript. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, imported helpers, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Exported or imported row components, prop spreads/defaults/rest, children, nested item conditions, lists, or component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
280
310
 
281
311
  ## Normal JavaScript
282
312
 
@@ -357,7 +387,7 @@ Supported:
357
387
  - Default, nested, and reactive context providers
358
388
  - Controlled `value` and `checked` form properties
359
389
  - Conditional child `&&` and ternary DOM patches
360
- - Top-level immutable JSX locals
390
+ - Top-level and block-scoped JSX locals, terminal early returns, and exhaustive JSX assignment
361
391
  - Direct keyed local-state lists
362
392
 
363
393
  Not implemented yet:
@@ -398,13 +428,24 @@ The same native counter calculation was measured inline and through one relative
398
428
 
399
429
  Bundling removes the helper file boundary, leaving 26 raw bytes and 18 gzip bytes for the function definition and calls. The measured call adds 0.69 µs per state update. The smaller 393 B command-only counter above uses a different optimized runtime path and is not the helper overhead baseline.
400
430
 
431
+ #### Context Object Cost
432
+
433
+ The same native counter was measured with local state access and through `value={{ count, setCount }}`. Context uses live object properties in both derived text and handlers, so this measures the complete recursive capture and generic binding capability.
434
+
435
+ | Kudzu variant | Files | Initial JS gzip | Total output | Clean build | Click |
436
+ |---|---:|---:|---:|---:|---:|
437
+ | Local native state | 5 | **1,890 B** | **4,064 B** | **421 ms** | **3.96 µs** |
438
+ | Context object | 7 | 4,991 B | 11,829 B | 441 ms | 7.54 µs |
439
+
440
+ Context adds 3,101 B gzip and 3.59 µs per update only on pages using nested reactive capture descriptors. It preserves immediate logical reads across repeated setter calls and batches DOM writes once per synchronous turn. Capability specialization removes the recursive state/setter branches from pages that do not use them.
441
+
401
442
  #### Wrapper-Free Derived Text
402
443
 
403
- The same object-state counter was built with the v0.4.14 span target and the comment-bounded text range. Browser medians use five 20,000-update batches in each of seven fresh Chrome sessions.
444
+ The same object-state counter was built with the legacy span target and the current comment-bounded text range. Browser medians use five 20,000-update batches in each of seven fresh Chrome sessions.
404
445
 
405
446
  | Text target | Files | JS gzip | Total output | Clean build | Update |
406
447
  |---|---:|---:|---:|---:|---:|
407
- | Span v0.4.14 | 7 | **4,453 B** | **10,065 B** | **404 ms** | **4.83 µs** |
448
+ | Legacy span target | 7 | **4,453 B** | **10,065 B** | **404 ms** | **4.83 µs** |
408
449
  | Comment range | 7 | 4,763 B | 10,922 B | 426 ms | 5.03 µs |
409
450
 
410
451
  The range costs 310 B gzip only on pages using derived reactive text. It removes wrapper elements and preserves authored structure across table cells, options, SVG text, selectors, and conditional remounts; ordinary attribute and condition pages tree-shake the range code entirely.
@@ -448,7 +489,7 @@ The list starts with 1,000 keyed items, then updates every label, reverses the o
448
489
  | Svelte CSR | No | 12.9 KB | 33.1 KB | 828 ms | 5.8 ms | 38.9 ms | 4.0 ms | 5.9 ms | 54.6 ms |
449
490
  | Qwik CSR | No | 22.2 KB | 64.1 KB | 594 ms | 9.1 ms | 22.2 ms | 30.8 ms | 19.0 ms | 81.1 ms |
450
491
 
451
- An intrinsic-root versus row-component A/B build produced byte-for-byte identical `dist` output: 5,175 B JS gzip and 61,731 B total. Seven interleaved clean builds measured 444 ms and 441 ms. Browser operation medians totaled 23.8 ms and 25.4 ms respectively; because the deployed HTML and JavaScript are identical, that 1.6 ms difference is measurement variance rather than component runtime overhead.
492
+ An intrinsic-root versus projected-prop row-component A/B build produced byte-for-byte identical `dist` output: 5,175 B JS gzip and 61,731 B total. Seven interleaved clean builds measured 467 ms and 455 ms. Browser operation medians totaled 23.7 ms and 23.9 ms respectively; because the deployed HTML and JavaScript are identical, the 0.2 ms difference is measurement variance rather than component runtime overhead.
452
493
 
453
494
  Astro is the hand-authored native DOM baseline in the interactive fixtures. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. Kudzu's keyed-list operations total 23.2 ms, 10.7 ms behind the hand-authored Astro baseline and 7.1 ms ahead of React across all four operations.
454
495
 
@@ -229,7 +229,7 @@ async function loadEvaluator(descriptor) {
229
229
  }
230
230
 
231
231
  async function createBindingContext(descriptor) {
232
- const scope = Object.fromEntries(Object.entries(descriptor.scope).map(([name, value]) => [name, deserialize(value)]))
232
+ const scope = Object.fromEntries(Object.entries(descriptor.scope).map(([name, value]) => [name, deserialize(value, id => browserState.get(id))]))
233
233
  const nested = {}
234
234
  await Promise.all(Object.entries(descriptor.scopeBindings).map(async ([name, binding]) => {
235
235
  const evaluator = await loadEvaluator(binding)
@@ -245,10 +245,19 @@ function bindingStateIds(descriptor) {
245
245
  return new Set([
246
246
  ...Object.values(descriptor.states),
247
247
  ...Object.values(descriptor.scopeStates),
248
+ ...(globalThis.__KUDZU_CAPTURE_STATE__ ? Object.values(descriptor.scope).flatMap(serializedStateIds) : []),
248
249
  ...Object.values(descriptor.scopeBindings).flatMap(binding => [...bindingStateIds(binding)])
249
250
  ])
250
251
  }
251
252
 
253
+ function serializedStateIds(value) {
254
+ if (!value || typeof value !== "object") return []
255
+ if (value.type === "state") return [value.id]
256
+ if (value.type === "array") return value.value.flatMap(serializedStateIds)
257
+ if (value.type === "object") return value.value.flatMap(([, entry]) => serializedStateIds(entry))
258
+ return []
259
+ }
260
+
252
261
  function matching(root, selector) {
253
262
  return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
254
263
  }
@@ -82,6 +82,8 @@ export async function build({ quiet = false, minify = true } = {}) {
82
82
  const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
83
83
  const hasTextBindings = plans.some(plan => plan.bindings.some(binding => binding.target === "text"))
84
84
  const hasListConditions = plans.some(plan => plan.lists.some(list => list.conditions))
85
+ const hasNestedStateCaptures = hasNestedCaptureState(plans)
86
+ const hasSetterCaptures = hasCaptureType(plans, "setter")
85
87
  const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
86
88
  const hasNativeHandlers = nativeModules.length > 0
87
89
  if (behaviorCount) {
@@ -89,14 +91,20 @@ export async function build({ quiet = false, minify = true } = {}) {
89
91
  const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
90
92
  await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
91
93
  }
92
- if (bindingCount || hasNativeHandlers) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify)
94
+ if (bindingCount || hasNativeHandlers) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify, {
95
+ "globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures),
96
+ "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
97
+ })
93
98
  if (bindingCount || listStyleCount) await writeJavaScript(join(assetsDirectory, "kudzu-style.js"), await readFile(new URL("./style.js", import.meta.url), "utf8"), minify)
94
99
  if (bindingCount) {
95
100
  const bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
96
101
  .replace('"./shared-runtime.js"', '"./kudzu.js"')
97
102
  .replace('"./serialization.js"', '"./kudzu-serialization.js"')
98
103
  .replace('"./style.js"', '"./kudzu-style.js"')
99
- await writeBundledJavaScript(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime, minify, { "globalThis.__KUDZU_TEXT_BINDINGS__": String(hasTextBindings) })
104
+ await writeBundledJavaScript(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime, minify, {
105
+ "globalThis.__KUDZU_TEXT_BINDINGS__": String(hasTextBindings),
106
+ "globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures)
107
+ })
100
108
  }
101
109
  if (listCount) {
102
110
  let listRuntime = (await readFile(new URL("./list-runtime.js", import.meta.url), "utf8"))
@@ -115,7 +123,9 @@ export async function build({ quiet = false, minify = true } = {}) {
115
123
  const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
116
124
  .replace('"./shared-runtime.js"', '"./kudzu.js"')
117
125
  .replace('"./serialization.js"', '"./kudzu-serialization.js"')
118
- await writeJavaScript(join(assetsDirectory, "kudzu-native.js"), specializeNativeRuntime(nativeRuntime, nativeEvents, nativeModules), minify)
126
+ await writeJavaScript(join(assetsDirectory, "kudzu-native.js"), specializeNativeRuntime(nativeRuntime, nativeEvents, nativeModules), minify, {
127
+ "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
128
+ })
119
129
  }
120
130
  for (const handlerModule of handlerModules) {
121
131
  const output = join(assetsDirectory, handlerModule.path)
@@ -171,6 +181,20 @@ function specializeNativeRuntime(source, events, modules) {
171
181
  return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
172
182
  }
173
183
 
184
+ function hasCaptureType(value, type) {
185
+ if (!value || typeof value !== "object") return false
186
+ if (value.type === type) return true
187
+ return (Array.isArray(value) ? value : Object.values(value)).some(entry => hasCaptureType(entry, type))
188
+ }
189
+
190
+ function hasNestedCaptureState(value, insideCapture = false) {
191
+ if (!value || typeof value !== "object") return false
192
+ if (value.type === "state") return insideCapture
193
+ if (value.type === "array") return value.value.some(entry => hasNestedCaptureState(entry, true))
194
+ if (value.type === "object") return value.value.some(([, entry]) => hasNestedCaptureState(entry, true))
195
+ return (Array.isArray(value) ? value : Object.values(value)).some(entry => hasNestedCaptureState(entry, false))
196
+ }
197
+
174
198
  export function specializeRuntime(source, events, hasStateSeed) {
175
199
  const specialized = specializeEvents(source, events)
176
200
  if (hasStateSeed) return specialized
@@ -179,8 +203,8 @@ export function specializeRuntime(source, events, hasStateSeed) {
179
203
  .replace(/^ if \(initialState\).*\n/m, "")
180
204
  }
181
205
 
182
- async function writeJavaScript(file, source, minify) {
183
- const code = minify ? (await transform(source, { format: "esm", legalComments: "none", minify: true, target: "es2022" })).code : source
206
+ async function writeJavaScript(file, source, minify, define) {
207
+ const code = minify || define ? (await transform(source, { define, format: "esm", legalComments: "none", minify, target: "es2022" })).code : source
184
208
  await writeFile(file, code)
185
209
  }
186
210
 
@@ -392,9 +416,13 @@ async function compile(file, sourceFiles, base) {
392
416
  function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, clientImports) {
393
417
  return context => sourceFile => {
394
418
  const factory = context.factory
419
+ sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
420
+ ts.setParentRecursive(sourceFile, false)
395
421
  const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
396
422
  const settersByFunction = new Map()
397
423
  const functions = new Map()
424
+ const components = new Map()
425
+ const contexts = new Set()
398
426
  const jsxLocalDeclarations = new Map()
399
427
  const jsxLocalsByFunction = new Map()
400
428
  const listLocalDeclarations = new WeakSet()
@@ -420,14 +448,21 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
420
448
  }
421
449
  }
422
450
  }
423
- if (ts.isFunctionDeclaration(node) && node.name) functions.set(node.name.text, node)
451
+ if (ts.isFunctionDeclaration(node) && node.name) {
452
+ functions.set(node.name.text, node)
453
+ if (node.parent === sourceFile) components.set(node.name.text, { function: node, declaration: node })
454
+ }
424
455
  if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))) {
425
456
  functions.set(node.name.text, node.initializer)
457
+ if (node.parent?.parent?.parent === sourceFile) components.set(node.name.text, { function: node.initializer, declaration: node })
426
458
  }
427
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isTopLevelConst(node)) {
459
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isCallExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && node.initializer.expression.text === "createContext") contexts.add(node.name.text)
460
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isLocalConst(node)) {
428
461
  const owner = nearestFunction(node)
429
462
  const declarations = jsxLocalDeclarations.get(owner) ?? new Map()
430
- declarations.set(node.name.text, { node, initializer: node.initializer })
463
+ const entries = declarations.get(node.name.text) ?? []
464
+ entries.push({ node, initializer: node.initializer })
465
+ declarations.set(node.name.text, entries)
431
466
  jsxLocalDeclarations.set(owner, declarations)
432
467
  }
433
468
  ts.forEachChild(node, collect)
@@ -438,49 +473,135 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
438
473
  let changed = true
439
474
  while (changed) {
440
475
  changed = false
441
- for (const [name, { initializer }] of declarations) {
442
- if (!names.has(name) && isJsxLocalValue(initializer, names)) {
476
+ for (const [name, entries] of declarations) {
477
+ if (!names.has(name) && entries.some(({ initializer }) => isJsxLocalValue(initializer, names))) {
443
478
  names.add(name)
444
479
  changed = true
445
480
  }
446
481
  }
447
482
  }
483
+ for (const name of names) {
484
+ const entries = declarations.get(name)
485
+ if (entries.length > 1) {
486
+ const position = sourceFile.getLineAndCharacterOfPosition(entries[1].node.getStart(sourceFile))
487
+ throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} Block-scoped JSX local "${name}" must not shadow another local`)
488
+ }
489
+ }
448
490
  jsxLocalsByFunction.set(owner, names)
449
491
  }
450
492
  for (const [owner, declarations] of jsxLocalDeclarations) {
451
493
  const setters = settersByFunction.get(owner) ?? new Map()
452
- for (const [name, declaration] of declarations) {
453
- const parts = keyedListParts(declaration.initializer, setters)
454
- if (!parts) continue
455
- const uses = []
456
- const collectUses = node => {
457
- if (ts.isJsxExpression(node) && node.initializer === undefined && ts.isIdentifier(node.expression) && node.expression.text === name && nearestFunction(node) === owner) uses.push(node)
458
- ts.forEachChild(node, collectUses)
494
+ for (const [name, entries] of declarations) {
495
+ for (const declaration of entries) {
496
+ const parts = keyedListParts(declaration.initializer, setters)
497
+ if (!parts) continue
498
+ const uses = []
499
+ const collectUses = node => {
500
+ if (ts.isJsxExpression(node) && node.initializer === undefined && ts.isIdentifier(node.expression) && node.expression.text === name && nearestFunction(node) === owner) uses.push(node)
501
+ ts.forEachChild(node, collectUses)
502
+ }
503
+ collectUses(owner.body)
504
+ const references = identifierReferenceCount(owner.body, name)
505
+ const position = sourceFile.getLineAndCharacterOfPosition(declaration.node.getStart(sourceFile))
506
+ if (uses.length > 1) throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} Keyed list local "${name}" must be rendered exactly once`)
507
+ if (references !== uses.length) throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} Keyed list local "${name}" may only be used as a JSX child`)
508
+ listLocalDeclarations.add(declaration.node)
509
+ if (uses.length) listLocalUses.set(uses[0], parts)
459
510
  }
460
- collectUses(owner.body)
461
- const references = identifierReferenceCount(owner.body, name)
462
- const position = sourceFile.getLineAndCharacterOfPosition(declaration.node.getStart(sourceFile))
463
- if (uses.length > 1) throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} Keyed list local "${name}" must be rendered exactly once`)
464
- if (references !== uses.length) throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} Keyed list local "${name}" may only be used as a JSX child`)
465
- listLocalDeclarations.add(declaration.node)
466
- if (uses.length) listLocalUses.set(uses[0], parts)
467
511
  }
468
512
  }
469
- const renderedLists = new WeakMap()
513
+ const rawRenderedLists = []
470
514
  const collectRenderedLists = node => {
471
515
  if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
472
516
  const parts = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction))
473
- if (parts) {
474
- if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
475
- validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, functions)
476
- renderedLists.set(node, parts)
477
- }
517
+ if (parts) rawRenderedLists.push({ node, parts })
478
518
  }
479
519
  ts.forEachChild(node, collectRenderedLists)
480
520
  }
481
521
  collectRenderedLists(sourceFile)
522
+ const fail = (node, message) => {
523
+ const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
524
+ throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
525
+ }
526
+ const rejectUnsupportedRenderControl = node => {
527
+ if (ts.isIfStatement(node) && containsRenderControl(node, jsxLocalsByFunction.get(nearestFunction(node)) ?? new Set())) {
528
+ const setters = settersForNode(node, settersByFunction)
529
+ if (referencedStateNames(node.expression, setters).size) {
530
+ fail(node, "Reactive render if statements must use terminal returns or exhaustive adjacent JSX assignment")
531
+ }
532
+ }
533
+ ts.forEachChild(node, rejectUnsupportedRenderControl)
534
+ }
535
+ rejectUnsupportedRenderControl(sourceFile)
536
+ const listComponentNames = new Set(rawRenderedLists.flatMap(({ parts }) => {
537
+ const tag = jsxTagName(parts.root)
538
+ return tag && ts.isIdentifier(tag) && tag.text[0] === tag.text[0].toUpperCase() ? [tag.text] : []
539
+ }))
540
+ const componentSpecializations = new WeakMap()
541
+ const specializedDeclarations = new WeakSet()
542
+ for (const name of listComponentNames) {
543
+ const component = components.get(name)
544
+ if (!component) fail(sourceFile, `Keyed list component ${name} must be declared at the top level in the same file`)
545
+ if (isExportedDeclaration(component.declaration)) fail(component.declaration, `Keyed list component ${name} cannot be exported`)
546
+ const calls = jsxTagUses(sourceFile, name)
547
+ if (identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `Keyed list component ${name} may only be referenced as JSX`)
548
+ for (const call of calls) componentSpecializations.set(call, specializeComponentCall(call, component.function, sourceFile, factory, context, fail))
549
+ specializedDeclarations.add(component.declaration)
550
+ }
551
+ const renderedLists = new WeakMap()
552
+ for (const { node, parts: originalParts } of rawRenderedLists) {
553
+ if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
554
+ const specialization = componentSpecializations.get(originalParts.root)
555
+ const root = specialization?.root ?? originalParts.root
556
+ const callback = root === originalParts.root ? originalParts.callback : factory.updateArrowFunction(
557
+ originalParts.callback,
558
+ originalParts.callback.modifiers,
559
+ originalParts.callback.typeParameters,
560
+ originalParts.callback.parameters,
561
+ originalParts.callback.type,
562
+ originalParts.callback.equalsGreaterThanToken,
563
+ root
564
+ )
565
+ if (callback !== originalParts.callback) {
566
+ ts.setParentRecursive(callback, false)
567
+ callback.parent = originalParts.callback.parent
568
+ }
569
+ const parts = { ...originalParts, root, callback }
570
+ for (const calculation of specialization?.calculations ?? []) {
571
+ ts.setParentRecursive(calculation, false)
572
+ calculation.parent = callback
573
+ validateListExpression(calculation, parts.item, originalParts.root, fail)
574
+ }
575
+ validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions)
576
+ renderedLists.set(node, parts)
577
+ }
578
+
579
+ const compileRenderExpression = (expression, anchor) => {
580
+ const parts = conditionalParts(expression)
581
+ if (!parts) return ts.visitNode(expression, visitor)
582
+ const setters = settersForNode(anchor, settersByFunction)
583
+ const usedStates = referencedStateNames(parts.condition, setters)
584
+ const captures = captureNames(parts.condition, parts.condition, setters)
585
+ if (!usedStates.size && !captures.size) return ts.visitEachChild(expression, visitor, context)
586
+ usesBehavior = true
587
+ usesConditional = true
588
+ return compileConditional(
589
+ parts.kind,
590
+ parts.condition,
591
+ compileRenderExpression(parts.truthy, anchor),
592
+ compileRenderExpression(parts.falsy, anchor),
593
+ setters,
594
+ factory,
595
+ context,
596
+ reactiveBindings,
597
+ handlerUrl
598
+ )
599
+ }
482
600
 
483
601
  const visitor = node => {
602
+ if (specializedDeclarations.has(node)) return node
603
+ if (componentSpecializations.has(node)) return ts.visitNode(componentSpecializations.get(node).root, visitor)
604
+
484
605
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
485
606
  return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(modulePath(node.moduleSpecifier.text)), node.attributes)
486
607
  }
@@ -504,18 +625,13 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
504
625
  }
505
626
 
506
627
  if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && jsxLocalsByFunction.get(nearestFunction(node))?.has(node.name.text) && referencesIdentifier(nearestFunction(node).body, node.name.text)) {
507
- const parts = conditionalParts(node.initializer)
508
- if (parts) {
509
- const setters = settersForNode(node, settersByFunction)
510
- const usedStates = referencedStateNames(parts.condition, setters)
511
- const captures = captureNames(parts.condition, parts.condition, setters)
512
- if (usedStates.size || captures.size) {
513
- usesBehavior = true
514
- usesConditional = true
515
- const compiled = compileConditional(parts.kind, parts.condition, ts.visitNode(parts.truthy, visitor), ts.visitNode(parts.falsy, visitor), setters, factory, context, reactiveBindings, handlerUrl)
516
- return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, compiled)
517
- }
518
- }
628
+ const compiled = compileRenderExpression(node.initializer, node)
629
+ if (compiled !== node.initializer) return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, compiled)
630
+ }
631
+
632
+ if (ts.isReturnStatement(node) && node.expression && isJsxLocalValue(node.expression, jsxLocalsByFunction.get(nearestFunction(node)) ?? new Set())) {
633
+ const compiled = compileRenderExpression(node.expression, node)
634
+ if (compiled !== node.expression) return factory.updateReturnStatement(node, compiled)
519
635
  }
520
636
 
521
637
  if (ts.isJsxExpression(node) && node.expression && listConditions.has(node.expression)) {
@@ -546,19 +662,10 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
546
662
  ts.visitNode(listParts.callback, visitor)
547
663
  ]))
548
664
  }
549
- const parts = conditionalParts(node.expression)
550
- if (parts) {
551
- const setters = settersForNode(node, settersByFunction)
552
- const usedStates = referencedStateNames(parts.condition, setters)
553
- const captures = captureNames(parts.condition, parts.condition, setters)
554
- if (usedStates.size || captures.size) {
555
- usesBehavior = true
556
- usesConditional = true
557
- const truthy = ts.visitNode(parts.truthy, visitor)
558
- const falsy = ts.visitNode(parts.falsy, visitor)
559
- const compiled = compileConditional(parts.kind, parts.condition, truthy, falsy, setters, factory, context, reactiveBindings, handlerUrl)
560
- return factory.updateJsxExpression(node, compiled)
561
- }
665
+ const conditional = conditionalParts(node.expression)
666
+ if (conditional) {
667
+ const compiled = compileRenderExpression(node.expression, node)
668
+ if (compiled !== node.expression) return factory.updateJsxExpression(node, compiled)
562
669
  }
563
670
  const setters = settersForNode(node, settersByFunction)
564
671
  const usedStates = referencedStateNames(node.expression, setters)
@@ -570,7 +677,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
570
677
  }
571
678
  }
572
679
 
573
- if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !/^on/i.test(node.name.getText()) && !["key", "ref", "dangerouslysetinnerhtml"].includes(node.name.getText().toLowerCase())) {
680
+ if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !isContextProviderValue(node, contexts) && !/^on/i.test(node.name.getText()) && !["key", "ref", "dangerouslysetinnerhtml"].includes(node.name.getText().toLowerCase())) {
574
681
  const expression = node.initializer.expression
575
682
  const setters = settersForNode(node, settersByFunction)
576
683
  const usedStates = referencedStateNames(expression, setters)
@@ -621,6 +728,112 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
621
728
  }
622
729
  }
623
730
 
731
+ function normalizeRenderControlFlow(sourceFile, factory, context) {
732
+ const normalizeStatements = statements => {
733
+ const nested = statements.map(statement => ts.visitEachChild(statement, visitNested, context))
734
+ const assigned = []
735
+ for (let index = 0; index < nested.length; index++) {
736
+ const statement = nested[index]
737
+ const next = nested[index + 1]
738
+ const declaration = singleUninitializedLet(statement)
739
+ const assignment = declaration && next && assignmentConditional(next, declaration.name.text, factory)
740
+ if (declaration && assignment) {
741
+ const updated = factory.updateVariableDeclaration(declaration, declaration.name, declaration.exclamationToken, declaration.type, assignment)
742
+ const list = factory.createVariableDeclarationList([updated], ts.NodeFlags.Const)
743
+ assigned.push(factory.updateVariableStatement(statement, statement.modifiers, list))
744
+ index++
745
+ } else {
746
+ assigned.push(statement)
747
+ }
748
+ }
749
+
750
+ if (!assigned.length) return assigned
751
+ const finalIf = returnConditional(assigned.at(-1), factory)
752
+ if (finalIf) return [...assigned.slice(0, -1), factory.createReturnStatement(finalIf)]
753
+ if (!ts.isReturnStatement(assigned.at(-1)) || !assigned.at(-1).expression) return assigned
754
+ let expression = assigned.at(-1).expression
755
+ let start = assigned.length - 1
756
+ while (start > 0) {
757
+ const previous = assigned[start - 1]
758
+ if (!ts.isIfStatement(previous) || previous.elseStatement) break
759
+ const truthy = returnOnlyExpression(previous.thenStatement)
760
+ if (!truthy) break
761
+ expression = factory.createConditionalExpression(previous.expression, factory.createToken(ts.SyntaxKind.QuestionToken), truthy, factory.createToken(ts.SyntaxKind.ColonToken), expression)
762
+ start--
763
+ }
764
+ return start === assigned.length - 1 ? assigned : [...assigned.slice(0, start), factory.createReturnStatement(expression)]
765
+ }
766
+
767
+ const visitNested = node => {
768
+ if (ts.isBlock(node)) return factory.updateBlock(node, normalizeStatements([...node.statements]))
769
+ if (isFunctionLike(node) && ts.isBlock(node.body)) {
770
+ if (!isRenderFunction(node)) return node
771
+ const body = factory.updateBlock(node.body, normalizeStatements([...node.body.statements]))
772
+ if (ts.isFunctionDeclaration(node)) return factory.updateFunctionDeclaration(node, node.modifiers, node.asteriskToken, node.name, node.typeParameters, node.parameters, node.type, body)
773
+ if (ts.isFunctionExpression(node)) return factory.updateFunctionExpression(node, node.modifiers, node.asteriskToken, node.name, node.typeParameters, node.parameters, node.type, body)
774
+ if (ts.isArrowFunction(node)) return factory.updateArrowFunction(node, node.modifiers, node.typeParameters, node.parameters, node.type, node.equalsGreaterThanToken, body)
775
+ }
776
+ return ts.visitEachChild(node, visitNested, context)
777
+ }
778
+
779
+ return ts.visitEachChild(sourceFile, visitNested, context)
780
+ }
781
+
782
+ function isRenderFunction(node) {
783
+ if (ts.isFunctionDeclaration(node)) return node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword) || Boolean(node.name && /^[A-Z]/.test(node.name.text))
784
+ const declaration = node.parent
785
+ return ts.isVariableDeclaration(declaration) && ts.isIdentifier(declaration.name) && /^[A-Z]/.test(declaration.name.text)
786
+ }
787
+
788
+ function singleUninitializedLet(statement) {
789
+ if (!ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Let) === 0 || statement.declarationList.declarations.length !== 1) return undefined
790
+ const declaration = statement.declarationList.declarations[0]
791
+ return ts.isIdentifier(declaration.name) && !declaration.initializer ? declaration : undefined
792
+ }
793
+
794
+ function assignmentConditional(statement, name, factory) {
795
+ if (!ts.isIfStatement(statement) || !statement.elseStatement) return undefined
796
+ const truthy = assignmentOnlyExpression(statement.thenStatement, name)
797
+ const falsy = ts.isIfStatement(statement.elseStatement)
798
+ ? assignmentConditional(statement.elseStatement, name, factory)
799
+ : assignmentOnlyExpression(statement.elseStatement, name)
800
+ if (!truthy || !falsy) return undefined
801
+ return factory.createConditionalExpression(statement.expression, factory.createToken(ts.SyntaxKind.QuestionToken), truthy, factory.createToken(ts.SyntaxKind.ColonToken), falsy)
802
+ }
803
+
804
+ function assignmentOnlyExpression(statement, name) {
805
+ const candidate = ts.isBlock(statement) && statement.statements.length === 1 ? statement.statements[0] : statement
806
+ if (!ts.isExpressionStatement(candidate) || !ts.isBinaryExpression(candidate.expression) || candidate.expression.operatorToken.kind !== ts.SyntaxKind.EqualsToken || !ts.isIdentifier(candidate.expression.left) || candidate.expression.left.text !== name) return undefined
807
+ return candidate.expression.right
808
+ }
809
+
810
+ function returnConditional(statement, factory) {
811
+ if (!ts.isIfStatement(statement) || !statement.elseStatement) return undefined
812
+ const truthy = returnOnlyExpression(statement.thenStatement)
813
+ const falsy = ts.isIfStatement(statement.elseStatement)
814
+ ? returnConditional(statement.elseStatement, factory)
815
+ : returnOnlyExpression(statement.elseStatement)
816
+ if (!truthy || !falsy) return undefined
817
+ return factory.createConditionalExpression(statement.expression, factory.createToken(ts.SyntaxKind.QuestionToken), truthy, factory.createToken(ts.SyntaxKind.ColonToken), falsy)
818
+ }
819
+
820
+ function returnOnlyExpression(statement) {
821
+ const candidate = ts.isBlock(statement) && statement.statements.length === 1 ? statement.statements[0] : statement
822
+ return ts.isReturnStatement(candidate) && candidate.expression ? candidate.expression : undefined
823
+ }
824
+
825
+ function containsRenderControl(root, knownLocals) {
826
+ let found = false
827
+ const visit = node => {
828
+ if (isFunctionLike(node) && node !== root) return
829
+ if (ts.isReturnStatement(node) && node.expression && isJsxLocalValue(node.expression, knownLocals)) found = true
830
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && containsJsx(node.right)) found = true
831
+ if (!found) ts.forEachChild(node, visit)
832
+ }
833
+ visit(root)
834
+ return found
835
+ }
836
+
624
837
  function keyedListParts(expression, setters) {
625
838
  const value = unwrapExpression(expression)
626
839
  if (!ts.isCallExpression(value) || value.arguments.length !== 1 || !ts.isPropertyAccessExpression(value.expression) || value.expression.name.text !== "map" || !ts.isIdentifier(value.expression.expression)) return undefined
@@ -639,35 +852,13 @@ function keyedListParts(expression, setters) {
639
852
  return { state, callback, root, item: callback.parameters[0].name.text, keyField: field }
640
853
  }
641
854
 
642
- function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, functions) {
855
+ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions) {
643
856
  const fail = (node, message) => {
644
857
  const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
645
858
  throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
646
859
  }
647
- let root = parts.root
648
- let item = parts.item
649
- const rootTag = ts.isJsxElement(root) ? root.openingElement.tagName : root.tagName
650
- if (ts.isIdentifier(rootTag) && rootTag.text[0] === rootTag.text[0].toUpperCase()) {
651
- const component = functions.get(rootTag.text)
652
- if (!component) fail(root, `Keyed list component ${rootTag.text} must be declared in the same file`)
653
- const uses = jsxTagUses(sourceFile, rootTag.text)
654
- if (uses.length !== 1 || uses[0] !== root) fail(root, `Keyed list component ${rootTag.text} may only be used as this list root`)
655
- const attributes = ts.isJsxElement(root) ? root.openingElement.attributes : root.attributes
656
- let itemProp
657
- for (const attribute of attributes.properties) {
658
- if (ts.isJsxSpreadAttribute(attribute)) {
659
- if (referencesIdentifier(attribute.expression, item)) fail(attribute, "Keyed list item spreads are not supported")
660
- continue
661
- }
662
- if (attribute.name.getText() === "key" || !attribute.initializer || !ts.isJsxExpression(attribute.initializer) || !attribute.initializer.expression || !referencesIdentifier(attribute.initializer.expression, item)) continue
663
- if (!ts.isIdentifier(attribute.initializer.expression) || attribute.initializer.expression.text !== item || itemProp) fail(attribute, `Keyed list component ${rootTag.text} must receive the whole item through one direct prop`)
664
- itemProp = attribute.name.getText()
665
- }
666
- if (ts.isJsxElement(root) && root.children.some(child => referencesIdentifier(child, item))) fail(root, `Keyed list component ${rootTag.text} must receive the whole item through one direct prop`)
667
- if (!itemProp) fail(root, `Keyed list component ${rootTag.text} must receive the whole item through one direct prop`)
668
- item = componentItemParameter(component, itemProp, node => fail(node, `Keyed list component ${rootTag.text} must destructure its item prop`))
669
- root = componentJsxRoot(component, node => fail(node, `Keyed list component ${rootTag.text} must return one JSX element`))
670
- }
860
+ const root = parts.root
861
+ const item = parts.item
671
862
  const validateElement = node => {
672
863
  const tag = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName
673
864
  if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(node, "Keyed list items must use intrinsic JSX elements")
@@ -717,24 +908,127 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
717
908
  visit(root)
718
909
  }
719
910
 
720
- function componentItemParameter(component, prop, fail) {
721
- if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) fail(component)
722
- const element = component.parameters[0].name.elements.find(entry => !entry.dotDotDotToken && !entry.initializer && (entry.propertyName ?? entry.name).getText() === prop)
723
- if (!element || !ts.isIdentifier(element.name)) fail(component.parameters[0])
724
- return element.name.text
725
- }
911
+ function specializeComponentCall(call, component, sourceFile, factory, context, fail) {
912
+ if (component.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || component.asteriskToken) fail(component, "Keyed list components must be synchronous")
913
+ if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) fail(component, "Keyed list components must use one destructured props parameter")
914
+ if (ts.isJsxElement(call) && call.children.some(child => !ts.isJsxText(child) || child.text.trim())) fail(call, "Keyed list component children are not supported")
915
+ const callAttributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
916
+ const props = new Map()
917
+ let key
918
+ for (const attribute of callAttributes.properties) {
919
+ if (ts.isJsxSpreadAttribute(attribute)) fail(attribute, "Keyed list component prop spreads are not supported")
920
+ const name = attribute.name.getText()
921
+ if (props.has(name) || name === "key" && key) fail(attribute, `Duplicate keyed list component prop "${name}"`)
922
+ const value = !attribute.initializer
923
+ ? factory.createTrue()
924
+ : ts.isStringLiteral(attribute.initializer)
925
+ ? factory.createStringLiteral(attribute.initializer.text)
926
+ : ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression
927
+ ? attribute.initializer.expression
928
+ : factory.createIdentifier("undefined")
929
+ if (name === "key") key = attribute
930
+ else props.set(name, value)
931
+ }
932
+ const substitutions = new Map()
933
+ const acceptedProps = new Set()
934
+ for (const element of component.parameters[0].name.elements) {
935
+ if (element.dotDotDotToken || element.initializer || !ts.isIdentifier(element.name)) fail(element, "Keyed list component props cannot use rest, defaults, or nested destructuring")
936
+ const prop = (element.propertyName ?? element.name).getText()
937
+ acceptedProps.add(prop)
938
+ substitutions.set(element.name.text, props.get(prop) ?? factory.createIdentifier("undefined"))
939
+ }
940
+ for (const prop of props.keys()) if (!acceptedProps.has(prop)) fail(call, `Unknown keyed list component prop "${prop}"`)
726
941
 
727
- function componentJsxRoot(component, fail) {
942
+ let returned
943
+ const calculations = []
728
944
  if (!ts.isBlock(component.body)) {
729
- const root = unwrapExpression(component.body)
730
- if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(component.body)
731
- return root
945
+ returned = component.body
946
+ } else {
947
+ const statements = [...component.body.statements]
948
+ const last = statements.pop()
949
+ if (!last || !ts.isReturnStatement(last) || !last.expression) fail(component.body, "Keyed list component must end with one JSX return")
950
+ for (const statement of statements) {
951
+ if (!ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0 || statement.declarationList.declarations.length !== 1) fail(statement, "Keyed list component locals must be single const declarations")
952
+ const declaration = statement.declarationList.declarations[0]
953
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration, "Keyed list component locals must be initialized identifiers")
954
+ const calculation = substituteClone(declaration.initializer, substitutions, factory, context)
955
+ calculations.push(calculation)
956
+ substitutions.set(declaration.name.text, calculation)
957
+ }
958
+ returned = last.expression
959
+ }
960
+ let root = unwrapExpression(substituteClone(returned, substitutions, factory, context))
961
+ if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(returned, "Keyed list component must return one JSX element")
962
+ const tag = jsxTagName(root)
963
+ if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(returned, "Keyed list component must directly return an intrinsic JSX element")
964
+ const rootAttributes = ts.isJsxElement(root) ? root.openingElement.attributes : root.attributes
965
+ if (rootAttributes.properties.some(attribute => ts.isJsxAttribute(attribute) && attribute.name.text === "key")) fail(root, "Keyed list component intrinsic root cannot declare key")
966
+ if (key) root = addJsxAttribute(root, cloneAst(key, factory, context), factory)
967
+ ts.setParentRecursive(root, false)
968
+ root.parent = call.parent
969
+ return { root, calculations }
970
+ }
971
+
972
+ function substituteClone(root, substitutions, factory, context) {
973
+ const visit = (node, shadowed = new Set()) => {
974
+ if (ts.isShorthandPropertyAssignment(node) && substitutions.has(node.name.text) && !shadowed.has(node.name.text)) {
975
+ return factory.createPropertyAssignment(cloneAst(node.name, factory, context), cloneAst(substitutions.get(node.name.text), factory, context))
976
+ }
977
+ if (ts.isIdentifier(node) && substitutions.has(node.text) && !shadowed.has(node.text) && isReferenceIdentifier(node) && !isJsxSyntaxIdentifier(node)) {
978
+ return cloneAst(substitutions.get(node.text), factory, context)
979
+ }
980
+ const nextShadowed = isFunctionLike(node)
981
+ ? new Set([...shadowed, ...node.parameters.flatMap(parameter => bindingNames(parameter.name))])
982
+ : shadowed
983
+ const clone = factory.cloneNode(node)
984
+ ts.setTextRange(clone, node)
985
+ ts.setOriginalNode(clone, node)
986
+ return ts.visitEachChild(clone, child => visit(child, nextShadowed), context)
987
+ }
988
+ return visit(root)
989
+ }
990
+
991
+ function cloneAst(root, factory, context) {
992
+ const visit = node => {
993
+ const clone = factory.cloneNode(node)
994
+ ts.setTextRange(clone, node)
995
+ ts.setOriginalNode(clone, node)
996
+ return ts.visitEachChild(clone, visit, context)
997
+ }
998
+ return visit(root)
999
+ }
1000
+
1001
+ function addJsxAttribute(root, attribute, factory) {
1002
+ if (ts.isJsxSelfClosingElement(root)) {
1003
+ return factory.updateJsxSelfClosingElement(root, root.tagName, root.typeArguments, factory.updateJsxAttributes(root.attributes, [attribute, ...root.attributes.properties]))
732
1004
  }
733
- if (component.body.statements.length !== 1 || !ts.isReturnStatement(component.body.statements[0]) || !component.body.statements[0].expression) fail(component.body)
734
- const statement = component.body.statements[0]
735
- const root = unwrapExpression(statement.expression)
736
- if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(statement)
737
- return root
1005
+ const opening = factory.updateJsxOpeningElement(root.openingElement, root.openingElement.tagName, root.openingElement.typeArguments, factory.updateJsxAttributes(root.openingElement.attributes, [attribute, ...root.openingElement.attributes.properties]))
1006
+ return factory.updateJsxElement(root, opening, root.children, root.closingElement)
1007
+ }
1008
+
1009
+ function jsxTagName(node) {
1010
+ return ts.isJsxElement(node) ? node.openingElement.tagName : ts.isJsxSelfClosingElement(node) ? node.tagName : undefined
1011
+ }
1012
+
1013
+ function isContextProviderValue(node, contexts) {
1014
+ if (node.name.getText() !== "value") return false
1015
+ const element = node.parent?.parent
1016
+ const tag = ts.isJsxOpeningElement(element) || ts.isJsxSelfClosingElement(element) ? element.tagName : undefined
1017
+ return ts.isPropertyAccessExpression(tag) && tag.name.text === "Provider" && ts.isIdentifier(tag.expression) && contexts.has(tag.expression.text)
1018
+ }
1019
+
1020
+ function isJsxSyntaxIdentifier(node) {
1021
+ const parent = node.parent
1022
+ return (ts.isJsxOpeningElement(parent) || ts.isJsxClosingElement(parent) || ts.isJsxSelfClosingElement(parent)) && parent.tagName === node || ts.isJsxAttribute(parent) && parent.name === node
1023
+ }
1024
+
1025
+ function isFunctionLike(node) {
1026
+ return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node)
1027
+ }
1028
+
1029
+ function isExportedDeclaration(node) {
1030
+ const statement = ts.isVariableDeclaration(node) ? node.parent?.parent : node
1031
+ return statement?.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword || modifier.kind === ts.SyntaxKind.DefaultKeyword) ?? false
738
1032
  }
739
1033
 
740
1034
  function jsxTagUses(root, name) {
@@ -870,11 +1164,10 @@ function unwrapExpression(node) {
870
1164
  return ts.isParenthesizedExpression(node) ? unwrapExpression(node.expression) : node
871
1165
  }
872
1166
 
873
- function isTopLevelConst(node) {
1167
+ function isLocalConst(node) {
874
1168
  const list = node.parent
875
1169
  const statement = list?.parent
876
- const owner = nearestFunction(node)
877
- return ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 && ts.isVariableStatement(statement) && statement.parent === owner?.body
1170
+ return ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 && ts.isVariableStatement(statement)
878
1171
  }
879
1172
 
880
1173
  function isJsxLocalValue(expression, known) {
@@ -1,6 +1,7 @@
1
1
  import { serializeStyle } from "./style.js"
2
2
 
3
3
  const signalMarker = Symbol("kudzu.signal")
4
+ const setterMarker = Symbol("kudzu.setter")
4
5
  const behaviorMarker = Symbol("kudzu.behavior")
5
6
  const nativeBehaviorMarker = Symbol("kudzu.nativeBehavior")
6
7
  const bindingMarker = Symbol("kudzu.binding")
@@ -35,10 +36,12 @@ export function useState(initialValue, name) {
35
36
  }
36
37
  }
37
38
 
38
- renderContext.states[id] = { name: name ?? id, initialValue }
39
- return [signal, () => {
39
+ const setter = () => {
40
40
  throw new Error("State setters are compiled into ordered browser behaviors")
41
- }]
41
+ }
42
+ Object.defineProperty(setter, setterMarker, { value: id })
43
+ renderContext.states[id] = { name: name ?? id, initialValue }
44
+ return [signal, setter]
42
45
  }
43
46
 
44
47
  export function useRef(initialValue) {
@@ -190,6 +193,8 @@ function bindingDescriptor(value) {
190
193
  function serializeCapture(name, value, seen) {
191
194
  if (value?.[listItemMarker]) return { type: "list-item" }
192
195
  if (value?.[refMarker]) return { type: "ref", id: value.id }
196
+ if (value?.[signalMarker]) return { type: "state", id: value.id }
197
+ if (typeof value === "function" && value[setterMarker]) return { type: "setter", id: value[setterMarker] }
193
198
  if (value === null || typeof value === "string" || typeof value === "boolean") return value
194
199
  if (typeof value === "number") {
195
200
  return Number.isFinite(value) && !Object.is(value, -0) ? value : { type: "number", value: String(value) }
@@ -375,7 +380,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
375
380
  }
376
381
  if (node?.[bindingMarker]) {
377
382
  const descriptor = bindingDescriptor(node)
378
- const reactive = Object.keys(node.states).length > 0 || Object.keys(node.scopeStates).length > 0 || Object.keys(node.scopeBindings).length > 0
383
+ const reactive = reactiveStateIds(descriptor).size > 0
379
384
  if (!reactive) return renderNode(node.value, namespace, selectValue)
380
385
  renderContext.bindings.push({ target: "text", ...descriptor })
381
386
  if (renderContext.conditionDepth || renderContext.listDepth) for (const stateId of reactiveStateIds(descriptor)) renderContext.conditionStates.add(stateId)
@@ -492,14 +497,14 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
492
497
  }
493
498
  if (value?.[signalMarker] || value?.[bindingMarker]) {
494
499
  const initialValue = value[signalMarker] ? value.value : value.value
495
- const reactive = value[signalMarker] || Object.keys(value.states).length > 0 || Object.keys(value.scopeStates).length > 0 || Object.keys(value.scopeBindings).length > 0
500
+ const descriptor = value[signalMarker]
501
+ ? { state: value.id }
502
+ : bindingDescriptor(value)
503
+ const reactive = reactiveStateIds(descriptor).size > 0
496
504
  if (!reactive) {
497
505
  if (tag !== "select" || name !== "value") attributes += renderAttribute(name, initialValue)
498
506
  continue
499
507
  }
500
- const descriptor = value[signalMarker]
501
- ? { state: value.id }
502
- : bindingDescriptor(value)
503
508
  if (tag !== "select" || name !== "value") attributes += renderAttribute(name, initialValue)
504
509
  if (propertyTarget) attributes += ` data-k-bind-${name}='${escapeJsonAttribute(descriptor)}'`
505
510
  else attributeBindings.push({ target: name, ...descriptor })
@@ -587,10 +592,19 @@ function reactiveStateIds(descriptor) {
587
592
  return new Set([
588
593
  ...Object.values(descriptor.states),
589
594
  ...Object.values(descriptor.scopeStates),
595
+ ...Object.values(descriptor.scope).flatMap(serializedStateIds),
590
596
  ...Object.values(descriptor.scopeBindings).flatMap(entry => [...reactiveStateIds(entry)])
591
597
  ])
592
598
  }
593
599
 
600
+ function serializedStateIds(value) {
601
+ if (!value || typeof value !== "object") return []
602
+ if (value.type === "state") return [value.id]
603
+ if (value.type === "array") return value.value.flatMap(serializedStateIds)
604
+ if (value.type === "object") return value.value.flatMap(([, entry]) => serializedStateIds(entry))
605
+ return []
606
+ }
607
+
594
608
  function renderAttribute(name, value) {
595
609
  if (name === "style") {
596
610
  const style = serializeStyle(value)
@@ -6,7 +6,6 @@ const registrations = new WeakMap()
6
6
  export function createNativeContext(state, stateIds, commit, serializedScope = {}) {
7
7
  const changed = new Set()
8
8
  let scheduled = false
9
- const scope = Object.fromEntries(Object.entries(serializedScope).map(([name, value]) => [name, deserialize(value)]))
10
9
 
11
10
  const flush = () => {
12
11
  scheduled = false
@@ -15,6 +14,18 @@ export function createNativeContext(state, stateIds, commit, serializedScope = {
15
14
  for (const id of ids) commit(id, state.get(id))
16
15
  }
17
16
 
17
+ const setId = globalThis.__KUDZU_CAPTURE_SETTER__ ? (id, value) => {
18
+ const current = state.get(id)
19
+ state.set(id, typeof value === "function" ? value(current) : value)
20
+ changed.add(id)
21
+ if (!scheduled) {
22
+ scheduled = true
23
+ queueMicrotask(flush)
24
+ }
25
+ } : undefined
26
+
27
+ const scope = Object.fromEntries(Object.entries(serializedScope).map(([name, value]) => [name, deserialize(value, id => state.get(id), globalThis.__KUDZU_CAPTURE_SETTER__ ? setId : undefined)]))
28
+
18
29
  return {
19
30
  get(name) {
20
31
  return state.get(stateIds[name])
@@ -23,6 +34,10 @@ export function createNativeContext(state, stateIds, commit, serializedScope = {
23
34
  return serializedScope[name]?.type === "state" ? state.get(serializedScope[name].id) : scope[name]
24
35
  },
25
36
  set(name, value) {
37
+ if (globalThis.__KUDZU_CAPTURE_SETTER__) {
38
+ setId(stateIds[name], value)
39
+ return
40
+ }
26
41
  const id = stateIds[name]
27
42
  const current = state.get(id)
28
43
  state.set(id, typeof value === "function" ? value(current) : value)
@@ -1,13 +1,29 @@
1
- export function deserialize(value) {
1
+ export function deserialize(value, getState, setState) {
2
2
  if (!value || typeof value !== "object") return value
3
3
  if (value.type === "undefined") return undefined
4
4
  if (value.type === "number") return value.value === "NaN" ? NaN : value.value === "Infinity" ? Infinity : value.value === "-Infinity" ? -Infinity : -0
5
5
  if (value.type === "ref") return { get current() { return typeof document === "undefined" ? null : document.querySelector(`[data-k-ref="${value.id}"]`) } }
6
- if (value.type === "array") return value.value.map(deserialize)
6
+ if (globalThis.__KUDZU_CAPTURE_STATE__ && value.type === "state") return getState?.(value.id)
7
+ if (globalThis.__KUDZU_CAPTURE_SETTER__ && value.type === "setter") return next => {
8
+ if (!setState) throw new Error("Captured state setter is not available in this context")
9
+ setState(value.id, next)
10
+ }
11
+ if (value.type === "array") {
12
+ const array = []
13
+ for (const [index, entry] of value.value.entries()) defineCapture(array, String(index), entry, getState, setState)
14
+ return array
15
+ }
7
16
  if (value.type === "object") {
8
17
  const object = value.nullPrototype ? Object.create(null) : {}
9
- for (const [key, entry] of value.value) Object.defineProperty(object, key, { value: deserialize(entry), enumerable: true, writable: true, configurable: true })
18
+ for (const [key, entry] of value.value) defineCapture(object, key, entry, getState, setState)
10
19
  return object
11
20
  }
12
21
  return value
13
22
  }
23
+
24
+ function defineCapture(target, key, entry, getState, setState) {
25
+ const descriptor = globalThis.__KUDZU_CAPTURE_STATE__ && entry?.type === "state" && getState
26
+ ? { get: () => getState(entry.id) }
27
+ : { value: deserialize(entry, getState, setState), writable: true }
28
+ Object.defineProperty(target, key, { ...descriptor, enumerable: true, configurable: true })
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.5.1",
3
+ "version": "0.5.4",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",