@kudzujs/core 0.7.29 → 0.8.0

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
@@ -8,9 +8,9 @@ HTML-first TSX framework with synchronous state semantics and no virtual DOM.
8
8
 
9
9
  Kudzu compiles ordinary React-shaped TypeScript and TSX into complete static HTML, CSS, and only the route-specific ESM capabilities actually used. Static pages ship zero JavaScript. React, hydration, a VDOM, and a retained browser component tree are not part of the output.
10
10
 
11
- > Experimental `0.7.x`: the compiler API and supported TSX surface may change.
11
+ > Experimental `0.8.x`: the compiler API and supported TSX surface may change.
12
12
 
13
- **Latest release: 0.7.29 - Hermetic TypeScript checks.** Project and fixture typechecks no longer inherit unrelated ambient types or third-party declarations from ancestor `node_modules` directories. Read the [release notes](./RELEASES.md#0729---hermetic-typescript-checks) or open the [release page](https://kudzujs.cloud/releases/0.7.29).
13
+ **Latest release: 0.8.0 - URL-backed custom hooks.** Relative React custom hooks, reachable source graphs, imported structured calculations and static collections, event-only package ESM, and native SVG now compose across a fourteen-route FIRE migration without adding React or a hook runtime. Read the [release notes](./RELEASES.md#080---url-backed-custom-hooks) or open the [release page](https://kudzujs.cloud/releases/0.8.0).
14
14
 
15
15
  - [Documentation](https://kudzujs.cloud/docs)
16
16
  - [Installation guide](https://kudzujs.cloud/docs#install)
@@ -84,7 +84,9 @@ ordinary React-shaped TSX
84
84
  - Native document navigation is the default; static routes do not load a client runtime.
85
85
  - A named or aliased React Router `Link` with a static root-relative `to` erases to a base-aware native anchor; no router package or runtime is emitted.
86
86
  - A direct named or aliased React Router `useParams()` call on a `runtimeParams` bracket route reuses Kudzu's route-specific pathname reader.
87
- - Read-only React Router `useSearchParams()` with direct static `get("name")` locals lowers to nullable signals initialized by a minimal route-specific query reader.
87
+ - React Router `useSearchParams()` supports direct static `get("name")` locals and inline setter updaters, lowering reads and history writes to one route-specific query capability.
88
+ - Only TypeScript modules reachable from pages are compiled. Imported immutable direct maps can fold to static HTML, while direct fields from relative structured calculations reevaluate through route binding ESM.
89
+ - Package imports used directly inside JSX event callbacks are removed from build modules and retained only in bundled route handler ESM.
88
90
  - A named or aliased React Router `useNavigate()` top-level binding lowers direct nested-callback calls with safe static root-relative destinations to native `location.assign()` or `location.replace()` navigation.
89
91
  - Unsupported nearby patterns fail during the build with a source location and actionable boundary.
90
92
 
package/RELEASES.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.8.0 - URL-backed custom hooks
4
+
5
+ Kudzu 0.8.0 migrates practical relative React custom hooks that combine local state, effects, direct actions, writable URL search parameters, and guarded browser storage while preserving static HTML and route-specific capabilities.
6
+
7
+ ### New in 0.8.0
8
+
9
+ - Named or default zero-argument custom hooks imported from relative TypeScript modules can expose direct shorthand `useState` value/setter pairs and callbacks that capture those states.
10
+ - React Router `useSearchParams()` accepts a top-level `[params, setParams]` tuple. Direct browser callbacks may pass one synchronous inline updater and optionally exactly `{ replace: true }`.
11
+ - Query writes use native `URLSearchParams` and History APIs, immediately recommit affected route signals, and follow browser `popstate` without an SPA router.
12
+ - Custom-hook mount and dependency effects can restore validated values from `localStorage`, persist subsequent state, and keep deterministic build-time fallbacks.
13
+ - Page entries now define the compiled TypeScript graph, so unreachable migration modules no longer block a build while reachable invalid imports retain diagnostics.
14
+ - Direct maps over imported immutable JSON-safe arrays fold to static HTML; relative calculation helpers can drive direct reactive result fields through route binding ESM.
15
+ - Package imports referenced directly inside JSX event handlers erase from build modules and bundle only into route handlers, enabling ExcelJS export without package execution during rendering.
16
+ - FIRE migration validation covers all fourteen routes, production Tailwind/Inter assets, URL and storage restoration, reset, presets, native SVG charts, Quiz and keyed Debt flows, and a real Excel workbook buffer.
17
+ - Static sibling routes continue to ship no JavaScript; React, React Router, hydration, a VDOM, and a browser hook dispatcher remain absent.
18
+
19
+ ### Boundary
20
+
21
+ Custom hooks must be synchronous zero-argument relative imports with one final direct shorthand object return. Caller destructuring cannot use aliases, defaults, or rest. Writable search parameters require direct inline updater callbacks. Relative calculation results require direct static field reads. Package imports must be referenced directly inside intrinsic JSX event callbacks. Dynamic query reads, direct-value setters, arbitrary options, mutable value refs, generic reactive objects, calculated collection fields, helper-indirect package use, and callback graphs with private captures remain unsupported.
22
+
23
+ ### Upgrade
24
+
25
+ ```bash
26
+ npm install @kudzujs/core@^0.8.0
27
+ ```
28
+
29
+ ## 0.7.30 - Reactive number formatting
30
+
31
+ Kudzu 0.7.30 supports fixed-locale `Intl.NumberFormat` display chains in reactive JSX locals, restoring conventional comma-formatted controlled inputs while preserving narrow render-call validation.
32
+
33
+ ### New in 0.7.30
34
+
35
+ - Reactive text and attributes accept `new Intl.NumberFormat("literal").format(Math.round(expression))` over supported state-derived primitive expressions.
36
+ - Initial HTML is formatted during the build and existing binding ESM reevaluates the same native expression after state changes.
37
+ - No collection selector opcode, shared formatting runtime, hydration, or browser component instance is added.
38
+ - Locale values must be direct string literals; dynamic locales and options remain unsupported.
39
+ - Optional calls, aliases, shadowed `Intl` or `Math`, arbitrary constructors, and other render calls retain source diagnostics.
40
+ - A migration-derived FIRE `CurrencyInput` restores `100,000` annual and `8,333` monthly display while preserving annualized state updates.
41
+ - The complete suite passes 123/123 tests, including emitted evaluator and invalid dynamic-locale coverage.
42
+
43
+ ### Boundary
44
+
45
+ This is display formatting only. The accepted expression requires one static locale and exactly `Math.round(expression)` as the format argument. Collection selectors and effect dependency expressions keep their existing pure expression language.
46
+
47
+ ### Upgrade
48
+
49
+ ```bash
50
+ npm install @kudzujs/core@^0.7.30
51
+ ```
52
+
3
53
  ## 0.7.29 - Hermetic TypeScript checks
4
54
 
5
55
  Kudzu 0.7.29 makes project and migration-fixture typechecks independent of unrelated ambient types and package declarations installed in ancestor directories.
@@ -2,19 +2,21 @@
2
2
 
3
3
  Kudzu specializes ordinary common React-shaped TSX so migrations need minimal source restructuring. Declarative components, collection pipelines, conditions, hooks, and handlers should be lowered at build time rather than replaced with application-owned imperative DOM code. This principle applies across migrations and is not Stay-specific; it does not imply a React package, VDOM, hydration, or ecosystem runtime.
4
4
 
5
- Migration source may retain conventional `react` imports for supported named or aliased hooks, direct members such as `React.useState`, same-file `memo`, inline `useCallback`, direct-state expression or analyzable collection-pipeline `useMemo`, direct intrinsic `forwardRef`, top-level `const` identifiers initialized by `useId()`, and default, namespace, or named `Fragment`. Kudzu's JSX declarations accept ReactNode-shaped component returns and contextually type common intrinsic DOM events, so strict React component props do not need migration-only `unknown` or explicit event annotations. `forwardRef()` accepts one inline synchronous `(props, ref)` function and requires the object ref exactly once on its direct intrinsic root; the compiler removes `ref` from props/rest and erases the wrapper. `useId()` becomes a deterministic build-time HTML ID and emits no browser capability; keyed rows reject it because cloned row templates cannot safely duplicate HTML IDs. Collection memos may start from local array state or a named relative import of an exported JSON-safe `const` array, including type-only `as const` and `satisfies` wrappers, and may read direct local state declared in their dependency array. `build.mjs` canonicalizes those forms and rewrites module references to `@kudzujs/core` before build-time evaluation. Memo wrappers are erased or inlined into existing bindings and keyed-list selectors because no browser component rerender or memo cache exists. Static routes remain JavaScript-free and emitted modules are checked for surviving React imports.
5
+ Migration source may retain conventional `react` imports for supported named or aliased hooks, direct members such as `React.useState`, same-file `memo`, inline `useCallback`, direct-state expression or analyzable collection-pipeline `useMemo`, direct intrinsic `forwardRef`, top-level `const` identifiers initialized by `useId()`, and default, namespace, or named `Fragment`. A named or default zero-argument custom hook imported from a relative TypeScript module may return direct shorthand state/setter pairs and callbacks that capture those states; callers use one top-level `const` object destructuring without aliases, defaults, or rest. Kudzu's JSX declarations accept ReactNode-shaped component returns and contextually type common intrinsic DOM events, so strict React component props do not need migration-only `unknown` or explicit event annotations. `forwardRef()` accepts one inline synchronous `(props, ref)` function and requires the object ref exactly once on its direct intrinsic root; the compiler removes `ref` from props/rest and erases the wrapper. `useId()` becomes a deterministic build-time HTML ID and emits no browser capability; keyed rows reject it because cloned row templates cannot safely duplicate HTML IDs. Collection memos may start from local array state or a named relative import of an exported JSON-safe `const` array, including type-only `as const` and `satisfies` wrappers, and may read direct local state declared in their dependency array. `build.mjs` canonicalizes those forms and rewrites module references to `@kudzujs/core` before build-time evaluation. Memo wrappers are erased or inlined into existing bindings and keyed-list selectors because no browser component rerender or memo cache exists. Static routes remain JavaScript-free and emitted modules are checked for surviving React imports.
6
+
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. 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.
6
8
 
7
9
  A named or aliased `Link` import from `react-router-dom` may render directly with one static root-relative `to` plus native anchor props. The compiler prefixes the configured `base`, changes the element to `<a href>`, and erases the import, so native navigation remains the default and configured navigation groups see an ordinary eligible anchor. Dynamic or relative destinations, `NavLink`, router-only props, spreads, default/namespace imports, and non-JSX uses fail with source diagnostics. No React Router package code or router runtime is emitted.
8
10
 
9
11
  A named or aliased React Router `useParams` import may also be called directly without runtime arguments on a bracket route exporting `runtimeParams = true`. The compiler redirects that local binding to `@kudzujs/core`, preserving one optional TypeScript type argument, and reuses the existing route-specific pathname matcher. Indirect calls and other router hooks remain unsupported. Build-known `getStaticPaths()` routes use page props instead because no browser pathname capability is needed.
10
12
 
11
- A named or aliased read-only React Router `useSearchParams` import may initialize one top-level `const [params]` binding. Each top-level `const value = params.get("literal")` lowers to one cached nullable `useSearchParam()` signal. The existing parameter asset initializes those signals with native `URLSearchParams.get()` semantics before route effects mount, including during configured same-document navigation. Missing keys remain `null`, direct text renders blank, and nullable attributes are removed. Setter destructuring, dynamic names, other methods, aliases, wrapped reads, and layout ownership are rejected. Routes without query reads do not emit this branch or a parameter asset.
13
+ A named or aliased React Router `useSearchParams` import may initialize one top-level `const [params]` or `const [params, setParams]` binding. Each top-level `const value = params.get("literal")` lowers to one cached nullable `useSearchParam()` signal. Direct setter calls inside nested browser callbacks accept one synchronous inline updater over native `URLSearchParams`; no options pushes history and exactly `{ replace: true }` replaces it. The route parameter asset recommits changed query signals and follows `popstate`. Missing keys remain `null`, direct text renders blank, and nullable attributes are removed. Dynamic names, direct-value setters, other methods on the outer params object, aliases, wrapped reads, and layout ownership are rejected. Routes without query reads or writes do not emit this branch or a parameter asset.
12
14
 
13
15
  A named or aliased React Router `useNavigate` import may initialize one top-level `const` identifier. A direct call from a nested browser callback with one safe static root-relative string lowers to `location.assign()` after applying `base`; exactly `{ replace: true }` lowers to `location.replace()`. This deliberately performs native document navigation even when enhanced navigation is configured. Dynamic or relative destinations, render-time calls, aliases passed as values, and options such as `state`, `relative`, or `preventScrollReset` are rejected. Routes without an actual navigation handler emit no browser JavaScript.
14
16
 
15
17
  Direct `clsx` calls over literal strings, numbers, arrays, object conditions, and conditional expressions are similarly lowered to ordinary concatenation and conditional expressions. The package import is erased, and dynamic classes continue through the existing binding compiler without serializing or shipping the `clsx` function.
16
18
 
17
- Repeated ordinary same-file and relative-imported child components execute independently at build time, so each `useState` call receives a distinct concrete state ID while shared native handler modules retain per-element state maps and captures. A direct JSON-safe primitive parent state passed to a destructured child prop remains the same signal for child DOM bindings and effect dependencies; repeated calls own independent effect records, and conditional removal cleans up before remount recreates the effect. Reactive text and attributes may reference recursively chained top-level immutable locals derived through supported pure primitive expressions from direct state; the compiler substitutes those expressions into the existing binding evaluator and subscribes every source state. Multiple direct primitive dependencies share the existing commit batching path: every value is compared with `Object.is`, and one or more same-turn changes cause one cleanup and rerun. A top-level immutable local derived through a supported pure primitive expression from direct state may also be an effect dependency: source state commits schedule evaluation, the derived result is compared with `Object.is`, and the expression is substituted into setup and cleanup handlers. Effect setup and directly returned cleanup callbacks may each resolve one top-level simple `const` function in the same component; those functions are substituted into the existing handler graph rather than retained in a browser registry. Reactive conditional descriptors own state created by their direct branch: initial visible output reuses the rendered template IDs, removal deletes those slots, and remount recreates them from serialized initial values. Static sibling routes and branches without local state add no ownership metadata, component function, hook dispatcher, or rerender loop.
19
+ Repeated ordinary same-file and relative-imported child components execute independently at build time, so each `useState` call receives a distinct concrete state ID while shared native handler modules retain per-element state maps and captures. A direct JSON-safe primitive parent state passed to a destructured child prop remains the same signal for child DOM bindings and effect dependencies; repeated calls own independent effect records, and conditional removal cleans up before remount recreates the effect. Reactive text and attributes may reference recursively chained top-level immutable locals derived through supported pure primitive expressions from direct state; the compiler substitutes those expressions into the existing binding evaluator and subscribes every source state. Fixed-locale `new Intl.NumberFormat("literal").format(Math.round(expression))` display chains reuse that binding ESM, while dynamic locales and options remain unsupported. Multiple direct primitive dependencies share the existing commit batching path: every value is compared with `Object.is`, and one or more same-turn changes cause one cleanup and rerun. A top-level immutable local derived through a supported pure primitive expression from direct state may also be an effect dependency: source state commits schedule evaluation, the derived result is compared with `Object.is`, and the expression is substituted into setup and cleanup handlers. Effect setup and directly returned cleanup callbacks may each resolve one top-level simple `const` function in the same component; those functions are substituted into the existing handler graph rather than retained in a browser registry. Reactive conditional descriptors own state created by their direct branch: initial visible output reuses the rendered template IDs, removal deletes those slots, and remount recreates them from serialized initial values. Static sibling routes and branches without local state add no ownership metadata, component function, hook dispatcher, or rerender loop.
18
20
 
19
21
  Reduced Zustand migration stores lower to one ordinary layout-lifetime state slot. The compiler accepts one exported `create(set => ({ data, ...actions }))` store with one serializable data property, direct property selectors, and synchronous capture-free actions using one-argument merge-form `set`; selected actions reuse the reducer-style functional update compiler, so same-turn calls observe current logical state and DOM writes still batch. The shared layout must initialize the store before route consumers, outside keyed rows. No Zustand import, store subscription runtime, React hook, or generic external-store capability is emitted.
20
22
 
@@ -40,12 +40,16 @@ export async function build({ quiet = false, minify = true } = {}) {
40
40
  await mkdir(outputDirectory, { recursive: true })
41
41
 
42
42
  const projectFiles = await walk(sourceDirectory)
43
- const sourceFiles = projectFiles.filter(file => /\.(?:ts|tsx)$/.test(file) && !file.endsWith(".d.ts")).sort()
43
+ const allSourceFiles = projectFiles.filter(file => /\.(?:ts|tsx)$/.test(file) && !file.endsWith(".d.ts")).sort()
44
44
  const configuredStyleSources = new Set(configuredStyles.sources.map(style => style.source))
45
45
  const discoveredCssFiles = projectFiles.filter(file => file.toLowerCase().endsWith(".css") && !configuredStyleSources.has(file)).sort()
46
- if (!sourceFiles.length) throw new Error("No TypeScript files found in src/")
46
+ if (!allSourceFiles.length) throw new Error("No TypeScript files found in src/")
47
+ const allSourceFileSet = new Set(allSourceFiles)
48
+ const sourceIndex = new Map(await Promise.all(allSourceFiles.map(async file => [file, await readFile(file, "utf8")])))
49
+ const pageFiles = allSourceFiles.filter(file => file.startsWith(`${pagesDirectory}${sep}`) && file.endsWith(".tsx"))
50
+ if (!pageFiles.length) throw new Error("No pages found in src/pages/")
51
+ const sourceFiles = reachableSourceFiles(pageFiles, allSourceFileSet, sourceIndex)
47
52
  const sourceFileSet = new Set(sourceFiles)
48
- const sourceIndex = new Map(await Promise.all(sourceFiles.map(async file => [file, await readFile(file, "utf8")])))
49
53
  const staticFiles = await safeStaticFiles(projectFiles)
50
54
  const cssFiles = orderSourceStyles(discoveredCssFiles, sourceFiles, sourceIndex, staticFiles)
51
55
  const importedAssets = new Set()
@@ -59,9 +63,6 @@ export async function build({ quiet = false, minify = true } = {}) {
59
63
  if (handlerModule) handlerModules.push(handlerModule)
60
64
  }
61
65
 
62
- const pageFiles = sourceFiles.filter(file => file.startsWith(`${pagesDirectory}${sep}`) && file.endsWith(".tsx"))
63
- if (!pageFiles.length) throw new Error("No pages found in src/pages/")
64
-
65
66
  let behaviorCount = 0
66
67
  let regularBehaviorCount = 0
67
68
  let bindingCount = 0
@@ -153,7 +154,7 @@ export async function build({ quiet = false, minify = true } = {}) {
153
154
  const usesDependencyRuntime = !navigable && hasDependencies && !result.plan.effects.some(effect => effect.owner) && !result.hasBindings && !result.hasLists && !result.plan.events.some(event => event.native)
154
155
  pageEntries.push({ route, html: result.html, usesDependencyRuntime })
155
156
  plans.push({ route: routePath, ...result.plan })
156
- if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params, searchParams: result.plan.searchParams, usesDependencyRuntime, navigable })
157
+ if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params, searchParams: result.plan.searchParams, searchParamsWritable: result.plan.searchParamsWritable, usesDependencyRuntime, navigable })
157
158
  if (result.hasEffects) effectEntries.push({ path: effectPath, effects: runtimeEffects(result.plan.effects, navigable), paramPath: result.hasParams ? paramPath : undefined, usesDependencyRuntime, navigable })
158
159
  if (result.plan.events.some(event => event.native)) nativeEntries.push({
159
160
  path: nativePath,
@@ -386,7 +387,7 @@ export async function build({ quiet = false, minify = true } = {}) {
386
387
  for (const entry of paramEntries) {
387
388
  const output = join(assetsDirectory, entry.path)
388
389
  await mkdir(dirname(output), { recursive: true })
389
- await writeJavaScript(output, printParamEntry(entry.schema, entry.params, entry.searchParams, output, assetsDirectory, base, runtimeName(entry.usesDependencyRuntime), entry.navigable), minify)
390
+ await writeJavaScript(output, printParamEntry(entry.schema, entry.params, entry.searchParams, entry.searchParamsWritable, output, assetsDirectory, base, runtimeName(entry.usesDependencyRuntime), entry.navigable), minify)
390
391
  }
391
392
  for (const entry of effectEntries) {
392
393
  const output = join(assetsDirectory, entry.path)
@@ -403,7 +404,7 @@ export async function build({ quiet = false, minify = true } = {}) {
403
404
  await mkdir(resolve(output, ".."), { recursive: true })
404
405
  await writeJavaScript(output, await compileClientModule(file, sourceFileSet, staticFiles, importedAssets, cssModules, base), minify)
405
406
  }
406
- if (clientModules.length) {
407
+ if (clientModules.length || emittedHandlerModules.some(module => module.hasPackageImports)) {
407
408
  await bundle({
408
409
  entryPoints: emittedHandlerModules.map(module => join(assetsDirectory, module.path)),
409
410
  outbase: join(assetsDirectory, "handlers"),
@@ -1406,9 +1407,10 @@ async function invokeCleanup() {
1406
1407
  }${disposal}`
1407
1408
  }
1408
1409
 
1409
- function printParamEntry(schema, params, searchParams, output, assetsDirectory, base, runtimeName, navigable) {
1410
- const signature = searchParams.length ? "pathname, search" : "pathname"
1411
- const prefix = navigable ? `export function initializeParams(${signature}) {\n` : `${schema ? "let pathname = location.pathname\n" : ""}${searchParams.length ? "let search = location.search\n" : ""}`
1410
+ function printParamEntry(schema, params, searchParams, searchParamsWritable, output, assetsDirectory, base, runtimeName, navigable) {
1411
+ const hasSearch = searchParams.length || searchParamsWritable
1412
+ const signature = hasSearch ? "pathname, search" : "pathname"
1413
+ const prefix = navigable ? `export function initializeParams(${signature}) {\n${searchParamsWritable ? "globalThis.__kSetSearchParams = setSearchParams\n" : ""}` : `${schema ? "let pathname = location.pathname\n" : ""}${hasSearch ? "let search = location.search\n" : ""}`
1412
1414
  const suffix = navigable ? "\n}" : ""
1413
1415
  const pathname = schema ? `const base = ${inlineJson(browserPath(base).slice(1).split("/").filter(Boolean).map(segment => decodeURIComponent(segment)))}
1414
1416
  const schema = ${inlineJson(schema.segments)}
@@ -1443,15 +1445,35 @@ function decodeSegment(raw, param) {
1443
1445
  return value
1444
1446
  }
1445
1447
  ` : ""
1446
- const query = searchParams.length ? `const query = new URLSearchParams(search)
1448
+ const searchInitializer = searchParamsWritable && searchParams.length ? `function initializeSearch(search) {
1449
+ const query = new URLSearchParams(search)
1450
+ for (const param of ${inlineJson(searchParams)}) {
1451
+ const value = query.get(param.name)
1452
+ browserState.set(param.id, value)
1453
+ commitDom(param.id, value)
1454
+ }
1455
+ }
1456
+ ` : ""
1457
+ const query = searchParams.length ? searchParamsWritable ? "initializeSearch(search)\n" : `const query = new URLSearchParams(search)
1447
1458
  for (const param of ${inlineJson(searchParams)}) {
1448
1459
  const value = query.get(param.name)
1449
1460
  browserState.set(param.id, value)
1450
1461
  commitDom(param.id, value)
1451
1462
  }
1452
1463
  ` : ""
1464
+ const writer = searchParamsWritable ? `
1465
+ function setSearchParams(update, replace) {
1466
+ const next = update(new URLSearchParams(location.search))
1467
+ if (!(next instanceof URLSearchParams)) throw new Error("React Router search parameter updater must return URLSearchParams")
1468
+ const url = new URL(location.href)
1469
+ url.search = next.toString()
1470
+ history[replace ? "replaceState" : "pushState"](null, "", url)
1471
+ ${searchParams.length ? "initializeSearch(location.search)" : ""}
1472
+ }
1473
+ ${navigable ? "" : `globalThis.__kSetSearchParams = setSearchParams
1474
+ addEventListener("popstate", () => ${searchParams.length ? "initializeSearch(location.search)" : "undefined"})`}` : ""
1453
1475
  return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}
1454
- ${prefix}${pathname}${query}${suffix}`
1476
+ ${searchInitializer}${prefix}${pathname}${query}${suffix}${writer}`
1455
1477
  }
1456
1478
 
1457
1479
  function hasCaptureType(value, type) {
@@ -1758,7 +1780,7 @@ async function compile(file, sourceFiles, sourceIndex, staticFiles, importedAsse
1758
1780
  if (!nativeHandlers.length && !effectHandlers.length && !reactiveBindings.length && !listExpressions.length) return undefined
1759
1781
  const callbacks = [...nativeHandlers, ...effectHandlers]
1760
1782
  const moduleSource = [
1761
- printClientImports(callbacks.flatMap(handler => handler.imports), handlerPath),
1783
+ printClientImports([...callbacks, ...reactiveBindings].flatMap(handler => handler.imports ?? []), handlerPath),
1762
1784
  ...callbacks.map(handler => printNativeHandler(handler)),
1763
1785
  ...reactiveBindings.map(entry => printReactiveBinding(entry)),
1764
1786
  ...listExpressions.map(entry => printListExpression(entry))
@@ -1769,7 +1791,7 @@ async function compile(file, sourceFiles, sourceIndex, staticFiles, importedAsse
1769
1791
  })
1770
1792
  const moduleErrors = moduleResult.diagnostics?.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) ?? []
1771
1793
  if (moduleErrors.length) throw new Error(moduleErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
1772
- return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, hasEffects: effectHandlers.length > 0, clientImports: [...clientImports] }
1794
+ return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, hasEffects: effectHandlers.length > 0, clientImports: [...clientImports], hasPackageImports: [...callbacks, ...reactiveBindings].some(entry => entry.imports?.some(import_ => import_.package)) }
1773
1795
  }
1774
1796
 
1775
1797
  function emittedPackageReference(source, file, packages) {
@@ -1784,6 +1806,30 @@ function emittedPackageReference(source, file, packages) {
1784
1806
  return found
1785
1807
  }
1786
1808
 
1809
+ function reachableSourceFiles(entries, sourceFiles, sourceIndex) {
1810
+ const reachable = new Set()
1811
+ const queue = [...entries]
1812
+ while (queue.length) {
1813
+ const file = queue.pop()
1814
+ if (reachable.has(file)) continue
1815
+ reachable.add(file)
1816
+ const sourceFile = parseSourceFile(file, sourceIndex.get(file))
1817
+ const visit = node => {
1818
+ const specifier = (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && runtimeModuleReference(node) && node.moduleSpecifier
1819
+ if (specifier && ts.isStringLiteral(specifier) && specifier.text.startsWith(".") && !isStaticImport(specifier.text)) {
1820
+ try { queue.push(resolveSourceImport(file, specifier.text, sourceFiles)) } catch {}
1821
+ }
1822
+ const worker = relativeWorkerCandidate(node, sourceFile)
1823
+ if (worker && ts.isStringLiteral(worker.url.arguments[0]) && worker.url.arguments[0].text.endsWith(".worker.ts")) {
1824
+ try { queue.push(resolveSourceImport(file, worker.url.arguments[0].text, sourceFiles)) } catch {}
1825
+ }
1826
+ ts.forEachChild(node, visit)
1827
+ }
1828
+ visit(sourceFile)
1829
+ }
1830
+ return [...reachable].sort()
1831
+ }
1832
+
1787
1833
  function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1788
1834
  const links = new Set()
1789
1835
  const params = new Set()
@@ -1814,8 +1860,11 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1814
1860
 
1815
1861
  let searchHelper = "__kUseSearchParam"
1816
1862
  while (sourceFile.text.includes(searchHelper)) searchHelper += "_"
1863
+ let searchWriterHelper = "__kUseSearchParamsWriter"
1864
+ while (sourceFile.text.includes(searchWriterHelper)) searchWriterHelper += "_"
1817
1865
  const searchDeclarations = new Set()
1818
1866
  const searchReads = new Map()
1867
+ const searchWrites = new Map()
1819
1868
  const searchObjects = []
1820
1869
  const collectSearchHooks = node => {
1821
1870
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && searchHooks.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) {
@@ -1823,29 +1872,31 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1823
1872
  const statement = declaration?.parent?.parent
1824
1873
  const owner = nearestFunction(node)
1825
1874
  const first = ts.isVariableDeclaration(declaration) && ts.isArrayBindingPattern(declaration.name) ? declaration.name.elements[0] : undefined
1826
- if (node.questionDotToken || node.arguments.length || node.typeArguments?.length || !ts.isVariableDeclaration(declaration) || declaration.initializer !== node || !isLocalConst(declaration) || !owner || statement?.parent !== owner.body || !ts.isBindingElement(first) || !ts.isIdentifier(first.name) || declaration.name.elements.length !== 1) {
1827
- throw sourceNodeError(node, sourceFile, "React Router useSearchParams must use one top-level const [params] = useSearchParams() without a setter")
1875
+ const second = ts.isVariableDeclaration(declaration) && ts.isArrayBindingPattern(declaration.name) ? declaration.name.elements[1] : undefined
1876
+ if (node.questionDotToken || node.arguments.length || node.typeArguments?.length || !ts.isVariableDeclaration(declaration) || declaration.initializer !== node || !isLocalConst(declaration) || !owner || statement?.parent !== owner.body || !ts.isBindingElement(first) || !ts.isIdentifier(first.name) || declaration.name.elements.length > 2 || second && (!ts.isBindingElement(second) || !ts.isIdentifier(second.name))) {
1877
+ throw sourceNodeError(node, sourceFile, "React Router useSearchParams must initialize one top-level const [params] or [params, setParams] binding")
1828
1878
  }
1829
- const entry = { name: first.name.text, declaration, statement, owner }
1879
+ const entry = { name: first.name.text, setter: second?.name.text, declaration, statement, owner }
1830
1880
  searchDeclarations.add(declaration)
1831
1881
  searchObjects.push(entry)
1832
1882
  }
1833
1883
  ts.forEachChild(node, collectSearchHooks)
1834
1884
  }
1835
1885
  collectSearchHooks(sourceFile)
1836
- const localBindingShadowed = (node, entry) => {
1886
+ const localBindingShadowed = (node, entry, name = entry.name) => {
1837
1887
  for (let current = node.parent; current && current !== entry.owner; current = current.parent) {
1838
- if (isFunctionLike(current) && (current.parameters.some(parameter => bindingNames(parameter.name).includes(entry.name)) || functionVarDeclaresName(current, entry.name))) return true
1839
- if (ts.isBlock(current) && current.statements.some(statement => statement !== entry.statement && statementDeclaresName(statement, entry.name))) return true
1840
- if (ts.isCaseBlock(current) && current.clauses.some(clause => clause.statements.some(statement => statement !== entry.statement && statementDeclaresName(statement, entry.name)))) return true
1841
- if (ts.isCatchClause(current) && current.variableDeclaration && bindingNames(current.variableDeclaration.name).includes(entry.name)) return true
1842
- if ((ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current)) && loopDeclaresName(current, entry.name)) return true
1888
+ if (isFunctionLike(current) && (current.parameters.some(parameter => bindingNames(parameter.name).includes(name)) || functionVarDeclaresName(current, name))) return true
1889
+ if (ts.isBlock(current) && current.statements.some(statement => statement !== entry.statement && statementDeclaresName(statement, name))) return true
1890
+ if (ts.isCaseBlock(current) && current.clauses.some(clause => clause.statements.some(statement => statement !== entry.statement && statementDeclaresName(statement, name)))) return true
1891
+ if (ts.isCatchClause(current) && current.variableDeclaration && bindingNames(current.variableDeclaration.name).includes(name)) return true
1892
+ if ((ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current)) && loopDeclaresName(current, name)) return true
1843
1893
  }
1844
1894
  return false
1845
1895
  }
1846
1896
  for (const entry of searchObjects) {
1847
1897
  const collectReads = node => {
1848
1898
  if (ts.isIdentifier(node) && node.text === entry.name && isReferenceIdentifier(node) && !localBindingShadowed(node, entry)) {
1899
+ if (entry.setter && ts.isCallExpression(node.parent) && node.parent.arguments.includes(node) && ts.isIdentifier(node.parent.expression) && node.parent.expression.text === entry.setter) return
1849
1900
  const property = node.parent
1850
1901
  const call = property?.parent
1851
1902
  const declaration = call?.parent
@@ -1862,6 +1913,28 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1862
1913
  ts.forEachChild(node, collectReads)
1863
1914
  }
1864
1915
  collectReads(entry.owner.body)
1916
+ if (!entry.setter) continue
1917
+ const collectWrites = node => {
1918
+ if (ts.isIdentifier(node) && node.text === entry.setter && isReferenceIdentifier(node) && !localBindingShadowed(node, entry, entry.setter)) {
1919
+ const call = node.parent
1920
+ if (!ts.isCallExpression(call) || call.expression !== node || call.questionDotToken || call.typeArguments?.length || nearestFunction(call) === entry.owner) throw sourceNodeError(node, sourceFile, "React Router search parameter setters may only be called directly from a nested browser callback")
1921
+ if (call.arguments.length < 1 || call.arguments.length > 2) throw sourceNodeError(call, sourceFile, "React Router search parameter setters require one inline updater and optional { replace: true }")
1922
+ const updater = unwrapExpression(call.arguments[0])
1923
+ if ((!ts.isArrowFunction(updater) && !ts.isFunctionExpression(updater)) || updater.asteriskToken || updater.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || updater.parameters.length !== 1 || !ts.isIdentifier(updater.parameters[0].name)) throw sourceNodeError(call.arguments[0], sourceFile, "React Router search parameter setters require one synchronous inline updater with one identifier parameter")
1924
+ let replace = false
1925
+ if (call.arguments.length === 2) {
1926
+ const options = unwrapExpression(call.arguments[1])
1927
+ const property = ts.isObjectLiteralExpression(options) && options.properties.length === 1 ? options.properties[0] : undefined
1928
+ const name = property && ts.isPropertyAssignment(property) && !ts.isComputedPropertyName(property.name) && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) ? property.name.text : undefined
1929
+ if (name !== "replace" || property.initializer.kind !== ts.SyntaxKind.TrueKeyword) throw sourceNodeError(call.arguments[1], sourceFile, "React Router search parameter setters only support exactly { replace: true } as a second argument")
1930
+ replace = true
1931
+ }
1932
+ searchWrites.set(call, { updater, replace })
1933
+ return
1934
+ }
1935
+ ts.forEachChild(node, collectWrites)
1936
+ }
1937
+ collectWrites(entry.owner.body)
1865
1938
  }
1866
1939
 
1867
1940
  const navigateDeclarations = new Set()
@@ -1941,11 +2014,21 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1941
2014
  const importedLink = tag => ts.isIdentifier(tag) && links.has(tag.text) && !isShadowedIdentifier(tag, sourceFile)
1942
2015
  const visitor = node => {
1943
2016
  if (ts.isVariableStatement(node) && node.declarationList.declarations.some(declaration => searchDeclarations.has(declaration) || navigateDeclarations.has(declaration))) {
1944
- const declarations = node.declarationList.declarations.filter(declaration => !searchDeclarations.has(declaration) && !navigateDeclarations.has(declaration))
2017
+ const declarations = node.declarationList.declarations.flatMap(declaration => {
2018
+ if (navigateDeclarations.has(declaration)) return []
2019
+ if (!searchDeclarations.has(declaration)) return [ts.visitEachChild(declaration, visitor, context)]
2020
+ const entry = searchObjects.find(candidate => candidate.declaration === declaration)
2021
+ if (!entry?.setter) return []
2022
+ return [factory.updateVariableDeclaration(declaration, declaration.name, declaration.exclamationToken, declaration.type, factory.createCallExpression(factory.createIdentifier(searchWriterHelper), undefined, []))]
2023
+ })
1945
2024
  if (!declarations.length) return undefined
1946
- return factory.updateVariableStatement(node, node.modifiers, factory.updateVariableDeclarationList(node.declarationList, declarations.map(declaration => ts.visitEachChild(declaration, visitor, context))))
2025
+ return factory.updateVariableStatement(node, node.modifiers, factory.updateVariableDeclarationList(node.declarationList, declarations))
1947
2026
  }
1948
2027
  if (ts.isCallExpression(node) && searchReads.has(node)) return factory.createCallExpression(factory.createIdentifier(searchHelper), undefined, [searchReads.get(node)])
2028
+ if (ts.isCallExpression(node) && searchWrites.has(node)) {
2029
+ const { updater, replace } = searchWrites.get(node)
2030
+ return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("globalThis"), "__kSetSearchParams"), undefined, [ts.visitNode(updater, visitor), replace ? factory.createTrue() : factory.createFalse()])
2031
+ }
1949
2032
  if (ts.isCallExpression(node) && navigateCalls.has(node)) {
1950
2033
  const { method, destination } = navigateCalls.get(node)
1951
2034
  return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createPropertyAccessExpression(factory.createIdentifier("globalThis"), "location"), method), undefined, [factory.createStringLiteral(destination)])
@@ -1962,7 +2045,7 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1962
2045
  }
1963
2046
  if (ts.isIdentifier(node) && links.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router Link imports may only be used as direct JSX elements")
1964
2047
  if (ts.isIdentifier(node) && params.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useParams imports may only be called directly")
1965
- if (ts.isIdentifier(node) && searchHooks.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useSearchParams imports may only be used by the supported read-only pattern")
2048
+ if (ts.isIdentifier(node) && searchHooks.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useSearchParams imports may only initialize the supported top-level tuple binding")
1966
2049
  if (ts.isIdentifier(node) && navigateHooks.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useNavigate imports may only initialize the supported top-level navigate binding")
1967
2050
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "react-router-dom") {
1968
2051
  const clause = node.importClause
@@ -1979,7 +2062,8 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1979
2062
  if (!params.size && !searchHooks.size) return normalized
1980
2063
  const imports = [
1981
2064
  ...[...params].map(name => factory.createImportSpecifier(false, name === "useParams" ? undefined : factory.createIdentifier("useParams"), factory.createIdentifier(name))),
1982
- ...(searchHooks.size ? [factory.createImportSpecifier(false, factory.createIdentifier("useSearchParam"), factory.createIdentifier(searchHelper))] : [])
2065
+ ...(searchReads.size ? [factory.createImportSpecifier(false, factory.createIdentifier("useSearchParam"), factory.createIdentifier(searchHelper))] : []),
2066
+ ...(searchObjects.some(entry => entry.setter) ? [factory.createImportSpecifier(false, factory.createIdentifier("useSearchParamsWriter"), factory.createIdentifier(searchWriterHelper))] : [])
1983
2067
  ]
1984
2068
  const declaration = factory.createImportDeclaration(undefined, factory.createImportClause(false, undefined, factory.createNamedImports(imports)), factory.createStringLiteral("@kudzujs/core"))
1985
2069
  const statements = [...normalized.statements]
@@ -2515,7 +2599,10 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2515
2599
  return context => sourceFile => {
2516
2600
  const factory = context.factory
2517
2601
  const hasLinkElements = /<link/i.test(sourceFile.text)
2518
- const importedCollections = importedSerializableCollectionNames(sourceFile, file, sourceFiles, sourceIndex)
2602
+ const importedStaticCollections = importedSerializableCollections(sourceFile, file, sourceFiles, sourceIndex)
2603
+ const importedCollections = new Set(importedStaticCollections.keys())
2604
+ sourceFile = normalizeImportedStaticCollections(sourceFile, importedStaticCollections, factory, context)
2605
+ ts.setParentRecursive(sourceFile, false)
2519
2606
  sourceFile = normalizeReactRouterSyntax(sourceFile, factory, context, base)
2520
2607
  ts.setParentRecursive(sourceFile, false)
2521
2608
  sourceFile = normalizeClsxSyntax(sourceFile, factory, context)
@@ -2531,6 +2618,12 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2531
2618
  ts.setParentRecursive(sourceFile, false)
2532
2619
  rejectOrdinaryWorkerImports(sourceFile, file, sourceFiles)
2533
2620
  const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
2621
+ const packageBindings = packageImportBindings(sourceFile)
2622
+ for (const [name] of packageBindings) {
2623
+ const references = referenceIdentifiers(sourceFile, name)
2624
+ const invalid = references.find(reference => !insideJsxEventHandler(reference, sourceFile))
2625
+ if (invalid) throw sourceNodeError(invalid, sourceFile, `Package import ${JSON.stringify(name)} may only be referenced directly inside JSX event handlers`)
2626
+ }
2534
2627
  const hasUseEffectImport = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && ["@kudzujs/core", "react"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useEffect"))
2535
2628
  const importedSources = new Map()
2536
2629
  const importedSource = target => {
@@ -2554,6 +2647,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2554
2647
  return imported
2555
2648
  }
2556
2649
  const importedCollectionTransforms = new Map()
2650
+ const importedCalculationFunctions = new Map()
2557
2651
  for (const [name, binding] of importBindings) {
2558
2652
  if (binding.kind === "namespace") continue
2559
2653
  try {
@@ -2573,8 +2667,10 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2573
2667
  return store
2574
2668
  }
2575
2669
  const functions = new Map()
2670
+ const customHookFunctionsByOwner = new Map()
2576
2671
  const components = new Map()
2577
2672
  const contexts = new Set()
2673
+ const customHooks = new Map()
2578
2674
  const jsxLocalDeclarations = new Map()
2579
2675
  const jsxLocalsByFunction = new Map()
2580
2676
  const listLocalDeclarations = new WeakSet()
@@ -2598,9 +2694,73 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2598
2694
  let usesComponentRef = false
2599
2695
  let usesComponentEffects = false
2600
2696
 
2697
+ const resolveCustomHook = (binding, call) => {
2698
+ const exportName = binding.kind === "default" ? "default" : binding.imported
2699
+ const key = `${binding.target}:${exportName}`
2700
+ if (customHooks.has(key)) return customHooks.get(key)
2701
+ const hook = resolveComponentExport(binding.target, exportName, importedSource, sourceFiles)
2702
+ const hookSource = hook.getSourceFile()
2703
+ if (hook.parameters.length || hook.asteriskToken || hook.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || !ts.isBlock(hook.body)) throw sourceNodeError(hook, hookSource, "Relative custom hooks must be synchronous zero-argument functions with a block body")
2704
+ const returns = hook.body.statements.filter(ts.isReturnStatement)
2705
+ const returned = returns.length === 1 && returns[0] === hook.body.statements.at(-1) && returns[0].expression ? unwrapExpression(returns[0].expression) : undefined
2706
+ if (!returned || !ts.isObjectLiteralExpression(returned)) throw sourceNodeError(hook.body, hookSource, "Relative custom hooks must end with one direct object return")
2707
+
2708
+ const states = new Map()
2709
+ const callbacks = new Map()
2710
+ for (const statement of hook.body.statements) {
2711
+ if (!ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const)) continue
2712
+ for (const declaration of statement.declarationList.declarations) {
2713
+ if (ts.isArrayBindingPattern(declaration.name) && declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "useState") {
2714
+ const [state, setter] = declaration.name.elements
2715
+ if (declaration.name.elements.length === 2 && state && setter && ts.isBindingElement(state) && ts.isBindingElement(setter) && ts.isIdentifier(state.name) && ts.isIdentifier(setter.name)) states.set(setter.name.text, state.name.text)
2716
+ }
2717
+ if (ts.isIdentifier(declaration.name) && declaration.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) callbacks.set(declaration.name.text, declaration.initializer)
2718
+ }
2719
+ }
2720
+ const fields = new Set()
2721
+ for (const property of returned.properties) {
2722
+ if (!ts.isShorthandPropertyAssignment(property)) throw sourceNodeError(property, hookSource, "Relative custom hooks must return direct shorthand bindings")
2723
+ fields.add(property.name.text)
2724
+ }
2725
+ for (const [name, callback] of callbacks) {
2726
+ const capture = nativeCaptureNames(callback, states).values().next().value
2727
+ if (capture) throw sourceNodeError(callback, hookSource, `Relative custom hook callback ${JSON.stringify(name)} cannot capture private binding ${JSON.stringify(capture)}`)
2728
+ }
2729
+ const analysis = { callbacks, fields, states }
2730
+ customHooks.set(key, analysis)
2731
+ return analysis
2732
+ }
2733
+
2601
2734
  const collect = node => {
2602
2735
  if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) {
2603
2736
  const callName = ts.isIdentifier(node.initializer.expression) ? node.initializer.expression.text : ""
2737
+ if (callName && /^use[A-Z]/.test(callName) && importBindings.has(callName) && importBindings.get(callName).kind !== "namespace" && !resolvedZustandStore(importBindings.get(callName))) {
2738
+ if (!isLocalConst(node) || !ts.isObjectBindingPattern(node.name) || node.initializer.arguments.length) throw sourceNodeError(node, sourceFile, "Relative custom hooks must initialize one top-level const object destructuring with no arguments")
2739
+ const hook = resolveCustomHook(importBindings.get(callName), node.initializer)
2740
+ const names = new Set()
2741
+ for (const element of node.name.elements) {
2742
+ if (element.dotDotDotToken || element.propertyName || element.initializer || !ts.isIdentifier(element.name)) throw sourceNodeError(element, sourceFile, "Relative custom hook results must use direct identifier shorthand without aliases, defaults, or rest")
2743
+ const name = element.name.text
2744
+ if (!hook.fields.has(name)) throw sourceNodeError(element, sourceFile, `Relative custom hook does not directly return ${JSON.stringify(name)}`)
2745
+ names.add(name)
2746
+ }
2747
+ const owner = nearestFunction(node)
2748
+ if (!owner) throw sourceNodeError(node, sourceFile, "Relative custom hooks cannot be used outside a Kudzu component")
2749
+ const setters = settersByFunction.get(owner) ?? new Map()
2750
+ for (const [setter, state] of hook.states) {
2751
+ if (names.has(setter) !== names.has(state)) throw sourceNodeError(node.name, sourceFile, `Relative custom hook state ${JSON.stringify(state)} and setter ${JSON.stringify(setter)} must be destructured together`)
2752
+ if (names.has(setter)) setters.set(setter, state)
2753
+ }
2754
+ settersByFunction.set(owner, setters)
2755
+ for (const name of names) {
2756
+ if (hook.callbacks.has(name)) {
2757
+ const callbacks = customHookFunctionsByOwner.get(owner) ?? new Map()
2758
+ callbacks.set(name, hook.callbacks.get(name))
2759
+ customHookFunctionsByOwner.set(owner, callbacks)
2760
+ }
2761
+ else if (![...hook.states].some(([setter, state]) => name === setter || name === state)) throw sourceNodeError(node.name, sourceFile, `Relative custom hook result ${JSON.stringify(name)} must be a direct useState value, setter, or callback`)
2762
+ }
2763
+ }
2604
2764
  if (ts.isIdentifier(node.name) && callName && importBindings.has(callName) && importBindings.get(callName).kind !== "namespace") {
2605
2765
  const storeImport = importBindings.get(callName)
2606
2766
  const store = resolvedZustandStore(storeImport)
@@ -2677,6 +2837,10 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2677
2837
  ts.forEachChild(node, collect)
2678
2838
  }
2679
2839
  collect(sourceFile)
2840
+ const functionsForNode = node => {
2841
+ const callbacks = customHookFunctionsByOwner.get(nearestFunction(node))
2842
+ return callbacks ? new Map([...functions, ...callbacks]) : functions
2843
+ }
2680
2844
  for (const [owner, declarations] of jsxLocalDeclarations) {
2681
2845
  const names = new Set()
2682
2846
  let changed = true
@@ -2722,6 +2886,63 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2722
2886
  const fail = (node, message) => {
2723
2887
  throw sourceNodeError(node, sourceFile, message)
2724
2888
  }
2889
+ const validateImportedCalculation = (call, field) => {
2890
+ const name = call.expression.text
2891
+ let calculation = importedCalculationFunctions.get(name)
2892
+ if (!calculation) {
2893
+ const binding = importBindings.get(name)
2894
+ try {
2895
+ calculation = resolveComponentExport(binding.target, binding.kind === "default" ? "default" : binding.imported, importedSource, sourceFiles)
2896
+ } catch {
2897
+ fail(call.expression, "Reactive imported calculations must resolve to a directly exported relative TypeScript function")
2898
+ }
2899
+ importedCalculationFunctions.set(name, calculation)
2900
+ }
2901
+ if (calculation.asteriskToken || calculation.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) fail(call.expression, "Reactive imported calculations must be synchronous functions")
2902
+ if (calculation.parameters.length !== call.arguments.length) fail(call, "Reactive imported calculations require one direct argument for each declared parameter")
2903
+ const returns = ts.isBlock(calculation.body) ? [] : [unwrapExpression(calculation.body)]
2904
+ const collectReturns = node => {
2905
+ if (node !== calculation.body && isFunctionLike(node)) return
2906
+ if (ts.isReturnStatement(node)) returns.push(node.expression ? unwrapExpression(node.expression) : null)
2907
+ ts.forEachChild(node, collectReturns)
2908
+ }
2909
+ if (ts.isBlock(calculation.body)) collectReturns(calculation.body)
2910
+ if (ts.isBlock(calculation.body) && !ts.isReturnStatement(calculation.body.statements.at(-1))) fail(call.expression, "Reactive imported calculations must end with an unconditional return")
2911
+ if (!returns.length || returns.some(returned => !returned || !ts.isObjectLiteralExpression(returned))) fail(call.expression, "Reactive imported calculations must return a plain object")
2912
+ const fieldExists = returns.every(returned => returned.properties.some(property => ts.isSpreadAssignment(property) || !ts.isComputedPropertyName(property.name) && property.name.text === field))
2913
+ if (!fieldExists) fail(call.parent, `Reactive imported calculation does not return field ${JSON.stringify(field)}`)
2914
+ }
2915
+ const validateReactiveJsxExpression = (expression, allowedNames) => {
2916
+ const value = unwrapExpression(expression)
2917
+ const formatAccess = ts.isCallExpression(value) && !value.questionDotToken && ts.isPropertyAccessExpression(value.expression) && !value.expression.questionDotToken && value.expression.name.text === "format" ? value.expression : undefined
2918
+ const formatter = formatAccess && unwrapExpression(formatAccess.expression)
2919
+ const constructor = formatter && ts.isNewExpression(formatter) && ts.isPropertyAccessExpression(formatter.expression) && formatter.expression.name.text === "NumberFormat" && ts.isIdentifier(formatter.expression.expression) && formatter.expression.expression.text === "Intl" ? formatter : undefined
2920
+ if (!constructor) {
2921
+ const validate = node => {
2922
+ const current = unwrapExpression(node)
2923
+ if (ts.isPropertyAccessExpression(current) && ts.isCallExpression(unwrapExpression(current.expression))) {
2924
+ const call = unwrapExpression(current.expression)
2925
+ if (ts.isIdentifier(call.expression) && importBindings.has(call.expression.text) && importBindings.get(call.expression.text).kind !== "namespace") {
2926
+ validateImportedCalculation(call, current.name.text)
2927
+ for (const argument of call.arguments) collectionExpression(argument, {}, (target, message) => fail(target, message.replace("Rendered collection", "Reactive imported calculation")), allowedNames)
2928
+ return factory.createNumericLiteral(0)
2929
+ }
2930
+ }
2931
+ return ts.visitEachChild(current, validate, context)
2932
+ }
2933
+ const normalized = ts.visitNode(value, validate)
2934
+ collectionExpression(normalized, {}, (node, message) => fail(node, message.replace("Rendered collection", "Reactive JSX local")), allowedNames)
2935
+ return
2936
+ }
2937
+ const intl = constructor.expression.expression
2938
+ if (!isUnshadowedGlobal(intl, sourceFile)) fail(intl, "Reactive JSX Intl.NumberFormat requires the unshadowed global Intl object")
2939
+ if (constructor.arguments?.length !== 1 || !ts.isStringLiteral(constructor.arguments[0])) fail(constructor, "Reactive JSX Intl.NumberFormat requires exactly one static string locale")
2940
+ const rounded = value.arguments.length === 1 ? unwrapExpression(value.arguments[0]) : undefined
2941
+ const roundAccess = rounded && ts.isCallExpression(rounded) && !rounded.questionDotToken && rounded.arguments.length === 1 && ts.isPropertyAccessExpression(rounded.expression) && !rounded.expression.questionDotToken && rounded.expression.name.text === "round" && ts.isIdentifier(rounded.expression.expression) && rounded.expression.expression.text === "Math" ? rounded.expression : undefined
2942
+ if (!roundAccess) fail(value, "Reactive JSX Intl.NumberFormat format() requires exactly Math.round(expression)")
2943
+ if (!isUnshadowedGlobal(roundAccess.expression, sourceFile)) fail(roundAccess.expression, "Reactive JSX Intl.NumberFormat requires the unshadowed global Math object")
2944
+ collectionExpression(rounded.arguments[0], {}, (node, message) => fail(node, message.replace("Rendered collection", "Reactive JSX local")), allowedNames)
2945
+ }
2725
2946
  const resolveReactiveJsxExpression = (expression, owner, setters) => {
2726
2947
  const declarations = jsxLocalDeclarations.get(owner)
2727
2948
  if (!declarations) return expression
@@ -2758,7 +2979,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2758
2979
  if (!usedStates.size) return expression
2759
2980
  const captures = captureNames(expanded, expanded, setters)
2760
2981
  const allowedNames = new Set([...setters.values(), ...captures])
2761
- collectionExpression(expanded, {}, (node, message) => fail(node, message.replace("Rendered collection", "Reactive JSX local")), allowedNames)
2982
+ validateReactiveJsxExpression(expanded, allowedNames)
2762
2983
  return expanded
2763
2984
  }
2764
2985
  const componentSpecializations = new WeakMap()
@@ -2910,7 +3131,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2910
3131
  }
2911
3132
  const setters = new Map(parentSetters)
2912
3133
  for (const state of aggregate.ordinaryStates) setters.set(state.setter, state.state)
2913
- if (jsxSetterCallbackProps(node, setters, functions, reducersForNode(node, reducersByFunction)).length) fail(node, "Setter callbacks cannot cross a second component boundary")
3134
+ if (jsxSetterCallbackProps(node, setters, functionsForNode(node), reducersForNode(node, reducersByFunction)).length) fail(node, "Setter callbacks cannot cross a second component boundary")
2914
3135
  const nested = specializeComponentCall(node, component, sourceFile, factory, context, fail, "Nested setter-callback", true, true, new Set(setters.values()))
2915
3136
  if (dynamic && (nested.hookDeclarations.length || nested.effects.length)) fail(node, "Hookful nested setter-callback components require an unconditional or statically truthy render path")
2916
3137
  nested.root = expandSetterComponents(nested.root, component.getSourceFile(), [...trail, component], nested, setters)
@@ -2990,7 +3211,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2990
3211
  const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
2991
3212
  for (const attribute of attributes.properties) {
2992
3213
  if (!ts.isJsxAttribute(attribute) || !callbackProps.includes(attribute.name.text) || !attribute.initializer || !ts.isJsxExpression(attribute.initializer) || !ts.isIdentifier(attribute.initializer.expression)) continue
2993
- const callback = functions.get(attribute.initializer.expression.text)
3214
+ const callback = functionsForNode(attribute).get(attribute.initializer.expression.text)
2994
3215
  if (callback) substitutions.set(attribute.initializer.expression.text, callback)
2995
3216
  }
2996
3217
  if (substitutions.size) {
@@ -3039,14 +3260,14 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3039
3260
  }
3040
3261
  for (const [name, component] of components) {
3041
3262
  for (const call of jsxTagUses(sourceFile, name)) {
3042
- const callbackProps = jsxSetterCallbackProps(call, settersByFunction.get(nearestFunction(call)) ?? new Map(), functions, reducersForNode(call, reducersByFunction))
3263
+ const callbackProps = jsxSetterCallbackProps(call, settersByFunction.get(nearestFunction(call)) ?? new Map(), functionsForNode(call), reducersForNode(call, reducersByFunction))
3043
3264
  if (callbackProps.length) specializeSetterCallbacks(call, component.function, callbackProps, false)
3044
3265
  }
3045
3266
  }
3046
3267
  for (const [name, binding] of importBindings) {
3047
3268
  if (binding.kind === "namespace") continue
3048
3269
  const calls = jsxTagUses(sourceFile, name)
3049
- const callbackCalls = calls.map(call => ({ call, callbackProps: jsxSetterCallbackProps(call, settersByFunction.get(nearestFunction(call)) ?? new Map(), functions, reducersForNode(call, reducersByFunction)) })).filter(entry => entry.callbackProps.length)
3270
+ const callbackCalls = calls.map(call => ({ call, callbackProps: jsxSetterCallbackProps(call, settersByFunction.get(nearestFunction(call)) ?? new Map(), functionsForNode(call), reducersForNode(call, reducersByFunction)) })).filter(entry => entry.callbackProps.length)
3050
3271
  if (!callbackCalls.length) continue
3051
3272
  const imported = binding.kind === "default" ? "default" : binding.imported
3052
3273
  let component
@@ -3306,13 +3527,17 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3306
3527
  return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral("@kudzujs/core"), node.attributes)
3307
3528
  }
3308
3529
 
3530
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && packageBindings.size && importDeclarationNames(node).some(name => packageBindings.has(name))) return undefined
3531
+
3309
3532
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
3533
+ if (!runtimeModuleReference(node)) return node
3310
3534
  if (isStaticImport(node.moduleSpecifier.text)) return staticImportEntry(node, sourceFile, file, staticFiles, importedAssets, cssModules, base, factory)?.replacement
3311
3535
  const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
3312
3536
  return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
3313
3537
  }
3314
3538
 
3315
3539
  if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
3540
+ if (!runtimeModuleReference(node)) return node
3316
3541
  const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
3317
3542
  return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
3318
3543
  }
@@ -3462,6 +3687,17 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3462
3687
  return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, factory.createIdentifier("undefined"))
3463
3688
  }
3464
3689
 
3690
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isCallExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && importBindings.has(node.initializer.expression.text)) {
3691
+ const setters = settersForNode(node, settersByFunction)
3692
+ const stateNames = new Set(setters.values())
3693
+ const rewrite = current => {
3694
+ if (ts.isShorthandPropertyAssignment(current) && stateNames.has(current.name.text)) return factory.createPropertyAssignment(current.name, factory.createPropertyAccessExpression(current.name, "value"))
3695
+ if (ts.isIdentifier(current) && stateNames.has(current.text) && isReferenceIdentifier(current)) return factory.createPropertyAccessExpression(current, "value")
3696
+ return ts.visitEachChild(current, rewrite, context)
3697
+ }
3698
+ if (referencedStateNames(node.initializer, setters).size) return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, ts.visitNode(node.initializer, rewrite))
3699
+ }
3700
+
3465
3701
  if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && jsxLocalsByFunction.get(nearestFunction(node))?.has(node.name.text) && referencesIdentifier(nearestFunction(node).body, node.name.text)) {
3466
3702
  const compiled = compileRenderExpression(node.initializer, node)
3467
3703
  if (compiled !== node.initializer) return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, compiled)
@@ -3518,7 +3754,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3518
3754
  if ((usedStates.size || captures.size) && !ts.isIdentifier(expression) && !containsJsx(expression)) {
3519
3755
  usesBehavior = true
3520
3756
  usesBinding = true
3521
- return factory.updateJsxExpression(node, compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl))
3757
+ return factory.updateJsxExpression(node, compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl, importBindings, clientImports))
3522
3758
  }
3523
3759
  }
3524
3760
 
@@ -3531,14 +3767,14 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3531
3767
  if ((usedStates.size || captures.size) && !ts.isIdentifier(expression)) {
3532
3768
  usesBehavior = true
3533
3769
  usesBinding = true
3534
- const compiled = compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl)
3770
+ const compiled = compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl, importBindings, clientImports)
3535
3771
  return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, compiled))
3536
3772
  }
3537
3773
  }
3538
3774
 
3539
3775
  if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.text)) {
3540
3776
  const setters = settersForNode(node, settersByFunction)
3541
- const event = compileEvent(node.initializer.expression, setters, reducersForNode(node, reducersByFunction), functions, factory, nativeHandlers, handlerUrl, listEventItems.get(node), importBindings, clientImports)
3777
+ const event = compileEvent(node.initializer.expression, setters, reducersForNode(node, reducersByFunction), functionsForNode(node), factory, nativeHandlers, handlerUrl, listEventItems.get(node), new Map([...importBindings, ...packageBindings]), clientImports)
3542
3778
  if (event) {
3543
3779
  usesBehavior = true
3544
3780
  return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, event))
@@ -4681,13 +4917,13 @@ function isJsxLocalValue(expression, known) {
4681
4917
  return Boolean(parts && (isJsxLocalValue(parts.truthy, known) || isJsxLocalValue(parts.falsy, known)))
4682
4918
  }
4683
4919
 
4684
- function compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl) {
4920
+ function compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl, importBindings = new Map(), clientImports = new Set()) {
4685
4921
  const parts = conditionalParts(expression)
4686
4922
  const state = parts && directStateIdentifier(parts.condition, setters)
4687
4923
  if (state && isPrimitiveDefaultLiteral(parts.truthy) && isPrimitiveDefaultLiteral(parts.falsy)) {
4688
4924
  return factory.createCallExpression(factory.createIdentifier("__kSelect"), undefined, [state, parts.truthy, parts.falsy])
4689
4925
  }
4690
- return factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl))
4926
+ return factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl, importBindings, clientImports))
4691
4927
  }
4692
4928
 
4693
4929
  function compileConditional(kind, expression, truthy, falsy, setters, factory, context, reactiveBindings, handlerUrl) {
@@ -4703,11 +4939,14 @@ function directStateIdentifier(expression, setters) {
4703
4939
  return ts.isIdentifier(value) && new Set(setters.values()).has(value.text) ? value : undefined
4704
4940
  }
4705
4941
 
4706
- function compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl) {
4942
+ function compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl, importBindings = new Map(), clientImports = new Set()) {
4707
4943
  const usedStates = referencedStateNames(expression, setters)
4708
- const captures = captureNames(expression, expression, setters)
4944
+ const importedNames = referencedImportedBindings(expression, importBindings)
4945
+ const imports = [...importedNames].map(name => importBindings.get(name))
4946
+ for (const entry of imports) if (!entry.package) clientImports.add(entry.target)
4947
+ const captures = new Set([...captureNames(expression, expression, setters)].filter(name => !importedNames.has(name)))
4709
4948
  const exportName = `binding${reactiveBindings.length}`
4710
- reactiveBindings.push({ exportName, expression, captures, states: usedStates })
4949
+ reactiveBindings.push({ exportName, expression, captures, states: usedStates, imports })
4711
4950
  const states = [...usedStates].map(name => factory.createArrayLiteralExpression([
4712
4951
  factory.createStringLiteral(name),
4713
4952
  factory.createIdentifier(name)
@@ -4780,7 +5019,7 @@ function compileNativeCallback(expression, setters, reducers, factory, entries,
4780
5019
  const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
4781
5020
  imports.push(...[...usedReducers].map(name => reducers.get(name).import).filter(Boolean))
4782
5021
  const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
4783
- for (const entry of imports) clientImports.add(entry.target)
5022
+ for (const entry of imports) if (!entry.package) clientImports.add(entry.target)
4784
5023
  const usedStates = nativeStateNames(expression, setters)
4785
5024
  const exportName = `${prefix}${entries.length}`
4786
5025
  entries.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))), reducers: new Map([...reducers].filter(([name]) => usedReducers.has(name))), snapshotNested })
@@ -4843,7 +5082,7 @@ function compileOptimizedEvent(expression, setters, factory) {
4843
5082
  }
4844
5083
 
4845
5084
  const nativeGlobals = new Set([
4846
- "Array", "ArrayBuffer", "BigInt", "Boolean", "Date", "Error", "Event", "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", "atob", "btoa", "clearInterval", "clearTimeout", "console", "crypto", "document", "fetch", "globalThis", "history", "isFinite", "isNaN", "location", "navigator", "parseFloat", "parseInt", "queueMicrotask", "requestAnimationFrame", "setInterval", "setTimeout", "structuredClone", "undefined", "window"
5085
+ "Array", "ArrayBuffer", "BigInt", "Blob", "Boolean", "Date", "Error", "Event", "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", "atob", "btoa", "clearInterval", "clearTimeout", "console", "crypto", "document", "fetch", "globalThis", "history", "isFinite", "isNaN", "location", "navigator", "parseFloat", "parseInt", "queueMicrotask", "requestAnimationFrame", "setInterval", "setTimeout", "structuredClone", "undefined", "window"
4847
5086
  ])
4848
5087
 
4849
5088
  function rewriteEffectWorkers(callback, file, sourceFile, sourceFiles, workerReferences, factory, context) {
@@ -4928,7 +5167,7 @@ function referencedImportedBindings(expression, imports) {
4928
5167
  if (ts.isIdentifier(node) && imports.has(node.text) && isReferenceIdentifier(node)) names.add(node.text)
4929
5168
  ts.forEachChild(node, visit)
4930
5169
  }
4931
- visit(expression.body)
5170
+ visit(expression.body ?? expression)
4932
5171
  return names
4933
5172
  }
4934
5173
 
@@ -5100,18 +5339,61 @@ function clientImportBindings(sourceFile, file, sourceFiles) {
5100
5339
  return bindings
5101
5340
  }
5102
5341
 
5342
+ function packageImportBindings(sourceFile) {
5343
+ const bindings = new Map()
5344
+ const rejectDynamic = node => {
5345
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
5346
+ const specifier = node.arguments.length === 1 && ts.isStringLiteralLike(node.arguments[0]) ? node.arguments[0].text : null
5347
+ if (specifier === null) throw sourceNodeError(node, sourceFile, "Dynamic import specifiers are not supported")
5348
+ if (!specifier.startsWith(".")) throw sourceNodeError(node, sourceFile, `Dynamic package import ${JSON.stringify(specifier)} is not supported`)
5349
+ }
5350
+ ts.forEachChild(node, rejectDynamic)
5351
+ }
5352
+ rejectDynamic(sourceFile)
5353
+ for (const node of sourceFile.statements) {
5354
+ if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) continue
5355
+ const target = node.moduleSpecifier.text
5356
+ if (!node.importClause) {
5357
+ if (!target.startsWith(".") && !["react", "react-router-dom", "@kudzujs/core"].includes(target) && !target.startsWith("@kudzujs/core/")) throw sourceNodeError(node, sourceFile, `Side-effect package import ${JSON.stringify(target)} is not supported`)
5358
+ continue
5359
+ }
5360
+ if (node.importClause.isTypeOnly) continue
5361
+ if (target.startsWith(".") || target.startsWith("node:") || target === "react" || target === "react-router-dom" || target === "@kudzujs/core" || target.startsWith("@kudzujs/core/")) continue
5362
+ if (node.importClause.name) bindings.set(node.importClause.name.text, { kind: "default", local: node.importClause.name.text, target, package: true })
5363
+ const named = node.importClause.namedBindings
5364
+ if (named && ts.isNamespaceImport(named)) bindings.set(named.name.text, { kind: "namespace", local: named.name.text, target, package: true })
5365
+ if (named && ts.isNamedImports(named)) for (const entry of named.elements) if (!entry.isTypeOnly) bindings.set(entry.name.text, { kind: "named", imported: (entry.propertyName ?? entry.name).text, local: entry.name.text, target, package: true })
5366
+ }
5367
+ return bindings
5368
+ }
5369
+
5103
5370
  function importedSerializableCollectionNames(sourceFile, file, sourceFiles, sourceIndex) {
5104
- const names = new Set()
5371
+ return new Set(importedSerializableCollections(sourceFile, file, sourceFiles, sourceIndex).keys())
5372
+ }
5373
+
5374
+ function importedSerializableCollections(sourceFile, file, sourceFiles, sourceIndex) {
5375
+ const collections = new Map()
5105
5376
  for (const [name, binding] of clientImportBindings(sourceFile, file, sourceFiles)) {
5106
5377
  if (binding.kind !== "named") continue
5107
5378
  const imported = parseSourceFile(binding.target, sourceIndex.get(binding.target))
5108
5379
  for (const statement of imported.statements) {
5109
5380
  if (!ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const) || !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue
5110
5381
  const declaration = statement.declarationList.declarations.find(entry => ts.isIdentifier(entry.name) && entry.name.text === binding.imported)
5111
- if (declaration?.initializer && ts.isArrayLiteralExpression(unwrapExpression(declaration.initializer)) && isSerializableStateLiteral(declaration.initializer)) names.add(name)
5382
+ if (declaration?.initializer && ts.isArrayLiteralExpression(unwrapExpression(declaration.initializer)) && isSerializableStateLiteral(declaration.initializer)) collections.set(name, unwrapExpression(declaration.initializer))
5112
5383
  }
5113
5384
  }
5114
- return names
5385
+ return collections
5386
+ }
5387
+
5388
+ function normalizeImportedStaticCollections(sourceFile, collections, factory, context) {
5389
+ if (!collections.size) return sourceFile
5390
+ const visitor = node => {
5391
+ if (ts.isPropertyAccessExpression(node) && node.name.text === "map" && ts.isIdentifier(node.expression) && collections.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) {
5392
+ return factory.updatePropertyAccessExpression(node, synthesizeTree(cloneAst(collections.get(node.expression.text), factory, context)), node.name)
5393
+ }
5394
+ return ts.visitEachChild(node, visitor, context)
5395
+ }
5396
+ return ts.visitNode(sourceFile, visitor)
5115
5397
  }
5116
5398
 
5117
5399
  function resolveComponentExport(file, exportName, getSource, sourceFiles, trail = []) {
@@ -5200,7 +5482,7 @@ function printClientImports(entries, handlerPath) {
5200
5482
  const groups = Map.groupBy(unique.values(), entry => entry.target)
5201
5483
  const imports = []
5202
5484
  for (const [target, group] of groups) {
5203
- const specifier = relativeModulePath(handlerPath, clientModulePath(target))
5485
+ const specifier = group[0].package ? target : relativeModulePath(handlerPath, clientModulePath(target))
5204
5486
  const defaults = group.filter(entry => entry.kind === "default")
5205
5487
  const named = group.filter(entry => entry.kind === "named")
5206
5488
  if (defaults.length === 1 || named.length) imports.push(`import ${defaults.length === 1 ? `${defaults[0].local}${named.length ? ", " : ""}` : ""}${named.length ? `{ ${named.map(entry => entry.imported === entry.local ? entry.local : `${entry.imported} as ${entry.local}`).join(", ")} }` : ""} from ${JSON.stringify(specifier)}`)
@@ -13,6 +13,7 @@ export function useReducer<State, Action>(reducer: Reducer<State, Action>, initi
13
13
  export function useEffect(effect: () => void | EffectCleanup | Promise<void>, dependencies: readonly EffectDependency[]): void
14
14
  export function useParams<Params extends Record<string, string> = Record<string, string>>(): Readonly<Params>
15
15
  export function useSearchParam(name: string): string | null
16
+ export function useSearchParamsWriter(): [undefined, undefined]
16
17
 
17
18
  export interface RefObject<T> {
18
19
  readonly current: T | null
@@ -96,6 +97,7 @@ export function renderPage<Props = Record<string, never>>(
96
97
  states: Array<{ id: string; name: string; initialValue: unknown; lifetime?: "layout" | "route"; internal?: true }>
97
98
  params: Array<{ name: string; id: string }>
98
99
  searchParams: Array<{ name: string; id: string }>
100
+ searchParamsWritable: boolean
99
101
  events: Array<{
100
102
  event: string
101
103
  commands?: Array<[string, string, unknown]>
@@ -139,6 +139,15 @@ export function useSearchParam(name) {
139
139
  return signal
140
140
  }
141
141
 
142
+ export function useSearchParamsWriter() {
143
+ if (!renderContext) throw new Error("useSearchParamsWriter() can only run while rendering a Kudzu component")
144
+ if (renderContext.renderScope === "layout") throw new Error("useSearchParamsWriter() is only supported in route scope")
145
+ renderContext.searchParamsWritable = true
146
+ renderContext.hasBehaviors = true
147
+ renderContext.hasParams = true
148
+ return [undefined, undefined]
149
+ }
150
+
142
151
  function createSignal(id, value) {
143
152
  return {
144
153
  [signalMarker]: true,
@@ -432,7 +441,7 @@ function serializeCapture(name, value, seen) {
432
441
  }
433
442
 
434
443
  export async function renderPage(component, metadata = {}, props = {}, layout) {
435
- renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, nextId: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowRefs: [], listRowConditions: [], listRowLists: [], effectOwners: [], contexts: [], stores: new Map(), states: {}, textStates: new Set(), conditionStates: new Set(), conditionOwnedStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerModules: new Set(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, searchParams: new Map(), searchParamEntries: [], hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
444
+ renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, nextId: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowRefs: [], listRowConditions: [], listRowLists: [], effectOwners: [], contexts: [], stores: new Map(), states: {}, textStates: new Set(), conditionStates: new Set(), conditionOwnedStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerModules: new Set(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, searchParams: new Map(), searchParamEntries: [], searchParamsWritable: false, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
436
445
 
437
446
  try {
438
447
  const page = { [routeScopeMarker]: true, component, props }
@@ -513,6 +522,7 @@ export async function renderPage(component, metadata = {}, props = {}, layout) {
513
522
  states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
514
523
  params: renderContext.paramEntries,
515
524
  searchParams: renderContext.searchParamEntries,
525
+ searchParamsWritable: renderContext.searchParamsWritable,
516
526
  events: renderContext.events,
517
527
  effects: renderContext.effects,
518
528
  bindings: renderContext.bindings,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.7.29",
3
+ "version": "0.8.0",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",