@kudzujs/core 0.4.10 → 0.4.11

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
@@ -181,7 +181,7 @@ const [items, setItems] = useState([
181
181
  { id: 2, name: "Pine", done: true }
182
182
  ])
183
183
 
184
- <ul>{items.map(item =>
184
+ const rows = items.map(item =>
185
185
  <li
186
186
  key={item.id}
187
187
  className={item.done ? "done" : "active"}
@@ -191,12 +191,14 @@ const [items, setItems] = useState([
191
191
  {item.name.toUpperCase()}
192
192
  <button onClick={() => setItems(items.filter(entry => entry.id !== item.id))}>Remove</button>
193
193
  </li>
194
- )}</ul>
194
+ )
195
+
196
+ return <ul>{rows}</ul>
195
197
  ```
196
198
 
197
- Kudzu emits initial items as static HTML, then adds, removes, updates, styles, and moves keyed elements directly. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. 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.
199
+ Kudzu emits initial items as static HTML, then adds, removes, updates, styles, 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. 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.
198
200
 
199
- 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 direct local-state `.map`, one identifier callback parameter, one intrinsic JSX root, and `key={item.<field>}`. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, locals, imported helpers, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Nested conditions or lists, item spreads, component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
201
+ 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, and `key={item.<field>}`. A list alias may only be rendered once and cannot be read by other JavaScript. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, locals, imported helpers, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Nested conditions or lists, item spreads, component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
200
202
 
201
203
  ## Normal JavaScript
202
204
 
@@ -266,7 +268,7 @@ Supported:
266
268
 
267
269
  Not implemented yet:
268
270
 
269
- - Block-scoped JSX locals and non-direct list item expressions
271
+ - Block-scoped JSX locals and reusable keyed-list aliases
270
272
  - Server actions and request-time SSR
271
273
  - Imported client helpers and React package islands
272
274
  - HMR and framework DevTools
@@ -281,13 +283,13 @@ Same counter with initial value `7` and increment/decrement buttons:
281
283
 
282
284
  | Framework | Initial content | Initial JS gzip | Total output | Clean build |
283
285
  |---|---:|---:|---:|---:|
284
- | Kudzu | Yes | 393 B | 1.1 KB | **383 ms** |
285
- | Astro | Yes | **158 B** | **365 B** | 914 ms |
286
- | Svelte CSR | No | 10.5 KB | 26.9 KB | 892 ms |
287
- | Qwik CSR | No | 20.6 KB | 57.8 KB | 606 ms |
288
- | Vue CSR | No | 24.0 KB | 60.3 KB | 815 ms |
289
- | React CSR | No | 59.2 KB | 189.0 KB | 1044 ms |
290
- | Next.js | Yes | 182.1 KB | 652.2 KB | 3030 ms |
286
+ | Kudzu | Yes | 393 B | 1.1 KB | **429 ms** |
287
+ | Astro | Yes | **158 B** | **365 B** | 942 ms |
288
+ | Svelte CSR | No | 10.5 KB | 26.9 KB | 850 ms |
289
+ | Qwik CSR | No | 20.6 KB | 57.8 KB | 686 ms |
290
+ | Vue CSR | No | 24.0 KB | 60.3 KB | 875 ms |
291
+ | React CSR | No | 59.2 KB | 189.0 KB | 1150 ms |
292
+ | Next.js | Yes | 182.1 KB | 652.2 KB | 3203 ms |
291
293
 
292
294
  Astro produces the smallest hand-authored counter. Kudzu's advantage in this fixture is React-shaped state code with a sub-1 KB runtime, not the smallest possible JavaScript.
293
295
 
@@ -297,29 +299,29 @@ Same content and CSS across every fixture:
297
299
 
298
300
  | Framework | Initial content | Initial JS gzip | Total output | Clean build |
299
301
  |---|---:|---:|---:|---:|
300
- | Kudzu | Yes | **0 B** | 3.2 KB | **409 ms** |
301
- | Astro | Yes | **0 B** | **3.0 KB** | 1053 ms |
302
- | Svelte CSR | No | 10.2 KB | 27.2 KB | 863 ms |
303
- | Qwik CSR | No | 20.2 KB | 59.6 KB | 611 ms |
304
- | Vue CSR | No | 24.2 KB | 62.3 KB | 782 ms |
305
- | React CSR | No | 59.8 KB | 192.3 KB | 1062 ms |
306
- | Next.js | Yes | 182.6 KB | 663.6 KB | 3118 ms |
302
+ | Kudzu | Yes | **0 B** | 3.2 KB | **395 ms** |
303
+ | Astro | Yes | **0 B** | **3.0 KB** | 1048 ms |
304
+ | Svelte CSR | No | 10.2 KB | 27.2 KB | 897 ms |
305
+ | Qwik CSR | No | 20.2 KB | 59.6 KB | 608 ms |
306
+ | Vue CSR | No | 24.2 KB | 62.3 KB | 801 ms |
307
+ | React CSR | No | 59.8 KB | 192.3 KB | 1097 ms |
308
+ | Next.js | Yes | 182.6 KB | 663.6 KB | 3085 ms |
307
309
 
308
310
  ### 1,000-item Keyed List
309
311
 
310
- The list starts with 1,000 keyed items, then updates every label, reverses the order, removes odd IDs, and adds 500 items. Browser timings are medians from seven fresh headless Chrome runs.
312
+ The list starts with 1,000 keyed items, then updates every label, reverses the order, removes odd IDs, and adds 500 items. Browser timings are medians from seven fresh headless Chrome runs, measured when a DOM observer sees each expected result rather than at the next animation frame.
311
313
 
312
314
  | Framework | Initial content | Initial JS gzip | Total output | Build | Update | Reverse | Remove | Add | Operations total |
313
315
  |---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
314
- | Astro | Yes | **324 B** | **43.6 KB** | 862 ms | **3.5 ms** | 24.8 ms | **8.4 ms** | **17.9 ms** | **54.6 ms** |
315
- | Kudzu | Yes | 5.0 KB | 60.3 KB | **435 ms** | 6.1 ms | 29.0 ms | 12.8 ms | 19.5 ms | 67.4 ms |
316
- | Vue CSR | No | 24.3 KB | 61.3 KB | 789 ms | 9.3 ms | 32.1 ms | 10.6 ms | 18.6 ms | 70.6 ms |
317
- | React CSR | No | 59.3 KB | 189.4 KB | 1049 ms | 8.8 ms | 34.7 ms | 13.5 ms | 18.2 ms | 75.2 ms |
318
- | Next.js | Yes | 182.2 KB | 695.2 KB | 3029 ms | 7.0 ms | 37.2 ms | 12.9 ms | 21.9 ms | 79.0 ms |
319
- | Qwik CSR | No | 22.2 KB | 64.1 KB | 623 ms | 13.4 ms | **21.7 ms** | 33.6 ms | 28.9 ms | 97.6 ms |
320
- | Svelte CSR | No | 12.9 KB | 33.1 KB | 870 ms | 5.6 ms | 61.3 ms | 15.6 ms | 18.3 ms | 100.8 ms |
321
-
322
- Astro is the hand-authored native DOM baseline in the interactive fixtures. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. In this run Kudzu's keyed-list operations total 67.4 ms, 12.8 ms behind Astro and 7.8 ms ahead of React across all four operations.
316
+ | Astro | Yes | **324 B** | **43.6 KB** | 843 ms | **4.7 ms** | **4.4 ms** | **1.6 ms** | **3.6 ms** | **14.3 ms** |
317
+ | Kudzu | Yes | 5.0 KB | 60.3 KB | **432 ms** | 8.0 ms | 8.0 ms | 2.1 ms | 7.8 ms | 25.9 ms |
318
+ | Vue CSR | No | 24.3 KB | 61.3 KB | 776 ms | 12.0 ms | 10.8 ms | 4.6 ms | 7.4 ms | 34.8 ms |
319
+ | React CSR | No | 59.3 KB | 189.4 KB | 1032 ms | 11.9 ms | 14.5 ms | 4.7 ms | 6.5 ms | 37.6 ms |
320
+ | Next.js | Yes | 182.2 KB | 695.2 KB | 2988 ms | 8.6 ms | 15.8 ms | 5.1 ms | 8.5 ms | 38.0 ms |
321
+ | Svelte CSR | No | 12.9 KB | 33.1 KB | 858 ms | 6.6 ms | 48.5 ms | 5.2 ms | 7.3 ms | 67.6 ms |
322
+ | Qwik CSR | No | 22.2 KB | 64.1 KB | 618 ms | 11.4 ms | 27.5 ms | 37.9 ms | 22.8 ms | 99.6 ms |
323
+
324
+ Astro is the hand-authored native DOM baseline in the interactive fixtures. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. Kudzu's keyed-list operations total 25.9 ms, 11.6 ms behind the hand-authored Astro baseline and 11.7 ms ahead of React across all four operations.
323
325
 
324
326
  Benchmark snapshot collected on July 22, 2026 with Node 24.14.0 on an Intel i5-9500. These results compare the selected one-page fixtures, not ecosystem maturity, browser interaction speed beyond the listed operations, or each framework's full rendering options. Build times vary with machine load and filesystem cache.
325
327
 
@@ -324,6 +324,8 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
324
324
  const functions = new Map()
325
325
  const jsxLocalDeclarations = new Map()
326
326
  const jsxLocalsByFunction = new Map()
327
+ const listLocalDeclarations = new WeakSet()
328
+ const listLocalUses = new WeakMap()
327
329
  const listValues = new WeakMap()
328
330
  const listEventItems = new WeakMap()
329
331
  let usesBehavior = false
@@ -351,7 +353,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
351
353
  if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isTopLevelConst(node)) {
352
354
  const owner = nearestFunction(node)
353
355
  const declarations = jsxLocalDeclarations.get(owner) ?? new Map()
354
- declarations.set(node.name.text, node.initializer)
356
+ declarations.set(node.name.text, { node, initializer: node.initializer })
355
357
  jsxLocalDeclarations.set(owner, declarations)
356
358
  }
357
359
  ts.forEachChild(node, collect)
@@ -362,7 +364,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
362
364
  let changed = true
363
365
  while (changed) {
364
366
  changed = false
365
- for (const [name, initializer] of declarations) {
367
+ for (const [name, { initializer }] of declarations) {
366
368
  if (!names.has(name) && isJsxLocalValue(initializer, names)) {
367
369
  names.add(name)
368
370
  changed = true
@@ -371,6 +373,25 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
371
373
  }
372
374
  jsxLocalsByFunction.set(owner, names)
373
375
  }
376
+ for (const [owner, declarations] of jsxLocalDeclarations) {
377
+ const setters = settersByFunction.get(owner) ?? new Map()
378
+ for (const [name, declaration] of declarations) {
379
+ const parts = keyedListParts(declaration.initializer, setters)
380
+ if (!parts) continue
381
+ const uses = []
382
+ const collectUses = node => {
383
+ if (ts.isJsxExpression(node) && node.initializer === undefined && ts.isIdentifier(node.expression) && node.expression.text === name && nearestFunction(node) === owner) uses.push(node)
384
+ ts.forEachChild(node, collectUses)
385
+ }
386
+ collectUses(owner.body)
387
+ const references = identifierReferenceCount(owner.body, name)
388
+ const position = sourceFile.getLineAndCharacterOfPosition(declaration.node.getStart(sourceFile))
389
+ if (uses.length > 1) throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} Keyed list local "${name}" must be rendered exactly once`)
390
+ 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`)
391
+ listLocalDeclarations.add(declaration.node)
392
+ if (uses.length) listLocalUses.set(uses[0], parts)
393
+ }
394
+ }
374
395
 
375
396
  const visitor = node => {
376
397
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
@@ -391,6 +412,10 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
391
412
  return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, initializer)
392
413
  }
393
414
 
415
+ if (ts.isVariableDeclaration(node) && listLocalDeclarations.has(node)) {
416
+ return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, factory.createIdentifier("undefined"))
417
+ }
418
+
394
419
  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)) {
395
420
  const parts = conditionalParts(node.initializer)
396
421
  if (parts) {
@@ -415,7 +440,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
415
440
  }
416
441
 
417
442
  if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
418
- const listParts = keyedListParts(node.expression, settersForNode(node, settersByFunction))
443
+ const listParts = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction))
419
444
  if (listParts) {
420
445
  if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
421
446
  validateKeyedList(listParts, sourceFile, settersForNode(node, settersByFunction), listValues, listEventItems)
@@ -651,6 +676,16 @@ function referencesIdentifier(root, name) {
651
676
  return found
652
677
  }
653
678
 
679
+ function identifierReferenceCount(root, name) {
680
+ let count = 0
681
+ const visit = node => {
682
+ if (ts.isIdentifier(node) && node.text === name && isReferenceIdentifier(node)) count++
683
+ ts.forEachChild(node, visit)
684
+ }
685
+ visit(root)
686
+ return count
687
+ }
688
+
654
689
  function unwrapExpression(node) {
655
690
  return ts.isParenthesizedExpression(node) ? unwrapExpression(node.expression) : node
656
691
  }
@@ -105,15 +105,12 @@ function updateList(list) {
105
105
  next.push([token, node])
106
106
  values.set(token, value)
107
107
  }
108
- const removals = parent.ownerDocument.createDocumentFragment()
109
108
  for (const [token, node] of list.roots) {
110
109
  if (keys.has(token)) continue
111
110
  if (list.descriptor.mount) {
112
111
  unmountDom(node)
113
112
  node.remove()
114
- } else {
115
- removals.append(node)
116
- }
113
+ } else node.remove()
117
114
  }
118
115
  if (added) {
119
116
  if (list.descriptor.mount) mountDom(additions)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.4.10",
3
+ "version": "0.4.11",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",