@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 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 lists, item spreads, component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
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.
@@ -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 = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction))
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, setters, listValues, listEventItems, listConditions) {
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 !== parts.root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) fail(node, "Nested keyed lists are not supported")
645
- if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression, parts.item)) fail(node, "Keyed list item spreads are not supported")
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, parts.item)
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, parts.item)) fail(node, "Keyed list item conditions must read the item")
656
- validateListExpression(condition.condition, parts.item, node, fail)
657
- listConditions.set(node.expression, { ...condition, item: parts.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, parts.item)
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, parts.item)) {
674
- validateListExpression(expression, parts.item, node, fail)
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: parts.item })
711
+ listValues.set(node.expression, { item })
677
712
  return
678
713
  }
679
714
  }
680
715
  ts.forEachChild(node, visit)
681
716
  }
682
- visit(parts.root)
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"])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",