@kudzujs/core 0.6.6 → 0.6.8

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/GOAL_B.md CHANGED
@@ -120,13 +120,9 @@ The real-Worker browser check uses real wall time and requires sustained generat
120
120
  ## Delivery Order
121
121
 
122
122
  1. **Worker compiler capability**: exact syntax, graph bundling, hashing, base rewriting, diagnostics, and zero-cost exclusion.
123
- 2. **Realtime vertical slice**: mock telemetry Worker, bounded buffer, downsampling, imperative chart, and route cleanup.
124
- 3. **Shared transport**: add a layout-owned mock connection only if multiple routes prove that one Worker per route is wasteful.
125
- 4. **Device workflows**: filters, commands, timeout/error handling, and stale response suppression.
126
- 5. **Alarm workflows**: active/history views and optimistic acknowledgement with rollback.
127
- 6. **Widget expansion**: add one gauge, table, map, or real chart engine at a time only when a fixture requires it.
123
+ 2. **Capability conformance fixture**: mock telemetry Worker, bounded buffer, downsampling, imperative DOM ownership, and route cleanup.
128
124
 
129
- Each phase starts with one failing fixture and ends with correctness, lifecycle, browser, size, and build measurements.
125
+ Further work belongs to the React migration roadmap and starts from a reduced compatibility fixture that fails. Kudzu does not implement device, alarm, transport, or widget product features.
130
126
 
131
127
  ## Performance Gates
132
128
 
package/README.md CHANGED
@@ -330,9 +330,23 @@ const rows = items.map(item => <ItemRow
330
330
  />)
331
331
  ```
332
332
 
333
- The original component remains reusable across multiple lists and ordinary JSX. No component function or component runtime is shipped to the browser. Kudzu emits initial items as static HTML, then adds, removes, updates, styles, conditional branches, and moves keyed elements directly. The map may appear directly in JSX or in one top-level immutable `const` rendered once as a JSX child. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Single-level item-local `&&` and ternary JSX conditions patch only their bounded branch and mount or unmount its handlers. Item-local handlers and effects receive the latest JSON-safe item for their key. Effects mount after a row is connected and clean up when it is removed. A direct primitive item dependency such as `[item.name]`, optionally mixed with state as `[version, item.name]`, reruns only rows whose selected value changed; the replacement setup receives the complete latest item. Unrelated fields and reorder do not rerun it, while a key change removes and mounts the row. The item remains stored once in shared list state; runtime descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
333
+ The map may also stay inside a component that receives the local state array directly. The component may be declared in the page or imported by default or name from a relative TypeScript module; direct named re-exports are resolved at build time:
334
334
 
335
- Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a local-state `.map`, one identifier callback parameter, one intrinsic JSX root or top-level local or relative-imported row component, and `key={item.<field>}`. Row components accept destructured projected props, top-level single-`const` calculations and inline effects before one intrinsic return. Effect dependencies inside a row may be empty, direct primitive Kudzu state identifiers, or direct `item.<field>` properties whose selected values remain JSON-safe primitives. Whole-item, computed, nested, derived, `__proto__`, `prototype`, and `constructor` dependencies are rejected. A list alias may only be rendered once and cannot be read by other JavaScript. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, imported helpers used inside calculations, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Package or namespace row imports, same-file exported rows, reusable aliases, prop spreads/defaults/rest, children, nested item conditions, lists, or component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
335
+ ```tsx
336
+ function ItemList({ items }: { items: Item[] }) {
337
+ return <ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>
338
+ }
339
+
340
+ return <ItemList items={items} />
341
+ ```
342
+
343
+ The original row component remains reusable across multiple lists and ordinary JSX. State-backed list wrappers and row components are specialized to intrinsic JSX at build time; no component function or component runtime is shipped to the browser. Kudzu emits initial items as static HTML, then adds, removes, updates, styles, conditional branches, and moves keyed elements directly. The map may appear directly in JSX, in one top-level immutable `const` rendered once as a JSX child, or in one synchronous wrapper receiving the state identifier as a direct prop. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Single-level item-local `&&` and ternary JSX conditions patch only their bounded branch and mount or unmount its handlers. Item-local handlers and effects receive the latest JSON-safe item for their key. Effects mount after a row is connected and clean up when it is removed. A direct primitive item dependency such as `[item.name]`, optionally mixed with state as `[version, item.name]`, reruns only rows whose selected value changed; the replacement setup receives the complete latest item. Unrelated fields and reorder do not rerun it, while a key change removes and mounts the row. The item remains stored once in shared list state; runtime descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
344
+
345
+ Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a local-state `.map`, one identifier callback parameter, one intrinsic JSX root or top-level local or relative-imported row component, and `key={item.<field>}`. State-backed list wrappers use one destructured props parameter, an intrinsic return root, no effects, and a direct local-state prop. Same-file wrappers must be unexported and state-backed at every call; relative default, named/aliased, and direct named re-export wrappers are specialized per qualifying call. Row components accept destructured projected props, top-level single-`const` calculations and inline effects before one intrinsic return. Effect dependencies inside a row may be empty, direct primitive Kudzu state identifiers, or direct `item.<field>` properties whose selected values remain JSON-safe primitives. Whole-item, computed, nested, derived, `__proto__`, `prototype`, and `constructor` dependencies are rejected. A list alias may only be rendered once and cannot be read by other JavaScript. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, imported helpers used inside calculations, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Package, namespace, and star-export list wrappers, package or namespace row imports, same-file exported rows, reusable aliases, prop spreads/defaults/rest, children, nested item conditions, lists, or component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
346
+
347
+ The focused wrapper fixture emits 1,393 B raw / 500 B gzip HTML and 10,719 B raw / 4,665 B gzip JavaScript across its route capabilities. After one warm-up, seven clean builds measured 314.1, 325.3, 322.3, 327.2, 336.1, 322.4, and 315.0 ms, with a 322.4 ms median.
348
+
349
+ The three-wrapper relative-import fixture emits 2,279 B raw / 629 B gzip HTML and 11,370 B raw / 4,828 B gzip JavaScript, including one imported-wrapper item expression; its unused component handler module is not emitted. After one warm-up, seven clean builds measured 340.2, 349.4, 352.8, 335.0, 354.1, 364.3, and 349.5 ms, with a 349.5 ms median.
336
350
 
337
351
  ## Effects
338
352
 
@@ -394,6 +408,8 @@ useEffect(() => {
394
408
 
395
409
  Kudzu resolves the path from the callback source, bundles the Worker and its relative TypeScript imports separately as content-hashed ESM under `assets/workers`, and rewrites the constructor to the base-prefixed same-origin asset URL. The Worker is fetched only when the effect mounts; it is not a capability script, preload, or window import. Unrendered effect handlers do not cause their Worker root to be emitted. This slice requires unshadowed global `Worker` and `URL`, exact `import.meta.url`, a relative `.worker.ts` string literal, and exactly `{ type: "module" }`. Worker graphs reject JSX, package runtime imports, TypeScript import-equals declarations, dynamic imports, `require()`, missing files, and paths outside `src`. Worker source cannot be imported or re-exported as an ordinary runtime module. Construction in event handlers, imported helpers, or imported keyed-row effects is rejected; move keyed-row Worker ownership to a directly compiled page or local component effect. Public or absolute JavaScript Workers remain ordinary browser code and are not transformed.
396
410
 
411
+ Route-owned browser requests use the same dependency-effect cleanup rather than a request runtime. Keep the effect callback synchronous, create an `AbortController` and timeout inside it, start the promise chain, and directly return cleanup that clears the timer and aborts the request. A command-only handler can update primitive command/revision state; the dependency effect then owns the request. Replacement or route disposal runs cleanup before the next setup and invalidates the old effect's setters. Applications must still check `response.ok`, distinguish timeout from other failures, and guard any imperative DOM writes themselves.
412
+
397
413
  A matched mount-fetch benchmark renders a title and two keyed rows from local JSON. With one warm-up and seven rotating clean builds, Kudzu shipped initial HTML, 3.4 KB initial JS gzip, 8.1 KB total output, and built in 374 ms. React CSR shipped no initial content, 59.3 KB initial JS gzip, 189.2 KB total output, and built in 992 ms. Hand-written ESM shipped 534 B initial JS gzip, 1.2 KB total output, and built in 210 ms. Fresh-profile Chrome medians to loaded data were 157.9 ms, 166.5 ms, and 153.4 ms respectively.
398
414
 
399
415
  A matched resize-listener cleanup fixture, measured with the same warm-up and seven rotating clean builds, shipped 1.2 KB JavaScript gzip and built in 402 ms with Kudzu. Svelte shipped 10.1 KB and built in 861 ms, Vue shipped 23.6 KB and built in 768 ms, and React shipped 59.1 KB and built in 1,058 ms. Kudzu and the 127 B hand-written Astro baseline emitted initial HTML; the CSR fixtures did not.
@@ -21,7 +21,7 @@ Exact relative `.worker.ts` constructors in inline effects are validated and bun
21
21
 
22
22
  Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime. Source CSS and global `kudzu.config` styles are emitted in document heads before `afterBuild()` runs; static stylesheet links in component JSX fail compilation instead of loading from the body.
23
23
 
24
- Direct JSON-safe primitive keyed-item dependencies subscribe each row record to its owning list commit and compare selected fields after `list-runtime.js` synchronously refreshes the row marker. Only changed rows rerun with the complete latest item; reorder compares equal, unrelated fields do nothing, and key changes remain remove plus mount. Builds without item dependencies emit no item reader or list-state subscription code.
24
+ Same-file and relative-imported components receiving a direct local-state array prop are specialized to intrinsic JSX before keyed-list analysis, so their component function is not retained in the browser. Handler modules are emitted only when a rendered descriptor references them, preventing specialized imported components from adding dead browser assets. Direct JSON-safe primitive keyed-item dependencies subscribe each row record to its owning list commit and compare selected fields after `list-runtime.js` synchronously refreshes the row marker. Only changed rows rerun with the complete latest item; reorder compares equal, unrelated fields do nothing, and key changes remain remove plus mount. Builds without item dependencies emit no item reader or list-state subscription code.
25
25
 
26
26
  `kudzu.config` may opt one emitted shared-layout group into same-document navigation with legacy `navigation: { routes: ["/product", "/items/[id]"] }`, or multiple groups with `navigation: { groups: [{ routes: [...] }, { routes: [...] }] }`. The forms are mutually exclusive. Identities are globally unique emitted exact paths or `runtimeParams` patterns; each group uses one page-exported layout function identity. Runtime records securely match concrete pathnames under `base`, and their cache-safe parameter initializer runs before route DOM/effects mount on every transition. Each group receives a deterministic route-hashed asset specialized to only its records, pattern decoder, and effect/parameter lifecycle needs. Cross-group and ungrouped anchors remain native and are not prefetched; overlapping path domains across groups fail the build. Route effect entries export cache-safe layout and route mount functions: layout effects, including conditional/keyed DOM-owned effects, persist for the group session; route effects receive a fresh owner registry after each route insertion; and non-persisted page disposal cleans route before layout. Direct primitive state, runtime parameter, and keyed-item property dependencies and cleanup are supported. Fragment payloads and coordinated View Transitions are not implemented.
27
27
 
@@ -70,6 +70,7 @@ export async function build({ quiet = false, minify = true } = {}) {
70
70
  const emittedRoutes = new Set()
71
71
  const emittedApplicationRoutes = new Set()
72
72
  const emittedNavigationRecords = []
73
+ const renderedHandlerUrls = new Set()
73
74
  const styleUrls = [...new Set([
74
75
  ...cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)),
75
76
  ...configuredStyles
@@ -129,6 +130,7 @@ export async function build({ quiet = false, minify = true } = {}) {
129
130
  runtimeParams: runtimeSchema?.params,
130
131
  ...(navigable ? { navigationAsset: navigationGroup.assetPath, applicationId: navigationGroup.applicationId, layoutId: navigationGroup.layoutId, routeId: applicationRoute } : {})
131
132
  }, props, module.layout)
133
+ for (const url of result.handlerModules) renderedHandlerUrls.add(url)
132
134
  if (navigationGroup) {
133
135
  navigationGroup.hasEffects ||= result.hasEffects
134
136
  navigationGroup.hasParams ||= result.hasParams
@@ -163,11 +165,12 @@ export async function build({ quiet = false, minify = true } = {}) {
163
165
 
164
166
  const assetsDirectory = join(outputDirectory, "assets")
165
167
  await mkdir(assetsDirectory, { recursive: true })
168
+ const emittedHandlerModules = handlerModules.filter(module => renderedHandlerUrls.has(assetPath(base, `assets/${module.path}`)))
166
169
  const renderedEffects = new Set(plans.flatMap(plan => plan.effects.map(effect => `${effect.module}:${effect.handler}`)))
167
170
  const renderedWorkerReferences = workerReferences.filter(reference => renderedEffects.has(`${reference.module}:${reference.handler}`))
168
171
  if (renderedWorkerReferences.length && await exists(join(root, "public", "assets", "workers"))) throw new Error("public/assets/workers collides with Kudzu's generated Worker asset namespace")
169
172
  const workerAssets = await emitWorkers(renderedWorkerReferences, sourceFileSet, assetsDirectory, base, minify)
170
- for (const module of handlerModules) {
173
+ for (const module of emittedHandlerModules) {
171
174
  for (const reference of workerReferences) {
172
175
  if (reference.module !== assetPath(base, `assets/${module.path}`)) continue
173
176
  const url = workerAssets.get(reference.placeholder) ?? "about:blank"
@@ -192,7 +195,7 @@ export async function build({ quiet = false, minify = true } = {}) {
192
195
  const hasNestedStateCaptures = hasNestedCaptureState(plans)
193
196
  const hasSetterCaptures = hasCaptureType(plans, "setter")
194
197
  const hasEffectCaptures = plans.some(plan => plan.effects.some(effect => Object.keys(effect.scope).length))
195
- const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
198
+ const nativeModules = emittedHandlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
196
199
  const hasNativeHandlers = nativeModules.length > 0
197
200
  const hasEffects = effectEntries.length > 0
198
201
  const hasNavigableEffects = effectEntries.some(entry => entry.navigable)
@@ -290,7 +293,7 @@ export async function build({ quiet = false, minify = true } = {}) {
290
293
  await writeJavaScript(join(assetsDirectory, group.assetName), specializeNavigationEffects(navigationRuntime, group.hasEffects || group.hasParams), minify)
291
294
  }
292
295
  }
293
- for (const handlerModule of handlerModules) {
296
+ for (const handlerModule of emittedHandlerModules) {
294
297
  const output = join(assetsDirectory, handlerModule.path)
295
298
  await mkdir(resolve(output, ".."), { recursive: true })
296
299
  await writeJavaScript(output, handlerModule.code, minify)
@@ -305,11 +308,11 @@ export async function build({ quiet = false, minify = true } = {}) {
305
308
  await mkdir(dirname(output), { recursive: true })
306
309
  await writeJavaScript(output, entry.navigable
307
310
  ? entry.effects.some(effect => effect.owner)
308
- ? printOwnedNavigableEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base)
309
- : printNavigableEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base)
310
- : printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath, runtimeName(entry.usesDependencyRuntime)), minify)
311
+ ? printOwnedNavigableEffectEntry(entry.effects, output, emittedHandlerModules, assetsDirectory, base)
312
+ : printNavigableEffectEntry(entry.effects, output, emittedHandlerModules, assetsDirectory, base)
313
+ : printEffectEntry(entry.effects, output, emittedHandlerModules, assetsDirectory, base, entry.paramPath, runtimeName(entry.usesDependencyRuntime)), minify)
311
314
  }
312
- const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
315
+ const clientModules = await collectClientModules(emittedHandlerModules.flatMap(module => module.clientImports), sourceFileSet)
313
316
  for (const file of clientModules) {
314
317
  const output = join(assetsDirectory, clientModulePath(file))
315
318
  await mkdir(resolve(output, ".."), { recursive: true })
@@ -317,7 +320,7 @@ export async function build({ quiet = false, minify = true } = {}) {
317
320
  }
318
321
  if (clientModules.length) {
319
322
  await bundle({
320
- entryPoints: handlerModules.map(module => join(assetsDirectory, module.path)),
323
+ entryPoints: emittedHandlerModules.map(module => join(assetsDirectory, module.path)),
321
324
  outbase: join(assetsDirectory, "handlers"),
322
325
  outdir: join(assetsDirectory, "handlers"),
323
326
  entryNames: "[dir]/[name]",
@@ -1701,8 +1704,56 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1701
1704
  }
1702
1705
  }
1703
1706
  }
1707
+ const fail = (node, message) => {
1708
+ throw sourceNodeError(node, sourceFile, message)
1709
+ }
1710
+ const componentSpecializations = new WeakMap()
1711
+ const specializedDeclarations = new WeakSet()
1712
+ const stateBackedComponentFunctions = new WeakSet()
1713
+ const stateBackedComponentRoots = []
1714
+ for (const [name, component] of components) {
1715
+ const calls = jsxTagUses(sourceFile, name)
1716
+ const stateBackedCalls = calls.filter(call => isStateBackedListComponentCall(call, component.function, settersByFunction.get(nearestFunction(call)) ?? new Map()))
1717
+ if (!stateBackedCalls.length) continue
1718
+ if (isExportedDeclaration(component.declaration)) fail(component.declaration, `State-backed list component ${name} cannot be exported`)
1719
+ if (identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `State-backed list component ${name} may only be referenced as JSX`)
1720
+ if (stateBackedCalls.length !== calls.length) fail(component.declaration, `State-backed list component ${name} must receive its mapped prop from local state at every call`)
1721
+ for (const call of stateBackedCalls) {
1722
+ const specialization = specializeComponentCall(call, component.function, sourceFile, factory, context, fail)
1723
+ if (specialization.effects.length) fail(call, "State-backed list components cannot declare effects")
1724
+ componentSpecializations.set(call, specialization)
1725
+ stateBackedComponentRoots.push(specialization.root)
1726
+ }
1727
+ specializedDeclarations.add(component.declaration)
1728
+ stateBackedComponentFunctions.add(component.function)
1729
+ }
1730
+ for (const [name, binding] of importBindings) {
1731
+ if (binding.kind === "namespace") continue
1732
+ const calls = jsxTagUses(sourceFile, name)
1733
+ if (!calls.some(call => jsxCallHasDirectStateProp(call, settersByFunction.get(nearestFunction(call)) ?? new Map()))) continue
1734
+ const imported = binding.kind === "default" ? "default" : binding.imported
1735
+ let component
1736
+ try {
1737
+ component = resolveComponentExport(binding.target, imported, importedSource, sourceFiles)
1738
+ } catch (error) {
1739
+ if (error.message.includes("does not export a statically analyzable keyed list component")) continue
1740
+ throw error
1741
+ }
1742
+ const stateBackedCalls = calls.filter(call => isStateBackedListComponentCall(call, component, settersByFunction.get(nearestFunction(call)) ?? new Map()))
1743
+ for (const call of stateBackedCalls) {
1744
+ const specialization = specializeComponentCall(call, component, sourceFile, factory, context, fail)
1745
+ if (specialization.effects.length) fail(call, "State-backed list components cannot declare effects")
1746
+ componentSpecializations.set(call, specialization)
1747
+ stateBackedComponentRoots.push(specialization.root)
1748
+ }
1749
+ }
1704
1750
  const rawRenderedLists = []
1705
1751
  const collectRenderedLists = node => {
1752
+ const specialization = componentSpecializations.get(node)
1753
+ if (specialization) {
1754
+ collectRenderedLists(specialization.root)
1755
+ return
1756
+ }
1706
1757
  if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
1707
1758
  const parts = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction))
1708
1759
  if (parts) rawRenderedLists.push({ node, parts })
@@ -1710,9 +1761,6 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1710
1761
  ts.forEachChild(node, collectRenderedLists)
1711
1762
  }
1712
1763
  collectRenderedLists(sourceFile)
1713
- const fail = (node, message) => {
1714
- throw sourceNodeError(node, sourceFile, message)
1715
- }
1716
1764
  const rejectUnsupportedRenderControl = node => {
1717
1765
  if (ts.isIfStatement(node) && containsRenderControl(node, jsxLocalsByFunction.get(nearestFunction(node)) ?? new Set())) {
1718
1766
  const setters = settersForNode(node, settersByFunction)
@@ -1727,8 +1775,6 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1727
1775
  const tag = jsxTagName(parts.root)
1728
1776
  return tag && ts.isIdentifier(tag) && tag.text[0] === tag.text[0].toUpperCase() ? [tag.text] : []
1729
1777
  }))
1730
- const componentSpecializations = new WeakMap()
1731
- const specializedDeclarations = new WeakSet()
1732
1778
  const keyedComponentCalls = new Set(rawRenderedLists.map(({ parts }) => parts.root))
1733
1779
  for (const name of listComponentNames) {
1734
1780
  let component = components.get(name)
@@ -1740,8 +1786,12 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1740
1786
  component = { function: resolveComponentExport(binding.target, imported, importedSource, sourceFiles), declaration: undefined }
1741
1787
  }
1742
1788
  if (local && isExportedDeclaration(component.declaration)) fail(component.declaration, `Keyed list component ${name} cannot be exported`)
1743
- const calls = jsxTagUses(sourceFile, name)
1744
- if (local && identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `Keyed list component ${name} may only be referenced as JSX`)
1789
+ const declaredCalls = jsxTagUses(sourceFile, name)
1790
+ if (local && identifierReferenceCount(sourceFile, name) !== declaredCalls.length) fail(component.declaration, `Keyed list component ${name} may only be referenced as JSX`)
1791
+ const calls = [
1792
+ ...declaredCalls.filter(call => !stateBackedComponentFunctions.has(nearestFunction(call))),
1793
+ ...stateBackedComponentRoots.flatMap(root => jsxTagUses(root, name))
1794
+ ]
1745
1795
  for (const call of calls) {
1746
1796
  const specialization = specializeComponentCall(call, component.function, sourceFile, factory, context, fail)
1747
1797
  if (specialization.effects.length && !keyedComponentCalls.has(call)) fail(call, "Effectful keyed row components may only be used directly as keyed map rows")
@@ -2137,12 +2187,51 @@ function keyedListParts(expression, setters) {
2137
2187
  const root = unwrapExpression(callback.body)
2138
2188
  if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) throw new Error("Keyed list map callback must return one JSX element")
2139
2189
  const attributes = ts.isJsxElement(root) ? root.openingElement.attributes : root.attributes
2140
- const key = attributes.properties.find(attribute => ts.isJsxAttribute(attribute) && attribute.name.getText() === "key")
2190
+ const key = attributes.properties.find(attribute => ts.isJsxAttribute(attribute) && ts.isIdentifier(attribute.name) && attribute.name.text === "key")
2141
2191
  const field = key && ts.isJsxAttribute(key) && key.initializer && ts.isJsxExpression(key.initializer) && key.initializer.expression && directProperty(key.initializer.expression, callback.parameters[0].name.text)
2142
2192
  if (!field) throw new Error(`Keyed list root must have key={${callback.parameters[0].name.text}.<field>}`)
2143
2193
  return { state, callback, root, item: callback.parameters[0].name.text, keyField: field }
2144
2194
  }
2145
2195
 
2196
+ function isStateBackedListComponentCall(call, component, setters) {
2197
+ if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) return false
2198
+ const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
2199
+ const stateNames = new Set(setters.values())
2200
+ const mappedProps = new Set()
2201
+ for (const element of component.parameters[0].name.elements) {
2202
+ if (!ts.isIdentifier(element.name)) continue
2203
+ const prop = (element.propertyName ?? element.name).getText()
2204
+ const attribute = attributes.properties.find(entry => ts.isJsxAttribute(entry) && entry.name.getText() === prop)
2205
+ const value = attribute?.initializer && ts.isJsxExpression(attribute.initializer) ? unwrapExpression(attribute.initializer.expression) : undefined
2206
+ if (value && ts.isIdentifier(value) && stateNames.has(value.text)) mappedProps.add(element.name.text)
2207
+ }
2208
+ if (!mappedProps.size) return false
2209
+ const returned = ts.isBlock(component.body)
2210
+ ? [...component.body.statements].reverse().find(ts.isReturnStatement)?.expression
2211
+ : component.body
2212
+ if (!returned || !containsJsx(returned)) return false
2213
+ let found = false
2214
+ const visit = node => {
2215
+ if (found || node !== returned && isFunctionLike(node)) return
2216
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && ts.isIdentifier(node.expression.expression) && mappedProps.has(node.expression.expression.text)) {
2217
+ found = true
2218
+ return
2219
+ }
2220
+ ts.forEachChild(node, visit)
2221
+ }
2222
+ visit(returned)
2223
+ return found
2224
+ }
2225
+
2226
+ function jsxCallHasDirectStateProp(call, setters) {
2227
+ const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
2228
+ const stateNames = new Set(setters.values())
2229
+ return attributes.properties.some(attribute => {
2230
+ const value = ts.isJsxAttribute(attribute) && attribute.initializer && ts.isJsxExpression(attribute.initializer) ? unwrapExpression(attribute.initializer.expression) : undefined
2231
+ return value && ts.isIdentifier(value) && stateNames.has(value.text)
2232
+ })
2233
+ }
2234
+
2146
2235
  function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions) {
2147
2236
  const fail = (node, message) => {
2148
2237
  throw sourceNodeError(node, sourceFile, message)
@@ -70,6 +70,7 @@ export function renderPage<Props = Record<string, never>>(
70
70
  hasLists: boolean
71
71
  hasListStyles: boolean
72
72
  hasStateSeed: boolean
73
+ handlerModules: string[]
73
74
  plan: {
74
75
  states: Array<{ id: string; name: string; initialValue: unknown; lifetime?: "layout" | "route" }>
75
76
  params: Array<{ name: string; id: string }>
@@ -99,6 +99,7 @@ export function useEffect(callback, dependencies, module, handler, states, scope
99
99
  owners.push(owner)
100
100
  }
101
101
  if (!renderContext.listDepth || list) renderContext.effects.push({ module, handler, states, scope, source, renderScope: renderContext.renderScope, ...(dependencyIds.length ? { dependencies: dependencyIds } : {}), ...(itemDependencies.length ? { itemDependencies, listState: renderContext.listRoot.state } : {}), ...(cleanup ? { cleanup: true } : {}), ...(owner ? { owner } : {}), ...(list ? { list: true } : {}) })
102
+ renderContext.handlerModules.add(module)
102
103
  renderContext.hasBehaviors = true
103
104
  renderContext.hasEffects = true
104
105
  }
@@ -141,6 +142,7 @@ export function behavior(commands) {
141
142
  }
142
143
 
143
144
  export function nativeBehavior(module, handler, states, scope) {
145
+ renderContext?.handlerModules.add(module)
144
146
  return {
145
147
  [nativeBehaviorMarker]: true,
146
148
  module,
@@ -187,6 +189,7 @@ export function listField(read, field) {
187
189
  }
188
190
 
189
191
  export function listExpression(read, module, handler) {
192
+ renderContext?.handlerModules.add(module)
190
193
  const value = renderContext?.listTemplate ? undefined : read()
191
194
  if (value && typeof value.then === "function") throw new Error("Derived keyed list item expressions must return synchronous values")
192
195
  return { [listExpressionMarker]: true, module, handler, value }
@@ -197,6 +200,7 @@ export function listItem() {
197
200
  }
198
201
 
199
202
  export function listConditional(kind, read, truthy, falsy, module, handler) {
203
+ renderContext?.handlerModules.add(module)
200
204
  return { [listConditionalMarker]: true, kind, value: renderContext?.listTemplate ? undefined : read(), truthy, falsy, module, handler }
201
205
  }
202
206
 
@@ -230,6 +234,7 @@ function assertListValue(value, seen) {
230
234
  }
231
235
 
232
236
  function reactiveDescriptor(module, handler, states, scope) {
237
+ renderContext?.handlerModules.add(module)
233
238
  const scopeStates = {}
234
239
  const serializedScope = {}
235
240
  const scopeBindings = {}
@@ -295,7 +300,7 @@ function serializeCapture(name, value, seen) {
295
300
  }
296
301
 
297
302
  export async function renderPage(component, metadata = {}, props = {}, layout) {
298
- renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], effectOwners: [], contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
303
+ renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], effectOwners: [], contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerModules: new Set(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
299
304
 
300
305
  try {
301
306
  const page = { [routeScopeMarker]: true, component, props }
@@ -370,6 +375,7 @@ export async function renderPage(component, metadata = {}, props = {}, layout) {
370
375
  hasLists: renderContext.hasLists,
371
376
  hasListStyles: renderContext.hasListStyles,
372
377
  hasStateSeed: initialState.length > 0,
378
+ handlerModules: [...renderContext.handlerModules],
373
379
  plan: {
374
380
  states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
375
381
  params: renderContext.paramEntries,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",