@kudzujs/core 0.8.11 → 0.8.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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.11 - Stale-safe data migration.** Build-known query data moves to async pages, browser-only reads move to route effects, and superseded promise or fetch setters can no longer overwrite newer state. Read the [release notes](./RELEASES.md#0811---stale-safe-data-migration) or open the [release page](https://kudzujs.cloud/releases/0.8.11).
17
+ **Latest release: 0.8.13 - Browser capability migration.** Memos scroll spies, Excalidraw progressive sharing, and Cal.com responsive media queries compile through owned browser effects without React or general capability runtimes. Read the [release notes](./RELEASES.md#0813---browser-capability-migration) or open the [release page](https://kudzujs.cloud/releases/0.8.13).
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,61 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.8.13 - Browser capability migration
4
+
5
+ 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.
6
+
7
+ ### New in 0.8.13
8
+
9
+ - Memos-shaped scroll spies retain an effect-owned `useRef(0)` animation-frame handle with burst coalescing, exact cleanup, smooth navigation, and active-heading ARIA.
10
+ - Excalidraw-shaped `"share" in navigator` conditions emit false static fallback and mount Web Share DOM and handlers only in supporting browsers.
11
+ - Cal.com-shaped static `useSyncExternalStore` media queries emit desktop-first HTML and lower matching `change` subscriptions into owned effects.
12
+ - Direct Web Share and clipboard actions preserve application-owned success and failure state without package adapters.
13
+ - Static sibling routes remain complete zero-JavaScript HTML for every migration fixture.
14
+
15
+ ### Fixed in 0.8.13
16
+
17
+ - Build-time Node `navigator` availability can no longer permanently fold browser capability UI to the wrong branch.
18
+ - Optimized state-select text bindings now register the same direct state commit path as optimized attributes.
19
+ - Animation-frame refs reject aliases, shadowed bindings, inverted guards, indirect reset/cancellation, repeated scheduling, and missing cleanup with source locations.
20
+ - The complete suite passes 149/149 tests.
21
+
22
+ ### Boundary
23
+
24
+ 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.
25
+
26
+ ### Upgrade
27
+
28
+ ```bash
29
+ npm install @kudzujs/core@^0.8.13
30
+ ```
31
+
32
+ ## 0.8.12 - Static icon migration
33
+
34
+ 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.
35
+
36
+ ### New in 0.8.12
37
+
38
+ - Used icons move into relative TSX components with direct intrinsic SVG roots and familiar size, stroke, class, fill, ARIA, and title props.
39
+ - Meaningful icons retain explicit roles and titles, while decorative icons remain hidden from assistive technology.
40
+ - React-shaped SVG presentation attributes normalize to native output and complete icon routes ship zero JavaScript.
41
+ - Unreachable icon modules are excluded from compilation instead of becoming deploy artifacts.
42
+ - Public documentation records the migration recipe and its deliberate package boundary.
43
+
44
+ ### Fixed in 0.8.12
45
+
46
+ - Handler emission now follows references in the final route plan and HTML, removing evaluator modules left behind by build-folded component conditions while preserving list-expression evaluators.
47
+ - The complete suite passes 143/143 tests.
48
+
49
+ ### Boundary
50
+
51
+ This release does not execute `lucide-react`. Dynamic icon lookup, `createLucideIcon()`, package-owned factories, and a generic icon runtime remain unsupported source migration work.
52
+
53
+ ### Upgrade
54
+
55
+ ```bash
56
+ npm install @kudzujs/core@^0.8.12
57
+ ```
58
+
3
59
  ## 0.8.11 - Stale-safe data migration
4
60
 
5
61
  Kudzu 0.8.11 proves source migration paths for TanStack Query-shaped data and React Hook Form-shaped forms while strengthening dependency-effect stale-write isolation.
@@ -6,7 +6,11 @@ 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
+ 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.
12
+
13
+ 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
14
 
11
15
  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
16
 
@@ -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 }
@@ -145,7 +145,8 @@ export async function build({ quiet = false, minify = true } = {}) {
145
145
  runtimeParams: runtimeSchema?.params,
146
146
  ...(navigable ? { navigationAsset: navigationGroup.assetPath, applicationId: navigationGroup.applicationId, layoutId: navigationGroup.layoutId, routeId: applicationRoute } : {})
147
147
  }, props, module.layout)
148
- for (const url of result.handlerModules) renderedHandlerUrls.add(url)
148
+ const renderedOutput = `${JSON.stringify(result.plan)}\n${result.html}`
149
+ for (const url of result.handlerModules) if (renderedOutput.includes(JSON.stringify(url))) renderedHandlerUrls.add(url)
149
150
  if (navigationGroup) {
150
151
  navigationGroup.hasEffects ||= result.hasEffects
151
152
  navigationGroup.hasParams ||= result.hasParams
@@ -2744,6 +2745,287 @@ function normalizeCustomHookTimerRefs(sourceFile, factory, context) {
2744
2745
  return normalized
2745
2746
  }
2746
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
+
2747
3029
  function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, staticFiles, importedAssets, cssModules, base, clientImports, workerReferences) {
2748
3030
  return context => sourceFile => {
2749
3031
  const factory = context.factory
@@ -2756,8 +3038,14 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2756
3038
  ts.setParentRecursive(sourceFile, false)
2757
3039
  sourceFile = normalizeClsxSyntax(sourceFile, factory, context)
2758
3040
  ts.setParentRecursive(sourceFile, false)
3041
+ sourceFile = normalizeMediaQueryExternalStores(sourceFile, factory, context)
3042
+ ts.setParentRecursive(sourceFile, false)
2759
3043
  sourceFile = normalizeReactMigrationSyntax(sourceFile, factory, context, importedCollections)
2760
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)
2761
3049
  sourceFile = normalizeCustomHookTimerRefs(sourceFile, factory, context)
2762
3050
  const customHookTimerStates = customHookTimerStatesBySource.get(sourceFile) ?? new Set()
2763
3051
  ts.setParentRecursive(sourceFile, false)
@@ -2786,8 +3074,14 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2786
3074
  ts.setParentRecursive(imported, false)
2787
3075
  imported = normalizeClsxSyntax(imported, factory, context)
2788
3076
  ts.setParentRecursive(imported, false)
3077
+ imported = normalizeMediaQueryExternalStores(imported, factory, context)
3078
+ ts.setParentRecursive(imported, false)
2789
3079
  imported = normalizeReactMigrationSyntax(imported, factory, context, importedSerializableCollectionNames(imported, target, sourceFiles, sourceIndex))
2790
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)
2791
3085
  imported = normalizeCustomHookTimerRefs(imported, factory, context)
2792
3086
  importedTimerStates.set(target, customHookTimerStatesBySource.get(imported) ?? new Set())
2793
3087
  ts.setParentRecursive(imported, false)
@@ -5433,7 +5727,7 @@ function compileOptimizedEvent(expression, setters, factory) {
5433
5727
  }
5434
5728
 
5435
5729
  const nativeGlobals = new Set([
5436
- "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"
5730
+ "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", "cancelAnimationFrame", "clearInterval", "clearTimeout", "console", "crypto", "document", "fetch", "globalThis", "history", "isFinite", "isNaN", "localStorage", "location", "navigator", "parseFloat", "parseInt", "queueMicrotask", "requestAnimationFrame", "setInterval", "setTimeout", "structuredClone", "undefined", "window"
5437
5731
  ])
5438
5732
 
5439
5733
  function rewriteEffectWorkers(callback, file, sourceFile, sourceFiles, workerReferences, factory, context) {
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.8.11",
3
+ "version": "0.8.13",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",