@kudzujs/core 0.4.9 → 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 +43 -29
- package/framework/build.mjs +88 -1
- package/framework/list-runtime.js +1 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -160,6 +160,17 @@ Logical state persists across branch switches, while uncontrolled DOM state rese
|
|
|
160
160
|
|
|
161
161
|
Reactive conditional DOM currently targets the HTML namespace and is rejected inside SVG or MathML.
|
|
162
162
|
|
|
163
|
+
Top-level immutable JSX locals can hold static or state-dependent branches:
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
const menu = open ? <MenuBar /> : <p>Menu dormant</p>
|
|
167
|
+
const content = open && menu
|
|
168
|
+
|
|
169
|
+
return <main>{content}</main>
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
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.
|
|
173
|
+
|
|
163
174
|
## Keyed Lists
|
|
164
175
|
|
|
165
176
|
Map local array state directly to one keyed JSX element per item:
|
|
@@ -170,7 +181,7 @@ const [items, setItems] = useState([
|
|
|
170
181
|
{ id: 2, name: "Pine", done: true }
|
|
171
182
|
])
|
|
172
183
|
|
|
173
|
-
|
|
184
|
+
const rows = items.map(item =>
|
|
174
185
|
<li
|
|
175
186
|
key={item.id}
|
|
176
187
|
className={item.done ? "done" : "active"}
|
|
@@ -180,12 +191,14 @@ const [items, setItems] = useState([
|
|
|
180
191
|
{item.name.toUpperCase()}
|
|
181
192
|
<button onClick={() => setItems(items.filter(entry => entry.id !== item.id))}>Remove</button>
|
|
182
193
|
</li>
|
|
183
|
-
)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return <ul>{rows}</ul>
|
|
184
197
|
```
|
|
185
198
|
|
|
186
|
-
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.
|
|
187
200
|
|
|
188
|
-
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>`.
|
|
189
202
|
|
|
190
203
|
## Normal JavaScript
|
|
191
204
|
|
|
@@ -250,11 +263,12 @@ Supported:
|
|
|
250
263
|
- Object DOM refs in native event handlers
|
|
251
264
|
- Controlled `value` and `checked` form properties
|
|
252
265
|
- Conditional child `&&` and ternary DOM patches
|
|
266
|
+
- Top-level immutable JSX locals
|
|
253
267
|
- Direct keyed local-state lists
|
|
254
268
|
|
|
255
269
|
Not implemented yet:
|
|
256
270
|
|
|
257
|
-
-
|
|
271
|
+
- Block-scoped JSX locals and reusable keyed-list aliases
|
|
258
272
|
- Server actions and request-time SSR
|
|
259
273
|
- Imported client helpers and React package islands
|
|
260
274
|
- HMR and framework DevTools
|
|
@@ -269,13 +283,13 @@ Same counter with initial value `7` and increment/decrement buttons:
|
|
|
269
283
|
|
|
270
284
|
| Framework | Initial content | Initial JS gzip | Total output | Clean build |
|
|
271
285
|
|---|---:|---:|---:|---:|
|
|
272
|
-
| Kudzu | Yes | 393 B | 1.1 KB | **
|
|
273
|
-
| Astro | Yes | **158 B** | **365 B** |
|
|
274
|
-
| Svelte CSR | No | 10.5 KB | 26.9 KB |
|
|
275
|
-
| Qwik CSR | No | 20.6 KB | 57.8 KB |
|
|
276
|
-
| Vue CSR | No | 24.0 KB | 60.3 KB |
|
|
277
|
-
| React CSR | No | 59.2 KB | 189.0 KB |
|
|
278
|
-
| 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 |
|
|
279
293
|
|
|
280
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.
|
|
281
295
|
|
|
@@ -285,29 +299,29 @@ Same content and CSS across every fixture:
|
|
|
285
299
|
|
|
286
300
|
| Framework | Initial content | Initial JS gzip | Total output | Clean build |
|
|
287
301
|
|---|---:|---:|---:|---:|
|
|
288
|
-
| Kudzu | Yes | **0 B** | 3.2 KB | **
|
|
289
|
-
| Astro | Yes | **0 B** | **3.0 KB** |
|
|
290
|
-
| Svelte CSR | No | 10.2 KB | 27.2 KB |
|
|
291
|
-
| Qwik CSR | No | 20.2 KB | 59.6 KB |
|
|
292
|
-
| Vue CSR | No | 24.2 KB | 62.3 KB |
|
|
293
|
-
| React CSR | No | 59.8 KB | 192.3 KB |
|
|
294
|
-
| 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 |
|
|
295
309
|
|
|
296
310
|
### 1,000-item Keyed List
|
|
297
311
|
|
|
298
|
-
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.
|
|
299
313
|
|
|
300
314
|
| Framework | Initial content | Initial JS gzip | Total output | Build | Update | Reverse | Remove | Add | Operations total |
|
|
301
315
|
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
|
302
|
-
| Astro | Yes | **324 B** | **43.6 KB** |
|
|
303
|
-
| Kudzu | Yes | 5.0 KB | 60.3 KB | **
|
|
304
|
-
| Vue CSR | No | 24.3 KB | 61.3 KB |
|
|
305
|
-
|
|
|
306
|
-
|
|
|
307
|
-
|
|
|
308
|
-
|
|
|
309
|
-
|
|
310
|
-
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.
|
|
311
325
|
|
|
312
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.
|
|
313
327
|
|
package/framework/build.mjs
CHANGED
|
@@ -322,6 +322,10 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
322
322
|
const factory = context.factory
|
|
323
323
|
const settersByFunction = new Map()
|
|
324
324
|
const functions = new Map()
|
|
325
|
+
const jsxLocalDeclarations = new Map()
|
|
326
|
+
const jsxLocalsByFunction = new Map()
|
|
327
|
+
const listLocalDeclarations = new WeakSet()
|
|
328
|
+
const listLocalUses = new WeakMap()
|
|
325
329
|
const listValues = new WeakMap()
|
|
326
330
|
const listEventItems = new WeakMap()
|
|
327
331
|
let usesBehavior = false
|
|
@@ -346,9 +350,48 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
346
350
|
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))) {
|
|
347
351
|
functions.set(node.name.text, node.initializer)
|
|
348
352
|
}
|
|
353
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isTopLevelConst(node)) {
|
|
354
|
+
const owner = nearestFunction(node)
|
|
355
|
+
const declarations = jsxLocalDeclarations.get(owner) ?? new Map()
|
|
356
|
+
declarations.set(node.name.text, { node, initializer: node.initializer })
|
|
357
|
+
jsxLocalDeclarations.set(owner, declarations)
|
|
358
|
+
}
|
|
349
359
|
ts.forEachChild(node, collect)
|
|
350
360
|
}
|
|
351
361
|
collect(sourceFile)
|
|
362
|
+
for (const [owner, declarations] of jsxLocalDeclarations) {
|
|
363
|
+
const names = new Set()
|
|
364
|
+
let changed = true
|
|
365
|
+
while (changed) {
|
|
366
|
+
changed = false
|
|
367
|
+
for (const [name, { initializer }] of declarations) {
|
|
368
|
+
if (!names.has(name) && isJsxLocalValue(initializer, names)) {
|
|
369
|
+
names.add(name)
|
|
370
|
+
changed = true
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
jsxLocalsByFunction.set(owner, names)
|
|
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
|
+
}
|
|
352
395
|
|
|
353
396
|
const visitor = node => {
|
|
354
397
|
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
|
|
@@ -369,6 +412,25 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
369
412
|
return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, initializer)
|
|
370
413
|
}
|
|
371
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
|
+
|
|
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)) {
|
|
420
|
+
const parts = conditionalParts(node.initializer)
|
|
421
|
+
if (parts) {
|
|
422
|
+
const setters = settersForNode(node, settersByFunction)
|
|
423
|
+
const usedStates = referencedStateNames(parts.condition, setters)
|
|
424
|
+
const captures = captureNames(parts.condition, parts.condition, setters)
|
|
425
|
+
if (usedStates.size || captures.size) {
|
|
426
|
+
usesBehavior = true
|
|
427
|
+
usesConditional = true
|
|
428
|
+
const compiled = compileConditional(parts.kind, parts.condition, ts.visitNode(parts.truthy, visitor), ts.visitNode(parts.falsy, visitor), setters, factory, context, reactiveBindings, handlerUrl)
|
|
429
|
+
return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, compiled)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
372
434
|
if (ts.isJsxExpression(node) && node.expression && listValues.has(node.expression)) {
|
|
373
435
|
return factory.updateJsxExpression(node, compileListValue(node.expression, listValues.get(node.expression), factory, listExpressions, handlerUrl))
|
|
374
436
|
}
|
|
@@ -378,7 +440,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
378
440
|
}
|
|
379
441
|
|
|
380
442
|
if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
|
|
381
|
-
const listParts = keyedListParts(node.expression, settersForNode(node, settersByFunction))
|
|
443
|
+
const listParts = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction))
|
|
382
444
|
if (listParts) {
|
|
383
445
|
if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
|
|
384
446
|
validateKeyedList(listParts, sourceFile, settersForNode(node, settersByFunction), listValues, listEventItems)
|
|
@@ -614,10 +676,35 @@ function referencesIdentifier(root, name) {
|
|
|
614
676
|
return found
|
|
615
677
|
}
|
|
616
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
|
+
|
|
617
689
|
function unwrapExpression(node) {
|
|
618
690
|
return ts.isParenthesizedExpression(node) ? unwrapExpression(node.expression) : node
|
|
619
691
|
}
|
|
620
692
|
|
|
693
|
+
function isTopLevelConst(node) {
|
|
694
|
+
const list = node.parent
|
|
695
|
+
const statement = list?.parent
|
|
696
|
+
const owner = nearestFunction(node)
|
|
697
|
+
return ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 && ts.isVariableStatement(statement) && statement.parent === owner?.body
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function isJsxLocalValue(expression, known) {
|
|
701
|
+
const value = unwrapExpression(expression)
|
|
702
|
+
if (ts.isJsxElement(value) || ts.isJsxSelfClosingElement(value) || ts.isJsxFragment(value)) return true
|
|
703
|
+
if (ts.isIdentifier(value)) return known.has(value.text)
|
|
704
|
+
const parts = conditionalParts(value)
|
|
705
|
+
return Boolean(parts && (isJsxLocalValue(parts.truthy, known) || isJsxLocalValue(parts.falsy, known)))
|
|
706
|
+
}
|
|
707
|
+
|
|
621
708
|
function compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl) {
|
|
622
709
|
return factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl))
|
|
623
710
|
}
|
|
@@ -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)
|