@kudzujs/core 0.7.13 → 0.7.15

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.13 - Deterministic useId.** Ordinary React-shaped components can retain top-level `useId()` calls while Kudzu emits stable static HTML IDs and no browser capability. Read the [release notes](./RELEASES.md#0713---deterministic-useid) or open the [release page](https://kudzujs.cloud/releases/0.7.13).
13
+ **Latest release: 0.7.15 - Child state ownership.** Repeated ordinary child components own independent state, while reactive conditional removal deletes child slots and remount recreates their initial values. Read the [release notes](./RELEASES.md#0715---child-state-ownership) or open the [release page](https://kudzujs.cloud/releases/0.7.15).
14
14
 
15
15
  - [Documentation](https://kudzujs.cloud/docs)
16
16
  - [Installation guide](https://kudzujs.cloud/docs#install)
@@ -78,7 +78,7 @@ ordinary React-shaped TSX
78
78
  ```
79
79
 
80
80
  - Function components execute at build time and do not survive as browser components.
81
- - `useState` and reduced `useReducer` compile to synchronous logical state and batched direct DOM writes; top-level `useId` becomes deterministic static HTML without browser JavaScript.
81
+ - `useState` and reduced `useReducer` compile to synchronous logical state and batched direct DOM writes; top-level `useId` and direct intrinsic `forwardRef` authoring erase to static HTML without a component runtime.
82
82
  - Conditions, keyed collections, attributes, events, refs, effects, and supported component boundaries compile to route-specific capabilities.
83
83
  - Build-known data and routes become complete HTML through async components and `getStaticPaths()`.
84
84
  - Native document navigation is the default; static routes do not load a client runtime.
package/RELEASES.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.7.15 - Child state ownership
4
+
5
+ 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.
6
+
7
+ ### New in 0.7.15
8
+
9
+ - Repeated same-file and relative-imported child components receive distinct concrete state IDs.
10
+ - Shared generated handler modules retain per-element state maps and serializable prop captures, so one child update cannot mutate its sibling.
11
+ - Reactive conditional descriptors record only state created directly by each branch.
12
+ - Conditional removal unmounts DOM capabilities and deletes the active branch's owned state slots.
13
+ - Re-entry recreates primitive, object, or array state from serialized initial values instead of restoring stale state.
14
+ - Initially active branches reuse their pre-rendered state IDs rather than executing the child component a second time.
15
+ - Nested condition owners remain independent, and existing route/layout, keyed-row, effect, and navigation ownership behavior is preserved.
16
+ - Static sibling routes remain JavaScript-free; no component registry, hook dispatcher, or generic rerender loop is emitted.
17
+
18
+ ### Measured fixture
19
+
20
+ 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.
21
+
22
+ ### Boundary
23
+
24
+ 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.
25
+
26
+ ### Upgrade
27
+
28
+ ```bash
29
+ npm install @kudzujs/core@^0.7.15
30
+ ```
31
+
32
+ ## 0.7.14 - Intrinsic forwardRef
33
+
34
+ Kudzu 0.7.14 preserves conventional direct `forwardRef()` component authoring while erasing the wrapper into build-time intrinsic output.
35
+
36
+ ### New in 0.7.14
37
+
38
+ - `forwardRef` may be imported directly or with an alias from `react`, or called as a direct default/namespace React member.
39
+ - One top-level `const` component may wrap one inline synchronous `(props, ref)` render function.
40
+ - The compiler removes `ref` from ordinary props and rest bindings before supplying it as the render function's second parameter.
41
+ - The forwarded object ref must appear exactly once on the direct intrinsic root and reuses Kudzu's existing deterministic ref marker.
42
+ - Components remain valid when the optional ref prop is omitted; `null` and `undefined` intrinsic refs emit no marker.
43
+ - Same-file and relative-imported components compile without React, a wrapper function, hydration, or a browser component runtime.
44
+ - Indirect callbacks, async/generator renders, callback or composed refs, fragments, component roots, nested targets, repeated forwarding, and `memo(forwardRef(...))` fail with source diagnostics.
45
+
46
+ ### Boundary
47
+
48
+ This release intentionally supports one direct object-ref boundary. It does not add React ref objects, imperative handles, generic ref composition, or browser component instances. Existing keyed-row ownership checks still require refs used inside keyed lists to originate from that row.
49
+
50
+ ### Upgrade
51
+
52
+ ```bash
53
+ npm install @kudzujs/core@^0.7.14
54
+ ```
55
+
3
56
  ## 0.7.13 - Deterministic useId
4
57
 
5
58
  Kudzu 0.7.13 preserves conventional top-level React `useId()` authoring while emitting deterministic static HTML IDs with no browser runtime.
@@ -2,10 +2,12 @@
2
2
 
3
3
  Kudzu specializes ordinary common React-shaped TSX so migrations need minimal source restructuring. Declarative components, collection pipelines, conditions, hooks, and handlers should be lowered at build time rather than replaced with application-owned imperative DOM code. This principle applies across migrations and is not Stay-specific; it does not imply a React package, VDOM, hydration, or ecosystem runtime.
4
4
 
5
- Migration source may retain conventional `react` imports for supported named or aliased hooks, direct members such as `React.useState`, same-file `memo`, inline `useCallback`, direct-state expression or analyzable collection-pipeline `useMemo`, top-level `const` identifiers initialized by `useId()`, and default, namespace, or named `Fragment`. `useId()` becomes a deterministic build-time HTML ID and emits no browser capability; keyed rows reject it because cloned row templates cannot safely duplicate HTML IDs. Collection memos may start from local array state or a named relative import of an exported JSON-safe `const` array and may read direct local state declared in their dependency array. `build.mjs` canonicalizes those forms and rewrites module references to `@kudzujs/core` before build-time evaluation. Memo wrappers are erased or inlined into existing bindings and keyed-list selectors because no browser component rerender or memo cache exists. Static routes remain JavaScript-free and emitted modules are checked for surviving React imports.
5
+ Migration source may retain conventional `react` imports for supported named or aliased hooks, direct members such as `React.useState`, same-file `memo`, inline `useCallback`, direct-state expression or analyzable collection-pipeline `useMemo`, direct intrinsic `forwardRef`, top-level `const` identifiers initialized by `useId()`, and default, namespace, or named `Fragment`. `forwardRef()` accepts one inline synchronous `(props, ref)` function and requires the object ref exactly once on its direct intrinsic root; the compiler removes `ref` from props/rest and erases the wrapper. `useId()` becomes a deterministic build-time HTML ID and emits no browser capability; keyed rows reject it because cloned row templates cannot safely duplicate HTML IDs. Collection memos may start from local array state or a named relative import of an exported JSON-safe `const` array and may read direct local state declared in their dependency array. `build.mjs` canonicalizes those forms and rewrites module references to `@kudzujs/core` before build-time evaluation. Memo wrappers are erased or inlined into existing bindings and keyed-list selectors because no browser component rerender or memo cache exists. Static routes remain JavaScript-free and emitted modules are checked for surviving React imports.
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.
@@ -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()
@@ -1883,7 +1883,7 @@ function normalizeZustandMigrationSyntax(sourceFile, factory, context) {
1883
1883
 
1884
1884
  function normalizeReactMigrationSyntax(sourceFile, factory, context, importedCollections = new Set()) {
1885
1885
  const supported = new Set(["createContext", "useContext", "useEffect", "useId", "useReducer", "useRef", "useState"])
1886
- const erased = new Set(["memo", "useCallback", "useMemo"])
1886
+ const erased = new Set(["forwardRef", "memo", "useCallback", "useMemo"])
1887
1887
  const aliases = new Map()
1888
1888
  const reactObjects = new Set()
1889
1889
  for (const statement of sourceFile.statements) {
@@ -1988,6 +1988,7 @@ function normalizeReactMigrationSyntax(sourceFile, factory, context, importedCol
1988
1988
  }
1989
1989
  if (ts.isCallExpression(node)) {
1990
1990
  const name = migrationCallName(node)
1991
+ if (name === "forwardRef") return ts.visitNode(lowerReactForwardRef(node, sourceFile, factory), visitor)
1991
1992
  if (name === "memo") {
1992
1993
  if (node.arguments.length !== 1 || !(ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0]) || ts.isIdentifier(node.arguments[0]))) throw sourceNodeError(node, sourceFile, "React memo() requires exactly one function component or component identifier")
1993
1994
  if (ts.isIdentifier(node.arguments[0]) && isShadowedIdentifier(node.arguments[0], sourceFile)) throw sourceNodeError(node.arguments[0], sourceFile, "React memo() component identifiers must resolve to an unshadowed same-file top-level function")
@@ -2074,6 +2075,60 @@ function normalizeReactMigrationSyntax(sourceFile, factory, context, importedCol
2074
2075
  return normalized
2075
2076
  }
2076
2077
 
2078
+ function lowerReactForwardRef(call, sourceFile, factory) {
2079
+ const declaration = call.parent
2080
+ const statement = declaration?.parent?.parent
2081
+ if (!ts.isVariableDeclaration(declaration) || declaration.initializer !== call || !ts.isIdentifier(declaration.name) || !statement || !ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0 || statement.parent !== sourceFile) {
2082
+ throw sourceNodeError(call, sourceFile, "React forwardRef() must directly initialize one top-level const component")
2083
+ }
2084
+ if (call.arguments.length !== 1 || !ts.isArrowFunction(call.arguments[0]) && !ts.isFunctionExpression(call.arguments[0])) throw sourceNodeError(call, sourceFile, "React forwardRef() requires exactly one inline render function")
2085
+ const callback = call.arguments[0]
2086
+ if (callback.asteriskToken || callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) throw sourceNodeError(callback, sourceFile, "React forwardRef() render function must be synchronous and cannot be a generator")
2087
+ if (callback.parameters.length !== 2) throw sourceNodeError(callback, sourceFile, "React forwardRef() render function must declare exactly (props, ref)")
2088
+ const [props, ref] = callback.parameters
2089
+ if (props.dotDotDotToken || props.initializer || !ts.isIdentifier(props.name) && !ts.isObjectBindingPattern(props.name)) throw sourceNodeError(props, sourceFile, "React forwardRef() props must use one identifier or a flat object binding")
2090
+ if (ref.dotDotDotToken || ref.initializer || !ts.isIdentifier(ref.name)) throw sourceNodeError(ref, sourceFile, "React forwardRef() ref parameter must be one identifier")
2091
+
2092
+ let elements
2093
+ if (ts.isIdentifier(props.name)) {
2094
+ elements = [
2095
+ factory.createBindingElement(undefined, factory.createIdentifier("ref"), factory.createIdentifier(ref.name.text)),
2096
+ factory.createBindingElement(factory.createToken(ts.SyntaxKind.DotDotDotToken), undefined, factory.createIdentifier(props.name.text))
2097
+ ]
2098
+ } else {
2099
+ for (const element of props.name.elements) {
2100
+ const property = (element.propertyName ?? element.name)
2101
+ if (!ts.isIdentifier(element.name) || property.text === "ref") throw sourceNodeError(element, sourceFile, property.text === "ref" ? "React forwardRef() props must not declare ref; Kudzu supplies ref through the second parameter" : "React forwardRef() props must use one identifier or a flat object binding")
2102
+ }
2103
+ const rest = props.name.elements.findIndex(element => Boolean(element.dotDotDotToken))
2104
+ elements = [...props.name.elements]
2105
+ elements.splice(rest < 0 ? elements.length : rest, 0, factory.createBindingElement(undefined, factory.createIdentifier("ref"), factory.createIdentifier(ref.name.text)))
2106
+ }
2107
+
2108
+ const last = ts.isBlock(callback.body) ? callback.body.statements.at(-1) : undefined
2109
+ let returnCount = 0
2110
+ const countReturns = node => {
2111
+ if (node !== callback.body && isFunctionLike(node)) return
2112
+ if (ts.isReturnStatement(node)) returnCount++
2113
+ ts.forEachChild(node, countReturns)
2114
+ }
2115
+ countReturns(callback.body)
2116
+ const returned = ts.isBlock(callback.body)
2117
+ ? last && ts.isReturnStatement(last) ? last.expression : undefined
2118
+ : callback.body
2119
+ const root = returned && unwrapExpression(returned)
2120
+ const tag = root && jsxTagName(root)
2121
+ if ((ts.isBlock(callback.body) && returnCount !== 1) || !root || !ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root) || !ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) throw sourceNodeError(callback.body, sourceFile, "React forwardRef() render function must directly return one intrinsic JSX element")
2122
+ const attributes = ts.isJsxElement(root) ? root.openingElement.attributes : root.attributes
2123
+ const forwarded = attributes.properties.filter(attribute => ts.isJsxAttribute(attribute) && attribute.name.text === "ref" && ts.isJsxExpression(attribute.initializer) && ts.isIdentifier(attribute.initializer.expression) && attribute.initializer.expression.text === ref.name.text)
2124
+ if (forwarded.length !== 1 || referenceIdentifiers(callback.body, ref.name.text).length !== 1) throw sourceNodeError(ref, sourceFile, "React forwardRef() ref must be forwarded exactly once as ref={ref} on the direct intrinsic root")
2125
+
2126
+ const parameter = factory.updateParameterDeclaration(props, props.modifiers, undefined, factory.createObjectBindingPattern(elements), props.questionToken, props.type, undefined)
2127
+ return ts.isArrowFunction(callback)
2128
+ ? factory.updateArrowFunction(callback, callback.modifiers, callback.typeParameters, [parameter], callback.type, callback.equalsGreaterThanToken, callback.body)
2129
+ : factory.updateFunctionExpression(callback, callback.modifiers, undefined, callback.name, callback.typeParameters, [parameter], callback.type, callback.body)
2130
+ }
2131
+
2077
2132
  function validateUseIdSyntax(sourceFile) {
2078
2133
  const imported = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && ["@kudzujs/core", "react"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useId"))
2079
2134
  if (!imported) return
@@ -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]) {
@@ -708,6 +721,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
708
721
  for (const [rawName, value] of Object.entries(props)) {
709
722
  if (rawName === "children" || rawName === "key") continue
710
723
  if (rawName === "ref") {
724
+ if (value == null) continue
711
725
  if (!value?.[refMarker]) throw new Error("ref must be created by useRef(null)")
712
726
  if (renderContext.listDepth && !value.row) throw new Error("Refs in keyed lists must be declared by the keyed row component")
713
727
  attributes += ` data-k-ref="${value.id}"`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.7.13",
3
+ "version": "0.7.15",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",