@kudzujs/core 0.7.30 → 0.8.1

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.30 - Reactive number formatting.** Fixed-locale `Intl.NumberFormat` display chains now remain reactive, restoring comma-formatted controlled inputs without broadening arbitrary render calls. Read the [release notes](./RELEASES.md#0730---reactive-number-formatting) or open the [release page](https://kudzujs.cloud/releases/0.7.30).
13
+ **Latest release: 0.8.1 - Custom-hook reset actions.** Relative custom hooks can batch direct primitive state resets into one behavior command, including imported negative and null literals, while dependent URL and storage effects observe the same commit. Read the [release notes](./RELEASES.md#081---custom-hook-reset-actions) or open the [release page](https://kudzujs.cloud/releases/0.8.1).
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,53 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.8.1 - Custom-hook reset actions
4
+
5
+ Kudzu 0.8.1 compiles direct multi-state reset callbacks returned by relative custom hooks into one existing behavior command, keeping conventional hook ownership without adding a callback runtime.
6
+
7
+ ### New in 0.8.1
8
+
9
+ - Returned custom-hook callbacks may reset their exposed state/setter pairs to direct string, number, negative-number, boolean, or `null` literals in one batched commit.
10
+ - Imported callback literals are synthesized before command emission, fixing source-range corruption when optimized behavior commands originate in another module.
11
+ - The FIRE Standard calculator now owns its twelve-field reset inside `useStandardParams()`; existing dependency effects update URL parameters and local storage after the same commit.
12
+ - A React/Vite migration fixture verifies exact optimized command output plus browser updates for multiple reset values.
13
+ - Static routes and routes without reset actions add no JavaScript or runtime capability.
14
+
15
+ ### Boundary
16
+
17
+ Reset callbacks may capture only directly returned state/setter pairs and use direct literal setter calls. Private defaults, helper-indirect setters, dynamic values, timers, clipboard work, aliases, and arbitrary callback graphs remain unsupported.
18
+
19
+ ### Upgrade
20
+
21
+ ```bash
22
+ npm install @kudzujs/core@^0.8.1
23
+ ```
24
+
25
+ ## 0.8.0 - URL-backed custom hooks
26
+
27
+ 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.
28
+
29
+ ### New in 0.8.0
30
+
31
+ - 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.
32
+ - React Router `useSearchParams()` accepts a top-level `[params, setParams]` tuple. Direct browser callbacks may pass one synchronous inline updater and optionally exactly `{ replace: true }`.
33
+ - Query writes use native `URLSearchParams` and History APIs, immediately recommit affected route signals, and follow browser `popstate` without an SPA router.
34
+ - Custom-hook mount and dependency effects can restore validated values from `localStorage`, persist subsequent state, and keep deterministic build-time fallbacks.
35
+ - Page entries now define the compiled TypeScript graph, so unreachable migration modules no longer block a build while reachable invalid imports retain diagnostics.
36
+ - 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.
37
+ - 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.
38
+ - 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.
39
+ - Static sibling routes continue to ship no JavaScript; React, React Router, hydration, a VDOM, and a browser hook dispatcher remain absent.
40
+
41
+ ### Boundary
42
+
43
+ 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.
44
+
45
+ ### Upgrade
46
+
47
+ ```bash
48
+ npm install @kudzujs/core@^0.8.0
49
+ ```
50
+
3
51
  ## 0.7.30 - Reactive number formatting
4
52
 
5
53
  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.
@@ -2,13 +2,15 @@
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, including a callback that resets multiple states to direct primitive literals; 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
 
@@ -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,13 +2886,52 @@ 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
+ }
2725
2915
  const validateReactiveJsxExpression = (expression, allowedNames) => {
2726
2916
  const value = unwrapExpression(expression)
2727
2917
  const formatAccess = ts.isCallExpression(value) && !value.questionDotToken && ts.isPropertyAccessExpression(value.expression) && !value.expression.questionDotToken && value.expression.name.text === "format" ? value.expression : undefined
2728
2918
  const formatter = formatAccess && unwrapExpression(formatAccess.expression)
2729
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
2730
2920
  if (!constructor) {
2731
- collectionExpression(value, {}, (node, message) => fail(node, message.replace("Rendered collection", "Reactive JSX local")), allowedNames)
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)
2732
2935
  return
2733
2936
  }
2734
2937
  const intl = constructor.expression.expression
@@ -2928,7 +3131,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2928
3131
  }
2929
3132
  const setters = new Map(parentSetters)
2930
3133
  for (const state of aggregate.ordinaryStates) setters.set(state.setter, state.state)
2931
- 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")
2932
3135
  const nested = specializeComponentCall(node, component, sourceFile, factory, context, fail, "Nested setter-callback", true, true, new Set(setters.values()))
2933
3136
  if (dynamic && (nested.hookDeclarations.length || nested.effects.length)) fail(node, "Hookful nested setter-callback components require an unconditional or statically truthy render path")
2934
3137
  nested.root = expandSetterComponents(nested.root, component.getSourceFile(), [...trail, component], nested, setters)
@@ -3008,7 +3211,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3008
3211
  const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
3009
3212
  for (const attribute of attributes.properties) {
3010
3213
  if (!ts.isJsxAttribute(attribute) || !callbackProps.includes(attribute.name.text) || !attribute.initializer || !ts.isJsxExpression(attribute.initializer) || !ts.isIdentifier(attribute.initializer.expression)) continue
3011
- const callback = functions.get(attribute.initializer.expression.text)
3214
+ const callback = functionsForNode(attribute).get(attribute.initializer.expression.text)
3012
3215
  if (callback) substitutions.set(attribute.initializer.expression.text, callback)
3013
3216
  }
3014
3217
  if (substitutions.size) {
@@ -3057,14 +3260,14 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3057
3260
  }
3058
3261
  for (const [name, component] of components) {
3059
3262
  for (const call of jsxTagUses(sourceFile, name)) {
3060
- 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))
3061
3264
  if (callbackProps.length) specializeSetterCallbacks(call, component.function, callbackProps, false)
3062
3265
  }
3063
3266
  }
3064
3267
  for (const [name, binding] of importBindings) {
3065
3268
  if (binding.kind === "namespace") continue
3066
3269
  const calls = jsxTagUses(sourceFile, name)
3067
- 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)
3068
3271
  if (!callbackCalls.length) continue
3069
3272
  const imported = binding.kind === "default" ? "default" : binding.imported
3070
3273
  let component
@@ -3324,13 +3527,17 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3324
3527
  return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral("@kudzujs/core"), node.attributes)
3325
3528
  }
3326
3529
 
3530
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && packageBindings.size && importDeclarationNames(node).some(name => packageBindings.has(name))) return undefined
3531
+
3327
3532
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
3533
+ if (!runtimeModuleReference(node)) return node
3328
3534
  if (isStaticImport(node.moduleSpecifier.text)) return staticImportEntry(node, sourceFile, file, staticFiles, importedAssets, cssModules, base, factory)?.replacement
3329
3535
  const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
3330
3536
  return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
3331
3537
  }
3332
3538
 
3333
3539
  if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
3540
+ if (!runtimeModuleReference(node)) return node
3334
3541
  const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
3335
3542
  return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
3336
3543
  }
@@ -3480,6 +3687,17 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3480
3687
  return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, factory.createIdentifier("undefined"))
3481
3688
  }
3482
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
+
3483
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)) {
3484
3702
  const compiled = compileRenderExpression(node.initializer, node)
3485
3703
  if (compiled !== node.initializer) return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, compiled)
@@ -3536,7 +3754,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3536
3754
  if ((usedStates.size || captures.size) && !ts.isIdentifier(expression) && !containsJsx(expression)) {
3537
3755
  usesBehavior = true
3538
3756
  usesBinding = true
3539
- 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))
3540
3758
  }
3541
3759
  }
3542
3760
 
@@ -3549,14 +3767,14 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3549
3767
  if ((usedStates.size || captures.size) && !ts.isIdentifier(expression)) {
3550
3768
  usesBehavior = true
3551
3769
  usesBinding = true
3552
- const compiled = compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl)
3770
+ const compiled = compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl, importBindings, clientImports)
3553
3771
  return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, compiled))
3554
3772
  }
3555
3773
  }
3556
3774
 
3557
3775
  if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.text)) {
3558
3776
  const setters = settersForNode(node, settersByFunction)
3559
- 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)
3560
3778
  if (event) {
3561
3779
  usesBehavior = true
3562
3780
  return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, event))
@@ -4699,13 +4917,13 @@ function isJsxLocalValue(expression, known) {
4699
4917
  return Boolean(parts && (isJsxLocalValue(parts.truthy, known) || isJsxLocalValue(parts.falsy, known)))
4700
4918
  }
4701
4919
 
4702
- function compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl) {
4920
+ function compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl, importBindings = new Map(), clientImports = new Set()) {
4703
4921
  const parts = conditionalParts(expression)
4704
4922
  const state = parts && directStateIdentifier(parts.condition, setters)
4705
4923
  if (state && isPrimitiveDefaultLiteral(parts.truthy) && isPrimitiveDefaultLiteral(parts.falsy)) {
4706
4924
  return factory.createCallExpression(factory.createIdentifier("__kSelect"), undefined, [state, parts.truthy, parts.falsy])
4707
4925
  }
4708
- 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))
4709
4927
  }
4710
4928
 
4711
4929
  function compileConditional(kind, expression, truthy, falsy, setters, factory, context, reactiveBindings, handlerUrl) {
@@ -4721,11 +4939,14 @@ function directStateIdentifier(expression, setters) {
4721
4939
  return ts.isIdentifier(value) && new Set(setters.values()).has(value.text) ? value : undefined
4722
4940
  }
4723
4941
 
4724
- function compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl) {
4942
+ function compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl, importBindings = new Map(), clientImports = new Set()) {
4725
4943
  const usedStates = referencedStateNames(expression, setters)
4726
- 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)))
4727
4948
  const exportName = `binding${reactiveBindings.length}`
4728
- reactiveBindings.push({ exportName, expression, captures, states: usedStates })
4949
+ reactiveBindings.push({ exportName, expression, captures, states: usedStates, imports })
4729
4950
  const states = [...usedStates].map(name => factory.createArrayLiteralExpression([
4730
4951
  factory.createStringLiteral(name),
4731
4952
  factory.createIdentifier(name)
@@ -4798,7 +5019,7 @@ function compileNativeCallback(expression, setters, reducers, factory, entries,
4798
5019
  const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
4799
5020
  imports.push(...[...usedReducers].map(name => reducers.get(name).import).filter(Boolean))
4800
5021
  const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
4801
- for (const entry of imports) clientImports.add(entry.target)
5022
+ for (const entry of imports) if (!entry.package) clientImports.add(entry.target)
4802
5023
  const usedStates = nativeStateNames(expression, setters)
4803
5024
  const exportName = `${prefix}${entries.length}`
4804
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 })
@@ -4861,7 +5082,7 @@ function compileOptimizedEvent(expression, setters, factory) {
4861
5082
  }
4862
5083
 
4863
5084
  const nativeGlobals = new Set([
4864
- "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"
4865
5086
  ])
4866
5087
 
4867
5088
  function rewriteEffectWorkers(callback, file, sourceFile, sourceFiles, workerReferences, factory, context) {
@@ -4946,7 +5167,7 @@ function referencedImportedBindings(expression, imports) {
4946
5167
  if (ts.isIdentifier(node) && imports.has(node.text) && isReferenceIdentifier(node)) names.add(node.text)
4947
5168
  ts.forEachChild(node, visit)
4948
5169
  }
4949
- visit(expression.body)
5170
+ visit(expression.body ?? expression)
4950
5171
  return names
4951
5172
  }
4952
5173
 
@@ -5118,18 +5339,61 @@ function clientImportBindings(sourceFile, file, sourceFiles) {
5118
5339
  return bindings
5119
5340
  }
5120
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
+
5121
5370
  function importedSerializableCollectionNames(sourceFile, file, sourceFiles, sourceIndex) {
5122
- 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()
5123
5376
  for (const [name, binding] of clientImportBindings(sourceFile, file, sourceFiles)) {
5124
5377
  if (binding.kind !== "named") continue
5125
5378
  const imported = parseSourceFile(binding.target, sourceIndex.get(binding.target))
5126
5379
  for (const statement of imported.statements) {
5127
5380
  if (!ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const) || !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue
5128
5381
  const declaration = statement.declarationList.declarations.find(entry => ts.isIdentifier(entry.name) && entry.name.text === binding.imported)
5129
- 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))
5130
5383
  }
5131
5384
  }
5132
- 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)
5133
5397
  }
5134
5398
 
5135
5399
  function resolveComponentExport(file, exportName, getSource, sourceFiles, trail = []) {
@@ -5218,7 +5482,7 @@ function printClientImports(entries, handlerPath) {
5218
5482
  const groups = Map.groupBy(unique.values(), entry => entry.target)
5219
5483
  const imports = []
5220
5484
  for (const [target, group] of groups) {
5221
- const specifier = relativeModulePath(handlerPath, clientModulePath(target))
5485
+ const specifier = group[0].package ? target : relativeModulePath(handlerPath, clientModulePath(target))
5222
5486
  const defaults = group.filter(entry => entry.kind === "default")
5223
5487
  const named = group.filter(entry => entry.kind === "named")
5224
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)}`)
@@ -5872,7 +6136,7 @@ function scopeRead(factory, name) {
5872
6136
 
5873
6137
  function compileEventCommand(expression, setters, factory) {
5874
6138
  if (ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression) && ts.isIdentifier(expression.expression.expression) && expression.expression.expression.text === "console" && expression.expression.name.text === "log" && expression.arguments.length === 2 && ts.isStringLiteral(expression.arguments[0]) && ts.isIdentifier(expression.arguments[1]) && [...setters.values()].includes(expression.arguments[1].text)) {
5875
- return command(factory, "log", expression.arguments[1], expression.arguments[0])
6139
+ return command(factory, "log", expression.arguments[1], factory.createStringLiteral(expression.arguments[0].text))
5876
6140
  }
5877
6141
 
5878
6142
  if (!ts.isCallExpression(expression) || !ts.isIdentifier(expression.expression) || expression.arguments.length !== 1) return undefined
@@ -5889,7 +6153,7 @@ function compileEventCommand(expression, setters, factory) {
5889
6153
  if (value.body.operatorToken.kind !== ts.SyntaxKind.PlusToken && value.body.operatorToken.kind !== ts.SyntaxKind.MinusToken) return undefined
5890
6154
  return command(factory, "add", state, numericExpression(factory, Number(value.body.right.text), value.body.operatorToken.kind === ts.SyntaxKind.MinusToken))
5891
6155
  }
5892
- if (isPrimitiveLiteral(value)) return command(factory, "set", state, value)
6156
+ if (isPrimitiveLiteral(value)) return command(factory, "set", state, synthesizeSerializableStateLiteral(value, factory))
5893
6157
  return undefined
5894
6158
  }
5895
6159
 
@@ -5898,7 +6162,7 @@ function command(factory, operation, state, value) {
5898
6162
  }
5899
6163
 
5900
6164
  function isPrimitiveLiteral(node) {
5901
- return ts.isStringLiteral(node) || ts.isNumericLiteral(node) || node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword
6165
+ return isPrimitiveDefaultLiteral(node)
5902
6166
  }
5903
6167
 
5904
6168
  function numericExpression(factory, value, negative) {
@@ -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.30",
3
+ "version": "0.8.1",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",