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