@kudzujs/core 0.7.24 → 0.7.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/RELEASES.md +53 -0
- package/framework/README.md +4 -2
- package/framework/binding-runtime.js +18 -2
- package/framework/build.mjs +117 -10
- package/framework/collection-selector.js +54 -41
- package/framework/core.mjs +1 -0
- package/framework/jsx-runtime.d.ts +23 -2
- package/framework/list-runtime.js +6 -5
- package/framework/shared-runtime.js +10 -0
- 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.26 - React-shaped migration and faster lists.** React-compatible JSX typing, setter-adapter specialization, imported collection normalization, and indexed keyed-row release make ordinary migrations easier while moving the measured 500-card search ahead of React. Read the [release notes](./RELEASES.md#0726---react-shaped-migration-and-faster-lists) or open the [release page](https://kudzujs.cloud/releases/0.7.26).
|
|
14
14
|
|
|
15
15
|
- [Documentation](https://kudzujs.cloud/docs)
|
|
16
16
|
- [Installation guide](https://kudzujs.cloud/docs#install)
|
|
@@ -79,12 +79,13 @@ ordinary React-shaped TSX
|
|
|
79
79
|
|
|
80
80
|
- Function components execute at build time and do not survive as browser components.
|
|
81
81
|
- `useState` and reduced `useReducer`, including directly serializable lazy initialization, compile to synchronous logical state and batched direct DOM writes; top-level `useId` and direct intrinsic `forwardRef` authoring erase to static HTML without a component runtime.
|
|
82
|
-
- Conditions, keyed collections, attributes, events, refs, effects, and supported component boundaries compile to route-specific capabilities.
|
|
82
|
+
- Conditions, keyed collections, attributes, events, refs, effects, and supported component boundaries compile to route-specific capabilities. A direct setter may cross one ordinary component boundary through one value-adapter event call; inline or simple `const` setter callbacks and object refs may cross the same direct intrinsic boundary.
|
|
83
83
|
- Build-known data and routes become complete HTML through async components and `getStaticPaths()`.
|
|
84
84
|
- Native document navigation is the default; static routes do not load a client runtime.
|
|
85
85
|
- A named or aliased React Router `Link` with a static root-relative `to` erases to a base-aware native anchor; no router package or runtime is emitted.
|
|
86
86
|
- A direct named or aliased React Router `useParams()` call on a `runtimeParams` bracket route reuses Kudzu's route-specific pathname reader.
|
|
87
87
|
- Read-only React Router `useSearchParams()` with direct static `get("name")` locals lowers to nullable signals initialized by a minimal route-specific query reader.
|
|
88
|
+
- A named or aliased React Router `useNavigate()` top-level binding lowers direct nested-callback calls with safe static root-relative destinations to native `location.assign()` or `location.replace()` navigation.
|
|
88
89
|
- Unsupported nearby patterns fail during the build with a source location and actionable boundary.
|
|
89
90
|
|
|
90
91
|
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.26 - React-shaped migration and faster lists
|
|
4
|
+
|
|
5
|
+
Kudzu 0.7.26 broadens ordinary React-shaped migration input and removes repeated keyed-row cleanup scans, making the matched 500-card search faster than React without adding a VDOM, hydration, or a retained component tree.
|
|
6
|
+
|
|
7
|
+
### New in 0.7.26
|
|
8
|
+
|
|
9
|
+
- JSX typing accepts React 19 `ReactNode`-shaped component output while preserving Kudzu's compile-time element model.
|
|
10
|
+
- Intrinsic DOM handlers retain contextual event typing through migration-compatible JSX declarations.
|
|
11
|
+
- A direct setter or one-call value-adapter callback may cross one supported component boundary, covering ordinary controlled search inputs without serializing a function.
|
|
12
|
+
- Imported static collections wrapped in TypeScript-only assertions such as `as const` remain analyzable and erase normally.
|
|
13
|
+
- Collection selector execution caches state reads and avoids recursive rest-array, `slice()`, and `map()` allocation in hot expression paths.
|
|
14
|
+
- Safe keyed rows with local state but no row effects, nested lists, or shared text targets release binding and condition registrations directly by state ID instead of rescanning every removed subtree.
|
|
15
|
+
- Rows outside that proven boundary continue through the existing DOM-owned unmount path.
|
|
16
|
+
- A 21-run alternating fresh-profile benchmark measured the 500-card filter at 13.5 ms for Kudzu and 13.9 ms for React, making Kudzu 2.88% faster and 52.30% faster than its preserved 28.3 ms baseline.
|
|
17
|
+
- The same benchmark measured Kudzu's build 11.90% faster, keyed row toggle 7.02% faster, and emitted JavaScript 87.29% smaller by aggregate gzip.
|
|
18
|
+
- The complete suite passes 118/118 tests, including remove-all, unseen-key remount, and fresh row-state coverage for the indexed release path.
|
|
19
|
+
|
|
20
|
+
### Boundary
|
|
21
|
+
|
|
22
|
+
Indexed row release is compiler-selected only when cleanup can be proven from row-owned state IDs and the row has no effects, nested lists, or shared text targets. Effectful, nested, and otherwise lifecycle-bearing rows retain the general ownership cleanup path. Setter adapters remain limited to one supported component boundary and one direct intrinsic handler call. React remains migration input only and is never emitted or executed.
|
|
23
|
+
|
|
24
|
+
### Upgrade
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install @kudzujs/core@^0.7.26
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## 0.7.25 - Router-shaped native navigation
|
|
31
|
+
|
|
32
|
+
Kudzu 0.7.25 accepts a narrow React Router `useNavigate()` source shape and lowers safe static destinations to native full-document navigation without shipping React Router or adding an SPA runtime.
|
|
33
|
+
|
|
34
|
+
### New in 0.7.25
|
|
35
|
+
|
|
36
|
+
- A named or aliased `useNavigate` import may initialize one top-level `const` identifier in a component.
|
|
37
|
+
- Direct calls from nested browser callbacks with one safe static root-relative destination lower to `globalThis.location.assign()`.
|
|
38
|
+
- Exactly `{ replace: true }` lowers to `globalThis.location.replace()`.
|
|
39
|
+
- Configured `base` is applied while query strings and fragments are preserved.
|
|
40
|
+
- The React Router import and navigate binding are erased from emitted component code.
|
|
41
|
+
- Dynamic or relative destinations, render-time calls, passed aliases, optional calls, and other navigation options fail with source diagnostics.
|
|
42
|
+
- Native document navigation remains deliberate even when enhanced navigation is configured.
|
|
43
|
+
- Unaffected destination routes remain complete static documents with zero JavaScript.
|
|
44
|
+
- The complete suite passes 117/117 tests with Chrome coverage for base-aware full-document navigation.
|
|
45
|
+
|
|
46
|
+
### Boundary
|
|
47
|
+
|
|
48
|
+
The supported migration shape is one top-level `const navigate = useNavigate()` and direct calls from nested browser callbacks. Destinations must be safe static root-relative strings. Only the absent options argument and exactly `{ replace: true }` are supported; dynamic values, relative paths, deltas, state, relative routing, scroll options, aliases, and render-time calls remain unsupported. No SPA router is included.
|
|
49
|
+
|
|
50
|
+
### Upgrade
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm install @kudzujs/core@^0.7.25
|
|
54
|
+
```
|
|
55
|
+
|
|
3
56
|
## 0.7.24 - Router-shaped query reads
|
|
4
57
|
|
|
5
58
|
Kudzu 0.7.24 accepts a narrow read-only React Router `useSearchParams()` shape and lowers each static query read to a nullable route signal backed by native `URLSearchParams`, without adding a router runtime.
|
package/framework/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Kudzu specializes ordinary common React-shaped TSX so migrations need minimal source restructuring. Declarative components, collection pipelines, conditions, hooks, and handlers should be lowered at build time rather than replaced with application-owned imperative DOM code. This principle applies across migrations and is not Stay-specific; it does not imply a React package, VDOM, hydration, or ecosystem runtime.
|
|
4
4
|
|
|
5
|
-
Migration source may retain conventional `react` imports for supported named or aliased hooks, direct members such as `React.useState`, same-file `memo`, inline `useCallback`, direct-state expression or analyzable collection-pipeline `useMemo`, direct intrinsic `forwardRef`, top-level `const` identifiers initialized by `useId()`, and default, namespace, or named `Fragment`. `forwardRef()` accepts one inline synchronous `(props, ref)` function and requires the object ref exactly once on its direct intrinsic root; the compiler removes `ref` from props/rest and erases the wrapper. `useId()` becomes a deterministic build-time HTML ID and emits no browser capability; keyed rows reject it because cloned row templates cannot safely duplicate HTML IDs. Collection memos may start from local array state or a named relative import of an exported JSON-safe `const` array and may read direct local state declared in their dependency array. `build.mjs` canonicalizes those forms and rewrites module references to `@kudzujs/core` before build-time evaluation. Memo wrappers are erased or inlined into existing bindings and keyed-list selectors because no browser component rerender or memo cache exists. Static routes remain JavaScript-free and emitted modules are checked for surviving React imports.
|
|
5
|
+
Migration source may retain conventional `react` imports for supported named or aliased hooks, direct members such as `React.useState`, same-file `memo`, inline `useCallback`, direct-state expression or analyzable collection-pipeline `useMemo`, direct intrinsic `forwardRef`, top-level `const` identifiers initialized by `useId()`, and default, namespace, or named `Fragment`. Kudzu's JSX declarations accept ReactNode-shaped component returns and contextually type common intrinsic DOM events, so strict React component props do not need migration-only `unknown` or explicit event annotations. `forwardRef()` accepts one inline synchronous `(props, ref)` function and requires the object ref exactly once on its direct intrinsic root; the compiler removes `ref` from props/rest and erases the wrapper. `useId()` becomes a deterministic build-time HTML ID and emits no browser capability; keyed rows reject it because cloned row templates cannot safely duplicate HTML IDs. Collection memos may start from local array state or a named relative import of an exported JSON-safe `const` array, including type-only `as const` and `satisfies` wrappers, and may read direct local state declared in their dependency array. `build.mjs` canonicalizes those forms and rewrites module references to `@kudzujs/core` before build-time evaluation. Memo wrappers are erased or inlined into existing bindings and keyed-list selectors because no browser component rerender or memo cache exists. Static routes remain JavaScript-free and emitted modules are checked for surviving React imports.
|
|
6
6
|
|
|
7
7
|
A named or aliased `Link` import from `react-router-dom` may render directly with one static root-relative `to` plus native anchor props. The compiler prefixes the configured `base`, changes the element to `<a href>`, and erases the import, so native navigation remains the default and configured navigation groups see an ordinary eligible anchor. Dynamic or relative destinations, `NavLink`, router-only props, spreads, default/namespace imports, and non-JSX uses fail with source diagnostics. No React Router package code or router runtime is emitted.
|
|
8
8
|
|
|
@@ -10,6 +10,8 @@ A named or aliased React Router `useParams` import may also be called directly w
|
|
|
10
10
|
|
|
11
11
|
A named or aliased read-only React Router `useSearchParams` import may initialize one top-level `const [params]` binding. Each top-level `const value = params.get("literal")` lowers to one cached nullable `useSearchParam()` signal. The existing parameter asset initializes those signals with native `URLSearchParams.get()` semantics before route effects mount, including during configured same-document navigation. Missing keys remain `null`, direct text renders blank, and nullable attributes are removed. Setter destructuring, dynamic names, other methods, aliases, wrapped reads, and layout ownership are rejected. Routes without query reads do not emit this branch or a parameter asset.
|
|
12
12
|
|
|
13
|
+
A named or aliased React Router `useNavigate` import may initialize one top-level `const` identifier. A direct call from a nested browser callback with one safe static root-relative string lowers to `location.assign()` after applying `base`; exactly `{ replace: true }` lowers to `location.replace()`. This deliberately performs native document navigation even when enhanced navigation is configured. Dynamic or relative destinations, render-time calls, aliases passed as values, and options such as `state`, `relative`, or `preventScrollReset` are rejected. Routes without an actual navigation handler emit no browser JavaScript.
|
|
14
|
+
|
|
13
15
|
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.
|
|
14
16
|
|
|
15
17
|
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.
|
|
@@ -45,7 +47,7 @@ Rendered collection selectors compile immutable local aliases and inline `(item)
|
|
|
45
47
|
|
|
46
48
|
The reduced `useReducer` form reuses ordinary state slots and React's pure reducer contract. An optional inline, same-file, or relative-imported synchronous one-parameter initializer may derive a directly serializable literal only from its directly serializable initial argument; the compiler substitutes that argument and lowers the call to the ordinary two-argument ownership path. A direct dispatch in a compiled handler becomes a functional `set` whose reducer is bundled from a relative TypeScript module into that handler graph. Pure reducer-owned keyed lists reuse unchanged item identities for reorder, one removal, and append fast paths; ordinary `useState` lists retain full validation. One direct dispatch prop into a same-file or relative-imported synchronous component, including a direct keyed row, is specialized to intrinsic JSX at the call site, so its handler retains the parent reducer scope and no dispatch capture or child handler asset is emitted. A reducer row reads the latest item through the existing list scope and uses the same multiple serializable state, effect, condition, and object-ref specialization as other keyed rows. Relative TypeScript imports referenced inside that child handler receive collision-free call-site aliases and join the parent handler graph. One nested relative-imported intrinsic child may receive an inline or simple `const` callback containing dispatch; the compiler recursively substitutes that callback once and omits the nested child handler asset. Missing directly serializable literal defaults and direct intrinsic rest props in these reducer specializations are substituted at the same call site. Reducer-free routes and shared runtimes are unchanged; no reducer runtime or browser component instance exists.
|
|
47
49
|
|
|
48
|
-
|
|
50
|
+
A direct setter may cross one same-file or relative-imported component boundary when the child invokes it exactly once inside an intrinsic event handler. Inline and simple `const` setter callbacks may use that adapter shape or direct event forwarding. Value adapters such as `event => onValueChange(event.currentTarget.value)` specialize into the parent setter graph instead of serializing a function. A `null`-initialized object ref may cross the same boundary to the direct intrinsic root. Both remain compiler-owned descriptors: conditional removal drops the handler with the element and makes the ref resolve to `null`, while remount creates a fresh element without retaining a component instance.
|
|
49
51
|
|
|
50
52
|
`kudzu.config` may opt one emitted shared-layout group into same-document navigation with legacy `navigation: { routes: ["/product", "/items/[id]"] }`, or multiple groups with `navigation: { groups: [{ routes: [...] }, { routes: [...] }] }`. The forms are mutually exclusive. Identities are globally unique emitted exact paths or `runtimeParams` patterns; each group uses one page-exported layout function identity. Runtime records securely match concrete pathnames under `base`, and their cache-safe parameter initializer runs before route DOM/effects mount on every transition. Each group receives a deterministic route-hashed asset specialized to only its records, pattern decoder, and effect/parameter lifecycle needs. Cross-group and ungrouped anchors remain native and are not prefetched; overlapping path domains across groups fail the build. Route effect entries export cache-safe layout and route mount functions: layout effects, including conditional/keyed DOM-owned effects, persist for the group session; route effects receive a fresh owner registry after each route insertion; and non-persisted page disposal cleans route before layout. Direct primitive state, runtime parameter, and keyed-item property dependencies and cleanup are supported. Fragment payloads and coordinated View Transitions are not implemented.
|
|
51
53
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { browserState, mountDom, registerCommitter, registerMountHook, registerUnmountHook, unmountDom } from "./shared-runtime.js"
|
|
1
|
+
import { browserState, mountDom, registerCommitter, registerMountHook, registerStateReleaseHook, registerUnmountHook, releaseState, unmountDom } from "./shared-runtime.js"
|
|
2
2
|
import { deserialize } from "./serialization.js"
|
|
3
3
|
import { serializeStyle } from "./style.js"
|
|
4
4
|
|
|
@@ -63,6 +63,7 @@ registerMountHook(mountBindings)
|
|
|
63
63
|
registerMountHook(mountConditions)
|
|
64
64
|
registerUnmountHook(unmountBindings)
|
|
65
65
|
registerUnmountHook(unmountConditions)
|
|
66
|
+
registerStateReleaseHook(releaseBindings)
|
|
66
67
|
|
|
67
68
|
if (typeof document !== "undefined") mountDom(document)
|
|
68
69
|
|
|
@@ -195,6 +196,21 @@ function unmountConditions(root) {
|
|
|
195
196
|
}
|
|
196
197
|
}
|
|
197
198
|
|
|
199
|
+
function releaseBindings(id) {
|
|
200
|
+
for (const binding of bindingTargets.get(id) ?? []) {
|
|
201
|
+
for (const [bindingId, entry] of bindingRegistrations.get(binding.node) ?? []) bindingTargets.get(bindingId)?.delete(entry)
|
|
202
|
+
bindingRegistrations.delete(binding.node)
|
|
203
|
+
}
|
|
204
|
+
bindingTargets.delete(id)
|
|
205
|
+
for (const condition of conditionTargets.get(id) ?? []) {
|
|
206
|
+
const registration = conditionRegistrations.get(condition.start)
|
|
207
|
+
for (const [conditionId, entry] of registration?.registrations ?? []) conditionTargets.get(conditionId)?.delete(entry)
|
|
208
|
+
unmountConditionStates(condition, condition.current === "true")
|
|
209
|
+
conditionRegistrations.delete(condition.start)
|
|
210
|
+
}
|
|
211
|
+
conditionTargets.delete(id)
|
|
212
|
+
}
|
|
213
|
+
|
|
198
214
|
function mountConditionStates(condition, truthy, replace) {
|
|
199
215
|
for (const [id, initialValue] of condition.owned?.[truthy ? "true" : "false"] ?? []) {
|
|
200
216
|
if (replace || !browserState.has(id)) browserState.set(id, initialValue !== null && typeof initialValue === "object" ? structuredClone(initialValue) : initialValue)
|
|
@@ -202,7 +218,7 @@ function mountConditionStates(condition, truthy, replace) {
|
|
|
202
218
|
}
|
|
203
219
|
|
|
204
220
|
function unmountConditionStates(condition, truthy) {
|
|
205
|
-
for (const [id] of condition.owned?.[truthy ? "true" : "false"] ?? [])
|
|
221
|
+
for (const [id] of condition.owned?.[truthy ? "true" : "false"] ?? []) releaseState(id)
|
|
206
222
|
}
|
|
207
223
|
|
|
208
224
|
function removeConditionRange(start, end, mount) {
|
package/framework/build.mjs
CHANGED
|
@@ -1788,15 +1788,16 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
|
|
|
1788
1788
|
const links = new Set()
|
|
1789
1789
|
const params = new Set()
|
|
1790
1790
|
const searchHooks = new Set()
|
|
1791
|
+
const navigateHooks = new Set()
|
|
1791
1792
|
for (const statement of sourceFile.statements) {
|
|
1792
1793
|
if ((ts.isExportDeclaration(statement) || ts.isImportDeclaration(statement)) && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "react-router-dom") {
|
|
1793
1794
|
if (ts.isExportDeclaration(statement)) throw sourceNodeError(statement, sourceFile, "React Router exports are not supported; import Link directly where it renders")
|
|
1794
1795
|
const clause = statement.importClause
|
|
1795
1796
|
if (clause?.isTypeOnly) continue
|
|
1796
1797
|
if (!clause) throw sourceNodeError(statement, sourceFile, "Side-effect React Router imports are not supported")
|
|
1797
|
-
if (clause.name) throw sourceNodeError(clause.name, sourceFile, "React Router default imports are not supported; use named Link, useParams, or
|
|
1798
|
+
if (clause.name) throw sourceNodeError(clause.name, sourceFile, "React Router default imports are not supported; use named Link, useParams, useSearchParams, or useNavigate imports")
|
|
1798
1799
|
const bindings = clause.namedBindings
|
|
1799
|
-
if (!bindings || ts.isNamespaceImport(bindings)) throw sourceNodeError(bindings ?? statement, sourceFile, "React Router namespace imports are not supported; use named Link, useParams, or
|
|
1800
|
+
if (!bindings || ts.isNamespaceImport(bindings)) throw sourceNodeError(bindings ?? statement, sourceFile, "React Router namespace imports are not supported; use named Link, useParams, useSearchParams, or useNavigate imports")
|
|
1800
1801
|
for (const entry of bindings.elements) {
|
|
1801
1802
|
if (entry.isTypeOnly) continue
|
|
1802
1803
|
const imported = (entry.propertyName ?? entry.name).text
|
|
@@ -1804,11 +1805,12 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
|
|
|
1804
1805
|
if (imported === "Link") links.add(entry.name.text)
|
|
1805
1806
|
else if (imported === "useParams") params.add(entry.name.text)
|
|
1806
1807
|
else if (imported === "useSearchParams") searchHooks.add(entry.name.text)
|
|
1807
|
-
else
|
|
1808
|
+
else if (imported === "useNavigate") navigateHooks.add(entry.name.text)
|
|
1809
|
+
else throw sourceNodeError(entry, sourceFile, `React Router ${imported} is not supported; only named Link, useParams, useSearchParams, and useNavigate imports can be lowered`)
|
|
1808
1810
|
}
|
|
1809
1811
|
}
|
|
1810
1812
|
}
|
|
1811
|
-
if (!links.size && !params.size && !searchHooks.size) return sourceFile
|
|
1813
|
+
if (!links.size && !params.size && !searchHooks.size && !navigateHooks.size) return sourceFile
|
|
1812
1814
|
|
|
1813
1815
|
let searchHelper = "__kUseSearchParam"
|
|
1814
1816
|
while (sourceFile.text.includes(searchHelper)) searchHelper += "_"
|
|
@@ -1831,7 +1833,7 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
|
|
|
1831
1833
|
ts.forEachChild(node, collectSearchHooks)
|
|
1832
1834
|
}
|
|
1833
1835
|
collectSearchHooks(sourceFile)
|
|
1834
|
-
const
|
|
1836
|
+
const localBindingShadowed = (node, entry) => {
|
|
1835
1837
|
for (let current = node.parent; current && current !== entry.owner; current = current.parent) {
|
|
1836
1838
|
if (isFunctionLike(current) && (current.parameters.some(parameter => bindingNames(parameter.name).includes(entry.name)) || functionVarDeclaresName(current, entry.name))) return true
|
|
1837
1839
|
if (ts.isBlock(current) && current.statements.some(statement => statement !== entry.statement && statementDeclaresName(statement, entry.name))) return true
|
|
@@ -1843,7 +1845,7 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
|
|
|
1843
1845
|
}
|
|
1844
1846
|
for (const entry of searchObjects) {
|
|
1845
1847
|
const collectReads = node => {
|
|
1846
|
-
if (ts.isIdentifier(node) && node.text === entry.name && isReferenceIdentifier(node) && !
|
|
1848
|
+
if (ts.isIdentifier(node) && node.text === entry.name && isReferenceIdentifier(node) && !localBindingShadowed(node, entry)) {
|
|
1847
1849
|
const property = node.parent
|
|
1848
1850
|
const call = property?.parent
|
|
1849
1851
|
const declaration = call?.parent
|
|
@@ -1862,6 +1864,55 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
|
|
|
1862
1864
|
collectReads(entry.owner.body)
|
|
1863
1865
|
}
|
|
1864
1866
|
|
|
1867
|
+
const navigateDeclarations = new Set()
|
|
1868
|
+
const navigateCalls = new Map()
|
|
1869
|
+
const navigateFunctions = []
|
|
1870
|
+
const collectNavigateHooks = node => {
|
|
1871
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && navigateHooks.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) {
|
|
1872
|
+
const declaration = node.parent
|
|
1873
|
+
const statement = declaration?.parent?.parent
|
|
1874
|
+
const owner = nearestFunction(node)
|
|
1875
|
+
if (node.questionDotToken || node.arguments.length || node.typeArguments?.length || !ts.isVariableDeclaration(declaration) || declaration.initializer !== node || !ts.isIdentifier(declaration.name) || !isLocalConst(declaration) || !owner || statement?.parent !== owner.body) {
|
|
1876
|
+
throw sourceNodeError(node, sourceFile, "React Router useNavigate must initialize one top-level const identifier in a component")
|
|
1877
|
+
}
|
|
1878
|
+
const entry = { name: declaration.name.text, declaration, statement, owner }
|
|
1879
|
+
navigateDeclarations.add(declaration)
|
|
1880
|
+
navigateFunctions.push(entry)
|
|
1881
|
+
}
|
|
1882
|
+
ts.forEachChild(node, collectNavigateHooks)
|
|
1883
|
+
}
|
|
1884
|
+
collectNavigateHooks(sourceFile)
|
|
1885
|
+
for (const entry of navigateFunctions) {
|
|
1886
|
+
const collectCalls = node => {
|
|
1887
|
+
if (ts.isIdentifier(node) && node.text === entry.name && isReferenceIdentifier(node) && !localBindingShadowed(node, entry)) {
|
|
1888
|
+
const call = node.parent
|
|
1889
|
+
if (!ts.isCallExpression(call) || call.expression !== node || call.questionDotToken || call.typeArguments?.length || nearestFunction(call) === entry.owner) {
|
|
1890
|
+
throw sourceNodeError(node, sourceFile, "React Router navigate bindings may only be called directly from a nested browser callback")
|
|
1891
|
+
}
|
|
1892
|
+
if (call.arguments.length < 1 || call.arguments.length > 2 || !ts.isStringLiteral(call.arguments[0])) {
|
|
1893
|
+
throw sourceNodeError(call, sourceFile, 'React Router useNavigate requires a static root-relative navigate("/path") destination')
|
|
1894
|
+
}
|
|
1895
|
+
const destination = call.arguments[0].text
|
|
1896
|
+
const pathname = destination.match(/^[^?#]*/)[0]
|
|
1897
|
+
let decoded
|
|
1898
|
+
try { decoded = decodeURIComponent(pathname) } catch { throw sourceNodeError(call.arguments[0], sourceFile, 'React Router useNavigate requires a safe static root-relative navigate("/path") destination') }
|
|
1899
|
+
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(call.arguments[0], sourceFile, 'React Router useNavigate requires a safe static root-relative navigate("/path") destination')
|
|
1900
|
+
let method = "assign"
|
|
1901
|
+
if (call.arguments.length === 2) {
|
|
1902
|
+
const options = unwrapExpression(call.arguments[1])
|
|
1903
|
+
const property = ts.isObjectLiteralExpression(options) && options.properties.length === 1 ? options.properties[0] : undefined
|
|
1904
|
+
const name = property && ts.isPropertyAssignment(property) && !ts.isComputedPropertyName(property.name) && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) ? property.name.text : undefined
|
|
1905
|
+
if (name !== "replace" || property.initializer.kind !== ts.SyntaxKind.TrueKeyword) throw sourceNodeError(call.arguments[1], sourceFile, 'React Router useNavigate only supports exactly { replace: true } as a second argument')
|
|
1906
|
+
method = "replace"
|
|
1907
|
+
}
|
|
1908
|
+
navigateCalls.set(call, { method, destination: withBase(base, destination) })
|
|
1909
|
+
return
|
|
1910
|
+
}
|
|
1911
|
+
ts.forEachChild(node, collectCalls)
|
|
1912
|
+
}
|
|
1913
|
+
collectCalls(entry.owner.body)
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1865
1916
|
const routerProps = new Set(["discover", "end", "prefetch", "preventScrollReset", "relative", "reloadDocument", "replace", "state", "viewTransition"])
|
|
1866
1917
|
const attributes = attributesNode => {
|
|
1867
1918
|
const output = []
|
|
@@ -1889,12 +1940,16 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
|
|
|
1889
1940
|
}
|
|
1890
1941
|
const importedLink = tag => ts.isIdentifier(tag) && links.has(tag.text) && !isShadowedIdentifier(tag, sourceFile)
|
|
1891
1942
|
const visitor = node => {
|
|
1892
|
-
if (ts.isVariableStatement(node) && node.declarationList.declarations.some(declaration => searchDeclarations.has(declaration))) {
|
|
1893
|
-
const declarations = node.declarationList.declarations.filter(declaration => !searchDeclarations.has(declaration))
|
|
1943
|
+
if (ts.isVariableStatement(node) && node.declarationList.declarations.some(declaration => searchDeclarations.has(declaration) || navigateDeclarations.has(declaration))) {
|
|
1944
|
+
const declarations = node.declarationList.declarations.filter(declaration => !searchDeclarations.has(declaration) && !navigateDeclarations.has(declaration))
|
|
1894
1945
|
if (!declarations.length) return undefined
|
|
1895
1946
|
return factory.updateVariableStatement(node, node.modifiers, factory.updateVariableDeclarationList(node.declarationList, declarations.map(declaration => ts.visitEachChild(declaration, visitor, context))))
|
|
1896
1947
|
}
|
|
1897
1948
|
if (ts.isCallExpression(node) && searchReads.has(node)) return factory.createCallExpression(factory.createIdentifier(searchHelper), undefined, [searchReads.get(node)])
|
|
1949
|
+
if (ts.isCallExpression(node) && navigateCalls.has(node)) {
|
|
1950
|
+
const { method, destination } = navigateCalls.get(node)
|
|
1951
|
+
return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createPropertyAccessExpression(factory.createIdentifier("globalThis"), "location"), method), undefined, [factory.createStringLiteral(destination)])
|
|
1952
|
+
}
|
|
1898
1953
|
if (ts.isJsxElement(node) && importedLink(node.openingElement.tagName)) {
|
|
1899
1954
|
const opening = factory.updateJsxOpeningElement(node.openingElement, factory.createIdentifier("a"), node.openingElement.typeArguments, attributes(node.openingElement.attributes))
|
|
1900
1955
|
const closing = factory.updateJsxClosingElement(node.closingElement, factory.createIdentifier("a"))
|
|
@@ -1908,12 +1963,13 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
|
|
|
1908
1963
|
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")
|
|
1909
1964
|
if (ts.isIdentifier(node) && params.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useParams imports may only be called directly")
|
|
1910
1965
|
if (ts.isIdentifier(node) && searchHooks.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useSearchParams imports may only be used by the supported read-only pattern")
|
|
1966
|
+
if (ts.isIdentifier(node) && navigateHooks.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useNavigate imports may only initialize the supported top-level navigate binding")
|
|
1911
1967
|
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "react-router-dom") {
|
|
1912
1968
|
const clause = node.importClause
|
|
1913
1969
|
if (!clause || clause.isTypeOnly) return node
|
|
1914
1970
|
const bindings = clause.namedBindings
|
|
1915
1971
|
if (!bindings || !ts.isNamedImports(bindings)) return node
|
|
1916
|
-
const elements = bindings.elements.filter(entry => entry.isTypeOnly || !["Link", "useParams", "useSearchParams"].includes((entry.propertyName ?? entry.name).text))
|
|
1972
|
+
const elements = bindings.elements.filter(entry => entry.isTypeOnly || !["Link", "useParams", "useSearchParams", "useNavigate"].includes((entry.propertyName ?? entry.name).text))
|
|
1917
1973
|
if (!elements.length) return undefined
|
|
1918
1974
|
return factory.updateImportDeclaration(node, node.modifiers, factory.updateImportClause(clause, clause.isTypeOnly, undefined, factory.updateNamedImports(bindings, elements)), node.moduleSpecifier, node.attributes)
|
|
1919
1975
|
}
|
|
@@ -2786,6 +2842,45 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
2786
2842
|
stateBackedComponentRoots.push(specialization.root)
|
|
2787
2843
|
}
|
|
2788
2844
|
}
|
|
2845
|
+
const specializeSetterCallbacks = (call, component, callbackProps, imported) => {
|
|
2846
|
+
if (componentSpecializations.has(call)) fail(call, "Setter callback props cannot be combined with another component specialization")
|
|
2847
|
+
if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) fail(component, "Setter-callback components must use one destructured props parameter")
|
|
2848
|
+
for (const prop of callbackProps) {
|
|
2849
|
+
const element = component.parameters[0].name.elements.find(entry => !entry.dotDotDotToken && (entry.propertyName ?? entry.name).getText() === prop)
|
|
2850
|
+
if (!element || !ts.isIdentifier(element.name)) fail(call, `Setter-callback component must destructure callback prop ${JSON.stringify(prop)}`)
|
|
2851
|
+
const references = []
|
|
2852
|
+
const collectReferences = node => {
|
|
2853
|
+
if (ts.isIdentifier(node) && node.text === element.name.text && isReferenceIdentifier(node)) references.push(node)
|
|
2854
|
+
ts.forEachChild(node, collectReferences)
|
|
2855
|
+
}
|
|
2856
|
+
collectReferences(component.body)
|
|
2857
|
+
if (references.length !== 1) fail(element, `Setter-callback prop ${JSON.stringify(prop)} must be used exactly once in the component`)
|
|
2858
|
+
}
|
|
2859
|
+
const specialization = specializeComponentCall(call, component, sourceFile, factory, context, fail, "Setter-callback")
|
|
2860
|
+
if (specialization.effects.length || specialization.hookDeclarations.length) fail(call, "Setter-callback components cannot declare hooks")
|
|
2861
|
+
if (imported) synthesizeTree(specialization.root = mergeSpecializedImports(specialization.root, component.getSourceFile(), call, specialization.effects))
|
|
2862
|
+
componentSpecializations.set(call, specialization)
|
|
2863
|
+
}
|
|
2864
|
+
for (const [name, component] of components) {
|
|
2865
|
+
for (const call of jsxTagUses(sourceFile, name)) {
|
|
2866
|
+
const callbackProps = jsxSetterCallbackProps(call, settersByFunction.get(nearestFunction(call)) ?? new Map(), functions, reducersForNode(call, reducersByFunction))
|
|
2867
|
+
if (callbackProps.length) specializeSetterCallbacks(call, component.function, callbackProps, false)
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
for (const [name, binding] of importBindings) {
|
|
2871
|
+
if (binding.kind === "namespace") continue
|
|
2872
|
+
const calls = jsxTagUses(sourceFile, name)
|
|
2873
|
+
const callbackCalls = calls.map(call => ({ call, callbackProps: jsxSetterCallbackProps(call, settersByFunction.get(nearestFunction(call)) ?? new Map(), functions, reducersForNode(call, reducersByFunction)) })).filter(entry => entry.callbackProps.length)
|
|
2874
|
+
if (!callbackCalls.length) continue
|
|
2875
|
+
const imported = binding.kind === "default" ? "default" : binding.imported
|
|
2876
|
+
let component
|
|
2877
|
+
try {
|
|
2878
|
+
component = resolveComponentExport(binding.target, imported, importedSource, sourceFiles)
|
|
2879
|
+
} catch {
|
|
2880
|
+
fail(callbackCalls[0].call, "Setter callback props require a component imported from a relative TypeScript module")
|
|
2881
|
+
}
|
|
2882
|
+
for (const { call, callbackProps } of callbackCalls) specializeSetterCallbacks(call, component, callbackProps, true)
|
|
2883
|
+
}
|
|
2789
2884
|
for (const [name, component] of components) {
|
|
2790
2885
|
const calls = jsxTagUses(sourceFile, name)
|
|
2791
2886
|
const dispatchCalls = calls.filter(call => jsxCallHasDirectReducerProp(call, reducersForNode(call, reducersByFunction)))
|
|
@@ -3663,6 +3758,17 @@ function jsxCallHasDirectStateProp(call, setters) {
|
|
|
3663
3758
|
})
|
|
3664
3759
|
}
|
|
3665
3760
|
|
|
3761
|
+
function jsxSetterCallbackProps(call, setters, functions, reducers) {
|
|
3762
|
+
const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
|
|
3763
|
+
return attributes.properties.flatMap(attribute => {
|
|
3764
|
+
if (!ts.isJsxAttribute(attribute) || !/^on[A-Z]/.test(attribute.name.text) || !attribute.initializer || !ts.isJsxExpression(attribute.initializer) || !attribute.initializer.expression) return []
|
|
3765
|
+
const value = unwrapExpression(attribute.initializer.expression)
|
|
3766
|
+
if (ts.isIdentifier(value) && setters.has(value.text)) return [attribute.name.text]
|
|
3767
|
+
const callback = ts.isArrowFunction(value) || ts.isFunctionExpression(value) ? value : ts.isIdentifier(value) ? functions.get(value.text) : undefined
|
|
3768
|
+
return callback && !nativeCaptureNames(callback, setters).size && !referencedReducerDispatches(callback.body, reducers, callback).size && referencedStateNames(callback.body, setters, callback).size ? [attribute.name.text] : []
|
|
3769
|
+
})
|
|
3770
|
+
}
|
|
3771
|
+
|
|
3666
3772
|
function jsxCallHasDirectReducerProp(call, reducers) {
|
|
3667
3773
|
const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
|
|
3668
3774
|
return attributes.properties.some(attribute => {
|
|
@@ -4348,7 +4454,7 @@ function identifierReferences(root, name) {
|
|
|
4348
4454
|
}
|
|
4349
4455
|
|
|
4350
4456
|
function unwrapExpression(node) {
|
|
4351
|
-
return ts.isParenthesizedExpression(node) ? unwrapExpression(node.expression) : node
|
|
4457
|
+
return ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isNonNullExpression(node) || ts.isSatisfiesExpression(node) ? unwrapExpression(node.expression) : node
|
|
4352
4458
|
}
|
|
4353
4459
|
|
|
4354
4460
|
function isLocalConst(node) {
|
|
@@ -4656,6 +4762,7 @@ function isReferenceIdentifier(node) {
|
|
|
4656
4762
|
(ts.isVariableDeclaration(parent) && parent.name === node) ||
|
|
4657
4763
|
(ts.isParameter(parent) && parent.name === node) ||
|
|
4658
4764
|
(ts.isFunctionDeclaration(parent) && parent.name === node) ||
|
|
4765
|
+
(ts.isJsxAttribute(parent) && parent.name === node) ||
|
|
4659
4766
|
(ts.isBindingElement(parent) && (parent.name === node || parent.propertyName === node)) ||
|
|
4660
4767
|
ts.isImportSpecifier(parent) || ts.isImportClause(parent)) return false
|
|
4661
4768
|
return true
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
export function selectCollection(anchor, selector = [], readState) {
|
|
2
2
|
let values = anchor == null ? [] : anchor
|
|
3
|
+
const state = readState ? new Map() : undefined
|
|
4
|
+
const read = state ? name => {
|
|
5
|
+
if (!state.has(name)) state.set(name, readState(name))
|
|
6
|
+
return state.get(name)
|
|
7
|
+
} : undefined
|
|
3
8
|
for (const operation of selector) {
|
|
4
|
-
if (operation[0] === "from") values = Array.from(values, operation[1] ? (item, index) => evaluateCollectionExpression(operation[1], item, index,
|
|
9
|
+
if (operation[0] === "from") values = Array.from(values, operation[1] ? (item, index) => evaluateCollectionExpression(operation[1], item, index, read) : undefined)
|
|
5
10
|
else {
|
|
6
11
|
if (!Array.isArray(values)) throw new Error("Rendered collection source must remain an array")
|
|
7
|
-
if (operation[0] === "filter") values = values.filter((item, index) => evaluateCollectionExpression(operation[1], item, index,
|
|
12
|
+
if (operation[0] === "filter") values = values.filter((item, index) => evaluateCollectionExpression(operation[1], item, index, read))
|
|
8
13
|
else if (operation[0] === "flatMap") values = values.flatMap(item => item?.[operation[1]] ?? [])
|
|
9
|
-
else if (operation[0] === "slice") values = values.slice(evaluateCollectionExpression(operation[1], undefined, undefined,
|
|
10
|
-
else if (operation[0] === "sort") values = [...values].sort((left, right) => evaluateCollectionExpression(operation[1], left, right,
|
|
14
|
+
else if (operation[0] === "slice") values = values.slice(evaluateCollectionExpression(operation[1], undefined, undefined, read), operation[2] && evaluateCollectionExpression(operation[2], undefined, undefined, read))
|
|
15
|
+
else if (operation[0] === "sort") values = [...values].sort((left, right) => evaluateCollectionExpression(operation[1], left, right, read))
|
|
11
16
|
}
|
|
12
17
|
}
|
|
13
18
|
if (!Array.isArray(values)) throw new Error("Rendered collection source must remain an array")
|
|
@@ -15,55 +20,63 @@ export function selectCollection(anchor, selector = [], readState) {
|
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
export function evaluateCollectionExpression(expression, item, index, readState) {
|
|
18
|
-
const
|
|
19
|
-
if (kind === "value") return
|
|
23
|
+
const kind = expression[0]
|
|
24
|
+
if (kind === "value") return expression[1]
|
|
20
25
|
if (kind === "undefined") return undefined
|
|
21
26
|
if (kind === "item") return item
|
|
22
27
|
if (kind === "index") return index
|
|
23
28
|
if (kind === "state") {
|
|
24
|
-
if (!readState) throw new Error(`Rendered collection state ${JSON.stringify(
|
|
25
|
-
return readState(
|
|
29
|
+
if (!readState) throw new Error(`Rendered collection state ${JSON.stringify(expression[1])} is not available`)
|
|
30
|
+
return readState(expression[1])
|
|
26
31
|
}
|
|
27
32
|
if (kind === "get") {
|
|
28
|
-
const object = evaluateCollectionExpression(
|
|
29
|
-
return object == null &&
|
|
33
|
+
const object = evaluateCollectionExpression(expression[1], item, index, readState)
|
|
34
|
+
return object == null && expression[3] ? undefined : object[expression[2]]
|
|
30
35
|
}
|
|
31
36
|
if (kind === "unary") {
|
|
32
|
-
const value = evaluateCollectionExpression(
|
|
33
|
-
if (
|
|
34
|
-
if (
|
|
35
|
-
if (
|
|
36
|
-
if (
|
|
37
|
+
const value = evaluateCollectionExpression(expression[2], item, index, readState)
|
|
38
|
+
if (expression[1] === "!") return !value
|
|
39
|
+
if (expression[1] === "+") return +value
|
|
40
|
+
if (expression[1] === "-") return -value
|
|
41
|
+
if (expression[1] === "typeof") return typeof value
|
|
37
42
|
}
|
|
38
43
|
if (kind === "binary") {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
if (
|
|
47
|
-
if (
|
|
48
|
-
if (
|
|
49
|
-
if (
|
|
50
|
-
if (
|
|
51
|
-
if (
|
|
52
|
-
if (
|
|
53
|
-
if (
|
|
54
|
-
if (
|
|
55
|
-
if (
|
|
56
|
-
if (
|
|
44
|
+
const operator = expression[1]
|
|
45
|
+
const left = evaluateCollectionExpression(expression[2], item, index, readState)
|
|
46
|
+
if (operator === "&&") return left && evaluateCollectionExpression(expression[3], item, index, readState)
|
|
47
|
+
if (operator === "||") return left || evaluateCollectionExpression(expression[3], item, index, readState)
|
|
48
|
+
if (operator === "??") return left ?? evaluateCollectionExpression(expression[3], item, index, readState)
|
|
49
|
+
const right = evaluateCollectionExpression(expression[3], item, index, readState)
|
|
50
|
+
if (operator === "===") return left === right
|
|
51
|
+
if (operator === "!==") return left !== right
|
|
52
|
+
if (operator === "==") return left == right
|
|
53
|
+
if (operator === "!=") return left != right
|
|
54
|
+
if (operator === "<") return left < right
|
|
55
|
+
if (operator === "<=") return left <= right
|
|
56
|
+
if (operator === ">") return left > right
|
|
57
|
+
if (operator === ">=") return left >= right
|
|
58
|
+
if (operator === "+") return left + right
|
|
59
|
+
if (operator === "-") return left - right
|
|
60
|
+
if (operator === "*") return left * right
|
|
61
|
+
if (operator === "/") return left / right
|
|
62
|
+
if (operator === "%") return left % right
|
|
57
63
|
}
|
|
58
|
-
if (kind === "conditional") return evaluateCollectionExpression(
|
|
59
|
-
if (kind === "array") return
|
|
60
|
-
if (kind === "object") return Object.fromEntries(
|
|
61
|
-
if (kind === "template") return
|
|
64
|
+
if (kind === "conditional") return evaluateCollectionExpression(expression[1], item, index, readState) ? evaluateCollectionExpression(expression[2], item, index, readState) : evaluateCollectionExpression(expression[3], item, index, readState)
|
|
65
|
+
if (kind === "array") return expression.slice(1).map(value => evaluateCollectionExpression(value, item, index, readState))
|
|
66
|
+
if (kind === "object") return Object.fromEntries(expression.slice(1).map(([key, value]) => [key, evaluateCollectionExpression(value, item, index, readState)]))
|
|
67
|
+
if (kind === "template") return expression[1].map((text, offset) => text + (offset < expression[2].length ? evaluateCollectionExpression(expression[2][offset], item, index, readState) : "")).join("")
|
|
62
68
|
if (kind === "call") {
|
|
63
|
-
const receiver = evaluateCollectionExpression(
|
|
64
|
-
return receiver[
|
|
69
|
+
const receiver = evaluateCollectionExpression(expression[1], item, index, readState)
|
|
70
|
+
if (expression.length === 3) return receiver[expression[2]]()
|
|
71
|
+
if (expression.length === 4) return receiver[expression[2]](evaluateCollectionExpression(expression[3], item, index, readState))
|
|
72
|
+
const arguments_ = new Array(expression.length - 3)
|
|
73
|
+
for (let offset = 3; offset < expression.length; offset++) arguments_[offset - 3] = evaluateCollectionExpression(expression[offset], item, index, readState)
|
|
74
|
+
return receiver[expression[2]](...arguments_)
|
|
75
|
+
}
|
|
76
|
+
if (kind === "global" || kind === "math") {
|
|
77
|
+
const arguments_ = new Array(expression.length - 2)
|
|
78
|
+
for (let offset = 2; offset < expression.length; offset++) arguments_[offset - 2] = evaluateCollectionExpression(expression[offset], item, index, readState)
|
|
79
|
+
return (kind === "global" ? globalThis : Math)[expression[1]](...arguments_)
|
|
65
80
|
}
|
|
66
|
-
if (kind === "global") return globalThis[parts[0]](...parts.slice(1).map(value => evaluateCollectionExpression(value, item, index, readState)))
|
|
67
|
-
if (kind === "math") return Math[parts[0]](...parts.slice(1).map(value => evaluateCollectionExpression(value, item, index, readState)))
|
|
68
81
|
throw new Error(`Unsupported rendered collection expression: ${String(kind)}`)
|
|
69
82
|
}
|
package/framework/core.mjs
CHANGED
|
@@ -908,6 +908,7 @@ async function renderList(node, namespace, selectValue) {
|
|
|
908
908
|
descriptor.rowRefs = renderContext.listRowRefs.map(({ id }) => id)
|
|
909
909
|
descriptor.mount = true
|
|
910
910
|
}
|
|
911
|
+
if (descriptor.rowStates && !descriptor.effects && !descriptor.nested && !template.includes("data-k-text")) descriptor.fastRelease = true
|
|
911
912
|
const seed = node.ownerField || node.selector.length || node.keyField === null ? undefined : listSeed(node.values, renderContext.listFields)
|
|
912
913
|
if (seed) descriptor.seed = seed
|
|
913
914
|
let current = ""
|
|
@@ -1,12 +1,33 @@
|
|
|
1
1
|
export namespace JSX {
|
|
2
|
-
type Element =
|
|
2
|
+
type Element = any
|
|
3
3
|
type Children = unknown
|
|
4
4
|
|
|
5
|
+
type TargetedEvent<Target extends EventTarget, NativeEvent extends Event = Event> = NativeEvent & {
|
|
6
|
+
currentTarget: Target
|
|
7
|
+
target: EventTarget & Target
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type IntrinsicProps<Target extends EventTarget> = Record<string, unknown> & {
|
|
11
|
+
children?: Children
|
|
12
|
+
onBlur?: (event: TargetedEvent<Target, FocusEvent>) => unknown
|
|
13
|
+
onChange?: (event: TargetedEvent<Target>) => unknown
|
|
14
|
+
onClick?: (event: TargetedEvent<Target, MouseEvent>) => unknown
|
|
15
|
+
onFocus?: (event: TargetedEvent<Target, FocusEvent>) => unknown
|
|
16
|
+
onInput?: (event: TargetedEvent<Target, InputEvent>) => unknown
|
|
17
|
+
onKeyDown?: (event: TargetedEvent<Target, KeyboardEvent>) => unknown
|
|
18
|
+
onKeyUp?: (event: TargetedEvent<Target, KeyboardEvent>) => unknown
|
|
19
|
+
onSubmit?: (event: TargetedEvent<Target, SubmitEvent>) => unknown
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
interface IntrinsicAttributes {
|
|
6
23
|
key?: string | number
|
|
7
24
|
}
|
|
8
25
|
|
|
9
|
-
|
|
26
|
+
type IntrinsicElements = {
|
|
27
|
+
[Name in keyof HTMLElementTagNameMap]: IntrinsicProps<HTMLElementTagNameMap[Name]>
|
|
28
|
+
} & {
|
|
29
|
+
[Name in keyof SVGElementTagNameMap]: IntrinsicProps<SVGElementTagNameMap[Name]>
|
|
30
|
+
} & {
|
|
10
31
|
[elementName: string]: Record<string, unknown>
|
|
11
32
|
}
|
|
12
33
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { browserState, mountDom, notifyListItem, registerCommitter, registerMountHook, registerUnmountHook, unmountDom } from "./shared-runtime.js"
|
|
1
|
+
import { browserState, mountDom, notifyListItem, registerCommitter, registerMountHook, registerUnmountHook, releaseState, unmountDom } from "./shared-runtime.js"
|
|
2
2
|
import { selectCollection } from "./collection-selector.js"
|
|
3
3
|
|
|
4
4
|
const listTargets = new Map()
|
|
@@ -219,7 +219,8 @@ function updateList(list) {
|
|
|
219
219
|
}
|
|
220
220
|
for (const [token, node] of list.roots) {
|
|
221
221
|
if (keys.has(token)) continue
|
|
222
|
-
if (
|
|
222
|
+
if (list.descriptor.fastRelease) node.remove()
|
|
223
|
+
else if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) {
|
|
223
224
|
unmountDom(node)
|
|
224
225
|
node.remove()
|
|
225
226
|
} else node.remove()
|
|
@@ -565,7 +566,7 @@ function addListRoot(list, { item, index = list.roots.size, key, token, value })
|
|
|
565
566
|
|
|
566
567
|
function removeListRoot(list, token) {
|
|
567
568
|
const node = list.roots.get(token)
|
|
568
|
-
if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) unmountDom(node)
|
|
569
|
+
if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount && !list.descriptor.fastRelease) unmountDom(node)
|
|
569
570
|
node.remove()
|
|
570
571
|
if (__KUDZU_LIST_ROW_HOOKS__ && list.descriptor.rowStates) deleteRowStates(list.descriptor, ownershipPaths.get(node))
|
|
571
572
|
list.roots.delete(token)
|
|
@@ -1035,7 +1036,7 @@ function initializeRowStates(descriptor, key, root) {
|
|
|
1035
1036
|
}
|
|
1036
1037
|
|
|
1037
1038
|
function deleteFlatRowStates(descriptor, token) {
|
|
1038
|
-
for (const state of descriptor.rowStates)
|
|
1039
|
+
for (const state of descriptor.rowStates) releaseState(flatRowStateId(state.id, token))
|
|
1039
1040
|
}
|
|
1040
1041
|
|
|
1041
1042
|
function flatRowStateId(id, token) {
|
|
@@ -1044,7 +1045,7 @@ function flatRowStateId(id, token) {
|
|
|
1044
1045
|
|
|
1045
1046
|
function deleteRowStates(descriptor, path) {
|
|
1046
1047
|
const statePath = descriptor.ownerField ? path : [path.at(-1).slice(path.at(-1).indexOf("=") + 1)]
|
|
1047
|
-
for (const state of descriptor.rowStates)
|
|
1048
|
+
for (const state of descriptor.rowStates) releaseState(rowStateId(state.id, statePath))
|
|
1048
1049
|
}
|
|
1049
1050
|
function rowStateId(id, path) {
|
|
1050
1051
|
return id.replace("$k", encodeURIComponent(path.join("/")))
|
|
@@ -26,6 +26,7 @@ export const browserState = new Map()
|
|
|
26
26
|
const committers = []
|
|
27
27
|
const mountHooks = []
|
|
28
28
|
const unmountHooks = []
|
|
29
|
+
const stateReleaseHooks = []
|
|
29
30
|
const textTargets = new Map()
|
|
30
31
|
const mountedText = new WeakSet()
|
|
31
32
|
|
|
@@ -59,6 +60,15 @@ export function registerUnmountHook(unmount) {
|
|
|
59
60
|
unmountHooks.push(unmount)
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
export function registerStateReleaseHook(release) {
|
|
64
|
+
stateReleaseHooks.push(release)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function releaseState(id) {
|
|
68
|
+
for (const release of stateReleaseHooks) release(id)
|
|
69
|
+
browserState.delete(id)
|
|
70
|
+
}
|
|
71
|
+
|
|
62
72
|
export function commitDom(id, value) {
|
|
63
73
|
for (const node of textTargets.get(id) ?? []) {
|
|
64
74
|
if (node.isConnected) node.textContent = value
|