@kudzujs/core 0.8.12 → 0.8.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/RELEASES.md +59 -0
- package/framework/README.md +9 -1
- package/framework/binding-runtime.js +7 -0
- package/framework/build.mjs +319 -1
- package/framework/core.d.ts +2 -0
- package/package.json +1 -1
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.14 - Localized blog migration.** Browser locale entry, prefixed native links, static MDX with interactive copy/tabs, and continuous canvas animation compile without React, eval, a package router, or a canvas runtime. Read the [release notes](./RELEASES.md#0814---localized-blog-migration) or open the [release page](https://kudzujs.cloud/releases/0.8.14).
|
|
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,64 @@
|
|
|
1
1
|
# Kudzu Releases
|
|
2
2
|
|
|
3
|
+
## 0.8.14 - Localized blog migration
|
|
4
|
+
|
|
5
|
+
Kudzu 0.8.14 proves a localized MDX blog and continuous imperative canvas can migrate without React, request-time i18n, eval, or a canvas runtime.
|
|
6
|
+
|
|
7
|
+
### New in 0.8.14
|
|
8
|
+
|
|
9
|
+
- Build-known MDX emits complete static article HTML without `eval()` or `new Function()`.
|
|
10
|
+
- Locale-prefixed `/ko` and `/en` routes build through `getStaticPaths()`, and relative Link replacements automatically prefix native hrefs from the build-known locale.
|
|
11
|
+
- The root route selects a stored locale or reads `navigator.languages`, then preserves query and hash through native `location.replace()`.
|
|
12
|
+
- MDX copy blocks and tabs compile through existing clipboard, state, binding, and conditional capabilities.
|
|
13
|
+
- One `null`-initialized canvas ref may feed an inline effect whose local state survives recursive animation frames, visibility changes, and native input listeners.
|
|
14
|
+
- Bare `IntersectionObserver` and `performance` remain browser globals in route-specific effect ESM.
|
|
15
|
+
- Static sibling routes remain complete zero-JavaScript HTML.
|
|
16
|
+
|
|
17
|
+
### Fixed in 0.8.14
|
|
18
|
+
|
|
19
|
+
- Effect-owned `IntersectionObserver` instances must disconnect in cleanup.
|
|
20
|
+
- Local animation-frame handles assigned by effects must be cancelled in cleanup.
|
|
21
|
+
- The complete suite passes 151/151 tests.
|
|
22
|
+
|
|
23
|
+
### Boundary
|
|
24
|
+
|
|
25
|
+
This release does not execute `next-intl`, add request-time `Accept-Language` negotiation, general mutable refs, callbacks shared across effects or handlers, a canvas runtime, or arbitrary MDX component execution. Request-time locale redirects remain host/edge configuration; resource-private drawing state and listeners stay inside one owning effect.
|
|
26
|
+
|
|
27
|
+
### Upgrade
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install @kudzujs/core@^0.8.14
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 0.8.13 - Browser capability migration
|
|
34
|
+
|
|
35
|
+
Kudzu 0.8.13 proves browser-owned migration paths from Memos, Excalidraw, and Cal.com without adding React, general external-store compatibility, or browser capability runtimes.
|
|
36
|
+
|
|
37
|
+
### New in 0.8.13
|
|
38
|
+
|
|
39
|
+
- Memos-shaped scroll spies retain an effect-owned `useRef(0)` animation-frame handle with burst coalescing, exact cleanup, smooth navigation, and active-heading ARIA.
|
|
40
|
+
- Excalidraw-shaped `"share" in navigator` conditions emit false static fallback and mount Web Share DOM and handlers only in supporting browsers.
|
|
41
|
+
- Cal.com-shaped static `useSyncExternalStore` media queries emit desktop-first HTML and lower matching `change` subscriptions into owned effects.
|
|
42
|
+
- Direct Web Share and clipboard actions preserve application-owned success and failure state without package adapters.
|
|
43
|
+
- Static sibling routes remain complete zero-JavaScript HTML for every migration fixture.
|
|
44
|
+
|
|
45
|
+
### Fixed in 0.8.13
|
|
46
|
+
|
|
47
|
+
- Build-time Node `navigator` availability can no longer permanently fold browser capability UI to the wrong branch.
|
|
48
|
+
- Optimized state-select text bindings now register the same direct state commit path as optimized attributes.
|
|
49
|
+
- Animation-frame refs reject aliases, shadowed bindings, inverted guards, indirect reset/cancellation, repeated scheduling, and missing cleanup with source locations.
|
|
50
|
+
- The complete suite passes 149/149 tests.
|
|
51
|
+
|
|
52
|
+
### Boundary
|
|
53
|
+
|
|
54
|
+
This release does not add general mutable refs, external stores, media hooks, browser-expression rendering, animation scheduling, collaboration transport, QR generation, or React package execution. Dynamic capability names, `navigator.canShare()`, parameterized/imported media hooks, dynamic queries, and arbitrary store snapshots remain source migration work.
|
|
55
|
+
|
|
56
|
+
### Upgrade
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm install @kudzujs/core@^0.8.13
|
|
60
|
+
```
|
|
61
|
+
|
|
3
62
|
## 0.8.12 - Static icon migration
|
|
4
63
|
|
|
5
64
|
Kudzu 0.8.12 proves a source migration path from Lucide React-shaped icon usage to accessible static SVG without a package factory or browser runtime.
|
package/framework/README.md
CHANGED
|
@@ -6,7 +6,15 @@ 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. 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. Multiple timers, dynamic delays, intervals, aliases, 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. 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 effect may instead exclusively own `useRef(0)` as one animation-frame handle when a local callback resets it, one scheduler assigns `requestAnimationFrame()`, and cleanup cancels the pending frame; Kudzu lowers it to the existing effect scope without an animation runtime. Multiple timers, dynamic delays, intervals, ref aliases, cross-effect frame handles, unowned delayed writes, and arbitrary timed callback graphs remain unsupported.
|
|
10
|
+
|
|
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
|
+
|
|
13
|
+
Localized static migrations do not need a package router. `getStaticPaths()` emits each build-known locale, a small relative Link replacement prefixes native hrefs from the locale prop, and an optional `/` mount effect may choose a stored locale or inspect `navigator.languages` before calling `location.replace()` with the original query and hash. This preserves automatic browser-language entry and locale-prefixed navigation; request-time `Accept-Language` negotiation still requires host or edge configuration. Build-known MDX becomes static `dangerouslySetInnerHTML`, while copy controls, tabs, and other supported interactive components remain ordinary state and handler capabilities beside that HTML.
|
|
14
|
+
|
|
15
|
+
A top-level component local shaped exactly as `const supported = "literal" in navigator` may control one direct `supported && <Branch />` JSX site. Kudzu emits the branch as false static fallback, evaluates the browser capability once through an existing mount effect, and uses existing state-conditional ownership to mount its DOM and handlers only when supported. The value cannot escape or feed aliases, composed expressions, ternaries, dynamic properties, `canShare()`, or other browser globals.
|
|
16
|
+
|
|
17
|
+
A direct top-level `useSyncExternalStore()` may represent one static `window.matchMedia()` boolean when its three inline callbacks add/remove the same `change` listener, read the same literal query, and return `false` for the server snapshot. Kudzu emits false static fallback state and lowers the subscription to an existing owned effect. This is not general external-store support: imported/parameterized media hooks, dynamic queries, legacy listeners, and arbitrary snapshots remain diagnosed.
|
|
10
18
|
|
|
11
19
|
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.
|
|
12
20
|
|
|
@@ -104,6 +104,13 @@ function mountBindings(root) {
|
|
|
104
104
|
mountedBindings.add(node)
|
|
105
105
|
const registrations = []
|
|
106
106
|
bindingRegistrations.set(node, registrations)
|
|
107
|
+
if (descriptor.state) {
|
|
108
|
+
const binding = { node, target: "text", read: Object.hasOwn(descriptor, "truthy") ? () => browserState.get(descriptor.state) ? descriptor.truthy : descriptor.falsy : () => browserState.get(descriptor.state) }
|
|
109
|
+
register(bindingTargets, descriptor.state, binding)
|
|
110
|
+
registrations.push([descriptor.state, binding])
|
|
111
|
+
patchBinding(node, "text", binding.read())
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
107
114
|
loadEvaluator(descriptor).then(evaluator => {
|
|
108
115
|
if (!node.isConnected) return
|
|
109
116
|
const binding = { node, target: "text", read: evaluator.read }
|
package/framework/build.mjs
CHANGED
|
@@ -2745,6 +2745,287 @@ function normalizeCustomHookTimerRefs(sourceFile, factory, context) {
|
|
|
2745
2745
|
return normalized
|
|
2746
2746
|
}
|
|
2747
2747
|
|
|
2748
|
+
function normalizeMediaQueryExternalStores(sourceFile, factory, context) {
|
|
2749
|
+
const imports = sourceFile.statements.filter(statement => ts.isImportDeclaration(statement) && !statement.importClause?.isTypeOnly && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "react" && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings))
|
|
2750
|
+
const externalStoreImport = imports.flatMap(statement => statement.importClause.namedBindings.elements.map(entry => ({ entry, statement }))).find(({ entry }) => !entry.isTypeOnly && !entry.propertyName && entry.name.text === "useSyncExternalStore")
|
|
2751
|
+
if (!externalStoreImport) return sourceFile
|
|
2752
|
+
const returnedExpression = callback => {
|
|
2753
|
+
if (!(ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) || callback.parameters.length) return undefined
|
|
2754
|
+
if (!ts.isBlock(callback.body)) return unwrapExpression(callback.body)
|
|
2755
|
+
if (callback.body.statements.length !== 1 || !ts.isReturnStatement(callback.body.statements[0]) || !callback.body.statements[0].expression) return undefined
|
|
2756
|
+
return unwrapExpression(callback.body.statements[0].expression)
|
|
2757
|
+
}
|
|
2758
|
+
const matchMediaQuery = expression => {
|
|
2759
|
+
expression = unwrapExpression(expression)
|
|
2760
|
+
if (!ts.isCallExpression(expression) || expression.arguments.length !== 1 || !ts.isStringLiteral(unwrapExpression(expression.arguments[0])) || !ts.isPropertyAccessExpression(expression.expression) || !ts.isIdentifier(expression.expression.expression) || expression.expression.expression.text !== "window" || expression.expression.name.text !== "matchMedia" || !isUnshadowedGlobal(expression.expression.expression, sourceFile)) return undefined
|
|
2761
|
+
return unwrapExpression(expression.arguments[0]).text
|
|
2762
|
+
}
|
|
2763
|
+
const mediaListener = (statement, method, media, callback) => ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression) && statement.expression.arguments.length === 2 && ts.isPropertyAccessExpression(statement.expression.expression) && ts.isIdentifier(statement.expression.expression.expression) && statement.expression.expression.expression.text === media && statement.expression.expression.name.text === method && ts.isStringLiteral(unwrapExpression(statement.expression.arguments[0])) && unwrapExpression(statement.expression.arguments[0]).text === "change" && ts.isIdentifier(unwrapExpression(statement.expression.arguments[1])) && unwrapExpression(statement.expression.arguments[1]).text === callback
|
|
2764
|
+
const candidates = new Map()
|
|
2765
|
+
let index = 0
|
|
2766
|
+
const inspect = node => {
|
|
2767
|
+
if (!ts.isVariableStatement(node) || !(node.declarationList.flags & ts.NodeFlags.Const) || node.declarationList.declarations.length !== 1) {
|
|
2768
|
+
ts.forEachChild(node, inspect)
|
|
2769
|
+
return
|
|
2770
|
+
}
|
|
2771
|
+
const declaration = node.declarationList.declarations[0]
|
|
2772
|
+
const call = declaration.initializer && unwrapExpression(declaration.initializer)
|
|
2773
|
+
if (!ts.isIdentifier(declaration.name) || !call || !ts.isCallExpression(call) || !ts.isIdentifier(call.expression) || call.expression.text !== "useSyncExternalStore" || isShadowedIdentifier(call.expression, sourceFile)) {
|
|
2774
|
+
ts.forEachChild(node, inspect)
|
|
2775
|
+
return
|
|
2776
|
+
}
|
|
2777
|
+
if (call.arguments.length !== 3) throw sourceNodeError(call, sourceFile, "Media query useSyncExternalStore() requires subscribe, browser snapshot, and false server snapshot callbacks")
|
|
2778
|
+
const [subscribe, snapshot, serverSnapshot] = call.arguments.map(unwrapExpression)
|
|
2779
|
+
if (!(ts.isArrowFunction(subscribe) || ts.isFunctionExpression(subscribe)) || subscribe.parameters.length !== 1 || !ts.isIdentifier(subscribe.parameters[0].name) || !ts.isBlock(subscribe.body)) throw sourceNodeError(subscribe, sourceFile, "Media query subscriptions require one inline callback parameter and block body")
|
|
2780
|
+
if (subscribe.body.statements.length !== 3) throw sourceNodeError(subscribe, sourceFile, "Media query subscriptions must add and remove one matching change listener")
|
|
2781
|
+
const callback = subscribe.parameters[0].name.text
|
|
2782
|
+
const [mediaStatement, addStatement, returnStatement] = subscribe.body.statements
|
|
2783
|
+
const mediaDeclaration = ts.isVariableStatement(mediaStatement) && (mediaStatement.declarationList.flags & ts.NodeFlags.Const) && mediaStatement.declarationList.declarations.length === 1 ? mediaStatement.declarationList.declarations[0] : undefined
|
|
2784
|
+
const media = mediaDeclaration && ts.isIdentifier(mediaDeclaration.name) ? mediaDeclaration.name.text : undefined
|
|
2785
|
+
const query = mediaDeclaration?.initializer && matchMediaQuery(mediaDeclaration.initializer)
|
|
2786
|
+
const cleanup = ts.isReturnStatement(returnStatement) && returnStatement.expression ? unwrapExpression(returnStatement.expression) : undefined
|
|
2787
|
+
const cleanupStatement = cleanup && (ts.isArrowFunction(cleanup) || ts.isFunctionExpression(cleanup)) && !cleanup.parameters.length
|
|
2788
|
+
? ts.isBlock(cleanup.body) ? cleanup.body.statements.length === 1 ? cleanup.body.statements[0] : undefined : factory.createExpressionStatement(cleanup.body)
|
|
2789
|
+
: undefined
|
|
2790
|
+
if (!media || !query || !mediaListener(addStatement, "addEventListener", media, callback) || !cleanupStatement || !mediaListener(cleanupStatement, "removeEventListener", media, callback)) throw sourceNodeError(subscribe, sourceFile, "Media query subscriptions must add and remove one matching change listener")
|
|
2791
|
+
const snapshotValue = returnedExpression(snapshot)
|
|
2792
|
+
const snapshotQuery = snapshotValue && ts.isPropertyAccessExpression(snapshotValue) && snapshotValue.name.text === "matches" ? matchMediaQuery(snapshotValue.expression) : undefined
|
|
2793
|
+
const serverValue = returnedExpression(serverSnapshot)
|
|
2794
|
+
if (snapshotQuery !== query || !serverValue || serverValue.kind !== ts.SyntaxKind.FalseKeyword) throw sourceNodeError(call, sourceFile, "Media query external stores require matching static snapshots and a false server fallback")
|
|
2795
|
+
const owner = nearestFunction(node)
|
|
2796
|
+
const topLevelOwner = owner && (owner.parent === sourceFile || ts.isVariableDeclaration(owner.parent) && owner.parent.parent?.parent?.parent === sourceFile)
|
|
2797
|
+
if (!topLevelOwner || !owner.body || !ts.isBlock(owner.body) || node.parent !== owner.body) throw sourceNodeError(declaration, sourceFile, "Media query external stores must initialize one top-level component const")
|
|
2798
|
+
for (const name of ["useEffect", "useState"]) if (owner.parameters.some(parameter => bindingNames(parameter.name).includes(name)) || functionVarDeclaresName(owner, name) || owner.body.statements.some(statement => statementDeclaresName(statement, name))) throw sourceNodeError(declaration, sourceFile, `Media query external stores conflict with component-local ${name}`)
|
|
2799
|
+
let setter = `__kSetMediaQuery${index++}`
|
|
2800
|
+
while (sourceFile.text.includes(setter)) setter = `__kSetMediaQuery${index++}`
|
|
2801
|
+
candidates.set(node, { declaration, query, setter })
|
|
2802
|
+
}
|
|
2803
|
+
inspect(sourceFile)
|
|
2804
|
+
const references = referenceIdentifiers(sourceFile, "useSyncExternalStore")
|
|
2805
|
+
if (references.length !== candidates.size) throw sourceNodeError(references.find(reference => ![...candidates.values()].some(candidate => insideNode(reference, candidate.declaration.initializer))) ?? externalStoreImport.entry, sourceFile, "useSyncExternalStore is supported only for direct static media query declarations")
|
|
2806
|
+
const directHooks = new Set(imports.flatMap(statement => statement.importClause.namedBindings.elements.filter(entry => !entry.propertyName).map(entry => entry.name.text)))
|
|
2807
|
+
const missingHooks = ["useEffect", "useState"].filter(name => !directHooks.has(name))
|
|
2808
|
+
for (const name of missingHooks) {
|
|
2809
|
+
const collision = sourceFile.statements.some(statement => statementDeclaresName(statement, name) || ts.isImportDeclaration(statement) && importDeclarationNames(statement).includes(name))
|
|
2810
|
+
if (collision) throw sourceNodeError([...candidates.values()][0].declaration, sourceFile, `Media query external stores conflict with local ${name}`)
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
const visitor = node => {
|
|
2814
|
+
const candidate = ts.isVariableStatement(node) ? candidates.get(node) : undefined
|
|
2815
|
+
if (candidate) {
|
|
2816
|
+
const state = factory.createVariableStatement(node.modifiers, factory.createVariableDeclarationList([
|
|
2817
|
+
factory.createVariableDeclaration(factory.createArrayBindingPattern([
|
|
2818
|
+
factory.createBindingElement(undefined, undefined, candidate.declaration.name),
|
|
2819
|
+
factory.createBindingElement(undefined, undefined, candidate.setter)
|
|
2820
|
+
]), undefined, undefined, factory.createCallExpression(factory.createIdentifier("useState"), undefined, [factory.createFalse()]))
|
|
2821
|
+
], ts.NodeFlags.Const))
|
|
2822
|
+
const media = factory.createIdentifier("media")
|
|
2823
|
+
const update = factory.createIdentifier("update")
|
|
2824
|
+
const mediaCall = factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("window"), "matchMedia"), undefined, [factory.createStringLiteral(candidate.query)])
|
|
2825
|
+
const updateCallback = factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), factory.createCallExpression(factory.createIdentifier(candidate.setter), undefined, [factory.createPropertyAccessExpression(media, "matches")]))
|
|
2826
|
+
const listener = method => factory.createCallExpression(factory.createPropertyAccessExpression(media, method), undefined, [factory.createStringLiteral("change"), update])
|
|
2827
|
+
const cleanup = factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), listener("removeEventListener"))
|
|
2828
|
+
const setup = factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), factory.createBlock([
|
|
2829
|
+
factory.createVariableStatement(undefined, factory.createVariableDeclarationList([factory.createVariableDeclaration(media, undefined, undefined, mediaCall)], ts.NodeFlags.Const)),
|
|
2830
|
+
factory.createVariableStatement(undefined, factory.createVariableDeclarationList([factory.createVariableDeclaration(update, undefined, undefined, updateCallback)], ts.NodeFlags.Const)),
|
|
2831
|
+
factory.createExpressionStatement(factory.createCallExpression(update, undefined, [])),
|
|
2832
|
+
factory.createExpressionStatement(listener("addEventListener")),
|
|
2833
|
+
factory.createReturnStatement(cleanup)
|
|
2834
|
+
], true))
|
|
2835
|
+
const effectCall = factory.createCallExpression(factory.createIdentifier("useEffect"), undefined, [setup, factory.createArrayLiteralExpression()])
|
|
2836
|
+
ts.setOriginalNode(effectCall, candidate.declaration.initializer)
|
|
2837
|
+
ts.setTextRange(effectCall, candidate.declaration.initializer)
|
|
2838
|
+
return [state, factory.createExpressionStatement(effectCall)]
|
|
2839
|
+
}
|
|
2840
|
+
if (node === externalStoreImport.statement) {
|
|
2841
|
+
const clause = node.importClause
|
|
2842
|
+
const bindings = clause.namedBindings
|
|
2843
|
+
const elements = bindings.elements.filter(entry => entry !== externalStoreImport.entry)
|
|
2844
|
+
for (const name of missingHooks) elements.push(factory.createImportSpecifier(false, undefined, factory.createIdentifier(name)))
|
|
2845
|
+
return factory.updateImportDeclaration(node, node.modifiers, factory.updateImportClause(clause, false, clause.name, factory.updateNamedImports(bindings, elements)), node.moduleSpecifier, node.attributes)
|
|
2846
|
+
}
|
|
2847
|
+
return ts.visitEachChild(node, visitor, context)
|
|
2848
|
+
}
|
|
2849
|
+
return ts.visitNode(sourceFile, visitor)
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
function insideNode(node, root) {
|
|
2853
|
+
for (let current = node; current; current = current.parent) if (current === root) return true
|
|
2854
|
+
return false
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
function normalizeNavigatorCapabilityConditions(sourceFile, factory, context) {
|
|
2858
|
+
const candidates = new Map()
|
|
2859
|
+
let index = 0
|
|
2860
|
+
const inspect = node => {
|
|
2861
|
+
if (!ts.isVariableStatement(node) || !(node.declarationList.flags & ts.NodeFlags.Const) || node.declarationList.declarations.length !== 1) {
|
|
2862
|
+
ts.forEachChild(node, inspect)
|
|
2863
|
+
return
|
|
2864
|
+
}
|
|
2865
|
+
const declaration = node.declarationList.declarations[0]
|
|
2866
|
+
const value = declaration.initializer && unwrapExpression(declaration.initializer)
|
|
2867
|
+
if (!ts.isIdentifier(declaration.name) || !value || !ts.isBinaryExpression(value) || value.operatorToken.kind !== ts.SyntaxKind.InKeyword || !ts.isStringLiteral(unwrapExpression(value.left)) || !ts.isIdentifier(unwrapExpression(value.right)) || unwrapExpression(value.right).text !== "navigator" || !isUnshadowedGlobal(unwrapExpression(value.right), sourceFile)) {
|
|
2868
|
+
ts.forEachChild(node, inspect)
|
|
2869
|
+
return
|
|
2870
|
+
}
|
|
2871
|
+
const owner = nearestFunction(node)
|
|
2872
|
+
const topLevelOwner = owner && (owner.parent === sourceFile || ts.isVariableDeclaration(owner.parent) && owner.parent.parent?.parent?.parent === sourceFile)
|
|
2873
|
+
if (!topLevelOwner || !owner.body || !ts.isBlock(owner.body) || node.parent !== owner.body) throw sourceNodeError(declaration, sourceFile, "Navigator capability conditions must be top-level component const declarations")
|
|
2874
|
+
const references = referenceIdentifiers(owner.body, declaration.name.text)
|
|
2875
|
+
const condition = references.length === 1 ? references[0] : undefined
|
|
2876
|
+
const structural = condition && ts.isBinaryExpression(condition.parent) && condition.parent.left === condition && condition.parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && containsJsx(condition.parent.right) && ts.isJsxExpression(condition.parent.parent) && condition.parent.parent.expression === condition.parent
|
|
2877
|
+
if (!structural) throw sourceNodeError(declaration, sourceFile, "Navigator capability values may only control one direct JSX && branch")
|
|
2878
|
+
for (const name of ["useEffect", "useState"]) if (owner.parameters.some(parameter => bindingNames(parameter.name).includes(name)) || functionVarDeclaresName(owner, name) || owner.body.statements.some(statement => statementDeclaresName(statement, name))) throw sourceNodeError(declaration, sourceFile, `Navigator capability conditions conflict with component-local ${name}`)
|
|
2879
|
+
let setter = `__kSetNavigatorCapability${index++}`
|
|
2880
|
+
while (sourceFile.text.includes(setter)) setter = `__kSetNavigatorCapability${index++}`
|
|
2881
|
+
candidates.set(node, { declaration, property: unwrapExpression(value.left).text, setter })
|
|
2882
|
+
}
|
|
2883
|
+
inspect(sourceFile)
|
|
2884
|
+
if (!candidates.size) return sourceFile
|
|
2885
|
+
|
|
2886
|
+
const visitor = node => {
|
|
2887
|
+
const candidate = ts.isVariableStatement(node) ? candidates.get(node) : undefined
|
|
2888
|
+
if (candidate) {
|
|
2889
|
+
const state = factory.createVariableStatement(node.modifiers, factory.createVariableDeclarationList([
|
|
2890
|
+
factory.createVariableDeclaration(factory.createArrayBindingPattern([
|
|
2891
|
+
factory.createBindingElement(undefined, undefined, candidate.declaration.name),
|
|
2892
|
+
factory.createBindingElement(undefined, undefined, candidate.setter)
|
|
2893
|
+
]), undefined, undefined, factory.createCallExpression(factory.createIdentifier("useState"), undefined, [factory.createFalse()]))
|
|
2894
|
+
], ts.NodeFlags.Const))
|
|
2895
|
+
const capability = factory.createBinaryExpression(factory.createStringLiteral(candidate.property), factory.createToken(ts.SyntaxKind.InKeyword), factory.createIdentifier("navigator"))
|
|
2896
|
+
const setup = factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), factory.createBlock([
|
|
2897
|
+
factory.createExpressionStatement(factory.createCallExpression(factory.createIdentifier(candidate.setter), undefined, [capability]))
|
|
2898
|
+
], true))
|
|
2899
|
+
const effectCall = factory.createCallExpression(factory.createIdentifier("useEffect"), undefined, [setup, factory.createArrayLiteralExpression()])
|
|
2900
|
+
ts.setOriginalNode(effectCall, candidate.declaration.initializer)
|
|
2901
|
+
ts.setTextRange(effectCall, candidate.declaration.initializer)
|
|
2902
|
+
const effect = factory.createExpressionStatement(effectCall)
|
|
2903
|
+
return [state, effect]
|
|
2904
|
+
}
|
|
2905
|
+
return ts.visitEachChild(node, visitor, context)
|
|
2906
|
+
}
|
|
2907
|
+
let normalized = ts.visitNode(sourceFile, visitor)
|
|
2908
|
+
const hookImports = normalized.statements.filter(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))
|
|
2909
|
+
const hookImport = hookImports[0]
|
|
2910
|
+
const bindings = hookImport?.importClause.namedBindings
|
|
2911
|
+
const imported = new Set(hookImports.flatMap(statement => statement.importClause.namedBindings.elements.map(entry => entry.name.text)))
|
|
2912
|
+
const missing = ["useEffect", "useState"].filter(name => !imported.has(name))
|
|
2913
|
+
if (!missing.length) return normalized
|
|
2914
|
+
for (const name of missing) if (normalized.statements.some(statement => !hookImports.includes(statement) && (statementDeclaresName(statement, name) || ts.isImportDeclaration(statement) && importDeclarationNames(statement).includes(name)))) throw sourceNodeError([...candidates.values()][0].declaration, sourceFile, `Navigator capability conditions conflict with local ${name}`)
|
|
2915
|
+
if (hookImport && bindings && ts.isNamedImports(bindings)) {
|
|
2916
|
+
const statements = normalized.statements.map(statement => statement === hookImport ? factory.updateImportDeclaration(statement, statement.modifiers, factory.updateImportClause(statement.importClause, false, statement.importClause.name, factory.updateNamedImports(bindings, [
|
|
2917
|
+
...bindings.elements,
|
|
2918
|
+
...missing.map(name => factory.createImportSpecifier(false, undefined, factory.createIdentifier(name)))
|
|
2919
|
+
])), statement.moduleSpecifier, statement.attributes) : statement)
|
|
2920
|
+
return factory.updateSourceFile(normalized, statements)
|
|
2921
|
+
}
|
|
2922
|
+
const declaration = factory.createImportDeclaration(undefined, factory.createImportClause(false, undefined, factory.createNamedImports(missing.map(name => factory.createImportSpecifier(false, undefined, factory.createIdentifier(name))))), factory.createStringLiteral("@kudzujs/core"))
|
|
2923
|
+
const statements = [...normalized.statements]
|
|
2924
|
+
statements.splice(statements.findLastIndex(statement => ts.isImportDeclaration(statement)) + 1, 0, declaration)
|
|
2925
|
+
return factory.updateSourceFile(normalized, statements)
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
function normalizeEffectAnimationFrameRefs(sourceFile, factory, context) {
|
|
2929
|
+
const frameCall = (node, name) => ts.isCallExpression(node) && (
|
|
2930
|
+
ts.isIdentifier(node.expression) && node.expression.text === name ||
|
|
2931
|
+
ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === "window" && node.expression.name.text === name
|
|
2932
|
+
)
|
|
2933
|
+
const unshadowedFrameCall = (node, owner) => {
|
|
2934
|
+
const name = ts.isIdentifier(node.expression) ? node.expression : ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) ? node.expression.expression : undefined
|
|
2935
|
+
return name && !isShadowedIdentifier(name, owner) && !sourceFile.statements.some(statement => statementDeclaresName(statement, name.text) || ts.isImportDeclaration(statement) && importDeclarationNames(statement).includes(name.text))
|
|
2936
|
+
}
|
|
2937
|
+
const currentAccess = (node, name) => ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === name && node.name.text === "current"
|
|
2938
|
+
const inside = (node, root) => {
|
|
2939
|
+
for (let current = node; current; current = current.parent) if (current === root) return true
|
|
2940
|
+
return false
|
|
2941
|
+
}
|
|
2942
|
+
const directOrGuarded = (statement, body, name, negated) => {
|
|
2943
|
+
if (statement.parent === body) return true
|
|
2944
|
+
let branch = statement
|
|
2945
|
+
if (ts.isBlock(statement.parent) && statement.parent.statements.length === 1) branch = statement.parent
|
|
2946
|
+
const conditional = branch.parent
|
|
2947
|
+
if (!ts.isIfStatement(conditional) || conditional.thenStatement !== branch || conditional.parent !== body || conditional.elseStatement) return false
|
|
2948
|
+
let condition = unwrapExpression(conditional.expression)
|
|
2949
|
+
const isNegated = ts.isPrefixUnaryExpression(condition) && condition.operator === ts.SyntaxKind.ExclamationToken
|
|
2950
|
+
if (isNegated) condition = unwrapExpression(condition.operand)
|
|
2951
|
+
return isNegated === negated && currentAccess(condition, name)
|
|
2952
|
+
}
|
|
2953
|
+
const hasUseRefImport = 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.propertyName && entry.name.text === "useRef"))
|
|
2954
|
+
const hasUseEffectImport = 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.propertyName && entry.name.text === "useEffect"))
|
|
2955
|
+
const replacements = new Set()
|
|
2956
|
+
const inspect = node => {
|
|
2957
|
+
if (!hasUseRefImport || !ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name) || !node.initializer || !ts.isCallExpression(node.initializer) || !ts.isIdentifier(node.initializer.expression) || node.initializer.expression.text !== "useRef" || isShadowedIdentifier(node.initializer.expression, sourceFile) || node.initializer.arguments.length !== 1 || !ts.isNumericLiteral(node.initializer.arguments[0]) || Number(node.initializer.arguments[0].text) !== 0) {
|
|
2958
|
+
ts.forEachChild(node, inspect)
|
|
2959
|
+
return
|
|
2960
|
+
}
|
|
2961
|
+
const owner = nearestFunction(node)
|
|
2962
|
+
if (!owner?.body || !ts.isBlock(owner.body)) return
|
|
2963
|
+
const accesses = []
|
|
2964
|
+
const collect = current => {
|
|
2965
|
+
if (currentAccess(current, node.name.text) && !isShadowedIdentifier(current.expression, owner.body)) accesses.push(current)
|
|
2966
|
+
ts.forEachChild(current, collect)
|
|
2967
|
+
}
|
|
2968
|
+
collect(owner.body)
|
|
2969
|
+
const frameAssignments = accesses.filter(access => ts.isBinaryExpression(access.parent) && unwrapExpression(access.parent.left) === access && access.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && frameCall(unwrapExpression(access.parent.right), "requestAnimationFrame") && unshadowedFrameCall(unwrapExpression(access.parent.right), owner))
|
|
2970
|
+
if (!frameAssignments.length) return
|
|
2971
|
+
const invalidReference = referenceIdentifiers(owner.body, node.name.text).find(reference => !ts.isPropertyAccessExpression(reference.parent) || reference.parent.expression !== reference || reference.parent.name.text !== "current")
|
|
2972
|
+
if (invalidReference) throw sourceNodeError(invalidReference, sourceFile, "Animation frame refs may only use direct .current reads and assignments")
|
|
2973
|
+
const statement = node.parent?.parent
|
|
2974
|
+
const topLevelOwner = owner.parent === sourceFile || ts.isVariableDeclaration(owner.parent) && owner.parent.parent?.parent?.parent === sourceFile
|
|
2975
|
+
if (!topLevelOwner || !ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const) || statement.declarationList.declarations.length !== 1 || statement.parent !== owner.body) throw sourceNodeError(node, sourceFile, "Animation frame refs must be one top-level component const")
|
|
2976
|
+
if (frameAssignments.length !== 1) throw sourceNodeError(node, sourceFile, "Animation frame refs require one direct ref.current = requestAnimationFrame(callback) assignment")
|
|
2977
|
+
const frame = unwrapExpression(frameAssignments[0].parent.right)
|
|
2978
|
+
const frameCallback = frame.arguments.length === 1 && ts.isIdentifier(unwrapExpression(frame.arguments[0])) ? unwrapExpression(frame.arguments[0]) : undefined
|
|
2979
|
+
const effectCalls = owner.body.statements.flatMap(statement => hasUseEffectImport && ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression) && ts.isIdentifier(statement.expression.expression) && statement.expression.expression.text === "useEffect" && !isShadowedIdentifier(statement.expression.expression, sourceFile) ? [statement.expression] : [])
|
|
2980
|
+
const effects = effectCalls.filter(effect => effect.arguments[0] && inside(frameAssignments[0], effect.arguments[0]))
|
|
2981
|
+
if (effects.length !== 1) throw sourceNodeError(node, sourceFile, "Animation frame refs must belong to one inline component effect")
|
|
2982
|
+
const effect = effects[0]
|
|
2983
|
+
const callback = effect.arguments[0]
|
|
2984
|
+
if (!(ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) || !ts.isBlock(callback.body)) throw sourceNodeError(callback, sourceFile, "Animation frame refs require one inline block-bodied effect")
|
|
2985
|
+
if (accesses.some(access => !inside(access, callback))) throw sourceNodeError(node, sourceFile, "Animation frame refs may only be used inside their owning effect")
|
|
2986
|
+
const callbacks = new Map()
|
|
2987
|
+
for (const statement of callback.body.statements) {
|
|
2988
|
+
if (ts.isVariableStatement(statement)) for (const declaration of statement.declarationList.declarations) {
|
|
2989
|
+
if (ts.isIdentifier(declaration.name) && declaration.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) callbacks.set(declaration.name.text, declaration.initializer)
|
|
2990
|
+
}
|
|
2991
|
+
if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) callbacks.set(statement.name.text, statement)
|
|
2992
|
+
}
|
|
2993
|
+
const frameOwner = nearestFunction(frameAssignments[0])
|
|
2994
|
+
const shadowedCallback = frameCallback && isShadowedIdentifier(frameCallback, callback.body)
|
|
2995
|
+
const update = frameCallback && !shadowedCallback && callbacks.get(frameCallback.text)
|
|
2996
|
+
if (!update) throw sourceNodeError(frame, sourceFile, "Animation frame refs require a direct local callback")
|
|
2997
|
+
const frameStatement = frameAssignments[0].parent.parent
|
|
2998
|
+
if (!frameOwner || ![...callbacks.values()].includes(frameOwner) || !ts.isBlock(frameOwner.body) || !ts.isExpressionStatement(frameStatement) || !directOrGuarded(frameStatement, frameOwner.body, node.name.text, true)) throw sourceNodeError(frameAssignments[0], sourceFile, "Animation frame requests must be assigned directly inside one local scheduler")
|
|
2999
|
+
const resetAssignments = accesses.filter(access => ts.isBinaryExpression(access.parent) && unwrapExpression(access.parent.left) === access && access.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isNumericLiteral(unwrapExpression(access.parent.right)) && Number(unwrapExpression(access.parent.right).text) === 0)
|
|
3000
|
+
const resetStatement = resetAssignments[0]?.parent.parent
|
|
3001
|
+
if (resetAssignments.length !== 1 || nearestFunction(resetAssignments[0]) !== update || !ts.isBlock(update.body) || !ts.isExpressionStatement(resetStatement) || resetStatement.parent !== update.body) throw sourceNodeError(update, sourceFile, "Animation frame callbacks must directly reset their ref to 0")
|
|
3002
|
+
const writes = accesses.filter(access => ts.isBinaryExpression(access.parent) && unwrapExpression(access.parent.left) === access && access.parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && access.parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment)
|
|
3003
|
+
if (writes.length !== 2) throw sourceNodeError(node, sourceFile, "Animation frame refs may only be assigned by their request and reset operations")
|
|
3004
|
+
const returns = effectReturns(callback)
|
|
3005
|
+
const cancellations = []
|
|
3006
|
+
const collectCancellations = current => {
|
|
3007
|
+
const argument = current.arguments?.[0] && unwrapExpression(current.arguments[0])
|
|
3008
|
+
if (frameCall(current, "cancelAnimationFrame") && unshadowedFrameCall(current, owner) && current.arguments.length === 1 && currentAccess(argument, node.name.text) && !isShadowedIdentifier(argument.expression, owner.body)) cancellations.push(current)
|
|
3009
|
+
ts.forEachChild(current, collectCancellations)
|
|
3010
|
+
}
|
|
3011
|
+
collectCancellations(callback.body)
|
|
3012
|
+
const cancellation = cancellations[0]
|
|
3013
|
+
const cleanup = cancellation && returns.cleanups.find(candidate => nearestFunction(cancellation) === candidate)
|
|
3014
|
+
const cancellationStatement = cancellation?.parent
|
|
3015
|
+
if (cancellations.length !== 1 || !cleanup || !ts.isBlock(cleanup.body) || !ts.isExpressionStatement(cancellationStatement) || !directOrGuarded(cancellationStatement, cleanup.body, node.name.text, false)) throw sourceNodeError(node, sourceFile, "Animation frame refs require direct cancellation in effect cleanup")
|
|
3016
|
+
replacements.add(node)
|
|
3017
|
+
}
|
|
3018
|
+
inspect(sourceFile)
|
|
3019
|
+
if (!replacements.size) return sourceFile
|
|
3020
|
+
const visitor = node => {
|
|
3021
|
+
if (replacements.has(node)) return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, factory.createObjectLiteralExpression([
|
|
3022
|
+
factory.createPropertyAssignment("current", factory.createNumericLiteral(0))
|
|
3023
|
+
]))
|
|
3024
|
+
return ts.visitEachChild(node, visitor, context)
|
|
3025
|
+
}
|
|
3026
|
+
return ts.visitNode(sourceFile, visitor)
|
|
3027
|
+
}
|
|
3028
|
+
|
|
2748
3029
|
function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, staticFiles, importedAssets, cssModules, base, clientImports, workerReferences) {
|
|
2749
3030
|
return context => sourceFile => {
|
|
2750
3031
|
const factory = context.factory
|
|
@@ -2757,8 +3038,14 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
2757
3038
|
ts.setParentRecursive(sourceFile, false)
|
|
2758
3039
|
sourceFile = normalizeClsxSyntax(sourceFile, factory, context)
|
|
2759
3040
|
ts.setParentRecursive(sourceFile, false)
|
|
3041
|
+
sourceFile = normalizeMediaQueryExternalStores(sourceFile, factory, context)
|
|
3042
|
+
ts.setParentRecursive(sourceFile, false)
|
|
2760
3043
|
sourceFile = normalizeReactMigrationSyntax(sourceFile, factory, context, importedCollections)
|
|
2761
3044
|
ts.setParentRecursive(sourceFile, false)
|
|
3045
|
+
sourceFile = normalizeNavigatorCapabilityConditions(sourceFile, factory, context)
|
|
3046
|
+
ts.setParentRecursive(sourceFile, false)
|
|
3047
|
+
sourceFile = normalizeEffectAnimationFrameRefs(sourceFile, factory, context)
|
|
3048
|
+
ts.setParentRecursive(sourceFile, false)
|
|
2762
3049
|
sourceFile = normalizeCustomHookTimerRefs(sourceFile, factory, context)
|
|
2763
3050
|
const customHookTimerStates = customHookTimerStatesBySource.get(sourceFile) ?? new Set()
|
|
2764
3051
|
ts.setParentRecursive(sourceFile, false)
|
|
@@ -2787,8 +3074,14 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
2787
3074
|
ts.setParentRecursive(imported, false)
|
|
2788
3075
|
imported = normalizeClsxSyntax(imported, factory, context)
|
|
2789
3076
|
ts.setParentRecursive(imported, false)
|
|
3077
|
+
imported = normalizeMediaQueryExternalStores(imported, factory, context)
|
|
3078
|
+
ts.setParentRecursive(imported, false)
|
|
2790
3079
|
imported = normalizeReactMigrationSyntax(imported, factory, context, importedSerializableCollectionNames(imported, target, sourceFiles, sourceIndex))
|
|
2791
3080
|
ts.setParentRecursive(imported, false)
|
|
3081
|
+
imported = normalizeNavigatorCapabilityConditions(imported, factory, context)
|
|
3082
|
+
ts.setParentRecursive(imported, false)
|
|
3083
|
+
imported = normalizeEffectAnimationFrameRefs(imported, factory, context)
|
|
3084
|
+
ts.setParentRecursive(imported, false)
|
|
2792
3085
|
imported = normalizeCustomHookTimerRefs(imported, factory, context)
|
|
2793
3086
|
importedTimerStates.set(target, customHookTimerStatesBySource.get(imported) ?? new Set())
|
|
2794
3087
|
ts.setParentRecursive(imported, false)
|
|
@@ -3938,6 +4231,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
3938
4231
|
const invalidCleanup = returns.cleanups.find(cleanup => cleanup.parameters.length || cleanup.asteriskToken)
|
|
3939
4232
|
if (invalidCleanup) effectFail(invalidCleanup, "useEffect() cleanup functions cannot declare parameters or be generators")
|
|
3940
4233
|
if (returns.cleanup && callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) effectFail(callback, "useEffect() async callbacks cannot return cleanup functions")
|
|
4234
|
+
validateEffectOwnedBrowserResources(callback, returns, effectFail)
|
|
3941
4235
|
const callbackSource = specializedEffect?.sourceFile ?? sourceFile
|
|
3942
4236
|
const callbackFile = callbackSource.fileName
|
|
3943
4237
|
const workerStart = workerReferences.length
|
|
@@ -5434,7 +5728,7 @@ function compileOptimizedEvent(expression, setters, factory) {
|
|
|
5434
5728
|
}
|
|
5435
5729
|
|
|
5436
5730
|
const nativeGlobals = new Set([
|
|
5437
|
-
"Array", "ArrayBuffer", "BigInt", "Blob", "Boolean", "Date", "Error", "Event", "FileReader", "FormData", "Infinity", "Intl", "JSON", "Map", "Math", "NaN", "Number", "Object", "Promise", "Proxy", "RangeError", "ReferenceError", "Reflect", "RegExp", "Set", "String", "Symbol", "TypeError", "URL", "URLSearchParams", "WeakMap", "WeakSet", "WebSocket", "Worker", "alert", "atob", "btoa", "clearInterval", "clearTimeout", "console", "crypto", "document", "fetch", "globalThis", "history", "isFinite", "isNaN", "localStorage", "location", "navigator", "parseFloat", "parseInt", "queueMicrotask", "requestAnimationFrame", "setInterval", "setTimeout", "structuredClone", "undefined", "window"
|
|
5731
|
+
"Array", "ArrayBuffer", "BigInt", "Blob", "Boolean", "Date", "Error", "Event", "FileReader", "FormData", "Infinity", "IntersectionObserver", "Intl", "JSON", "Map", "Math", "NaN", "Number", "Object", "Promise", "Proxy", "RangeError", "ReferenceError", "Reflect", "RegExp", "Set", "String", "Symbol", "TypeError", "URL", "URLSearchParams", "WeakMap", "WeakSet", "WebSocket", "Worker", "alert", "atob", "btoa", "cancelAnimationFrame", "clearInterval", "clearTimeout", "console", "crypto", "document", "fetch", "globalThis", "history", "isFinite", "isNaN", "localStorage", "location", "navigator", "parseFloat", "parseInt", "performance", "queueMicrotask", "requestAnimationFrame", "setInterval", "setTimeout", "structuredClone", "undefined", "window"
|
|
5438
5732
|
])
|
|
5439
5733
|
|
|
5440
5734
|
function rewriteEffectWorkers(callback, file, sourceFile, sourceFiles, workerReferences, factory, context) {
|
|
@@ -5837,6 +6131,30 @@ function effectReturns(callback) {
|
|
|
5837
6131
|
return { cleanup, cleanups, invalid }
|
|
5838
6132
|
}
|
|
5839
6133
|
|
|
6134
|
+
function validateEffectOwnedBrowserResources(callback, returns, fail) {
|
|
6135
|
+
const observers = []
|
|
6136
|
+
const frameAssignments = []
|
|
6137
|
+
const cancellations = new Set()
|
|
6138
|
+
const disconnected = new Set()
|
|
6139
|
+
const insideCleanup = node => returns.cleanups.some(cleanup => {
|
|
6140
|
+
for (let current = node; current; current = current.parent) if (current === cleanup) return true
|
|
6141
|
+
return false
|
|
6142
|
+
})
|
|
6143
|
+
const visit = node => {
|
|
6144
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isNewExpression(unwrapExpression(node.initializer)) && ts.isIdentifier(unwrapExpression(node.initializer).expression) && unwrapExpression(node.initializer).expression.text === "IntersectionObserver") observers.push(node)
|
|
6145
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(unwrapExpression(node.left)) && ts.isCallExpression(unwrapExpression(node.right)) && ts.isIdentifier(unwrapExpression(node.right).expression) && unwrapExpression(node.right).expression.text === "requestAnimationFrame") frameAssignments.push(node)
|
|
6146
|
+
if (insideCleanup(node) && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "cancelAnimationFrame" && node.arguments.length === 1 && ts.isIdentifier(unwrapExpression(node.arguments[0]))) cancellations.add(unwrapExpression(node.arguments[0]).text)
|
|
6147
|
+
if (insideCleanup(node) && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.name.text === "disconnect" && node.arguments.length === 0) disconnected.add(node.expression.expression.text)
|
|
6148
|
+
ts.forEachChild(node, visit)
|
|
6149
|
+
}
|
|
6150
|
+
visit(callback.body)
|
|
6151
|
+
for (const observer of observers) if (!disconnected.has(observer.name.text)) fail(observer, `IntersectionObserver effects must disconnect ${JSON.stringify(observer.name.text)} in cleanup`)
|
|
6152
|
+
for (const assignment of frameAssignments) {
|
|
6153
|
+
const name = unwrapExpression(assignment.left).text
|
|
6154
|
+
if (!cancellations.has(name)) fail(assignment, `Animation loop effects must cancel ${JSON.stringify(name)} in cleanup`)
|
|
6155
|
+
}
|
|
6156
|
+
}
|
|
6157
|
+
|
|
5840
6158
|
function printClientImports(entries, handlerPath) {
|
|
5841
6159
|
const unique = new Map(entries.map(entry => [`${entry.target}:${entry.kind}:${entry.imported ?? ""}:${entry.local}`, entry]))
|
|
5842
6160
|
const groups = Map.groupBy(unique.values(), entry => entry.target)
|
package/framework/core.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface RefObject<T> {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function useRef<T>(initialValue: null): RefObject<T>
|
|
23
|
+
export function useRef(initialValue: 0): { current: number }
|
|
24
|
+
export function useSyncExternalStore<T>(subscribe: (callback: () => void) => () => void, getSnapshot: () => T, getServerSnapshot: () => T): T
|
|
23
25
|
|
|
24
26
|
export interface Context<T> {
|
|
25
27
|
Provider: (props: { value: T; children?: unknown }) => unknown
|