@kudzujs/core 0.7.19 → 0.7.21

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.19 - Reusable collection aliases.** One immutable local collection alias may feed multiple keyed lists when every reference remains statically analyzable; setter callbacks and object refs also have a proven one-boundary component contract. Read the [release notes](./RELEASES.md#0719---reusable-collection-aliases) or open the [release page](https://kudzujs.cloud/releases/0.7.19).
13
+ **Latest release: 0.7.21 - Composable collections and effects.** Relative pure collection transforms, slice pagination, reactive search, immutable sorting, derived primitive effect dependencies, and simple named setup/cleanup functions now lower into existing compiler-owned capabilities. Read the [release notes](./RELEASES.md#0721---composable-collections-and-effects) or open the [release page](https://kudzujs.cloud/releases/0.7.21).
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,54 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.7.21 - Composable collections and effects
4
+
5
+ Kudzu 0.7.21 expands ordinary React-shaped collection pipelines and effect authoring while preserving keyed identity, derived dependency semantics, and capability-specific browser output.
6
+
7
+ ### New in 0.7.21
8
+
9
+ - A relative-imported named or default synchronous one-parameter function may directly return a supported pure collection pipeline; Kudzu composes its selector without executing or shipping the function.
10
+ - Immutable `slice(start, end?)` accepts numeric literals and supported direct primitive state expressions for bounded pagination.
11
+ - Pure `filter` predicates may combine direct primitive state with supported string methods for reactive search.
12
+ - Expression-bodied `toSorted((left, right) => ...)` comparators compile into immutable selector sorting; mutating `sort()` fails with a source diagnostic.
13
+ - A top-level immutable primitive local derived from direct state may appear in an effect dependency array. Source commits schedule comparison, but unchanged derived results do not rerun the effect.
14
+ - Derived expressions are substituted into setup and cleanup handlers, so reruns and final disposal observe the latest value.
15
+ - Same-component top-level simple `const` setup and directly returned cleanup functions are statically substituted into effect handlers; indirect aliases remain unsupported.
16
+ - The complete suite passes 107/107 tests with browser coverage for pagination, search, sorted identity, derived equality, cleanup order, and named effect functions.
17
+
18
+ ### Boundary
19
+
20
+ Collection transforms must be relative, synchronous, capture-free, one-parameter functions with one returned supported pipeline. Slice bounds and sort comparators reject arbitrary calls; `sort()` remains unsupported. Derived effect locals must be pure primitive expressions over direct state. Effect function references must be direct top-level `const` functions in the same component; dynamic selection, parameters, and cross-component references remain unsupported.
21
+
22
+ ### Upgrade
23
+
24
+ ```bash
25
+ npm install @kudzujs/core@^0.7.21
26
+ ```
27
+
28
+ ## 0.7.20 - Computed child collections
29
+
30
+ Kudzu 0.7.20 accepts a common block-bodied keyed `map` callback that computes one direct child collection before returning JSX, without executing arbitrary callback code or adding a browser runtime path.
31
+
32
+ ### New in 0.7.20
33
+
34
+ - A keyed `map` callback may contain one top-level `const` declaration followed by its final JSX return.
35
+ - The `const` must start from a direct parent-item child property and may use the existing pure `filter`, direct-property `flatMap`, or `Array.from` selector pipeline.
36
+ - The computed alias feeds exactly one nested keyed list source.
37
+ - The compiler substitutes the proven calculation into the returned JSX before nested-list analysis, then reuses existing selector encoding and keyed ownership.
38
+ - Child insertion, reorder, removal, indexes, and DOM identity retain the existing nested-list behavior.
39
+ - Additional statements, multiple or mixed alias reads, parent capture, mutation, arbitrary calls, and asynchronous callbacks fail during compilation.
40
+ - The complete suite passes 107/107 tests with browser coverage for dynamic computed-child insertion and source-diagnostic coverage for mixed alias use.
41
+
42
+ ### Boundary
43
+
44
+ This release supports one direct-child collection `const` in a block-bodied keyed-map callback. Multiple calculations, aliases used outside one nested keyed list, imported transforms, parent captures, mutation, and async work remain unsupported.
45
+
46
+ ### Upgrade
47
+
48
+ ```bash
49
+ npm install @kudzujs/core@^0.7.20
50
+ ```
51
+
3
52
  ## 0.7.19 - Reusable collection aliases
4
53
 
5
54
  Kudzu 0.7.19 lets one immutable local collection alias feed multiple keyed list sites while preserving independent DOM identity, and formalizes setter callback and object-ref ownership across one ordinary component boundary.
@@ -6,7 +6,7 @@ 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. A direct JSON-safe primitive parent state passed to a destructured child prop remains the same signal for child DOM bindings and effect dependencies; repeated calls own independent effect records, and conditional removal cleans up before remount recreates the effect. 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.
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. A direct JSON-safe primitive parent state passed to a destructured child prop remains the same signal for child DOM bindings and effect dependencies; repeated calls own independent effect records, and conditional removal cleans up before remount recreates the effect. A top-level immutable local derived through a supported pure primitive expression from direct state may be an effect dependency: source state commits schedule evaluation, the derived result is compared with `Object.is`, and the expression is substituted into setup and cleanup handlers. Effect setup and directly returned cleanup callbacks may each resolve one top-level simple `const` function in the same component; those functions are substituted into the existing handler graph rather than retained in a browser registry. 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
10
 
11
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.
12
12
 
@@ -33,9 +33,9 @@ Page `metadata` can emit description, canonical, favicon, manifest, Open Graph,
33
33
 
34
34
  Inline SVG rendering normalizes an explicit set of common React presentation aliases before static serialization and binding descriptor creation. Reactive aliases therefore use the existing generic `setAttribute` path; static SVG adds no JavaScript and reactive SVG adds no SVG-specific runtime.
35
35
 
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.
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. A block-bodied keyed `map` callback may declare one top-level `const` computed from a direct child collection through the supported pure selector pipeline and then return JSX; the alias must feed exactly one nested keyed list source. 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.
37
37
 
38
- Rendered collection selectors compile immutable local aliases and inline `(item)` or `(item, index)` pipelines over local array state or supported static named imports. One alias may feed multiple keyed list sites when every reference is a statically analyzable collection source; mixed non-collection reads fail during compilation. 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.
38
+ Rendered collection selectors compile immutable local aliases and inline `(item)` or `(item, index)` pipelines over local array state or supported static named imports. One alias may feed multiple keyed list sites when every reference is a statically analyzable collection source; mixed non-collection reads fail during compilation. A relative-imported named or default synchronous function may accept one supported collection and directly return one pure selector pipeline; the compiler composes its selector without executing or shipping the function. Supported selectors are pure `filter` with direct local-state reads, direct-property `flatMap`, `Array.from`, immutable `slice(start, end?)`, and `toSorted((left, right) => expression)` before a final keyed `map`. Filter predicates may combine supported pure string methods such as `toLowerCase()` and `includes()` with direct primitive state for reactive search. Slice bounds may be direct numeric literals or supported expressions over direct primitive state. The two-parameter synchronous `toSorted` comparator may read item properties and direct primitive state; it sorts a copy in build and browser selectors, while mutating `sort()` is rejected. 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 row 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 or captured imported callbacks, arbitrary slice-bound calls or sort comparators, package/namespace transforms, mutation, asynchronous selectors, prototype-sensitive reads, dynamic row state initializers, non-`null` or callback refs, and recursive component cycles fail during compilation.
39
39
 
40
40
  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.
41
41
 
@@ -213,6 +213,7 @@ export async function build({ quiet = false, minify = true } = {}) {
213
213
  const hasComplexListRowState = plans.some(plan => plan.lists.some(list => list.rowStates?.some(state => state.initialValue !== null && typeof state.initialValue === "object")))
214
214
  const hasNestedLists = plans.some(plan => plan.lists.some(list => list.ownerField))
215
215
  const hasCollectionSelectors = plans.some(plan => plan.lists.some(list => list.selector))
216
+ const hasDerivedEffectDependencies = plans.some(plan => plan.effects.some(effect => effect.dependencyExpressions?.length))
216
217
  const hasStaticCollections = plans.some(plan => plan.lists.some(list => list.static))
217
218
  const hasListIndexes = plans.some(plan => plan.lists.some(list => list.indexed))
218
219
  const hasListStableFastPaths = plans.some(plan => plan.lists.some(list => !list.children && !list.ownerField && list.key !== null && !list.indexed && !list.reducer && !list.selector))
@@ -274,8 +275,9 @@ export async function build({ quiet = false, minify = true } = {}) {
274
275
  "globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures)
275
276
  })
276
277
  }
278
+ if (hasDerivedEffectDependencies) await writeJavaScript(join(assetsDirectory, "kudzu-collection-selector.js"), await readFile(new URL("./collection-selector.js", import.meta.url), "utf8"), minify)
277
279
  if (listCount) {
278
- if (hasCollectionSelectors) await writeJavaScript(join(assetsDirectory, "kudzu-collection-selector.js"), await readFile(new URL("./collection-selector.js", import.meta.url), "utf8"), minify)
280
+ if (hasCollectionSelectors && !hasDerivedEffectDependencies) await writeJavaScript(join(assetsDirectory, "kudzu-collection-selector.js"), await readFile(new URL("./collection-selector.js", import.meta.url), "utf8"), minify)
279
281
  let listRuntime = (await readFile(new URL("./list-runtime.js", import.meta.url), "utf8"))
280
282
  .replace('"./shared-runtime.js"', '"./kudzu.js"')
281
283
  listRuntime = hasCollectionSelectors
@@ -349,7 +351,7 @@ export async function build({ quiet = false, minify = true } = {}) {
349
351
  __KUDZU_LIST_INDEXES__: String(hasListIndexes),
350
352
  __KUDZU_LIST_STABLE_FAST_PATHS__: String(hasListStableFastPaths)
351
353
  })
352
- if (hasCollectionSelectors) await rm(join(assetsDirectory, "kudzu-collection-selector.js"))
354
+ if (hasCollectionSelectors && !hasDerivedEffectDependencies) await rm(join(assetsDirectory, "kudzu-collection-selector.js"))
353
355
  }
354
356
  if (hasNativeHandlers) {
355
357
  const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
@@ -522,6 +524,7 @@ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base
522
524
  const hasCleanup = effects.some(effect => effect.cleanup)
523
525
  const hasDependencies = effects.some(effect => effect.dependencies?.length || effect.itemDependencies?.length)
524
526
  const hasOwners = effects.some(effect => effect.owner)
527
+ const hasDependencyExpressions = effects.some(effect => effect.dependencyExpressions?.length)
525
528
  const moduleUrls = [...new Set(effects.map(effect => effect.module))]
526
529
  const modules = moduleUrls.map(url => {
527
530
  const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
@@ -533,12 +536,13 @@ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base
533
536
  ? `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}\nconst { browserState, commitDom } = __kRuntime`
534
537
  : `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}`,
535
538
  `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
539
+ ...(hasDependencyExpressions ? [`import { evaluateCollectionExpression as __kEvaluateDependency } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-collection-selector.js")))}`] : []),
536
540
  ...(paramPath ? [`import ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, paramPath)))}`] : []),
537
541
  ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
538
542
  ]
539
543
  const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
540
544
  if (hasOwners) return printOwnedEffectEntry(imports, effects, entries)
541
- if (effects.length === 1 && effects[0].dependencies?.length === 1) return printSingleDependencyEffect(imports, effects[0], hasCleanup)
545
+ if (effects.length === 1 && effects[0].dependencies?.length === 1 && !hasDependencyExpressions) return printSingleDependencyEffect(imports, effects[0], hasCleanup)
542
546
  const disposal = hasCleanup ? `
543
547
  let disposed = false
544
548
  const dispose = root => {
@@ -616,6 +620,7 @@ async function flush() {
616
620
  }
617
621
  }
618
622
  function readDependencies(record) {
623
+ ${hasDependencyExpressions ? printDerivedDependencyRead("browserState") : ""}
619
624
  return (record.effect.dependencies ?? []).map(id => {
620
625
  const value = browserState.get(id)
621
626
  if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
@@ -689,6 +694,7 @@ addEventListener("pagehide", event => {
689
694
  }
690
695
 
691
696
  function printNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, base) {
697
+ const hasDependencyExpressions = effects.some(effect => effect.dependencyExpressions?.length)
692
698
  const moduleUrls = [...new Set(effects.map(effect => effect.module))]
693
699
  const modules = moduleUrls.map(url => {
694
700
  const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
@@ -698,6 +704,7 @@ function printNavigableEffectEntry(effects, output, handlerModules, assetsDirect
698
704
  const imports = [
699
705
  `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
700
706
  `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
707
+ ...(hasDependencyExpressions ? [`import { evaluateCollectionExpression as __kEvaluateDependency } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-collection-selector.js")))}`] : []),
701
708
  ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
702
709
  ]
703
710
  const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
@@ -765,6 +772,7 @@ function mount(lifetime) {
765
772
  }
766
773
  }
767
774
  function readDependencies(record) {
775
+ ${hasDependencyExpressions ? printDerivedDependencyRead("__kRuntime.browserState") : ""}
768
776
  return (record.effect.dependencies ?? []).map(id => {
769
777
  const value = __kRuntime.browserState.get(id)
770
778
  if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
@@ -809,6 +817,7 @@ function mount(lifetime) {
809
817
 
810
818
  function printOwnedNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, base) {
811
819
  const hasItemDependencies = effects.some(effect => effect.itemDependencies?.length)
820
+ const hasDependencyExpressions = effects.some(effect => effect.dependencyExpressions?.length)
812
821
  const moduleUrls = [...new Set(effects.map(effect => effect.module))]
813
822
  const modules = moduleUrls.map(url => {
814
823
  const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
@@ -818,6 +827,7 @@ function printOwnedNavigableEffectEntry(effects, output, handlerModules, assetsD
818
827
  const imports = [
819
828
  `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
820
829
  `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
830
+ ...(hasDependencyExpressions ? [`import { evaluateCollectionExpression as __kEvaluateDependency } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-collection-selector.js")))}`] : []),
821
831
  ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
822
832
  ]
823
833
  const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
@@ -986,6 +996,7 @@ function mount(lifetime) {
986
996
  }
987
997
  }
988
998
  function readDependencies(record) {
999
+ ${hasDependencyExpressions ? printDerivedDependencyRead("__kRuntime.browserState") : ""}
989
1000
  const values = (record.effect.dependencies ?? []).map(id => {
990
1001
  const value = __kRuntime.browserState.get(id)
991
1002
  if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
@@ -1063,6 +1074,7 @@ function runtimeEffects(effects, lifetimes = false) {
1063
1074
  module: effect.module,
1064
1075
  handler: effect.handler,
1065
1076
  ...(effect.dependencies ? { dependencies: effect.dependencies } : {}),
1077
+ ...(effect.dependencyExpressions ? { dependencyExpressions: effect.dependencyExpressions, dependencyStates: effect.dependencyStates } : {}),
1066
1078
  ...(effect.itemDependencies ? { itemDependencies: effect.itemDependencies, listState: effect.listState } : {}),
1067
1079
  ...(effect.cleanup ? { cleanup: true } : {}),
1068
1080
  ...(effect.owner ? { owner: effect.owner } : {}),
@@ -1073,10 +1085,19 @@ function runtimeEffects(effects, lifetimes = false) {
1073
1085
  }))
1074
1086
  }
1075
1087
 
1088
+ function printDerivedDependencyRead(state) {
1089
+ return ` if (record.effect.dependencyExpressions) return record.effect.dependencyExpressions.map(expression => {
1090
+ const value = __kEvaluateDependency(expression, undefined, undefined, name => ${state}.get(record.effect.dependencyStates[name]))
1091
+ if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error("useEffect() derived dependency must remain a JSON-safe primitive")
1092
+ return value
1093
+ })`
1094
+ }
1095
+
1076
1096
  function printOwnedEffectEntry(imports, effects, entries) {
1077
1097
  const hasItemDependencies = effects.some(effect => effect.itemDependencies?.length)
1078
1098
  const hasOrdinaryDependencies = effects.some(effect => effect.dependencies?.length)
1079
- const hasRowState = effects.some(effect => JSON.stringify([effect.dependencies, effect.states, effect.scope]).includes("$k"))
1099
+ const hasDependencyExpressions = effects.some(effect => effect.dependencyExpressions?.length)
1100
+ const hasRowState = effects.some(effect => JSON.stringify([effect.dependencies, effect.dependencyStates, effect.states, effect.scope]).includes("$k"))
1080
1101
  return `${imports.join("\n")}
1081
1102
  const effects = ${inlineJson(effects)}
1082
1103
  const modules = new Map([${entries}])
@@ -1098,7 +1119,7 @@ ${hasRowState ? `function specializeRowEffect(effect, marker) {
1098
1119
  const path = marker.dataset.kRowPath
1099
1120
  const id = value => typeof value === "string" ? value.replace("$k", path) : value
1100
1121
  const capture = value => value?.type === "state" || value?.type === "setter" || value?.type === "ref" ? { ...value, id: id(value.id) } : value?.type === "array" ? { ...value, value: value.value.map(capture) } : value?.type === "object" ? { ...value, value: value.value.map(([key, entry]) => [key, capture(entry)]) } : value
1101
- return { ...effect, dependencies: effect.dependencies?.map(id), states: Object.fromEntries(Object.entries(effect.states).map(([name, value]) => [name, id(value)])), scope: Object.fromEntries(Object.entries(effect.scope).map(([name, value]) => [name, capture(value)])) }
1122
+ return { ...effect, dependencies: effect.dependencies?.map(id), dependencyStates: effect.dependencyStates && Object.fromEntries(Object.entries(effect.dependencyStates).map(([name, value]) => [name, id(value)])), states: Object.fromEntries(Object.entries(effect.states).map(([name, value]) => [name, id(value)])), scope: Object.fromEntries(Object.entries(effect.scope).map(([name, value]) => [name, capture(value)])) }
1102
1123
  }
1103
1124
  ` : ""}
1104
1125
  function registerDependencies(record) {
@@ -1240,6 +1261,7 @@ async function flush() {
1240
1261
  }
1241
1262
  }
1242
1263
  function readDependencies(record) {
1264
+ ${hasDependencyExpressions ? printDerivedDependencyRead("browserState") : ""}
1243
1265
  const values = (record.effect.dependencies ?? []).map(id => {
1244
1266
  const value = browserState.get(id)
1245
1267
  if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
@@ -2222,7 +2244,7 @@ function lowerReactMemoCollectionExpression(expression, factory) {
2222
2244
  if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && node.arguments.length === 1) {
2223
2245
  return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("Array"), "from"), undefined, [visit(node.expression.expression), node.arguments[0]])
2224
2246
  }
2225
- if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ["filter", "flatMap"].includes(node.expression.name.text)) {
2247
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ["filter", "flatMap", "slice", "toSorted"].includes(node.expression.name.text)) {
2226
2248
  return factory.updateCallExpression(node, factory.updatePropertyAccessExpression(node.expression, visit(node.expression.expression), node.expression.name), node.typeArguments, node.arguments)
2227
2249
  }
2228
2250
  if (isArrayFromCall(node)) return factory.updateCallExpression(node, node.expression, node.typeArguments, [visit(node.arguments[0]), ...node.arguments.slice(1)])
@@ -2309,6 +2331,13 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2309
2331
  }
2310
2332
  return imported
2311
2333
  }
2334
+ const importedCollectionTransforms = new Map()
2335
+ for (const [name, binding] of importBindings) {
2336
+ if (binding.kind === "namespace") continue
2337
+ try {
2338
+ importedCollectionTransforms.set(name, resolveComponentExport(binding.target, binding.kind === "default" ? "default" : binding.imported, target => parseSourceFile(target, sourceIndex.get(target)), sourceFiles))
2339
+ } catch {}
2340
+ }
2312
2341
  const settersByFunction = new Map()
2313
2342
  const reducersByFunction = new Map()
2314
2343
  const zustandStores = new Map()
@@ -2446,7 +2475,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2446
2475
  const setters = settersByFunction.get(owner) ?? new Map()
2447
2476
  for (const [name, entries] of declarations) {
2448
2477
  for (const declaration of entries) {
2449
- const parts = keyedListParts(declaration.initializer, setters, declarations, (target, message) => { throw sourceNodeError(target, sourceFile, message) }, new Set(), importedCollections)
2478
+ const parts = keyedListParts(declaration.initializer, setters, declarations, (target, message) => { throw sourceNodeError(target, sourceFile, message) }, new Set(), importedCollections, factory, context, importedCollectionTransforms)
2450
2479
  if (!parts) continue
2451
2480
  const uses = []
2452
2481
  const collectUses = node => {
@@ -2640,7 +2669,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2640
2669
  return
2641
2670
  }
2642
2671
  if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
2643
- const parts = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction), jsxLocalDeclarations.get(nearestFunction(node)), fail, new Set(), importedCollections)
2672
+ const parts = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction), jsxLocalDeclarations.get(nearestFunction(node)), fail, new Set(), importedCollections, factory, context, importedCollectionTransforms)
2644
2673
  if (parts) {
2645
2674
  for (const declaration of parts.aliasDeclarations ?? []) listLocalDeclarations.add(declaration)
2646
2675
  rawRenderedLists.push({ node, parts })
@@ -2855,14 +2884,24 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2855
2884
  fail(target, message)
2856
2885
  }
2857
2886
  if (node.arguments.length !== 2) effectFail(node, "useEffect() requires exactly a callback and literal dependency array")
2858
- const [callback, dependencies] = node.arguments
2859
- if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) effectFail(callback, "useEffect() callback must be an inline function")
2887
+ const [callbackArgument, dependencies] = node.arguments
2888
+ const effectOwner = nearestFunction(node)
2889
+ const resolveEffectFunction = expression => {
2890
+ if (!ts.isIdentifier(expression)) return undefined
2891
+ const entries = jsxLocalDeclarations.get(effectOwner)?.get(expression.text)
2892
+ if (entries?.length !== 1 || entries[0].node.parent?.parent?.parent !== effectOwner?.body) return undefined
2893
+ const initializer = entries[0].initializer
2894
+ return ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer) ? initializer : undefined
2895
+ }
2896
+ let callback = ts.isArrowFunction(callbackArgument) || ts.isFunctionExpression(callbackArgument) ? callbackArgument : resolveEffectFunction(callbackArgument)
2897
+ if (!callback) effectFail(callbackArgument, "useEffect() callback must be inline or one top-level const function")
2860
2898
  if (ts.isFunctionExpression(callback) && callback.name) effectFail(callback, "useEffect() callback function must be anonymous")
2861
2899
  if (callback.asteriskToken) effectFail(callback, "useEffect() callback cannot be a generator")
2862
2900
  if (callback.parameters.length) effectFail(callback, "useEffect() callback cannot declare parameters")
2863
2901
  if (!ts.isArrayLiteralExpression(dependencies)) effectFail(dependencies, "useEffect() dependencies must be a literal array")
2864
2902
  const itemDependencies = []
2865
2903
  const ordinaryDependencies = []
2904
+ const setters = settersForNode(node, settersByFunction)
2866
2905
  let dependencyItem = listEffect?.item
2867
2906
  for (const dependency of dependencies.elements) {
2868
2907
  const value = unwrapExpression(dependency)
@@ -2879,24 +2918,74 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2879
2918
  }
2880
2919
  const invalidDependency = ordinaryDependencies.find(dependency => !ts.isIdentifier(dependency))
2881
2920
  if (invalidDependency) effectFail(invalidDependency, "useEffect() dependencies must be direct state or runtime parameter identifiers")
2882
- if (!nearestFunction(node)) fail(node, "useEffect() cannot be used outside a Kudzu component")
2921
+ const dependencyExpressions = []
2922
+ const dependencyStates = new Map()
2923
+ const dependencySubstitutions = new Map()
2924
+ const subscriptionDependencies = []
2925
+ let hasDerivedDependency = false
2926
+ const stateNames = new Set(setters.values())
2927
+ const localDeclarations = jsxLocalDeclarations.get(nearestFunction(node))
2928
+ for (const dependency of ordinaryDependencies) {
2929
+ const entries = localDeclarations?.get(dependency.text)
2930
+ const initializer = entries?.length === 1 ? entries[0].initializer : undefined
2931
+ const directAlias = initializer && ts.isIdentifier(unwrapExpression(initializer)) && stateNames.has(unwrapExpression(initializer).text)
2932
+ const derivedStates = initializer && !directAlias ? referencedStateNames(initializer, setters) : new Set()
2933
+ if (derivedStates.size) {
2934
+ const usedStates = new Set()
2935
+ const expression = collectionExpression(initializer, {}, effectFail, stateNames, usedStates)
2936
+ if (!usedStates.size) effectFail(dependency, `useEffect() derived dependency "${dependency.text}" must read direct primitive state`)
2937
+ dependencyExpressions.push(expression)
2938
+ for (const name of usedStates) {
2939
+ subscriptionDependencies.push(factory.createIdentifier(name))
2940
+ dependencyStates.set(name, factory.createIdentifier(name))
2941
+ }
2942
+ dependencySubstitutions.set(dependency.text, initializer)
2943
+ hasDerivedDependency = true
2944
+ } else {
2945
+ subscriptionDependencies.push(dependency)
2946
+ dependencyExpressions.push(["state", dependency.text])
2947
+ dependencyStates.set(dependency.text, dependency)
2948
+ }
2949
+ }
2950
+ if (!hasDerivedDependency) {
2951
+ dependencyExpressions.length = 0
2952
+ dependencyStates.clear()
2953
+ }
2954
+ if (!effectOwner) fail(node, "useEffect() cannot be used outside a Kudzu component")
2883
2955
  if (!ts.isBlock(callback.body)) effectFail(callback, "useEffect() callback must use a block body")
2956
+ const cleanupSubstitutions = new Map()
2957
+ const collectNamedCleanups = current => {
2958
+ if (current !== callback && isFunctionLike(current)) return
2959
+ if (ts.isReturnStatement(current) && current.expression && ts.isIdentifier(unwrapExpression(current.expression))) {
2960
+ const cleanup = resolveEffectFunction(unwrapExpression(current.expression))
2961
+ if (cleanup) cleanupSubstitutions.set(unwrapExpression(current.expression).text, cleanup)
2962
+ }
2963
+ ts.forEachChild(current, collectNamedCleanups)
2964
+ }
2965
+ collectNamedCleanups(callback.body)
2966
+ if (cleanupSubstitutions.size) {
2967
+ callback = substituteClone(callback, cleanupSubstitutions, factory, context)
2968
+ ts.setParentRecursive(callback, false)
2969
+ callback.parent = callbackArgument.parent
2970
+ }
2884
2971
  const returns = effectReturns(callback)
2885
2972
  if (returns.invalid) effectFail(returns.invalid, "useEffect() return values must be inline cleanup functions")
2886
2973
  const invalidCleanup = returns.cleanups.find(cleanup => cleanup.parameters.length || cleanup.asteriskToken)
2887
2974
  if (invalidCleanup) effectFail(invalidCleanup, "useEffect() cleanup functions cannot declare parameters or be generators")
2888
2975
  if (returns.cleanup && callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) effectFail(callback, "useEffect() async callbacks cannot return cleanup functions")
2889
- const setters = settersForNode(node, settersByFunction)
2890
2976
  const callbackSource = listEffect?.sourceFile ?? sourceFile
2891
2977
  const callbackFile = callbackSource.fileName
2892
2978
  const workerStart = workerReferences.length
2893
- let compiledCallback
2979
+ let compiledCallback = dependencySubstitutions.size ? substituteClone(callback, dependencySubstitutions, factory, context) : callback
2980
+ if (compiledCallback !== callback) {
2981
+ ts.setParentRecursive(compiledCallback, false)
2982
+ compiledCallback.parent = callback.parent
2983
+ }
2894
2984
  if (listEffect && callbackFile !== file) {
2895
2985
  const originalCallback = listEffect.source.arguments[0]
2896
2986
  rejectWorkerConstructions(originalCallback, callbackSource, "Relative TypeScript Worker construction in imported keyed-row effects is not supported; construct the Worker in a directly compiled page or local component effect")
2897
- compiledCallback = callback
2898
2987
  } else {
2899
- compiledCallback = rewriteEffectWorkers(callback, callbackFile, callbackSource, sourceFiles, workerReferences, factory, context)
2988
+ compiledCallback = rewriteEffectWorkers(compiledCallback, callbackFile, callbackSource, sourceFiles, workerReferences, factory, context)
2900
2989
  }
2901
2990
  const descriptor = compileNativeCallback(compiledCallback, setters, reducersForNode(node, reducersByFunction), factory, effectHandlers, listEffect?.imports ?? importBindings, clientImports, "effect", dependencyItem, true, returns.cleanup)
2902
2991
  for (const reference of workerReferences.slice(workerStart)) Object.assign(reference, { module: handlerUrl, handler: descriptor.exportName })
@@ -2904,14 +2993,16 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2904
2993
  usesBehavior = true
2905
2994
  return factory.updateCallExpression(node, node.expression, node.typeArguments, [
2906
2995
  callback,
2907
- factory.createArrayLiteralExpression(ordinaryDependencies),
2996
+ factory.createArrayLiteralExpression(hasDerivedDependency ? subscriptionDependencies : ordinaryDependencies),
2908
2997
  factory.createStringLiteral(handlerUrl),
2909
2998
  factory.createStringLiteral(descriptor.exportName),
2910
2999
  descriptor.states,
2911
3000
  descriptor.scope,
2912
3001
  factory.createStringLiteral(listEffect ? sourceLocation(listEffect.source, listEffect.sourceFile) : sourceLocation(node, sourceFile)),
2913
3002
  returns.cleanup ? factory.createTrue() : factory.createFalse(),
2914
- factory.createArrayLiteralExpression(itemDependencies.map(field => factory.createStringLiteral(field)))
3003
+ factory.createArrayLiteralExpression(itemDependencies.map(field => factory.createStringLiteral(field))),
3004
+ hasDerivedDependency ? jsonExpression(dependencyExpressions, factory) : factory.createArrayLiteralExpression(),
3005
+ factory.createArrayLiteralExpression([...dependencyStates].map(([name, state]) => factory.createArrayLiteralExpression([factory.createStringLiteral(name), state])))
2915
3006
  ])
2916
3007
  }
2917
3008
 
@@ -3157,16 +3248,29 @@ function containsRenderControl(root, knownLocals) {
3157
3248
  return found
3158
3249
  }
3159
3250
 
3160
- function keyedListParts(expression, setters, declarations, fail, aliases = new Set(), importedCollections = new Set()) {
3251
+ function keyedListParts(expression, setters, declarations, fail, aliases = new Set(), importedCollections = new Set(), factory = ts.factory, context, importedCollectionTransforms = new Map()) {
3161
3252
  const value = unwrapExpression(expression)
3162
3253
  const directFrom = isArrayFromCall(value) && value.arguments.length === 2 && containsJsx(value.arguments[1])
3163
3254
  if (!directFrom && (!ts.isCallExpression(value) || value.arguments.length !== 1 || !ts.isPropertyAccessExpression(value.expression) || value.expression.name.text !== "map")) return undefined
3164
- const collection = renderedCollectionSource(directFrom ? value.arguments[0] : value.expression.expression, setters, declarations, fail, aliases, importedCollections, new Set(setters.values()))
3255
+ const collection = renderedCollectionSource(directFrom ? value.arguments[0] : value.expression.expression, setters, declarations, fail, aliases, importedCollections, new Set(setters.values()), importedCollectionTransforms, factory, context)
3165
3256
  if (!collection?.state) return undefined
3166
3257
  if (directFrom) collection.selector.push(["from", undefined])
3167
- const callback = directFrom ? value.arguments[1] : value.arguments[0]
3258
+ let callback = directFrom ? value.arguments[1] : value.arguments[0]
3168
3259
  const parameters = collectionParameters(callback, "Keyed list map", fail)
3169
- const root = unwrapExpression(callback.body)
3260
+ let root = unwrapExpression(callback.body)
3261
+ if (ts.isBlock(root)) {
3262
+ if (!context || root.statements.length !== 2 || !ts.isVariableStatement(root.statements[0]) || (root.statements[0].declarationList.flags & ts.NodeFlags.Const) === 0 || root.statements[0].declarationList.declarations.length !== 1 || !ts.isReturnStatement(root.statements[1]) || !root.statements[1].expression) fail(root, "Block-bodied keyed list map callbacks require one computed child collection const and a final JSX return")
3263
+ const declaration = root.statements[0].declarationList.declarations[0]
3264
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration, "Computed child collections must initialize one const identifier")
3265
+ const computed = renderedCollectionSource(declaration.initializer, new Map(), undefined, fail, new Set(), new Set(), new Set(), importedCollectionTransforms, factory, context)
3266
+ if (!computed?.ownerField || computed.parentItem !== parameters.item) fail(declaration.initializer, `Computed child collections must start from ${parameters.item}.<field>`)
3267
+ const returned = root.statements[1].expression
3268
+ if (identifierReferenceCount(returned, declaration.name.text) !== 1) fail(declaration.name, `Computed child collection alias "${declaration.name.text}" must be used exactly once`)
3269
+ root = unwrapExpression(substituteClone(returned, new Map([[declaration.name.text, declaration.initializer]]), factory, context))
3270
+ callback = factory.updateArrowFunction(callback, callback.modifiers, callback.typeParameters, callback.parameters, callback.type, callback.equalsGreaterThanToken, root)
3271
+ ts.setParentRecursive(callback, false)
3272
+ callback.parent = value
3273
+ }
3170
3274
  if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(callback.body, "Keyed list map callback must return one JSX element")
3171
3275
  const attributes = ts.isJsxElement(root) ? root.openingElement.attributes : root.attributes
3172
3276
  const key = attributes.properties.find(attribute => ts.isJsxAttribute(attribute) && ts.isIdentifier(attribute.name) && attribute.name.text === "key")
@@ -3195,7 +3299,7 @@ function nestedKeyedListParts(expression, parentItem, fail) {
3195
3299
  return { ...collection, callback, root, item: parameters.item, index: parameters.index, indexed: Boolean(parameters.index), keyField: positional ? null : keyField }
3196
3300
  }
3197
3301
 
3198
- function renderedCollectionSource(expression, setters, declarations, fail, aliases, importedCollections = new Set(), stateNames = new Set()) {
3302
+ function renderedCollectionSource(expression, setters, declarations, fail, aliases, importedCollections = new Set(), stateNames = new Set(), importedCollectionTransforms = new Map(), factory = ts.factory, context) {
3199
3303
  const value = unwrapExpression(expression)
3200
3304
  if (ts.isIdentifier(value)) {
3201
3305
  if ([...setters.values()].includes(value.text)) return { state: value, selector: [], selectorStates: new Set() }
@@ -3204,16 +3308,30 @@ function renderedCollectionSource(expression, setters, declarations, fail, alias
3204
3308
  if (!entries) return undefined
3205
3309
  if (entries.length !== 1 || aliases.has(value.text) || entries[0].node.parent?.parent?.parent !== nearestFunction(entries[0].node)?.body) fail(value, `Rendered collection alias "${value.text}" must be one top-level immutable local`)
3206
3310
  aliases.add(value.text)
3207
- const source = renderedCollectionSource(entries[0].initializer, setters, declarations, fail, aliases, importedCollections, stateNames)
3311
+ const source = renderedCollectionSource(entries[0].initializer, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
3208
3312
  aliases.delete(value.text)
3209
3313
  return source && { ...source, aliasDeclarations: [...(source.aliasDeclarations ?? []), entries[0].node], aliasUses: [...(source.aliasUses ?? []), value] }
3210
3314
  }
3211
3315
  if (ts.isPropertyAccessExpression(value) && ts.isIdentifier(value.expression)) return { state: undefined, ownerField: value.name.text, selector: [], parentItem: value.expression.text }
3316
+ if (ts.isCallExpression(value) && ts.isIdentifier(value.expression) && importedCollectionTransforms.has(value.expression.text)) {
3317
+ const transform = importedCollectionTransforms.get(value.expression.text)
3318
+ const parameter = transform.parameters[0]
3319
+ if (value.arguments.length !== 1 || transform.parameters.length !== 1 || transform.asteriskToken || transform.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || !parameter || !ts.isIdentifier(parameter.name) || parameter.dotDotDotToken || parameter.initializer || parameter.questionToken) fail(value, `Imported collection transform "${value.expression.text}" must be synchronous with exactly one identifier parameter and one argument`)
3320
+ const returned = ts.isBlock(transform.body)
3321
+ ? transform.body.statements.length === 1 && ts.isReturnStatement(transform.body.statements[0]) ? transform.body.statements[0].expression : undefined
3322
+ : transform.body
3323
+ if (!returned) fail(value, `Imported collection transform "${value.expression.text}" must contain only one returned collection expression`)
3324
+ const transformSource = renderedCollectionSource(returned, new Map([[parameter.name.text, parameter.name.text]]), undefined, fail, new Set(), new Set(), new Set([parameter.name.text]))
3325
+ if (!transformSource?.state || transformSource.state.text !== parameter.name.text || transformSource.selectorStates.size) fail(value, `Imported collection transform "${value.expression.text}" must return a supported pure pipeline rooted only in its parameter`)
3326
+ const source = renderedCollectionSource(value.arguments[0], setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
3327
+ if (!source) fail(value.arguments[0], `Imported collection transform "${value.expression.text}" requires a supported collection argument`)
3328
+ return { ...source, selector: [...source.selector, ...transformSource.selector] }
3329
+ }
3212
3330
  if (ts.isCallExpression(value) && ts.isPropertyAccessExpression(value.expression)) {
3213
3331
  const method = value.expression.name.text
3214
3332
  if (method === "filter") {
3215
3333
  if (value.arguments.length !== 1) fail(value, "Rendered collection filter() requires one inline predicate")
3216
- const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames)
3334
+ const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
3217
3335
  if (!source) return undefined
3218
3336
  const parameters = collectionParameters(value.arguments[0], "Rendered collection filter()", fail)
3219
3337
  const selectorStates = new Set(source.selectorStates)
@@ -3221,7 +3339,7 @@ function renderedCollectionSource(expression, setters, declarations, fail, alias
3221
3339
  }
3222
3340
  if (method === "flatMap") {
3223
3341
  if (value.arguments.length !== 1) fail(value, "Rendered collection flatMap() requires one inline projector")
3224
- const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames)
3342
+ const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
3225
3343
  if (!source) return undefined
3226
3344
  const parameters = collectionParameters(value.arguments[0], "Rendered collection flatMap()", fail)
3227
3345
  const field = directProperty(value.arguments[0].body, parameters.item)
@@ -3229,10 +3347,31 @@ function renderedCollectionSource(expression, setters, declarations, fail, alias
3229
3347
  if (["__proto__", "constructor", "prototype"].includes(field)) fail(value.arguments[0].body, `Rendered collection property "${field}" is not supported`)
3230
3348
  return { ...source, selector: [...source.selector, ["flatMap", field]] }
3231
3349
  }
3350
+ if (method === "slice") {
3351
+ if (value.arguments.length < 1 || value.arguments.length > 2) fail(value, "Rendered collection slice() requires a start and optional end")
3352
+ const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
3353
+ if (!source) return undefined
3354
+ const selectorStates = new Set(source.selectorStates)
3355
+ const start = collectionExpression(value.arguments[0], {}, fail, stateNames, selectorStates)
3356
+ const end = value.arguments[1] && collectionExpression(value.arguments[1], {}, fail, stateNames, selectorStates)
3357
+ return { ...source, selector: [...source.selector, ["slice", start, end]], selectorStates }
3358
+ }
3359
+ if (method === "toSorted") {
3360
+ if (value.arguments.length !== 1) fail(value, "Rendered collection toSorted() requires one inline comparator")
3361
+ const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
3362
+ if (!source) return undefined
3363
+ const comparator = value.arguments[0]
3364
+ const parameters = collectionParameters(comparator, "Rendered collection toSorted()", fail)
3365
+ if (comparator.parameters.length !== 2 || ts.isBlock(comparator.body)) fail(comparator, "Rendered collection toSorted() comparator must be a synchronous expression arrow with (left, right) identifier parameters")
3366
+ const selectorStates = new Set(source.selectorStates)
3367
+ const expression = collectionExpression(comparator.body, parameters, fail, stateNames, selectorStates)
3368
+ return { ...source, selector: [...source.selector, ["sort", expression]], selectorStates }
3369
+ }
3370
+ if (method === "sort") fail(value, "Rendered collections cannot use mutating sort(); use toSorted()")
3232
3371
  }
3233
3372
  if (isArrayFromCall(value)) {
3234
3373
  if (value.arguments.length < 1 || value.arguments.length > 2) fail(value, "Rendered Array.from() requires an anchor and optional inline mapper")
3235
- const source = renderedCollectionSource(value.arguments[0], setters, declarations, fail, aliases, importedCollections, stateNames)
3374
+ const source = renderedCollectionSource(value.arguments[0], setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
3236
3375
  if (!source) return undefined
3237
3376
  let mapper
3238
3377
  if (value.arguments[1]) {
@@ -3919,7 +4058,7 @@ function jsxTagUses(root, name) {
3919
4058
  return uses
3920
4059
  }
3921
4060
 
3922
- const pureListMethods = new Set(["at", "charAt", "charCodeAt", "concat", "endsWith", "includes", "indexOf", "join", "lastIndexOf", "padEnd", "padStart", "repeat", "replace", "replaceAll", "slice", "startsWith", "substring", "toLowerCase", "toUpperCase", "trim", "trimEnd", "trimStart"])
4061
+ const pureListMethods = new Set(["at", "charAt", "charCodeAt", "concat", "endsWith", "includes", "indexOf", "join", "lastIndexOf", "localeCompare", "padEnd", "padStart", "repeat", "replace", "replaceAll", "slice", "startsWith", "substring", "toLowerCase", "toUpperCase", "trim", "trimEnd", "trimStart"])
3923
4062
  const mutatingListMethods = new Set(["copyWithin", "fill", "pop", "push", "reverse", "shift", "sort", "splice", "unshift"])
3924
4063
  const pureMathMethods = new Set(["abs", "ceil", "floor", "max", "min", "pow", "round", "sign", "sqrt", "trunc"])
3925
4064
  const pureListGlobals = new Set(["Boolean", "Infinity", "Math", "NaN", "Number", "String", "undefined"])
@@ -6,13 +6,15 @@ export function selectCollection(anchor, selector = [], readState) {
6
6
  if (!Array.isArray(values)) throw new Error("Rendered collection source must remain an array")
7
7
  if (operation[0] === "filter") values = values.filter((item, index) => evaluateCollectionExpression(operation[1], item, index, readState))
8
8
  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))
9
11
  }
10
12
  }
11
13
  if (!Array.isArray(values)) throw new Error("Rendered collection source must remain an array")
12
14
  return values
13
15
  }
14
16
 
15
- function evaluateCollectionExpression(expression, item, index, readState) {
17
+ export function evaluateCollectionExpression(expression, item, index, readState) {
16
18
  const [kind, ...parts] = expression
17
19
  if (kind === "value") return parts[0]
18
20
  if (kind === "undefined") return undefined
@@ -145,14 +145,18 @@ function createInternalState(initialValue) {
145
145
  return signal
146
146
  }
147
147
 
148
- export function useEffect(callback, dependencies, module, handler, states, scope, source, cleanup, itemDependencies = []) {
148
+ export function useEffect(callback, dependencies, module, handler, states, scope, source, cleanup, itemDependencies = [], dependencyExpressions = [], dependencyStates = []) {
149
149
  if (!renderContext) throw new Error("useEffect() can only run while rendering a Kudzu component")
150
150
  if (typeof callback !== "function" || !Array.isArray(dependencies) || !module || !handler) throw new Error("useEffect() must be compiled with a literal dependency array")
151
151
  if (itemDependencies.length && !renderContext.listDepth) throw new Error(`${source} useEffect() item-property dependencies are only supported in direct keyed row components`)
152
- const dependencyIds = dependencies.map(dependency => {
152
+ const dependencyIds = [...new Set(dependencies.map(dependency => {
153
153
  if (!dependency?.[signalMarker] || !validEffectDependency(dependency.value)) throw new Error(`${source} useEffect() dependencies must be primitive Kudzu state or runtime parameter identifiers`)
154
154
  return dependency.id
155
- })
155
+ }))]
156
+ const dependencyStateIds = Object.fromEntries(dependencyStates.map(([name, dependency]) => {
157
+ if (!dependency?.[signalMarker] || !validEffectDependency(dependency.value)) throw new Error(`${source} useEffect() derived dependency state ${JSON.stringify(name)} must be primitive Kudzu state`)
158
+ return [name, dependency.id]
159
+ }))
156
160
  let owner
157
161
  let list = false
158
162
  if (renderContext.listDepth) {
@@ -178,7 +182,7 @@ export function useEffect(callback, dependencies, module, handler, states, scope
178
182
  owner = nextRenderId("e")
179
183
  owners.push(owner)
180
184
  }
181
- if (!renderContext.listDepth || list) renderContext.effects.push({ module, handler, states, scope, source, renderScope: renderContext.renderScope, ...(dependencyIds.length ? { dependencies: dependencyIds } : {}), ...(itemDependencies.length ? { itemDependencies, listState: renderContext.listRoot.state } : {}), ...(cleanup ? { cleanup: true } : {}), ...(owner ? { owner } : {}), ...(list ? { list: true } : {}) })
185
+ if (!renderContext.listDepth || list) renderContext.effects.push({ module, handler, states, scope, source, renderScope: renderContext.renderScope, ...(dependencyIds.length ? { dependencies: dependencyIds } : {}), ...(dependencyExpressions.length ? { dependencyExpressions, dependencyStates: dependencyStateIds } : {}), ...(itemDependencies.length ? { itemDependencies, listState: renderContext.listRoot.state } : {}), ...(cleanup ? { cleanup: true } : {}), ...(owner ? { owner } : {}), ...(list ? { list: true } : {}) })
182
186
  renderContext.handlerModules.add(module)
183
187
  renderContext.hasBehaviors = true
184
188
  renderContext.hasEffects = true
@@ -424,6 +428,7 @@ export async function renderPage(component, metadata = {}, props = {}, layout) {
424
428
  module: effect.module,
425
429
  handler: effect.handler,
426
430
  ...(effect.dependencies ? { dependencies: effect.dependencies } : {}),
431
+ ...(effect.dependencyExpressions ? { dependencyExpressions: effect.dependencyExpressions, dependencyStates: effect.dependencyStates } : {}),
427
432
  ...(effect.itemDependencies ? { itemDependencies: effect.itemDependencies, listState: effect.listState } : {}),
428
433
  ...(effect.cleanup ? { cleanup: true } : {}),
429
434
  ...(effect.owner ? { owner: effect.owner } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.7.19",
3
+ "version": "0.7.21",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",