@kudzujs/core 0.6.16 → 0.6.18

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
@@ -190,7 +190,7 @@ function Controls({ dispatch }: { dispatch: Dispatch<TodoAction> }) {
190
190
  return <Controls dispatch={dispatch} />
191
191
  ```
192
192
 
193
- Kudzu specializes that call at build time; no function prop or child component survives in the browser. The direct child may also be a keyed row such as `todos.map(todo => <Item key={todo.id} todo={todo} dispatch={dispatch} />)`. Its inline or simple `const` event handler receives the latest keyed item, while relative TypeScript constants and helpers used inside the handler are renamed for call-site safety and bundled into the parent handler graph. Lazy initializers, package, namespace, local, async, and generator reducers, package imports or child imports used outside event handlers, further dispatch forwarding, reducer-dispatch keyed-row effects or local state, and reducer dispatch through context remain unsupported.
193
+ Kudzu specializes that call at build time; no function prop or child component survives in the browser. Reducers follow React's pure reducer contract. The direct child may also be a keyed row such as `todos.map(todo => <Item key={todo.id} todo={todo} dispatch={dispatch} />)`. Its inline or simple `const` event handler receives the latest keyed item. That row may declare one top-level `useState` with a primitive literal initial value; the existing list key owns its state across item updates and reorder, and removal releases it so a later re-add starts from the initializer. Reactive attributes and conditional DOM use the existing binding capability. Relative TypeScript constants and helpers used inside the handler are renamed for call-site safety and bundled into the parent handler graph. Lazy state or reducer initializers, multiple or non-keyed specialized local states, package, namespace, local, async, and generator reducers, package imports or child imports used outside event handlers, further dispatch forwarding, reducer-dispatch keyed-row effects, and reducer dispatch through context remain unsupported.
194
194
 
195
195
  That specialized component may pass one inline or simple `const` callback containing dispatch to one relative-imported synchronous child with an intrinsic root:
196
196
 
@@ -737,7 +737,21 @@ Each keyed row owns one effect depending on `item.name`. The measured actions re
737
737
 
738
738
  This is a post-initialization runtime microbenchmark, not an architecture-equivalent loading comparison. Kudzu and Astro emit all 1,000 rows in HTML while React, Vue, and Svelte use empty CSR shells, so their JavaScript, output, and build columns are observations rather than framework-size or startup claims. Once every target has 1,000 rows and effects ready, targeted changed-root notification reduces Kudzu's selected update from 6.2 to 3.4 ms, versus Vue at 4.7 ms, Svelte at 5.2 ms, and React at 12.3 ms. It adds 126 B gzip to Kudzu's initial graph. List reconciliation remains O(n), which dominates unrelated-field updates; Vue measures 2.3 ms there versus Kudzu's 2.9 ms. Astro is the hand-written direct-DOM lower bound.
739
739
 
740
- The general benchmark snapshot was collected on July 22, 2026 and the keyed-effect comparison on July 27, 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.
740
+ ### 1,000-item Keyed Row State
741
+
742
+ Row 500 enters local edit state, the list reverses while preserving that row and its input DOM identity, then the row is removed and the same key is re-added with fresh non-editing state. Every target passed seven fresh-profile correctness runs; timings start at click and stop only after row order, unique IDs, labels, local state, and DOM identity match.
743
+
744
+ | Framework | Initial rows | Initial JS gzip | Total output | Build | Edit | Reverse | Remove | Re-add |
745
+ |---|---:|---:|---:|---:|---:|---:|---:|---:|
746
+ | Astro native | Yes | **373 B** | **83.7 KB** | 926 ms | **1.6 ms** | **5.9 ms** | **1.2 ms** | **1.4 ms** |
747
+ | Kudzu | Yes | 8.5 KB | 570.7 KB | **449 ms** | 2.7 ms | 10.1 ms | 3.0 ms | 3.1 ms |
748
+ | Vue CSR | No | 24.4 KB | 61.6 KB | 820 ms | 2.8 ms | 11.7 ms | 4.0 ms | 4.2 ms |
749
+ | Svelte CSR | No | 13.1 KB | 33.8 KB | 918 ms | 2.6 ms | 47.9 ms | 4.5 ms | 5.4 ms |
750
+ | React CSR | No | 59.4 KB | 189.5 KB | 1,074 ms | 5.9 ms | 26.4 ms | 9.0 ms | 6.5 ms |
751
+
752
+ Kudzu builds fastest and beats React, Vue, and Svelte on all four operations. Pure reducer identity fast paths skip unchanged-item validation for reorder, one removal, and append while preserving direct keyed DOM identity. Astro remains the hand-written native lower bound. Kudzu's 570.7 KB output includes complete initial HTML plus per-row direct-patch descriptors; React, Vue, and Svelte ship CSR shells, so deploy size and loading architecture are not equivalent comparisons.
753
+
754
+ The general benchmark snapshot was collected on July 22, 2026, the keyed-effect comparison on July 27, and keyed-row-state on July 28 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.
741
755
 
742
756
  ## Development
743
757
 
@@ -25,7 +25,7 @@ Inline SVG rendering normalizes an explicit set of common React presentation ali
25
25
 
26
26
  Same-file and relative-imported components receiving a direct local-state array prop are specialized to intrinsic JSX before keyed-list analysis, so their component function is not retained in the browser. Handler modules are emitted only when a rendered descriptor references them, preventing specialized imported components from adding dead browser assets. Direct JSON-safe primitive keyed-item dependencies subscribe each row record to its owning list commit and compare selected fields after `list-runtime.js` synchronously refreshes the row marker. Only changed rows rerun with the complete latest item; reorder compares equal, unrelated fields do nothing, and key changes remain remove plus mount. Builds without item dependencies emit no item reader or list-state subscription code.
27
27
 
28
- The reduced `useReducer` form reuses ordinary state slots. A direct dispatch in a compiled handler becomes a functional `set` whose reducer is bundled from a relative TypeScript module into that handler graph. One direct dispatch prop into a same-file or relative-imported synchronous component, including a direct keyed row, is specialized to intrinsic JSX at the call site, so its handler retains the parent reducer scope and no dispatch capture or child handler asset is emitted. A keyed-row handler reads the latest item through the existing list scope. Relative TypeScript imports referenced inside that child handler receive collision-free call-site aliases and join the parent handler graph. One nested relative-imported intrinsic child may receive an inline or simple `const` callback containing dispatch; the compiler recursively substitutes that callback once and omits the nested child handler asset. Missing primitive literal defaults in these reducer specializations are substituted at the same call site. Reducer-free routes and shared runtimes are unchanged; no reducer runtime or browser component instance exists.
28
+ The reduced `useReducer` form reuses ordinary state slots and React's pure reducer contract. A direct dispatch in a compiled handler becomes a functional `set` whose reducer is bundled from a relative TypeScript module into that handler graph. Pure reducer-owned keyed lists reuse unchanged item identities for reorder, one removal, and append fast paths; ordinary `useState` lists retain full validation. One direct dispatch prop into a same-file or relative-imported synchronous component, including a direct keyed row, is specialized to intrinsic JSX at the call site, so its handler retains the parent reducer scope and no dispatch capture or child handler asset is emitted. A keyed-row handler reads the latest item through the existing list scope. It may declare one top-level primitive-literal `useState`; compiler-generated state IDs combine its hook slot and list key, preserving state across updates and reorder while row unmount deletes it. Existing event, binding, and conditional capabilities consume those IDs. Relative TypeScript imports referenced inside that child handler receive collision-free call-site aliases and join the parent handler graph. One nested relative-imported intrinsic child may receive an inline or simple `const` callback containing dispatch; the compiler recursively substitutes that callback once and omits the nested child handler asset. Missing primitive literal defaults in these reducer specializations are substituted at the same call site. Reducer-free routes and shared runtimes are unchanged; no reducer runtime or browser component instance exists.
29
29
 
30
30
  `kudzu.config` may opt one emitted shared-layout group into same-document navigation with legacy `navigation: { routes: ["/product", "/items/[id]"] }`, or multiple groups with `navigation: { groups: [{ routes: [...] }, { routes: [...] }] }`. The forms are mutually exclusive. Identities are globally unique emitted exact paths or `runtimeParams` patterns; each group uses one page-exported layout function identity. Runtime records securely match concrete pathnames under `base`, and their cache-safe parameter initializer runs before route DOM/effects mount on every transition. Each group receives a deterministic route-hashed asset specialized to only its records, pattern decoder, and effect/parameter lifecycle needs. Cross-group and ungrouped anchors remain native and are not prefetched; overlapping path domains across groups fail the build. Route effect entries export cache-safe layout and route mount functions: layout effects, including conditional/keyed DOM-owned effects, persist for the group session; route effects receive a fresh owner registry after each route insertion; and non-persisted page disposal cleans route before layout. Direct primitive state, runtime parameter, and keyed-item property dependencies and cleanup are supported. Fragment payloads and coordinated View Transitions are not implemented.
31
31
 
@@ -30,7 +30,8 @@ export function patchBinding(node, target, value) {
30
30
  } else if (target === "class" && (value == null || value === false)) {
31
31
  node.removeAttribute("class")
32
32
  } else if (target === "class") {
33
- node.setAttribute("class", String(value))
33
+ if (node.namespaceURI === "http://www.w3.org/1999/xhtml") node.className = String(value)
34
+ else node.setAttribute("class", String(value))
34
35
  } else if (value == null || (value === false && !isStringBooleanAttribute(target))) {
35
36
  node.removeAttribute(target)
36
37
  } else {
@@ -77,7 +78,7 @@ function mountBindings(root) {
77
78
  if (node.dataset.kBindAttrs) descriptors.push(...JSON.parse(node.dataset.kBindAttrs).map(({ target, ...descriptor }) => [target, descriptor]))
78
79
  for (const [target, descriptor] of descriptors) {
79
80
  if (descriptor.state) {
80
- const binding = { node, target, read: () => browserState.get(descriptor.state) }
81
+ const binding = { node, target, read: Object.hasOwn(descriptor, "truthy") ? () => browserState.get(descriptor.state) ? descriptor.truthy : descriptor.falsy : () => browserState.get(descriptor.state) }
81
82
  register(bindingTargets, descriptor.state, binding)
82
83
  registrations.push([descriptor.state, binding])
83
84
  patchBinding(node, target, binding.read())
@@ -124,8 +125,8 @@ function mountConditions(root) {
124
125
  const truthy = start.content.querySelector("template[data-k-true]")
125
126
  const falsy = start.content.querySelector("template[data-k-false]")
126
127
  if (!end || !truthy || !falsy) continue
127
- const condition = { start, end, truthy, falsy, kind: descriptor.kind, current: conditionKey(descriptor.kind, descriptor.initial) }
128
- loadEvaluator(descriptor).then(evaluator => {
128
+ const condition = { start, end, truthy, falsy, kind: descriptor.kind, current: conditionKey(descriptor.kind, descriptor.initial), mount: descriptor.mount }
129
+ const mount = evaluator => {
129
130
  if (!start.isConnected) return
130
131
  condition.read = evaluator.read
131
132
  const registrations = []
@@ -135,7 +136,9 @@ function mountConditions(root) {
135
136
  }
136
137
  conditionRegistrations.set(start, { condition, registrations })
137
138
  updateCondition(condition)
138
- }).catch(error => console.error(error))
139
+ }
140
+ if (descriptor.state) mount({ read: () => browserState.get(descriptor.state), stateIds: [descriptor.state] })
141
+ else loadEvaluator(descriptor).then(mount).catch(error => console.error(error))
139
142
  }
140
143
  }
141
144
 
@@ -143,16 +146,16 @@ function updateCondition(condition) {
143
146
  const value = condition.read()
144
147
  const next = conditionKey(condition.kind, value)
145
148
  if (next === condition.current) return
146
- removeConditionRange(condition.start, condition.end)
149
+ removeConditionRange(condition.start, condition.end, condition.mount)
147
150
  const truthy = Boolean(value)
148
151
  const falseText = condition.kind === "and" && !truthy ? renderFalsy(value) : ""
149
152
  const fragment = falseText
150
153
  ? textFragment(condition.end.ownerDocument, falseText)
151
154
  : (truthy ? condition.truthy : condition.falsy).content.cloneNode(true)
152
- const nodes = [...fragment.childNodes]
155
+ const nodes = condition.mount ? [...fragment.childNodes] : undefined
153
156
  condition.end.parentNode.insertBefore(fragment, condition.end)
154
157
  condition.current = next
155
- for (const node of nodes) mountDom(node)
158
+ if (condition.mount) for (const node of nodes) mountDom(node)
156
159
  const select = condition.start.closest("select[data-k-bind-value]")
157
160
  for (const binding of new Set((bindingRegistrations.get(select) ?? []).map(([, entry]) => entry))) {
158
161
  patchBinding(binding.node, binding.target, binding.read())
@@ -183,13 +186,16 @@ function unmountConditions(root) {
183
186
  }
184
187
  }
185
188
 
186
- function removeConditionRange(start, end) {
189
+ function removeConditionRange(start, end, mount) {
190
+ if (start.nextSibling === end) return
187
191
  const range = start.ownerDocument.createRange()
188
192
  range.setStartAfter(start)
189
193
  range.setEndBefore(end)
190
- const root = range.commonAncestorContainer
191
- const nodes = matching(root, "*").filter(node => range.comparePoint(node, 0) === 0)
192
- for (const node of nodes) if (!nodes.some(parent => parent !== node && parent.contains(node))) unmountDom(node)
194
+ if (mount) {
195
+ const root = range.commonAncestorContainer
196
+ const nodes = matching(root, "*").filter(node => range.comparePoint(node, 0) === 0)
197
+ for (const node of nodes) if (!nodes.some(parent => parent !== node && parent.contains(node))) unmountDom(node)
198
+ }
193
199
  range.deleteContents()
194
200
  }
195
201
 
@@ -208,6 +214,9 @@ function textFragment(document, value) {
208
214
  }
209
215
 
210
216
  function findEnd(start, id) {
217
+ for (let node = start.nextSibling; node; node = node.nextSibling) {
218
+ if (node.nodeType === Node.ELEMENT_NODE && node.matches("template[data-k-if-end]") && node.dataset.kIfEnd === id) return node
219
+ }
211
220
  return [...start.ownerDocument.querySelectorAll("template[data-k-if-end]")]
212
221
  .find(node => node.dataset.kIfEnd === id)
213
222
  }
@@ -189,6 +189,7 @@ export async function build({ quiet = false, minify = true } = {}) {
189
189
  const hasListExpressionAttributes = plans.some(plan => plan.lists.some(list => list.expressionAttributes))
190
190
  const hasListSeeds = plans.some(plan => plan.lists.some(list => list.seed))
191
191
  const hasListEffects = plans.some(plan => plan.lists.some(list => list.effects))
192
+ const hasListRowStates = plans.some(plan => plan.lists.some(list => list.rowStates))
192
193
  const hasItemDependencies = plans.some(plan => plan.effects.some(effect => effect.itemDependencies?.length))
193
194
  const hasListAsyncParts = hasListExpressions || hasListExpressionAttributes || hasListConditions
194
195
  const hasListMounts = hasListConditions || plans.some(plan => plan.lists.some(list => list.mount))
@@ -270,7 +271,8 @@ export async function build({ quiet = false, minify = true } = {}) {
270
271
  __KUDZU_LIST_EFFECTS__: String(hasListEffects),
271
272
  __KUDZU_LIST_ASYNC_PARTS__: String(hasListAsyncParts),
272
273
  __KUDZU_LIST_MOUNTS__: String(hasListMounts),
273
- __KUDZU_LIST_ITEM_HOOKS__: String(hasItemDependencies)
274
+ __KUDZU_LIST_ITEM_HOOKS__: String(hasItemDependencies),
275
+ __KUDZU_LIST_ROW_STATES__: String(hasListRowStates)
274
276
  })
275
277
  }
276
278
  if (hasNativeHandlers) {
@@ -1629,6 +1631,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1629
1631
  let usesList = false
1630
1632
  let usesListEffects = false
1631
1633
  let usesListItem = false
1634
+ let usesRowState = false
1632
1635
 
1633
1636
  const collect = node => {
1634
1637
  if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) {
@@ -1736,10 +1739,26 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1736
1739
  }
1737
1740
  const componentSpecializations = new WeakMap()
1738
1741
  const reducerComponentCalls = new WeakSet()
1742
+ const reducerRowStateCalls = []
1739
1743
  const specializedDeclarations = new WeakSet()
1740
1744
  const stateBackedComponentFunctions = new WeakSet()
1741
1745
  const stateBackedComponentRoots = []
1742
1746
  let specializedImportIndex = 0
1747
+ const registerReducerRowState = (call, specialization) => {
1748
+ if (!specialization.rowState) return
1749
+ let owner
1750
+ for (let current = call.parent; current; current = current.parent) {
1751
+ if (isFunctionLike(current) && reducersByFunction.has(current)) {
1752
+ owner = current
1753
+ break
1754
+ }
1755
+ }
1756
+ const setters = settersByFunction.get(owner) ?? new Map()
1757
+ setters.set(specialization.rowState.setter, specialization.rowState.state)
1758
+ settersByFunction.set(owner, setters)
1759
+ reducerRowStateCalls.push(call)
1760
+ usesRowState = true
1761
+ }
1743
1762
  const mergeSpecializedImports = (root, componentSource, call) => {
1744
1763
  const componentImports = clientImportBindings(componentSource, componentSource.fileName, sourceFiles)
1745
1764
  for (const name of runtimeImportNames(componentSource, false)) if (referenceIdentifiers(root, name).length) fail(call, "Imported specialized component handlers may only use relative TypeScript runtime imports")
@@ -1837,6 +1856,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1837
1856
  if (componentSpecializations.has(call)) fail(call, "Reducer dispatch props cannot be combined with another component specialization")
1838
1857
  const specialization = specializeComponentCall(call, component.function, sourceFile, factory, context, fail, "Reducer-dispatch")
1839
1858
  if (specialization.effects.length) fail(call, "Reducer-dispatch components cannot declare effects")
1859
+ registerReducerRowState(call, specialization)
1840
1860
  specialization.root = expandReducerCallbacks(specialization.root, component.function.getSourceFile(), call)
1841
1861
  componentSpecializations.set(call, specialization)
1842
1862
  reducerComponentCalls.add(call)
@@ -1860,6 +1880,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1860
1880
  if (componentSpecializations.has(call)) fail(call, "Reducer dispatch props cannot be combined with another component specialization")
1861
1881
  const specialization = specializeComponentCall(call, component, sourceFile, factory, context, fail, "Reducer-dispatch")
1862
1882
  if (specialization.effects.length) fail(call, "Reducer-dispatch components cannot declare effects")
1883
+ registerReducerRowState(call, specialization)
1863
1884
  specialization.root = expandReducerCallbacks(specialization.root, componentSource, call)
1864
1885
  specialization.root = mergeSpecializedImports(specialization.root, componentSource, call)
1865
1886
  synthesizeTree(specialization.root)
@@ -1896,6 +1917,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1896
1917
  return tag && ts.isIdentifier(tag) && tag.text[0] === tag.text[0].toUpperCase() ? [tag.text] : []
1897
1918
  }))
1898
1919
  const keyedComponentCalls = new Set(rawRenderedLists.map(({ parts }) => parts.root))
1920
+ for (const call of reducerRowStateCalls) if (!keyedComponentCalls.has(call)) fail(call, "Reducer-dispatch component useState() is only supported in a direct keyed row")
1899
1921
  for (const name of listComponentNames) {
1900
1922
  let component = components.get(name)
1901
1923
  const local = Boolean(component)
@@ -1935,6 +1957,15 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1935
1957
  originalParts.callback.equalsGreaterThanToken,
1936
1958
  root
1937
1959
  )
1960
+ if (specialization?.stateDeclarations.length) callback = factory.updateArrowFunction(
1961
+ callback,
1962
+ callback.modifiers,
1963
+ callback.typeParameters,
1964
+ callback.parameters,
1965
+ callback.type,
1966
+ callback.equalsGreaterThanToken,
1967
+ factory.createBlock([...specialization.stateDeclarations, factory.createReturnStatement(root)], true)
1968
+ )
1938
1969
  if (callback !== originalParts.callback) {
1939
1970
  ts.setParentRecursive(callback, false)
1940
1971
  callback.parent = originalParts.callback.parent
@@ -1945,7 +1976,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1945
1976
  calculation.parent = callback
1946
1977
  validateListExpression(calculation, parts.item, originalParts.root, fail)
1947
1978
  }
1948
- validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions)
1979
+ validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, settersForNode(originalParts.root, settersByFunction), specialization?.rowState)
1949
1980
  if (specialization?.effects.length) {
1950
1981
  usesListEffects = true
1951
1982
  const statements = specialization.effects.map(entry => {
@@ -2070,7 +2101,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2070
2101
  ])
2071
2102
  }
2072
2103
 
2073
- if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && (node.initializer.expression.text === "useState" && node.initializer.arguments.length === 1 || node.initializer.expression.text === "useReducer" && node.initializer.arguments.length === 2)) {
2104
+ if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && ((node.initializer.expression.text === "useState" || node.initializer.expression.text === "__kRowUseState") && node.initializer.arguments.length === 1 || node.initializer.expression.text === "useReducer" && node.initializer.arguments.length === 2)) {
2074
2105
  const stateElement = node.name.elements[0]
2075
2106
  if (!stateElement || !ts.isBindingElement(stateElement) || !ts.isIdentifier(stateElement.name)) return node
2076
2107
  const initializer = factory.updateCallExpression(node.initializer, node.initializer.expression, node.initializer.typeArguments, [
@@ -2170,8 +2201,14 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2170
2201
 
2171
2202
  const behaviorImports = [factory.createImportSpecifier(false, factory.createIdentifier("behavior"), factory.createIdentifier("__kBehavior"))]
2172
2203
  if (nativeHandlers.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("nativeBehavior"), factory.createIdentifier("__kNativeBehavior")))
2173
- if (usesBinding) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("binding"), factory.createIdentifier("__kBinding")))
2174
- if (usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("conditional"), factory.createIdentifier("__kConditional")))
2204
+ if (usesBinding) {
2205
+ behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("binding"), factory.createIdentifier("__kBinding")))
2206
+ behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("select"), factory.createIdentifier("__kSelect")))
2207
+ }
2208
+ if (usesConditional) {
2209
+ behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("conditional"), factory.createIdentifier("__kConditional")))
2210
+ behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("stateConditional"), factory.createIdentifier("__kStateConditional")))
2211
+ }
2175
2212
  if (usesList) {
2176
2213
  behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("list"), factory.createIdentifier("__kList")))
2177
2214
  behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listExpression"), factory.createIdentifier("__kListExpression")))
@@ -2181,6 +2218,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2181
2218
  }
2182
2219
  if (usesListItem && !usesList) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listItem"), factory.createIdentifier("__kListItem")))
2183
2220
  if (usesListEffects) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useEffect"), factory.createIdentifier("__kListUseEffect")))
2221
+ if (usesRowState) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useState"), factory.createIdentifier("__kRowUseState")))
2184
2222
  if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
2185
2223
  const behaviorImport = factory.createImportDeclaration(
2186
2224
  undefined,
@@ -2399,7 +2437,7 @@ function insideJsxEventHandler(node, root) {
2399
2437
  return false
2400
2438
  }
2401
2439
 
2402
- function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions) {
2440
+ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, setters, rowState) {
2403
2441
  const fail = (node, message) => {
2404
2442
  throw sourceNodeError(node, sourceFile, message)
2405
2443
  }
@@ -2424,6 +2462,13 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
2424
2462
  const condition = conditionalParts(expression)
2425
2463
  if (condition && containsJsx(expression)) {
2426
2464
  if (conditionDepth) fail(node, "Nested item conditions are not supported in keyed lists")
2465
+ if (rowState && referencedStateNames(condition.condition, setters).has(rowState.state)) {
2466
+ conditionDepth++
2467
+ visit(condition.truthy)
2468
+ visit(condition.falsy)
2469
+ conditionDepth--
2470
+ return
2471
+ }
2427
2472
  if (!referencesIdentifier(condition.condition, item)) fail(node, "Keyed list item conditions must read the item")
2428
2473
  validateListExpression(condition.condition, item, node, fail)
2429
2474
  listConditions.set(node.expression, { ...condition, item })
@@ -2489,6 +2534,8 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
2489
2534
  let returned
2490
2535
  const calculations = []
2491
2536
  const effectCalls = []
2537
+ const stateDeclarations = []
2538
+ let rowState
2492
2539
  if (!ts.isBlock(component.body)) {
2493
2540
  returned = component.body
2494
2541
  } else {
@@ -2502,6 +2549,25 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
2502
2549
  }
2503
2550
  if (!ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0 || statement.declarationList.declarations.length !== 1) fail(statement, `${label} component locals must be single const declarations`)
2504
2551
  const declaration = statement.declarationList.declarations[0]
2552
+ if (declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "useState") {
2553
+ if (label !== "Reducer-dispatch") fail(declaration, `${label} components cannot declare local state`)
2554
+ if (rowState) throw sourceNodeError(declaration, component.getSourceFile(), "Reducer-dispatch keyed row components may declare exactly one top-level useState()")
2555
+ if (declaration.initializer.arguments.length !== 1 || !isPrimitiveDefaultLiteral(declaration.initializer.arguments[0])) throw sourceNodeError(declaration.initializer, component.getSourceFile(), "Reducer-dispatch keyed row useState() must use one primitive literal initial value; lazy initialization is not supported")
2556
+ if (!ts.isArrayBindingPattern(declaration.name) || declaration.name.elements.length !== 2 || declaration.name.elements.some(element => !element || !ts.isBindingElement(element) || !ts.isIdentifier(element.name) || element.initializer || element.dotDotDotToken)) throw sourceNodeError(declaration.name, component.getSourceFile(), "Reducer-dispatch keyed row useState() must use [state, setter] identifier destructuring")
2557
+ const suffix = Math.max(0, call.pos)
2558
+ const state = `__kRowState${suffix}`
2559
+ const setter = `__kRowSetter${suffix}`
2560
+ substitutions.set(declaration.name.elements[0].name.text, factory.createIdentifier(state))
2561
+ substitutions.set(declaration.name.elements[1].name.text, factory.createIdentifier(setter))
2562
+ const binding = factory.createArrayBindingPattern([
2563
+ factory.createBindingElement(undefined, undefined, factory.createIdentifier(state)),
2564
+ factory.createBindingElement(undefined, undefined, factory.createIdentifier(setter))
2565
+ ])
2566
+ const initializer = factory.createCallExpression(factory.createIdentifier("__kRowUseState"), undefined, [cloneAst(declaration.initializer.arguments[0], factory, context)])
2567
+ stateDeclarations.push(factory.createVariableStatement(undefined, factory.createVariableDeclarationList([factory.createVariableDeclaration(binding, undefined, undefined, initializer)], ts.NodeFlags.Const)))
2568
+ rowState = { state, setter }
2569
+ continue
2570
+ }
2505
2571
  if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration, `${label} component locals must be initialized identifiers`)
2506
2572
  const calculation = substituteClone(declaration.initializer, substitutions, factory, context)
2507
2573
  calculations.push({ name: declaration.name.text, expression: calculation })
@@ -2509,6 +2575,15 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
2509
2575
  }
2510
2576
  returned = last.expression
2511
2577
  }
2578
+ let unsupportedState
2579
+ const findUnsupportedState = node => {
2580
+ if (unsupportedState) return
2581
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useState") unsupportedState = node
2582
+ ts.forEachChild(node, findUnsupportedState)
2583
+ }
2584
+ findUnsupportedState(returned)
2585
+ for (const calculation of calculations) findUnsupportedState(calculation.expression)
2586
+ if (unsupportedState) throw sourceNodeError(unsupportedState, component.getSourceFile(), label === "Reducer-dispatch" ? "Reducer-dispatch keyed row useState() must be one top-level const declaration" : `${label} components cannot declare local state`)
2512
2587
  let root = unwrapExpression(substituteClone(returned, substitutions, factory, context))
2513
2588
  if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(returned, `${label} component must return one JSX element`)
2514
2589
  const tag = jsxTagName(root)
@@ -2524,7 +2599,9 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
2524
2599
  calculations: calculations
2525
2600
  .filter(calculation => label !== "Reducer-dispatch" || !isFunctionLike(calculation.expression) || !isEventOnlyComponentLocal(returned, calculation.name))
2526
2601
  .map(calculation => calculation.expression),
2527
- effects
2602
+ effects,
2603
+ stateDeclarations,
2604
+ rowState
2528
2605
  }
2529
2606
  }
2530
2607
 
@@ -2801,15 +2878,27 @@ function isJsxLocalValue(expression, known) {
2801
2878
  }
2802
2879
 
2803
2880
  function compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl) {
2881
+ const parts = conditionalParts(expression)
2882
+ const state = parts && directStateIdentifier(parts.condition, setters)
2883
+ if (state && isPrimitiveDefaultLiteral(parts.truthy) && isPrimitiveDefaultLiteral(parts.falsy)) {
2884
+ return factory.createCallExpression(factory.createIdentifier("__kSelect"), undefined, [state, parts.truthy, parts.falsy])
2885
+ }
2804
2886
  return factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl))
2805
2887
  }
2806
2888
 
2807
2889
  function compileConditional(kind, expression, truthy, falsy, setters, factory, context, reactiveBindings, handlerUrl) {
2808
- const [initial, ...descriptor] = compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl)
2890
+ const state = directStateIdentifier(expression, setters)
2809
2891
  const thunk = branch => factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), branch)
2892
+ if (state) return factory.createCallExpression(factory.createIdentifier("__kStateConditional"), undefined, [factory.createStringLiteral(kind), state, thunk(truthy), thunk(falsy)])
2893
+ const [initial, ...descriptor] = compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl)
2810
2894
  return factory.createCallExpression(factory.createIdentifier("__kConditional"), undefined, [factory.createStringLiteral(kind), initial, thunk(truthy), thunk(falsy), ...descriptor])
2811
2895
  }
2812
2896
 
2897
+ function directStateIdentifier(expression, setters) {
2898
+ const value = unwrapExpression(expression)
2899
+ return ts.isIdentifier(value) && new Set(setters.values()).has(value.text) ? value : undefined
2900
+ }
2901
+
2813
2902
  function compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl) {
2814
2903
  const usedStates = referencedStateNames(expression, setters)
2815
2904
  const captures = captureNames(expression, expression, setters)
@@ -3,6 +3,7 @@ import { serializeStyle } from "./style.js"
3
3
  const signalMarker = Symbol("kudzu.signal")
4
4
  const setterMarker = Symbol("kudzu.setter")
5
5
  const reducerDispatchMarker = Symbol("kudzu.reducerDispatch")
6
+ const reducerStateMarker = Symbol("kudzu.reducerState")
6
7
  const behaviorMarker = Symbol("kudzu.behavior")
7
8
  const nativeBehaviorMarker = Symbol("kudzu.nativeBehavior")
8
9
  const bindingMarker = Symbol("kudzu.binding")
@@ -47,20 +48,21 @@ export function useState(initialValue, name) {
47
48
  throw new Error("useState() can only run while rendering a Kudzu component")
48
49
  }
49
50
 
50
- const id = nextRenderId("s")
51
+ const id = renderContext.listRoot || renderContext.listRowRoot ? nextRowRenderId("s", initialValue) : nextRenderId("s")
51
52
  const signal = createSignal(id, initialValue)
52
53
 
53
54
  const setter = () => {
54
55
  throw new Error("State setters are compiled into ordered browser behaviors")
55
56
  }
56
57
  Object.defineProperty(setter, setterMarker, { value: id })
57
- renderContext.states[id] = { name: name ?? id, initialValue, ...(renderContext.scoped ? { lifetime: renderContext.renderScope } : {}) }
58
+ if (!renderContext.listTemplate) renderContext.states[id] = { name: name ?? id, initialValue, ...(renderContext.scoped ? { lifetime: renderContext.renderScope } : {}) }
58
59
  return [signal, setter]
59
60
  }
60
61
 
61
62
  export function useReducer(reducer, initialValue, name) {
62
63
  if (typeof reducer !== "function") throw new Error("useReducer() requires a reducer function")
63
64
  const [state] = useState(initialValue, name)
65
+ Object.defineProperty(state, reducerStateMarker, { value: true })
64
66
  const dispatch = () => {
65
67
  throw new Error("Reducer dispatches are compiled into browser handlers")
66
68
  }
@@ -198,10 +200,20 @@ export function binding(value, module, handler, states, scope) {
198
200
  return { [bindingMarker]: true, value, ...reactiveDescriptor(module, handler, states, scope) }
199
201
  }
200
202
 
203
+ export function select(state, truthy, falsy) {
204
+ if (!state?.[signalMarker]) throw new Error("A reactive selection must target framework state")
205
+ return { [bindingMarker]: true, value: state.value ? truthy : falsy, state: state.id, truthy, falsy }
206
+ }
207
+
201
208
  export function conditional(kind, value, truthy, falsy, module, handler, states, scope) {
202
209
  return { [conditionalMarker]: true, kind, value, truthy, falsy, ...reactiveDescriptor(module, handler, states, scope) }
203
210
  }
204
211
 
212
+ export function stateConditional(kind, state, truthy, falsy) {
213
+ if (!state?.[signalMarker]) throw new Error("A reactive conditional must target framework state")
214
+ return { [conditionalMarker]: true, kind, value: state.value, truthy, falsy, state: state.id }
215
+ }
216
+
205
217
  export function list(items, keyField, render) {
206
218
  if (!items?.[signalMarker] || !Array.isArray(items.value)) throw new Error("A keyed list must use local array state")
207
219
  const keys = new Set()
@@ -294,6 +306,7 @@ export function bindingValue(value) {
294
306
  }
295
307
 
296
308
  function bindingDescriptor(value) {
309
+ if (value.state) return { state: value.state, ...(Object.hasOwn(value, "truthy") ? { truthy: value.truthy, falsy: value.falsy } : {}) }
297
310
  return { module: value.module, handler: value.handler, states: value.states, scope: value.scope, scopeStates: value.scopeStates, scopeBindings: value.scopeBindings }
298
311
  }
299
312
 
@@ -334,7 +347,7 @@ function serializeCapture(name, value, seen) {
334
347
  }
335
348
 
336
349
  export async function renderPage(component, metadata = {}, props = {}, layout) {
337
- renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], effectOwners: [], contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerModules: new Set(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
350
+ renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowConditions: [], effectOwners: [], contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerModules: new Set(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
338
351
 
339
352
  try {
340
353
  const page = { [routeScopeMarker]: true, component, props }
@@ -507,12 +520,13 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
507
520
  if (!stateIds.size) return renderNode(node.value ? node.truthy() : node.falsy(), namespace, selectValue)
508
521
  if (namespace) throw new Error(`Reactive conditional DOM is not supported inside ${namespace}`)
509
522
 
510
- const id = nextRenderId("c")
523
+ const id = renderContext.listRoot || renderContext.listRowRoot ? nextRowRenderId("c") : nextRenderId("c")
511
524
  renderContext.conditionDepth++
512
525
  const truthy = await renderNode(node.truthy(), namespace, selectValue)
513
526
  const falsy = await renderNode(node.falsy(), namespace, selectValue)
514
527
  renderContext.conditionDepth--
515
- const metadata = { id, kind: node.kind, initial: node.value, ...descriptor }
528
+ const mount = truthy.includes("data-k-") || falsy.includes("data-k-") || truthy.includes("<!--k-text:") || falsy.includes("<!--k-text:")
529
+ const metadata = { id, kind: node.kind, initial: node.value, ...descriptor, ...(mount ? { mount: true } : {}) }
516
530
  for (const stateId of stateIds) renderContext.conditionStates.add(stateId)
517
531
  renderContext.conditions.push(metadata)
518
532
  renderContext.hasBehaviors = true
@@ -642,8 +656,10 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
642
656
  if (/^on[A-Z]/.test(rawName)) {
643
657
  const event = rawName.slice(2).toLowerCase()
644
658
  if (value?.[behaviorMarker]) {
645
- const commands = JSON.stringify(value.commands)
646
- attributes += ` data-k-on-${event}='${escapeJsonAttribute(value.commands)}'`
659
+ const command = value.commands.length === 1 ? value.commands[0] : undefined
660
+ attributes += command?.[0] === "set" && command[2] === true
661
+ ? ` data-k-set-true-${event}="${escapeAttribute(command[1])}"`
662
+ : ` data-k-on-${event}='${escapeJsonAttribute(value.commands)}'`
647
663
  renderContext.events.push({ event, commands: value.commands })
648
664
  } else if (value?.[nativeBehaviorMarker]) {
649
665
  const template = { module: value.module, handler: value.handler, states: value.states, scope: value.scope }
@@ -720,15 +736,20 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
720
736
  async function renderList(node, namespace, selectValue) {
721
737
  if (namespace) throw new Error(`Reactive keyed lists are not supported inside ${namespace}`)
722
738
  const id = nextRenderId("l")
723
- const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.items.value.map(item => item[node.keyField]) }
739
+ const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.items.value.map(item => item[node.keyField]), ...(node.items[reducerStateMarker] ? { reducer: true } : {}) }
724
740
  renderContext.listDepth++
725
741
  const previousListFields = renderContext.listFields
726
742
  const previousListEffectOwners = renderContext.listEffectOwners
743
+ const previousListRowStates = renderContext.listRowStates
744
+ const previousListRowConditions = renderContext.listRowConditions
727
745
  try {
728
746
  renderContext.listTemplate = true
729
747
  renderContext.listEffectOwners = []
748
+ renderContext.listRowStates = []
749
+ renderContext.listRowConditions = []
730
750
  renderContext.listFields = new Set([node.keyField])
731
- renderContext.listRoot = { id, state: node.items.id, template: true, effects: [], item: {} }
751
+ renderContext.listRoot = { id, state: node.items.id, template: true, effects: [], item: {}, rowIndexes: { s: 0, c: 0 } }
752
+ renderContext.listRowRoot = renderContext.listRoot
732
753
  const template = await renderNode(node.render({}), namespace, selectValue)
733
754
  if (template.includes("data-k-native-") || template.includes("data-k-effects=")) descriptor.mount = true
734
755
  if (template.includes("data-k-effects=")) descriptor.effects = true
@@ -738,13 +759,19 @@ async function renderList(node, namespace, selectValue) {
738
759
  if (template.includes("data-k-list-events")) descriptor.events = true
739
760
  if (template.includes("data-k-list-expression=")) descriptor.expressions = true
740
761
  if (template.includes("data-k-list-expression-attrs")) descriptor.expressionAttributes = true
762
+ if (renderContext.listRowStates.length) {
763
+ descriptor.rowStates = renderContext.listRowStates
764
+ if (renderContext.listRowConditions.length) descriptor.rowConditions = renderContext.listRowConditions.map(({ id }) => id)
765
+ descriptor.mount = true
766
+ }
741
767
  const seed = listSeed(node.items.value, renderContext.listFields)
742
768
  if (seed) descriptor.seed = seed
743
769
  let current = ""
744
770
  renderContext.listTemplate = false
745
771
  renderContext.listInitialMarkers = Boolean(descriptor.conditions)
746
772
  for (const item of node.items.value) {
747
- renderContext.listRoot = { id, state: node.items.id, key: item[node.keyField], template: false, effects: [], item }
773
+ renderContext.listRoot = { id, state: node.items.id, key: item[node.keyField], template: false, effects: [], item, rowIndexes: { s: 0, c: 0 } }
774
+ renderContext.listRowRoot = renderContext.listRoot
748
775
  current += await renderNode(node.render(item), namespace, selectValue)
749
776
  }
750
777
  renderContext.lists.push(descriptor)
@@ -753,10 +780,13 @@ async function renderList(node, namespace, selectValue) {
753
780
  return `<template data-k-list='${escapeJsonAttribute(descriptor)}'>${template}</template>${current}<template data-k-list-end="${id}"></template>`
754
781
  } finally {
755
782
  renderContext.listRoot = undefined
783
+ renderContext.listRowRoot = undefined
756
784
  renderContext.listTemplate = false
757
785
  renderContext.listInitialMarkers = false
758
786
  renderContext.listFields = previousListFields
759
787
  renderContext.listEffectOwners = previousListEffectOwners
788
+ renderContext.listRowStates = previousListRowStates
789
+ renderContext.listRowConditions = previousListRowConditions
760
790
  renderContext.listDepth--
761
791
  }
762
792
  }
@@ -767,6 +797,24 @@ function nextRenderId(kind) {
767
797
  return `${kind}${renderContext[counters[kind]]++}`
768
798
  }
769
799
 
800
+ function nextRowRenderId(kind, initialValue) {
801
+ const root = renderContext.listRoot ?? renderContext.listRowRoot
802
+ const index = root.rowIndexes[kind]++
803
+ const entries = kind === "s" ? renderContext.listRowStates : renderContext.listRowConditions
804
+ if (renderContext.listTemplate) {
805
+ const id = `${nextRenderId(kind)}:$k`
806
+ entries[index] = kind === "s" ? { id, initialValue } : { id }
807
+ return id
808
+ }
809
+ const entry = entries[index]
810
+ if (!entry) throw new Error(`Keyed row ${kind === "s" ? "state hooks" : "conditionals"} must have the same order for every item`)
811
+ return rowRenderId(entry.id, root.key)
812
+ }
813
+
814
+ function rowRenderId(id, key) {
815
+ return id.replace("$k", encodeURIComponent(`${typeof key}:${key}`))
816
+ }
817
+
770
818
  function optionValue(props) {
771
819
  if (props.value != null) return bindingValue(props.value)
772
820
  return Array.isArray(props.children) ? props.children.join("") : props.children ?? ""
@@ -31,6 +31,7 @@ function mountLists(root) {
31
31
  const descriptor = JSON.parse(start.dataset.kList)
32
32
  const end = findEnd(start, descriptor.id)
33
33
  const roots = listRoots(start, end)
34
+ if (__KUDZU_LIST_ROW_STATES__ && descriptor.rowStates) for (let index = 0; index < roots.length; index++) initializeRowStates(descriptor, descriptor.keys[index])
34
35
  const templateRoot = start.content.firstElementChild
35
36
  const parts = listItemPartPlan(templateRoot)
36
37
  for (const root of roots) __KUDZU_LIST_CONDITIONS__ && descriptor.conditions ? listItemParts(root) : mapListItemParts(parts, root)
@@ -43,6 +44,7 @@ function mountLists(root) {
43
44
  seedFields: __KUDZU_LIST_SEEDS__ && descriptor.seed && Object.keys(descriptor.seed),
44
45
  roots: new Map(roots.map((node, index) => [keyToken(descriptor.keys[index]), node])),
45
46
  values: new Map(),
47
+ items: undefined,
46
48
  container: roots[0]?.parentNode,
47
49
  boundary: end
48
50
  }
@@ -62,6 +64,7 @@ function unregisterList(start) {
62
64
  const lists = listTargets.get(registration.state)
63
65
  lists?.delete(registration.list)
64
66
  if (!lists?.size) listTargets.delete(registration.state)
67
+ if (__KUDZU_LIST_ROW_STATES__ && registration.list.descriptor.rowStates) for (const token of registration.list.roots.keys()) deleteRowStates(registration.list.descriptor, token)
65
68
  }
66
69
  listRegistrations.delete(start)
67
70
  mountedLists.delete(start)
@@ -70,6 +73,7 @@ function unregisterList(start) {
70
73
  function updateList(list) {
71
74
  const items = browserState.get(list.descriptor.state)
72
75
  if (!Array.isArray(items)) throw new Error("Keyed list state must remain an array")
76
+ if (list.descriptor.reducer && list.items && updateReducerList(list, items)) return
73
77
  const entries = []
74
78
  const keys = new Set()
75
79
  const seen = new Set()
@@ -82,7 +86,34 @@ function updateList(list) {
82
86
  const token = keyToken(key)
83
87
  if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
84
88
  keys.add(token)
85
- entries.push({ item, key, token, value: seededValue ?? JSON.stringify(item) })
89
+ entries.push({ item, key, token, value: seededValue === undefined ? JSON.stringify(item) : seededValue })
90
+ }
91
+ if (list.values.size && entries.every(entry => !list.values.has(entry.token) || list.values.get(entry.token) === entry.value)) {
92
+ const currentTokens = [...list.roots.keys()]
93
+ const nextTokens = entries.map(entry => entry.token)
94
+ if (nextTokens.length === currentTokens.length && nextTokens.every((token, index) => token === currentTokens[currentTokens.length - index - 1])) {
95
+ const parent = list.container ?? list.start.parentNode
96
+ const reordered = parent.ownerDocument.createDocumentFragment()
97
+ reordered.append(...nextTokens.map(token => list.roots.get(token)))
98
+ parent.insertBefore(reordered, list.boundary)
99
+ list.roots = new Map(nextTokens.map(token => [token, list.roots.get(token)]))
100
+ list.container ??= parent
101
+ return
102
+ }
103
+ if (nextTokens.length === currentTokens.length - 1) {
104
+ const removed = currentTokens.find(token => !keys.has(token))
105
+ const removedIndex = currentTokens.indexOf(removed)
106
+ if (removed && nextTokens.every((token, index) => token === currentTokens[index >= removedIndex ? index + 1 : index])) {
107
+ removeListRoot(list, removed)
108
+ list.roots = new Map(nextTokens.map(token => [token, list.roots.get(token)]))
109
+ return
110
+ }
111
+ }
112
+ if (nextTokens.length === currentTokens.length + 1 && currentTokens.every((token, index) => token === nextTokens[index])) {
113
+ const entry = entries.at(-1)
114
+ addListRoot(list, entry)
115
+ return
116
+ }
86
117
  }
87
118
  const next = []
88
119
  const values = new Map()
@@ -96,6 +127,7 @@ function updateList(list) {
96
127
  if (node?.dataset.kListRoot !== list.descriptor.id) node = undefined
97
128
  if (!node) throw new Error("Keyed list template has no root element")
98
129
  node.removeAttribute("data-k-list-root")
130
+ if (__KUDZU_LIST_ROW_STATES__ && list.descriptor.rowStates) initializeRowStates(list.descriptor, key, node)
99
131
  mapListItemParts(list.parts, node)
100
132
  fillListItem(node, item)
101
133
  additions.append(node)
@@ -113,6 +145,7 @@ function updateList(list) {
113
145
  unmountDom(node)
114
146
  node.remove()
115
147
  } else node.remove()
148
+ if (__KUDZU_LIST_ROW_STATES__ && list.descriptor.rowStates) deleteRowStates(list.descriptor, token)
116
149
  }
117
150
  if (added) {
118
151
  if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) {
@@ -143,6 +176,79 @@ function updateList(list) {
143
176
  }
144
177
  list.roots = new Map(next)
145
178
  list.values = values
179
+ list.items = items
180
+ }
181
+
182
+ function updateReducerList(list, items) {
183
+ const previous = list.items
184
+ if (items.length === previous.length && items.every((item, index) => item === previous[index])) {
185
+ list.items = items
186
+ return true
187
+ }
188
+ if (items.length === previous.length && items.every((item, index) => item === previous[previous.length - index - 1])) {
189
+ const tokens = [...list.roots.keys()].reverse()
190
+ const parent = list.container ?? list.start.parentNode
191
+ const reordered = parent.ownerDocument.createDocumentFragment()
192
+ reordered.append(...tokens.map(token => list.roots.get(token)))
193
+ parent.insertBefore(reordered, list.boundary)
194
+ list.roots = new Map(tokens.map(token => [token, list.roots.get(token)]))
195
+ list.items = items
196
+ list.container ??= parent
197
+ return true
198
+ }
199
+ if (items.length === previous.length - 1) {
200
+ let removed = 0
201
+ while (removed < items.length && items[removed] === previous[removed]) removed++
202
+ if (items.every((item, index) => item === previous[index >= removed ? index + 1 : index])) {
203
+ removeListRoot(list, keyToken(previous[removed]?.[list.descriptor.key]))
204
+ list.roots = new Map(items.map(item => {
205
+ const token = keyToken(item[list.descriptor.key])
206
+ return [token, list.roots.get(token)]
207
+ }))
208
+ list.items = items
209
+ return true
210
+ }
211
+ }
212
+ if (items.length === previous.length + 1 && previous.every((item, index) => item === items[index])) {
213
+ const item = items.at(-1)
214
+ const key = item?.[list.descriptor.key]
215
+ if (!validListKey(key)) throw new Error(`Keyed list key "${list.descriptor.key}" must be a string or finite number`)
216
+ assertListItem(item)
217
+ const seen = new Set()
218
+ const seededValue = __KUDZU_LIST_SEEDS__ ? list.seedFields && seededListValue(item, list.seedFields, list.descriptor.seed) : undefined
219
+ if (seededValue === undefined) assertListValue(item, seen, true)
220
+ const token = keyToken(key)
221
+ if (list.roots.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
222
+ addListRoot(list, { item, key, token, value: seededValue === undefined ? JSON.stringify(item) : seededValue })
223
+ list.items = items
224
+ return true
225
+ }
226
+ return false
227
+ }
228
+
229
+ function addListRoot(list, { item, key, token, value }) {
230
+ let node = list.start.content.firstElementChild?.cloneNode(true)
231
+ if (node?.dataset.kListRoot !== list.descriptor.id) node = undefined
232
+ if (!node) throw new Error("Keyed list template has no root element")
233
+ node.removeAttribute("data-k-list-root")
234
+ if (__KUDZU_LIST_ROW_STATES__ && list.descriptor.rowStates) initializeRowStates(list.descriptor, key, node)
235
+ mapListItemParts(list.parts, node)
236
+ fillListItem(node, item)
237
+ const parent = list.container ?? list.start.parentNode
238
+ parent.insertBefore(node, list.boundary)
239
+ if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) mountDom(node)
240
+ list.roots.set(token, node)
241
+ list.values.set(token, value)
242
+ list.container ??= parent
243
+ }
244
+
245
+ function removeListRoot(list, token) {
246
+ const node = list.roots.get(token)
247
+ if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) unmountDom(node)
248
+ node.remove()
249
+ if (__KUDZU_LIST_ROW_STATES__ && list.descriptor.rowStates) deleteRowStates(list.descriptor, token)
250
+ list.roots.delete(token)
251
+ list.values.delete(token)
146
252
  }
147
253
 
148
254
  function fillListItem(root, item) {
@@ -383,6 +489,40 @@ function keyToken(key) {
383
489
  return `${typeof key}:${key}`
384
490
  }
385
491
 
492
+ function initializeRowStates(descriptor, key, root) {
493
+ const token = keyToken(key)
494
+ const replacements = new Map()
495
+ for (const state of descriptor.rowStates) {
496
+ const id = rowStateId(state.id, token)
497
+ if (!browserState.has(id)) browserState.set(id, state.initialValue)
498
+ replacements.set(state.id, id)
499
+ }
500
+ for (const marker of descriptor.rowConditions ?? []) replacements.set(marker, rowStateId(marker, token))
501
+ if (root) replaceRowIds(root, replacements)
502
+ }
503
+
504
+ function deleteRowStates(descriptor, token) {
505
+ for (const state of descriptor.rowStates) browserState.delete(rowStateId(state.id, token))
506
+ }
507
+
508
+ function rowStateId(id, token) {
509
+ return id.replace("$k", encodeURIComponent(token))
510
+ }
511
+
512
+ function replaceRowIds(root, replacements) {
513
+ const replace = node => {
514
+ for (const attribute of [...node.attributes]) {
515
+ if (!attribute.name.startsWith("data-k-")) continue
516
+ let value = attribute.value
517
+ for (const [template, id] of replacements) if (value.includes(template)) value = value.replaceAll(template, id)
518
+ if (value !== attribute.value) attribute.value = value
519
+ }
520
+ for (const child of node.children) replace(child)
521
+ for (const child of node.content?.children ?? []) replace(child)
522
+ }
523
+ replace(root)
524
+ }
525
+
386
526
  function validListKey(key) {
387
527
  return typeof key === "string" || typeof key === "number" && Number.isFinite(key)
388
528
  }
@@ -394,6 +534,16 @@ function assertListItem(item) {
394
534
 
395
535
  function seededListValue(item, fields, seed) {
396
536
  const keys = Reflect.ownKeys(item)
537
+ if (fields.length === 1) {
538
+ const field = fields[0]
539
+ if (keys.length !== 1 || keys[0] !== field) return undefined
540
+ const descriptor = Object.getOwnPropertyDescriptor(item, field)
541
+ if (!descriptor.enumerable) throw new Error("Keyed list items must not contain non-enumerable properties")
542
+ if (!("value" in descriptor)) throw new Error("Keyed list items must not contain accessors")
543
+ const value = descriptor.value
544
+ const type = value === null ? "null" : typeof value
545
+ return type === seed[field] && !(type === "number" && (!Number.isFinite(value) || Object.is(value, -0))) ? value : undefined
546
+ }
397
547
  if (keys.length !== fields.length || keys.some(key => typeof key !== "string" || !fields.includes(key))) return undefined
398
548
  const values = []
399
549
  for (const field of fields) {
@@ -405,7 +555,7 @@ function seededListValue(item, fields, seed) {
405
555
  if (type !== seed[field] || type === "number" && (!Number.isFinite(value) || Object.is(value, -0))) return undefined
406
556
  values.push(value)
407
557
  }
408
- return JSON.stringify(values)
558
+ return values.length === 1 ? values[0] : JSON.stringify(values)
409
559
  }
410
560
 
411
561
  function assertListValue(value, seen, root = false) {
@@ -24,7 +24,10 @@ if(typeof document!=="undefined"){
24
24
 
25
25
  const eventNames = ["click", "input", "change"]
26
26
  for(const eventName of eventNames)document.addEventListener(eventName,event=>{
27
- const target=event.target.closest(`[data-k-on-${eventName}]`)
28
- if(target)applyCommands(browserState,JSON.parse(target.getAttribute(`data-k-on-${eventName}`)),commitDom)
27
+ const target=event.target.closest(`[data-k-set-true-${eventName}],[data-k-on-${eventName}]`)
28
+ if(!target)return
29
+ const direct=target.getAttribute(`data-k-set-true-${eventName}`)
30
+ if(direct){browserState.set(direct,true);commitDom(direct,true)}
31
+ else applyCommands(browserState,JSON.parse(target.getAttribute(`data-k-on-${eventName}`)),commitDom)
29
32
  })
30
33
  }
@@ -1,4 +1,12 @@
1
1
  export function applyCommands(state, commands, commit, log = console.log) {
2
+ if (commands.length === 1 && commands[0][0] !== "log") {
3
+ const [operation, id, operand] = commands[0]
4
+ const current = state.get(id)
5
+ const value = operation === "add" ? current + operand : operand
6
+ state.set(id, value)
7
+ commit(id, value)
8
+ return
9
+ }
2
10
  const changed = new Set()
3
11
 
4
12
  for (const [operation, id, operand] of commands) {
@@ -18,6 +26,8 @@ export const browserState = new Map()
18
26
  const committers = []
19
27
  const mountHooks = []
20
28
  const unmountHooks = []
29
+ const textTargets = new Map()
30
+ const mountedText = new WeakSet()
21
31
 
22
32
  /* list-item-hooks */
23
33
  const listItemHooks = new Map()
@@ -50,15 +60,33 @@ export function registerUnmountHook(unmount) {
50
60
  }
51
61
 
52
62
  export function commitDom(id, value) {
53
- for (const node of document.querySelectorAll(`[data-k-text="${id}"]`)) node.textContent = value
63
+ for (const node of textTargets.get(id) ?? []) {
64
+ if (node.isConnected) node.textContent = value
65
+ else textTargets.get(id).delete(node)
66
+ }
54
67
  for (const commit of committers) commit(id)
55
68
  }
56
69
 
57
70
  export function mountText(root) {
58
71
  for (const node of matching(root, "[data-k-text]")) {
72
+ if (mountedText.has(node)) continue
73
+ mountedText.add(node)
59
74
  const id = node.dataset.kText
60
75
  if (browserState.has(id)) node.textContent = browserState.get(id)
61
76
  else browserState.set(id, JSON.parse(node.dataset.kValue))
77
+ const targets = textTargets.get(id) ?? new Set()
78
+ targets.add(node)
79
+ textTargets.set(id, targets)
80
+ }
81
+ }
82
+
83
+ function unmountText(root) {
84
+ for (const node of matching(root, "[data-k-text]")) {
85
+ const id = node.dataset.kText
86
+ const targets = textTargets.get(id)
87
+ targets?.delete(node)
88
+ if (!targets?.size) textTargets.delete(id)
89
+ mountedText.delete(node)
62
90
  }
63
91
  }
64
92
 
@@ -69,6 +97,7 @@ export function mountDom(root) {
69
97
 
70
98
  export function unmountDom(root) {
71
99
  for (const unmount of unmountHooks) unmount(root)
100
+ unmountText(root)
72
101
  }
73
102
 
74
103
  if (typeof document !== "undefined") {
@@ -79,8 +108,14 @@ if (typeof document !== "undefined") {
79
108
  const eventNames = ["click", "input", "change"]
80
109
  for (const eventName of eventNames) {
81
110
  document.addEventListener(eventName, event => {
82
- const target = event.target.closest(`[data-k-on-${eventName}]`)
111
+ const target = event.target.closest(`[data-k-set-true-${eventName}],[data-k-on-${eventName}]`)
83
112
  if (!target) return
113
+ const direct = target.dataset[`kSetTrue${capitalize(eventName)}`]
114
+ if (direct) {
115
+ browserState.set(direct, true)
116
+ commitDom(direct, true)
117
+ return
118
+ }
84
119
  const commands = target.dataset[`kOn${capitalize(eventName)}`]
85
120
  applyCommands(browserState, JSON.parse(commands), commitDom)
86
121
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.6.16",
3
+ "version": "0.6.18",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",