@kudzujs/core 0.7.14 → 0.7.16

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
@@ -10,7 +10,7 @@ Kudzu compiles ordinary React-shaped TypeScript and TSX into complete static HTM
10
10
 
11
11
  > Experimental `0.7.x`: the compiler API and supported TSX surface may change.
12
12
 
13
- **Latest release: 0.7.14 - Intrinsic forwardRef.** Ordinary React-shaped components can retain direct `forwardRef()` wrappers while Kudzu separates ref from props and erases the wrapper into intrinsic output. Read the [release notes](./RELEASES.md#0714---intrinsic-forwardref) or open the [release page](https://kudzujs.cloud/releases/0.7.14).
13
+ **Latest release: 0.7.16 - Build-time lazy state.** Anonymous synchronous `useState` initializers may return directly serializable primitive, plain-object, or array literals without adding a browser runtime. Read the [release notes](./RELEASES.md#0716---build-time-lazy-state) or open the [release page](https://kudzujs.cloud/releases/0.7.16).
14
14
 
15
15
  - [Documentation](https://kudzujs.cloud/docs)
16
16
  - [Installation guide](https://kudzujs.cloud/docs#install)
package/RELEASES.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.7.16 - Build-time lazy state
4
+
5
+ Kudzu 0.7.16 accepts the common React-shaped `useState(() => initialValue)` form when the initializer directly returns a serializable literal, lowering it into existing compiler-owned state with no browser component runtime.
6
+
7
+ ### New in 0.7.16
8
+
9
+ - Anonymous synchronous zero-argument lazy initializers may directly return primitive, plain-object, or array literals.
10
+ - The compiler lowers those functions before state analysis, so ordinary, repeated, imported, conditional, and keyed-row state reuse existing ownership paths.
11
+ - Conditional removal still deletes owned state, and remount recreates a fresh clone of the serialized object or array initializer.
12
+ - React migration imports and direct `@kudzujs/core` imports receive the same behavior and TypeScript inference.
13
+ - Dynamic calls, captures, parameters, async or generator functions, named function expressions, and multi-statement bodies fail with source-located diagnostics.
14
+ - Static routes remain JavaScript-free; lazy initializer functions are absent from generated component and browser modules.
15
+
16
+ ### Boundary
17
+
18
+ This release evaluates no arbitrary user code at build time. Initializers must directly return data the compiler can already serialize. Lazy `useReducer` initialization and dynamic state factories remain unsupported.
19
+
20
+ ### Upgrade
21
+
22
+ ```bash
23
+ npm install @kudzujs/core@^0.7.16
24
+ ```
25
+
26
+ ## 0.7.15 - Child state ownership
27
+
28
+ Kudzu 0.7.15 gives repeated ordinary child components independent state and adds explicit state ownership to reactive conditional branches without introducing a browser component tree.
29
+
30
+ ### New in 0.7.15
31
+
32
+ - Repeated same-file and relative-imported child components receive distinct concrete state IDs.
33
+ - Shared generated handler modules retain per-element state maps and serializable prop captures, so one child update cannot mutate its sibling.
34
+ - Reactive conditional descriptors record only state created directly by each branch.
35
+ - Conditional removal unmounts DOM capabilities and deletes the active branch's owned state slots.
36
+ - Re-entry recreates primitive, object, or array state from serialized initial values instead of restoring stale state.
37
+ - Initially active branches reuse their pre-rendered state IDs rather than executing the child component a second time.
38
+ - Nested condition owners remain independent, and existing route/layout, keyed-row, effect, and navigation ownership behavior is preserved.
39
+ - Static sibling routes remain JavaScript-free; no component registry, hook dispatcher, or generic rerender loop is emitted.
40
+
41
+ ### Measured fixture
42
+
43
+ The focused three-route fixture rendered repeated same-file and imported toggles plus conditional removal/re-entry. A clean local build measured approximately 270 ms and emitted 10,815 B raw JavaScript across the two interactive routes; the static sibling route emitted no script. Chrome verified sibling isolation, state deletion on removal, fresh remount values, and stable initial active-branch IDs.
44
+
45
+ ### Boundary
46
+
47
+ This release owns direct state created while rendering ordinary conditional branches. Lazy `useState`/`useReducer` initialization, broader primitive prop dependencies, and additional callback/ref ownership remain fixture-driven work. Browser output still contains only state and DOM capabilities, never component functions.
48
+
49
+ ### Upgrade
50
+
51
+ ```bash
52
+ npm install @kudzujs/core@^0.7.15
53
+ ```
54
+
3
55
  ## 0.7.14 - Intrinsic forwardRef
4
56
 
5
57
  Kudzu 0.7.14 preserves conventional direct `forwardRef()` component authoring while erasing the wrapper into build-time intrinsic output.
@@ -6,6 +6,8 @@ Migration source may retain conventional `react` imports for supported named or
6
6
 
7
7
  Direct `clsx` calls over literal strings, numbers, arrays, object conditions, and conditional expressions are similarly lowered to ordinary concatenation and conditional expressions. The package import is erased, and dynamic classes continue through the existing binding compiler without serializing or shipping the `clsx` function.
8
8
 
9
+ Repeated ordinary same-file and relative-imported child components execute independently at build time, so each `useState` call receives a distinct concrete state ID while shared native handler modules retain per-element state maps and captures. Reactive conditional descriptors own state created by their direct branch: initial visible output reuses the rendered template IDs, removal deletes those slots, and remount recreates them from serialized initial values. Static sibling routes and branches without local state add no ownership metadata, component function, hook dispatcher, or rerender loop.
10
+
9
11
  Reduced Zustand migration stores lower to one ordinary layout-lifetime state slot. The compiler accepts one exported `create(set => ({ data, ...actions }))` store with one serializable data property, direct property selectors, and synchronous capture-free actions using one-argument merge-form `set`; selected actions reuse the reducer-style functional update compiler, so same-turn calls observe current logical state and DOM writes still batch. The shared layout must initialize the store before route consumers, outside keyed rows. No Zustand import, store subscription runtime, React hook, or generic external-store capability is emitted.
10
12
 
11
13
  - `build.mjs`: TSX compilation, static, `getStaticPaths`, and runtime-fallback routes, base paths, CSS collection, post-build hooks, behavior extraction, static HTML output, and the development server.
@@ -33,7 +35,7 @@ Inline SVG rendering normalizes an explicit set of common React presentation ali
33
35
 
34
36
  Same-file, directly exported same-file, and relative-imported component chains receiving a direct local-state array or keyed item are recursively specialized to intrinsic JSX before keyed-list analysis, so their component functions are not retained in the browser. A directly exported row may be reused across static and keyed JSX sites; export-list/default aliases and non-JSX references remain rejected. Missing destructured props use directly serializable primitive, plain-object, or array literal defaults during specialization. One final identifier rest binding may be expanded exactly once at the direct intrinsic root. Rows may own multiple direct-property child maps recursively, nested conditions, latest-item handlers, multiple directly serializable state slots, effects, and `null`-initialized object refs. Structural list sites and ancestor key paths scope hooks across updates and reorder and release them on removal. Handler modules are emitted only when a rendered descriptor references them. 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 and unrelated fields do nothing. Builds without item dependencies emit no item reader or list-state subscription code.
35
37
 
36
- Rendered collection selectors compile one-use aliases and inline `(item)` or `(item, index)` pipelines over local array state or supported static named imports. Supported selectors are pure `filter` with direct local-state reads, direct-property `flatMap`, and `Array.from` before a final keyed `map`; dependency commits re-evaluate the selector against the immutable build-time collection while field keys retain item identity and `key={index}` retains positional identity. Compiler-owned static filters over structural keyed rows validate source references and keys once, retain removed rows as detached prototypes, clone fresh restoration nodes, and insert only new runs without moving retained DOM. Specialized collection wrappers and keyed rows inline direct object-literal or calling-component `const` object prop spreads in source order and forward JSX children into intrinsic output. This route-specific path is compiled out elsewhere. Compiler-owned collection state is excluded from development snapshot restoration. Dynamic/computed prop spreads, arbitrary callbacks, mutation, asynchronous selectors, imported callback functions, prototype-sensitive reads, lazy/dynamic row state initializers, non-`null` or callback refs, and recursive component cycles fail during compilation.
38
+ Rendered collection selectors compile one-use aliases and inline `(item)` or `(item, index)` pipelines over local array state or supported static named imports. Supported selectors are pure `filter` with direct local-state reads, direct-property `flatMap`, and `Array.from` before a final keyed `map`; dependency commits re-evaluate the selector against the immutable build-time collection while field keys retain item identity and `key={index}` retains positional identity. Compiler-owned static filters over structural keyed rows validate source references and keys once, retain removed rows as detached prototypes, clone fresh restoration nodes, and insert only new runs without moving retained DOM. Specialized collection wrappers and keyed rows inline direct object-literal or calling-component `const` object prop spreads in source order and forward JSX children into intrinsic output. Anonymous zero-argument lazy state initializers that return a directly serializable literal lower to the same ownership path. This route-specific path is compiled out elsewhere. Compiler-owned collection state is excluded from development snapshot restoration. Dynamic/computed prop spreads, arbitrary callbacks, mutation, asynchronous selectors, imported callback functions, prototype-sensitive reads, dynamic row state initializers, non-`null` or callback refs, and recursive component cycles fail during compilation.
37
39
 
38
40
  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 reducer row reads the latest item through the existing list scope and uses the same multiple serializable state, effect, condition, and object-ref specialization as other keyed rows. 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 directly serializable literal defaults and direct intrinsic rest props 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.
39
41
 
@@ -125,7 +125,8 @@ function mountConditions(root) {
125
125
  const truthy = start.content.querySelector("template[data-k-true]")
126
126
  const falsy = start.content.querySelector("template[data-k-false]")
127
127
  if (!end || !truthy || !falsy) continue
128
- const condition = { start, end, truthy, falsy, kind: descriptor.kind, current: conditionKey(descriptor.kind, descriptor.initial), mount: descriptor.mount }
128
+ const condition = { start, end, truthy, falsy, kind: descriptor.kind, current: conditionKey(descriptor.kind, descriptor.initial), mount: descriptor.mount, owned: descriptor.owned }
129
+ mountConditionStates(condition, Boolean(descriptor.initial), false)
129
130
  const mount = evaluator => {
130
131
  if (!start.isConnected) return
131
132
  condition.read = evaluator.read
@@ -146,13 +147,16 @@ function updateCondition(condition) {
146
147
  const value = condition.read()
147
148
  const next = conditionKey(condition.kind, value)
148
149
  if (next === condition.current) return
150
+ const previous = condition.current === "true"
149
151
  removeConditionRange(condition.start, condition.end, condition.mount)
152
+ unmountConditionStates(condition, previous)
150
153
  const truthy = Boolean(value)
151
154
  const falseText = condition.kind === "and" && !truthy ? renderFalsy(value) : ""
152
155
  const fragment = falseText
153
156
  ? textFragment(condition.end.ownerDocument, falseText)
154
157
  : (truthy ? condition.truthy : condition.falsy).content.cloneNode(true)
155
158
  const nodes = condition.mount ? [...fragment.childNodes] : undefined
159
+ mountConditionStates(condition, truthy, true)
156
160
  condition.end.parentNode.insertBefore(fragment, condition.end)
157
161
  condition.current = next
158
162
  if (condition.mount) for (const node of nodes) mountDom(node)
@@ -183,11 +187,22 @@ function unmountConditions(root) {
183
187
  for (const start of matching(root, "template[data-k-if]")) {
184
188
  const registration = conditionRegistrations.get(start)
185
189
  for (const [id, condition] of registration?.registrations ?? []) conditionTargets.get(id)?.delete(condition)
190
+ if (registration?.condition) unmountConditionStates(registration.condition, registration.condition.current === "true")
186
191
  conditionRegistrations.delete(start)
187
192
  mountedConditions.delete(start)
188
193
  }
189
194
  }
190
195
 
196
+ function mountConditionStates(condition, truthy, replace) {
197
+ for (const [id, initialValue] of condition.owned?.[truthy ? "true" : "false"] ?? []) {
198
+ if (replace || !browserState.has(id)) browserState.set(id, initialValue !== null && typeof initialValue === "object" ? structuredClone(initialValue) : initialValue)
199
+ }
200
+ }
201
+
202
+ function unmountConditionStates(condition, truthy) {
203
+ for (const [id] of condition.owned?.[truthy ? "true" : "false"] ?? []) browserState.delete(id)
204
+ }
205
+
191
206
  function removeConditionRange(start, end, mount) {
192
207
  if (start.nextSibling === end) return
193
208
  const range = start.ownerDocument.createRange()
@@ -287,6 +287,7 @@ export async function build({ quiet = false, minify = true } = {}) {
287
287
  .replace("entries.push({ item, index, key, token, value:", "entries.push({ item, key, token, value:")
288
288
  .replace("for (const { item, index, key, token, value } of entries) {", "for (const { item, key, token, value } of entries) {")
289
289
  .replaceAll("fillListItem(node, item, list.descriptor.nested, index)", "fillListItem(node, item, list.descriptor.nested)")
290
+ .replace("fillListItem(node, item, list.descriptor.nested, index, mapListItemParts", "fillListItem(node, item, list.descriptor.nested, 0, mapListItemParts")
290
291
  .replace("function addListRoot(list, { item, index = list.roots.size, key, token, value })", "function addListRoot(list, { item, key, token, value })")
291
292
  .replace("fillListParts(root, listItemParts(root), listItems.get(owner), 0, __KUDZU_LIST_INDEXES__ ? listIndexes.get(owner) ?? 0 : 0)", "fillListParts(root, listItemParts(root), listItems.get(owner), 0)")
292
293
  .replace("function fillListItem(root, item, nested = false, index = 0)", "function fillListItem(root, item, nested = false)")
@@ -2147,6 +2148,31 @@ function validateUseIdSyntax(sourceFile) {
2147
2148
  visit(sourceFile)
2148
2149
  }
2149
2150
 
2151
+ function normalizeLazyStateInitializers(sourceFile, factory, context) {
2152
+ const bindings = new Set()
2153
+ for (const statement of sourceFile.statements) {
2154
+ if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly || !ts.isStringLiteral(statement.moduleSpecifier) || !["react", "@kudzujs/core"].includes(statement.moduleSpecifier.text)) continue
2155
+ const named = statement.importClause?.namedBindings
2156
+ if (named && ts.isNamedImports(named)) for (const entry of named.elements) {
2157
+ if (!entry.isTypeOnly && (entry.propertyName ?? entry.name).text === "useState" && entry.name.text === "useState") bindings.add("useState")
2158
+ }
2159
+ }
2160
+ if (!bindings.size) return sourceFile
2161
+ const visitor = node => {
2162
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && bindings.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile) && node.arguments[0] && (ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0]))) {
2163
+ const initializer = node.arguments[0]
2164
+ if (node.arguments.length !== 1 || initializer.parameters.length || initializer.asteriskToken || initializer.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || ts.isFunctionExpression(initializer) && initializer.name) throw sourceNodeError(initializer, sourceFile, "Lazy useState() requires one anonymous synchronous zero-parameter initializer")
2165
+ const expression = ts.isBlock(initializer.body)
2166
+ ? initializer.body.statements.length === 1 && ts.isReturnStatement(initializer.body.statements[0]) ? initializer.body.statements[0].expression : undefined
2167
+ : initializer.body
2168
+ if (!expression || !isSerializableStateLiteral(expression)) throw sourceNodeError(initializer.body, sourceFile, "Lazy useState() initializer must return one directly serializable primitive, plain-object, or array literal")
2169
+ return factory.updateCallExpression(node, node.expression, node.typeArguments, [cloneAst(expression, factory, context)])
2170
+ }
2171
+ return ts.visitEachChild(node, visitor, context)
2172
+ }
2173
+ return ts.visitNode(sourceFile, visitor)
2174
+ }
2175
+
2150
2176
  function importDeclarationNames(statement) {
2151
2177
  const names = []
2152
2178
  if (statement.importClause?.name) names.push(statement.importClause.name.text)
@@ -2232,6 +2258,8 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2232
2258
  sourceFile = normalizeReactMigrationSyntax(sourceFile, factory, context, importedCollections)
2233
2259
  ts.setParentRecursive(sourceFile, false)
2234
2260
  validateUseIdSyntax(sourceFile)
2261
+ sourceFile = normalizeLazyStateInitializers(sourceFile, factory, context)
2262
+ ts.setParentRecursive(sourceFile, false)
2235
2263
  sourceFile = normalizeZustandMigrationSyntax(sourceFile, factory, context)
2236
2264
  ts.setParentRecursive(sourceFile, false)
2237
2265
  sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
@@ -2248,6 +2276,8 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2248
2276
  imported = normalizeReactMigrationSyntax(imported, factory, context, importedSerializableCollectionNames(imported, target, sourceFiles, sourceIndex))
2249
2277
  ts.setParentRecursive(imported, false)
2250
2278
  validateUseIdSyntax(imported)
2279
+ imported = normalizeLazyStateInitializers(imported, factory, context)
2280
+ ts.setParentRecursive(imported, false)
2251
2281
  imported = normalizeZustandMigrationSyntax(imported, factory, context)
2252
2282
  ts.setParentRecursive(imported, false)
2253
2283
  imported = normalizeRenderControlFlow(imported, factory, context)
@@ -3634,7 +3664,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
3634
3664
  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`)
3635
3665
  const declaration = statement.declarationList.declarations[0]
3636
3666
  if (declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "useState") {
3637
- if (declaration.initializer.arguments.length !== 1 || !isSerializableStateLiteral(declaration.initializer.arguments[0])) throw sourceNodeError(declaration.initializer, component.getSourceFile(), "Keyed row useState() must use one directly serializable primitive, plain object, or array initial value; lazy and dynamic initializers are not supported")
3667
+ if (declaration.initializer.arguments.length !== 1 || !isSerializableStateLiteral(declaration.initializer.arguments[0])) throw sourceNodeError(declaration.initializer, component.getSourceFile(), "Keyed row useState() must use one directly serializable primitive, plain object, or array initial value; dynamic initializers are not supported")
3638
3668
  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(), "Keyed row useState() must use [state, setter] identifier destructuring")
3639
3669
  const suffix = `${Math.max(0, call.pos)}_${rowStates.length}`
3640
3670
  const state = `__kRowState${suffix}`
@@ -6,6 +6,7 @@ export type EffectDependency = string | number | boolean | null
6
6
  export const Fragment: unique symbol
7
7
 
8
8
  export function useId(): string
9
+ export function useState<T>(initialValue: () => T): [T, StateSetter<T>]
9
10
  export function useState<T>(initialValue: T): [T, StateSetter<T>]
10
11
  export function useReducer<State, Action>(reducer: Reducer<State, Action>, initialValue: State): [State, Dispatch<Action>]
11
12
  export function useEffect(effect: () => void | EffectCleanup | Promise<void>, dependencies: readonly EffectDependency[]): void
@@ -412,7 +412,7 @@ function serializeCapture(name, value, seen) {
412
412
  }
413
413
 
414
414
  export async function renderPage(component, metadata = {}, props = {}, layout) {
415
- renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, nextId: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowRefs: [], listRowConditions: [], listRowLists: [], effectOwners: [], contexts: [], stores: new Map(), 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 }
415
+ renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, nextId: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowRefs: [], listRowConditions: [], listRowLists: [], effectOwners: [], contexts: [], stores: new Map(), states: {}, textStates: new Set(), conditionStates: new Set(), conditionOwnedStates: 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 }
416
416
 
417
417
  try {
418
418
  const page = { [routeScopeMarker]: true, component, props }
@@ -466,7 +466,7 @@ export async function renderPage(component, metadata = {}, props = {}, layout) {
466
466
  const listStates = new Set(renderContext.lists.map(list => list.state))
467
467
  const seededListStates = new Set(renderContext.lists.filter(list => list.seed && !renderContext.textStates.has(list.state) && !renderContext.conditionStates.has(list.state)).map(list => list.state))
468
468
  const initialState = renderContext.hasBehaviors
469
- ? Object.entries(renderContext.states).filter(([id]) => (!renderContext.textStates.has(id) || renderContext.conditionStates.has(id)) && !seededListStates.has(id)).map(([id, entry]) => {
469
+ ? Object.entries(renderContext.states).filter(([id]) => !renderContext.conditionOwnedStates.has(id) && (!renderContext.textStates.has(id) || renderContext.conditionStates.has(id)) && !seededListStates.has(id)).map(([id, entry]) => {
470
470
  const compact = listStates.has(id) && compactListState(entry.initialValue)
471
471
  return compact ? [id, compact, 1] : [id, entry.initialValue]
472
472
  })
@@ -586,19 +586,32 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
586
586
  if (namespace) throw new Error(`Reactive conditional DOM is not supported inside ${namespace}`)
587
587
 
588
588
  const id = renderContext.listRoot || renderContext.listRowRoot ? nextRowRenderId("c") : nextRenderId("c")
589
+ const renderBranch = async branch => {
590
+ const before = new Set(Object.keys(renderContext.states))
591
+ const html = await renderNode(branch(), namespace, selectValue)
592
+ const states = Object.keys(renderContext.states).filter(stateId => !before.has(stateId) && !renderContext.conditionOwnedStates.has(stateId))
593
+ for (const stateId of states) renderContext.conditionOwnedStates.add(stateId)
594
+ return { html, states: states.map(stateId => [stateId, renderContext.states[stateId].initialValue]) }
595
+ }
589
596
  renderContext.conditionDepth++
590
- const truthy = await renderNode(node.truthy(), namespace, selectValue)
591
- const falsy = await renderNode(node.falsy(), namespace, selectValue)
592
- renderContext.conditionDepth--
593
- const mount = truthy.includes("data-k-") || falsy.includes("data-k-") || truthy.includes("<!--k-text:") || falsy.includes("<!--k-text:")
594
- const metadata = { id, kind: node.kind, initial: node.value, ...descriptor, ...(mount ? { mount: true } : {}) }
597
+ let truthy
598
+ let falsy
599
+ try {
600
+ truthy = await renderBranch(node.truthy)
601
+ falsy = await renderBranch(node.falsy)
602
+ } finally {
603
+ renderContext.conditionDepth--
604
+ }
605
+ const owned = truthy.states.length || falsy.states.length ? { true: truthy.states, false: falsy.states } : undefined
606
+ const mount = Boolean(owned) || truthy.html.includes("data-k-") || falsy.html.includes("data-k-") || truthy.html.includes("<!--k-text:") || falsy.html.includes("<!--k-text:")
607
+ const metadata = { id, kind: node.kind, initial: node.value, ...descriptor, ...(owned ? { owned } : {}), ...(mount ? { mount: true } : {}) }
595
608
  for (const stateId of stateIds) renderContext.conditionStates.add(stateId)
596
609
  renderContext.conditions.push(metadata)
597
610
  renderContext.hasBehaviors = true
598
611
  renderContext.hasBindings = true
599
612
  const encoded = escapeJsonAttribute(metadata)
600
- const current = node.value ? truthy : node.kind === "and" ? await renderNode(node.value, namespace, selectValue) : falsy
601
- return `<template data-k-if='${encoded}'><template data-k-true>${truthy}</template><template data-k-false>${falsy}</template></template>${current}<template data-k-if-end="${id}"></template>`
613
+ const current = node.value ? truthy.html : node.kind === "and" ? await renderNode(node.value, namespace, selectValue) : falsy.html
614
+ return `<template data-k-if='${encoded}'><template data-k-true>${truthy.html}</template><template data-k-false>${falsy.html}</template></template>${current}<template data-k-if-end="${id}"></template>`
602
615
  }
603
616
  if (node?.[listMarker]) return renderList(node, namespace, selectValue)
604
617
  if (node?.[listFieldMarker]) {
@@ -195,7 +195,7 @@ function updateList(list) {
195
195
  for (const { item, index, key, token, value } of entries) {
196
196
  let node = list.roots.get(token)
197
197
  if (!node) {
198
- const staticRoot = __KUDZU_STATIC_COLLECTIONS__ && list.staticRows?.get(token)?.cloneNode(true)
198
+ const staticRoot = __KUDZU_STATIC_COLLECTIONS__ ? list.staticRows?.get(token)?.cloneNode(true) : undefined
199
199
  node = staticRoot ?? (__KUDZU_NESTED_LISTS__ ? list.templateRoot : list.start.content.firstElementChild)?.cloneNode(true)
200
200
  if (!staticRoot && node?.dataset.kListRoot !== list.descriptor.id) node = undefined
201
201
  if (!node) throw new Error("Keyed list template has no root element")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.7.14",
3
+ "version": "0.7.16",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",