@kudzujs/core 0.8.61 → 0.8.62
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/MIGRATION_ROADMAP.md +2 -1
- package/README.md +1 -1
- package/RELEASES.md +29 -0
- package/docs/next-architecture/README.md +1 -1
- package/docs/next-architecture/compiler-current-architecture.md +1 -1
- package/docs/next-architecture/large-application-ai-native-roadmap.md +2 -2
- package/docs/next-architecture/versioning.md +2 -1
- package/framework/README.md +1 -1
- package/framework/compiler/outside-click-pass.mjs +79 -0
- package/framework/compiler/react-migration-pass.mjs +27 -1
- package/framework/compiler/source-compiler.mjs +52 -0
- package/package.json +1 -1
package/MIGRATION_ROADMAP.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This document is the source of truth for Kudzu's product direction, architecture invariants, and future development order. Read it before extending React-shaped syntax or browser capabilities.
|
|
4
4
|
|
|
5
|
-
The executable post-`0.8.
|
|
5
|
+
The executable post-`0.8.62` compiler and large-application sequence is maintained in [`docs/next-architecture/large-application-ai-native-roadmap.md`](./docs/next-architecture/large-application-ai-native-roadmap.md). Follow its PR dependencies for implementation work; this document remains authoritative when selecting or accepting a migration capability.
|
|
6
6
|
|
|
7
7
|
[`GOAL_A.md`](./GOAL_A.md) and [`GOAL_B.md`](./GOAL_B.md) are completed capability-validation records. Their commerce and realtime dashboard fixtures prove general lifecycle, navigation, async-workflow, and Worker capabilities; they are not separate product verticals or future priority lists.
|
|
8
8
|
|
|
@@ -97,6 +97,7 @@ This queue orders the next investigations by general migration value. Start only
|
|
|
97
97
|
|
|
98
98
|
- Relative zero-argument custom hooks expose direct shorthand state/setter pairs and state-capturing callbacks to one top-level caller destructuring.
|
|
99
99
|
- In `0.8.61`, a ClimateCompatibleGrowth-shaped relative `useDebounce(value, delay)` hook may accept one direct primitive state and numeric literal delay, initialize one returned state from the source build value, and own one exact timeout dependency effect with cleanup. Dynamic delays, non-primitive state, aliases, additional effects, intervals, and missing cleanup remain diagnosed.
|
|
100
|
+
- In `0.8.62`, a ClimateCompatibleGrowth dropdown may retain one direct `createRef()` DOM ref and pass it with an inline direct literal setter callback to its relative outside-click hook. The exact document `mousedown` listener and matching cleanup reuse existing ref serialization, setter capture, and conditional effect ownership. Aliases, dynamic event names, additional listeners, and mismatched cleanup remain diagnosed.
|
|
100
101
|
- Writable React Router search parameters use native inline `URLSearchParams` updaters, push/replace history, signal recommits, and `popstate` synchronization.
|
|
101
102
|
- Guarded `localStorage` restoration and persistence compose through existing mount/dependency effects, preserving deterministic static fallbacks without a storage runtime.
|
|
102
103
|
- Reachable source compilation excludes unused React/Vite modules while preserving source diagnostics for every reachable module.
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Kudzu compiles ordinary React-shaped TypeScript and TSX into complete static HTM
|
|
|
14
14
|
|
|
15
15
|
> Experimental `0.8.x`: the compiler API and supported TSX surface may change.
|
|
16
16
|
|
|
17
|
-
**Latest release: 0.8.
|
|
17
|
+
**Latest release: 0.8.62 - Outside-click hook ownership.** React `createRef()` and one parameterized relative outside-click hook compile through existing DOM-ref, effect capture, and cleanup ownership without a listener runtime. Read the [release notes](./RELEASES.md#0862---outside-click-hook-ownership), open the [release page](https://github.com/kudzujs/kudzu/releases/tag/v0.8.62), or follow the [architecture packet](./docs/next-architecture/README.md).
|
|
18
18
|
|
|
19
19
|
- [Documentation](https://kudzujs.cloud/docs)
|
|
20
20
|
- [Installation guide](https://kudzujs.cloud/docs#install)
|
package/RELEASES.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Kudzu Releases
|
|
2
2
|
|
|
3
|
+
## 0.8.62 - Outside-click hook ownership
|
|
4
|
+
|
|
5
|
+
Kudzu 0.8.62 compiles the ClimateCompatibleGrowth `createRef()` and parameterized outside-click custom-hook shape through existing DOM-ref, effect-capture, and cleanup ownership.
|
|
6
|
+
|
|
7
|
+
### Changed in 0.8.62
|
|
8
|
+
|
|
9
|
+
- A direct component `const ref = createRef<T>()` React migration input lowers to Kudzu's existing `useRef(null)` DOM ownership.
|
|
10
|
+
- A relative outside-click hook may accept that direct ref and one inline direct literal setter callback.
|
|
11
|
+
- The compiler specializes the callback into a serializable setter plus literal and retains only the ref, setter, and literal in existing effect scope.
|
|
12
|
+
|
|
13
|
+
### Boundaries
|
|
14
|
+
|
|
15
|
+
- The hook must own one static `document` `mousedown` listener, test `ref.current.contains(event.target)`, and remove the same listener in cleanup.
|
|
16
|
+
- Ref aliases, callback aliases or captures, dynamic event names, multiple refs/listeners, and mismatched cleanup remain rejected.
|
|
17
|
+
- No outside-click, listener, ref, or callback runtime is added.
|
|
18
|
+
|
|
19
|
+
### Validation
|
|
20
|
+
|
|
21
|
+
- A fixture reduced from ClimateCompatibleGrowth's MIT-licensed `useOutsideClickAlerter.ts` preserves its parameterized source shape.
|
|
22
|
+
- Browser coverage proves inside-click retention, outside-click closure, exact listener cleanup on conditional removal, and fresh remount ownership.
|
|
23
|
+
- A static sibling remains zero JavaScript and mismatched cleanup remains fail-closed.
|
|
24
|
+
- `npm run check`, `npm test`, and `npm run test:package` pass with all 233 tests and 174 generated pages.
|
|
25
|
+
|
|
26
|
+
### Upgrade
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm install @kudzujs/core@^0.8.62
|
|
30
|
+
```
|
|
31
|
+
|
|
3
32
|
## 0.8.61 - Parameterized debounce hooks
|
|
4
33
|
|
|
5
34
|
Kudzu 0.8.61 compiles the common relative `useDebounce(state, literalDelay)` custom-hook shape through existing state and effect ownership.
|
|
@@ -11,7 +11,7 @@ The top-level [`GOAL_A.md`](../../GOAL_A.md) and [`GOAL_B.md`](../../GOAL_B.md)
|
|
|
11
11
|
| C: state/resource model | Research only | Reduced fixtures expose a limitation |
|
|
12
12
|
| D: routing compatibility | Current behavior preserved | Revisit only with migration evidence and invariant review |
|
|
13
13
|
|
|
14
|
-
The active post-`0.8.
|
|
14
|
+
The active post-`0.8.62` implementation sequence is [`large-application-ai-native-roadmap.md`](./large-application-ai-native-roadmap.md). P0.12 validation, property-level object-state dependencies, direct three-boundary callback/ref ownership, direct primitive, plain-object, array-prop, keyed-item draft state initialization, matching array-draft setter effects, parameterized primitive debounce and outside-click hooks, repeated direct leaf-handler callback use, direct child callback fan-out, collision-free and private Context action setters, package-neutral shared-state/action IR, browser-only package imports and private mutable refs in owned effects, route/layout CSS closure, structural per-route capability/chunk reporting, signature-keyed runtime families, incremental source and affected-route development builds, measured route-entry output optimization, and the plain TypeScript source-scale fast path are complete. ResourceIR remains research for cross-owner transports and subscriptions; range ownership, virtualization, and optimistic transactions still require independent fixtures. The plan orders compiler semantic generalization, large-application foundations, compatibility boundaries, AI tooling, and production validation without changing the invariants below.
|
|
15
15
|
|
|
16
16
|
## Required Invariants
|
|
17
17
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Current Compiler Architecture
|
|
2
2
|
|
|
3
|
-
This maps the current `0.8.
|
|
3
|
+
This maps the current `0.8.62` architecture, built on the completed `0.8.23` Goal A compiler foundation. File and function names are the stable references; line numbers are intentionally omitted because later work may still move code.
|
|
4
4
|
|
|
5
5
|
## Responsibility Map
|
|
6
6
|
|
|
@@ -379,7 +379,7 @@ function increment(value) { setCount(value + 1) }; increment(count)
|
|
|
379
379
|
After the relevant P0 foundations, investigate capabilities in this order:
|
|
380
380
|
|
|
381
381
|
1. Property-level derived dependencies over ordinary object state. **Completed in `0.8.40`:** direct property paths and top-level immutable primitive locals over object state reuse tagged DerivedIR, subscribe to the source signal, and compare selected values with `Object.is`; whole-object and dynamic dependencies remain rejected.
|
|
382
|
-
2. Multi-boundary component/prop/callback/ref/context dataflow. **Three-boundary callback/ref ownership completed in `0.8.43`; collision-free Context action-private state completed in `0.8.44`; action-only Provider setter exposure removed in `0.8.46`; direct primitive prop state initialization completed in `0.8.47`; repeated direct leaf-handler callback use completed in `0.8.48`; direct child callback fan-out completed in `0.8.49`; direct plain-object prop state initialization completed in `0.8.57`; direct keyed item draft initialization completed in `0.8.58`; direct array prop draft initialization completed in `0.8.59`; matching array-draft setter effects completed in `0.8.60`; parameterized primitive debounce hooks completed in `0.8.61`:** forwarding preserves parent SignalIR, Context action lowering uses compiler-owned aliases when consumer locals reuse Provider state/setter names, action-required setters may remain compiler-only when their state is publicly exposed, specialized children may seed local state from a direct parent signal authored with a serializable primitive, plain-object, or array literal, keyed rows may seed object draft state from their direct item prop, and one callback may branch through multiple component `on*` props and intrinsic handlers. A direct first-boundary parent setter may also retain its authored matching `set*` prop name; additional forwarding remains restricted to `on*` props. The ClimateCompatibleGrowth-derived dropdown proves independent array drafts
|
|
382
|
+
2. Multi-boundary component/prop/callback/ref/context dataflow. **Three-boundary callback/ref ownership completed in `0.8.43`; collision-free Context action-private state completed in `0.8.44`; action-only Provider setter exposure removed in `0.8.46`; direct primitive prop state initialization completed in `0.8.47`; repeated direct leaf-handler callback use completed in `0.8.48`; direct child callback fan-out completed in `0.8.49`; direct plain-object prop state initialization completed in `0.8.57`; direct keyed item draft initialization completed in `0.8.58`; direct array prop draft initialization completed in `0.8.59`; matching array-draft setter effects completed in `0.8.60`; parameterized primitive debounce hooks completed in `0.8.61`; direct `createRef()` outside-click hooks completed in `0.8.62`:** forwarding preserves parent SignalIR, Context action lowering uses compiler-owned aliases when consumer locals reuse Provider state/setter names, action-required setters may remain compiler-only when their state is publicly exposed, specialized children may seed local state from a direct parent signal authored with a serializable primitive, plain-object, or array literal, keyed rows may seed object draft state from their direct item prop, and one callback may branch through multiple component `on*` props and intrinsic handlers. A direct first-boundary parent setter may also retain its authored matching `set*` prop name; additional forwarding remains restricted to `on*` props. The ClimateCompatibleGrowth-derived dropdown proves independent array drafts, exact direct setter-effect synchronization, parameterized debounce ownership, and one exact outside-click listener over an intrinsic DOM ref. A fourth callback boundary, callback aliases/non-handler uses, dynamic debounce delays, non-primitive debounce inputs, dynamic outside-click events, mismatched cleanup/state-setter pairs, fully hidden Context state, additional `set*` forwarding, keyed item aliases, property paths, and composed expressions remain fail-closed. Broader prop, callback, ref, and Context graphs remain migration-led work.
|
|
383
383
|
3. Package-neutral shared state/actions and migration of current Zustand internals. **Completed in `0.8.50`:** Zustand source normalization produces one generic shared-state adapter descriptor; selectors and handlers register JSON-safe SharedStateIR/SharedActionIR records, handler lowering consumes package-neutral actions, and existing RouteIR, layout ownership, same-turn updates, navigation persistence, and browser output remain unchanged. Redux/RTK and public adapter APIs remain unsupported.
|
|
384
384
|
4. Browser-only package imports in owned effect/resource modules. **Completed in `0.8.51` for effects:** direct package references in inline effect setup/cleanup callbacks use existing package import records and route-owned effect ESM bundling; build-time component modules and static siblings omit the package. Helper-indirect, render-time, dynamic-import, and ResourceIR package graphs remain unsupported.
|
|
385
385
|
5. ResourceIR from at least two independent WebSocket/SSE/SDK fixtures with the same semantics. **Completed in `0.8.52` without ResourceIR for private ownership:** the E2B terminal and route-owned WebSocket fixtures lower refs used exclusively by one inline effect to invocation-private closure objects, while existing effect ownership supplies replacement, cleanup, stale setter invalidation, navigation, and BFCache disposal. ResourceIR remains unapproved and now requires independent cross-owner transport/subscription fixtures that cannot fit this narrower model.
|
|
@@ -481,4 +481,4 @@ The first comparison is Kudzu versus React + Vite using the same agent, model, t
|
|
|
481
481
|
|
|
482
482
|
## Immediate Decision
|
|
483
483
|
|
|
484
|
-
PR 1 through PR 12, the `0.8.40` property-dependency slice, the `0.8.41` and `0.8.43` direct multi-boundary callback/ref slices, the `0.8.44` Context alias slice, the `0.8.46` action-only Provider setter slice, the `0.8.47` direct primitive, `0.8.57` plain-object prop, `0.8.58` direct keyed item, `0.8.59` direct array prop initializer, `0.8.60` matching array-draft setter-effect,
|
|
484
|
+
PR 1 through PR 12, the `0.8.40` property-dependency slice, the `0.8.41` and `0.8.43` direct multi-boundary callback/ref slices, the `0.8.44` Context alias slice, the `0.8.46` action-only Provider setter slice, the `0.8.47` direct primitive, `0.8.57` plain-object prop, `0.8.58` direct keyed item, `0.8.59` direct array prop initializer, `0.8.60` matching array-draft setter-effect, `0.8.61` parameterized primitive debounce-hook, and `0.8.62` direct-ref outside-click-hook slices, the `0.8.48` repeated direct leaf-handler callback slice, the `0.8.49` direct child callback fan-out slice, the `0.8.50` package-neutral shared-state/action slice, the `0.8.51` owned-effect package import slice, the `0.8.52` effect-private mutable-ref slice, the `0.8.53` route/layout CSS closure slice, the `0.8.54` structural per-route capability/chunk report, the `0.8.55` signature-keyed runtime families, item 7 incremental source and affected-route builds, and the `0.8.42` measured route-output optimization are complete. Continue migration-led semantic generalization; keep ResourceIR limited to independent fixtures, and do not add range ownership, virtualization, optimistic transactions, a public adapter/store API, or a router before evidence justifies them.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Planned Version Sequence
|
|
2
2
|
|
|
3
|
-
This is an execution sequence, not release history. `0.8.16` through `0.8.
|
|
3
|
+
This is an execution sequence, not release history. `0.8.16` through `0.8.62` are completed scopes represented by package/release records.
|
|
4
4
|
|
|
5
5
|
Keep each patch behavior-preserving and independently reviewable. If a boundary proves inseparable, revise this plan before combining releases; do not silently broaden a patch.
|
|
6
6
|
|
|
@@ -52,6 +52,7 @@ Keep each patch behavior-preserving and independently reviewable. If a boundary
|
|
|
52
52
|
| `0.8.59` | Initialize specialized child draft state from one direct parent array-state prop and accept a direct matching `set*` setter prop. | A ClimateCompatibleGrowth-derived dropdown preserves independent array drafts, explicit parent commit, source naming, nearby diagnostics, and a static zero-JavaScript sibling without runtime changes. |
|
|
53
53
|
| `0.8.60` | Synchronize direct prop-derived array draft state through its matching parent setter in a dependency effect. | The ClimateCompatibleGrowth-derived dropdown preserves its exact direct effect/dependency shape, stable setter erasure, array identity comparison, independent parent replacement, nearby diagnostics, and a static zero-JavaScript sibling without runtime changes. |
|
|
54
54
|
| `0.8.61` | Specialize one parameterized relative primitive debounce hook through existing state and dependency-effect ownership. | The ClimateCompatibleGrowth-derived hook preserves direct state initialization, literal delay scope, timeout replacement/cleanup, conditional release, latest-value commit, nearby diagnostics, and a static zero-JavaScript sibling without runtime changes. |
|
|
55
|
+
| `0.8.62` | Lower direct React `createRef()` and one parameterized outside-click hook through existing DOM-ref and effect ownership. | The ClimateCompatibleGrowth-derived hook preserves inside/outside behavior, serializable setter scope, exact listener cleanup, conditional release/remount, nearby diagnostics, and a static zero-JavaScript sibling without runtime changes. |
|
|
55
56
|
|
|
56
57
|
## Sequence Rules
|
|
57
58
|
|
package/framework/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Migration source may retain conventional `react` imports for supported named or
|
|
|
6
6
|
|
|
7
7
|
Compilation begins from page entries and follows relative runtime imports, re-exports, and validated Worker references; unreachable TypeScript migration files are not transformed. Direct maps over imported immutable JSON-safe arrays fold to literals for zero-JavaScript static rows. Synchronous relative calculation functions may return objects whose direct static fields feed reactive JSX bindings; build rendering uses current signal values and route-specific binding ESM reevaluates the same helper after state commits. One direct array field may instead feed a keyed intrinsic map: its evaluator refreshes a compiler-owned array anchor before the existing list reconciler runs, preserving keyed DOM and SVG identity without a calculation runtime. That field must remain a JSON-safe array after every source-state commit. Package imports have a separate narrow boundary: direct references inside intrinsic JSX event callbacks are erased from build modules and bundled into route handler ESM, while render-time, effect, helper-indirect, and mixed package use fails.
|
|
8
8
|
|
|
9
|
-
Native platform work remains ordinary source. A direct async handler or directly returned relative custom-hook callback may call `navigator.clipboard.writeText()` and update application-owned success/failure state; Kudzu emits only its existing route handler ESM. Debounced synchronization uses a dependency effect that creates `setTimeout()` work and directly returns `clearTimeout()` cleanup, reusing dependency, conditional, keyed, and route ownership. A relative `useDebounce(value, delay)` hook may accept one direct primitive state and numeric literal delay, initialize and return one debounced state, and reuse that same effect ownership. One directly returned relative custom-hook callback may own one `null`-initialized private timeout ref when it directly clears the previous value, assigns a numeric-literal-delay `setTimeout()`, and an empty-dependency effect directly clears the timer on cleanup. Kudzu lowers that ref to compiler-owned state shared by existing handler and effect contexts. An ordinary inline effect may exclusively own top-level `useRef(null)` and `useRef(0)` values through direct `.current` references; Kudzu moves them into the setup invocation's closure for browser SDK handles, WebSockets, generation tokens, and animation frames without serialized captures or a resource runtime. Multiple timers, dynamic delays, intervals, ref aliases, cross-effect/event mutable refs, unowned delayed writes, and arbitrary timed callback graphs remain unsupported.
|
|
9
|
+
Native platform work remains ordinary source. A direct async handler or directly returned relative custom-hook callback may call `navigator.clipboard.writeText()` and update application-owned success/failure state; Kudzu emits only its existing route handler ESM. Debounced synchronization uses a dependency effect that creates `setTimeout()` work and directly returns `clearTimeout()` cleanup, reusing dependency, conditional, keyed, and route ownership. A relative `useDebounce(value, delay)` hook may accept one direct primitive state and numeric literal delay, initialize and return one debounced state, and reuse that same effect ownership. Direct React `createRef()` input may lower to an intrinsic DOM ref passed with one direct literal setter callback into an exact outside-click listener effect; ref and setter serialization reuse existing effect scope and cleanup. One directly returned relative custom-hook callback may own one `null`-initialized private timeout ref when it directly clears the previous value, assigns a numeric-literal-delay `setTimeout()`, and an empty-dependency effect directly clears the timer on cleanup. Kudzu lowers that ref to compiler-owned state shared by existing handler and effect contexts. An ordinary inline effect may exclusively own top-level `useRef(null)` and `useRef(0)` values through direct `.current` references; Kudzu moves them into the setup invocation's closure for browser SDK handles, WebSockets, generation tokens, and animation frames without serialized captures or a resource runtime. Multiple timers, dynamic delays, intervals, ref aliases, cross-effect/event mutable refs, unowned delayed writes, and arbitrary timed callback graphs remain unsupported.
|
|
10
10
|
|
|
11
11
|
Imperative canvas migrations use the same effect ownership rather than a component or canvas runtime. One `null`-initialized canvas DOM ref may feed an inline effect whose local variables persist across a recursive animation-frame callback, an `IntersectionObserver`, and native canvas/window listeners; the returned cleanup must cancel the latest frame, disconnect the observer, and remove every listener. Bare `IntersectionObserver` and `performance` identifiers remain browser globals in emitted effect ESM. Component-level mutable value refs and callbacks shared across effects or JSX handlers remain unsupported; move resource-private state and listeners into the owning effect.
|
|
12
12
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import ts from "typescript"
|
|
2
|
+
import { isUnshadowedGlobal, unwrapExpression } from "./ast-helpers.mjs"
|
|
3
|
+
|
|
4
|
+
const valueName = "__kOutsideClickValue"
|
|
5
|
+
|
|
6
|
+
export function analyzeOutsideClickHook(hook) {
|
|
7
|
+
if (!hook || hook.asteriskToken || hook.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || !ts.isBlock(hook.body) || hook.body.statements.length !== 1) return undefined
|
|
8
|
+
if (!frameworkImport(hook.getSourceFile(), "useEffect")) return undefined
|
|
9
|
+
const normalized = hook.parameters.length === 3 && ts.isIdentifier(hook.parameters[2].name) && hook.parameters[2].name.text === valueName
|
|
10
|
+
if (!normalized && hook.parameters.length !== 2) return undefined
|
|
11
|
+
const [refParameter, callbackParameter] = hook.parameters
|
|
12
|
+
if (!ts.isIdentifier(refParameter?.name) || refParameter.initializer || refParameter.dotDotDotToken || !ts.isIdentifier(callbackParameter?.name) || callbackParameter.initializer || callbackParameter.dotDotDotToken) return undefined
|
|
13
|
+
const statement = hook.body.statements[0]
|
|
14
|
+
if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression) || !ts.isIdentifier(statement.expression.expression) || statement.expression.expression.text !== "useEffect" || statement.expression.arguments.length !== 2) return undefined
|
|
15
|
+
const [setup, dependencies] = statement.expression.arguments
|
|
16
|
+
if ((!ts.isArrowFunction(setup) && !ts.isFunctionExpression(setup)) || setup.parameters.length || setup.asteriskToken || setup.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || !ts.isBlock(setup.body) || setup.body.statements.length !== 3 || !ts.isArrayLiteralExpression(dependencies)) return undefined
|
|
17
|
+
const sourceDependencies = dependencies.elements.length === 2 && ts.isIdentifier(dependencies.elements[0]) && dependencies.elements[0].text === refParameter.name.text && ts.isIdentifier(dependencies.elements[1]) && dependencies.elements[1].text === callbackParameter.name.text
|
|
18
|
+
if (normalized ? dependencies.elements.length !== 0 : !sourceDependencies) return undefined
|
|
19
|
+
const [handlerStatement, addStatement, cleanupStatement] = setup.body.statements
|
|
20
|
+
if (!ts.isFunctionDeclaration(handlerStatement) || handlerStatement.asteriskToken || handlerStatement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || !handlerStatement.name || handlerStatement.parameters.length !== 1 || !ts.isIdentifier(handlerStatement.parameters[0].name) || !handlerStatement.body || handlerStatement.body.statements.length !== 1) return undefined
|
|
21
|
+
const event = handlerStatement.parameters[0].name.text
|
|
22
|
+
const condition = handlerStatement.body.statements[0]
|
|
23
|
+
if (!ts.isIfStatement(condition) || condition.elseStatement || !ts.isBlock(condition.thenStatement) || condition.thenStatement.statements.length !== 1 || !outsideCondition(condition.expression, refParameter.name.text, event)) return undefined
|
|
24
|
+
const callbackStatement = condition.thenStatement.statements[0]
|
|
25
|
+
if (!ts.isExpressionStatement(callbackStatement) || !ts.isCallExpression(callbackStatement.expression) || !ts.isIdentifier(callbackStatement.expression.expression) || callbackStatement.expression.expression.text !== callbackParameter.name.text) return undefined
|
|
26
|
+
if (normalized ? callbackStatement.expression.arguments.length !== 1 || !ts.isIdentifier(callbackStatement.expression.arguments[0]) || callbackStatement.expression.arguments[0].text !== valueName : callbackStatement.expression.arguments.length) return undefined
|
|
27
|
+
if (!listenerStatement(addStatement, "addEventListener", handlerStatement.name.text, hook.getSourceFile())) return undefined
|
|
28
|
+
const cleanup = ts.isReturnStatement(cleanupStatement) && cleanupStatement.expression && (ts.isArrowFunction(cleanupStatement.expression) || ts.isFunctionExpression(cleanupStatement.expression)) ? cleanupStatement.expression : undefined
|
|
29
|
+
const cleanupBody = cleanup && ts.isBlock(cleanup.body) && cleanup.body.statements.length === 1 ? cleanup.body.statements[0] : undefined
|
|
30
|
+
if (!cleanup || cleanup.parameters.length || cleanup.asteriskToken || cleanup.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || !listenerStatement(cleanupBody, "removeEventListener", handlerStatement.name.text, hook.getSourceFile())) return undefined
|
|
31
|
+
return { callbackCall: callbackStatement.expression, dependencies, normalized, valueName }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function normalizeOutsideClickHooks(sourceFile, factory, context) {
|
|
35
|
+
const visitor = node => {
|
|
36
|
+
if (ts.isFunctionDeclaration(node) || ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
|
|
37
|
+
const hook = analyzeOutsideClickHook(node)
|
|
38
|
+
if (hook && !hook.normalized) {
|
|
39
|
+
const rewrite = current => {
|
|
40
|
+
if (current === hook.callbackCall) return factory.updateCallExpression(current, current.expression, current.typeArguments, [factory.createIdentifier(valueName)])
|
|
41
|
+
if (current === hook.dependencies) return factory.updateArrayLiteralExpression(current, [])
|
|
42
|
+
return ts.visitEachChild(current, rewrite, context)
|
|
43
|
+
}
|
|
44
|
+
return updateFunction(node, [...node.parameters, factory.createParameterDeclaration(undefined, undefined, valueName)], ts.visitEachChild(node.body, rewrite, context), factory)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return ts.visitEachChild(node, visitor, context)
|
|
48
|
+
}
|
|
49
|
+
return ts.visitNode(sourceFile, visitor)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function updateFunction(node, parameters, body, factory) {
|
|
53
|
+
if (ts.isFunctionDeclaration(node)) return factory.updateFunctionDeclaration(node, node.modifiers, node.asteriskToken, node.name, node.typeParameters, parameters, node.type, body)
|
|
54
|
+
if (ts.isFunctionExpression(node)) return factory.updateFunctionExpression(node, node.modifiers, node.asteriskToken, node.name, node.typeParameters, parameters, node.type, body)
|
|
55
|
+
return factory.updateArrowFunction(node, node.modifiers, node.typeParameters, parameters, node.type, node.equalsGreaterThanToken, body)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function outsideCondition(node, ref, event) {
|
|
59
|
+
node = unwrapExpression(node)
|
|
60
|
+
if (!ts.isBinaryExpression(node) || node.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken || !currentAccess(unwrapExpression(node.left), ref)) return false
|
|
61
|
+
const right = unwrapExpression(node.right)
|
|
62
|
+
if (!ts.isPrefixUnaryExpression(right) || right.operator !== ts.SyntaxKind.ExclamationToken) return false
|
|
63
|
+
const contains = unwrapExpression(right.operand)
|
|
64
|
+
return ts.isCallExpression(contains) && contains.arguments.length === 1 && ts.isPropertyAccessExpression(contains.expression) && contains.expression.name.text === "contains" && currentAccess(unwrapExpression(contains.expression.expression), ref) && ts.isPropertyAccessExpression(unwrapExpression(contains.arguments[0])) && ts.isIdentifier(unwrapExpression(contains.arguments[0]).expression) && unwrapExpression(contains.arguments[0]).expression.text === event && unwrapExpression(contains.arguments[0]).name.text === "target"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function currentAccess(node, ref) {
|
|
68
|
+
return ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === ref && node.name.text === "current"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function listenerStatement(statement, method, handler, sourceFile) {
|
|
72
|
+
if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression) || statement.expression.arguments.length !== 2 || !ts.isStringLiteral(statement.expression.arguments[0]) || statement.expression.arguments[0].text !== "mousedown" || !ts.isIdentifier(statement.expression.arguments[1]) || statement.expression.arguments[1].text !== handler || !ts.isPropertyAccessExpression(statement.expression.expression)) return false
|
|
73
|
+
const target = statement.expression.expression.expression
|
|
74
|
+
return ts.isIdentifier(target) && target.text === "document" && isUnshadowedGlobal(target, sourceFile) && statement.expression.expression.name.text === method
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function frameworkImport(sourceFile, name) {
|
|
78
|
+
return sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && !statement.importClause?.isTypeOnly && ts.isStringLiteral(statement.moduleSpecifier) && ["react", "@kudzujs/core"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.isTypeOnly && !entry.propertyName && entry.name.text === name))
|
|
79
|
+
}
|
|
@@ -5,7 +5,7 @@ import { analyzeCollectionPipeline, isArrayFromCall } from "./collection-analysi
|
|
|
5
5
|
export function createReactMigrationPass({ cloneAst, jsxTagName }) {
|
|
6
6
|
function normalizeReactMigrationSyntax(sourceFile, factory, context, importedCollections = new Set()) {
|
|
7
7
|
const supported = new Set(["createContext", "useContext", "useEffect", "useId", "useReducer", "useRef", "useState"])
|
|
8
|
-
const erased = new Set(["forwardRef", "memo", "useCallback", "useMemo"])
|
|
8
|
+
const erased = new Set(["createRef", "forwardRef", "memo", "useCallback", "useMemo"])
|
|
9
9
|
const aliases = new Map()
|
|
10
10
|
const reactObjects = new Set()
|
|
11
11
|
for (const statement of sourceFile.statements) {
|
|
@@ -110,6 +110,15 @@ export function createReactMigrationPass({ cloneAst, jsxTagName }) {
|
|
|
110
110
|
}
|
|
111
111
|
if (ts.isCallExpression(node)) {
|
|
112
112
|
const name = migrationCallName(node)
|
|
113
|
+
if (name === "createRef") {
|
|
114
|
+
const declaration = node.parent
|
|
115
|
+
const owner = ts.isVariableDeclaration(declaration) && nearestFunction(declaration)
|
|
116
|
+
if (!ts.isVariableDeclaration(declaration) || declaration.initializer !== node || !ts.isIdentifier(declaration.name) || !isLocalConst(declaration) || !owner) throw sourceNodeError(node, sourceFile, "React createRef() must initialize one top-level const identifier in a component")
|
|
117
|
+
if (node.arguments.length) throw sourceNodeError(node, sourceFile, "React createRef() does not accept runtime arguments")
|
|
118
|
+
if (!hasOneIntrinsicRef(owner, declaration.name.text)) throw sourceNodeError(declaration, sourceFile, "React createRef() must be attached exactly once to an intrinsic element")
|
|
119
|
+
required.add("useRef")
|
|
120
|
+
return factory.createCallExpression(factory.createIdentifier("useRef"), node.typeArguments, [factory.createNull()])
|
|
121
|
+
}
|
|
113
122
|
if (name === "forwardRef") return ts.visitNode(lowerReactForwardRef(node, sourceFile, factory), visitor)
|
|
114
123
|
if (name === "memo") {
|
|
115
124
|
if (node.arguments.length !== 1 || !(ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0]) || ts.isIdentifier(node.arguments[0]))) throw sourceNodeError(node, sourceFile, "React memo() requires exactly one function component or component identifier")
|
|
@@ -251,6 +260,23 @@ export function createReactMigrationPass({ cloneAst, jsxTagName }) {
|
|
|
251
260
|
: factory.updateFunctionExpression(callback, callback.modifiers, undefined, callback.name, callback.typeParameters, [parameter], callback.type, callback.body)
|
|
252
261
|
}
|
|
253
262
|
|
|
263
|
+
function hasOneIntrinsicRef(owner, name) {
|
|
264
|
+
let attachments = 0
|
|
265
|
+
let intrinsic = 0
|
|
266
|
+
const visit = node => {
|
|
267
|
+
if (node !== owner && isFunctionLike(node)) return
|
|
268
|
+
if (ts.isJsxAttribute(node) && node.name.text === "ref" && node.initializer && ts.isJsxExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && node.initializer.expression.text === name) {
|
|
269
|
+
attachments++
|
|
270
|
+
const element = node.parent?.parent
|
|
271
|
+
const tag = ts.isJsxOpeningElement(element) || ts.isJsxSelfClosingElement(element) ? element.tagName : undefined
|
|
272
|
+
if (ts.isIdentifier(tag) && tag.text[0] === tag.text[0].toLowerCase()) intrinsic++
|
|
273
|
+
}
|
|
274
|
+
ts.forEachChild(node, visit)
|
|
275
|
+
}
|
|
276
|
+
visit(owner.body)
|
|
277
|
+
return attachments === 1 && intrinsic === 1
|
|
278
|
+
}
|
|
279
|
+
|
|
254
280
|
function validateUseIdSyntax(sourceFile) {
|
|
255
281
|
const imported = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && ["@kudzujs/core", "react"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useId"))
|
|
256
282
|
if (!imported) return
|
|
@@ -13,6 +13,7 @@ import { analyzeEffectDependencies, validateEffectOwnedBrowserResources } from "
|
|
|
13
13
|
import { createHandlerCodegen } from "./handler-codegen.mjs"
|
|
14
14
|
import { createHandlerLowering } from "./handler-lowering.mjs"
|
|
15
15
|
import { registerSharedAction, registerSharedState } from "./ir/module-ir.mjs"
|
|
16
|
+
import { analyzeOutsideClickHook, normalizeOutsideClickHooks } from "./outside-click-pass.mjs"
|
|
16
17
|
import { createCommandSpecializer } from "./optimize/command-specialization.mjs"
|
|
17
18
|
import { applyNormalizationPasses } from "./normalization-pipeline.mjs"
|
|
18
19
|
import { assetPath, relativeModulePath, withBase } from "./path-helpers.mjs"
|
|
@@ -305,6 +306,7 @@ function normalizeCompilerSource(sourceFile, { base, context, file, importedColl
|
|
|
305
306
|
source => normalizeReactMigrationSyntax(source, factory, context, importedCollections ?? importedSerializableCollectionNames(source, file, sourceFiles, sourceIndex)),
|
|
306
307
|
source => normalizeNavigatorCapabilityConditions(source, factory, context),
|
|
307
308
|
source => normalizeParameterizedDebounceHooks(source, factory, context),
|
|
309
|
+
source => normalizeOutsideClickHooks(source, factory, context),
|
|
308
310
|
source => normalizeEffectPrivateRefs(source, factory, context),
|
|
309
311
|
source => {
|
|
310
312
|
const result = normalizeCustomHookTimerRefs(source, factory, context)
|
|
@@ -422,6 +424,8 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
|
|
|
422
424
|
const customHooks = new Map()
|
|
423
425
|
const parameterizedDebounceCalls = new WeakSet()
|
|
424
426
|
const resolvedParameterizedDebounceHooks = new Map()
|
|
427
|
+
const outsideClickCalls = new WeakMap()
|
|
428
|
+
const resolvedOutsideClickHooks = new Map()
|
|
425
429
|
const jsxLocalDeclarations = new Map()
|
|
426
430
|
const jsxLocalsByFunction = new Map()
|
|
427
431
|
const listLocalDeclarations = []
|
|
@@ -616,6 +620,15 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
|
|
|
616
620
|
resolvedParameterizedDebounceHooks.set(key, analysis)
|
|
617
621
|
return analysis
|
|
618
622
|
}
|
|
623
|
+
const resolveOutsideClickHook = binding => {
|
|
624
|
+
const exportName = binding.kind === "default" ? "default" : binding.imported
|
|
625
|
+
const key = `${binding.target}:${exportName}`
|
|
626
|
+
if (resolvedOutsideClickHooks.has(key)) return resolvedOutsideClickHooks.get(key)
|
|
627
|
+
const hook = resolveComponentExport(binding.target, exportName, importedSource, sourceFiles)
|
|
628
|
+
const analysis = analyzeOutsideClickHook(hook)
|
|
629
|
+
resolvedOutsideClickHooks.set(key, analysis)
|
|
630
|
+
return analysis
|
|
631
|
+
}
|
|
619
632
|
|
|
620
633
|
const collect = node => {
|
|
621
634
|
if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) {
|
|
@@ -788,6 +801,17 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
|
|
|
788
801
|
}
|
|
789
802
|
}
|
|
790
803
|
}
|
|
804
|
+
if (ts.isCallExpression(node) && ts.isExpressionStatement(node.parent) && ts.isIdentifier(node.expression) && /^use[A-Z]/.test(node.expression.text) && importBindings.has(node.expression.text) && importBindings.get(node.expression.text).kind !== "namespace") {
|
|
805
|
+
const hook = resolveOutsideClickHook(importBindings.get(node.expression.text))
|
|
806
|
+
if (hook) {
|
|
807
|
+
const owner = nearestFunction(node)
|
|
808
|
+
const ref = node.arguments[0] && unwrapExpression(node.arguments[0])
|
|
809
|
+
const callback = directSetterLiteralCallback(node.arguments[1], owner ? settersByFunction.get(owner) ?? new Map() : new Map())
|
|
810
|
+
if (!owner || node.arguments.length !== 2 || !ts.isIdentifier(ref) || !componentHasDirectObjectRef(owner, ref.text)) throw sourceNodeError(node, sourceFile, "Outside-click hooks require one direct component DOM ref as their first argument")
|
|
811
|
+
if (!callback) throw sourceNodeError(node.arguments[1] ?? node, sourceFile, "Outside-click hooks require one inline direct literal setter callback")
|
|
812
|
+
outsideClickCalls.set(node, callback)
|
|
813
|
+
}
|
|
814
|
+
}
|
|
791
815
|
if (ts.isFunctionDeclaration(node) && node.name) {
|
|
792
816
|
functions.set(node.name.text, node)
|
|
793
817
|
if (node.parent === sourceFile) {
|
|
@@ -1747,6 +1771,8 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
|
|
|
1747
1771
|
return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, arguments_))
|
|
1748
1772
|
}
|
|
1749
1773
|
const visitor = node => {
|
|
1774
|
+
const outsideClick = ts.isCallExpression(node) && outsideClickCalls.get(node)
|
|
1775
|
+
if (outsideClick) return factory.updateCallExpression(node, node.expression, node.typeArguments, [node.arguments[0], factory.createIdentifier(outsideClick.setter), cloneAst(outsideClick.value, factory, context)])
|
|
1750
1776
|
if (ts.isJsxAttribute(node) && contextProviderPrivateSetters.has(node)) {
|
|
1751
1777
|
const expression = unwrapExpression(node.initializer.expression)
|
|
1752
1778
|
const value = factory.updateObjectLiteralExpression(expression, [...expression.properties, ...contextProviderPrivateSetters.get(node).map(name => factory.createShorthandPropertyAssignment(name))])
|
|
@@ -2242,6 +2268,32 @@ function componentHasDirectPrimitiveState(component, state) {
|
|
|
2242
2268
|
return Boolean(component && ts.isBlock(component.body) && component.body.statements.some(statement => ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => ts.isArrayBindingPattern(declaration.name) && ts.isIdentifier(declaration.name.elements[0]?.name) && declaration.name.elements[0].name.text === state && declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "useState" && declaration.initializer.arguments.length === 1 && isPrimitiveDefaultLiteral(unwrapExpression(declaration.initializer.arguments[0])))))
|
|
2243
2269
|
}
|
|
2244
2270
|
|
|
2271
|
+
function componentHasDirectObjectRef(component, ref) {
|
|
2272
|
+
if (!component || !ts.isBlock(component.body)) return false
|
|
2273
|
+
const declaration = component.body.statements.some(statement => ts.isVariableStatement(statement) && statement.declarationList.declarations.some(entry => ts.isIdentifier(entry.name) && entry.name.text === ref && entry.initializer && ts.isCallExpression(entry.initializer) && ts.isIdentifier(entry.initializer.expression) && entry.initializer.expression.text === "useRef" && entry.initializer.arguments.length === 1 && entry.initializer.arguments[0].kind === ts.SyntaxKind.NullKeyword))
|
|
2274
|
+
let attachments = 0
|
|
2275
|
+
let intrinsic = 0
|
|
2276
|
+
const visit = node => {
|
|
2277
|
+
if (ts.isJsxAttribute(node) && node.name.text === "ref" && node.initializer && ts.isJsxExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && node.initializer.expression.text === ref) {
|
|
2278
|
+
attachments++
|
|
2279
|
+
const element = node.parent?.parent
|
|
2280
|
+
const tag = ts.isJsxOpeningElement(element) || ts.isJsxSelfClosingElement(element) ? element.tagName : undefined
|
|
2281
|
+
if (ts.isIdentifier(tag) && tag.text[0] === tag.text[0].toLowerCase()) intrinsic++
|
|
2282
|
+
}
|
|
2283
|
+
ts.forEachChild(node, visit)
|
|
2284
|
+
}
|
|
2285
|
+
visit(component.body)
|
|
2286
|
+
return declaration && attachments === 1 && intrinsic === 1
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
function directSetterLiteralCallback(node, setters) {
|
|
2290
|
+
node = node && unwrapExpression(node)
|
|
2291
|
+
if ((!ts.isArrowFunction(node) && !ts.isFunctionExpression(node)) || node.parameters.length || node.asteriskToken || node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) return undefined
|
|
2292
|
+
const expression = ts.isBlock(node.body) ? node.body.statements.length === 1 && ts.isExpressionStatement(node.body.statements[0]) ? node.body.statements[0].expression : undefined : node.body
|
|
2293
|
+
if (!expression || !ts.isCallExpression(expression) || !ts.isIdentifier(expression.expression) || !setters.has(expression.expression.text) || expression.arguments.length !== 1 || !isPrimitiveDefaultLiteral(unwrapExpression(expression.arguments[0]))) return undefined
|
|
2294
|
+
return { setter: expression.expression.text, value: unwrapExpression(expression.arguments[0]) }
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2245
2297
|
function componentHasDirectArrayState(component, state) {
|
|
2246
2298
|
return Boolean(component && ts.isBlock(component.body) && component.body.statements.some(statement => ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => ts.isArrayBindingPattern(declaration.name) && ts.isIdentifier(declaration.name.elements[0]?.name) && declaration.name.elements[0].name.text === state && declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "useState" && declaration.initializer.arguments.length === 1 && ts.isArrayLiteralExpression(unwrapExpression(declaration.initializer.arguments[0])))))
|
|
2247
2299
|
}
|