@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 +31 -29
- package/framework/build.mjs +38 -3
- package/framework/list-runtime.js +1 -4
- package/package.json +1 -1
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
|
-
|
|
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
|
-
)
|
|
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
|
|
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
|
|
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 | **
|
|
285
|
-
| Astro | Yes | **158 B** | **365 B** |
|
|
286
|
-
| Svelte CSR | No | 10.5 KB | 26.9 KB |
|
|
287
|
-
| Qwik CSR | No | 20.6 KB | 57.8 KB |
|
|
288
|
-
| Vue CSR | No | 24.0 KB | 60.3 KB |
|
|
289
|
-
| React CSR | No | 59.2 KB | 189.0 KB |
|
|
290
|
-
| Next.js | Yes | 182.1 KB | 652.2 KB |
|
|
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 | **
|
|
301
|
-
| Astro | Yes | **0 B** | **3.0 KB** |
|
|
302
|
-
| Svelte CSR | No | 10.2 KB | 27.2 KB |
|
|
303
|
-
| Qwik CSR | No | 20.2 KB | 59.6 KB |
|
|
304
|
-
| Vue CSR | No | 24.2 KB | 62.3 KB |
|
|
305
|
-
| React CSR | No | 59.8 KB | 192.3 KB |
|
|
306
|
-
| Next.js | Yes | 182.6 KB | 663.6 KB |
|
|
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** |
|
|
315
|
-
| Kudzu | Yes | 5.0 KB | 60.3 KB | **
|
|
316
|
-
| Vue CSR | No | 24.3 KB | 61.3 KB |
|
|
317
|
-
| React CSR | No | 59.3 KB | 189.4 KB |
|
|
318
|
-
| Next.js | Yes | 182.2 KB | 695.2 KB |
|
|
319
|
-
|
|
|
320
|
-
|
|
|
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.
|
|
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
|
|
package/framework/build.mjs
CHANGED
|
@@ -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)
|