@kudzujs/core 0.4.3 → 0.4.6

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
@@ -166,7 +166,7 @@ const [items, setItems] = useState([
166
166
  )}</ul>
167
167
  ```
168
168
 
169
- Kudzu emits initial items as static HTML, then adds, removes, updates, 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 delegated events 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.
169
+ Kudzu emits initial items as static HTML, then adds, removes, updates, 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.
170
170
 
171
171
  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, fragments, and reactive `style`, `ref`, or `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
172
172
 
@@ -190,7 +190,7 @@ async function load() {
190
190
 
191
191
  Primitive values, arrays, plain objects, and destructured props can be captured by client handlers. Functions, symbols, bigints, cycles, class instances, and imported helper functions are not yet supported as captures.
192
192
 
193
- Native handlers are delegated after normal event bubbling and run from the target toward matching Kudzu ancestors in deterministic order. Delegated handlers cannot call or reference `preventDefault`, `stopPropagation`, or `stopImmediatePropagation`; the compiler rejects those methods because external ESM cannot apply them with correct synchronous DOM semantics.
193
+ Native handlers use direct DOM listeners with normal `currentTarget`, bubbling, default-action, and propagation semantics. Handler modules load before listener registration, so `preventDefault`, `stopPropagation`, and `stopImmediatePropagation` work synchronously as expected.
194
194
 
195
195
  ## Rendering
196
196
 
@@ -249,13 +249,13 @@ Same counter with initial value `7` and increment/decrement buttons:
249
249
 
250
250
  | Framework | Initial content | Initial JS gzip | Total output | Clean build |
251
251
  |---|---:|---:|---:|---:|
252
- | Kudzu | Yes | 487 B | 1.4 KB | **373 ms** |
253
- | Astro | Yes | **158 B** | **365 B** | 877 ms |
254
- | Svelte CSR | No | 10.5 KB | 26.9 KB | 837 ms |
255
- | Qwik CSR | No | 20.6 KB | 57.8 KB | 626 ms |
256
- | Vue CSR | No | 24.0 KB | 60.3 KB | 778 ms |
257
- | React CSR | No | 59.2 KB | 189.0 KB | 1049 ms |
258
- | Next.js | Yes | 182.1 KB | 652.2 KB | 3099 ms |
252
+ | Kudzu | Yes | 487 B | 1.4 KB | **363 ms** |
253
+ | Astro | Yes | **158 B** | **365 B** | 851 ms |
254
+ | Svelte CSR | No | 10.5 KB | 26.9 KB | 827 ms |
255
+ | Qwik CSR | No | 20.6 KB | 57.8 KB | 598 ms |
256
+ | Vue CSR | No | 24.0 KB | 60.3 KB | 762 ms |
257
+ | React CSR | No | 59.2 KB | 189.0 KB | 1018 ms |
258
+ | Next.js | Yes | 182.1 KB | 652.2 KB | 2989 ms |
259
259
 
260
260
  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.
261
261
 
@@ -265,15 +265,31 @@ Same content and CSS across every fixture:
265
265
 
266
266
  | Framework | Initial content | Initial JS gzip | Total output | Clean build |
267
267
  |---|---:|---:|---:|---:|
268
- | Kudzu | Yes | **0 B** | 3.2 KB | **478 ms** |
269
- | Astro | Yes | **0 B** | **3.0 KB** | 1253 ms |
270
- | Svelte CSR | No | 10.2 KB | 27.2 KB | 1018 ms |
271
- | Qwik CSR | No | 20.2 KB | 59.6 KB | 784 ms |
272
- | Vue CSR | No | 24.2 KB | 62.3 KB | 960 ms |
273
- | React CSR | No | 59.8 KB | 192.3 KB | 1253 ms |
274
- | Next.js | Yes | 182.6 KB | 663.6 KB | 3880 ms |
275
-
276
- Benchmark snapshot collected on July 21, 2026 with Node 24.14.0 on an Intel i5-9500. 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. These results compare the selected one-page fixtures, not ecosystem maturity, browser interaction speed, or each framework's full rendering options. Build times vary with machine load and filesystem cache.
268
+ | Kudzu | Yes | **0 B** | 3.2 KB | **380 ms** |
269
+ | Astro | Yes | **0 B** | **3.0 KB** | 965 ms |
270
+ | Svelte CSR | No | 10.2 KB | 27.2 KB | 824 ms |
271
+ | Qwik CSR | No | 20.2 KB | 59.6 KB | 594 ms |
272
+ | Vue CSR | No | 24.2 KB | 62.3 KB | 755 ms |
273
+ | React CSR | No | 59.8 KB | 192.3 KB | 1041 ms |
274
+ | Next.js | Yes | 182.6 KB | 663.6 KB | 2975 ms |
275
+
276
+ ### 1,000-item Keyed List
277
+
278
+ 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.
279
+
280
+ | Framework | Initial content | Initial JS gzip | Total output | Build | Update | Reverse | Remove | Add | Operations total |
281
+ |---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
282
+ | Astro | Yes | **324 B** | **43.6 KB** | 852 ms | **3.5 ms** | 25.8 ms | 15.8 ms | **17.9 ms** | **63.0 ms** |
283
+ | Kudzu | Yes | 6.3 KB | 69.3 KB | **406 ms** | 6.0 ms | 28.5 ms | 14.7 ms | 19.1 ms | **68.3 ms** |
284
+ | Next.js | Yes | 182.2 KB | 695.2 KB | 2950 ms | 6.8 ms | 34.1 ms | **9.0 ms** | 19.3 ms | 69.2 ms |
285
+ | React CSR | No | 59.3 KB | 189.4 KB | 1028 ms | 9.5 ms | 35.3 ms | 10.1 ms | **17.8 ms** | 72.7 ms |
286
+ | Vue CSR | No | 24.3 KB | 61.3 KB | 759 ms | 10.2 ms | 33.9 ms | 9.3 ms | 19.7 ms | 73.1 ms |
287
+ | Qwik CSR | No | 22.2 KB | 64.1 KB | 604 ms | 15.6 ms | **20.0 ms** | 30.4 ms | 18.1 ms | 84.1 ms |
288
+ | Svelte CSR | No | 12.9 KB | 33.1 KB | 845 ms | 5.4 ms | 64.4 ms | 11.2 ms | 19.1 ms | 100.1 ms |
289
+
290
+ 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 68.3 ms, ahead of the React fixture's 72.7 ms and within 5.3 ms of Astro across all four operations.
291
+
292
+ 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.
277
293
 
278
294
  ## Development
279
295
 
@@ -64,12 +64,13 @@ export async function build({ quiet = false } = {}) {
64
64
  await mkdir(assetsDirectory, { recursive: true })
65
65
  const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
66
66
  const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
67
+ const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => `/assets/${module.path}`)
68
+ const hasNativeHandlers = nativeModules.length > 0
67
69
  if (behaviorCount) {
68
- const runtimeFile = bindingCount || listCount ? "./shared-runtime.js" : "./runtime.js"
70
+ const runtimeFile = bindingCount || listCount || hasNativeHandlers ? "./shared-runtime.js" : "./runtime.js"
69
71
  const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
70
72
  await writeFile(join(assetsDirectory, "kudzu.js"), runtime)
71
73
  }
72
- const hasNativeHandlers = handlerModules.some(module => module.hasNativeHandlers)
73
74
  if (bindingCount || hasNativeHandlers) await cp(new URL("./serialization.js", import.meta.url), join(assetsDirectory, "kudzu-serialization.js"))
74
75
  if (bindingCount) {
75
76
  const bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
@@ -84,9 +85,9 @@ export async function build({ quiet = false } = {}) {
84
85
  }
85
86
  if (hasNativeHandlers) {
86
87
  const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
87
- .replace('"./runtime.js"', '"./kudzu.js"')
88
+ .replace('"./shared-runtime.js"', '"./kudzu.js"')
88
89
  .replace('"./serialization.js"', '"./kudzu-serialization.js"')
89
- await writeFile(join(assetsDirectory, "kudzu-native.js"), specializeEvents(nativeRuntime, nativeEvents))
90
+ await writeFile(join(assetsDirectory, "kudzu-native.js"), specializeNativeRuntime(nativeRuntime, nativeEvents, nativeModules))
90
91
  }
91
92
  for (const handlerModule of handlerModules) {
92
93
  const output = join(assetsDirectory, handlerModule.path)
@@ -104,12 +105,18 @@ function specializeEvents(source, events) {
104
105
  return source.replace(/const eventNames = \[[^\n]+\]/, `const eventNames = ${JSON.stringify(events)}`)
105
106
  }
106
107
 
108
+ function specializeNativeRuntime(source, events, modules) {
109
+ const imports = modules.map((module, index) => `import * as __kNativeModule${index} from ${JSON.stringify(module)}`).join("\n")
110
+ const entries = modules.map((module, index) => `[${JSON.stringify(module)}, __kNativeModule${index}]`).join(",")
111
+ return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
112
+ }
113
+
107
114
  function specializeRuntime(source, events, hasStateSeed) {
108
115
  const specialized = specializeEvents(source, events)
109
116
  if (hasStateSeed) return specialized
110
117
  return specialized
111
118
  .replace(" const initialState = document.body.dataset.kState\n", "")
112
- .replace(" if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)\n", "")
119
+ .replace(" if (initialState) for (const [id, value, compact] of JSON.parse(initialState)) browserState.set(id, compact ? value[1].map(row => Object.fromEntries(value[0].map((field, index) => [field, row[index]]))) : value)\n", "")
113
120
  }
114
121
 
115
122
  export function parseDevPort(value) {
@@ -661,7 +668,6 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
661
668
  if (ts.isIdentifier(expression)) expression = functions.get(expression.text)
662
669
  if (!expression || (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression) && !ts.isFunctionDeclaration(expression))) return undefined
663
670
 
664
- rejectNativeEventControls(expression)
665
671
  const optimized = compileOptimizedEvent(expression, setters, factory)
666
672
  if (optimized) return optimized
667
673
 
@@ -684,34 +690,6 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
684
690
  ])
685
691
  }
686
692
 
687
- function rejectNativeEventControls(expression) {
688
- const controls = new Set(["preventDefault", "stopPropagation", "stopImmediatePropagation"])
689
- const found = new Set()
690
- const eventAliases = new Set()
691
- const parameter = expression.parameters[0]?.name
692
- if (parameter && ts.isIdentifier(parameter)) eventAliases.add(parameter.text)
693
- const visit = node => {
694
- if (ts.isIdentifier(node) && controls.has(node.text)) found.add(node.text)
695
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isIdentifier(unwrapEventAlias(node.initializer)) && eventAliases.has(unwrapEventAlias(node.initializer).text)) {
696
- eventAliases.add(node.name.text)
697
- }
698
- if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(node.left) && ts.isIdentifier(unwrapEventAlias(node.right)) && eventAliases.has(unwrapEventAlias(node.right).text)) eventAliases.add(node.left.text)
699
- if (ts.isElementAccessExpression(node) && ts.isIdentifier(unwrapEventAlias(node.expression)) && eventAliases.has(unwrapEventAlias(node.expression).text)) {
700
- if (ts.isStringLiteral(node.argumentExpression) && controls.has(node.argumentExpression.text)) found.add(node.argumentExpression.text)
701
- else if (!ts.isStringLiteral(node.argumentExpression)) for (const control of controls) found.add(control)
702
- }
703
- ts.forEachChild(node, visit)
704
- }
705
- for (const parameter of expression.parameters) visit(parameter)
706
- visit(expression.body)
707
- if (found.size) throw new Error(`Delegated native handlers do not support event control methods: ${[...found].sort().join(", ")}`)
708
- }
709
-
710
- function unwrapEventAlias(node) {
711
- if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isNonNullExpression(node) || ts.isSatisfiesExpression(node)) return unwrapEventAlias(node.expression)
712
- return node
713
- }
714
-
715
693
  function nativeStateNames(expression, setters) {
716
694
  return referencedStateNames(expression.body, setters, expression)
717
695
  }
@@ -187,7 +187,7 @@ function serializeCapture(name, value, seen) {
187
187
  }
188
188
 
189
189
  export async function renderPage(component, metadata = {}) {
190
- renderContext = { nextState: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false }
190
+ renderContext = { nextState: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listFields: undefined, states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false }
191
191
 
192
192
  try {
193
193
  const body = await renderNode({ type: component, props: {} })
@@ -208,8 +208,13 @@ export async function renderPage(component, metadata = {}) {
208
208
  const listRuntime = renderContext.hasLists
209
209
  ? '<script type="module" src="/assets/kudzu-list.js"></script>'
210
210
  : ""
211
+ const listStates = new Set(renderContext.lists.map(list => list.state))
212
+ const seededListStates = new Set(renderContext.lists.filter(list => list.seed && !renderContext.textStates.has(list.state) && !renderContext.conditionStates.has(list.state)).map(list => list.state))
211
213
  const initialState = renderContext.hasBehaviors
212
- ? Object.entries(renderContext.states).filter(([id]) => !renderContext.textStates.has(id) || renderContext.conditionStates.has(id)).map(([id, entry]) => [id, entry.initialValue])
214
+ ? Object.entries(renderContext.states).filter(([id]) => (!renderContext.textStates.has(id) || renderContext.conditionStates.has(id)) && !seededListStates.has(id)).map(([id, entry]) => {
215
+ const compact = listStates.has(id) && compactListState(entry.initialValue)
216
+ return compact ? [id, compact, 1] : [id, entry.initialValue]
217
+ })
213
218
  : []
214
219
  const state = initialState.length
215
220
  ? ` data-k-state='${escapeJsonAttribute(initialState)}'`
@@ -305,11 +310,14 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
305
310
  }
306
311
  if (node?.[listMarker]) return renderList(node, namespace, selectValue)
307
312
  if (node?.[listFieldMarker]) {
308
- return `<template data-k-list-text="${escapeAttribute(node.field)}"></template>${escapeHtml(node.value ?? "")}<template data-k-list-text-end></template>`
313
+ if (renderContext.listTemplate) renderContext.listFields?.add(node.field)
314
+ const marker = renderContext.listTemplate ? ` data-k-list-text="${escapeAttribute(node.field)}"` : ""
315
+ return `<template${marker}></template>${escapeHtml(node.value ?? "")}<template data-k-list-text-end></template>`
309
316
  }
310
317
  if (node?.[listExpressionMarker]) {
311
318
  const descriptor = { module: node.module, handler: node.handler }
312
- return `<template data-k-list-expression='${escapeJsonAttribute(descriptor)}'></template>${escapeHtml(node.value ?? "")}<template data-k-list-expression-end></template>`
319
+ const marker = renderContext.listTemplate ? ` data-k-list-expression='${escapeJsonAttribute(descriptor)}'` : ""
320
+ return `<template${marker}></template>${escapeHtml(node.value ?? "")}<template data-k-list-expression-end></template>`
313
321
  }
314
322
  if (!node || typeof node !== "object" || !("type" in node)) {
315
323
  throw new Error(`Cannot render ${String(node)}`)
@@ -320,6 +328,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
320
328
 
321
329
  const tag = node.type
322
330
  const props = node.props ?? {}
331
+ const directListText = props.children?.[listFieldMarker] ? props.children : undefined
323
332
  const childSelectValue = tag === "select"
324
333
  ? Object.hasOwn(props, "value") ? bindingValue(props.value) : noSelectValue
325
334
  : selectValue
@@ -335,9 +344,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
335
344
  if (renderContext.listRoot) {
336
345
  const root = renderContext.listRoot
337
346
  renderContext.listRoot = undefined
338
- attributes += root.template
339
- ? ` data-k-list-root="${root.id}"`
340
- : ` data-k-list-item='${escapeJsonAttribute([root.id, root.key])}'`
347
+ if (root.template) attributes += ` data-k-list-root="${root.id}"`
341
348
  }
342
349
 
343
350
  for (const [rawName, value] of Object.entries(props)) {
@@ -411,26 +418,36 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
411
418
  }
412
419
 
413
420
  if (attributeBindings.length) attributes += ` data-k-bind-attrs='${escapeJsonAttribute(attributeBindings)}'`
414
- if (listAttributes.length) attributes += ` data-k-list-attrs='${escapeJsonAttribute(listAttributes)}'`
415
- if (listExpressionAttributes.length) attributes += ` data-k-list-expression-attrs='${escapeJsonAttribute(listExpressionAttributes)}'`
416
- if (listEvents.length) attributes += ` data-k-list-events='${escapeJsonAttribute(listEvents)}'`
421
+ if (renderContext.listTemplate && listAttributes.length) attributes += ` data-k-list-attrs='${escapeJsonAttribute(listAttributes)}'`
422
+ if (renderContext.listTemplate && listExpressionAttributes.length) attributes += ` data-k-list-expression-attrs='${escapeJsonAttribute(listExpressionAttributes)}'`
423
+ if (renderContext.listTemplate && listEvents.length) attributes += ` data-k-list-events='${escapeJsonAttribute(listEvents)}'`
424
+ if (renderContext.listTemplate && directListText) {
425
+ renderContext.listFields?.add(directListText.field)
426
+ attributes += ` data-k-list-text="${escapeAttribute(directListText.field)}"`
427
+ }
417
428
 
418
429
  if (tag === "option" && selectValue !== noSelectValue && String(optionValue(props)) === (selectValue == null ? "" : String(selectValue))) attributes += " selected"
419
430
 
420
431
  const voidElements = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"])
421
432
  if (voidElements.has(tag)) return `<${tag}${attributes}>`
422
- return `<${tag}${attributes}>${await renderNode(props.children, childNamespace, childSelectValue)}</${tag}>`
433
+ const children = directListText ? escapeHtml(directListText.value ?? "") : await renderNode(props.children, childNamespace, childSelectValue)
434
+ return `<${tag}${attributes}>${children}</${tag}>`
423
435
  }
424
436
 
425
437
  async function renderList(node, namespace, selectValue) {
426
438
  if (namespace) throw new Error(`Reactive keyed lists are not supported inside ${namespace}`)
427
439
  const id = `l${renderContext.nextList++}`
428
- const descriptor = { id, state: node.items.id, key: node.keyField }
440
+ const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.items.value.map(item => item[node.keyField]) }
429
441
  renderContext.listDepth++
442
+ const previousListFields = renderContext.listFields
430
443
  try {
431
444
  renderContext.listTemplate = true
445
+ renderContext.listFields = new Set([node.keyField])
432
446
  renderContext.listRoot = { id, template: true }
433
447
  const template = await renderNode(node.render({}), namespace, selectValue)
448
+ if (template.includes("data-k-native-")) descriptor.mount = true
449
+ const seed = listSeed(node.items.value, renderContext.listFields)
450
+ if (seed) descriptor.seed = seed
434
451
  let current = ""
435
452
  renderContext.listTemplate = false
436
453
  for (const item of node.items.value) {
@@ -444,6 +461,7 @@ async function renderList(node, namespace, selectValue) {
444
461
  } finally {
445
462
  renderContext.listRoot = undefined
446
463
  renderContext.listTemplate = false
464
+ renderContext.listFields = previousListFields
447
465
  renderContext.listDepth--
448
466
  }
449
467
  }
@@ -490,6 +508,24 @@ function escapeJsonAttribute(value) {
490
508
  return JSON.stringify(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll("'", "&#39;")
491
509
  }
492
510
 
511
+ function compactListState(value) {
512
+ if (!Array.isArray(value) || !value.length) return undefined
513
+ const fields = Object.keys(value[0])
514
+ if (!fields.length || !value.every(item => Object.keys(item).length === fields.length && fields.every(field => Object.hasOwn(item, field)))) return undefined
515
+ return [fields, value.map(item => fields.map(field => item[field]))]
516
+ }
517
+
518
+ function listSeed(items, fields) {
519
+ if (!items.length || !items.every(item => Object.keys(item).length === fields.size && Object.keys(item).every(field => fields.has(field)))) return undefined
520
+ const seed = {}
521
+ for (const field of fields) {
522
+ const types = new Set(items.map(item => item[field] === null ? "null" : typeof item[field]))
523
+ if (types.size !== 1 || !["string", "number", "boolean", "null"].includes([...types][0])) return undefined
524
+ seed[field] = [...types][0]
525
+ }
526
+ return seed
527
+ }
528
+
493
529
  function toKebabCase(value) {
494
530
  return value.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`)
495
531
  }
@@ -5,6 +5,8 @@ const listRegistrations = new WeakMap()
5
5
  const mountedLists = new WeakSet()
6
6
  const imports = new Map()
7
7
  const revisions = new WeakMap()
8
+ const itemParts = new WeakMap()
9
+ const itemPartsSelector = "[data-k-list-text],[data-k-list-attrs],[data-k-list-events],[data-k-list-expression],[data-k-list-expression-attrs]"
8
10
 
9
11
  function commitLists(id) {
10
12
  const lists = listTargets.get(id)
@@ -26,13 +28,22 @@ function mountLists(root) {
26
28
  if (mountedLists.has(start)) continue
27
29
  mountedLists.add(start)
28
30
  const descriptor = JSON.parse(start.dataset.kList)
29
- const roots = [...start.ownerDocument.querySelectorAll("[data-k-list-item]")].filter(node => JSON.parse(node.dataset.kListItem)[0] === descriptor.id)
31
+ const end = findEnd(start, descriptor.id)
32
+ const roots = listRoots(start, end)
33
+ const templateRoot = start.content.firstElementChild
34
+ const parts = listItemPartPlan(templateRoot)
35
+ for (const root of roots) mapListItemParts(parts, root)
36
+ if (descriptor.seed && !browserState.has(descriptor.state)) browserState.set(descriptor.state, roots.map((root, index) => seedListItem(root, descriptor, index)))
37
+ const items = browserState.get(descriptor.state)
30
38
  const list = {
31
39
  start,
32
40
  descriptor,
33
- roots: new Map(roots.map(node => [keyToken(JSON.parse(node.dataset.kListItem)[1]), node])),
41
+ parts,
42
+ seedFields: descriptor.seed && Object.keys(descriptor.seed),
43
+ roots: new Map(roots.map((node, index) => [keyToken(descriptor.keys[index]), node])),
44
+ values: new Map(),
34
45
  container: roots[0]?.parentNode,
35
- boundary: roots.length ? roots.at(-1).nextSibling : findEnd(start, descriptor.id)
46
+ boundary: end
36
47
  }
37
48
  register(listTargets, descriptor.state, list)
38
49
  listRegistrations.set(start, { state: descriptor.state, list })
@@ -60,66 +71,111 @@ function updateList(list) {
60
71
  if (!Array.isArray(items)) throw new Error("Keyed list state must remain an array")
61
72
  const entries = []
62
73
  const keys = new Set()
74
+ const seen = new Set()
63
75
  for (const item of items) {
64
76
  const key = item?.[list.descriptor.key]
65
77
  if (!validListKey(key)) throw new Error(`Keyed list key "${list.descriptor.key}" must be a string or finite number`)
66
78
  assertListItem(item)
67
- assertListValue(item, new Set())
79
+ const seededValue = list.seedFields && seededListValue(item, list.seedFields, list.descriptor.seed)
80
+ if (seededValue === undefined) assertListValue(item, seen, true)
68
81
  const token = keyToken(key)
69
82
  if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
70
83
  keys.add(token)
71
- entries.push({ item, key, token })
84
+ entries.push({ item, key, token, value: seededValue ?? JSON.stringify(item) })
72
85
  }
73
86
  const next = []
74
- for (const { item, key, token } of entries) {
87
+ const values = new Map()
88
+ const parent = list.container ?? list.start.parentNode
89
+ const additions = parent.ownerDocument.createDocumentFragment()
90
+ let added = false
91
+ for (const { item, key, token, value } of entries) {
75
92
  let node = list.roots.get(token)
76
93
  if (!node) {
77
- const fragment = list.start.content.cloneNode(true)
78
- node = fragment.querySelector(`[data-k-list-root="${list.descriptor.id}"]`)
94
+ node = list.start.content.firstElementChild?.cloneNode(true)
95
+ if (node?.dataset.kListRoot !== list.descriptor.id) node = undefined
79
96
  if (!node) throw new Error("Keyed list template has no root element")
80
97
  node.removeAttribute("data-k-list-root")
81
- node.dataset.kListItem = JSON.stringify([list.descriptor.id, key])
98
+ mapListItemParts(list.parts, node)
82
99
  fillListItem(node, item)
83
- ;(list.container ?? list.start.parentNode).insertBefore(fragment, list.boundary)
84
- list.container ??= node.parentNode
85
- mountDom(node)
86
- } else {
100
+ additions.append(node)
101
+ added = true
102
+ } else if (list.values.get(token) !== value) {
87
103
  fillListItem(node, item)
88
104
  }
89
105
  next.push([token, node])
106
+ values.set(token, value)
90
107
  }
108
+ const removals = parent.ownerDocument.createDocumentFragment()
91
109
  for (const [token, node] of list.roots) {
92
110
  if (keys.has(token)) continue
93
- unmountDom(node)
94
- node.remove()
111
+ if (list.descriptor.mount) {
112
+ unmountDom(node)
113
+ node.remove()
114
+ } else {
115
+ removals.append(node)
116
+ }
117
+ }
118
+ if (added) {
119
+ if (list.descriptor.mount) mountDom(additions)
120
+ parent.insertBefore(additions, list.boundary)
121
+ list.container ??= parent
122
+ }
123
+ let anchor = list.boundary
124
+ let misplaced = 0
125
+ for (let index = next.length - 1; index >= 0; index--) {
126
+ const node = next[index][1]
127
+ if (node.nextSibling !== anchor) misplaced++
128
+ anchor = node
129
+ }
130
+ if (misplaced > next.length / 2) {
131
+ const reordered = parent.ownerDocument.createDocumentFragment()
132
+ reordered.append(...next.map(([, node]) => node))
133
+ parent.insertBefore(reordered, list.boundary)
134
+ } else if (misplaced) {
135
+ anchor = list.boundary
136
+ for (let index = next.length - 1; index >= 0; index--) {
137
+ const node = next[index][1]
138
+ if (node.nextSibling !== anchor) parent.insertBefore(node, anchor)
139
+ anchor = node
140
+ }
95
141
  }
96
- const parent = list.container ?? list.start.parentNode
97
- for (const [, node] of next) parent.insertBefore(node, list.boundary)
98
142
  list.roots = new Map(next)
143
+ list.values = values
99
144
  }
100
145
 
101
146
  function fillListItem(root, item) {
102
147
  const revision = (revisions.get(root) ?? 0) + 1
103
148
  revisions.set(root, revision)
104
- for (const marker of matching(root, "template[data-k-list-text]")) {
105
- patchListText(marker, "template[data-k-list-text-end]", item?.[marker.dataset.kListText])
149
+ const parts = listItemParts(root)
150
+ for (const [node, field] of parts.directTexts) {
151
+ const text = item?.[field]
152
+ const value = text == null ? "" : String(text)
153
+ const current = node.firstChild
154
+ if (current?.nodeType === Node.TEXT_NODE && !current.nextSibling) {
155
+ if (current.data !== value) current.data = value
156
+ } else {
157
+ node.textContent = value
158
+ }
159
+ }
160
+ for (const [marker, field] of parts.texts) {
161
+ patchListText(marker, "template[data-k-list-text-end]", item?.[field])
106
162
  }
107
- for (const node of matching(root, "[data-k-list-attrs]")) {
108
- for (const [target, field] of JSON.parse(node.dataset.kListAttrs)) patchBinding(node, target, item?.[field])
163
+ for (const [node, attributes] of parts.attributes) {
164
+ for (const [target, field] of attributes) patchBinding(node, target, item?.[field])
109
165
  }
110
- for (const node of matching(root, "[data-k-list-events]")) {
111
- for (const [event, native] of JSON.parse(node.dataset.kListEvents)) {
166
+ for (const [node, events] of parts.events) {
167
+ for (const [event, native] of JSON.parse(events)) {
112
168
  native.scope = Object.fromEntries(Object.entries(native.scope).map(([name, value]) => [name, value?.type === "list-item" ? serializeItem(item) : value]))
113
169
  node.dataset[`kNative${capitalize(event)}`] = JSON.stringify(native)
114
170
  }
115
171
  }
116
- for (const marker of matching(root, "template[data-k-list-expression]")) {
117
- evaluate(JSON.parse(marker.dataset.kListExpression), item).then(value => {
172
+ for (const [marker, descriptor] of parts.expressions) {
173
+ evaluate(descriptor, item).then(value => {
118
174
  if (revisions.get(root) === revision && root.isConnected) patchListText(marker, "template[data-k-list-expression-end]", value)
119
175
  }).catch(error => console.error(error))
120
176
  }
121
- for (const node of matching(root, "[data-k-list-expression-attrs]")) {
122
- for (const [target, module, handler] of JSON.parse(node.dataset.kListExpressionAttrs)) {
177
+ for (const [node, attributes] of parts.expressionAttributes) {
178
+ for (const [target, module, handler] of attributes) {
123
179
  evaluate({ module, handler }, item).then(value => {
124
180
  if (revisions.get(root) === revision && root.isConnected) patchBinding(node, target, value)
125
181
  }).catch(error => console.error(error))
@@ -127,15 +183,91 @@ function fillListItem(root, item) {
127
183
  }
128
184
  }
129
185
 
186
+ function listItemParts(root) {
187
+ let parts = itemParts.get(root)
188
+ if (parts) return parts
189
+ parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [] }
190
+ for (const node of matching(root, itemPartsSelector)) {
191
+ if (node.hasAttribute("data-k-list-text")) (node.tagName === "TEMPLATE" ? parts.texts : parts.directTexts).push([node, node.dataset.kListText])
192
+ if (node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
193
+ if (node.hasAttribute("data-k-list-events")) parts.events.push([node, node.dataset.kListEvents])
194
+ if (node.hasAttribute("data-k-list-expression")) parts.expressions.push([node, JSON.parse(node.dataset.kListExpression)])
195
+ if (node.hasAttribute("data-k-list-expression-attrs")) parts.expressionAttributes.push([node, JSON.parse(node.dataset.kListExpressionAttrs)])
196
+ }
197
+ itemParts.set(root, parts)
198
+ return parts
199
+ }
200
+
201
+ function listItemPartPlan(template) {
202
+ const source = [template, ...template.querySelectorAll("*")]
203
+ const indexes = new Map(source.map((node, index) => [node, index]))
204
+ const parts = listItemParts(template)
205
+ return {
206
+ directTexts: parts.directTexts.map(([node, field]) => [indexes.get(node), field]),
207
+ texts: parts.texts.map(([node, field]) => [indexes.get(node), field]),
208
+ attributes: parts.attributes.map(([node, attributes]) => [indexes.get(node), attributes]),
209
+ events: parts.events.map(([node, events]) => [indexes.get(node), events]),
210
+ expressions: parts.expressions.map(([node, descriptor]) => [indexes.get(node), descriptor]),
211
+ expressionAttributes: parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes])
212
+ }
213
+ }
214
+
215
+ function mapListItemParts(parts, root) {
216
+ const target = [root, ...root.querySelectorAll("*")]
217
+ itemParts.set(root, {
218
+ directTexts: parts.directTexts.map(([index, field]) => [target[index], field]),
219
+ texts: parts.texts.map(([index, field]) => [target[index], field]),
220
+ attributes: parts.attributes.map(([index, attributes]) => [target[index], attributes]),
221
+ events: parts.events.map(([index, events]) => [target[index], events]),
222
+ expressions: parts.expressions.map(([index, descriptor]) => [target[index], descriptor]),
223
+ expressionAttributes: parts.expressionAttributes.map(([index, attributes]) => [target[index], attributes])
224
+ })
225
+ }
226
+
227
+ function listRoots(start, end) {
228
+ const roots = []
229
+ for (let node = start.nextSibling; node && node !== end; node = node.nextSibling) {
230
+ if (node.nodeType === Node.ELEMENT_NODE) roots.push(node)
231
+ }
232
+ return roots
233
+ }
234
+
235
+ function seedListItem(root, descriptor, index) {
236
+ const item = { [descriptor.key]: descriptor.keys[index] }
237
+ const parts = itemParts.get(root)
238
+ for (const [node, field] of parts.directTexts) item[field] = seedValue(node.textContent, descriptor.seed[field])
239
+ for (const [marker, field] of parts.texts) item[field] = seedValue(rangeText(marker, "template[data-k-list-text-end]"), descriptor.seed[field])
240
+ return item
241
+ }
242
+
243
+ function seedValue(value, type) {
244
+ if (type === "number") return Number(value)
245
+ if (type === "boolean") return value === "true"
246
+ if (type === "null") return null
247
+ return value
248
+ }
249
+
250
+ function rangeText(marker, endSelector) {
251
+ let value = ""
252
+ for (let node = marker.nextSibling; node && !(node.nodeType === Node.ELEMENT_NODE && node.matches(endSelector)); node = node.nextSibling) value += node.textContent
253
+ return value
254
+ }
255
+
130
256
  function patchListText(marker, endSelector, value) {
131
257
  let end = marker.nextSibling
132
258
  while (end && !(end.nodeType === Node.ELEMENT_NODE && end.matches(endSelector))) end = end.nextSibling
133
259
  if (!end) throw new Error("Keyed list text marker has no end")
260
+ const text = value == null ? "" : String(value)
261
+ const current = marker.nextSibling
262
+ if (current?.nodeType === Node.TEXT_NODE && current.nextSibling === end) {
263
+ if (current.data !== text) current.data = text
264
+ return
265
+ }
134
266
  const range = marker.ownerDocument.createRange()
135
267
  range.setStartAfter(marker)
136
268
  range.setEndBefore(end)
137
269
  range.deleteContents()
138
- end.before(marker.ownerDocument.createTextNode(value == null ? "" : String(value)))
270
+ end.before(marker.ownerDocument.createTextNode(text))
139
271
  }
140
272
 
141
273
  function evaluate(descriptor, item) {
@@ -189,19 +321,35 @@ function assertListItem(item) {
189
321
  if (!item || Array.isArray(item) || prototype !== Object.prototype) throw new Error("Keyed list items must be ordinary plain objects")
190
322
  }
191
323
 
192
- function assertListValue(value, seen) {
324
+ function seededListValue(item, fields, seed) {
325
+ const keys = Reflect.ownKeys(item)
326
+ if (keys.length !== fields.length || keys.some(key => typeof key !== "string" || !fields.includes(key))) return undefined
327
+ const values = []
328
+ for (const field of fields) {
329
+ const descriptor = Object.getOwnPropertyDescriptor(item, field)
330
+ if (!descriptor.enumerable) throw new Error("Keyed list items must not contain non-enumerable properties")
331
+ if (!("value" in descriptor)) throw new Error("Keyed list items must not contain accessors")
332
+ const value = descriptor.value
333
+ const type = value === null ? "null" : typeof value
334
+ if (type !== seed[field] || type === "number" && (!Number.isFinite(value) || Object.is(value, -0))) return undefined
335
+ values.push(value)
336
+ }
337
+ return JSON.stringify(values)
338
+ }
339
+
340
+ function assertListValue(value, seen, root = false) {
193
341
  if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0)) return
194
342
  if (!value || typeof value !== "object") throw new Error("Keyed list items must contain only JSON-safe values")
195
343
  if (seen.has(value)) throw new Error("Keyed list items must not contain cycles")
196
- const prototype = Object.getPrototypeOf(value)
197
- if (!Array.isArray(value) && prototype !== Object.prototype) throw new Error("Keyed list items must contain only arrays and ordinary plain objects")
198
- if (Object.getOwnPropertySymbols(value).length) throw new Error("Keyed list items must not contain symbols")
344
+ if (!root && !Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype) throw new Error("Keyed list items must contain only arrays and ordinary plain objects")
199
345
  seen.add(value)
200
- const descriptors = Object.getOwnPropertyDescriptors(value)
201
- if (Array.isArray(value) && Object.keys(descriptors).some(key => key !== "length" && !/^(0|[1-9]\d*)$/.test(key))) throw new Error("Keyed list arrays must not contain custom properties")
346
+ const keys = Reflect.ownKeys(value)
347
+ if (keys.some(key => typeof key === "symbol")) throw new Error("Keyed list items must not contain symbols")
348
+ if (Array.isArray(value) && keys.some(key => key !== "length" && !/^(0|[1-9]\d*)$/.test(key))) throw new Error("Keyed list arrays must not contain custom properties")
202
349
  if (Array.isArray(value) && Object.keys(value).length !== value.length) throw new Error("Keyed list arrays must not contain holes")
203
- for (const [key, descriptor] of Object.entries(descriptors)) {
350
+ for (const key of keys) {
204
351
  if (Array.isArray(value) && key === "length") continue
352
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
205
353
  if (!descriptor.enumerable) throw new Error("Keyed list items must not contain non-enumerable properties")
206
354
  if (!("value" in descriptor)) throw new Error("Keyed list items must not contain accessors")
207
355
  assertListValue(descriptor.value, seen)
@@ -1,6 +1,8 @@
1
- import { browserState, commitDom } from "./runtime.js"
1
+ import { browserState, commitDom, registerMountHook, registerUnmountHook } from "./shared-runtime.js"
2
2
  import { deserialize } from "./serialization.js"
3
3
 
4
+ const registrations = new WeakMap()
5
+
4
6
  export function createNativeContext(state, stateIds, commit, serializedScope = {}) {
5
7
  const changed = new Set()
6
8
  let scheduled = false
@@ -34,54 +36,44 @@ export function createNativeContext(state, stateIds, commit, serializedScope = {
34
36
  }
35
37
 
36
38
  if (typeof document !== "undefined") {
37
- const modules = new Map()
38
-
39
39
  const eventNames = ["click", "input", "change", "submit", "keydown", "keyup"]
40
- for (const eventName of eventNames) {
41
- document.addEventListener(eventName, event => {
42
- try {
43
- dispatchNative(event, snapshotNativeTargets(event, eventName), modules).catch(error => console.error(error))
44
- } catch (error) {
45
- console.error(error)
46
- }
47
- })
48
- }
40
+ const modules = new Map([])
41
+ const mount = root => mountNative(root, eventNames, modules)
42
+ registerMountHook(mount)
43
+ registerUnmountHook(unmountNative)
44
+ mount(document)
49
45
  }
50
46
 
51
- function snapshotNativeTargets(event, eventName) {
52
- const selector = `[data-k-native-${eventName}]`
53
- const targets = []
54
- for (let target = event.target.closest(selector); target; target = target.parentElement?.closest(selector)) {
55
- targets.push({ target, native: JSON.parse(target.dataset[`kNative${capitalize(eventName)}`]) })
47
+ function mountNative(root, eventNames, modules) {
48
+ for (const eventName of eventNames) {
49
+ for (const node of matching(root, `[data-k-native-${eventName}]`)) {
50
+ const listeners = registrations.get(node) ?? new Map()
51
+ if (listeners.has(eventName)) continue
52
+ const listener = event => {
53
+ try {
54
+ const native = JSON.parse(node.dataset[`kNative${capitalize(eventName)}`])
55
+ const result = modules.get(native.module)[native.handler](createNativeContext(browserState, native.states, commitDom, native.scope), event)
56
+ if (result && typeof result.then === "function") result.catch(error => console.error(error))
57
+ } catch (error) {
58
+ console.error(error)
59
+ }
60
+ }
61
+ node.addEventListener(eventName, listener)
62
+ listeners.set(eventName, listener)
63
+ registrations.set(node, listeners)
64
+ }
56
65
  }
57
- return targets
58
66
  }
59
67
 
60
- async function dispatchNative(event, targets, modules) {
61
- for (const { target, native } of targets) {
62
- let modulePromise = modules.get(native.module)
63
- if (!modulePromise) {
64
- modulePromise = import(native.module)
65
- modules.set(native.module, modulePromise)
66
- }
67
- try {
68
- const module = await modulePromise
69
- const result = module[native.handler](createNativeContext(browserState, native.states, commitDom, native.scope), delegatedEvent(event, target))
70
- if (result && typeof result.then === "function") result.catch(error => console.error(error))
71
- } catch (error) {
72
- console.error(error)
73
- }
68
+ function unmountNative(root) {
69
+ for (const node of matching(root, "*")) {
70
+ for (const [eventName, listener] of registrations.get(node) ?? []) node.removeEventListener(eventName, listener)
71
+ registrations.delete(node)
74
72
  }
75
73
  }
76
74
 
77
- function delegatedEvent(event, currentTarget) {
78
- return new Proxy(event, {
79
- get(source, property) {
80
- if (property === "currentTarget") return currentTarget
81
- const value = Reflect.get(source, property, source)
82
- return typeof value === "function" ? value.bind(source) : value
83
- }
84
- })
75
+ function matching(root, selector) {
76
+ return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
85
77
  }
86
78
 
87
79
  function capitalize(value) {
@@ -55,7 +55,7 @@ export function unmountDom(root) {
55
55
 
56
56
  if (typeof document !== "undefined") {
57
57
  const initialState = document.body.dataset.kState
58
- if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)
58
+ if (initialState) for (const [id, value, compact] of JSON.parse(initialState)) browserState.set(id, compact ? value[1].map(row => Object.fromEntries(value[0].map((field, index) => [field, row[index]]))) : value)
59
59
  mountText(document)
60
60
 
61
61
  const eventNames = ["click", "input", "change"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.4.3",
3
+ "version": "0.4.6",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",