@kudzujs/core 0.5.0 → 0.5.1
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 +13 -1
- package/framework/build.mjs +81 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -264,9 +264,19 @@ const rows = items.map(item =>
|
|
|
264
264
|
return <ul>{rows}</ul>
|
|
265
265
|
```
|
|
266
266
|
|
|
267
|
+
The root may also be a same-file row component that directly returns one intrinsic element and receives the whole item through one prop:
|
|
268
|
+
|
|
269
|
+
```tsx
|
|
270
|
+
function ItemRow({ item }: { item: Item }) {
|
|
271
|
+
return <li>{item.name}</li>
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const rows = items.map(item => <ItemRow key={item.id} item={item} />)
|
|
275
|
+
```
|
|
276
|
+
|
|
267
277
|
Kudzu emits initial items as static HTML, then adds, removes, updates, styles, conditional branches, and moves keyed elements directly. The map may appear directly in JSX or in one top-level immutable `const` rendered once as a JSX child. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Single-level item-local `&&` and ternary JSX conditions patch only their bounded branch and mount or unmount its handlers. Item-local handlers use direct DOM listeners and receive the latest JSON-safe item for their key, including after updates, additions, and reorders. The item remains stored once in shared list state; handler descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
|
|
268
278
|
|
|
269
|
-
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 item conditions or
|
|
279
|
+
Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a local-state `.map`, one identifier callback parameter, one intrinsic JSX root or same-file row component, and `key={item.<field>}`. A row component must directly destructure the whole item prop, directly return one intrinsic element, and be used only by that list. 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 item conditions, lists, or component tags, item spreads, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
|
|
270
280
|
|
|
271
281
|
## Normal JavaScript
|
|
272
282
|
|
|
@@ -438,6 +448,8 @@ The list starts with 1,000 keyed items, then updates every label, reverses the o
|
|
|
438
448
|
| Svelte CSR | No | 12.9 KB | 33.1 KB | 828 ms | 5.8 ms | 38.9 ms | 4.0 ms | 5.9 ms | 54.6 ms |
|
|
439
449
|
| Qwik CSR | No | 22.2 KB | 64.1 KB | 594 ms | 9.1 ms | 22.2 ms | 30.8 ms | 19.0 ms | 81.1 ms |
|
|
440
450
|
|
|
451
|
+
An intrinsic-root versus row-component A/B build produced byte-for-byte identical `dist` output: 5,175 B JS gzip and 61,731 B total. Seven interleaved clean builds measured 444 ms and 441 ms. Browser operation medians totaled 23.8 ms and 25.4 ms respectively; because the deployed HTML and JavaScript are identical, that 1.6 ms difference is measurement variance rather than component runtime overhead.
|
|
452
|
+
|
|
441
453
|
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 23.2 ms, 10.7 ms behind the hand-authored Astro baseline and 7.1 ms ahead of React across all four operations.
|
|
442
454
|
|
|
443
455
|
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.
|
package/framework/build.mjs
CHANGED
|
@@ -466,6 +466,19 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
466
466
|
if (uses.length) listLocalUses.set(uses[0], parts)
|
|
467
467
|
}
|
|
468
468
|
}
|
|
469
|
+
const renderedLists = new WeakMap()
|
|
470
|
+
const collectRenderedLists = node => {
|
|
471
|
+
if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
|
|
472
|
+
const parts = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction))
|
|
473
|
+
if (parts) {
|
|
474
|
+
if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
|
|
475
|
+
validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, functions)
|
|
476
|
+
renderedLists.set(node, parts)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
ts.forEachChild(node, collectRenderedLists)
|
|
480
|
+
}
|
|
481
|
+
collectRenderedLists(sourceFile)
|
|
469
482
|
|
|
470
483
|
const visitor = node => {
|
|
471
484
|
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
|
|
@@ -523,10 +536,8 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
523
536
|
}
|
|
524
537
|
|
|
525
538
|
if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
|
|
526
|
-
const listParts =
|
|
539
|
+
const listParts = renderedLists.get(node)
|
|
527
540
|
if (listParts) {
|
|
528
|
-
if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
|
|
529
|
-
validateKeyedList(listParts, sourceFile, settersForNode(node, settersByFunction), listValues, listEventItems, listConditions)
|
|
530
541
|
usesBehavior = true
|
|
531
542
|
usesList = true
|
|
532
543
|
return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, [
|
|
@@ -628,11 +639,35 @@ function keyedListParts(expression, setters) {
|
|
|
628
639
|
return { state, callback, root, item: callback.parameters[0].name.text, keyField: field }
|
|
629
640
|
}
|
|
630
641
|
|
|
631
|
-
function validateKeyedList(parts, sourceFile,
|
|
642
|
+
function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, functions) {
|
|
632
643
|
const fail = (node, message) => {
|
|
633
644
|
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
|
634
645
|
throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
|
|
635
646
|
}
|
|
647
|
+
let root = parts.root
|
|
648
|
+
let item = parts.item
|
|
649
|
+
const rootTag = ts.isJsxElement(root) ? root.openingElement.tagName : root.tagName
|
|
650
|
+
if (ts.isIdentifier(rootTag) && rootTag.text[0] === rootTag.text[0].toUpperCase()) {
|
|
651
|
+
const component = functions.get(rootTag.text)
|
|
652
|
+
if (!component) fail(root, `Keyed list component ${rootTag.text} must be declared in the same file`)
|
|
653
|
+
const uses = jsxTagUses(sourceFile, rootTag.text)
|
|
654
|
+
if (uses.length !== 1 || uses[0] !== root) fail(root, `Keyed list component ${rootTag.text} may only be used as this list root`)
|
|
655
|
+
const attributes = ts.isJsxElement(root) ? root.openingElement.attributes : root.attributes
|
|
656
|
+
let itemProp
|
|
657
|
+
for (const attribute of attributes.properties) {
|
|
658
|
+
if (ts.isJsxSpreadAttribute(attribute)) {
|
|
659
|
+
if (referencesIdentifier(attribute.expression, item)) fail(attribute, "Keyed list item spreads are not supported")
|
|
660
|
+
continue
|
|
661
|
+
}
|
|
662
|
+
if (attribute.name.getText() === "key" || !attribute.initializer || !ts.isJsxExpression(attribute.initializer) || !attribute.initializer.expression || !referencesIdentifier(attribute.initializer.expression, item)) continue
|
|
663
|
+
if (!ts.isIdentifier(attribute.initializer.expression) || attribute.initializer.expression.text !== item || itemProp) fail(attribute, `Keyed list component ${rootTag.text} must receive the whole item through one direct prop`)
|
|
664
|
+
itemProp = attribute.name.getText()
|
|
665
|
+
}
|
|
666
|
+
if (ts.isJsxElement(root) && root.children.some(child => referencesIdentifier(child, item))) fail(root, `Keyed list component ${rootTag.text} must receive the whole item through one direct prop`)
|
|
667
|
+
if (!itemProp) fail(root, `Keyed list component ${rootTag.text} must receive the whole item through one direct prop`)
|
|
668
|
+
item = componentItemParameter(component, itemProp, node => fail(node, `Keyed list component ${rootTag.text} must destructure its item prop`))
|
|
669
|
+
root = componentJsxRoot(component, node => fail(node, `Keyed list component ${rootTag.text} must return one JSX element`))
|
|
670
|
+
}
|
|
636
671
|
const validateElement = node => {
|
|
637
672
|
const tag = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName
|
|
638
673
|
if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(node, "Keyed list items must use intrinsic JSX elements")
|
|
@@ -641,10 +676,10 @@ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItem
|
|
|
641
676
|
const visit = node => {
|
|
642
677
|
if (ts.isJsxFragment(node)) fail(node, "Fragments are not supported in keyed lists")
|
|
643
678
|
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
|
|
644
|
-
if (node !==
|
|
645
|
-
if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression,
|
|
679
|
+
if (node !== root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) fail(node, "Nested keyed lists are not supported")
|
|
680
|
+
if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression, item)) fail(node, "Keyed list item spreads are not supported")
|
|
646
681
|
if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.getText())) {
|
|
647
|
-
listEventItems.set(node,
|
|
682
|
+
listEventItems.set(node, item)
|
|
648
683
|
return
|
|
649
684
|
}
|
|
650
685
|
if (ts.isJsxExpression(node) && node.expression) {
|
|
@@ -652,16 +687,16 @@ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItem
|
|
|
652
687
|
const condition = conditionalParts(expression)
|
|
653
688
|
if (condition && containsJsx(expression)) {
|
|
654
689
|
if (conditionDepth) fail(node, "Nested item conditions are not supported in keyed lists")
|
|
655
|
-
if (!referencesIdentifier(condition.condition,
|
|
656
|
-
validateListExpression(condition.condition,
|
|
657
|
-
listConditions.set(node.expression, { ...condition, item
|
|
690
|
+
if (!referencesIdentifier(condition.condition, item)) fail(node, "Keyed list item conditions must read the item")
|
|
691
|
+
validateListExpression(condition.condition, item, node, fail)
|
|
692
|
+
listConditions.set(node.expression, { ...condition, item })
|
|
658
693
|
conditionDepth++
|
|
659
694
|
visit(condition.truthy)
|
|
660
695
|
visit(condition.falsy)
|
|
661
696
|
conditionDepth--
|
|
662
697
|
return
|
|
663
698
|
}
|
|
664
|
-
const field = directProperty(expression,
|
|
699
|
+
const field = directProperty(expression, item)
|
|
665
700
|
const isRootKey = ts.isJsxAttribute(node.parent) && node.parent.name.getText() === "key"
|
|
666
701
|
if (field && ["__proto__", "constructor", "prototype"].includes(field)) fail(node, `Keyed list item property "${field}" is not supported`)
|
|
667
702
|
if (field && ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
|
|
@@ -670,16 +705,47 @@ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItem
|
|
|
670
705
|
listValues.set(node.expression, { field })
|
|
671
706
|
return
|
|
672
707
|
}
|
|
673
|
-
if (referencesIdentifier(expression,
|
|
674
|
-
validateListExpression(expression,
|
|
708
|
+
if (referencesIdentifier(expression, item)) {
|
|
709
|
+
validateListExpression(expression, item, node, fail)
|
|
675
710
|
if (ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
|
|
676
|
-
listValues.set(node.expression, { item
|
|
711
|
+
listValues.set(node.expression, { item })
|
|
677
712
|
return
|
|
678
713
|
}
|
|
679
714
|
}
|
|
680
715
|
ts.forEachChild(node, visit)
|
|
681
716
|
}
|
|
682
|
-
visit(
|
|
717
|
+
visit(root)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function componentItemParameter(component, prop, fail) {
|
|
721
|
+
if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) fail(component)
|
|
722
|
+
const element = component.parameters[0].name.elements.find(entry => !entry.dotDotDotToken && !entry.initializer && (entry.propertyName ?? entry.name).getText() === prop)
|
|
723
|
+
if (!element || !ts.isIdentifier(element.name)) fail(component.parameters[0])
|
|
724
|
+
return element.name.text
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function componentJsxRoot(component, fail) {
|
|
728
|
+
if (!ts.isBlock(component.body)) {
|
|
729
|
+
const root = unwrapExpression(component.body)
|
|
730
|
+
if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(component.body)
|
|
731
|
+
return root
|
|
732
|
+
}
|
|
733
|
+
if (component.body.statements.length !== 1 || !ts.isReturnStatement(component.body.statements[0]) || !component.body.statements[0].expression) fail(component.body)
|
|
734
|
+
const statement = component.body.statements[0]
|
|
735
|
+
const root = unwrapExpression(statement.expression)
|
|
736
|
+
if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(statement)
|
|
737
|
+
return root
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function jsxTagUses(root, name) {
|
|
741
|
+
const uses = []
|
|
742
|
+
const visit = node => {
|
|
743
|
+
const tag = ts.isJsxElement(node) ? node.openingElement.tagName : ts.isJsxSelfClosingElement(node) ? node.tagName : undefined
|
|
744
|
+
if (tag && ts.isIdentifier(tag) && tag.text === name) uses.push(node)
|
|
745
|
+
ts.forEachChild(node, visit)
|
|
746
|
+
}
|
|
747
|
+
visit(root)
|
|
748
|
+
return uses
|
|
683
749
|
}
|
|
684
750
|
|
|
685
751
|
const pureListMethods = new Set(["at", "charAt", "charCodeAt", "concat", "endsWith", "includes", "indexOf", "join", "lastIndexOf", "padEnd", "padStart", "repeat", "replace", "replaceAll", "slice", "startsWith", "substring", "toLowerCase", "toUpperCase", "trim", "trimEnd", "trimStart"])
|