@kudzujs/core 0.7.25 → 0.7.26

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.25 - Router-shaped native navigation.** Supported React Router `useNavigate()` calls now lower to base-aware native document navigation through `location.assign()` or `location.replace()`, with no router runtime. Read the [release notes](./RELEASES.md#0725---router-shaped-native-navigation) or open the [release page](https://kudzujs.cloud/releases/0.7.25).
13
+ **Latest release: 0.7.26 - React-shaped migration and faster lists.** React-compatible JSX typing, setter-adapter specialization, imported collection normalization, and indexed keyed-row release make ordinary migrations easier while moving the measured 500-card search ahead of React. Read the [release notes](./RELEASES.md#0726---react-shaped-migration-and-faster-lists) or open the [release page](https://kudzujs.cloud/releases/0.7.26).
14
14
 
15
15
  - [Documentation](https://kudzujs.cloud/docs)
16
16
  - [Installation guide](https://kudzujs.cloud/docs#install)
@@ -79,7 +79,7 @@ ordinary React-shaped TSX
79
79
 
80
80
  - Function components execute at build time and do not survive as browser components.
81
81
  - `useState` and reduced `useReducer`, including directly serializable lazy initialization, 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
- - Conditions, keyed collections, attributes, events, refs, effects, and supported component boundaries compile to route-specific capabilities. Inline or simple `const` setter callbacks and object refs may cross one ordinary component boundary into a direct intrinsic root.
82
+ - Conditions, keyed collections, attributes, events, refs, effects, and supported component boundaries compile to route-specific capabilities. A direct setter may cross one ordinary component boundary through one value-adapter event call; inline or simple `const` setter callbacks and object refs may cross the same direct intrinsic boundary.
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.
85
85
  - A named or aliased React Router `Link` with a static root-relative `to` erases to a base-aware native anchor; no router package or runtime is emitted.
package/RELEASES.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.7.26 - React-shaped migration and faster lists
4
+
5
+ Kudzu 0.7.26 broadens ordinary React-shaped migration input and removes repeated keyed-row cleanup scans, making the matched 500-card search faster than React without adding a VDOM, hydration, or a retained component tree.
6
+
7
+ ### New in 0.7.26
8
+
9
+ - JSX typing accepts React 19 `ReactNode`-shaped component output while preserving Kudzu's compile-time element model.
10
+ - Intrinsic DOM handlers retain contextual event typing through migration-compatible JSX declarations.
11
+ - A direct setter or one-call value-adapter callback may cross one supported component boundary, covering ordinary controlled search inputs without serializing a function.
12
+ - Imported static collections wrapped in TypeScript-only assertions such as `as const` remain analyzable and erase normally.
13
+ - Collection selector execution caches state reads and avoids recursive rest-array, `slice()`, and `map()` allocation in hot expression paths.
14
+ - Safe keyed rows with local state but no row effects, nested lists, or shared text targets release binding and condition registrations directly by state ID instead of rescanning every removed subtree.
15
+ - Rows outside that proven boundary continue through the existing DOM-owned unmount path.
16
+ - A 21-run alternating fresh-profile benchmark measured the 500-card filter at 13.5 ms for Kudzu and 13.9 ms for React, making Kudzu 2.88% faster and 52.30% faster than its preserved 28.3 ms baseline.
17
+ - The same benchmark measured Kudzu's build 11.90% faster, keyed row toggle 7.02% faster, and emitted JavaScript 87.29% smaller by aggregate gzip.
18
+ - The complete suite passes 118/118 tests, including remove-all, unseen-key remount, and fresh row-state coverage for the indexed release path.
19
+
20
+ ### Boundary
21
+
22
+ Indexed row release is compiler-selected only when cleanup can be proven from row-owned state IDs and the row has no effects, nested lists, or shared text targets. Effectful, nested, and otherwise lifecycle-bearing rows retain the general ownership cleanup path. Setter adapters remain limited to one supported component boundary and one direct intrinsic handler call. React remains migration input only and is never emitted or executed.
23
+
24
+ ### Upgrade
25
+
26
+ ```bash
27
+ npm install @kudzujs/core@^0.7.26
28
+ ```
29
+
3
30
  ## 0.7.25 - Router-shaped native navigation
4
31
 
5
32
  Kudzu 0.7.25 accepts a narrow React Router `useNavigate()` source shape and lowers safe static destinations to native full-document navigation without shipping React Router or adding an SPA runtime.
@@ -2,7 +2,7 @@
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`, 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.
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`. Kudzu's JSX declarations accept ReactNode-shaped component returns and contextually type common intrinsic DOM events, so strict React component props do not need migration-only `unknown` or explicit event annotations. `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, including type-only `as const` and `satisfies` wrappers, 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
  A named or aliased `Link` import from `react-router-dom` may render directly with one static root-relative `to` plus native anchor props. The compiler prefixes the configured `base`, changes the element to `<a href>`, and erases the import, so native navigation remains the default and configured navigation groups see an ordinary eligible anchor. Dynamic or relative destinations, `NavLink`, router-only props, spreads, default/namespace imports, and non-JSX uses fail with source diagnostics. No React Router package code or router runtime is emitted.
8
8
 
@@ -47,7 +47,7 @@ Rendered collection selectors compile immutable local aliases and inline `(item)
47
47
 
48
48
  The reduced `useReducer` form reuses ordinary state slots and React's pure reducer contract. An optional inline, same-file, or relative-imported synchronous one-parameter initializer may derive a directly serializable literal only from its directly serializable initial argument; the compiler substitutes that argument and lowers the call to the ordinary two-argument ownership path. 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.
49
49
 
50
- An inline or simple `const` setter callback may cross one same-file or relative-imported component boundary when the child forwards it to an event on its direct intrinsic root. A `null`-initialized object ref may cross the same boundary to that root. Both remain compiler-owned descriptors: conditional removal drops the handler with the element and makes the ref resolve to `null`, while remount creates a fresh element without retaining a component instance.
50
+ A direct setter may cross one same-file or relative-imported component boundary when the child invokes it exactly once inside an intrinsic event handler. Inline and simple `const` setter callbacks may use that adapter shape or direct event forwarding. Value adapters such as `event => onValueChange(event.currentTarget.value)` specialize into the parent setter graph instead of serializing a function. A `null`-initialized object ref may cross the same boundary to the direct intrinsic root. Both remain compiler-owned descriptors: conditional removal drops the handler with the element and makes the ref resolve to `null`, while remount creates a fresh element without retaining a component instance.
51
51
 
52
52
  `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.
53
53
 
@@ -1,4 +1,4 @@
1
- import { browserState, mountDom, registerCommitter, registerMountHook, registerUnmountHook, unmountDom } from "./shared-runtime.js"
1
+ import { browserState, mountDom, registerCommitter, registerMountHook, registerStateReleaseHook, registerUnmountHook, releaseState, unmountDom } from "./shared-runtime.js"
2
2
  import { deserialize } from "./serialization.js"
3
3
  import { serializeStyle } from "./style.js"
4
4
 
@@ -63,6 +63,7 @@ registerMountHook(mountBindings)
63
63
  registerMountHook(mountConditions)
64
64
  registerUnmountHook(unmountBindings)
65
65
  registerUnmountHook(unmountConditions)
66
+ registerStateReleaseHook(releaseBindings)
66
67
 
67
68
  if (typeof document !== "undefined") mountDom(document)
68
69
 
@@ -195,6 +196,21 @@ function unmountConditions(root) {
195
196
  }
196
197
  }
197
198
 
199
+ function releaseBindings(id) {
200
+ for (const binding of bindingTargets.get(id) ?? []) {
201
+ for (const [bindingId, entry] of bindingRegistrations.get(binding.node) ?? []) bindingTargets.get(bindingId)?.delete(entry)
202
+ bindingRegistrations.delete(binding.node)
203
+ }
204
+ bindingTargets.delete(id)
205
+ for (const condition of conditionTargets.get(id) ?? []) {
206
+ const registration = conditionRegistrations.get(condition.start)
207
+ for (const [conditionId, entry] of registration?.registrations ?? []) conditionTargets.get(conditionId)?.delete(entry)
208
+ unmountConditionStates(condition, condition.current === "true")
209
+ conditionRegistrations.delete(condition.start)
210
+ }
211
+ conditionTargets.delete(id)
212
+ }
213
+
198
214
  function mountConditionStates(condition, truthy, replace) {
199
215
  for (const [id, initialValue] of condition.owned?.[truthy ? "true" : "false"] ?? []) {
200
216
  if (replace || !browserState.has(id)) browserState.set(id, initialValue !== null && typeof initialValue === "object" ? structuredClone(initialValue) : initialValue)
@@ -202,7 +218,7 @@ function mountConditionStates(condition, truthy, replace) {
202
218
  }
203
219
 
204
220
  function unmountConditionStates(condition, truthy) {
205
- for (const [id] of condition.owned?.[truthy ? "true" : "false"] ?? []) browserState.delete(id)
221
+ for (const [id] of condition.owned?.[truthy ? "true" : "false"] ?? []) releaseState(id)
206
222
  }
207
223
 
208
224
  function removeConditionRange(start, end, mount) {
@@ -2842,6 +2842,45 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2842
2842
  stateBackedComponentRoots.push(specialization.root)
2843
2843
  }
2844
2844
  }
2845
+ const specializeSetterCallbacks = (call, component, callbackProps, imported) => {
2846
+ if (componentSpecializations.has(call)) fail(call, "Setter callback props cannot be combined with another component specialization")
2847
+ if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) fail(component, "Setter-callback components must use one destructured props parameter")
2848
+ for (const prop of callbackProps) {
2849
+ const element = component.parameters[0].name.elements.find(entry => !entry.dotDotDotToken && (entry.propertyName ?? entry.name).getText() === prop)
2850
+ if (!element || !ts.isIdentifier(element.name)) fail(call, `Setter-callback component must destructure callback prop ${JSON.stringify(prop)}`)
2851
+ const references = []
2852
+ const collectReferences = node => {
2853
+ if (ts.isIdentifier(node) && node.text === element.name.text && isReferenceIdentifier(node)) references.push(node)
2854
+ ts.forEachChild(node, collectReferences)
2855
+ }
2856
+ collectReferences(component.body)
2857
+ if (references.length !== 1) fail(element, `Setter-callback prop ${JSON.stringify(prop)} must be used exactly once in the component`)
2858
+ }
2859
+ const specialization = specializeComponentCall(call, component, sourceFile, factory, context, fail, "Setter-callback")
2860
+ if (specialization.effects.length || specialization.hookDeclarations.length) fail(call, "Setter-callback components cannot declare hooks")
2861
+ if (imported) synthesizeTree(specialization.root = mergeSpecializedImports(specialization.root, component.getSourceFile(), call, specialization.effects))
2862
+ componentSpecializations.set(call, specialization)
2863
+ }
2864
+ for (const [name, component] of components) {
2865
+ for (const call of jsxTagUses(sourceFile, name)) {
2866
+ const callbackProps = jsxSetterCallbackProps(call, settersByFunction.get(nearestFunction(call)) ?? new Map(), functions, reducersForNode(call, reducersByFunction))
2867
+ if (callbackProps.length) specializeSetterCallbacks(call, component.function, callbackProps, false)
2868
+ }
2869
+ }
2870
+ for (const [name, binding] of importBindings) {
2871
+ if (binding.kind === "namespace") continue
2872
+ const calls = jsxTagUses(sourceFile, name)
2873
+ const callbackCalls = calls.map(call => ({ call, callbackProps: jsxSetterCallbackProps(call, settersByFunction.get(nearestFunction(call)) ?? new Map(), functions, reducersForNode(call, reducersByFunction)) })).filter(entry => entry.callbackProps.length)
2874
+ if (!callbackCalls.length) continue
2875
+ const imported = binding.kind === "default" ? "default" : binding.imported
2876
+ let component
2877
+ try {
2878
+ component = resolveComponentExport(binding.target, imported, importedSource, sourceFiles)
2879
+ } catch {
2880
+ fail(callbackCalls[0].call, "Setter callback props require a component imported from a relative TypeScript module")
2881
+ }
2882
+ for (const { call, callbackProps } of callbackCalls) specializeSetterCallbacks(call, component, callbackProps, true)
2883
+ }
2845
2884
  for (const [name, component] of components) {
2846
2885
  const calls = jsxTagUses(sourceFile, name)
2847
2886
  const dispatchCalls = calls.filter(call => jsxCallHasDirectReducerProp(call, reducersForNode(call, reducersByFunction)))
@@ -3719,6 +3758,17 @@ function jsxCallHasDirectStateProp(call, setters) {
3719
3758
  })
3720
3759
  }
3721
3760
 
3761
+ function jsxSetterCallbackProps(call, setters, functions, reducers) {
3762
+ const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
3763
+ return attributes.properties.flatMap(attribute => {
3764
+ if (!ts.isJsxAttribute(attribute) || !/^on[A-Z]/.test(attribute.name.text) || !attribute.initializer || !ts.isJsxExpression(attribute.initializer) || !attribute.initializer.expression) return []
3765
+ const value = unwrapExpression(attribute.initializer.expression)
3766
+ if (ts.isIdentifier(value) && setters.has(value.text)) return [attribute.name.text]
3767
+ const callback = ts.isArrowFunction(value) || ts.isFunctionExpression(value) ? value : ts.isIdentifier(value) ? functions.get(value.text) : undefined
3768
+ return callback && !nativeCaptureNames(callback, setters).size && !referencedReducerDispatches(callback.body, reducers, callback).size && referencedStateNames(callback.body, setters, callback).size ? [attribute.name.text] : []
3769
+ })
3770
+ }
3771
+
3722
3772
  function jsxCallHasDirectReducerProp(call, reducers) {
3723
3773
  const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
3724
3774
  return attributes.properties.some(attribute => {
@@ -4404,7 +4454,7 @@ function identifierReferences(root, name) {
4404
4454
  }
4405
4455
 
4406
4456
  function unwrapExpression(node) {
4407
- return ts.isParenthesizedExpression(node) ? unwrapExpression(node.expression) : node
4457
+ return ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isNonNullExpression(node) || ts.isSatisfiesExpression(node) ? unwrapExpression(node.expression) : node
4408
4458
  }
4409
4459
 
4410
4460
  function isLocalConst(node) {
@@ -4712,6 +4762,7 @@ function isReferenceIdentifier(node) {
4712
4762
  (ts.isVariableDeclaration(parent) && parent.name === node) ||
4713
4763
  (ts.isParameter(parent) && parent.name === node) ||
4714
4764
  (ts.isFunctionDeclaration(parent) && parent.name === node) ||
4765
+ (ts.isJsxAttribute(parent) && parent.name === node) ||
4715
4766
  (ts.isBindingElement(parent) && (parent.name === node || parent.propertyName === node)) ||
4716
4767
  ts.isImportSpecifier(parent) || ts.isImportClause(parent)) return false
4717
4768
  return true
@@ -1,13 +1,18 @@
1
1
  export function selectCollection(anchor, selector = [], readState) {
2
2
  let values = anchor == null ? [] : anchor
3
+ const state = readState ? new Map() : undefined
4
+ const read = state ? name => {
5
+ if (!state.has(name)) state.set(name, readState(name))
6
+ return state.get(name)
7
+ } : undefined
3
8
  for (const operation of selector) {
4
- if (operation[0] === "from") values = Array.from(values, operation[1] ? (item, index) => evaluateCollectionExpression(operation[1], item, index, readState) : undefined)
9
+ if (operation[0] === "from") values = Array.from(values, operation[1] ? (item, index) => evaluateCollectionExpression(operation[1], item, index, read) : undefined)
5
10
  else {
6
11
  if (!Array.isArray(values)) throw new Error("Rendered collection source must remain an array")
7
- if (operation[0] === "filter") values = values.filter((item, index) => evaluateCollectionExpression(operation[1], item, index, readState))
12
+ if (operation[0] === "filter") values = values.filter((item, index) => evaluateCollectionExpression(operation[1], item, index, read))
8
13
  else if (operation[0] === "flatMap") values = values.flatMap(item => item?.[operation[1]] ?? [])
9
- else if (operation[0] === "slice") values = values.slice(evaluateCollectionExpression(operation[1], undefined, undefined, readState), operation[2] && evaluateCollectionExpression(operation[2], undefined, undefined, readState))
10
- else if (operation[0] === "sort") values = [...values].sort((left, right) => evaluateCollectionExpression(operation[1], left, right, readState))
14
+ else if (operation[0] === "slice") values = values.slice(evaluateCollectionExpression(operation[1], undefined, undefined, read), operation[2] && evaluateCollectionExpression(operation[2], undefined, undefined, read))
15
+ else if (operation[0] === "sort") values = [...values].sort((left, right) => evaluateCollectionExpression(operation[1], left, right, read))
11
16
  }
12
17
  }
13
18
  if (!Array.isArray(values)) throw new Error("Rendered collection source must remain an array")
@@ -15,55 +20,63 @@ export function selectCollection(anchor, selector = [], readState) {
15
20
  }
16
21
 
17
22
  export function evaluateCollectionExpression(expression, item, index, readState) {
18
- const [kind, ...parts] = expression
19
- if (kind === "value") return parts[0]
23
+ const kind = expression[0]
24
+ if (kind === "value") return expression[1]
20
25
  if (kind === "undefined") return undefined
21
26
  if (kind === "item") return item
22
27
  if (kind === "index") return index
23
28
  if (kind === "state") {
24
- if (!readState) throw new Error(`Rendered collection state ${JSON.stringify(parts[0])} is not available`)
25
- return readState(parts[0])
29
+ if (!readState) throw new Error(`Rendered collection state ${JSON.stringify(expression[1])} is not available`)
30
+ return readState(expression[1])
26
31
  }
27
32
  if (kind === "get") {
28
- const object = evaluateCollectionExpression(parts[0], item, index, readState)
29
- return object == null && parts[2] ? undefined : object[parts[1]]
33
+ const object = evaluateCollectionExpression(expression[1], item, index, readState)
34
+ return object == null && expression[3] ? undefined : object[expression[2]]
30
35
  }
31
36
  if (kind === "unary") {
32
- const value = evaluateCollectionExpression(parts[1], item, index, readState)
33
- if (parts[0] === "!") return !value
34
- if (parts[0] === "+") return +value
35
- if (parts[0] === "-") return -value
36
- if (parts[0] === "typeof") return typeof value
37
+ const value = evaluateCollectionExpression(expression[2], item, index, readState)
38
+ if (expression[1] === "!") return !value
39
+ if (expression[1] === "+") return +value
40
+ if (expression[1] === "-") return -value
41
+ if (expression[1] === "typeof") return typeof value
37
42
  }
38
43
  if (kind === "binary") {
39
- const left = evaluateCollectionExpression(parts[1], item, index, readState)
40
- if (parts[0] === "&&") return left && evaluateCollectionExpression(parts[2], item, index, readState)
41
- if (parts[0] === "||") return left || evaluateCollectionExpression(parts[2], item, index, readState)
42
- if (parts[0] === "??") return left ?? evaluateCollectionExpression(parts[2], item, index, readState)
43
- const right = evaluateCollectionExpression(parts[2], item, index, readState)
44
- if (parts[0] === "===") return left === right
45
- if (parts[0] === "!==") return left !== right
46
- if (parts[0] === "==") return left == right
47
- if (parts[0] === "!=") return left != right
48
- if (parts[0] === "<") return left < right
49
- if (parts[0] === "<=") return left <= right
50
- if (parts[0] === ">") return left > right
51
- if (parts[0] === ">=") return left >= right
52
- if (parts[0] === "+") return left + right
53
- if (parts[0] === "-") return left - right
54
- if (parts[0] === "*") return left * right
55
- if (parts[0] === "/") return left / right
56
- if (parts[0] === "%") return left % right
44
+ const operator = expression[1]
45
+ const left = evaluateCollectionExpression(expression[2], item, index, readState)
46
+ if (operator === "&&") return left && evaluateCollectionExpression(expression[3], item, index, readState)
47
+ if (operator === "||") return left || evaluateCollectionExpression(expression[3], item, index, readState)
48
+ if (operator === "??") return left ?? evaluateCollectionExpression(expression[3], item, index, readState)
49
+ const right = evaluateCollectionExpression(expression[3], item, index, readState)
50
+ if (operator === "===") return left === right
51
+ if (operator === "!==") return left !== right
52
+ if (operator === "==") return left == right
53
+ if (operator === "!=") return left != right
54
+ if (operator === "<") return left < right
55
+ if (operator === "<=") return left <= right
56
+ if (operator === ">") return left > right
57
+ if (operator === ">=") return left >= right
58
+ if (operator === "+") return left + right
59
+ if (operator === "-") return left - right
60
+ if (operator === "*") return left * right
61
+ if (operator === "/") return left / right
62
+ if (operator === "%") return left % right
57
63
  }
58
- if (kind === "conditional") return evaluateCollectionExpression(parts[0], item, index, readState) ? evaluateCollectionExpression(parts[1], item, index, readState) : evaluateCollectionExpression(parts[2], item, index, readState)
59
- if (kind === "array") return parts.map(value => evaluateCollectionExpression(value, item, index, readState))
60
- if (kind === "object") return Object.fromEntries(parts.map(([key, value]) => [key, evaluateCollectionExpression(value, item, index, readState)]))
61
- if (kind === "template") return parts[0].map((text, offset) => text + (offset < parts[1].length ? evaluateCollectionExpression(parts[1][offset], item, index, readState) : "")).join("")
64
+ if (kind === "conditional") return evaluateCollectionExpression(expression[1], item, index, readState) ? evaluateCollectionExpression(expression[2], item, index, readState) : evaluateCollectionExpression(expression[3], item, index, readState)
65
+ if (kind === "array") return expression.slice(1).map(value => evaluateCollectionExpression(value, item, index, readState))
66
+ if (kind === "object") return Object.fromEntries(expression.slice(1).map(([key, value]) => [key, evaluateCollectionExpression(value, item, index, readState)]))
67
+ if (kind === "template") return expression[1].map((text, offset) => text + (offset < expression[2].length ? evaluateCollectionExpression(expression[2][offset], item, index, readState) : "")).join("")
62
68
  if (kind === "call") {
63
- const receiver = evaluateCollectionExpression(parts[0], item, index, readState)
64
- return receiver[parts[1]](...parts.slice(2).map(value => evaluateCollectionExpression(value, item, index, readState)))
69
+ const receiver = evaluateCollectionExpression(expression[1], item, index, readState)
70
+ if (expression.length === 3) return receiver[expression[2]]()
71
+ if (expression.length === 4) return receiver[expression[2]](evaluateCollectionExpression(expression[3], item, index, readState))
72
+ const arguments_ = new Array(expression.length - 3)
73
+ for (let offset = 3; offset < expression.length; offset++) arguments_[offset - 3] = evaluateCollectionExpression(expression[offset], item, index, readState)
74
+ return receiver[expression[2]](...arguments_)
75
+ }
76
+ if (kind === "global" || kind === "math") {
77
+ const arguments_ = new Array(expression.length - 2)
78
+ for (let offset = 2; offset < expression.length; offset++) arguments_[offset - 2] = evaluateCollectionExpression(expression[offset], item, index, readState)
79
+ return (kind === "global" ? globalThis : Math)[expression[1]](...arguments_)
65
80
  }
66
- if (kind === "global") return globalThis[parts[0]](...parts.slice(1).map(value => evaluateCollectionExpression(value, item, index, readState)))
67
- if (kind === "math") return Math[parts[0]](...parts.slice(1).map(value => evaluateCollectionExpression(value, item, index, readState)))
68
81
  throw new Error(`Unsupported rendered collection expression: ${String(kind)}`)
69
82
  }
@@ -908,6 +908,7 @@ async function renderList(node, namespace, selectValue) {
908
908
  descriptor.rowRefs = renderContext.listRowRefs.map(({ id }) => id)
909
909
  descriptor.mount = true
910
910
  }
911
+ if (descriptor.rowStates && !descriptor.effects && !descriptor.nested && !template.includes("data-k-text")) descriptor.fastRelease = true
911
912
  const seed = node.ownerField || node.selector.length || node.keyField === null ? undefined : listSeed(node.values, renderContext.listFields)
912
913
  if (seed) descriptor.seed = seed
913
914
  let current = ""
@@ -1,12 +1,33 @@
1
1
  export namespace JSX {
2
- type Element = unknown
2
+ type Element = any
3
3
  type Children = unknown
4
4
 
5
+ type TargetedEvent<Target extends EventTarget, NativeEvent extends Event = Event> = NativeEvent & {
6
+ currentTarget: Target
7
+ target: EventTarget & Target
8
+ }
9
+
10
+ type IntrinsicProps<Target extends EventTarget> = Record<string, unknown> & {
11
+ children?: Children
12
+ onBlur?: (event: TargetedEvent<Target, FocusEvent>) => unknown
13
+ onChange?: (event: TargetedEvent<Target>) => unknown
14
+ onClick?: (event: TargetedEvent<Target, MouseEvent>) => unknown
15
+ onFocus?: (event: TargetedEvent<Target, FocusEvent>) => unknown
16
+ onInput?: (event: TargetedEvent<Target, InputEvent>) => unknown
17
+ onKeyDown?: (event: TargetedEvent<Target, KeyboardEvent>) => unknown
18
+ onKeyUp?: (event: TargetedEvent<Target, KeyboardEvent>) => unknown
19
+ onSubmit?: (event: TargetedEvent<Target, SubmitEvent>) => unknown
20
+ }
21
+
5
22
  interface IntrinsicAttributes {
6
23
  key?: string | number
7
24
  }
8
25
 
9
- interface IntrinsicElements {
26
+ type IntrinsicElements = {
27
+ [Name in keyof HTMLElementTagNameMap]: IntrinsicProps<HTMLElementTagNameMap[Name]>
28
+ } & {
29
+ [Name in keyof SVGElementTagNameMap]: IntrinsicProps<SVGElementTagNameMap[Name]>
30
+ } & {
10
31
  [elementName: string]: Record<string, unknown>
11
32
  }
12
33
  }
@@ -1,4 +1,4 @@
1
- import { browserState, mountDom, notifyListItem, registerCommitter, registerMountHook, registerUnmountHook, unmountDom } from "./shared-runtime.js"
1
+ import { browserState, mountDom, notifyListItem, registerCommitter, registerMountHook, registerUnmountHook, releaseState, unmountDom } from "./shared-runtime.js"
2
2
  import { selectCollection } from "./collection-selector.js"
3
3
 
4
4
  const listTargets = new Map()
@@ -219,7 +219,8 @@ function updateList(list) {
219
219
  }
220
220
  for (const [token, node] of list.roots) {
221
221
  if (keys.has(token)) continue
222
- if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) {
222
+ if (list.descriptor.fastRelease) node.remove()
223
+ else if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) {
223
224
  unmountDom(node)
224
225
  node.remove()
225
226
  } else node.remove()
@@ -565,7 +566,7 @@ function addListRoot(list, { item, index = list.roots.size, key, token, value })
565
566
 
566
567
  function removeListRoot(list, token) {
567
568
  const node = list.roots.get(token)
568
- if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) unmountDom(node)
569
+ if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount && !list.descriptor.fastRelease) unmountDom(node)
569
570
  node.remove()
570
571
  if (__KUDZU_LIST_ROW_HOOKS__ && list.descriptor.rowStates) deleteRowStates(list.descriptor, ownershipPaths.get(node))
571
572
  list.roots.delete(token)
@@ -1035,7 +1036,7 @@ function initializeRowStates(descriptor, key, root) {
1035
1036
  }
1036
1037
 
1037
1038
  function deleteFlatRowStates(descriptor, token) {
1038
- for (const state of descriptor.rowStates) browserState.delete(flatRowStateId(state.id, token))
1039
+ for (const state of descriptor.rowStates) releaseState(flatRowStateId(state.id, token))
1039
1040
  }
1040
1041
 
1041
1042
  function flatRowStateId(id, token) {
@@ -1044,7 +1045,7 @@ function flatRowStateId(id, token) {
1044
1045
 
1045
1046
  function deleteRowStates(descriptor, path) {
1046
1047
  const statePath = descriptor.ownerField ? path : [path.at(-1).slice(path.at(-1).indexOf("=") + 1)]
1047
- for (const state of descriptor.rowStates) browserState.delete(rowStateId(state.id, statePath))
1048
+ for (const state of descriptor.rowStates) releaseState(rowStateId(state.id, statePath))
1048
1049
  }
1049
1050
  function rowStateId(id, path) {
1050
1051
  return id.replace("$k", encodeURIComponent(path.join("/")))
@@ -26,6 +26,7 @@ export const browserState = new Map()
26
26
  const committers = []
27
27
  const mountHooks = []
28
28
  const unmountHooks = []
29
+ const stateReleaseHooks = []
29
30
  const textTargets = new Map()
30
31
  const mountedText = new WeakSet()
31
32
 
@@ -59,6 +60,15 @@ export function registerUnmountHook(unmount) {
59
60
  unmountHooks.push(unmount)
60
61
  }
61
62
 
63
+ export function registerStateReleaseHook(release) {
64
+ stateReleaseHooks.push(release)
65
+ }
66
+
67
+ export function releaseState(id) {
68
+ for (const release of stateReleaseHooks) release(id)
69
+ browserState.delete(id)
70
+ }
71
+
62
72
  export function commitDom(id, value) {
63
73
  for (const node of textTargets.get(id) ?? []) {
64
74
  if (node.isConnected) node.textContent = value
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.7.25",
3
+ "version": "0.7.26",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",