@kudzujs/core 0.7.20 → 0.7.22

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.20 - Computed child collections.** A block-bodied keyed `map` callback may compute one direct child collection through the supported pure selector pipeline and use it as a nested keyed list source. Read the [release notes](./RELEASES.md#0720---computed-child-collections) or open the [release page](https://kudzujs.cloud/releases/0.7.20).
13
+ **Latest release: 0.7.22 - SVG structures and native links.** Reactive SVG conditionals and flat keyed lists preserve their namespace, while supported React Router `Link` authoring erases to base-aware native anchors with zero router runtime. Read the [release notes](./RELEASES.md#0722---svg-structures-and-native-links) or open the [release page](https://kudzujs.cloud/releases/0.7.22).
14
14
 
15
15
  - [Documentation](https://kudzujs.cloud/docs)
16
16
  - [Installation guide](https://kudzujs.cloud/docs#install)
@@ -82,6 +82,7 @@ ordinary React-shaped TSX
82
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.
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
+ - 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.
85
86
  - Unsupported nearby patterns fail during the build with a source location and actionable boundary.
86
87
 
87
88
  Migration input may retain supported imports from `react`; Kudzu erases those references and never emits or executes React. New Kudzu source should import framework APIs from `@kudzujs/core`.
package/RELEASES.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.7.22 - SVG structures and native links
4
+
5
+ Kudzu 0.7.22 extends ordinary React-shaped structural authoring into SVG and erases the common React Router `Link` form to native navigation without adding an SVG renderer or router runtime.
6
+
7
+ ### New in 0.7.22
8
+
9
+ - Reactive conditional branches inside SVG parse replacement markup in the actual SVG parent context and retain existing DOM ownership semantics.
10
+ - Flat intrinsic keyed lists inside SVG support add, update, reorder, and removal while preserving keyed identity and the SVG namespace.
11
+ - SVG fragment construction is compiled out of HTML-only condition and list builds; the measured matched fixture added 333 B raw / 79 B aggregate gzip JavaScript.
12
+ - A named or aliased `Link` import from `react-router-dom` with one static root-relative `to` lowers to a native `<a href>`, receives the configured `base`, and erases the package import.
13
+ - Dynamic or relative Link destinations, traversal, `NavLink`, router-only props, spreads, default/namespace imports, and non-JSX uses fail with source diagnostics.
14
+ - Effect dependency arrays are explicitly proven for multiple direct primitive states or supported props through existing commit batching and `Object.is` comparison.
15
+ - The complete suite passes 111/111 tests with Chrome coverage for SVG namespace, conditional replacement, keyed identity, Link erasure, and zero-JavaScript static output.
16
+
17
+ ### Measured fixture
18
+
19
+ On the recorded Intel i5-9500 / Chrome 142 environment, 1,000-row SVG medians were 0.8 ms conditional, 1.9 ms update, 8.3 ms reverse, 2.4 ms remove, and 3.5 ms add. The matched HTML control measured 0.7, 1.8, 8.3, 2.5, and 3.6 ms. Link and native-anchor controls emitted byte-identical 248 B HTML and zero JavaScript. Full methodology and raw arrays are in `PERFORMANCE.md`.
20
+
21
+ ### Boundary
22
+
23
+ Structural SVG currently supports reactive conditionals and flat intrinsic keyed lists. Keyed-item conditions, nested SVG lists, reactive MathML, and namespaced attributes remain unsupported. React Router lowering accepts only direct named or aliased `Link` JSX with one safe static root-relative destination and native anchor props. Native document navigation remains the default; no SPA router is included.
24
+
25
+ ### Upgrade
26
+
27
+ ```bash
28
+ npm install @kudzujs/core@^0.7.22
29
+ ```
30
+
31
+ ## 0.7.21 - Composable collections and effects
32
+
33
+ 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.
34
+
35
+ ### New in 0.7.21
36
+
37
+ - 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.
38
+ - Immutable `slice(start, end?)` accepts numeric literals and supported direct primitive state expressions for bounded pagination.
39
+ - Pure `filter` predicates may combine direct primitive state with supported string methods for reactive search.
40
+ - Expression-bodied `toSorted((left, right) => ...)` comparators compile into immutable selector sorting; mutating `sort()` fails with a source diagnostic.
41
+ - 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.
42
+ - Derived expressions are substituted into setup and cleanup handlers, so reruns and final disposal observe the latest value.
43
+ - Same-component top-level simple `const` setup and directly returned cleanup functions are statically substituted into effect handlers; indirect aliases remain unsupported.
44
+ - The complete suite passes 107/107 tests with browser coverage for pagination, search, sorted identity, derived equality, cleanup order, and named effect functions.
45
+
46
+ ### Boundary
47
+
48
+ 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.
49
+
50
+ ### Upgrade
51
+
52
+ ```bash
53
+ npm install @kudzujs/core@^0.7.21
54
+ ```
55
+
3
56
  ## 0.7.20 - Computed child collections
4
57
 
5
58
  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.
@@ -4,9 +4,11 @@ Kudzu specializes ordinary common React-shaped TSX so migrations need minimal so
4
4
 
5
5
  Migration source may retain conventional `react` imports for supported named or aliased hooks, direct members such as `React.useState`, same-file `memo`, inline `useCallback`, direct-state expression or analyzable collection-pipeline `useMemo`, direct intrinsic `forwardRef`, top-level `const` identifiers initialized by `useId()`, and default, namespace, or named `Fragment`. `forwardRef()` accepts one inline synchronous `(props, ref)` function and requires the object ref exactly once on its direct intrinsic root; the compiler removes `ref` from props/rest and erases the wrapper. `useId()` becomes a deterministic build-time HTML ID and emits no browser capability; keyed rows reject it because cloned row templates cannot safely duplicate HTML IDs. Collection memos may start from local array state or a named relative import of an exported JSON-safe `const` array and may read direct local state declared in their dependency array. `build.mjs` canonicalizes those forms and rewrites module references to `@kudzujs/core` before build-time evaluation. Memo wrappers are erased or inlined into existing bindings and keyed-list selectors because no browser component rerender or memo cache exists. Static routes remain JavaScript-free and emitted modules are checked for surviving React imports.
6
6
 
7
+ 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
+
7
9
  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
10
 
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.
11
+ 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. Multiple direct primitive dependencies share the existing commit batching path: every value is compared with `Object.is`, and one or more same-turn changes cause one cleanup and rerun. A top-level immutable local derived through a supported pure primitive expression from direct state may also 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
12
 
11
13
  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
14
 
@@ -31,11 +33,11 @@ Exact relative `.worker.ts` constructors in inline effects are validated and bun
31
33
 
32
34
  Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime. Source CSS and global `kudzu.config` styles are emitted in document heads before `afterBuild()` runs; static stylesheet links in component JSX fail compilation instead of loading from the body.
33
35
 
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.
36
+ Inline SVG rendering normalizes an explicit set of common React presentation aliases before static serialization and binding descriptor creation. Reactive aliases use the existing generic `setAttribute` path. Reactive conditionals and flat intrinsic keyed lists store inert branch or row markup on SVG markers and parse it in the actual parent namespace only when replacement nodes are needed; existing condition/list ownership then handles insertion, identity, updates, and removal. Builds without structural SVG compile out that fragment path, and static SVG adds no JavaScript. Keyed-item conditions, nested SVG lists, MathML structures, and namespaced attributes remain unsupported.
35
37
 
36
38
  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
39
 
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.
40
+ 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
41
 
40
42
  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
43
 
@@ -122,10 +122,10 @@ function mountConditions(root) {
122
122
  mountedConditions.add(start)
123
123
  const descriptor = JSON.parse(start.dataset.kIf)
124
124
  const end = findEnd(start, descriptor.id)
125
- const truthy = start.content.querySelector("template[data-k-true]")
126
- const falsy = start.content.querySelector("template[data-k-false]")
125
+ const truthy = globalThis.__KUDZU_SVG_CONDITIONS__ && descriptor.svg ? start.dataset.kSvgTrue : start.content.querySelector("template[data-k-true]")
126
+ const falsy = globalThis.__KUDZU_SVG_CONDITIONS__ && descriptor.svg ? start.dataset.kSvgFalse : start.content.querySelector("template[data-k-false]")
127
127
  if (!end || !truthy || !falsy) continue
128
- const condition = { start, end, truthy, falsy, kind: descriptor.kind, current: conditionKey(descriptor.kind, descriptor.initial), mount: descriptor.mount, owned: descriptor.owned }
128
+ const condition = { start, end, truthy, falsy, svg: globalThis.__KUDZU_SVG_CONDITIONS__ && descriptor.svg, kind: descriptor.kind, current: conditionKey(descriptor.kind, descriptor.initial), mount: descriptor.mount, owned: descriptor.owned }
129
129
  mountConditionStates(condition, Boolean(descriptor.initial), false)
130
130
  const mount = evaluator => {
131
131
  if (!start.isConnected) return
@@ -154,7 +154,9 @@ function updateCondition(condition) {
154
154
  const falseText = condition.kind === "and" && !truthy ? renderFalsy(value) : ""
155
155
  const fragment = falseText
156
156
  ? textFragment(condition.end.ownerDocument, falseText)
157
- : (truthy ? condition.truthy : condition.falsy).content.cloneNode(true)
157
+ : globalThis.__KUDZU_SVG_CONDITIONS__ && condition.svg
158
+ ? svgFragment(condition.start, truthy ? condition.truthy : condition.falsy)
159
+ : (truthy ? condition.truthy : condition.falsy).content.cloneNode(true)
158
160
  const nodes = condition.mount ? [...fragment.childNodes] : undefined
159
161
  mountConditionStates(condition, truthy, true)
160
162
  condition.end.parentNode.insertBefore(fragment, condition.end)
@@ -230,6 +232,12 @@ function textFragment(document, value) {
230
232
  return fragment
231
233
  }
232
234
 
235
+ function svgFragment(marker, markup) {
236
+ const range = marker.ownerDocument.createRange()
237
+ range.selectNode(marker)
238
+ return range.createContextualFragment(markup)
239
+ }
240
+
233
241
  function findEnd(start, id) {
234
242
  for (let node = start.nextSibling; node; node = node.nextSibling) {
235
243
  if (node.nodeType === Node.ELEMENT_NODE && node.matches("template[data-k-if-end]") && node.dataset.kIfEnd === id) return node
@@ -199,7 +199,9 @@ export async function build({ quiet = false, minify = true } = {}) {
199
199
  const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
200
200
  const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
201
201
  const hasTextBindings = plans.some(plan => plan.bindings.some(binding => binding.target === "text"))
202
+ const hasSvgConditions = plans.some(plan => plan.conditions.some(condition => condition.svg))
202
203
  const hasListConditions = plans.some(plan => plan.lists.some(list => list.conditions))
204
+ const hasSvgLists = plans.some(plan => plan.lists.some(list => list.svg))
203
205
  const hasDeepListConditions = plans.some(plan => plan.lists.some(list => list.conditionHandlers))
204
206
  const hasListTextRanges = plans.some(plan => plan.lists.some(list => list.textRanges))
205
207
  const hasListAttributes = plans.some(plan => plan.lists.some(list => list.attributes))
@@ -213,6 +215,7 @@ export async function build({ quiet = false, minify = true } = {}) {
213
215
  const hasComplexListRowState = plans.some(plan => plan.lists.some(list => list.rowStates?.some(state => state.initialValue !== null && typeof state.initialValue === "object")))
214
216
  const hasNestedLists = plans.some(plan => plan.lists.some(list => list.ownerField))
215
217
  const hasCollectionSelectors = plans.some(plan => plan.lists.some(list => list.selector))
218
+ const hasDerivedEffectDependencies = plans.some(plan => plan.effects.some(effect => effect.dependencyExpressions?.length))
216
219
  const hasStaticCollections = plans.some(plan => plan.lists.some(list => list.static))
217
220
  const hasListIndexes = plans.some(plan => plan.lists.some(list => list.indexed))
218
221
  const hasListStableFastPaths = plans.some(plan => plan.lists.some(list => !list.children && !list.ownerField && list.key !== null && !list.indexed && !list.reducer && !list.selector))
@@ -271,11 +274,13 @@ export async function build({ quiet = false, minify = true } = {}) {
271
274
  if (navigationRoutes.length) bindingRuntime = specializeNavigationTextDescriptors(bindingRuntime)
272
275
  await writeBundledJavaScript(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime, minify, {
273
276
  "globalThis.__KUDZU_TEXT_BINDINGS__": String(hasTextBindings),
277
+ "globalThis.__KUDZU_SVG_CONDITIONS__": String(hasSvgConditions),
274
278
  "globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures)
275
279
  })
276
280
  }
281
+ if (hasDerivedEffectDependencies) await writeJavaScript(join(assetsDirectory, "kudzu-collection-selector.js"), await readFile(new URL("./collection-selector.js", import.meta.url), "utf8"), minify)
277
282
  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)
283
+ if (hasCollectionSelectors && !hasDerivedEffectDependencies) await writeJavaScript(join(assetsDirectory, "kudzu-collection-selector.js"), await readFile(new URL("./collection-selector.js", import.meta.url), "utf8"), minify)
279
284
  let listRuntime = (await readFile(new URL("./list-runtime.js", import.meta.url), "utf8"))
280
285
  .replace('"./shared-runtime.js"', '"./kudzu.js"')
281
286
  listRuntime = hasCollectionSelectors
@@ -347,9 +352,10 @@ export async function build({ quiet = false, minify = true } = {}) {
347
352
  __KUDZU_COLLECTION_SELECTORS__: String(hasCollectionSelectors),
348
353
  __KUDZU_STATIC_COLLECTIONS__: String(hasStaticCollections),
349
354
  __KUDZU_LIST_INDEXES__: String(hasListIndexes),
350
- __KUDZU_LIST_STABLE_FAST_PATHS__: String(hasListStableFastPaths)
355
+ __KUDZU_LIST_STABLE_FAST_PATHS__: String(hasListStableFastPaths),
356
+ __KUDZU_SVG_LISTS__: String(hasSvgLists)
351
357
  })
352
- if (hasCollectionSelectors) await rm(join(assetsDirectory, "kudzu-collection-selector.js"))
358
+ if (hasCollectionSelectors && !hasDerivedEffectDependencies) await rm(join(assetsDirectory, "kudzu-collection-selector.js"))
353
359
  }
354
360
  if (hasNativeHandlers) {
355
361
  const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
@@ -522,6 +528,7 @@ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base
522
528
  const hasCleanup = effects.some(effect => effect.cleanup)
523
529
  const hasDependencies = effects.some(effect => effect.dependencies?.length || effect.itemDependencies?.length)
524
530
  const hasOwners = effects.some(effect => effect.owner)
531
+ const hasDependencyExpressions = effects.some(effect => effect.dependencyExpressions?.length)
525
532
  const moduleUrls = [...new Set(effects.map(effect => effect.module))]
526
533
  const modules = moduleUrls.map(url => {
527
534
  const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
@@ -533,12 +540,13 @@ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base
533
540
  ? `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}\nconst { browserState, commitDom } = __kRuntime`
534
541
  : `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}`,
535
542
  `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
543
+ ...(hasDependencyExpressions ? [`import { evaluateCollectionExpression as __kEvaluateDependency } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-collection-selector.js")))}`] : []),
536
544
  ...(paramPath ? [`import ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, paramPath)))}`] : []),
537
545
  ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
538
546
  ]
539
547
  const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
540
548
  if (hasOwners) return printOwnedEffectEntry(imports, effects, entries)
541
- if (effects.length === 1 && effects[0].dependencies?.length === 1) return printSingleDependencyEffect(imports, effects[0], hasCleanup)
549
+ if (effects.length === 1 && effects[0].dependencies?.length === 1 && !hasDependencyExpressions) return printSingleDependencyEffect(imports, effects[0], hasCleanup)
542
550
  const disposal = hasCleanup ? `
543
551
  let disposed = false
544
552
  const dispose = root => {
@@ -616,6 +624,7 @@ async function flush() {
616
624
  }
617
625
  }
618
626
  function readDependencies(record) {
627
+ ${hasDependencyExpressions ? printDerivedDependencyRead("browserState") : ""}
619
628
  return (record.effect.dependencies ?? []).map(id => {
620
629
  const value = browserState.get(id)
621
630
  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 +698,7 @@ addEventListener("pagehide", event => {
689
698
  }
690
699
 
691
700
  function printNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, base) {
701
+ const hasDependencyExpressions = effects.some(effect => effect.dependencyExpressions?.length)
692
702
  const moduleUrls = [...new Set(effects.map(effect => effect.module))]
693
703
  const modules = moduleUrls.map(url => {
694
704
  const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
@@ -698,6 +708,7 @@ function printNavigableEffectEntry(effects, output, handlerModules, assetsDirect
698
708
  const imports = [
699
709
  `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
700
710
  `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
711
+ ...(hasDependencyExpressions ? [`import { evaluateCollectionExpression as __kEvaluateDependency } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-collection-selector.js")))}`] : []),
701
712
  ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
702
713
  ]
703
714
  const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
@@ -765,6 +776,7 @@ function mount(lifetime) {
765
776
  }
766
777
  }
767
778
  function readDependencies(record) {
779
+ ${hasDependencyExpressions ? printDerivedDependencyRead("__kRuntime.browserState") : ""}
768
780
  return (record.effect.dependencies ?? []).map(id => {
769
781
  const value = __kRuntime.browserState.get(id)
770
782
  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 +821,7 @@ function mount(lifetime) {
809
821
 
810
822
  function printOwnedNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, base) {
811
823
  const hasItemDependencies = effects.some(effect => effect.itemDependencies?.length)
824
+ const hasDependencyExpressions = effects.some(effect => effect.dependencyExpressions?.length)
812
825
  const moduleUrls = [...new Set(effects.map(effect => effect.module))]
813
826
  const modules = moduleUrls.map(url => {
814
827
  const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
@@ -818,6 +831,7 @@ function printOwnedNavigableEffectEntry(effects, output, handlerModules, assetsD
818
831
  const imports = [
819
832
  `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
820
833
  `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
834
+ ...(hasDependencyExpressions ? [`import { evaluateCollectionExpression as __kEvaluateDependency } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-collection-selector.js")))}`] : []),
821
835
  ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
822
836
  ]
823
837
  const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
@@ -986,6 +1000,7 @@ function mount(lifetime) {
986
1000
  }
987
1001
  }
988
1002
  function readDependencies(record) {
1003
+ ${hasDependencyExpressions ? printDerivedDependencyRead("__kRuntime.browserState") : ""}
989
1004
  const values = (record.effect.dependencies ?? []).map(id => {
990
1005
  const value = __kRuntime.browserState.get(id)
991
1006
  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 +1078,7 @@ function runtimeEffects(effects, lifetimes = false) {
1063
1078
  module: effect.module,
1064
1079
  handler: effect.handler,
1065
1080
  ...(effect.dependencies ? { dependencies: effect.dependencies } : {}),
1081
+ ...(effect.dependencyExpressions ? { dependencyExpressions: effect.dependencyExpressions, dependencyStates: effect.dependencyStates } : {}),
1066
1082
  ...(effect.itemDependencies ? { itemDependencies: effect.itemDependencies, listState: effect.listState } : {}),
1067
1083
  ...(effect.cleanup ? { cleanup: true } : {}),
1068
1084
  ...(effect.owner ? { owner: effect.owner } : {}),
@@ -1073,10 +1089,19 @@ function runtimeEffects(effects, lifetimes = false) {
1073
1089
  }))
1074
1090
  }
1075
1091
 
1092
+ function printDerivedDependencyRead(state) {
1093
+ return ` if (record.effect.dependencyExpressions) return record.effect.dependencyExpressions.map(expression => {
1094
+ const value = __kEvaluateDependency(expression, undefined, undefined, name => ${state}.get(record.effect.dependencyStates[name]))
1095
+ 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")
1096
+ return value
1097
+ })`
1098
+ }
1099
+
1076
1100
  function printOwnedEffectEntry(imports, effects, entries) {
1077
1101
  const hasItemDependencies = effects.some(effect => effect.itemDependencies?.length)
1078
1102
  const hasOrdinaryDependencies = effects.some(effect => effect.dependencies?.length)
1079
- const hasRowState = effects.some(effect => JSON.stringify([effect.dependencies, effect.states, effect.scope]).includes("$k"))
1103
+ const hasDependencyExpressions = effects.some(effect => effect.dependencyExpressions?.length)
1104
+ const hasRowState = effects.some(effect => JSON.stringify([effect.dependencies, effect.dependencyStates, effect.states, effect.scope]).includes("$k"))
1080
1105
  return `${imports.join("\n")}
1081
1106
  const effects = ${inlineJson(effects)}
1082
1107
  const modules = new Map([${entries}])
@@ -1098,7 +1123,7 @@ ${hasRowState ? `function specializeRowEffect(effect, marker) {
1098
1123
  const path = marker.dataset.kRowPath
1099
1124
  const id = value => typeof value === "string" ? value.replace("$k", path) : value
1100
1125
  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)])) }
1126
+ 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
1127
  }
1103
1128
  ` : ""}
1104
1129
  function registerDependencies(record) {
@@ -1240,6 +1265,7 @@ async function flush() {
1240
1265
  }
1241
1266
  }
1242
1267
  function readDependencies(record) {
1268
+ ${hasDependencyExpressions ? printDerivedDependencyRead("browserState") : ""}
1243
1269
  const values = (record.effect.dependencies ?? []).map(id => {
1244
1270
  const value = browserState.get(id)
1245
1271
  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")
@@ -1712,7 +1738,8 @@ async function compile(file, sourceFiles, sourceIndex, staticFiles, importedAsse
1712
1738
  if (errors.length) {
1713
1739
  throw new Error(errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
1714
1740
  }
1715
- if (hasReactModuleReference(result.outputText, file)) throw new Error(`${relative(root, file)} Runtime React module references are not supported`)
1741
+ const packageReference = emittedPackageReference(result.outputText, file, new Set(["react", "react-router-dom"]))
1742
+ if (packageReference) throw new Error(`${relative(root, file)} Runtime ${packageReference} module references are not supported`)
1716
1743
 
1717
1744
  const output = compiledPath(file)
1718
1745
  await mkdir(resolve(output, ".."), { recursive: true })
@@ -1735,18 +1762,88 @@ async function compile(file, sourceFiles, sourceIndex, staticFiles, importedAsse
1735
1762
  return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, hasEffects: effectHandlers.length > 0, clientImports: [...clientImports] }
1736
1763
  }
1737
1764
 
1738
- function hasReactModuleReference(source, file) {
1765
+ function emittedPackageReference(source, file, packages) {
1739
1766
  const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS)
1740
- let found = false
1767
+ let found
1741
1768
  const visit = node => {
1742
- if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "react") found = true
1743
- if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require") && ts.isStringLiteral(node.arguments[0]) && node.arguments[0].text === "react") found = true
1769
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && packages.has(node.moduleSpecifier.text)) found = node.moduleSpecifier.text
1770
+ if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require") && ts.isStringLiteral(node.arguments[0]) && packages.has(node.arguments[0].text)) found = node.arguments[0].text
1744
1771
  if (!found) ts.forEachChild(node, visit)
1745
1772
  }
1746
1773
  visit(sourceFile)
1747
1774
  return found
1748
1775
  }
1749
1776
 
1777
+ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1778
+ const links = new Set()
1779
+ for (const statement of sourceFile.statements) {
1780
+ if ((ts.isExportDeclaration(statement) || ts.isImportDeclaration(statement)) && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "react-router-dom") {
1781
+ if (ts.isExportDeclaration(statement)) throw sourceNodeError(statement, sourceFile, "React Router exports are not supported; import Link directly where it renders")
1782
+ const clause = statement.importClause
1783
+ if (clause?.isTypeOnly) continue
1784
+ if (!clause) throw sourceNodeError(statement, sourceFile, "Side-effect React Router imports are not supported")
1785
+ if (clause.name) throw sourceNodeError(clause.name, sourceFile, "React Router default imports are not supported; use a named Link import")
1786
+ const bindings = clause.namedBindings
1787
+ if (!bindings || ts.isNamespaceImport(bindings)) throw sourceNodeError(bindings ?? statement, sourceFile, "React Router namespace imports are not supported; use a named Link import")
1788
+ for (const entry of bindings.elements) {
1789
+ if (entry.isTypeOnly) continue
1790
+ const imported = (entry.propertyName ?? entry.name).text
1791
+ if (imported === "NavLink") throw sourceNodeError(entry, sourceFile, "React Router NavLink active-route semantics cannot be erased to a native anchor")
1792
+ if (imported !== "Link") throw sourceNodeError(entry, sourceFile, `React Router ${imported} is not supported; only named Link imports can be erased to native anchors`)
1793
+ links.add(entry.name.text)
1794
+ }
1795
+ }
1796
+ }
1797
+ if (!links.size) return sourceFile
1798
+
1799
+ const routerProps = new Set(["discover", "end", "prefetch", "preventScrollReset", "relative", "reloadDocument", "replace", "state", "viewTransition"])
1800
+ const attributes = attributesNode => {
1801
+ const output = []
1802
+ let destination
1803
+ for (const property of attributesNode.properties) {
1804
+ if (ts.isJsxSpreadAttribute(property)) throw sourceNodeError(property, sourceFile, "React Router Link does not support spread attributes during native anchor lowering")
1805
+ const name = property.name.text
1806
+ if (name === "href") throw sourceNodeError(property, sourceFile, "React Router Link must not declare href; Kudzu derives it from to")
1807
+ if (routerProps.has(name)) throw sourceNodeError(property, sourceFile, `React Router Link prop ${JSON.stringify(name)} cannot be erased to a native anchor`)
1808
+ if (name !== "to") {
1809
+ output.push(ts.visitEachChild(property, visitor, context))
1810
+ continue
1811
+ }
1812
+ if (destination !== undefined) throw sourceNodeError(property, sourceFile, "React Router Link requires exactly one to attribute")
1813
+ if (!property.initializer || !ts.isStringLiteral(property.initializer)) throw sourceNodeError(property, sourceFile, 'React Router Link requires a static root-relative to="/path"')
1814
+ destination = property.initializer.text
1815
+ const pathname = destination.match(/^[^?#]*/)[0]
1816
+ let decoded
1817
+ try { decoded = decodeURIComponent(pathname) } catch { throw sourceNodeError(property.initializer, sourceFile, 'React Router Link requires a safe static root-relative to="/path"') }
1818
+ if (!destination.startsWith("/") || destination.startsWith("//") || /%(?:2f|5c)/i.test(pathname) || /[\\\0]/.test(decoded) || decoded.split("/").includes("..") || [...decoded].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) >= 127 && character.charCodeAt(0) <= 159)) throw sourceNodeError(property.initializer, sourceFile, 'React Router Link requires a safe static root-relative to="/path"')
1819
+ output.push(factory.createJsxAttribute(factory.createIdentifier("href"), factory.createStringLiteral(withBase(base, destination))))
1820
+ }
1821
+ if (destination === undefined) throw sourceNodeError(attributesNode.parent, sourceFile, "React Router Link requires exactly one static root-relative to attribute")
1822
+ return factory.updateJsxAttributes(attributesNode, output)
1823
+ }
1824
+ const importedLink = tag => ts.isIdentifier(tag) && links.has(tag.text) && !isShadowedIdentifier(tag, sourceFile)
1825
+ const visitor = node => {
1826
+ if (ts.isJsxElement(node) && importedLink(node.openingElement.tagName)) {
1827
+ const opening = factory.updateJsxOpeningElement(node.openingElement, factory.createIdentifier("a"), node.openingElement.typeArguments, attributes(node.openingElement.attributes))
1828
+ const closing = factory.updateJsxClosingElement(node.closingElement, factory.createIdentifier("a"))
1829
+ return factory.updateJsxElement(node, opening, ts.visitNodes(node.children, visitor), closing)
1830
+ }
1831
+ if (ts.isJsxSelfClosingElement(node) && importedLink(node.tagName)) return factory.updateJsxSelfClosingElement(node, factory.createIdentifier("a"), node.typeArguments, attributes(node.attributes))
1832
+ if (ts.isIdentifier(node) && links.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router Link imports may only be used as direct JSX elements")
1833
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "react-router-dom") {
1834
+ const clause = node.importClause
1835
+ if (!clause || clause.isTypeOnly) return node
1836
+ const bindings = clause.namedBindings
1837
+ if (!bindings || !ts.isNamedImports(bindings)) return node
1838
+ const elements = bindings.elements.filter(entry => entry.isTypeOnly || (entry.propertyName ?? entry.name).text !== "Link")
1839
+ if (!elements.length) return undefined
1840
+ return factory.updateImportDeclaration(node, node.modifiers, factory.updateImportClause(clause, clause.isTypeOnly, undefined, factory.updateNamedImports(bindings, elements)), node.moduleSpecifier, node.attributes)
1841
+ }
1842
+ return ts.visitEachChild(node, visitor, context)
1843
+ }
1844
+ return ts.visitNode(sourceFile, visitor)
1845
+ }
1846
+
1750
1847
  function normalizeClsxSyntax(sourceFile, factory, context) {
1751
1848
  const names = new Set()
1752
1849
  for (const statement of sourceFile.statements) {
@@ -2222,7 +2319,7 @@ function lowerReactMemoCollectionExpression(expression, factory) {
2222
2319
  if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && node.arguments.length === 1) {
2223
2320
  return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("Array"), "from"), undefined, [visit(node.expression.expression), node.arguments[0]])
2224
2321
  }
2225
- if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ["filter", "flatMap"].includes(node.expression.name.text)) {
2322
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ["filter", "flatMap", "slice", "toSorted"].includes(node.expression.name.text)) {
2226
2323
  return factory.updateCallExpression(node, factory.updatePropertyAccessExpression(node.expression, visit(node.expression.expression), node.expression.name), node.typeArguments, node.arguments)
2227
2324
  }
2228
2325
  if (isArrayFromCall(node)) return factory.updateCallExpression(node, node.expression, node.typeArguments, [visit(node.arguments[0]), ...node.arguments.slice(1)])
@@ -2276,6 +2373,8 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2276
2373
  const factory = context.factory
2277
2374
  const hasLinkElements = /<link/i.test(sourceFile.text)
2278
2375
  const importedCollections = importedSerializableCollectionNames(sourceFile, file, sourceFiles, sourceIndex)
2376
+ sourceFile = normalizeReactRouterSyntax(sourceFile, factory, context, base)
2377
+ ts.setParentRecursive(sourceFile, false)
2279
2378
  sourceFile = normalizeClsxSyntax(sourceFile, factory, context)
2280
2379
  ts.setParentRecursive(sourceFile, false)
2281
2380
  sourceFile = normalizeReactMigrationSyntax(sourceFile, factory, context, importedCollections)
@@ -2294,7 +2393,9 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2294
2393
  const importedSource = target => {
2295
2394
  let imported = importedSources.get(target)
2296
2395
  if (!imported) {
2297
- imported = normalizeClsxSyntax(parseSourceFile(target, sourceIndex.get(target)), factory, context)
2396
+ imported = normalizeReactRouterSyntax(parseSourceFile(target, sourceIndex.get(target)), factory, context, base)
2397
+ ts.setParentRecursive(imported, false)
2398
+ imported = normalizeClsxSyntax(imported, factory, context)
2298
2399
  ts.setParentRecursive(imported, false)
2299
2400
  imported = normalizeReactMigrationSyntax(imported, factory, context, importedSerializableCollectionNames(imported, target, sourceFiles, sourceIndex))
2300
2401
  ts.setParentRecursive(imported, false)
@@ -2309,6 +2410,13 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2309
2410
  }
2310
2411
  return imported
2311
2412
  }
2413
+ const importedCollectionTransforms = new Map()
2414
+ for (const [name, binding] of importBindings) {
2415
+ if (binding.kind === "namespace") continue
2416
+ try {
2417
+ importedCollectionTransforms.set(name, resolveComponentExport(binding.target, binding.kind === "default" ? "default" : binding.imported, target => parseSourceFile(target, sourceIndex.get(target)), sourceFiles))
2418
+ } catch {}
2419
+ }
2312
2420
  const settersByFunction = new Map()
2313
2421
  const reducersByFunction = new Map()
2314
2422
  const zustandStores = new Map()
@@ -2446,7 +2554,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2446
2554
  const setters = settersByFunction.get(owner) ?? new Map()
2447
2555
  for (const [name, entries] of declarations) {
2448
2556
  for (const declaration of entries) {
2449
- const parts = keyedListParts(declaration.initializer, setters, declarations, (target, message) => { throw sourceNodeError(target, sourceFile, message) }, new Set(), importedCollections, factory, context)
2557
+ const parts = keyedListParts(declaration.initializer, setters, declarations, (target, message) => { throw sourceNodeError(target, sourceFile, message) }, new Set(), importedCollections, factory, context, importedCollectionTransforms)
2450
2558
  if (!parts) continue
2451
2559
  const uses = []
2452
2560
  const collectUses = node => {
@@ -2640,7 +2748,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2640
2748
  return
2641
2749
  }
2642
2750
  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, factory, context)
2751
+ const parts = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction), jsxLocalDeclarations.get(nearestFunction(node)), fail, new Set(), importedCollections, factory, context, importedCollectionTransforms)
2644
2752
  if (parts) {
2645
2753
  for (const declaration of parts.aliasDeclarations ?? []) listLocalDeclarations.add(declaration)
2646
2754
  rawRenderedLists.push({ node, parts })
@@ -2855,14 +2963,24 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2855
2963
  fail(target, message)
2856
2964
  }
2857
2965
  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")
2966
+ const [callbackArgument, dependencies] = node.arguments
2967
+ const effectOwner = nearestFunction(node)
2968
+ const resolveEffectFunction = expression => {
2969
+ if (!ts.isIdentifier(expression)) return undefined
2970
+ const entries = jsxLocalDeclarations.get(effectOwner)?.get(expression.text)
2971
+ if (entries?.length !== 1 || entries[0].node.parent?.parent?.parent !== effectOwner?.body) return undefined
2972
+ const initializer = entries[0].initializer
2973
+ return ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer) ? initializer : undefined
2974
+ }
2975
+ let callback = ts.isArrowFunction(callbackArgument) || ts.isFunctionExpression(callbackArgument) ? callbackArgument : resolveEffectFunction(callbackArgument)
2976
+ if (!callback) effectFail(callbackArgument, "useEffect() callback must be inline or one top-level const function")
2860
2977
  if (ts.isFunctionExpression(callback) && callback.name) effectFail(callback, "useEffect() callback function must be anonymous")
2861
2978
  if (callback.asteriskToken) effectFail(callback, "useEffect() callback cannot be a generator")
2862
2979
  if (callback.parameters.length) effectFail(callback, "useEffect() callback cannot declare parameters")
2863
2980
  if (!ts.isArrayLiteralExpression(dependencies)) effectFail(dependencies, "useEffect() dependencies must be a literal array")
2864
2981
  const itemDependencies = []
2865
2982
  const ordinaryDependencies = []
2983
+ const setters = settersForNode(node, settersByFunction)
2866
2984
  let dependencyItem = listEffect?.item
2867
2985
  for (const dependency of dependencies.elements) {
2868
2986
  const value = unwrapExpression(dependency)
@@ -2879,24 +2997,74 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2879
2997
  }
2880
2998
  const invalidDependency = ordinaryDependencies.find(dependency => !ts.isIdentifier(dependency))
2881
2999
  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")
3000
+ const dependencyExpressions = []
3001
+ const dependencyStates = new Map()
3002
+ const dependencySubstitutions = new Map()
3003
+ const subscriptionDependencies = []
3004
+ let hasDerivedDependency = false
3005
+ const stateNames = new Set(setters.values())
3006
+ const localDeclarations = jsxLocalDeclarations.get(nearestFunction(node))
3007
+ for (const dependency of ordinaryDependencies) {
3008
+ const entries = localDeclarations?.get(dependency.text)
3009
+ const initializer = entries?.length === 1 ? entries[0].initializer : undefined
3010
+ const directAlias = initializer && ts.isIdentifier(unwrapExpression(initializer)) && stateNames.has(unwrapExpression(initializer).text)
3011
+ const derivedStates = initializer && !directAlias ? referencedStateNames(initializer, setters) : new Set()
3012
+ if (derivedStates.size) {
3013
+ const usedStates = new Set()
3014
+ const expression = collectionExpression(initializer, {}, effectFail, stateNames, usedStates)
3015
+ if (!usedStates.size) effectFail(dependency, `useEffect() derived dependency "${dependency.text}" must read direct primitive state`)
3016
+ dependencyExpressions.push(expression)
3017
+ for (const name of usedStates) {
3018
+ subscriptionDependencies.push(factory.createIdentifier(name))
3019
+ dependencyStates.set(name, factory.createIdentifier(name))
3020
+ }
3021
+ dependencySubstitutions.set(dependency.text, initializer)
3022
+ hasDerivedDependency = true
3023
+ } else {
3024
+ subscriptionDependencies.push(dependency)
3025
+ dependencyExpressions.push(["state", dependency.text])
3026
+ dependencyStates.set(dependency.text, dependency)
3027
+ }
3028
+ }
3029
+ if (!hasDerivedDependency) {
3030
+ dependencyExpressions.length = 0
3031
+ dependencyStates.clear()
3032
+ }
3033
+ if (!effectOwner) fail(node, "useEffect() cannot be used outside a Kudzu component")
2883
3034
  if (!ts.isBlock(callback.body)) effectFail(callback, "useEffect() callback must use a block body")
3035
+ const cleanupSubstitutions = new Map()
3036
+ const collectNamedCleanups = current => {
3037
+ if (current !== callback && isFunctionLike(current)) return
3038
+ if (ts.isReturnStatement(current) && current.expression && ts.isIdentifier(unwrapExpression(current.expression))) {
3039
+ const cleanup = resolveEffectFunction(unwrapExpression(current.expression))
3040
+ if (cleanup) cleanupSubstitutions.set(unwrapExpression(current.expression).text, cleanup)
3041
+ }
3042
+ ts.forEachChild(current, collectNamedCleanups)
3043
+ }
3044
+ collectNamedCleanups(callback.body)
3045
+ if (cleanupSubstitutions.size) {
3046
+ callback = substituteClone(callback, cleanupSubstitutions, factory, context)
3047
+ ts.setParentRecursive(callback, false)
3048
+ callback.parent = callbackArgument.parent
3049
+ }
2884
3050
  const returns = effectReturns(callback)
2885
3051
  if (returns.invalid) effectFail(returns.invalid, "useEffect() return values must be inline cleanup functions")
2886
3052
  const invalidCleanup = returns.cleanups.find(cleanup => cleanup.parameters.length || cleanup.asteriskToken)
2887
3053
  if (invalidCleanup) effectFail(invalidCleanup, "useEffect() cleanup functions cannot declare parameters or be generators")
2888
3054
  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
3055
  const callbackSource = listEffect?.sourceFile ?? sourceFile
2891
3056
  const callbackFile = callbackSource.fileName
2892
3057
  const workerStart = workerReferences.length
2893
- let compiledCallback
3058
+ let compiledCallback = dependencySubstitutions.size ? substituteClone(callback, dependencySubstitutions, factory, context) : callback
3059
+ if (compiledCallback !== callback) {
3060
+ ts.setParentRecursive(compiledCallback, false)
3061
+ compiledCallback.parent = callback.parent
3062
+ }
2894
3063
  if (listEffect && callbackFile !== file) {
2895
3064
  const originalCallback = listEffect.source.arguments[0]
2896
3065
  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
3066
  } else {
2899
- compiledCallback = rewriteEffectWorkers(callback, callbackFile, callbackSource, sourceFiles, workerReferences, factory, context)
3067
+ compiledCallback = rewriteEffectWorkers(compiledCallback, callbackFile, callbackSource, sourceFiles, workerReferences, factory, context)
2900
3068
  }
2901
3069
  const descriptor = compileNativeCallback(compiledCallback, setters, reducersForNode(node, reducersByFunction), factory, effectHandlers, listEffect?.imports ?? importBindings, clientImports, "effect", dependencyItem, true, returns.cleanup)
2902
3070
  for (const reference of workerReferences.slice(workerStart)) Object.assign(reference, { module: handlerUrl, handler: descriptor.exportName })
@@ -2904,14 +3072,16 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2904
3072
  usesBehavior = true
2905
3073
  return factory.updateCallExpression(node, node.expression, node.typeArguments, [
2906
3074
  callback,
2907
- factory.createArrayLiteralExpression(ordinaryDependencies),
3075
+ factory.createArrayLiteralExpression(hasDerivedDependency ? subscriptionDependencies : ordinaryDependencies),
2908
3076
  factory.createStringLiteral(handlerUrl),
2909
3077
  factory.createStringLiteral(descriptor.exportName),
2910
3078
  descriptor.states,
2911
3079
  descriptor.scope,
2912
3080
  factory.createStringLiteral(listEffect ? sourceLocation(listEffect.source, listEffect.sourceFile) : sourceLocation(node, sourceFile)),
2913
3081
  returns.cleanup ? factory.createTrue() : factory.createFalse(),
2914
- factory.createArrayLiteralExpression(itemDependencies.map(field => factory.createStringLiteral(field)))
3082
+ factory.createArrayLiteralExpression(itemDependencies.map(field => factory.createStringLiteral(field))),
3083
+ hasDerivedDependency ? jsonExpression(dependencyExpressions, factory) : factory.createArrayLiteralExpression(),
3084
+ factory.createArrayLiteralExpression([...dependencyStates].map(([name, state]) => factory.createArrayLiteralExpression([factory.createStringLiteral(name), state])))
2915
3085
  ])
2916
3086
  }
2917
3087
 
@@ -3157,11 +3327,11 @@ function containsRenderControl(root, knownLocals) {
3157
3327
  return found
3158
3328
  }
3159
3329
 
3160
- function keyedListParts(expression, setters, declarations, fail, aliases = new Set(), importedCollections = new Set(), factory = ts.factory, context) {
3330
+ function keyedListParts(expression, setters, declarations, fail, aliases = new Set(), importedCollections = new Set(), factory = ts.factory, context, importedCollectionTransforms = new Map()) {
3161
3331
  const value = unwrapExpression(expression)
3162
3332
  const directFrom = isArrayFromCall(value) && value.arguments.length === 2 && containsJsx(value.arguments[1])
3163
3333
  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()))
3334
+ const collection = renderedCollectionSource(directFrom ? value.arguments[0] : value.expression.expression, setters, declarations, fail, aliases, importedCollections, new Set(setters.values()), importedCollectionTransforms, factory, context)
3165
3335
  if (!collection?.state) return undefined
3166
3336
  if (directFrom) collection.selector.push(["from", undefined])
3167
3337
  let callback = directFrom ? value.arguments[1] : value.arguments[0]
@@ -3171,7 +3341,7 @@ function keyedListParts(expression, setters, declarations, fail, aliases = new S
3171
3341
  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")
3172
3342
  const declaration = root.statements[0].declarationList.declarations[0]
3173
3343
  if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration, "Computed child collections must initialize one const identifier")
3174
- const computed = renderedCollectionSource(declaration.initializer, new Map(), undefined, fail, new Set())
3344
+ const computed = renderedCollectionSource(declaration.initializer, new Map(), undefined, fail, new Set(), new Set(), new Set(), importedCollectionTransforms, factory, context)
3175
3345
  if (!computed?.ownerField || computed.parentItem !== parameters.item) fail(declaration.initializer, `Computed child collections must start from ${parameters.item}.<field>`)
3176
3346
  const returned = root.statements[1].expression
3177
3347
  if (identifierReferenceCount(returned, declaration.name.text) !== 1) fail(declaration.name, `Computed child collection alias "${declaration.name.text}" must be used exactly once`)
@@ -3208,7 +3378,7 @@ function nestedKeyedListParts(expression, parentItem, fail) {
3208
3378
  return { ...collection, callback, root, item: parameters.item, index: parameters.index, indexed: Boolean(parameters.index), keyField: positional ? null : keyField }
3209
3379
  }
3210
3380
 
3211
- function renderedCollectionSource(expression, setters, declarations, fail, aliases, importedCollections = new Set(), stateNames = new Set()) {
3381
+ function renderedCollectionSource(expression, setters, declarations, fail, aliases, importedCollections = new Set(), stateNames = new Set(), importedCollectionTransforms = new Map(), factory = ts.factory, context) {
3212
3382
  const value = unwrapExpression(expression)
3213
3383
  if (ts.isIdentifier(value)) {
3214
3384
  if ([...setters.values()].includes(value.text)) return { state: value, selector: [], selectorStates: new Set() }
@@ -3217,16 +3387,30 @@ function renderedCollectionSource(expression, setters, declarations, fail, alias
3217
3387
  if (!entries) return undefined
3218
3388
  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`)
3219
3389
  aliases.add(value.text)
3220
- const source = renderedCollectionSource(entries[0].initializer, setters, declarations, fail, aliases, importedCollections, stateNames)
3390
+ const source = renderedCollectionSource(entries[0].initializer, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
3221
3391
  aliases.delete(value.text)
3222
3392
  return source && { ...source, aliasDeclarations: [...(source.aliasDeclarations ?? []), entries[0].node], aliasUses: [...(source.aliasUses ?? []), value] }
3223
3393
  }
3224
3394
  if (ts.isPropertyAccessExpression(value) && ts.isIdentifier(value.expression)) return { state: undefined, ownerField: value.name.text, selector: [], parentItem: value.expression.text }
3395
+ if (ts.isCallExpression(value) && ts.isIdentifier(value.expression) && importedCollectionTransforms.has(value.expression.text)) {
3396
+ const transform = importedCollectionTransforms.get(value.expression.text)
3397
+ const parameter = transform.parameters[0]
3398
+ 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`)
3399
+ const returned = ts.isBlock(transform.body)
3400
+ ? transform.body.statements.length === 1 && ts.isReturnStatement(transform.body.statements[0]) ? transform.body.statements[0].expression : undefined
3401
+ : transform.body
3402
+ if (!returned) fail(value, `Imported collection transform "${value.expression.text}" must contain only one returned collection expression`)
3403
+ const transformSource = renderedCollectionSource(returned, new Map([[parameter.name.text, parameter.name.text]]), undefined, fail, new Set(), new Set(), new Set([parameter.name.text]))
3404
+ 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`)
3405
+ const source = renderedCollectionSource(value.arguments[0], setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
3406
+ if (!source) fail(value.arguments[0], `Imported collection transform "${value.expression.text}" requires a supported collection argument`)
3407
+ return { ...source, selector: [...source.selector, ...transformSource.selector] }
3408
+ }
3225
3409
  if (ts.isCallExpression(value) && ts.isPropertyAccessExpression(value.expression)) {
3226
3410
  const method = value.expression.name.text
3227
3411
  if (method === "filter") {
3228
3412
  if (value.arguments.length !== 1) fail(value, "Rendered collection filter() requires one inline predicate")
3229
- const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames)
3413
+ const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
3230
3414
  if (!source) return undefined
3231
3415
  const parameters = collectionParameters(value.arguments[0], "Rendered collection filter()", fail)
3232
3416
  const selectorStates = new Set(source.selectorStates)
@@ -3234,7 +3418,7 @@ function renderedCollectionSource(expression, setters, declarations, fail, alias
3234
3418
  }
3235
3419
  if (method === "flatMap") {
3236
3420
  if (value.arguments.length !== 1) fail(value, "Rendered collection flatMap() requires one inline projector")
3237
- const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames)
3421
+ const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
3238
3422
  if (!source) return undefined
3239
3423
  const parameters = collectionParameters(value.arguments[0], "Rendered collection flatMap()", fail)
3240
3424
  const field = directProperty(value.arguments[0].body, parameters.item)
@@ -3242,10 +3426,31 @@ function renderedCollectionSource(expression, setters, declarations, fail, alias
3242
3426
  if (["__proto__", "constructor", "prototype"].includes(field)) fail(value.arguments[0].body, `Rendered collection property "${field}" is not supported`)
3243
3427
  return { ...source, selector: [...source.selector, ["flatMap", field]] }
3244
3428
  }
3429
+ if (method === "slice") {
3430
+ if (value.arguments.length < 1 || value.arguments.length > 2) fail(value, "Rendered collection slice() requires a start and optional end")
3431
+ const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
3432
+ if (!source) return undefined
3433
+ const selectorStates = new Set(source.selectorStates)
3434
+ const start = collectionExpression(value.arguments[0], {}, fail, stateNames, selectorStates)
3435
+ const end = value.arguments[1] && collectionExpression(value.arguments[1], {}, fail, stateNames, selectorStates)
3436
+ return { ...source, selector: [...source.selector, ["slice", start, end]], selectorStates }
3437
+ }
3438
+ if (method === "toSorted") {
3439
+ if (value.arguments.length !== 1) fail(value, "Rendered collection toSorted() requires one inline comparator")
3440
+ const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
3441
+ if (!source) return undefined
3442
+ const comparator = value.arguments[0]
3443
+ const parameters = collectionParameters(comparator, "Rendered collection toSorted()", fail)
3444
+ 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")
3445
+ const selectorStates = new Set(source.selectorStates)
3446
+ const expression = collectionExpression(comparator.body, parameters, fail, stateNames, selectorStates)
3447
+ return { ...source, selector: [...source.selector, ["sort", expression]], selectorStates }
3448
+ }
3449
+ if (method === "sort") fail(value, "Rendered collections cannot use mutating sort(); use toSorted()")
3245
3450
  }
3246
3451
  if (isArrayFromCall(value)) {
3247
3452
  if (value.arguments.length < 1 || value.arguments.length > 2) fail(value, "Rendered Array.from() requires an anchor and optional inline mapper")
3248
- const source = renderedCollectionSource(value.arguments[0], setters, declarations, fail, aliases, importedCollections, stateNames)
3453
+ const source = renderedCollectionSource(value.arguments[0], setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
3249
3454
  if (!source) return undefined
3250
3455
  let mapper
3251
3456
  if (value.arguments[1]) {
@@ -3932,7 +4137,7 @@ function jsxTagUses(root, name) {
3932
4137
  return uses
3933
4138
  }
3934
4139
 
3935
- 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"])
4140
+ 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"])
3936
4141
  const mutatingListMethods = new Set(["copyWithin", "fill", "pop", "push", "reverse", "shift", "sort", "splice", "unshift"])
3937
4142
  const pureMathMethods = new Set(["abs", "ceil", "floor", "max", "min", "pow", "round", "sign", "sqrt", "trunc"])
3938
4143
  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 } : {}),
@@ -583,7 +588,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
583
588
  const descriptor = bindingDescriptor(node)
584
589
  const stateIds = reactiveStateIds(descriptor)
585
590
  if (!stateIds.size) return renderNode(node.value ? node.truthy() : node.falsy(), namespace, selectValue)
586
- if (namespace) throw new Error(`Reactive conditional DOM is not supported inside ${namespace}`)
591
+ if (namespace === "math") throw new Error("Reactive conditional DOM is not supported inside math")
587
592
 
588
593
  const id = renderContext.listRoot || renderContext.listRowRoot ? nextRowRenderId("c") : nextRenderId("c")
589
594
  const renderBranch = async branch => {
@@ -604,14 +609,17 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
604
609
  }
605
610
  const owned = truthy.states.length || falsy.states.length ? { true: truthy.states, false: falsy.states } : undefined
606
611
  const mount = Boolean(owned) || truthy.html.includes("data-k-") || falsy.html.includes("data-k-") || truthy.html.includes("<!--k-text:") || falsy.html.includes("<!--k-text:")
607
- const metadata = { id, kind: node.kind, initial: node.value, ...descriptor, ...(owned ? { owned } : {}), ...(mount ? { mount: true } : {}) }
612
+ const metadata = { id, kind: node.kind, initial: node.value, ...descriptor, ...(namespace === "svg" ? { svg: true } : {}), ...(owned ? { owned } : {}), ...(mount ? { mount: true } : {}) }
608
613
  for (const stateId of stateIds) renderContext.conditionStates.add(stateId)
609
614
  renderContext.conditions.push(metadata)
610
615
  renderContext.hasBehaviors = true
611
616
  renderContext.hasBindings = true
612
617
  const encoded = escapeJsonAttribute(metadata)
613
618
  const current = node.value ? truthy.html : node.kind === "and" ? await renderNode(node.value, namespace, selectValue) : falsy.html
614
- return `<template data-k-if='${encoded}'><template data-k-true>${truthy.html}</template><template data-k-false>${falsy.html}</template></template>${current}<template data-k-if-end="${id}"></template>`
619
+ const branches = namespace === "svg"
620
+ ? ` data-k-svg-true="${escapeAttribute(truthy.html)}" data-k-svg-false="${escapeAttribute(falsy.html)}"></template>`
621
+ : `><template data-k-true>${truthy.html}</template><template data-k-false>${falsy.html}</template></template>`
622
+ return `<template data-k-if='${encoded}'${branches}${current}<template data-k-if-end="${id}"></template>`
615
623
  }
616
624
  if (node?.[listMarker]) return renderList(node, namespace, selectValue)
617
625
  if (node?.[listFieldMarker]) {
@@ -639,6 +647,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
639
647
  return `<!--k-text:${id}-->${escapeHtml(node.value ?? "")}<!--k-text-end-->`
640
648
  }
641
649
  if (node?.[listConditionalMarker]) {
650
+ if (namespace === "svg") throw new Error("Keyed row conditions are not supported inside svg")
642
651
  const descriptor = { kind: node.kind, module: node.module, handler: node.handler }
643
652
  const owner = renderContext.listRoot ?? renderContext.listRowRoot
644
653
  if (owner) owner.conditions = true
@@ -828,12 +837,13 @@ function sharedInitialListMarker() {
828
837
  }
829
838
 
830
839
  async function renderList(node, namespace, selectValue) {
831
- if (namespace) throw new Error(`Reactive keyed lists are not supported inside ${namespace}`)
840
+ if (namespace === "math") throw new Error("Reactive keyed lists are not supported inside math")
841
+ if (namespace === "svg" && node.ownerField) throw new Error("Nested reactive keyed lists are not supported inside svg")
832
842
  const ownerRoot = node.ownerField ? renderContext.listRoot ?? renderContext.listRowRoot : undefined
833
843
  const ownerTemplate = Boolean(node.ownerField && renderContext.listTemplate)
834
844
  const rowList = node.ownerField ? nextRowList() : undefined
835
845
  const id = rowList?.id ?? nextRenderId("l")
836
- const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.values.map((item, index) => node.keyField === null ? index : item[node.keyField]), ...(node.items[internalStateMarker] ? { static: true } : {}), ...(node.ownerField ? { ownerField: node.ownerField } : {}), ...(node.selector.length ? { selector: node.selector } : {}), ...(node.selectorStates.length ? { selectorStates: Object.fromEntries(node.selectorStates.map(([name, state]) => [name, state.id])) } : {}), ...(node.indexed ? { indexed: true } : {}), ...(!node.selector.length && node.keyField !== null && node.items[reducerStateMarker] ? { reducer: true } : {}) }
846
+ const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.values.map((item, index) => node.keyField === null ? index : item[node.keyField]), ...(namespace === "svg" ? { svg: true } : {}), ...(node.items[internalStateMarker] ? { static: true } : {}), ...(node.ownerField ? { ownerField: node.ownerField } : {}), ...(node.selector.length ? { selector: node.selector } : {}), ...(node.selectorStates.length ? { selectorStates: Object.fromEntries(node.selectorStates.map(([name, state]) => [name, state.id])) } : {}), ...(node.indexed ? { indexed: true } : {}), ...(!node.selector.length && node.keyField !== null && node.items[reducerStateMarker] ? { reducer: true } : {}) }
837
847
  if (ownerTemplate) {
838
848
  ownerRoot.descriptor.children ??= []
839
849
  ownerRoot.descriptor.children.push({ id, field: node.ownerField, key: node.keyField, ...(node.selector.length ? { selector: node.selector } : {}) })
@@ -899,7 +909,8 @@ async function renderList(node, namespace, selectValue) {
899
909
  renderContext.hasBehaviors = true
900
910
  renderContext.hasLists = true
901
911
  const prototype = node.ownerField && !ownerTemplate ? "" : template
902
- return `<template data-k-list='${escapeJsonAttribute(descriptor)}'>${prototype}</template>${current}<template data-k-list-end="${id}"></template>`
912
+ const svgTemplate = namespace === "svg" ? ` data-k-svg-template="${escapeAttribute(prototype)}"` : ""
913
+ return `<template data-k-list='${escapeJsonAttribute(descriptor)}'${svgTemplate}>${namespace === "svg" ? "" : prototype}</template>${current}<template data-k-list-end="${id}"></template>`
903
914
  } finally {
904
915
  renderContext.listRoot = previousListRoot
905
916
  renderContext.listRowRoot = previousListRowRoot
@@ -44,7 +44,7 @@ function mountLists(root) {
44
44
  const end = findEnd(start, descriptor.id)
45
45
  const roots = listRoots(start, end)
46
46
  const nested = __KUDZU_NESTED_LISTS__ ? mountNestedPrototype(start, descriptor, roots) : undefined
47
- const templateRoot = __KUDZU_NESTED_LISTS__ ? nested.templateRoot : start.content.firstElementChild
47
+ const templateRoot = __KUDZU_NESTED_LISTS__ ? nested.templateRoot : listTemplateRoot(start, descriptor)
48
48
  if (__KUDZU_LIST_ROW_HOOKS__) for (let index = 0; index < roots.length; index++) initializeGeneralRowHooks(descriptor, descriptor.keys[index], roots[index], nested?.owner)
49
49
  const parts = listItemPartPlan(templateRoot, descriptor.nested)
50
50
  const staticRows = __KUDZU_STATIC_COLLECTIONS__ && descriptor.static && parts.directFill ? new Map() : undefined
@@ -63,7 +63,8 @@ function mountLists(root) {
63
63
  const list = {
64
64
  start,
65
65
  descriptor,
66
- ...(__KUDZU_NESTED_LISTS__ ? { templateRoot, ...(nested.childPrototypes?.size ? { childPrototypes: nested.childPrototypes } : {}) } : {}),
66
+ templateRoot,
67
+ ...(__KUDZU_NESTED_LISTS__ && nested.childPrototypes?.size ? { childPrototypes: nested.childPrototypes } : {}),
67
68
  parts,
68
69
  ...(__KUDZU_STATIC_COLLECTIONS__ && staticRows ? { staticRows } : {}),
69
70
  ...(__KUDZU_STATIC_COLLECTIONS__ && staticEntries ? { staticEntries, staticPositions: staticEntries.positions } : {}),
@@ -196,7 +197,7 @@ function updateList(list) {
196
197
  let node = list.roots.get(token)
197
198
  if (!node) {
198
199
  const staticRoot = __KUDZU_STATIC_COLLECTIONS__ ? list.staticRows?.get(token)?.cloneNode(true) : undefined
199
- node = staticRoot ?? (__KUDZU_NESTED_LISTS__ ? list.templateRoot : list.start.content.firstElementChild)?.cloneNode(true)
200
+ node = staticRoot ?? list.templateRoot?.cloneNode(true)
200
201
  if (!staticRoot && node?.dataset.kListRoot !== list.descriptor.id) node = undefined
201
202
  if (!node) throw new Error("Keyed list template has no root element")
202
203
  node.removeAttribute("data-k-list-root")
@@ -360,7 +361,7 @@ function updateStableList(list, items) {
360
361
  keys.add(token)
361
362
  if (index < previous.length && (key !== previous[index][keyField] || appending && item !== previous[index])) stable = false
362
363
  if (!stable || items.length === previous.length + 1 || index < previous.length) continue
363
- let node = (__KUDZU_NESTED_LISTS__ ? list.templateRoot : list.start.content.firstElementChild)?.cloneNode(true)
364
+ let node = list.templateRoot?.cloneNode(true)
364
365
  if (node?.dataset.kListRoot !== list.descriptor.id) node = undefined
365
366
  if (!node) throw new Error("Keyed list template has no root element")
366
367
  node.removeAttribute("data-k-list-root")
@@ -545,7 +546,7 @@ function updateReducerList(list, items) {
545
546
  }
546
547
 
547
548
  function addListRoot(list, { item, index = list.roots.size, key, token, value }) {
548
- let node = (__KUDZU_NESTED_LISTS__ ? list.templateRoot : list.start.content.firstElementChild)?.cloneNode(true)
549
+ let node = list.templateRoot?.cloneNode(true)
549
550
  if (node?.dataset.kListRoot !== list.descriptor.id) node = undefined
550
551
  if (!node) throw new Error("Keyed list template has no root element")
551
552
  node.removeAttribute("data-k-list-root")
@@ -862,7 +863,7 @@ function listOwner(start) {
862
863
  function mountNestedPrototype(start, descriptor, roots) {
863
864
  const owner = descriptor.ownerField ? listOwner(start) : undefined
864
865
  const prototypeStart = owner ? childPrototypes.get(owner)?.get(descriptor.id) : undefined
865
- const templateRoot = prototypeStart?.content.firstElementChild ?? start.content.firstElementChild
866
+ const templateRoot = prototypeStart?.content.firstElementChild ?? listTemplateRoot(start, descriptor)
866
867
  if (!templateRoot) throw new Error("Nested keyed list has no shared row prototype")
867
868
  const prototypes = descriptor.children && new Map(descriptor.children.map(child => [child.id, findChildPrototype(templateRoot, child.id)]))
868
869
  if (prototypes && [...prototypes.values()].some(prototype => !prototype)) throw new Error("Keyed list template has no nested row prototype")
@@ -871,6 +872,13 @@ function mountNestedPrototype(start, descriptor, roots) {
871
872
  return { owner, childPrototypes: prototypes, templateRoot }
872
873
  }
873
874
 
875
+ function listTemplateRoot(start, descriptor) {
876
+ if (!__KUDZU_SVG_LISTS__ || !descriptor.svg) return start.content.firstElementChild
877
+ const range = start.ownerDocument.createRange()
878
+ range.selectNode(start)
879
+ return range.createContextualFragment(start.dataset.kSvgTemplate).firstElementChild
880
+ }
881
+
874
882
  function findChildPrototype(root, id) {
875
883
  for (const element of root.children) {
876
884
  if (element.matches("template[data-k-list]")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.7.20",
3
+ "version": "0.7.22",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",