@kudzujs/core 0.6.15 → 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. Relative TypeScript constants and helpers used inside the child 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, forwarding across another component, 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 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. 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)) {
@@ -1735,10 +1738,27 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1735
1738
  throw sourceNodeError(node, sourceFile, message)
1736
1739
  }
1737
1740
  const componentSpecializations = new WeakMap()
1741
+ const reducerComponentCalls = new WeakSet()
1742
+ const reducerRowStateCalls = []
1738
1743
  const specializedDeclarations = new WeakSet()
1739
1744
  const stateBackedComponentFunctions = new WeakSet()
1740
1745
  const stateBackedComponentRoots = []
1741
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
+ }
1742
1762
  const mergeSpecializedImports = (root, componentSource, call) => {
1743
1763
  const componentImports = clientImportBindings(componentSource, componentSource.fileName, sourceFiles)
1744
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")
@@ -1836,8 +1856,10 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1836
1856
  if (componentSpecializations.has(call)) fail(call, "Reducer dispatch props cannot be combined with another component specialization")
1837
1857
  const specialization = specializeComponentCall(call, component.function, sourceFile, factory, context, fail, "Reducer-dispatch")
1838
1858
  if (specialization.effects.length) fail(call, "Reducer-dispatch components cannot declare effects")
1859
+ registerReducerRowState(call, specialization)
1839
1860
  specialization.root = expandReducerCallbacks(specialization.root, component.function.getSourceFile(), call)
1840
1861
  componentSpecializations.set(call, specialization)
1862
+ reducerComponentCalls.add(call)
1841
1863
  }
1842
1864
  specializedDeclarations.add(component.declaration)
1843
1865
  }
@@ -1858,10 +1880,12 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1858
1880
  if (componentSpecializations.has(call)) fail(call, "Reducer dispatch props cannot be combined with another component specialization")
1859
1881
  const specialization = specializeComponentCall(call, component, sourceFile, factory, context, fail, "Reducer-dispatch")
1860
1882
  if (specialization.effects.length) fail(call, "Reducer-dispatch components cannot declare effects")
1883
+ registerReducerRowState(call, specialization)
1861
1884
  specialization.root = expandReducerCallbacks(specialization.root, componentSource, call)
1862
1885
  specialization.root = mergeSpecializedImports(specialization.root, componentSource, call)
1863
1886
  synthesizeTree(specialization.root)
1864
1887
  componentSpecializations.set(call, specialization)
1888
+ reducerComponentCalls.add(call)
1865
1889
  }
1866
1890
  }
1867
1891
  const rawRenderedLists = []
@@ -1893,6 +1917,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1893
1917
  return tag && ts.isIdentifier(tag) && tag.text[0] === tag.text[0].toUpperCase() ? [tag.text] : []
1894
1918
  }))
1895
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")
1896
1921
  for (const name of listComponentNames) {
1897
1922
  let component = components.get(name)
1898
1923
  const local = Boolean(component)
@@ -1910,7 +1935,9 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1910
1935
  ...stateBackedComponentRoots.flatMap(root => jsxTagUses(root, name))
1911
1936
  ]
1912
1937
  for (const call of calls) {
1913
- const specialization = specializeComponentCall(call, component.function, sourceFile, factory, context, fail)
1938
+ const specialization = reducerComponentCalls.has(call)
1939
+ ? componentSpecializations.get(call)
1940
+ : specializeComponentCall(call, component.function, sourceFile, factory, context, fail)
1914
1941
  if (specialization.effects.length && !keyedComponentCalls.has(call)) fail(call, "Effectful keyed row components may only be used directly as keyed map rows")
1915
1942
  componentSpecializations.set(call, specialization)
1916
1943
  }
@@ -1930,6 +1957,15 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1930
1957
  originalParts.callback.equalsGreaterThanToken,
1931
1958
  root
1932
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
+ )
1933
1969
  if (callback !== originalParts.callback) {
1934
1970
  ts.setParentRecursive(callback, false)
1935
1971
  callback.parent = originalParts.callback.parent
@@ -1940,7 +1976,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1940
1976
  calculation.parent = callback
1941
1977
  validateListExpression(calculation, parts.item, originalParts.root, fail)
1942
1978
  }
1943
- validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions)
1979
+ validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, settersForNode(originalParts.root, settersByFunction), specialization?.rowState)
1944
1980
  if (specialization?.effects.length) {
1945
1981
  usesListEffects = true
1946
1982
  const statements = specialization.effects.map(entry => {
@@ -2065,7 +2101,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2065
2101
  ])
2066
2102
  }
2067
2103
 
2068
- 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)) {
2069
2105
  const stateElement = node.name.elements[0]
2070
2106
  if (!stateElement || !ts.isBindingElement(stateElement) || !ts.isIdentifier(stateElement.name)) return node
2071
2107
  const initializer = factory.updateCallExpression(node.initializer, node.initializer.expression, node.initializer.typeArguments, [
@@ -2165,8 +2201,14 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2165
2201
 
2166
2202
  const behaviorImports = [factory.createImportSpecifier(false, factory.createIdentifier("behavior"), factory.createIdentifier("__kBehavior"))]
2167
2203
  if (nativeHandlers.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("nativeBehavior"), factory.createIdentifier("__kNativeBehavior")))
2168
- if (usesBinding) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("binding"), factory.createIdentifier("__kBinding")))
2169
- 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
+ }
2170
2212
  if (usesList) {
2171
2213
  behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("list"), factory.createIdentifier("__kList")))
2172
2214
  behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listExpression"), factory.createIdentifier("__kListExpression")))
@@ -2176,6 +2218,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2176
2218
  }
2177
2219
  if (usesListItem && !usesList) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listItem"), factory.createIdentifier("__kListItem")))
2178
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")))
2179
2222
  if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
2180
2223
  const behaviorImport = factory.createImportDeclaration(
2181
2224
  undefined,
@@ -2394,7 +2437,7 @@ function insideJsxEventHandler(node, root) {
2394
2437
  return false
2395
2438
  }
2396
2439
 
2397
- function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions) {
2440
+ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, setters, rowState) {
2398
2441
  const fail = (node, message) => {
2399
2442
  throw sourceNodeError(node, sourceFile, message)
2400
2443
  }
@@ -2419,6 +2462,13 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
2419
2462
  const condition = conditionalParts(expression)
2420
2463
  if (condition && containsJsx(expression)) {
2421
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
+ }
2422
2472
  if (!referencesIdentifier(condition.condition, item)) fail(node, "Keyed list item conditions must read the item")
2423
2473
  validateListExpression(condition.condition, item, node, fail)
2424
2474
  listConditions.set(node.expression, { ...condition, item })
@@ -2484,6 +2534,8 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
2484
2534
  let returned
2485
2535
  const calculations = []
2486
2536
  const effectCalls = []
2537
+ const stateDeclarations = []
2538
+ let rowState
2487
2539
  if (!ts.isBlock(component.body)) {
2488
2540
  returned = component.body
2489
2541
  } else {
@@ -2497,13 +2549,41 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
2497
2549
  }
2498
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`)
2499
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
+ }
2500
2571
  if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration, `${label} component locals must be initialized identifiers`)
2501
2572
  const calculation = substituteClone(declaration.initializer, substitutions, factory, context)
2502
- calculations.push(calculation)
2573
+ calculations.push({ name: declaration.name.text, expression: calculation })
2503
2574
  substitutions.set(declaration.name.text, calculation)
2504
2575
  }
2505
2576
  returned = last.expression
2506
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`)
2507
2587
  let root = unwrapExpression(substituteClone(returned, substitutions, factory, context))
2508
2588
  if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(returned, `${label} component must return one JSX element`)
2509
2589
  const tag = jsxTagName(root)
@@ -2514,7 +2594,15 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
2514
2594
  ts.setParentRecursive(root, false)
2515
2595
  root.parent = call.parent
2516
2596
  const effects = effectCalls.map(source => ({ source, call: substituteClone(source, substitutions, factory, context) }))
2517
- return { root, calculations, effects }
2597
+ return {
2598
+ root,
2599
+ calculations: calculations
2600
+ .filter(calculation => label !== "Reducer-dispatch" || !isFunctionLike(calculation.expression) || !isEventOnlyComponentLocal(returned, calculation.name))
2601
+ .map(calculation => calculation.expression),
2602
+ effects,
2603
+ stateDeclarations,
2604
+ rowState
2605
+ }
2518
2606
  }
2519
2607
 
2520
2608
  function isPrimitiveDefaultLiteral(node) {
@@ -2523,6 +2611,29 @@ function isPrimitiveDefaultLiteral(node) {
2523
2611
  node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword || node.kind === ts.SyntaxKind.NullKeyword
2524
2612
  }
2525
2613
 
2614
+ function isEventOnlyComponentLocal(root, name) {
2615
+ let found = false
2616
+ let eventOnly = true
2617
+ const visit = node => {
2618
+ if (ts.isIdentifier(node) && node.text === name && isReferenceIdentifier(node)) {
2619
+ found = true
2620
+ let parent = node.parent
2621
+ while (parent && parent !== root) {
2622
+ if (ts.isJsxAttribute(parent)) {
2623
+ if (!/^on[A-Z]/.test(parent.name.text)) eventOnly = false
2624
+ return
2625
+ }
2626
+ parent = parent.parent
2627
+ }
2628
+ eventOnly = false
2629
+ return
2630
+ }
2631
+ ts.forEachChild(node, visit)
2632
+ }
2633
+ visit(root)
2634
+ return found && eventOnly
2635
+ }
2636
+
2526
2637
  function substituteClone(root, substitutions, factory, context) {
2527
2638
  const visit = (node, shadowed = new Set()) => {
2528
2639
  if (ts.isTypeNode(node)) return cloneAst(node, factory, context)
@@ -2767,15 +2878,27 @@ function isJsxLocalValue(expression, known) {
2767
2878
  }
2768
2879
 
2769
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
+ }
2770
2886
  return factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl))
2771
2887
  }
2772
2888
 
2773
2889
  function compileConditional(kind, expression, truthy, falsy, setters, factory, context, reactiveBindings, handlerUrl) {
2774
- const [initial, ...descriptor] = compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl)
2890
+ const state = directStateIdentifier(expression, setters)
2775
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)
2776
2894
  return factory.createCallExpression(factory.createIdentifier("__kConditional"), undefined, [factory.createStringLiteral(kind), initial, thunk(truthy), thunk(falsy), ...descriptor])
2777
2895
  }
2778
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
+
2779
2902
  function compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl) {
2780
2903
  const usedStates = referencedStateNames(expression, setters)
2781
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.15",
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",