@kudzujs/core 0.4.12 → 0.4.13

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
@@ -98,6 +98,14 @@ function increaseTwice() {
98
98
 
99
99
  The handler above increments by two and patches its bound DOM once. Inspect the generated plan at `.kudzu/kudzu-plan.json`.
100
100
 
101
+ State may also hold serializable plain objects. Property expressions in JSX text update directly:
102
+
103
+ ```tsx
104
+ const [weather, setWeather] = useState({ temperature: 28, label: "Warm" })
105
+
106
+ return <p>{weather.temperature}° {weather.label}</p>
107
+ ```
108
+
101
109
  ## Reactive Attributes
102
110
 
103
111
  `className`, `disabled`, controlled `value`, and controlled `checked` accept normal state-dependent TSX expressions. The same `value` binding works for inputs and selects:
@@ -209,6 +217,7 @@ const rows = items.map(item =>
209
217
  style={{ opacity: item.done ? 0.5 : 1 }}
210
218
  >
211
219
  {item.name.toUpperCase()}
220
+ {item.done ? <strong>Complete</strong> : <span>Pending</span>}
212
221
  <button onClick={() => setItems(items.filter(entry => entry.id !== item.id))}>Remove</button>
213
222
  </li>
214
223
  )
@@ -216,9 +225,9 @@ const rows = items.map(item =>
216
225
  return <ul>{rows}</ul>
217
226
  ```
218
227
 
219
- 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.
228
+ 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.
220
229
 
221
- 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>`.
230
+ 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>`.
222
231
 
223
232
  ## Normal JavaScript
224
233
 
@@ -335,14 +344,14 @@ The list starts with 1,000 keyed items, then updates every label, reverses the o
335
344
  | Framework | Initial content | Initial JS gzip | Total output | Build | Update | Reverse | Remove | Add | Operations total |
336
345
  |---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
337
346
  | Astro | Yes | **324 B** | **43.6 KB** | 826 ms | **4.1 ms** | **3.7 ms** | **1.4 ms** | **3.3 ms** | **12.5 ms** |
338
- | Kudzu | Yes | 5.0 KB | 60.3 KB | **452 ms** | 7.1 ms | 7.5 ms | 1.9 ms | 7.0 ms | 23.5 ms |
347
+ | Kudzu | Yes | 5.4 KB | 61.6 KB | **448 ms** | 8.6 ms | 8.3 ms | 2.4 ms | 8.6 ms | 27.9 ms |
339
348
  | Next.js | Yes | 182.2 KB | 695.2 KB | 3002 ms | 7.5 ms | 12.3 ms | 4.1 ms | 7.8 ms | 31.7 ms |
340
349
  | Vue CSR | No | 24.3 KB | 61.3 KB | 765 ms | 11.4 ms | 9.8 ms | 4.4 ms | 7.0 ms | 32.6 ms |
341
350
  | React CSR | No | 59.3 KB | 189.4 KB | 1039 ms | 9.9 ms | 13.4 ms | 4.7 ms | 6.1 ms | 34.1 ms |
342
351
  | Svelte CSR | No | 12.9 KB | 33.1 KB | 845 ms | 6.2 ms | 42.9 ms | 4.6 ms | 6.2 ms | 59.9 ms |
343
352
  | Qwik CSR | No | 22.2 KB | 64.1 KB | 630 ms | 10.7 ms | 27.5 ms | 39.2 ms | 22.2 ms | 99.6 ms |
344
353
 
345
- 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.5 ms, 11.0 ms behind the hand-authored Astro baseline and 10.6 ms ahead of React across all four operations.
354
+ 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 27.9 ms, 15.4 ms behind the hand-authored Astro baseline and 6.2 ms ahead of React across all four operations.
346
355
 
347
356
  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.
348
357
 
@@ -6,7 +6,7 @@
6
6
  - `runtime.js`: command-only runtime for direct state-to-text patches.
7
7
  - `shared-runtime.js`: command runtime with capability commit and DOM lifecycle hooks, emitted only when needed.
8
8
  - `binding-runtime.js`: optional generic attributes, form properties, and conditional range patches.
9
- - `list-runtime.js`: optional keyed list validation, external item-expression evaluation, dynamic styles and item-handler scopes, moves, and cleanup.
9
+ - `list-runtime.js`: optional keyed list validation, external item-expression evaluation, item-local conditional ranges, dynamic styles and item-handler scopes, moves, and cleanup.
10
10
  - `serialization.js`: capture deserialization shared by binding and native handlers.
11
11
  - `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
12
12
  - `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
@@ -9,11 +9,14 @@ const mountedBindings = new WeakSet()
9
9
  const mountedConditions = new WeakSet()
10
10
  const bindingRegistrations = new WeakMap()
11
11
  const conditionRegistrations = new WeakMap()
12
- const bindingTypes = ["class", "disabled", "value", "checked", "style"]
12
+ const bindingTypes = ["text", "class", "disabled", "value", "checked", "style"]
13
13
  const bindingSelector = [...bindingTypes.map(target => `[data-k-bind-${target}]`), "[data-k-bind-attrs]"].join(",")
14
14
 
15
15
  export function patchBinding(node, target, value) {
16
- if (target === "disabled") {
16
+ if (target === "text") {
17
+ const next = value == null ? "" : String(value)
18
+ if (node.textContent !== next) node.textContent = next
19
+ } else if (target === "disabled") {
17
20
  node.toggleAttribute("disabled", Boolean(value))
18
21
  } else if (target === "checked") {
19
22
  node.checked = Boolean(value)
@@ -328,6 +328,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
328
328
  const listLocalUses = new WeakMap()
329
329
  const listValues = new WeakMap()
330
330
  const listEventItems = new WeakMap()
331
+ const listConditions = new WeakMap()
331
332
  let usesBehavior = false
332
333
  let usesBinding = false
333
334
  let usesConditional = false
@@ -431,6 +432,15 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
431
432
  }
432
433
  }
433
434
 
435
+ if (ts.isJsxExpression(node) && node.expression && listConditions.has(node.expression)) {
436
+ const entry = listConditions.get(node.expression)
437
+ return factory.updateJsxExpression(node, compileListConditional({
438
+ ...entry,
439
+ truthy: ts.visitNode(entry.truthy, visitor),
440
+ falsy: ts.visitNode(entry.falsy, visitor)
441
+ }, factory, listExpressions, handlerUrl))
442
+ }
443
+
434
444
  if (ts.isJsxExpression(node) && node.expression && listValues.has(node.expression)) {
435
445
  return factory.updateJsxExpression(node, compileListValue(node.expression, listValues.get(node.expression), factory, listExpressions, handlerUrl))
436
446
  }
@@ -443,7 +453,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
443
453
  const listParts = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction))
444
454
  if (listParts) {
445
455
  if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
446
- validateKeyedList(listParts, sourceFile, settersForNode(node, settersByFunction), listValues, listEventItems)
456
+ validateKeyedList(listParts, sourceFile, settersForNode(node, settersByFunction), listValues, listEventItems, listConditions)
447
457
  usesBehavior = true
448
458
  usesList = true
449
459
  return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, [
@@ -466,6 +476,14 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
466
476
  return factory.updateJsxExpression(node, compiled)
467
477
  }
468
478
  }
479
+ const setters = settersForNode(node, settersByFunction)
480
+ const usedStates = referencedStateNames(node.expression, setters)
481
+ const captures = captureNames(node.expression, node.expression, setters)
482
+ if ((usedStates.size || captures.size) && !ts.isIdentifier(node.expression) && !containsJsx(node.expression)) {
483
+ usesBehavior = true
484
+ usesBinding = true
485
+ return factory.updateJsxExpression(node, compileReactiveBinding(node.expression, setters, factory, context, reactiveBindings, handlerUrl))
486
+ }
469
487
  }
470
488
 
471
489
  if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !/^on/i.test(node.name.getText()) && !["key", "ref", "dangerouslysetinnerhtml"].includes(node.name.getText().toLowerCase())) {
@@ -507,6 +525,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
507
525
  behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listExpression"), factory.createIdentifier("__kListExpression")))
508
526
  behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listField"), factory.createIdentifier("__kListField")))
509
527
  behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listItem"), factory.createIdentifier("__kListItem")))
528
+ behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listConditional"), factory.createIdentifier("__kListConditional")))
510
529
  }
511
530
  if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
512
531
  const behaviorImport = factory.createImportDeclaration(
@@ -536,7 +555,7 @@ function keyedListParts(expression, setters) {
536
555
  return { state, callback, root, item: callback.parameters[0].name.text, keyField: field }
537
556
  }
538
557
 
539
- function validateKeyedList(parts, sourceFile, setters, listValues, listEventItems) {
558
+ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItems, listConditions) {
540
559
  const fail = (node, message) => {
541
560
  const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
542
561
  throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
@@ -545,6 +564,7 @@ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItem
545
564
  const tag = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName
546
565
  if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(node, "Keyed list items must use intrinsic JSX elements")
547
566
  }
567
+ let conditionDepth = 0
548
568
  const visit = node => {
549
569
  if (ts.isJsxFragment(node)) fail(node, "Fragments are not supported in keyed lists")
550
570
  if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
@@ -556,7 +576,18 @@ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItem
556
576
  }
557
577
  if (ts.isJsxExpression(node) && node.expression) {
558
578
  const expression = unwrapExpression(node.expression)
559
- if (conditionalParts(expression) && containsJsx(expression)) fail(node, "Nested reactive conditions are not supported in keyed lists")
579
+ const condition = conditionalParts(expression)
580
+ if (condition && containsJsx(expression)) {
581
+ if (conditionDepth) fail(node, "Nested item conditions are not supported in keyed lists")
582
+ if (!referencesIdentifier(condition.condition, parts.item)) fail(node, "Keyed list item conditions must read the item")
583
+ validateListExpression(condition.condition, parts.item, node, fail)
584
+ listConditions.set(node.expression, { ...condition, item: parts.item })
585
+ conditionDepth++
586
+ visit(condition.truthy)
587
+ visit(condition.falsy)
588
+ conditionDepth--
589
+ return
590
+ }
560
591
  const field = directProperty(expression, parts.item)
561
592
  const isRootKey = ts.isJsxAttribute(node.parent) && node.parent.name.getText() === "key"
562
593
  if (field && ["__proto__", "constructor", "prototype"].includes(field)) fail(node, `Keyed list item property "${field}" is not supported`)
@@ -645,6 +676,16 @@ function compileListExpression(read, expression, item, factory, listExpressions,
645
676
  return factory.createCallExpression(factory.createIdentifier("__kListExpression"), undefined, [read, factory.createStringLiteral(handlerUrl), factory.createStringLiteral(exportName)])
646
677
  }
647
678
 
679
+ function compileListConditional(entry, factory, listExpressions, handlerUrl) {
680
+ const exportName = `listExpression${listExpressions.length}`
681
+ listExpressions.push({ exportName, expression: entry.condition, item: entry.item })
682
+ const read = factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), entry.condition)
683
+ const thunk = branch => factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), branch)
684
+ return factory.createCallExpression(factory.createIdentifier("__kListConditional"), undefined, [
685
+ factory.createStringLiteral(entry.kind), read, thunk(entry.truthy), thunk(entry.falsy), factory.createStringLiteral(handlerUrl), factory.createStringLiteral(exportName)
686
+ ])
687
+ }
688
+
648
689
  function compileListValue(expression, entry, factory, listExpressions, handlerUrl) {
649
690
  const read = factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), expression)
650
691
  return entry.field
@@ -24,6 +24,7 @@ export function list(items: unknown, keyField: string, render: (item: unknown) =
24
24
  export function listField(read: () => unknown, field: string): unknown
25
25
  export function listExpression(read: () => unknown, module: string, handler: string): unknown
26
26
  export function listItem(): unknown
27
+ export function listConditional(kind: "and" | "ternary", read: () => unknown, truthy: () => unknown, falsy: () => unknown, module: string, handler: string): unknown
27
28
 
28
29
  export function renderPage(
29
30
  component: (props: Record<string, never>) => unknown | Promise<unknown>,
@@ -9,6 +9,7 @@ const listMarker = Symbol("kudzu.list")
9
9
  const listFieldMarker = Symbol("kudzu.listField")
10
10
  const listExpressionMarker = Symbol("kudzu.listExpression")
11
11
  const listItemMarker = Symbol("kudzu.listItem")
12
+ const listConditionalMarker = Symbol("kudzu.listConditional")
12
13
  const refMarker = Symbol("kudzu.ref")
13
14
  const contextMarker = Symbol("kudzu.context")
14
15
  const contextProviderMarker = Symbol("kudzu.contextProvider")
@@ -123,6 +124,10 @@ export function listItem() {
123
124
  return { [listItemMarker]: true }
124
125
  }
125
126
 
127
+ export function listConditional(kind, read, truthy, falsy, module, handler) {
128
+ return { [listConditionalMarker]: true, kind, value: renderContext?.listTemplate ? undefined : read(), truthy, falsy, module, handler }
129
+ }
130
+
126
131
  function validListKey(key) {
127
132
  return typeof key === "string" || typeof key === "number" && Number.isFinite(key)
128
133
  }
@@ -216,7 +221,7 @@ function serializeCapture(name, value, seen) {
216
221
  }
217
222
 
218
223
  export async function renderPage(component, metadata = {}) {
219
- renderContext = { nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listFields: undefined, contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false, hasListStyles: false }
224
+ renderContext = { nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false, hasListStyles: false }
220
225
 
221
226
  try {
222
227
  const body = await renderNode({ type: component, props: {} })
@@ -348,15 +353,44 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
348
353
  }
349
354
  if (node?.[listMarker]) return renderList(node, namespace, selectValue)
350
355
  if (node?.[listFieldMarker]) {
351
- if (renderContext.listTemplate) renderContext.listFields?.add(node.field)
352
- const marker = renderContext.listTemplate ? ` data-k-list-text="${escapeAttribute(node.field)}"` : ""
356
+ if (renderContext.listTemplate || renderContext.listInitialMarkers || renderContext.listConditionalBranch) renderContext.listFields?.add(node.field)
357
+ const marker = renderContext.listTemplate || renderContext.listInitialMarkers || renderContext.listConditionalBranch ? ` data-k-list-text="${escapeAttribute(node.field)}"` : ""
353
358
  return `<template${marker}></template>${escapeHtml(node.value ?? "")}<template data-k-list-text-end></template>`
354
359
  }
355
360
  if (node?.[listExpressionMarker]) {
356
361
  const descriptor = { module: node.module, handler: node.handler }
357
- const marker = renderContext.listTemplate ? ` data-k-list-expression='${escapeJsonAttribute(descriptor)}'` : ""
362
+ const marker = renderContext.listTemplate || renderContext.listInitialMarkers || renderContext.listConditionalBranch ? ` data-k-list-expression='${escapeJsonAttribute(descriptor)}'` : ""
358
363
  return `<template${marker}></template>${escapeHtml(node.value ?? "")}<template data-k-list-expression-end></template>`
359
364
  }
365
+ if (node?.[bindingMarker]) {
366
+ const descriptor = bindingDescriptor(node)
367
+ const reactive = Object.keys(node.states).length > 0 || Object.keys(node.scopeStates).length > 0 || Object.keys(node.scopeBindings).length > 0
368
+ if (!reactive) return renderNode(node.value, namespace, selectValue)
369
+ renderContext.bindings.push({ target: "text", ...descriptor })
370
+ if (renderContext.conditionDepth || renderContext.listDepth) for (const stateId of reactiveStateIds(descriptor)) renderContext.conditionStates.add(stateId)
371
+ renderContext.hasBehaviors = true
372
+ renderContext.hasBindings = true
373
+ return `<span data-k-bind-text='${escapeJsonAttribute(descriptor)}'>${escapeHtml(node.value ?? "")}</span>`
374
+ }
375
+ if (node?.[listConditionalMarker]) {
376
+ const descriptor = { kind: node.kind, module: node.module, handler: node.handler }
377
+ const previousBranch = renderContext.listConditionalBranch
378
+ renderContext.listConditionalBranch = true
379
+ let truthy
380
+ let falsy
381
+ try {
382
+ truthy = await renderNode(node.truthy(), namespace, selectValue)
383
+ falsy = await renderNode(node.falsy(), namespace, selectValue)
384
+ } finally {
385
+ renderContext.listConditionalBranch = previousBranch
386
+ }
387
+ const key = conditionKey(node.kind, node.value)
388
+ const current = renderContext.listTemplate
389
+ ? ""
390
+ : node.kind === "and" && !node.value ? escapeHtml(renderFalsy(node.value)) : node.value ? truthy : falsy
391
+ const initial = renderContext.listTemplate ? "" : ` data-k-list-current="${escapeAttribute(key)}"`
392
+ return `<template data-k-list-condition='${escapeJsonAttribute(descriptor)}'${initial}><template data-k-list-true>${truthy}</template><template data-k-list-false>${falsy}</template></template>${current}<template data-k-list-condition-end></template>`
393
+ }
360
394
  if (!node || typeof node !== "object" || !("type" in node)) {
361
395
  throw new Error(`Cannot render ${String(node)}`)
362
396
  }
@@ -459,10 +493,10 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
459
493
  }
460
494
 
461
495
  if (attributeBindings.length) attributes += ` data-k-bind-attrs='${escapeJsonAttribute(attributeBindings)}'`
462
- if (renderContext.listTemplate && listAttributes.length) attributes += ` data-k-list-attrs='${escapeJsonAttribute(listAttributes)}'`
463
- if (renderContext.listTemplate && listExpressionAttributes.length) attributes += ` data-k-list-expression-attrs='${escapeJsonAttribute(listExpressionAttributes)}'`
464
- if (renderContext.listTemplate && listEvents.length) attributes += ` data-k-list-events='${escapeJsonAttribute(listEvents)}'`
465
- if (renderContext.listTemplate && directListText) {
496
+ if ((renderContext.listTemplate || renderContext.listInitialMarkers || renderContext.listConditionalBranch) && listAttributes.length) attributes += ` data-k-list-attrs='${escapeJsonAttribute(listAttributes)}'`
497
+ if ((renderContext.listTemplate || renderContext.listInitialMarkers || renderContext.listConditionalBranch) && listExpressionAttributes.length) attributes += ` data-k-list-expression-attrs='${escapeJsonAttribute(listExpressionAttributes)}'`
498
+ if ((renderContext.listTemplate || renderContext.listInitialMarkers || renderContext.listConditionalBranch) && listEvents.length) attributes += ` data-k-list-events='${escapeJsonAttribute(listEvents)}'`
499
+ if ((renderContext.listTemplate || renderContext.listInitialMarkers || renderContext.listConditionalBranch) && directListText) {
466
500
  renderContext.listFields?.add(directListText.field)
467
501
  attributes += ` data-k-list-text="${escapeAttribute(directListText.field)}"`
468
502
  }
@@ -487,10 +521,12 @@ async function renderList(node, namespace, selectValue) {
487
521
  renderContext.listRoot = { id, template: true }
488
522
  const template = await renderNode(node.render({}), namespace, selectValue)
489
523
  if (template.includes("data-k-native-")) descriptor.mount = true
524
+ if (template.includes("data-k-list-condition")) descriptor.conditions = true
490
525
  const seed = listSeed(node.items.value, renderContext.listFields)
491
526
  if (seed) descriptor.seed = seed
492
527
  let current = ""
493
528
  renderContext.listTemplate = false
529
+ renderContext.listInitialMarkers = Boolean(descriptor.conditions)
494
530
  for (const item of node.items.value) {
495
531
  renderContext.listRoot = { id, key: item[node.keyField], template: false }
496
532
  current += await renderNode(node.render(item), namespace, selectValue)
@@ -502,6 +538,7 @@ async function renderList(node, namespace, selectValue) {
502
538
  } finally {
503
539
  renderContext.listRoot = undefined
504
540
  renderContext.listTemplate = false
541
+ renderContext.listInitialMarkers = false
505
542
  renderContext.listFields = previousListFields
506
543
  renderContext.listDepth--
507
544
  }
@@ -512,6 +549,14 @@ function optionValue(props) {
512
549
  return Array.isArray(props.children) ? props.children.join("") : props.children ?? ""
513
550
  }
514
551
 
552
+ function conditionKey(kind, value) {
553
+ return value ? "true" : kind === "and" ? `false:${renderFalsy(value)}` : "false"
554
+ }
555
+
556
+ function renderFalsy(value) {
557
+ return value === false || value == null || value === true ? "" : String(value)
558
+ }
559
+
515
560
  function reactiveStateIds(descriptor) {
516
561
  if (descriptor.state) return new Set([descriptor.state])
517
562
  return new Set([
@@ -6,7 +6,8 @@ const mountedLists = new WeakSet()
6
6
  const imports = new Map()
7
7
  const revisions = new WeakMap()
8
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]"
9
+ const conditionOwners = new WeakMap()
10
+ const itemPartsSelector = "[data-k-list-text],[data-k-list-attrs],[data-k-list-events],[data-k-list-expression],[data-k-list-expression-attrs],[data-k-list-condition]"
10
11
 
11
12
  function commitLists(id) {
12
13
  const lists = listTargets.get(id)
@@ -32,7 +33,7 @@ function mountLists(root) {
32
33
  const roots = listRoots(start, end)
33
34
  const templateRoot = start.content.firstElementChild
34
35
  const parts = listItemPartPlan(templateRoot)
35
- for (const root of roots) mapListItemParts(parts, root)
36
+ for (const root of roots) descriptor.conditions ? listItemParts(root) : mapListItemParts(parts, root)
36
37
  if (descriptor.seed && !browserState.has(descriptor.state)) browserState.set(descriptor.state, roots.map((root, index) => seedListItem(root, descriptor, index)))
37
38
  const items = browserState.get(descriptor.state)
38
39
  const list = {
@@ -144,6 +145,10 @@ function fillListItem(root, item) {
144
145
  const revision = (revisions.get(root) ?? 0) + 1
145
146
  revisions.set(root, revision)
146
147
  const parts = listItemParts(root)
148
+ fillListParts(root, parts, item, revision)
149
+ }
150
+
151
+ function fillListParts(root, parts, item, revision) {
147
152
  for (const [node, field] of parts.directTexts) {
148
153
  const text = item?.[field]
149
154
  const value = text == null ? "" : String(text)
@@ -178,18 +183,27 @@ function fillListItem(root, item) {
178
183
  }).catch(error => console.error(error))
179
184
  }
180
185
  }
186
+ for (const [marker, descriptor] of parts.conditions) {
187
+ evaluate(descriptor, item).then(value => {
188
+ if (revisions.get(root) === revision && root.isConnected) updateListCondition(marker, descriptor.kind, value, item)
189
+ }).catch(error => console.error(error))
190
+ }
181
191
  }
182
192
 
183
193
  function listItemParts(root) {
184
194
  let parts = itemParts.get(root)
185
195
  if (parts) return parts
186
- parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [] }
196
+ parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [], conditions: [] }
187
197
  for (const node of matching(root, itemPartsSelector)) {
188
198
  if (node.hasAttribute("data-k-list-text")) (node.tagName === "TEMPLATE" ? parts.texts : parts.directTexts).push([node, node.dataset.kListText])
189
199
  if (node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
190
200
  if (node.hasAttribute("data-k-list-events")) parts.events.push([node, node.dataset.kListEvents])
191
201
  if (node.hasAttribute("data-k-list-expression")) parts.expressions.push([node, JSON.parse(node.dataset.kListExpression)])
192
202
  if (node.hasAttribute("data-k-list-expression-attrs")) parts.expressionAttributes.push([node, JSON.parse(node.dataset.kListExpressionAttrs)])
203
+ if (node.hasAttribute("data-k-list-condition")) {
204
+ parts.conditions.push([node, JSON.parse(node.dataset.kListCondition)])
205
+ conditionOwners.set(node, root)
206
+ }
193
207
  }
194
208
  itemParts.set(root, parts)
195
209
  return parts
@@ -205,7 +219,8 @@ function listItemPartPlan(template) {
205
219
  attributes: parts.attributes.map(([node, attributes]) => [indexes.get(node), attributes]),
206
220
  events: parts.events.map(([node, events]) => [indexes.get(node), events]),
207
221
  expressions: parts.expressions.map(([node, descriptor]) => [indexes.get(node), descriptor]),
208
- expressionAttributes: parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes])
222
+ expressionAttributes: parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes]),
223
+ conditions: parts.conditions.map(([node, descriptor]) => [indexes.get(node), descriptor])
209
224
  }
210
225
  }
211
226
 
@@ -217,10 +232,50 @@ function mapListItemParts(parts, root) {
217
232
  attributes: parts.attributes.map(([index, attributes]) => [target[index], attributes]),
218
233
  events: parts.events.map(([index, events]) => [target[index], events]),
219
234
  expressions: parts.expressions.map(([index, descriptor]) => [target[index], descriptor]),
220
- expressionAttributes: parts.expressionAttributes.map(([index, attributes]) => [target[index], attributes])
235
+ expressionAttributes: parts.expressionAttributes.map(([index, attributes]) => [target[index], attributes]),
236
+ conditions: parts.conditions.map(([index, descriptor]) => {
237
+ conditionOwners.set(target[index], root)
238
+ return [target[index], descriptor]
239
+ })
221
240
  })
222
241
  }
223
242
 
243
+ function updateListCondition(marker, kind, value, item) {
244
+ const current = listConditionKey(kind, value)
245
+ if (marker.dataset.kListCurrent === current) return
246
+ let end = marker.nextSibling
247
+ while (end && !(end.nodeType === Node.ELEMENT_NODE && end.matches("template[data-k-list-condition-end]"))) end = end.nextSibling
248
+ if (!end) throw new Error("Keyed list condition marker has no end")
249
+ for (let node = marker.nextSibling; node && node !== end;) {
250
+ const next = node.nextSibling
251
+ unmountDom(node)
252
+ node.remove()
253
+ node = next
254
+ }
255
+ const falseText = kind === "and" && !value ? renderFalsy(value) : ""
256
+ const fragment = falseText
257
+ ? marker.ownerDocument.createDocumentFragment()
258
+ : marker.content.querySelector(value ? "template[data-k-list-true]" : "template[data-k-list-false]").content.cloneNode(true)
259
+ if (falseText) fragment.append(marker.ownerDocument.createTextNode(falseText))
260
+ const nodes = [...fragment.childNodes]
261
+ const revision = (revisions.get(marker) ?? 0) + 1
262
+ revisions.set(marker, revision)
263
+ fillListParts(marker, listItemParts(fragment), item, revision)
264
+ end.parentNode.insertBefore(fragment, end)
265
+ marker.dataset.kListCurrent = current
266
+ const owner = conditionOwners.get(marker)
267
+ if (owner) itemParts.delete(owner)
268
+ for (const node of nodes) mountDom(node)
269
+ }
270
+
271
+ function listConditionKey(kind, value) {
272
+ return value ? "true" : kind === "and" ? `false:${renderFalsy(value)}` : "false"
273
+ }
274
+
275
+ function renderFalsy(value) {
276
+ return value === false || value == null || value === true ? "" : String(value)
277
+ }
278
+
224
279
  function listRoots(start, end) {
225
280
  const roots = []
226
281
  for (let node = start.nextSibling; node && node !== end; node = node.nextSibling) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",