@kudzujs/core 0.5.4 → 0.5.6

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
@@ -6,7 +6,7 @@
6
6
 
7
7
  HTML-first TSX framework with synchronous state semantics and no virtual DOM.
8
8
 
9
- Kudzu keeps the familiar function-component, props, children, event-handler, and `useState` shape. Static components compile to HTML. Simple interactions compile to small behavior commands, while normal sync or async JavaScript handlers compile to external ESM.
9
+ Kudzu keeps the familiar function-component, props, children, event-handler, `useState`, and mount-effect shape. Static components compile to HTML. Simple interactions compile to small behavior commands, while normal sync or async JavaScript handlers and mount effects compile to external ESM.
10
10
 
11
11
  > Experimental `0.4.x`: the compiler API and supported TSX surface may change.
12
12
 
@@ -281,7 +281,7 @@ const rows = items.map(item =>
281
281
  return <ul>{rows}</ul>
282
282
  ```
283
283
 
284
- The root may also be a top-level same-file row component. Kudzu specializes each call at build time, so projected props, callback props, and simple local calculations compile to the same intrinsic list template:
284
+ The root may also be a top-level row component declared in the same file or imported from a relative TypeScript module. Default, named, aliased, and named re-export imports are resolved at build time. Kudzu specializes each call, so projected props, callback props, and simple local calculations compile to the same intrinsic list template:
285
285
 
286
286
  ```tsx
287
287
  function ItemRow({ name, done, onRemove }: {
@@ -306,7 +306,28 @@ const rows = items.map(item => <ItemRow
306
306
 
307
307
  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 use direct DOM listeners and receive the latest JSON-safe item for their key, including after updates, additions, and reorders. The item remains stored once in shared list state; handler descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
308
308
 
309
- 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 same-file row component, and `key={item.<field>}`. Row components accept destructured projected props and top-level single-`const` calculations before one intrinsic return. 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, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Exported or imported row components, 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>`.
309
+ 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 and top-level single-`const` calculations before one intrinsic return. 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>`.
310
+
311
+ ## Mount Effects
312
+
313
+ Browser-only initial work uses the familiar empty-dependency effect shape:
314
+
315
+ ```tsx
316
+ import { useEffect, useState } from "@kudzujs/core"
317
+
318
+ const [items, setItems] = useState([])
319
+
320
+ useEffect(async () => {
321
+ const response = await fetch("/api/items")
322
+ setItems(await response.json())
323
+ }, [])
324
+ ```
325
+
326
+ Kudzu does not execute the effect during static rendering and does not ship the component. It emits one route-specific effect entry that invokes the compiled callback against existing logical state and direct DOM commit capabilities. Effects may update reactive text, attributes, conditions, and keyed lists. Multiple effects start independently in source order, and one synchronous or asynchronous failure is reported without suppressing later effects.
327
+
328
+ Only inline block-bodied callbacks with a literal empty dependency array are supported. Dependencies, cleanup or other return values, callback parameters, and non-serializable captures are rejected at build time. Pages without effects receive no effect entry and retain their existing output.
329
+
330
+ 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.
310
331
 
311
332
  ## Normal JavaScript
312
333
 
@@ -377,6 +398,7 @@ Supported:
377
398
  - Static trusted `dangerouslySetInnerHTML`
378
399
  - Base-path deployments, multiple CSS files, and `afterBuild`
379
400
  - Primitive `useState` bindings
401
+ - Mount-only `useEffect(fn, [])` compiled to route-specific ESM
380
402
  - Synchronous and async event handlers
381
403
  - Relative imported helpers in native handlers
382
404
  - Serializable component-local captures
@@ -8,10 +8,11 @@
8
8
  - `binding-runtime.js`: optional generic attributes, form properties, comment-bounded text patches, and conditional range patches.
9
9
  - `list-runtime.js`: optional keyed list validation, external item-expression evaluation, item-local conditional ranges, dynamic styles and item-handler scopes, moves, and cleanup.
10
10
  - `serialization.js`: capture deserialization shared by binding and native handlers.
11
+ - `effect-runtime.js`: optional state and capture context for route-specific mount-effect entries.
11
12
  - `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
12
13
  - `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
13
14
  - `*.d.ts`: public TypeScript and JSX declarations.
14
15
 
15
- Static routes receive no browser runtime. Command routes receive `runtime.js`; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators and their bundled relative TypeScript helpers live under `dist/assets/handlers/`; shared helper chunks are emitted only when multiple handler entries need them. The dev server derives stable state identities from route-unique state variable names in each route plan; every state sharing a duplicate name is omitted. It then injects its SSE reload, short-lived full-URL-scoped logical-state snapshot, and build-error client into responses only, never into `dist/`. Snapshots are consumed even when the next page is static or broken. Reload restoration covers compatible framework state, not uncontrolled DOM state, focus, selection, or imperative mutations.
16
+ Static routes receive no browser runtime. Command routes receive `runtime.js`; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`; mount effects add `effect-runtime.js` and one route-specific entry. List builds remove unused text-range, attribute, event, expression, condition, seed, and mount branches. Effect builds omit capture deserialization entirely when every effect scope is empty. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators and their bundled relative TypeScript helpers live under `dist/assets/handlers/`; shared helper chunks are emitted only when multiple handler entries need them. The dev server derives stable state identities from route-unique state variable names in each route plan; every state sharing a duplicate name is omitted. It then injects its SSE reload, short-lived full-URL-scoped logical-state snapshot, and build-error client into responses only, never into `dist/`. Snapshots are consumed even when the next page is static or broken. Reload restoration covers compatible framework state, not uncontrolled DOM state, focus, selection, or imperative mutations.
16
17
 
17
18
  Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
@@ -29,10 +29,11 @@ export async function build({ quiet = false, minify = true } = {}) {
29
29
  const cssFiles = projectFiles.filter(file => file.endsWith(".css")).sort()
30
30
  if (!sourceFiles.length) throw new Error("No TypeScript files found in src/")
31
31
  const sourceFileSet = new Set(sourceFiles)
32
+ const sourceIndex = new Map(await Promise.all(sourceFiles.map(async file => [file, await readFile(file, "utf8")])))
32
33
 
33
34
  const handlerModules = []
34
35
  for (const file of sourceFiles) {
35
- const handlerModule = await compile(file, sourceFileSet, base)
36
+ const handlerModule = await compile(file, sourceFileSet, sourceIndex, base)
36
37
  if (handlerModule) handlerModules.push(handlerModule)
37
38
  }
38
39
 
@@ -45,6 +46,7 @@ export async function build({ quiet = false, minify = true } = {}) {
45
46
  let listStyleCount = 0
46
47
  let stateSeedCount = 0
47
48
  const plans = []
49
+ const effectEntries = []
48
50
  const emittedRoutes = new Set()
49
51
  const styleUrls = cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`))
50
52
 
@@ -57,17 +59,20 @@ export async function build({ quiet = false, minify = true } = {}) {
57
59
  for (const { params, props } of entries) {
58
60
  const route = routeFromPage(pageFile, params)
59
61
  const routePath = withBase(base, `/${route}`)
62
+ const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
60
63
  if (emittedRoutes.has(routePath)) throw new Error(`Duplicate route: ${routePath}`)
61
64
  emittedRoutes.add(routePath)
62
65
  const result = await renderPage(module.default, {
63
66
  ...(module.metadata ?? {}),
64
67
  styles: styleUrls.length ? styleUrls : false,
65
- base
68
+ base,
69
+ effectAsset: assetPath(base, `assets/${effectPath}`)
66
70
  }, props)
67
71
  const routeDirectory = join(outputDirectory, route)
68
72
  await mkdir(routeDirectory, { recursive: true })
69
73
  await writeFile(join(routeDirectory, "index.html"), result.html)
70
74
  plans.push({ route: routePath, ...result.plan })
75
+ if (result.hasEffects) effectEntries.push({ path: effectPath, effects: result.plan.effects })
71
76
  if (result.hasBehaviors) behaviorCount++
72
77
  if (result.hasBindings) bindingCount++
73
78
  if (result.hasLists) listCount++
@@ -82,19 +87,37 @@ export async function build({ quiet = false, minify = true } = {}) {
82
87
  const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
83
88
  const hasTextBindings = plans.some(plan => plan.bindings.some(binding => binding.target === "text"))
84
89
  const hasListConditions = plans.some(plan => plan.lists.some(list => list.conditions))
90
+ const hasListTextRanges = plans.some(plan => plan.lists.some(list => list.textRanges))
91
+ const hasListAttributes = plans.some(plan => plan.lists.some(list => list.attributes))
92
+ const hasListEvents = plans.some(plan => plan.lists.some(list => list.events))
93
+ const hasListExpressions = plans.some(plan => plan.lists.some(list => list.expressions))
94
+ const hasListExpressionAttributes = plans.some(plan => plan.lists.some(list => list.expressionAttributes))
95
+ const hasListSeeds = plans.some(plan => plan.lists.some(list => list.seed))
96
+ const hasListAsyncParts = hasListExpressions || hasListExpressionAttributes || hasListConditions
97
+ const hasListMounts = hasListConditions || plans.some(plan => plan.lists.some(list => list.mount))
85
98
  const hasNestedStateCaptures = hasNestedCaptureState(plans)
86
99
  const hasSetterCaptures = hasCaptureType(plans, "setter")
100
+ const hasEffectCaptures = plans.some(plan => plan.effects.some(effect => Object.keys(effect.scope).length))
87
101
  const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
88
102
  const hasNativeHandlers = nativeModules.length > 0
103
+ const hasEffects = effectEntries.length > 0
89
104
  if (behaviorCount) {
90
105
  const runtimeFile = bindingCount || listCount || hasNativeHandlers ? "./shared-runtime.js" : "./runtime.js"
91
106
  const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
92
107
  await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
93
108
  }
94
- if (bindingCount || hasNativeHandlers) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify, {
109
+ if (bindingCount || hasNativeHandlers || hasEffectCaptures) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify, {
95
110
  "globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures),
96
111
  "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
97
112
  })
113
+ if (hasEffects) {
114
+ let effectRuntime = await readFile(new URL("./effect-runtime.js", import.meta.url), "utf8")
115
+ effectRuntime = hasEffectCaptures ? effectRuntime.replace('"./serialization.js"', '"./kudzu-serialization.js"') : effectRuntime.replace(/^import[^\n]+\n/, "")
116
+ await writeBundledJavaScript(join(assetsDirectory, "kudzu-effect.js"), effectRuntime, minify, {
117
+ "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures),
118
+ "globalThis.__KUDZU_EFFECT_CAPTURES__": String(hasEffectCaptures)
119
+ })
120
+ }
98
121
  if (bindingCount || listStyleCount) await writeJavaScript(join(assetsDirectory, "kudzu-style.js"), await readFile(new URL("./style.js", import.meta.url), "utf8"), minify)
99
122
  if (bindingCount) {
100
123
  const bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
@@ -117,7 +140,17 @@ export async function build({ quiet = false, minify = true } = {}) {
117
140
  }`
118
141
  listRuntime = listRuntime.replace(" /* list-style */", listStyleCount ? stylePatch : "")
119
142
  if (listStyleCount) listRuntime = `import { serializeStyle } from "./kudzu-style.js"\n${listRuntime}`
120
- await writeBundledJavaScript(join(assetsDirectory, "kudzu-list.js"), listRuntime, minify, { __KUDZU_LIST_CONDITIONS__: String(hasListConditions) })
143
+ await writeBundledJavaScript(join(assetsDirectory, "kudzu-list.js"), listRuntime, minify, {
144
+ __KUDZU_LIST_CONDITIONS__: String(hasListConditions),
145
+ __KUDZU_LIST_TEXT_RANGES__: String(hasListTextRanges),
146
+ __KUDZU_LIST_ATTRIBUTES__: String(hasListAttributes),
147
+ __KUDZU_LIST_EVENTS__: String(hasListEvents),
148
+ __KUDZU_LIST_EXPRESSIONS__: String(hasListExpressions),
149
+ __KUDZU_LIST_EXPRESSION_ATTRIBUTES__: String(hasListExpressionAttributes),
150
+ __KUDZU_LIST_SEEDS__: String(hasListSeeds),
151
+ __KUDZU_LIST_ASYNC_PARTS__: String(hasListAsyncParts),
152
+ __KUDZU_LIST_MOUNTS__: String(hasListMounts)
153
+ })
121
154
  }
122
155
  if (hasNativeHandlers) {
123
156
  const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
@@ -132,6 +165,11 @@ export async function build({ quiet = false, minify = true } = {}) {
132
165
  await mkdir(resolve(output, ".."), { recursive: true })
133
166
  await writeJavaScript(output, handlerModule.code, minify)
134
167
  }
168
+ for (const entry of effectEntries) {
169
+ const output = join(assetsDirectory, entry.path)
170
+ await mkdir(dirname(output), { recursive: true })
171
+ await writeJavaScript(output, printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base), minify)
172
+ }
135
173
  const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
136
174
  for (const file of clientModules) {
137
175
  const output = join(assetsDirectory, clientModulePath(file))
@@ -181,6 +219,32 @@ function specializeNativeRuntime(source, events, modules) {
181
219
  return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
182
220
  }
183
221
 
222
+ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base) {
223
+ const moduleUrls = [...new Set(effects.map(effect => effect.module))]
224
+ const modules = moduleUrls.map(url => {
225
+ const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
226
+ if (!module) throw new Error(`Effect handler module was not emitted: ${url}`)
227
+ return module
228
+ })
229
+ const imports = [
230
+ `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
231
+ `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
232
+ ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
233
+ ]
234
+ const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
235
+ return `${imports.join("\n")}
236
+ const effects = ${inlineJson(effects)}
237
+ const modules = new Map([${entries}])
238
+ for (const effect of effects) {
239
+ try {
240
+ const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, effect.scope))
241
+ if (result && typeof result.then === "function") result.catch(error => console.error(error))
242
+ } catch (error) {
243
+ console.error(error)
244
+ }
245
+ }`
246
+ }
247
+
184
248
  function hasCaptureType(value, type) {
185
249
  if (!value || typeof value !== "object") return false
186
250
  if (value.type === type) return true
@@ -369,9 +433,10 @@ function escapeHtml(value) {
369
433
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
370
434
  }
371
435
 
372
- async function compile(file, sourceFiles, base) {
373
- const source = await readFile(file, "utf8")
436
+ async function compile(file, sourceFiles, sourceIndex, base) {
437
+ const source = sourceIndex.get(file)
374
438
  const nativeHandlers = []
439
+ const effectHandlers = []
375
440
  const reactiveBindings = []
376
441
  const listExpressions = []
377
442
  const clientImports = new Set()
@@ -384,7 +449,7 @@ async function compile(file, sourceFiles, base) {
384
449
  jsx: ts.JsxEmit.ReactJSX,
385
450
  jsxImportSource: "@kudzujs/core"
386
451
  },
387
- transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, clientImports)] },
452
+ transformers: { before: [createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, sourceIndex, clientImports)] },
388
453
  reportDiagnostics: true
389
454
  })
390
455
 
@@ -397,10 +462,11 @@ async function compile(file, sourceFiles, base) {
397
462
  await mkdir(resolve(output, ".."), { recursive: true })
398
463
  await writeFile(output, result.outputText)
399
464
 
400
- if (!nativeHandlers.length && !reactiveBindings.length && !listExpressions.length) return undefined
465
+ if (!nativeHandlers.length && !effectHandlers.length && !reactiveBindings.length && !listExpressions.length) return undefined
466
+ const callbacks = [...nativeHandlers, ...effectHandlers]
401
467
  const moduleSource = [
402
- printClientImports(nativeHandlers.flatMap(handler => handler.imports), handlerPath),
403
- ...nativeHandlers.map(handler => printNativeHandler(handler)),
468
+ printClientImports(callbacks.flatMap(handler => handler.imports), handlerPath),
469
+ ...callbacks.map(handler => printNativeHandler(handler)),
404
470
  ...reactiveBindings.map(entry => printReactiveBinding(entry)),
405
471
  ...listExpressions.map(entry => printListExpression(entry))
406
472
  ].join("\n")
@@ -410,15 +476,26 @@ async function compile(file, sourceFiles, base) {
410
476
  })
411
477
  const moduleErrors = moduleResult.diagnostics?.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) ?? []
412
478
  if (moduleErrors.length) throw new Error(moduleErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
413
- return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, clientImports: [...clientImports] }
479
+ return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, hasEffects: effectHandlers.length > 0, clientImports: [...clientImports] }
414
480
  }
415
481
 
416
- function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, clientImports) {
482
+ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports) {
417
483
  return context => sourceFile => {
418
484
  const factory = context.factory
419
485
  sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
420
486
  ts.setParentRecursive(sourceFile, false)
421
487
  const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
488
+ const hasUseEffectImport = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && statement.moduleSpecifier.text === "@kudzujs/core" && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useEffect"))
489
+ const importedSources = new Map()
490
+ const importedSource = target => {
491
+ let imported = importedSources.get(target)
492
+ if (!imported) {
493
+ imported = normalizeRenderControlFlow(parseSourceFile(target, sourceIndex.get(target)), factory, context)
494
+ ts.setParentRecursive(imported, false)
495
+ importedSources.set(target, imported)
496
+ }
497
+ return imported
498
+ }
422
499
  const settersByFunction = new Map()
423
500
  const functions = new Map()
424
501
  const components = new Map()
@@ -520,8 +597,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
520
597
  }
521
598
  collectRenderedLists(sourceFile)
522
599
  const fail = (node, message) => {
523
- const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
524
- throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
600
+ throw sourceNodeError(node, sourceFile, message)
525
601
  }
526
602
  const rejectUnsupportedRenderControl = node => {
527
603
  if (ts.isIfStatement(node) && containsRenderControl(node, jsxLocalsByFunction.get(nearestFunction(node)) ?? new Set())) {
@@ -540,13 +616,19 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
540
616
  const componentSpecializations = new WeakMap()
541
617
  const specializedDeclarations = new WeakSet()
542
618
  for (const name of listComponentNames) {
543
- const component = components.get(name)
544
- if (!component) fail(sourceFile, `Keyed list component ${name} must be declared at the top level in the same file`)
545
- if (isExportedDeclaration(component.declaration)) fail(component.declaration, `Keyed list component ${name} cannot be exported`)
619
+ let component = components.get(name)
620
+ const local = Boolean(component)
621
+ if (!component) {
622
+ const binding = importBindings.get(name)
623
+ if (!binding || binding.kind === "namespace") fail(sourceFile, `Keyed list component ${name} must be declared locally or imported from a relative TypeScript module`)
624
+ const imported = binding.kind === "default" ? "default" : binding.imported
625
+ component = { function: resolveComponentExport(binding.target, imported, importedSource, sourceFiles), declaration: undefined }
626
+ }
627
+ if (local && isExportedDeclaration(component.declaration)) fail(component.declaration, `Keyed list component ${name} cannot be exported`)
546
628
  const calls = jsxTagUses(sourceFile, name)
547
- if (identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `Keyed list component ${name} may only be referenced as JSX`)
629
+ if (local && identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `Keyed list component ${name} may only be referenced as JSX`)
548
630
  for (const call of calls) componentSpecializations.set(call, specializeComponentCall(call, component.function, sourceFile, factory, context, fail))
549
- specializedDeclarations.add(component.declaration)
631
+ if (local) specializedDeclarations.add(component.declaration)
550
632
  }
551
633
  const renderedLists = new WeakMap()
552
634
  for (const { node, parts: originalParts } of rawRenderedLists) {
@@ -603,11 +685,38 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
603
685
  if (componentSpecializations.has(node)) return ts.visitNode(componentSpecializations.get(node).root, visitor)
604
686
 
605
687
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
606
- return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(modulePath(node.moduleSpecifier.text)), node.attributes)
688
+ const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
689
+ return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
607
690
  }
608
691
 
609
692
  if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
610
- return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(modulePath(node.moduleSpecifier.text)), node.attributes)
693
+ const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
694
+ return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
695
+ }
696
+
697
+ if (hasUseEffectImport && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useEffect") {
698
+ if (node.arguments.length !== 2) fail(node, "useEffect() requires exactly a callback and literal empty dependency array")
699
+ const [callback, dependencies] = node.arguments
700
+ if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) fail(callback, "useEffect() callback must be an inline function")
701
+ if (ts.isFunctionExpression(callback) && callback.name) fail(callback, "useEffect() callback function must be anonymous")
702
+ if (callback.parameters.length) fail(callback, "useEffect() callback cannot declare parameters")
703
+ if (!ts.isArrayLiteralExpression(dependencies) || dependencies.elements.length) fail(dependencies, "useEffect() dependencies must be a literal empty array")
704
+ if (!nearestFunction(node)) fail(node, "useEffect() cannot be used outside a Kudzu component")
705
+ if (returnsCleanup(callback)) fail(callback, "useEffect() cleanup functions are not supported")
706
+ if (!ts.isBlock(callback.body)) fail(callback, "useEffect() callback must use a block body")
707
+ if (returnsEffectValue(callback)) fail(callback, "useEffect() return values are not supported")
708
+ const setters = settersForNode(node, settersByFunction)
709
+ const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, importBindings, clientImports, "effect", undefined, true)
710
+ usesBehavior = true
711
+ return factory.updateCallExpression(node, node.expression, node.typeArguments, [
712
+ callback,
713
+ dependencies,
714
+ factory.createStringLiteral(handlerUrl),
715
+ factory.createStringLiteral(descriptor.exportName),
716
+ descriptor.states,
717
+ descriptor.scope,
718
+ factory.createStringLiteral(sourceLocation(node, sourceFile))
719
+ ])
611
720
  }
612
721
 
613
722
  if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && node.initializer.expression.text === "useState" && node.initializer.arguments.length === 1) {
@@ -677,7 +786,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
677
786
  }
678
787
  }
679
788
 
680
- if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !isContextProviderValue(node, contexts) && !/^on/i.test(node.name.getText()) && !["key", "ref", "dangerouslysetinnerhtml"].includes(node.name.getText().toLowerCase())) {
789
+ if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !isContextProviderValue(node, contexts) && !/^on/i.test(node.name.text) && !["key", "ref", "dangerouslysetinnerhtml"].includes(node.name.text.toLowerCase())) {
681
790
  const expression = node.initializer.expression
682
791
  const setters = settersForNode(node, settersByFunction)
683
792
  const usedStates = referencedStateNames(expression, setters)
@@ -690,15 +799,16 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
690
799
  }
691
800
  }
692
801
 
693
- if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.getText())) {
802
+ if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.text)) {
694
803
  const setters = settersForNode(node, settersByFunction)
695
804
  const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl, listEventItems.get(node), importBindings, clientImports)
696
805
  if (event) {
697
806
  usesBehavior = true
698
807
  return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, event))
699
808
  }
809
+ if (ts.isIdentifier(node.initializer.expression) && isDestructuredParameter(node.initializer.expression, nearestFunction(node))) return node
700
810
  const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
701
- throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${node.name.getText()} must reference a function`)
811
+ throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${node.name.text} must reference a function`)
702
812
  }
703
813
 
704
814
  return ts.visitEachChild(node, visitor, context)
@@ -854,8 +964,7 @@ function keyedListParts(expression, setters) {
854
964
 
855
965
  function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions) {
856
966
  const fail = (node, message) => {
857
- const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
858
- throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
967
+ throw sourceNodeError(node, sourceFile, message)
859
968
  }
860
969
  const root = parts.root
861
970
  const item = parts.item
@@ -869,7 +978,7 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
869
978
  if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
870
979
  if (node !== root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) fail(node, "Nested keyed lists are not supported")
871
980
  if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression, item)) fail(node, "Keyed list item spreads are not supported")
872
- if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.getText())) {
981
+ if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.text)) {
873
982
  listEventItems.set(node, item)
874
983
  return
875
984
  }
@@ -888,9 +997,9 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
888
997
  return
889
998
  }
890
999
  const field = directProperty(expression, item)
891
- const isRootKey = ts.isJsxAttribute(node.parent) && node.parent.name.getText() === "key"
1000
+ const isRootKey = ts.isJsxAttribute(node.parent) && node.parent.name.text === "key"
892
1001
  if (field && ["__proto__", "constructor", "prototype"].includes(field)) fail(node, `Keyed list item property "${field}" is not supported`)
893
- if (field && ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
1002
+ if (field && ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.text.toLowerCase())) fail(node, `Keyed list item ${node.parent.name.text} is not supported`)
894
1003
  if (isRootKey) return
895
1004
  if (field) {
896
1005
  listValues.set(node.expression, { field })
@@ -898,7 +1007,7 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
898
1007
  }
899
1008
  if (referencesIdentifier(expression, item)) {
900
1009
  validateListExpression(expression, item, node, fail)
901
- if (ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
1010
+ if (ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.text.toLowerCase())) fail(node, `Keyed list item ${node.parent.name.text} is not supported`)
902
1011
  listValues.set(node.expression, { item })
903
1012
  return
904
1013
  }
@@ -971,6 +1080,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
971
1080
 
972
1081
  function substituteClone(root, substitutions, factory, context) {
973
1082
  const visit = (node, shadowed = new Set()) => {
1083
+ if (ts.isTypeNode(node)) return cloneAst(node, factory, context)
974
1084
  if (ts.isShorthandPropertyAssignment(node) && substitutions.has(node.name.text) && !shadowed.has(node.name.text)) {
975
1085
  return factory.createPropertyAssignment(cloneAst(node.name, factory, context), cloneAst(substitutions.get(node.name.text), factory, context))
976
1086
  }
@@ -1026,6 +1136,10 @@ function isFunctionLike(node) {
1026
1136
  return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node)
1027
1137
  }
1028
1138
 
1139
+ function isDestructuredParameter(identifier, fn) {
1140
+ return fn?.parameters.some(parameter => ts.isObjectBindingPattern(parameter.name) && parameter.name.elements.some(element => ts.isIdentifier(element.name) && element.name.text === identifier.text)) ?? false
1141
+ }
1142
+
1029
1143
  function isExportedDeclaration(node) {
1030
1144
  const statement = ts.isVariableDeclaration(node) ? node.parent?.parent : node
1031
1145
  return statement?.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword || modifier.kind === ts.SyntaxKind.DefaultKeyword) ?? false
@@ -1057,6 +1171,7 @@ const assignmentOperators = new Set([
1057
1171
 
1058
1172
  function validateListExpression(expression, item, source, fail) {
1059
1173
  const visit = node => {
1174
+ if (ts.isTypeNode(node)) return
1060
1175
  if (ts.isElementAccessExpression(node) && referencesIdentifier(node.expression, item)) {
1061
1176
  const key = node.argumentExpression
1062
1177
  if (!ts.isStringLiteral(key) && !ts.isNumericLiteral(key)) fail(source, "Derived keyed list item computed properties require a direct string or numeric literal key")
@@ -1249,26 +1364,37 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
1249
1364
  const optimized = compileOptimizedEvent(expression, setters, factory)
1250
1365
  if (optimized) return optimized
1251
1366
 
1367
+ const descriptor = compileNativeCallback(expression, setters, factory, nativeHandlers, importBindings, clientImports, "handler", listItem)
1368
+ return factory.createCallExpression(factory.createIdentifier("__kNativeBehavior"), undefined, [
1369
+ factory.createStringLiteral(handlerUrl),
1370
+ factory.createStringLiteral(descriptor.exportName),
1371
+ descriptor.states,
1372
+ descriptor.scope
1373
+ ])
1374
+ }
1375
+
1376
+ function compileNativeCallback(expression, setters, factory, entries, importBindings, clientImports, prefix, listItem, deferValues = false) {
1252
1377
  const allCaptures = nativeCaptureNames(expression, setters)
1253
1378
  const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
1254
1379
  const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
1255
1380
  for (const entry of imports) clientImports.add(entry.target)
1256
1381
  const usedStates = nativeStateNames(expression, setters)
1257
- const exportName = `handler${nativeHandlers.length}`
1258
- nativeHandlers.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))) })
1259
- const states = [...usedStates].map(name => factory.createArrayLiteralExpression([
1260
- factory.createStringLiteral(name),
1261
- factory.createIdentifier(name)
1262
- ]))
1263
- return factory.createCallExpression(factory.createIdentifier("__kNativeBehavior"), undefined, [
1264
- factory.createStringLiteral(handlerUrl),
1265
- factory.createStringLiteral(exportName),
1266
- factory.createArrayLiteralExpression(states),
1267
- factory.createArrayLiteralExpression([...captures].map(name => factory.createArrayLiteralExpression([
1382
+ const exportName = `${prefix}${entries.length}`
1383
+ entries.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))) })
1384
+ const value = name => deferValues
1385
+ ? factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), factory.createIdentifier(name))
1386
+ : factory.createIdentifier(name)
1387
+ return {
1388
+ exportName,
1389
+ states: factory.createArrayLiteralExpression([...usedStates].map(name => factory.createArrayLiteralExpression([
1390
+ factory.createStringLiteral(name),
1391
+ value(name)
1392
+ ]))),
1393
+ scope: factory.createArrayLiteralExpression([...captures].map(name => factory.createArrayLiteralExpression([
1268
1394
  factory.createStringLiteral(name),
1269
- name === listItem ? factory.createCallExpression(factory.createIdentifier("__kListItem"), undefined, []) : factory.createIdentifier(name)
1395
+ name === listItem ? factory.createCallExpression(factory.createIdentifier("__kListItem"), undefined, []) : value(name)
1270
1396
  ])))
1271
- ])
1397
+ }
1272
1398
  }
1273
1399
 
1274
1400
  function nativeStateNames(expression, setters) {
@@ -1279,7 +1405,8 @@ function referencedStateNames(root, setters, scopeRoot = root) {
1279
1405
  const stateNames = new Set(setters.values())
1280
1406
  const used = new Set()
1281
1407
  const visit = node => {
1282
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text)) used.add(setters.get(node.expression.text))
1408
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text) && !isShadowedIdentifier(node.expression, scopeRoot)) used.add(setters.get(node.expression.text))
1409
+ if (ts.isIdentifier(node) && setters.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, scopeRoot)) used.add(setters.get(node.text))
1283
1410
  if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, scopeRoot)) used.add(node.text)
1284
1411
  ts.forEachChild(node, visit)
1285
1412
  }
@@ -1318,20 +1445,22 @@ function referencedImportedBindings(expression, imports) {
1318
1445
 
1319
1446
  function captureNames(declarationRoot, referenceRoot, setters) {
1320
1447
  const local = new Set()
1321
- const collectDeclarations = node => {
1322
- if (ts.isVariableDeclaration(node)) for (const name of bindingNames(node.name)) local.add(name)
1323
- if (ts.isParameter(node)) for (const name of bindingNames(node.name)) local.add(name)
1324
- if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) local.add(node.name.text)
1325
- ts.forEachChild(node, collectDeclarations)
1448
+ if (!isFunctionLike(declarationRoot)) {
1449
+ const collectDeclarations = node => {
1450
+ if (ts.isVariableDeclaration(node)) for (const name of bindingNames(node.name)) local.add(name)
1451
+ if (ts.isParameter(node)) for (const name of bindingNames(node.name)) local.add(name)
1452
+ if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) local.add(node.name.text)
1453
+ ts.forEachChild(node, collectDeclarations)
1454
+ }
1455
+ collectDeclarations(declarationRoot)
1326
1456
  }
1327
- collectDeclarations(declarationRoot)
1328
-
1329
1457
  const stateNames = new Set(setters.values())
1330
1458
  const captures = new Set()
1331
1459
  const visit = node => {
1332
1460
  if (ts.isTypeNode(node)) return
1333
- if (ts.isIdentifier(node) && isReferenceIdentifier(node) && !local.has(node.text) && !setters.has(node.text) && !stateNames.has(node.text) && !nativeGlobals.has(node.text)) {
1334
- captures.add(node.text)
1461
+ if (ts.isIdentifier(node)) {
1462
+ const declared = isFunctionLike(declarationRoot) ? isShadowedIdentifier(node, declarationRoot) : local.has(node.text)
1463
+ if (isReferenceIdentifier(node) && !declared && !setters.has(node.text) && !stateNames.has(node.text) && !nativeGlobals.has(node.text)) captures.add(node.text)
1335
1464
  }
1336
1465
  ts.forEachChild(node, visit)
1337
1466
  }
@@ -1353,7 +1482,7 @@ function isReferenceIdentifier(node) {
1353
1482
  (ts.isVariableDeclaration(parent) && parent.name === node) ||
1354
1483
  (ts.isParameter(parent) && parent.name === node) ||
1355
1484
  (ts.isFunctionDeclaration(parent) && parent.name === node) ||
1356
- (ts.isBindingElement(parent) && parent.name === node) ||
1485
+ (ts.isBindingElement(parent) && (parent.name === node || parent.propertyName === node)) ||
1357
1486
  ts.isImportSpecifier(parent) || ts.isImportClause(parent)) return false
1358
1487
  return true
1359
1488
  }
@@ -1373,6 +1502,43 @@ function isShadowedByParameter(node, scopeRoot) {
1373
1502
  return false
1374
1503
  }
1375
1504
 
1505
+ function isShadowedIdentifier(node, scopeRoot) {
1506
+ if (isShadowedByParameter(node, scopeRoot)) return true
1507
+ if (node === scopeRoot) return false
1508
+ if (isFunctionLike(scopeRoot) && scopeRoot.name?.text === node.text) return true
1509
+ if (isFunctionLike(scopeRoot) && functionVarDeclaresName(scopeRoot, node.text)) return true
1510
+ for (let current = node.parent; current; current = current.parent) {
1511
+ if (current === scopeRoot) break
1512
+ if (ts.isBlock(current) && current.statements.some(statement => statementDeclaresName(statement, node.text))) return true
1513
+ if (ts.isCaseBlock(current) && current.clauses.some(clause => clause.statements.some(statement => statementDeclaresName(statement, node.text)))) return true
1514
+ if (ts.isCatchClause(current) && current.variableDeclaration && bindingNames(current.variableDeclaration.name).includes(node.text)) return true
1515
+ if ((ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current)) && loopDeclaresName(current, node.text)) return true
1516
+ if (isFunctionLike(current) && functionVarDeclaresName(current, node.text)) return true
1517
+ }
1518
+ return false
1519
+ }
1520
+
1521
+ function statementDeclaresName(statement, name) {
1522
+ if (ts.isVariableStatement(statement)) return statement.declarationList.declarations.some(declaration => bindingNames(declaration.name).includes(name))
1523
+ return (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name?.text === name
1524
+ }
1525
+
1526
+ function loopDeclaresName(loop, name) {
1527
+ const declaration = ts.isForStatement(loop) ? loop.initializer : loop.initializer
1528
+ return declaration && ts.isVariableDeclarationList(declaration) && declaration.declarations.some(entry => bindingNames(entry.name).includes(name))
1529
+ }
1530
+
1531
+ function functionVarDeclaresName(fn, name) {
1532
+ let found = false
1533
+ const visit = node => {
1534
+ if (found || node !== fn.body && isFunctionLike(node)) return
1535
+ if (ts.isVariableDeclarationList(node) && (node.flags & ts.NodeFlags.BlockScoped) === 0 && node.declarations.some(entry => bindingNames(entry.name).includes(name))) found = true
1536
+ if (!found) ts.forEachChild(node, visit)
1537
+ }
1538
+ if (fn.body) visit(fn.body)
1539
+ return found
1540
+ }
1541
+
1376
1542
  function settersForNode(node, settersByFunction) {
1377
1543
  for (let current = node.parent; current; current = current.parent) {
1378
1544
  if (!ts.isFunctionDeclaration(current) && !ts.isFunctionExpression(current) && !ts.isArrowFunction(current)) continue
@@ -1399,6 +1565,96 @@ function clientImportBindings(sourceFile, file, sourceFiles) {
1399
1565
  return bindings
1400
1566
  }
1401
1567
 
1568
+ function resolveComponentExport(file, exportName, getSource, sourceFiles, trail = []) {
1569
+ const key = `${file}:${exportName}`
1570
+ if (trail.includes(key)) throw new Error(`Imported keyed list component re-export cycle: ${[...trail, key].map(entry => relative(root, entry.slice(0, entry.lastIndexOf(":")))).join(" -> ")}`)
1571
+ const sourceFile = getSource(file)
1572
+ const nextTrail = [...trail, key]
1573
+
1574
+ for (const statement of sourceFile.statements) {
1575
+ if (ts.isFunctionDeclaration(statement)) {
1576
+ const isDefault = statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)
1577
+ const isExported = statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)
1578
+ if (exportName === "default" && isDefault || exportName !== "default" && isExported && statement.name?.text === exportName) return statement
1579
+ }
1580
+ if (ts.isVariableStatement(statement) && statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) && exportName !== "default") {
1581
+ const declaration = statement.declarationList.declarations.find(entry => ts.isIdentifier(entry.name) && entry.name.text === exportName)
1582
+ if (declaration?.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) return declaration.initializer
1583
+ }
1584
+ if (exportName === "default" && ts.isExportAssignment(statement) && !statement.isExportEquals && ts.isIdentifier(statement.expression)) {
1585
+ const component = localComponentDeclaration(sourceFile, statement.expression.text)
1586
+ if (component) return component
1587
+ }
1588
+ if (ts.isExportDeclaration(statement) && ts.isNamedExports(statement.exportClause)) {
1589
+ const entry = statement.exportClause.elements.find(element => !element.isTypeOnly && element.name.text === exportName)
1590
+ if (!entry) continue
1591
+ const imported = (entry.propertyName ?? entry.name).text
1592
+ if (statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) {
1593
+ if (!statement.moduleSpecifier.text.startsWith(".")) throw sourceNodeError(statement, sourceFile, "Imported keyed list components must use relative TypeScript re-exports")
1594
+ const target = resolveSourceImport(file, statement.moduleSpecifier.text, sourceFiles)
1595
+ return resolveComponentExport(target, imported, getSource, sourceFiles, nextTrail)
1596
+ }
1597
+ const component = localComponentDeclaration(sourceFile, imported)
1598
+ if (component) return component
1599
+ }
1600
+ }
1601
+ throw new Error(`${relative(root, file)} does not export a statically analyzable keyed list component named ${JSON.stringify(exportName)}`)
1602
+ }
1603
+
1604
+ function localComponentDeclaration(sourceFile, name) {
1605
+ for (const statement of sourceFile.statements) {
1606
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === name) return statement
1607
+ if (ts.isVariableStatement(statement)) {
1608
+ const declaration = statement.declarationList.declarations.find(entry => ts.isIdentifier(entry.name) && entry.name.text === name)
1609
+ if (declaration?.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) return declaration.initializer
1610
+ }
1611
+ }
1612
+ return undefined
1613
+ }
1614
+
1615
+ function sourceNodeError(node, fallbackSource, message) {
1616
+ const original = ts.getOriginalNode(node)
1617
+ const sourceFile = original.getSourceFile?.()?.fileName ? original.getSourceFile() : fallbackSource
1618
+ const position = sourceFile.getLineAndCharacterOfPosition(original.getStart(sourceFile))
1619
+ return new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
1620
+ }
1621
+
1622
+ function sourceLocation(node, fallbackSource) {
1623
+ const original = ts.getOriginalNode(node)
1624
+ const sourceFile = original.getSourceFile?.()?.fileName ? original.getSourceFile() : fallbackSource
1625
+ const position = sourceFile.getLineAndCharacterOfPosition(original.getStart(sourceFile))
1626
+ return `${sourceFile.fileName}:${position.line + 1}:${position.character + 1}`
1627
+ }
1628
+
1629
+ function returnsCleanup(callback) {
1630
+ if (ts.isArrowFunction(callback) && !ts.isBlock(callback.body)) {
1631
+ const body = unwrapExpression(callback.body)
1632
+ return ts.isArrowFunction(body) || ts.isFunctionExpression(body)
1633
+ }
1634
+ let found = false
1635
+ const visit = node => {
1636
+ if (found || node !== callback.body && isFunctionLike(node)) return
1637
+ if (ts.isReturnStatement(node) && node.expression) {
1638
+ const expression = unwrapExpression(node.expression)
1639
+ if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) found = true
1640
+ }
1641
+ if (!found) ts.forEachChild(node, visit)
1642
+ }
1643
+ visit(callback.body)
1644
+ return found
1645
+ }
1646
+
1647
+ function returnsEffectValue(callback) {
1648
+ let found = false
1649
+ const visit = node => {
1650
+ if (found || node !== callback.body && isFunctionLike(node)) return
1651
+ if (ts.isReturnStatement(node) && node.expression) found = true
1652
+ if (!found) ts.forEachChild(node, visit)
1653
+ }
1654
+ visit(callback.body)
1655
+ return found
1656
+ }
1657
+
1402
1658
  function printClientImports(entries, handlerPath) {
1403
1659
  const unique = new Map(entries.map(entry => [`${entry.target}:${entry.kind}:${entry.imported ?? ""}:${entry.local}`, entry]))
1404
1660
  const groups = Map.groupBy(unique.values(), entry => entry.target)
@@ -1474,7 +1730,7 @@ function resolveSourceImport(importer, specifier, sourceFiles) {
1474
1730
  const stem = /\.(?:js|jsx|ts|tsx)$/.test(extension) ? base.slice(0, -extension.length) : base
1475
1731
  const candidates = extension === ".ts" || extension === ".tsx"
1476
1732
  ? [base]
1477
- : [`${stem}.ts`, `${stem}.tsx`]
1733
+ : [`${stem}.ts`, `${stem}.tsx`, join(stem, "index.ts"), join(stem, "index.tsx")]
1478
1734
  const matches = candidates.filter(candidate => sourceFiles.has(candidate))
1479
1735
  if (matches.length !== 1) throw new Error(`${relative(root, importer)} Relative import ${JSON.stringify(specifier)} must resolve to one TypeScript file in src/`)
1480
1736
  return matches[0]
@@ -1516,24 +1772,33 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
1516
1772
  const stateNames = new Set(setters.values())
1517
1773
  const transformer = context => root => {
1518
1774
  const visitor = node => {
1519
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text)) {
1775
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text) && !isShadowedIdentifier(node.expression, expression)) {
1520
1776
  return factory.createCallExpression(
1521
1777
  factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "set"),
1522
1778
  undefined,
1523
1779
  [factory.createStringLiteral(setters.get(node.expression.text)), ...node.arguments.map(argument => ts.visitNode(argument, visitor))]
1524
1780
  )
1525
1781
  }
1526
- if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, expression)) {
1782
+ if (ts.isShorthandPropertyAssignment(node) && setters.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
1783
+ return factory.createPropertyAssignment(node.name, setterReference(factory, setters.get(node.name.text)))
1784
+ }
1785
+ if (ts.isIdentifier(node) && setters.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
1786
+ return setterReference(factory, setters.get(node.text))
1787
+ }
1788
+ if (ts.isShorthandPropertyAssignment(node) && stateNames.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
1789
+ return factory.createPropertyAssignment(node.name, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"), undefined, [factory.createStringLiteral(node.name.text)]))
1790
+ }
1791
+ if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
1527
1792
  return factory.createCallExpression(
1528
1793
  factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
1529
1794
  undefined,
1530
1795
  [factory.createStringLiteral(node.text)]
1531
1796
  )
1532
1797
  }
1533
- if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text)) {
1798
+ if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
1534
1799
  return factory.createPropertyAssignment(node.name, scopeRead(factory, node.name.text))
1535
1800
  }
1536
- if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node)) {
1801
+ if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
1537
1802
  return scopeRead(factory, node.text)
1538
1803
  }
1539
1804
  return ts.visitEachChild(node, visitor, context)
@@ -1562,6 +1827,17 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
1562
1827
  }
1563
1828
  }
1564
1829
 
1830
+ function setterReference(factory, stateName) {
1831
+ return factory.createArrowFunction(
1832
+ undefined,
1833
+ undefined,
1834
+ [factory.createParameterDeclaration(undefined, undefined, "value")],
1835
+ undefined,
1836
+ factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
1837
+ factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "set"), undefined, [factory.createStringLiteral(stateName), factory.createIdentifier("value")])
1838
+ )
1839
+ }
1840
+
1565
1841
  function printReactiveBinding({ exportName, expression, captures, states }) {
1566
1842
  const factory = ts.factory
1567
1843
  const transformer = context => root => {
@@ -1667,11 +1943,6 @@ function numericExpression(factory, value, negative) {
1667
1943
  return negative ? factory.createPrefixUnaryExpression(ts.SyntaxKind.MinusToken, literal) : literal
1668
1944
  }
1669
1945
 
1670
- function modulePath(value) {
1671
- if (/\.(?:ts|tsx|js|jsx)$/.test(value)) return value.replace(/\.(?:ts|tsx|js|jsx)$/, ".mjs")
1672
- return `${value}.mjs`
1673
- }
1674
-
1675
1946
  function compiledPath(file) {
1676
1947
  return join(workDirectory, relative(sourceDirectory, file)).replace(/\.(?:ts|tsx)$/, ".mjs")
1677
1948
  }
@@ -1,6 +1,7 @@
1
1
  export type StateSetter<T> = (value: T | ((previous: T) => T)) => void
2
2
 
3
3
  export function useState<T>(initialValue: T): [T, StateSetter<T>]
4
+ export function useEffect(effect: () => void | Promise<void>, dependencies: readonly []): void
4
5
 
5
6
  export interface RefObject<T> {
6
7
  readonly current: T | null
@@ -46,11 +47,13 @@ export function renderPage<Props = Record<string, never>>(
46
47
  manifest?: string
47
48
  styles?: boolean | string[]
48
49
  base?: string
50
+ effectAsset?: string
49
51
  },
50
52
  props?: Props
51
53
  ): Promise<{
52
54
  html: string
53
55
  hasBehaviors: boolean
56
+ hasEffects: boolean
54
57
  hasBindings: boolean
55
58
  hasLists: boolean
56
59
  hasListStyles: boolean
@@ -62,6 +65,7 @@ export function renderPage<Props = Record<string, never>>(
62
65
  commands?: Array<[string, string, unknown]>
63
66
  native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
64
67
  }>
68
+ effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }>
65
69
  bindings: Array<{
66
70
  target: string
67
71
  state?: string
@@ -44,6 +44,16 @@ export function useState(initialValue, name) {
44
44
  return [signal, setter]
45
45
  }
46
46
 
47
+ export function useEffect(callback, dependencies, module, handler, states, scope, source) {
48
+ if (!renderContext) throw new Error("useEffect() can only run while rendering a Kudzu component")
49
+ if (typeof callback !== "function" || !Array.isArray(dependencies) || dependencies.length || !module || !handler) {
50
+ throw new Error("useEffect() must be compiled with a literal empty dependency array")
51
+ }
52
+ renderContext.effects.push({ module, handler, states, scope, source })
53
+ renderContext.hasBehaviors = true
54
+ renderContext.hasEffects = true
55
+ }
56
+
47
57
  export function useRef(initialValue) {
48
58
  if (!renderContext) throw new Error("useRef() can only run while rendering a Kudzu component")
49
59
  if (initialValue !== null) throw new Error("Kudzu DOM refs must initialize with null")
@@ -82,8 +92,14 @@ export function nativeBehavior(module, handler, states, scope) {
82
92
  [nativeBehaviorMarker]: true,
83
93
  module,
84
94
  handler,
95
+ ...nativeDescriptor(states, scope)
96
+ }
97
+ }
98
+
99
+ function nativeDescriptor(states, scope) {
100
+ return {
85
101
  states: Object.fromEntries(states.map(([name, signal]) => {
86
- if (!signal?.[signalMarker]) throw new Error("A native behavior must target framework state")
102
+ if (!signal?.[signalMarker]) throw new Error("A native callback must target framework state")
87
103
  return [name, signal.id]
88
104
  })),
89
105
  scope: Object.fromEntries(scope.map(([name, value]) => [name, value?.[signalMarker] ? { type: "state", id: value.id } : serializeCapture(name, value, new Set())]))
@@ -226,10 +242,21 @@ function serializeCapture(name, value, seen) {
226
242
  }
227
243
 
228
244
  export async function renderPage(component, metadata = {}, props = {}) {
229
- renderContext = { nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], textBindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false, hasListStyles: false }
245
+ renderContext = { nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasBindings: false, hasLists: false, hasListStyles: false }
230
246
 
231
247
  try {
232
248
  const body = await renderNode({ type: component, props })
249
+ renderContext.effects = renderContext.effects.map(effect => {
250
+ try {
251
+ return {
252
+ module: effect.module,
253
+ handler: effect.handler,
254
+ ...nativeDescriptor(effect.states.map(([name, read]) => [name, read()]), effect.scope.map(([name, read]) => [name, read()]))
255
+ }
256
+ } catch (error) {
257
+ throw new Error(`${effect.source} ${error.message}`)
258
+ }
259
+ })
233
260
  const title = escapeHtml(metadata.title ?? "Kudzu")
234
261
  const head = renderMetadata(metadata)
235
262
  const styles = metadata.styles === false
@@ -247,6 +274,9 @@ export async function renderPage(component, metadata = {}, props = {}) {
247
274
  const listRuntime = renderContext.hasLists
248
275
  ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-list.js")}"></script>`
249
276
  : ""
277
+ const effectRuntime = renderContext.hasEffects
278
+ ? `<script type="module" src="${escapeAttribute(metadata.effectAsset)}"></script>`
279
+ : ""
250
280
  const listStates = new Set(renderContext.lists.map(list => list.state))
251
281
  const seededListStates = new Set(renderContext.lists.filter(list => list.seed && !renderContext.textStates.has(list.state) && !renderContext.conditionStates.has(list.state)).map(list => list.state))
252
282
  const initialState = renderContext.hasBehaviors
@@ -263,8 +293,9 @@ export async function renderPage(component, metadata = {}, props = {}) {
263
293
  : ""
264
294
 
265
295
  return {
266
- html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}</head><body${state}${textBindings}>${body}${runtime}${bindingRuntime}${listRuntime}${nativeRuntime}</body></html>`,
296
+ html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}</head><body${state}${textBindings}>${body}${runtime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}</body></html>`,
267
297
  hasBehaviors: renderContext.hasBehaviors,
298
+ hasEffects: renderContext.hasEffects,
268
299
  hasBindings: renderContext.hasBindings,
269
300
  hasLists: renderContext.hasLists,
270
301
  hasListStyles: renderContext.hasListStyles,
@@ -272,6 +303,7 @@ export async function renderPage(component, metadata = {}, props = {}) {
272
303
  plan: {
273
304
  states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
274
305
  events: renderContext.events,
306
+ effects: renderContext.effects,
275
307
  bindings: renderContext.bindings,
276
308
  conditions: renderContext.conditions,
277
309
  lists: renderContext.lists
@@ -552,6 +584,11 @@ async function renderList(node, namespace, selectValue) {
552
584
  const template = await renderNode(node.render({}), namespace, selectValue)
553
585
  if (template.includes("data-k-native-")) descriptor.mount = true
554
586
  if (template.includes("data-k-list-condition")) descriptor.conditions = true
587
+ if (template.includes("data-k-list-text-end")) descriptor.textRanges = true
588
+ if (template.includes("data-k-list-attrs")) descriptor.attributes = true
589
+ if (template.includes("data-k-list-events")) descriptor.events = true
590
+ if (template.includes("data-k-list-expression=")) descriptor.expressions = true
591
+ if (template.includes("data-k-list-expression-attrs")) descriptor.expressionAttributes = true
555
592
  const seed = listSeed(node.items.value, renderContext.listFields)
556
593
  if (seed) descriptor.seed = seed
557
594
  let current = ""
@@ -0,0 +1,39 @@
1
+ import { deserialize } from "./serialization.js"
2
+
3
+ export function createEffectContext(state, stateIds, commit, serializedScope = {}) {
4
+ const changed = new Set()
5
+ let scheduled = false
6
+
7
+ const flush = () => {
8
+ scheduled = false
9
+ const ids = [...changed]
10
+ changed.clear()
11
+ for (const id of ids) commit(id, state.get(id))
12
+ }
13
+
14
+ const setId = (id, value) => {
15
+ const current = state.get(id)
16
+ state.set(id, typeof value === "function" ? value(current) : value)
17
+ changed.add(id)
18
+ if (!scheduled) {
19
+ scheduled = true
20
+ queueMicrotask(flush)
21
+ }
22
+ }
23
+
24
+ const scope = globalThis.__KUDZU_EFFECT_CAPTURES__
25
+ ? Object.fromEntries(Object.entries(serializedScope).map(([name, value]) => [name, deserialize(value, id => state.get(id), globalThis.__KUDZU_CAPTURE_SETTER__ ? setId : undefined)]))
26
+ : undefined
27
+
28
+ return {
29
+ get(name) {
30
+ return state.get(stateIds[name])
31
+ },
32
+ scope(name) {
33
+ return globalThis.__KUDZU_EFFECT_CAPTURES__ ? serializedScope[name]?.type === "state" ? state.get(serializedScope[name].id) : scope[name] : undefined
34
+ },
35
+ set(name, value) {
36
+ setId(stateIds[name], value)
37
+ }
38
+ }
39
+ }
@@ -3,11 +3,11 @@ import { browserState, mountDom, registerCommitter, registerMountHook, registerU
3
3
  const listTargets = new Map()
4
4
  const listRegistrations = new WeakMap()
5
5
  const mountedLists = new WeakSet()
6
- const imports = new Map()
7
- const revisions = new WeakMap()
6
+ const imports = __KUDZU_LIST_ASYNC_PARTS__ ? new Map() : undefined
7
+ const revisions = __KUDZU_LIST_ASYNC_PARTS__ ? new WeakMap() : undefined
8
8
  const itemParts = new WeakMap()
9
- const conditionOwners = new WeakMap()
10
- const itemPartsSelector = `[data-k-list-text],[data-k-list-attrs],[data-k-list-events],[data-k-list-expression],[data-k-list-expression-attrs]${__KUDZU_LIST_CONDITIONS__ ? ",[data-k-list-condition]" : ""}`
9
+ const conditionOwners = __KUDZU_LIST_CONDITIONS__ ? new WeakMap() : undefined
10
+ const itemPartsSelector = `[data-k-list-text]${__KUDZU_LIST_ATTRIBUTES__ ? ",[data-k-list-attrs]" : ""}${__KUDZU_LIST_EVENTS__ ? ",[data-k-list-events]" : ""}${__KUDZU_LIST_EXPRESSIONS__ ? ",[data-k-list-expression]" : ""}${__KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? ",[data-k-list-expression-attrs]" : ""}${__KUDZU_LIST_CONDITIONS__ ? ",[data-k-list-condition]" : ""}`
11
11
 
12
12
  function commitLists(id) {
13
13
  const lists = listTargets.get(id)
@@ -34,13 +34,13 @@ function mountLists(root) {
34
34
  const templateRoot = start.content.firstElementChild
35
35
  const parts = listItemPartPlan(templateRoot)
36
36
  for (const root of roots) __KUDZU_LIST_CONDITIONS__ && descriptor.conditions ? listItemParts(root) : mapListItemParts(parts, root)
37
- if (descriptor.seed && !browserState.has(descriptor.state)) browserState.set(descriptor.state, roots.map((root, index) => seedListItem(root, descriptor, index)))
37
+ if (__KUDZU_LIST_SEEDS__ && descriptor.seed && !browserState.has(descriptor.state)) browserState.set(descriptor.state, roots.map((root, index) => seedListItem(root, descriptor, index)))
38
38
  const items = browserState.get(descriptor.state)
39
39
  const list = {
40
40
  start,
41
41
  descriptor,
42
42
  parts,
43
- seedFields: descriptor.seed && Object.keys(descriptor.seed),
43
+ seedFields: __KUDZU_LIST_SEEDS__ && descriptor.seed && Object.keys(descriptor.seed),
44
44
  roots: new Map(roots.map((node, index) => [keyToken(descriptor.keys[index]), node])),
45
45
  values: new Map(),
46
46
  container: roots[0]?.parentNode,
@@ -77,7 +77,7 @@ function updateList(list) {
77
77
  const key = item?.[list.descriptor.key]
78
78
  if (!validListKey(key)) throw new Error(`Keyed list key "${list.descriptor.key}" must be a string or finite number`)
79
79
  assertListItem(item)
80
- const seededValue = list.seedFields && seededListValue(item, list.seedFields, list.descriptor.seed)
80
+ const seededValue = __KUDZU_LIST_SEEDS__ ? list.seedFields && seededListValue(item, list.seedFields, list.descriptor.seed) : undefined
81
81
  if (seededValue === undefined) assertListValue(item, seen, true)
82
82
  const token = keyToken(key)
83
83
  if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
@@ -108,13 +108,13 @@ function updateList(list) {
108
108
  }
109
109
  for (const [token, node] of list.roots) {
110
110
  if (keys.has(token)) continue
111
- if (list.descriptor.mount) {
111
+ if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) {
112
112
  unmountDom(node)
113
113
  node.remove()
114
114
  } else node.remove()
115
115
  }
116
116
  if (added) {
117
- if (list.descriptor.mount) mountDom(additions)
117
+ if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) mountDom(additions)
118
118
  parent.insertBefore(additions, list.boundary)
119
119
  list.container ??= parent
120
120
  }
@@ -142,8 +142,8 @@ function updateList(list) {
142
142
  }
143
143
 
144
144
  function fillListItem(root, item) {
145
- const revision = (revisions.get(root) ?? 0) + 1
146
- revisions.set(root, revision)
145
+ const revision = __KUDZU_LIST_ASYNC_PARTS__ ? (revisions.get(root) ?? 0) + 1 : 0
146
+ if (__KUDZU_LIST_ASYNC_PARTS__) revisions.set(root, revision)
147
147
  const parts = listItemParts(root)
148
148
  fillListParts(root, parts, item, revision)
149
149
  }
@@ -159,30 +159,38 @@ function fillListParts(root, parts, item, revision) {
159
159
  node.textContent = value
160
160
  }
161
161
  }
162
- for (const [marker, field] of parts.texts) {
163
- patchListText(marker, "template[data-k-list-text-end]", item?.[field])
162
+ if (__KUDZU_LIST_TEXT_RANGES__) {
163
+ for (const [marker, field] of parts.texts) patchListText(marker, "template[data-k-list-text-end]", item?.[field])
164
164
  }
165
- for (const [node, attributes] of parts.attributes) {
166
- for (const [target, field] of attributes) patchBinding(node, target, item?.[field])
167
- }
168
- for (const [node, events] of parts.events) {
169
- for (const [event, native] of JSON.parse(events)) {
170
- native.scope = Object.fromEntries(Object.entries(native.scope).map(([name, value]) => [name, value?.type === "list-item" ? serializeItem(item) : value]))
171
- node.dataset[`kNative${capitalize(event)}`] = JSON.stringify(native)
165
+ if (__KUDZU_LIST_ATTRIBUTES__) {
166
+ for (const [node, attributes] of parts.attributes) {
167
+ for (const [target, field] of attributes) patchBinding(node, target, item?.[field])
172
168
  }
173
169
  }
174
- for (const [marker, descriptor] of parts.expressions) {
175
- evaluate(descriptor, item).then(value => {
176
- if (revisions.get(root) === revision && root.isConnected) patchListText(marker, "template[data-k-list-expression-end]", value)
177
- }).catch(error => console.error(error))
170
+ if (__KUDZU_LIST_EVENTS__) {
171
+ for (const [node, events] of parts.events) {
172
+ for (const [event, native] of JSON.parse(events)) {
173
+ native.scope = Object.fromEntries(Object.entries(native.scope).map(([name, value]) => [name, value?.type === "list-item" ? serializeItem(item) : value]))
174
+ node.dataset[`kNative${capitalize(event)}`] = JSON.stringify(native)
175
+ }
176
+ }
178
177
  }
179
- for (const [node, attributes] of parts.expressionAttributes) {
180
- for (const [target, module, handler] of attributes) {
181
- evaluate({ module, handler }, item).then(value => {
182
- if (revisions.get(root) === revision && root.isConnected) patchBinding(node, target, value)
178
+ if (__KUDZU_LIST_EXPRESSIONS__) {
179
+ for (const [marker, descriptor] of parts.expressions) {
180
+ evaluate(descriptor, item).then(value => {
181
+ if (revisions.get(root) === revision && root.isConnected) patchListText(marker, "template[data-k-list-expression-end]", value)
183
182
  }).catch(error => console.error(error))
184
183
  }
185
184
  }
185
+ if (__KUDZU_LIST_EXPRESSION_ATTRIBUTES__) {
186
+ for (const [node, attributes] of parts.expressionAttributes) {
187
+ for (const [target, module, handler] of attributes) {
188
+ evaluate({ module, handler }, item).then(value => {
189
+ if (revisions.get(root) === revision && root.isConnected) patchBinding(node, target, value)
190
+ }).catch(error => console.error(error))
191
+ }
192
+ }
193
+ }
186
194
  if (__KUDZU_LIST_CONDITIONS__) {
187
195
  for (const [marker, descriptor] of parts.conditions) {
188
196
  evaluate(descriptor, item).then(value => {
@@ -198,10 +206,10 @@ function listItemParts(root) {
198
206
  parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [], conditions: [] }
199
207
  for (const node of matching(root, itemPartsSelector)) {
200
208
  if (node.hasAttribute("data-k-list-text")) (node.tagName === "TEMPLATE" ? parts.texts : parts.directTexts).push([node, node.dataset.kListText])
201
- if (node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
202
- if (node.hasAttribute("data-k-list-events")) parts.events.push([node, node.dataset.kListEvents])
203
- if (node.hasAttribute("data-k-list-expression")) parts.expressions.push([node, JSON.parse(node.dataset.kListExpression)])
204
- if (node.hasAttribute("data-k-list-expression-attrs")) parts.expressionAttributes.push([node, JSON.parse(node.dataset.kListExpressionAttrs)])
209
+ if (__KUDZU_LIST_ATTRIBUTES__ && node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
210
+ if (__KUDZU_LIST_EVENTS__ && node.hasAttribute("data-k-list-events")) parts.events.push([node, node.dataset.kListEvents])
211
+ if (__KUDZU_LIST_EXPRESSIONS__ && node.hasAttribute("data-k-list-expression")) parts.expressions.push([node, JSON.parse(node.dataset.kListExpression)])
212
+ if (__KUDZU_LIST_EXPRESSION_ATTRIBUTES__ && node.hasAttribute("data-k-list-expression-attrs")) parts.expressionAttributes.push([node, JSON.parse(node.dataset.kListExpressionAttrs)])
205
213
  if (__KUDZU_LIST_CONDITIONS__ && node.hasAttribute("data-k-list-condition")) {
206
214
  parts.conditions.push([node, JSON.parse(node.dataset.kListCondition)])
207
215
  conditionOwners.set(node, root)
@@ -217,11 +225,11 @@ function listItemPartPlan(template) {
217
225
  const parts = listItemParts(template)
218
226
  return {
219
227
  directTexts: parts.directTexts.map(([node, field]) => [indexes.get(node), field]),
220
- texts: parts.texts.map(([node, field]) => [indexes.get(node), field]),
221
- attributes: parts.attributes.map(([node, attributes]) => [indexes.get(node), attributes]),
222
- events: parts.events.map(([node, events]) => [indexes.get(node), events]),
223
- expressions: parts.expressions.map(([node, descriptor]) => [indexes.get(node), descriptor]),
224
- expressionAttributes: parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes]),
228
+ texts: __KUDZU_LIST_TEXT_RANGES__ ? parts.texts.map(([node, field]) => [indexes.get(node), field]) : [],
229
+ attributes: __KUDZU_LIST_ATTRIBUTES__ ? parts.attributes.map(([node, attributes]) => [indexes.get(node), attributes]) : [],
230
+ events: __KUDZU_LIST_EVENTS__ ? parts.events.map(([node, events]) => [indexes.get(node), events]) : [],
231
+ expressions: __KUDZU_LIST_EXPRESSIONS__ ? parts.expressions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : [],
232
+ expressionAttributes: __KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes]) : [],
225
233
  conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : []
226
234
  }
227
235
  }
@@ -230,11 +238,11 @@ function mapListItemParts(parts, root) {
230
238
  const target = [root, ...root.querySelectorAll("*")]
231
239
  itemParts.set(root, {
232
240
  directTexts: parts.directTexts.map(([index, field]) => [target[index], field]),
233
- texts: parts.texts.map(([index, field]) => [target[index], field]),
234
- attributes: parts.attributes.map(([index, attributes]) => [target[index], attributes]),
235
- events: parts.events.map(([index, events]) => [target[index], events]),
236
- expressions: parts.expressions.map(([index, descriptor]) => [target[index], descriptor]),
237
- expressionAttributes: parts.expressionAttributes.map(([index, attributes]) => [target[index], attributes]),
241
+ texts: __KUDZU_LIST_TEXT_RANGES__ ? parts.texts.map(([index, field]) => [target[index], field]) : [],
242
+ attributes: __KUDZU_LIST_ATTRIBUTES__ ? parts.attributes.map(([index, attributes]) => [target[index], attributes]) : [],
243
+ events: __KUDZU_LIST_EVENTS__ ? parts.events.map(([index, events]) => [target[index], events]) : [],
244
+ expressions: __KUDZU_LIST_EXPRESSIONS__ ? parts.expressions.map(([index, descriptor]) => [target[index], descriptor]) : [],
245
+ expressionAttributes: __KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? parts.expressionAttributes.map(([index, attributes]) => [target[index], attributes]) : [],
238
246
  conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([index, descriptor]) => {
239
247
  conditionOwners.set(target[index], root)
240
248
  return [target[index], descriptor]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",