@kudzujs/core 0.5.2 → 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
 
@@ -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
  }
@@ -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,10 +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()
398
424
  const components = new Map()
425
+ const contexts = new Set()
399
426
  const jsxLocalDeclarations = new Map()
400
427
  const jsxLocalsByFunction = new Map()
401
428
  const listLocalDeclarations = new WeakSet()
@@ -429,10 +456,13 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
429
456
  functions.set(node.name.text, node.initializer)
430
457
  if (node.parent?.parent?.parent === sourceFile) components.set(node.name.text, { function: node.initializer, declaration: node })
431
458
  }
432
- 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)) {
433
461
  const owner = nearestFunction(node)
434
462
  const declarations = jsxLocalDeclarations.get(owner) ?? new Map()
435
- 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)
436
466
  jsxLocalDeclarations.set(owner, declarations)
437
467
  }
438
468
  ts.forEachChild(node, collect)
@@ -443,32 +473,41 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
443
473
  let changed = true
444
474
  while (changed) {
445
475
  changed = false
446
- for (const [name, { initializer }] of declarations) {
447
- 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))) {
448
478
  names.add(name)
449
479
  changed = true
450
480
  }
451
481
  }
452
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
+ }
453
490
  jsxLocalsByFunction.set(owner, names)
454
491
  }
455
492
  for (const [owner, declarations] of jsxLocalDeclarations) {
456
493
  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)
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)
464
510
  }
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
511
  }
473
512
  }
474
513
  const rawRenderedLists = []
@@ -484,6 +523,16 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
484
523
  const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
485
524
  throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
486
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)
487
536
  const listComponentNames = new Set(rawRenderedLists.flatMap(({ parts }) => {
488
537
  const tag = jsxTagName(parts.root)
489
538
  return tag && ts.isIdentifier(tag) && tag.text[0] === tag.text[0].toUpperCase() ? [tag.text] : []
@@ -527,6 +576,28 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
527
576
  renderedLists.set(node, parts)
528
577
  }
529
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
+ }
600
+
530
601
  const visitor = node => {
531
602
  if (specializedDeclarations.has(node)) return node
532
603
  if (componentSpecializations.has(node)) return ts.visitNode(componentSpecializations.get(node).root, visitor)
@@ -554,18 +625,13 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
554
625
  }
555
626
 
556
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)) {
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
- }
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)
569
635
  }
570
636
 
571
637
  if (ts.isJsxExpression(node) && node.expression && listConditions.has(node.expression)) {
@@ -596,19 +662,10 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
596
662
  ts.visitNode(listParts.callback, visitor)
597
663
  ]))
598
664
  }
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
- }
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)
612
669
  }
613
670
  const setters = settersForNode(node, settersByFunction)
614
671
  const usedStates = referencedStateNames(node.expression, setters)
@@ -620,7 +677,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
620
677
  }
621
678
  }
622
679
 
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())) {
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())) {
624
681
  const expression = node.initializer.expression
625
682
  const setters = settersForNode(node, settersByFunction)
626
683
  const usedStates = referencedStateNames(expression, setters)
@@ -671,6 +728,112 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
671
728
  }
672
729
  }
673
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
+
674
837
  function keyedListParts(expression, setters) {
675
838
  const value = unwrapExpression(expression)
676
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
@@ -847,6 +1010,13 @@ function jsxTagName(node) {
847
1010
  return ts.isJsxElement(node) ? node.openingElement.tagName : ts.isJsxSelfClosingElement(node) ? node.tagName : undefined
848
1011
  }
849
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
+
850
1020
  function isJsxSyntaxIdentifier(node) {
851
1021
  const parent = node.parent
852
1022
  return (ts.isJsxOpeningElement(parent) || ts.isJsxClosingElement(parent) || ts.isJsxSelfClosingElement(parent)) && parent.tagName === node || ts.isJsxAttribute(parent) && parent.name === node
@@ -994,11 +1164,10 @@ function unwrapExpression(node) {
994
1164
  return ts.isParenthesizedExpression(node) ? unwrapExpression(node.expression) : node
995
1165
  }
996
1166
 
997
- function isTopLevelConst(node) {
1167
+ function isLocalConst(node) {
998
1168
  const list = node.parent
999
1169
  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
1170
+ return ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 && ts.isVariableStatement(statement)
1002
1171
  }
1003
1172
 
1004
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.2",
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",