@kudzujs/core 0.5.2 → 0.5.5

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,7 +281,7 @@ const rows = items.map(item =>
264
281
  return <ul>{rows}</ul>
265
282
  ```
266
283
 
267
- 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:
284
+ The root may also be a top-level row component declared in the same file or imported from a relative TypeScript module. Default, named, aliased, and named re-export imports are resolved at build time. Kudzu specializes each call, so projected props, callback props, and simple local calculations compile to the same intrinsic list template:
268
285
 
269
286
  ```tsx
270
287
  function ItemRow({ name, done, onRemove }: {
@@ -289,7 +306,7 @@ const rows = items.map(item => <ItemRow
289
306
 
290
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.
291
308
 
292
- 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>`.
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 local or relative-imported 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 used inside calculations, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Package or namespace row imports, same-file exported rows, reusable aliases, 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>`.
293
310
 
294
311
  ## Normal JavaScript
295
312
 
@@ -370,7 +387,7 @@ Supported:
370
387
  - Default, nested, and reactive context providers
371
388
  - Controlled `value` and `checked` form properties
372
389
  - Conditional child `&&` and ternary DOM patches
373
- - Top-level immutable JSX locals
390
+ - Top-level and block-scoped JSX locals, terminal early returns, and exhaustive JSX assignment
374
391
  - Direct keyed local-state lists
375
392
 
376
393
  Not implemented yet:
@@ -411,13 +428,24 @@ The same native counter calculation was measured inline and through one relative
411
428
 
412
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.
413
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
+
414
442
  #### Wrapper-Free Derived Text
415
443
 
416
- 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.
417
445
 
418
446
  | Text target | Files | JS gzip | Total output | Clean build | Update |
419
447
  |---|---:|---:|---:|---:|---:|
420
- | 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** |
421
449
  | Comment range | 7 | 4,763 B | 10,922 B | 426 ms | 5.03 µs |
422
450
 
423
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.
@@ -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
  }
@@ -29,10 +29,11 @@ export async function build({ quiet = false, minify = true } = {}) {
29
29
  const cssFiles = projectFiles.filter(file => file.endsWith(".css")).sort()
30
30
  if (!sourceFiles.length) throw new Error("No TypeScript files found in src/")
31
31
  const sourceFileSet = new Set(sourceFiles)
32
+ const sourceIndex = new Map(await Promise.all(sourceFiles.map(async file => [file, await readFile(file, "utf8")])))
32
33
 
33
34
  const handlerModules = []
34
35
  for (const file of sourceFiles) {
35
- const handlerModule = await compile(file, sourceFileSet, base)
36
+ const handlerModule = await compile(file, sourceFileSet, sourceIndex, base)
36
37
  if (handlerModule) handlerModules.push(handlerModule)
37
38
  }
38
39
 
@@ -82,6 +83,8 @@ export async function build({ quiet = false, minify = true } = {}) {
82
83
  const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
83
84
  const hasTextBindings = plans.some(plan => plan.bindings.some(binding => binding.target === "text"))
84
85
  const hasListConditions = plans.some(plan => plan.lists.some(list => list.conditions))
86
+ const hasNestedStateCaptures = hasNestedCaptureState(plans)
87
+ const hasSetterCaptures = hasCaptureType(plans, "setter")
85
88
  const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
86
89
  const hasNativeHandlers = nativeModules.length > 0
87
90
  if (behaviorCount) {
@@ -89,14 +92,20 @@ export async function build({ quiet = false, minify = true } = {}) {
89
92
  const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
90
93
  await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
91
94
  }
92
- if (bindingCount || hasNativeHandlers) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify)
95
+ if (bindingCount || hasNativeHandlers) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify, {
96
+ "globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures),
97
+ "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
98
+ })
93
99
  if (bindingCount || listStyleCount) await writeJavaScript(join(assetsDirectory, "kudzu-style.js"), await readFile(new URL("./style.js", import.meta.url), "utf8"), minify)
94
100
  if (bindingCount) {
95
101
  const bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
96
102
  .replace('"./shared-runtime.js"', '"./kudzu.js"')
97
103
  .replace('"./serialization.js"', '"./kudzu-serialization.js"')
98
104
  .replace('"./style.js"', '"./kudzu-style.js"')
99
- await writeBundledJavaScript(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime, minify, { "globalThis.__KUDZU_TEXT_BINDINGS__": String(hasTextBindings) })
105
+ await writeBundledJavaScript(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime, minify, {
106
+ "globalThis.__KUDZU_TEXT_BINDINGS__": String(hasTextBindings),
107
+ "globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures)
108
+ })
100
109
  }
101
110
  if (listCount) {
102
111
  let listRuntime = (await readFile(new URL("./list-runtime.js", import.meta.url), "utf8"))
@@ -115,7 +124,9 @@ export async function build({ quiet = false, minify = true } = {}) {
115
124
  const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
116
125
  .replace('"./shared-runtime.js"', '"./kudzu.js"')
117
126
  .replace('"./serialization.js"', '"./kudzu-serialization.js"')
118
- await writeJavaScript(join(assetsDirectory, "kudzu-native.js"), specializeNativeRuntime(nativeRuntime, nativeEvents, nativeModules), minify)
127
+ await writeJavaScript(join(assetsDirectory, "kudzu-native.js"), specializeNativeRuntime(nativeRuntime, nativeEvents, nativeModules), minify, {
128
+ "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
129
+ })
119
130
  }
120
131
  for (const handlerModule of handlerModules) {
121
132
  const output = join(assetsDirectory, handlerModule.path)
@@ -171,6 +182,20 @@ function specializeNativeRuntime(source, events, modules) {
171
182
  return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
172
183
  }
173
184
 
185
+ function hasCaptureType(value, type) {
186
+ if (!value || typeof value !== "object") return false
187
+ if (value.type === type) return true
188
+ return (Array.isArray(value) ? value : Object.values(value)).some(entry => hasCaptureType(entry, type))
189
+ }
190
+
191
+ function hasNestedCaptureState(value, insideCapture = false) {
192
+ if (!value || typeof value !== "object") return false
193
+ if (value.type === "state") return insideCapture
194
+ if (value.type === "array") return value.value.some(entry => hasNestedCaptureState(entry, true))
195
+ if (value.type === "object") return value.value.some(([, entry]) => hasNestedCaptureState(entry, true))
196
+ return (Array.isArray(value) ? value : Object.values(value)).some(entry => hasNestedCaptureState(entry, false))
197
+ }
198
+
174
199
  export function specializeRuntime(source, events, hasStateSeed) {
175
200
  const specialized = specializeEvents(source, events)
176
201
  if (hasStateSeed) return specialized
@@ -179,8 +204,8 @@ export function specializeRuntime(source, events, hasStateSeed) {
179
204
  .replace(/^ if \(initialState\).*\n/m, "")
180
205
  }
181
206
 
182
- async function writeJavaScript(file, source, minify) {
183
- const code = minify ? (await transform(source, { format: "esm", legalComments: "none", minify: true, target: "es2022" })).code : source
207
+ async function writeJavaScript(file, source, minify, define) {
208
+ const code = minify || define ? (await transform(source, { define, format: "esm", legalComments: "none", minify, target: "es2022" })).code : source
184
209
  await writeFile(file, code)
185
210
  }
186
211
 
@@ -345,8 +370,8 @@ function escapeHtml(value) {
345
370
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
346
371
  }
347
372
 
348
- async function compile(file, sourceFiles, base) {
349
- const source = await readFile(file, "utf8")
373
+ async function compile(file, sourceFiles, sourceIndex, base) {
374
+ const source = sourceIndex.get(file)
350
375
  const nativeHandlers = []
351
376
  const reactiveBindings = []
352
377
  const listExpressions = []
@@ -360,7 +385,7 @@ async function compile(file, sourceFiles, base) {
360
385
  jsx: ts.JsxEmit.ReactJSX,
361
386
  jsxImportSource: "@kudzujs/core"
362
387
  },
363
- transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, clientImports)] },
388
+ transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, sourceIndex, clientImports)] },
364
389
  reportDiagnostics: true
365
390
  })
366
391
 
@@ -389,13 +414,26 @@ async function compile(file, sourceFiles, base) {
389
414
  return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, clientImports: [...clientImports] }
390
415
  }
391
416
 
392
- function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, clientImports) {
417
+ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports) {
393
418
  return context => sourceFile => {
394
419
  const factory = context.factory
420
+ sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
421
+ ts.setParentRecursive(sourceFile, false)
395
422
  const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
423
+ const importedSources = new Map()
424
+ const importedSource = target => {
425
+ let imported = importedSources.get(target)
426
+ if (!imported) {
427
+ imported = normalizeRenderControlFlow(parseSourceFile(target, sourceIndex.get(target)), factory, context)
428
+ ts.setParentRecursive(imported, false)
429
+ importedSources.set(target, imported)
430
+ }
431
+ return imported
432
+ }
396
433
  const settersByFunction = new Map()
397
434
  const functions = new Map()
398
435
  const components = new Map()
436
+ const contexts = new Set()
399
437
  const jsxLocalDeclarations = new Map()
400
438
  const jsxLocalsByFunction = new Map()
401
439
  const listLocalDeclarations = new WeakSet()
@@ -429,10 +467,13 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
429
467
  functions.set(node.name.text, node.initializer)
430
468
  if (node.parent?.parent?.parent === sourceFile) components.set(node.name.text, { function: node.initializer, declaration: node })
431
469
  }
432
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isTopLevelConst(node)) {
470
+ 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)
471
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isLocalConst(node)) {
433
472
  const owner = nearestFunction(node)
434
473
  const declarations = jsxLocalDeclarations.get(owner) ?? new Map()
435
- declarations.set(node.name.text, { node, initializer: node.initializer })
474
+ const entries = declarations.get(node.name.text) ?? []
475
+ entries.push({ node, initializer: node.initializer })
476
+ declarations.set(node.name.text, entries)
436
477
  jsxLocalDeclarations.set(owner, declarations)
437
478
  }
438
479
  ts.forEachChild(node, collect)
@@ -443,32 +484,41 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
443
484
  let changed = true
444
485
  while (changed) {
445
486
  changed = false
446
- for (const [name, { initializer }] of declarations) {
447
- if (!names.has(name) && isJsxLocalValue(initializer, names)) {
487
+ for (const [name, entries] of declarations) {
488
+ if (!names.has(name) && entries.some(({ initializer }) => isJsxLocalValue(initializer, names))) {
448
489
  names.add(name)
449
490
  changed = true
450
491
  }
451
492
  }
452
493
  }
494
+ for (const name of names) {
495
+ const entries = declarations.get(name)
496
+ if (entries.length > 1) {
497
+ const position = sourceFile.getLineAndCharacterOfPosition(entries[1].node.getStart(sourceFile))
498
+ throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} Block-scoped JSX local "${name}" must not shadow another local`)
499
+ }
500
+ }
453
501
  jsxLocalsByFunction.set(owner, names)
454
502
  }
455
503
  for (const [owner, declarations] of jsxLocalDeclarations) {
456
504
  const setters = settersByFunction.get(owner) ?? new Map()
457
- for (const [name, declaration] of declarations) {
458
- const parts = keyedListParts(declaration.initializer, setters)
459
- if (!parts) continue
460
- const uses = []
461
- const collectUses = node => {
462
- if (ts.isJsxExpression(node) && node.initializer === undefined && ts.isIdentifier(node.expression) && node.expression.text === name && nearestFunction(node) === owner) uses.push(node)
463
- ts.forEachChild(node, collectUses)
505
+ for (const [name, entries] of declarations) {
506
+ for (const declaration of entries) {
507
+ const parts = keyedListParts(declaration.initializer, setters)
508
+ if (!parts) continue
509
+ const uses = []
510
+ const collectUses = node => {
511
+ if (ts.isJsxExpression(node) && node.initializer === undefined && ts.isIdentifier(node.expression) && node.expression.text === name && nearestFunction(node) === owner) uses.push(node)
512
+ ts.forEachChild(node, collectUses)
513
+ }
514
+ collectUses(owner.body)
515
+ const references = identifierReferenceCount(owner.body, name)
516
+ const position = sourceFile.getLineAndCharacterOfPosition(declaration.node.getStart(sourceFile))
517
+ if (uses.length > 1) throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} Keyed list local "${name}" must be rendered exactly once`)
518
+ 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`)
519
+ listLocalDeclarations.add(declaration.node)
520
+ if (uses.length) listLocalUses.set(uses[0], parts)
464
521
  }
465
- collectUses(owner.body)
466
- const references = identifierReferenceCount(owner.body, name)
467
- const position = sourceFile.getLineAndCharacterOfPosition(declaration.node.getStart(sourceFile))
468
- if (uses.length > 1) throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} Keyed list local "${name}" must be rendered exactly once`)
469
- 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`)
470
- listLocalDeclarations.add(declaration.node)
471
- if (uses.length) listLocalUses.set(uses[0], parts)
472
522
  }
473
523
  }
474
524
  const rawRenderedLists = []
@@ -481,9 +531,18 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
481
531
  }
482
532
  collectRenderedLists(sourceFile)
483
533
  const fail = (node, message) => {
484
- const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
485
- throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
534
+ throw sourceNodeError(node, sourceFile, message)
535
+ }
536
+ const rejectUnsupportedRenderControl = node => {
537
+ if (ts.isIfStatement(node) && containsRenderControl(node, jsxLocalsByFunction.get(nearestFunction(node)) ?? new Set())) {
538
+ const setters = settersForNode(node, settersByFunction)
539
+ if (referencedStateNames(node.expression, setters).size) {
540
+ fail(node, "Reactive render if statements must use terminal returns or exhaustive adjacent JSX assignment")
541
+ }
542
+ }
543
+ ts.forEachChild(node, rejectUnsupportedRenderControl)
486
544
  }
545
+ rejectUnsupportedRenderControl(sourceFile)
487
546
  const listComponentNames = new Set(rawRenderedLists.flatMap(({ parts }) => {
488
547
  const tag = jsxTagName(parts.root)
489
548
  return tag && ts.isIdentifier(tag) && tag.text[0] === tag.text[0].toUpperCase() ? [tag.text] : []
@@ -491,13 +550,19 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
491
550
  const componentSpecializations = new WeakMap()
492
551
  const specializedDeclarations = new WeakSet()
493
552
  for (const name of listComponentNames) {
494
- const component = components.get(name)
495
- if (!component) fail(sourceFile, `Keyed list component ${name} must be declared at the top level in the same file`)
496
- if (isExportedDeclaration(component.declaration)) fail(component.declaration, `Keyed list component ${name} cannot be exported`)
553
+ let component = components.get(name)
554
+ const local = Boolean(component)
555
+ if (!component) {
556
+ const binding = importBindings.get(name)
557
+ if (!binding || binding.kind === "namespace") fail(sourceFile, `Keyed list component ${name} must be declared locally or imported from a relative TypeScript module`)
558
+ const imported = binding.kind === "default" ? "default" : binding.imported
559
+ component = { function: resolveComponentExport(binding.target, imported, importedSource, sourceFiles), declaration: undefined }
560
+ }
561
+ if (local && isExportedDeclaration(component.declaration)) fail(component.declaration, `Keyed list component ${name} cannot be exported`)
497
562
  const calls = jsxTagUses(sourceFile, name)
498
- if (identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `Keyed list component ${name} may only be referenced as JSX`)
563
+ if (local && identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `Keyed list component ${name} may only be referenced as JSX`)
499
564
  for (const call of calls) componentSpecializations.set(call, specializeComponentCall(call, component.function, sourceFile, factory, context, fail))
500
- specializedDeclarations.add(component.declaration)
565
+ if (local) specializedDeclarations.add(component.declaration)
501
566
  }
502
567
  const renderedLists = new WeakMap()
503
568
  for (const { node, parts: originalParts } of rawRenderedLists) {
@@ -527,16 +592,40 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
527
592
  renderedLists.set(node, parts)
528
593
  }
529
594
 
595
+ const compileRenderExpression = (expression, anchor) => {
596
+ const parts = conditionalParts(expression)
597
+ if (!parts) return ts.visitNode(expression, visitor)
598
+ const setters = settersForNode(anchor, settersByFunction)
599
+ const usedStates = referencedStateNames(parts.condition, setters)
600
+ const captures = captureNames(parts.condition, parts.condition, setters)
601
+ if (!usedStates.size && !captures.size) return ts.visitEachChild(expression, visitor, context)
602
+ usesBehavior = true
603
+ usesConditional = true
604
+ return compileConditional(
605
+ parts.kind,
606
+ parts.condition,
607
+ compileRenderExpression(parts.truthy, anchor),
608
+ compileRenderExpression(parts.falsy, anchor),
609
+ setters,
610
+ factory,
611
+ context,
612
+ reactiveBindings,
613
+ handlerUrl
614
+ )
615
+ }
616
+
530
617
  const visitor = node => {
531
618
  if (specializedDeclarations.has(node)) return node
532
619
  if (componentSpecializations.has(node)) return ts.visitNode(componentSpecializations.get(node).root, visitor)
533
620
 
534
621
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
535
- return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(modulePath(node.moduleSpecifier.text)), node.attributes)
622
+ const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
623
+ return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
536
624
  }
537
625
 
538
626
  if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
539
- return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(modulePath(node.moduleSpecifier.text)), node.attributes)
627
+ const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
628
+ return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
540
629
  }
541
630
 
542
631
  if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && node.initializer.expression.text === "useState" && node.initializer.arguments.length === 1) {
@@ -554,18 +643,13 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
554
643
  }
555
644
 
556
645
  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)) {
557
- const parts = conditionalParts(node.initializer)
558
- if (parts) {
559
- const setters = settersForNode(node, settersByFunction)
560
- const usedStates = referencedStateNames(parts.condition, setters)
561
- const captures = captureNames(parts.condition, parts.condition, setters)
562
- if (usedStates.size || captures.size) {
563
- usesBehavior = true
564
- usesConditional = true
565
- const compiled = compileConditional(parts.kind, parts.condition, ts.visitNode(parts.truthy, visitor), ts.visitNode(parts.falsy, visitor), setters, factory, context, reactiveBindings, handlerUrl)
566
- return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, compiled)
567
- }
568
- }
646
+ const compiled = compileRenderExpression(node.initializer, node)
647
+ if (compiled !== node.initializer) return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, compiled)
648
+ }
649
+
650
+ if (ts.isReturnStatement(node) && node.expression && isJsxLocalValue(node.expression, jsxLocalsByFunction.get(nearestFunction(node)) ?? new Set())) {
651
+ const compiled = compileRenderExpression(node.expression, node)
652
+ if (compiled !== node.expression) return factory.updateReturnStatement(node, compiled)
569
653
  }
570
654
 
571
655
  if (ts.isJsxExpression(node) && node.expression && listConditions.has(node.expression)) {
@@ -596,19 +680,10 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
596
680
  ts.visitNode(listParts.callback, visitor)
597
681
  ]))
598
682
  }
599
- const parts = conditionalParts(node.expression)
600
- if (parts) {
601
- const setters = settersForNode(node, settersByFunction)
602
- const usedStates = referencedStateNames(parts.condition, setters)
603
- const captures = captureNames(parts.condition, parts.condition, setters)
604
- if (usedStates.size || captures.size) {
605
- usesBehavior = true
606
- usesConditional = true
607
- const truthy = ts.visitNode(parts.truthy, visitor)
608
- const falsy = ts.visitNode(parts.falsy, visitor)
609
- const compiled = compileConditional(parts.kind, parts.condition, truthy, falsy, setters, factory, context, reactiveBindings, handlerUrl)
610
- return factory.updateJsxExpression(node, compiled)
611
- }
683
+ const conditional = conditionalParts(node.expression)
684
+ if (conditional) {
685
+ const compiled = compileRenderExpression(node.expression, node)
686
+ if (compiled !== node.expression) return factory.updateJsxExpression(node, compiled)
612
687
  }
613
688
  const setters = settersForNode(node, settersByFunction)
614
689
  const usedStates = referencedStateNames(node.expression, setters)
@@ -620,7 +695,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
620
695
  }
621
696
  }
622
697
 
623
- 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())) {
698
+ if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !isContextProviderValue(node, contexts) && !/^on/i.test(node.name.text) && !["key", "ref", "dangerouslysetinnerhtml"].includes(node.name.text.toLowerCase())) {
624
699
  const expression = node.initializer.expression
625
700
  const setters = settersForNode(node, settersByFunction)
626
701
  const usedStates = referencedStateNames(expression, setters)
@@ -633,15 +708,16 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
633
708
  }
634
709
  }
635
710
 
636
- if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.getText())) {
711
+ if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.text)) {
637
712
  const setters = settersForNode(node, settersByFunction)
638
713
  const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl, listEventItems.get(node), importBindings, clientImports)
639
714
  if (event) {
640
715
  usesBehavior = true
641
716
  return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, event))
642
717
  }
718
+ if (ts.isIdentifier(node.initializer.expression) && isDestructuredParameter(node.initializer.expression, nearestFunction(node))) return node
643
719
  const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
644
- throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${node.name.getText()} must reference a function`)
720
+ throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${node.name.text} must reference a function`)
645
721
  }
646
722
 
647
723
  return ts.visitEachChild(node, visitor, context)
@@ -671,6 +747,112 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
671
747
  }
672
748
  }
673
749
 
750
+ function normalizeRenderControlFlow(sourceFile, factory, context) {
751
+ const normalizeStatements = statements => {
752
+ const nested = statements.map(statement => ts.visitEachChild(statement, visitNested, context))
753
+ const assigned = []
754
+ for (let index = 0; index < nested.length; index++) {
755
+ const statement = nested[index]
756
+ const next = nested[index + 1]
757
+ const declaration = singleUninitializedLet(statement)
758
+ const assignment = declaration && next && assignmentConditional(next, declaration.name.text, factory)
759
+ if (declaration && assignment) {
760
+ const updated = factory.updateVariableDeclaration(declaration, declaration.name, declaration.exclamationToken, declaration.type, assignment)
761
+ const list = factory.createVariableDeclarationList([updated], ts.NodeFlags.Const)
762
+ assigned.push(factory.updateVariableStatement(statement, statement.modifiers, list))
763
+ index++
764
+ } else {
765
+ assigned.push(statement)
766
+ }
767
+ }
768
+
769
+ if (!assigned.length) return assigned
770
+ const finalIf = returnConditional(assigned.at(-1), factory)
771
+ if (finalIf) return [...assigned.slice(0, -1), factory.createReturnStatement(finalIf)]
772
+ if (!ts.isReturnStatement(assigned.at(-1)) || !assigned.at(-1).expression) return assigned
773
+ let expression = assigned.at(-1).expression
774
+ let start = assigned.length - 1
775
+ while (start > 0) {
776
+ const previous = assigned[start - 1]
777
+ if (!ts.isIfStatement(previous) || previous.elseStatement) break
778
+ const truthy = returnOnlyExpression(previous.thenStatement)
779
+ if (!truthy) break
780
+ expression = factory.createConditionalExpression(previous.expression, factory.createToken(ts.SyntaxKind.QuestionToken), truthy, factory.createToken(ts.SyntaxKind.ColonToken), expression)
781
+ start--
782
+ }
783
+ return start === assigned.length - 1 ? assigned : [...assigned.slice(0, start), factory.createReturnStatement(expression)]
784
+ }
785
+
786
+ const visitNested = node => {
787
+ if (ts.isBlock(node)) return factory.updateBlock(node, normalizeStatements([...node.statements]))
788
+ if (isFunctionLike(node) && ts.isBlock(node.body)) {
789
+ if (!isRenderFunction(node)) return node
790
+ const body = factory.updateBlock(node.body, normalizeStatements([...node.body.statements]))
791
+ if (ts.isFunctionDeclaration(node)) return factory.updateFunctionDeclaration(node, node.modifiers, node.asteriskToken, node.name, node.typeParameters, node.parameters, node.type, body)
792
+ if (ts.isFunctionExpression(node)) return factory.updateFunctionExpression(node, node.modifiers, node.asteriskToken, node.name, node.typeParameters, node.parameters, node.type, body)
793
+ if (ts.isArrowFunction(node)) return factory.updateArrowFunction(node, node.modifiers, node.typeParameters, node.parameters, node.type, node.equalsGreaterThanToken, body)
794
+ }
795
+ return ts.visitEachChild(node, visitNested, context)
796
+ }
797
+
798
+ return ts.visitEachChild(sourceFile, visitNested, context)
799
+ }
800
+
801
+ function isRenderFunction(node) {
802
+ if (ts.isFunctionDeclaration(node)) return node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword) || Boolean(node.name && /^[A-Z]/.test(node.name.text))
803
+ const declaration = node.parent
804
+ return ts.isVariableDeclaration(declaration) && ts.isIdentifier(declaration.name) && /^[A-Z]/.test(declaration.name.text)
805
+ }
806
+
807
+ function singleUninitializedLet(statement) {
808
+ if (!ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Let) === 0 || statement.declarationList.declarations.length !== 1) return undefined
809
+ const declaration = statement.declarationList.declarations[0]
810
+ return ts.isIdentifier(declaration.name) && !declaration.initializer ? declaration : undefined
811
+ }
812
+
813
+ function assignmentConditional(statement, name, factory) {
814
+ if (!ts.isIfStatement(statement) || !statement.elseStatement) return undefined
815
+ const truthy = assignmentOnlyExpression(statement.thenStatement, name)
816
+ const falsy = ts.isIfStatement(statement.elseStatement)
817
+ ? assignmentConditional(statement.elseStatement, name, factory)
818
+ : assignmentOnlyExpression(statement.elseStatement, name)
819
+ if (!truthy || !falsy) return undefined
820
+ return factory.createConditionalExpression(statement.expression, factory.createToken(ts.SyntaxKind.QuestionToken), truthy, factory.createToken(ts.SyntaxKind.ColonToken), falsy)
821
+ }
822
+
823
+ function assignmentOnlyExpression(statement, name) {
824
+ const candidate = ts.isBlock(statement) && statement.statements.length === 1 ? statement.statements[0] : statement
825
+ 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
826
+ return candidate.expression.right
827
+ }
828
+
829
+ function returnConditional(statement, factory) {
830
+ if (!ts.isIfStatement(statement) || !statement.elseStatement) return undefined
831
+ const truthy = returnOnlyExpression(statement.thenStatement)
832
+ const falsy = ts.isIfStatement(statement.elseStatement)
833
+ ? returnConditional(statement.elseStatement, factory)
834
+ : returnOnlyExpression(statement.elseStatement)
835
+ if (!truthy || !falsy) return undefined
836
+ return factory.createConditionalExpression(statement.expression, factory.createToken(ts.SyntaxKind.QuestionToken), truthy, factory.createToken(ts.SyntaxKind.ColonToken), falsy)
837
+ }
838
+
839
+ function returnOnlyExpression(statement) {
840
+ const candidate = ts.isBlock(statement) && statement.statements.length === 1 ? statement.statements[0] : statement
841
+ return ts.isReturnStatement(candidate) && candidate.expression ? candidate.expression : undefined
842
+ }
843
+
844
+ function containsRenderControl(root, knownLocals) {
845
+ let found = false
846
+ const visit = node => {
847
+ if (isFunctionLike(node) && node !== root) return
848
+ if (ts.isReturnStatement(node) && node.expression && isJsxLocalValue(node.expression, knownLocals)) found = true
849
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && containsJsx(node.right)) found = true
850
+ if (!found) ts.forEachChild(node, visit)
851
+ }
852
+ visit(root)
853
+ return found
854
+ }
855
+
674
856
  function keyedListParts(expression, setters) {
675
857
  const value = unwrapExpression(expression)
676
858
  if (!ts.isCallExpression(value) || value.arguments.length !== 1 || !ts.isPropertyAccessExpression(value.expression) || value.expression.name.text !== "map" || !ts.isIdentifier(value.expression.expression)) return undefined
@@ -691,8 +873,7 @@ function keyedListParts(expression, setters) {
691
873
 
692
874
  function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions) {
693
875
  const fail = (node, message) => {
694
- const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
695
- throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
876
+ throw sourceNodeError(node, sourceFile, message)
696
877
  }
697
878
  const root = parts.root
698
879
  const item = parts.item
@@ -706,7 +887,7 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
706
887
  if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
707
888
  if (node !== root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) fail(node, "Nested keyed lists are not supported")
708
889
  if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression, item)) fail(node, "Keyed list item spreads are not supported")
709
- if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.getText())) {
890
+ if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.text)) {
710
891
  listEventItems.set(node, item)
711
892
  return
712
893
  }
@@ -725,9 +906,9 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
725
906
  return
726
907
  }
727
908
  const field = directProperty(expression, item)
728
- const isRootKey = ts.isJsxAttribute(node.parent) && node.parent.name.getText() === "key"
909
+ const isRootKey = ts.isJsxAttribute(node.parent) && node.parent.name.text === "key"
729
910
  if (field && ["__proto__", "constructor", "prototype"].includes(field)) fail(node, `Keyed list item property "${field}" is not supported`)
730
- if (field && ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
911
+ if (field && ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.text.toLowerCase())) fail(node, `Keyed list item ${node.parent.name.text} is not supported`)
731
912
  if (isRootKey) return
732
913
  if (field) {
733
914
  listValues.set(node.expression, { field })
@@ -735,7 +916,7 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
735
916
  }
736
917
  if (referencesIdentifier(expression, item)) {
737
918
  validateListExpression(expression, item, node, fail)
738
- if (ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
919
+ if (ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.text.toLowerCase())) fail(node, `Keyed list item ${node.parent.name.text} is not supported`)
739
920
  listValues.set(node.expression, { item })
740
921
  return
741
922
  }
@@ -808,6 +989,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
808
989
 
809
990
  function substituteClone(root, substitutions, factory, context) {
810
991
  const visit = (node, shadowed = new Set()) => {
992
+ if (ts.isTypeNode(node)) return cloneAst(node, factory, context)
811
993
  if (ts.isShorthandPropertyAssignment(node) && substitutions.has(node.name.text) && !shadowed.has(node.name.text)) {
812
994
  return factory.createPropertyAssignment(cloneAst(node.name, factory, context), cloneAst(substitutions.get(node.name.text), factory, context))
813
995
  }
@@ -847,6 +1029,13 @@ function jsxTagName(node) {
847
1029
  return ts.isJsxElement(node) ? node.openingElement.tagName : ts.isJsxSelfClosingElement(node) ? node.tagName : undefined
848
1030
  }
849
1031
 
1032
+ function isContextProviderValue(node, contexts) {
1033
+ if (node.name.getText() !== "value") return false
1034
+ const element = node.parent?.parent
1035
+ const tag = ts.isJsxOpeningElement(element) || ts.isJsxSelfClosingElement(element) ? element.tagName : undefined
1036
+ return ts.isPropertyAccessExpression(tag) && tag.name.text === "Provider" && ts.isIdentifier(tag.expression) && contexts.has(tag.expression.text)
1037
+ }
1038
+
850
1039
  function isJsxSyntaxIdentifier(node) {
851
1040
  const parent = node.parent
852
1041
  return (ts.isJsxOpeningElement(parent) || ts.isJsxClosingElement(parent) || ts.isJsxSelfClosingElement(parent)) && parent.tagName === node || ts.isJsxAttribute(parent) && parent.name === node
@@ -856,6 +1045,10 @@ function isFunctionLike(node) {
856
1045
  return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node)
857
1046
  }
858
1047
 
1048
+ function isDestructuredParameter(identifier, fn) {
1049
+ return fn?.parameters.some(parameter => ts.isObjectBindingPattern(parameter.name) && parameter.name.elements.some(element => ts.isIdentifier(element.name) && element.name.text === identifier.text)) ?? false
1050
+ }
1051
+
859
1052
  function isExportedDeclaration(node) {
860
1053
  const statement = ts.isVariableDeclaration(node) ? node.parent?.parent : node
861
1054
  return statement?.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword || modifier.kind === ts.SyntaxKind.DefaultKeyword) ?? false
@@ -887,6 +1080,7 @@ const assignmentOperators = new Set([
887
1080
 
888
1081
  function validateListExpression(expression, item, source, fail) {
889
1082
  const visit = node => {
1083
+ if (ts.isTypeNode(node)) return
890
1084
  if (ts.isElementAccessExpression(node) && referencesIdentifier(node.expression, item)) {
891
1085
  const key = node.argumentExpression
892
1086
  if (!ts.isStringLiteral(key) && !ts.isNumericLiteral(key)) fail(source, "Derived keyed list item computed properties require a direct string or numeric literal key")
@@ -994,11 +1188,10 @@ function unwrapExpression(node) {
994
1188
  return ts.isParenthesizedExpression(node) ? unwrapExpression(node.expression) : node
995
1189
  }
996
1190
 
997
- function isTopLevelConst(node) {
1191
+ function isLocalConst(node) {
998
1192
  const list = node.parent
999
1193
  const statement = list?.parent
1000
- const owner = nearestFunction(node)
1001
- return ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 && ts.isVariableStatement(statement) && statement.parent === owner?.body
1194
+ return ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 && ts.isVariableStatement(statement)
1002
1195
  }
1003
1196
 
1004
1197
  function isJsxLocalValue(expression, known) {
@@ -1230,6 +1423,60 @@ function clientImportBindings(sourceFile, file, sourceFiles) {
1230
1423
  return bindings
1231
1424
  }
1232
1425
 
1426
+ function resolveComponentExport(file, exportName, getSource, sourceFiles, trail = []) {
1427
+ const key = `${file}:${exportName}`
1428
+ if (trail.includes(key)) throw new Error(`Imported keyed list component re-export cycle: ${[...trail, key].map(entry => relative(root, entry.slice(0, entry.lastIndexOf(":")))).join(" -> ")}`)
1429
+ const sourceFile = getSource(file)
1430
+ const nextTrail = [...trail, key]
1431
+
1432
+ for (const statement of sourceFile.statements) {
1433
+ if (ts.isFunctionDeclaration(statement)) {
1434
+ const isDefault = statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)
1435
+ const isExported = statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)
1436
+ if (exportName === "default" && isDefault || exportName !== "default" && isExported && statement.name?.text === exportName) return statement
1437
+ }
1438
+ if (ts.isVariableStatement(statement) && statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) && exportName !== "default") {
1439
+ const declaration = statement.declarationList.declarations.find(entry => ts.isIdentifier(entry.name) && entry.name.text === exportName)
1440
+ if (declaration?.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) return declaration.initializer
1441
+ }
1442
+ if (exportName === "default" && ts.isExportAssignment(statement) && !statement.isExportEquals && ts.isIdentifier(statement.expression)) {
1443
+ const component = localComponentDeclaration(sourceFile, statement.expression.text)
1444
+ if (component) return component
1445
+ }
1446
+ if (ts.isExportDeclaration(statement) && ts.isNamedExports(statement.exportClause)) {
1447
+ const entry = statement.exportClause.elements.find(element => !element.isTypeOnly && element.name.text === exportName)
1448
+ if (!entry) continue
1449
+ const imported = (entry.propertyName ?? entry.name).text
1450
+ if (statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) {
1451
+ if (!statement.moduleSpecifier.text.startsWith(".")) throw sourceNodeError(statement, sourceFile, "Imported keyed list components must use relative TypeScript re-exports")
1452
+ const target = resolveSourceImport(file, statement.moduleSpecifier.text, sourceFiles)
1453
+ return resolveComponentExport(target, imported, getSource, sourceFiles, nextTrail)
1454
+ }
1455
+ const component = localComponentDeclaration(sourceFile, imported)
1456
+ if (component) return component
1457
+ }
1458
+ }
1459
+ throw new Error(`${relative(root, file)} does not export a statically analyzable keyed list component named ${JSON.stringify(exportName)}`)
1460
+ }
1461
+
1462
+ function localComponentDeclaration(sourceFile, name) {
1463
+ for (const statement of sourceFile.statements) {
1464
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === name) return statement
1465
+ if (ts.isVariableStatement(statement)) {
1466
+ const declaration = statement.declarationList.declarations.find(entry => ts.isIdentifier(entry.name) && entry.name.text === name)
1467
+ if (declaration?.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) return declaration.initializer
1468
+ }
1469
+ }
1470
+ return undefined
1471
+ }
1472
+
1473
+ function sourceNodeError(node, fallbackSource, message) {
1474
+ const original = ts.getOriginalNode(node)
1475
+ const sourceFile = original.getSourceFile?.()?.fileName ? original.getSourceFile() : fallbackSource
1476
+ const position = sourceFile.getLineAndCharacterOfPosition(original.getStart(sourceFile))
1477
+ return new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
1478
+ }
1479
+
1233
1480
  function printClientImports(entries, handlerPath) {
1234
1481
  const unique = new Map(entries.map(entry => [`${entry.target}:${entry.kind}:${entry.imported ?? ""}:${entry.local}`, entry]))
1235
1482
  const groups = Map.groupBy(unique.values(), entry => entry.target)
@@ -1305,7 +1552,7 @@ function resolveSourceImport(importer, specifier, sourceFiles) {
1305
1552
  const stem = /\.(?:js|jsx|ts|tsx)$/.test(extension) ? base.slice(0, -extension.length) : base
1306
1553
  const candidates = extension === ".ts" || extension === ".tsx"
1307
1554
  ? [base]
1308
- : [`${stem}.ts`, `${stem}.tsx`]
1555
+ : [`${stem}.ts`, `${stem}.tsx`, join(stem, "index.ts"), join(stem, "index.tsx")]
1309
1556
  const matches = candidates.filter(candidate => sourceFiles.has(candidate))
1310
1557
  if (matches.length !== 1) throw new Error(`${relative(root, importer)} Relative import ${JSON.stringify(specifier)} must resolve to one TypeScript file in src/`)
1311
1558
  return matches[0]
@@ -1498,11 +1745,6 @@ function numericExpression(factory, value, negative) {
1498
1745
  return negative ? factory.createPrefixUnaryExpression(ts.SyntaxKind.MinusToken, literal) : literal
1499
1746
  }
1500
1747
 
1501
- function modulePath(value) {
1502
- if (/\.(?:ts|tsx|js|jsx)$/.test(value)) return value.replace(/\.(?:ts|tsx|js|jsx)$/, ".mjs")
1503
- return `${value}.mjs`
1504
- }
1505
-
1506
1748
  function compiledPath(file) {
1507
1749
  return join(workDirectory, relative(sourceDirectory, file)).replace(/\.(?:ts|tsx)$/, ".mjs")
1508
1750
  }
@@ -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.2",
3
+ "version": "0.5.5",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",