@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 +13 -4
- package/framework/README.md +1 -1
- package/framework/binding-runtime.js +5 -2
- package/framework/build.mjs +44 -3
- package/framework/core.d.ts +1 -0
- package/framework/core.mjs +53 -8
- package/framework/list-runtime.js +60 -5
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
|
package/framework/README.md
CHANGED
|
@@ -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 === "
|
|
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)
|
package/framework/build.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
package/framework/core.d.ts
CHANGED
|
@@ -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>,
|
package/framework/core.mjs
CHANGED
|
@@ -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
|
|
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) {
|