@kudzujs/core 0.7.14 → 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 +1 -1
- package/RELEASES.md +29 -0
- package/framework/README.md +2 -0
- package/framework/binding-runtime.js +16 -1
- package/framework/core.mjs +22 -9
- package/package.json +1 -1
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
|
+
**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)
|
package/RELEASES.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
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
|
+
|
|
3
32
|
## 0.7.14 - Intrinsic forwardRef
|
|
4
33
|
|
|
5
34
|
Kudzu 0.7.14 preserves conventional direct `forwardRef()` component authoring while erasing the wrapper into build-time intrinsic output.
|
package/framework/README.md
CHANGED
|
@@ -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.
|
|
@@ -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()
|
package/framework/core.mjs
CHANGED
|
@@ -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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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]) {
|