@kudzujs/core 0.5.5 → 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
 
@@ -308,6 +308,27 @@ The original component remains reusable across multiple lists and ordinary JSX.
308
308
 
309
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
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.
331
+
311
332
  ## Normal JavaScript
312
333
 
313
334
  Command-only setters use the smallest optimized path. Conditions, local variables, browser globals, events, and `async`/`await` compile to external ESM without `eval`, `new Function`, or inline executable code.
@@ -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.
@@ -46,6 +46,7 @@ export async function build({ quiet = false, minify = true } = {}) {
46
46
  let listStyleCount = 0
47
47
  let stateSeedCount = 0
48
48
  const plans = []
49
+ const effectEntries = []
49
50
  const emittedRoutes = new Set()
50
51
  const styleUrls = cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`))
51
52
 
@@ -58,17 +59,20 @@ export async function build({ quiet = false, minify = true } = {}) {
58
59
  for (const { params, props } of entries) {
59
60
  const route = routeFromPage(pageFile, params)
60
61
  const routePath = withBase(base, `/${route}`)
62
+ const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
61
63
  if (emittedRoutes.has(routePath)) throw new Error(`Duplicate route: ${routePath}`)
62
64
  emittedRoutes.add(routePath)
63
65
  const result = await renderPage(module.default, {
64
66
  ...(module.metadata ?? {}),
65
67
  styles: styleUrls.length ? styleUrls : false,
66
- base
68
+ base,
69
+ effectAsset: assetPath(base, `assets/${effectPath}`)
67
70
  }, props)
68
71
  const routeDirectory = join(outputDirectory, route)
69
72
  await mkdir(routeDirectory, { recursive: true })
70
73
  await writeFile(join(routeDirectory, "index.html"), result.html)
71
74
  plans.push({ route: routePath, ...result.plan })
75
+ if (result.hasEffects) effectEntries.push({ path: effectPath, effects: result.plan.effects })
72
76
  if (result.hasBehaviors) behaviorCount++
73
77
  if (result.hasBindings) bindingCount++
74
78
  if (result.hasLists) listCount++
@@ -83,19 +87,37 @@ export async function build({ quiet = false, minify = true } = {}) {
83
87
  const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
84
88
  const hasTextBindings = plans.some(plan => plan.bindings.some(binding => binding.target === "text"))
85
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))
86
98
  const hasNestedStateCaptures = hasNestedCaptureState(plans)
87
99
  const hasSetterCaptures = hasCaptureType(plans, "setter")
100
+ const hasEffectCaptures = plans.some(plan => plan.effects.some(effect => Object.keys(effect.scope).length))
88
101
  const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
89
102
  const hasNativeHandlers = nativeModules.length > 0
103
+ const hasEffects = effectEntries.length > 0
90
104
  if (behaviorCount) {
91
105
  const runtimeFile = bindingCount || listCount || hasNativeHandlers ? "./shared-runtime.js" : "./runtime.js"
92
106
  const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
93
107
  await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
94
108
  }
95
- 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, {
96
110
  "globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures),
97
111
  "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
98
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
+ }
99
121
  if (bindingCount || listStyleCount) await writeJavaScript(join(assetsDirectory, "kudzu-style.js"), await readFile(new URL("./style.js", import.meta.url), "utf8"), minify)
100
122
  if (bindingCount) {
101
123
  const bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
@@ -118,7 +140,17 @@ export async function build({ quiet = false, minify = true } = {}) {
118
140
  }`
119
141
  listRuntime = listRuntime.replace(" /* list-style */", listStyleCount ? stylePatch : "")
120
142
  if (listStyleCount) listRuntime = `import { serializeStyle } from "./kudzu-style.js"\n${listRuntime}`
121
- 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
+ })
122
154
  }
123
155
  if (hasNativeHandlers) {
124
156
  const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
@@ -133,6 +165,11 @@ export async function build({ quiet = false, minify = true } = {}) {
133
165
  await mkdir(resolve(output, ".."), { recursive: true })
134
166
  await writeJavaScript(output, handlerModule.code, minify)
135
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
+ }
136
173
  const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
137
174
  for (const file of clientModules) {
138
175
  const output = join(assetsDirectory, clientModulePath(file))
@@ -182,6 +219,32 @@ function specializeNativeRuntime(source, events, modules) {
182
219
  return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
183
220
  }
184
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
+
185
248
  function hasCaptureType(value, type) {
186
249
  if (!value || typeof value !== "object") return false
187
250
  if (value.type === type) return true
@@ -373,6 +436,7 @@ function escapeHtml(value) {
373
436
  async function compile(file, sourceFiles, sourceIndex, base) {
374
437
  const source = sourceIndex.get(file)
375
438
  const nativeHandlers = []
439
+ const effectHandlers = []
376
440
  const reactiveBindings = []
377
441
  const listExpressions = []
378
442
  const clientImports = new Set()
@@ -385,7 +449,7 @@ async function compile(file, sourceFiles, sourceIndex, base) {
385
449
  jsx: ts.JsxEmit.ReactJSX,
386
450
  jsxImportSource: "@kudzujs/core"
387
451
  },
388
- transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, sourceIndex, clientImports)] },
452
+ transformers: { before: [createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, sourceIndex, clientImports)] },
389
453
  reportDiagnostics: true
390
454
  })
391
455
 
@@ -398,10 +462,11 @@ async function compile(file, sourceFiles, sourceIndex, base) {
398
462
  await mkdir(resolve(output, ".."), { recursive: true })
399
463
  await writeFile(output, result.outputText)
400
464
 
401
- 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]
402
467
  const moduleSource = [
403
- printClientImports(nativeHandlers.flatMap(handler => handler.imports), handlerPath),
404
- ...nativeHandlers.map(handler => printNativeHandler(handler)),
468
+ printClientImports(callbacks.flatMap(handler => handler.imports), handlerPath),
469
+ ...callbacks.map(handler => printNativeHandler(handler)),
405
470
  ...reactiveBindings.map(entry => printReactiveBinding(entry)),
406
471
  ...listExpressions.map(entry => printListExpression(entry))
407
472
  ].join("\n")
@@ -411,15 +476,16 @@ async function compile(file, sourceFiles, sourceIndex, base) {
411
476
  })
412
477
  const moduleErrors = moduleResult.diagnostics?.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) ?? []
413
478
  if (moduleErrors.length) throw new Error(moduleErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
414
- 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] }
415
480
  }
416
481
 
417
- function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports) {
482
+ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports) {
418
483
  return context => sourceFile => {
419
484
  const factory = context.factory
420
485
  sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
421
486
  ts.setParentRecursive(sourceFile, false)
422
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"))
423
489
  const importedSources = new Map()
424
490
  const importedSource = target => {
425
491
  let imported = importedSources.get(target)
@@ -628,6 +694,31 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
628
694
  return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
629
695
  }
630
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
+ ])
720
+ }
721
+
631
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) {
632
723
  const stateElement = node.name.elements[0]
633
724
  if (!stateElement || !ts.isBindingElement(stateElement) || !ts.isIdentifier(stateElement.name)) return node
@@ -1273,26 +1364,37 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
1273
1364
  const optimized = compileOptimizedEvent(expression, setters, factory)
1274
1365
  if (optimized) return optimized
1275
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) {
1276
1377
  const allCaptures = nativeCaptureNames(expression, setters)
1277
1378
  const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
1278
1379
  const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
1279
1380
  for (const entry of imports) clientImports.add(entry.target)
1280
1381
  const usedStates = nativeStateNames(expression, setters)
1281
- const exportName = `handler${nativeHandlers.length}`
1282
- nativeHandlers.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))) })
1283
- const states = [...usedStates].map(name => factory.createArrayLiteralExpression([
1284
- factory.createStringLiteral(name),
1285
- factory.createIdentifier(name)
1286
- ]))
1287
- return factory.createCallExpression(factory.createIdentifier("__kNativeBehavior"), undefined, [
1288
- factory.createStringLiteral(handlerUrl),
1289
- factory.createStringLiteral(exportName),
1290
- factory.createArrayLiteralExpression(states),
1291
- 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([
1292
1394
  factory.createStringLiteral(name),
1293
- name === listItem ? factory.createCallExpression(factory.createIdentifier("__kListItem"), undefined, []) : factory.createIdentifier(name)
1395
+ name === listItem ? factory.createCallExpression(factory.createIdentifier("__kListItem"), undefined, []) : value(name)
1294
1396
  ])))
1295
- ])
1397
+ }
1296
1398
  }
1297
1399
 
1298
1400
  function nativeStateNames(expression, setters) {
@@ -1303,7 +1405,8 @@ function referencedStateNames(root, setters, scopeRoot = root) {
1303
1405
  const stateNames = new Set(setters.values())
1304
1406
  const used = new Set()
1305
1407
  const visit = node => {
1306
- 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))
1307
1410
  if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, scopeRoot)) used.add(node.text)
1308
1411
  ts.forEachChild(node, visit)
1309
1412
  }
@@ -1342,20 +1445,22 @@ function referencedImportedBindings(expression, imports) {
1342
1445
 
1343
1446
  function captureNames(declarationRoot, referenceRoot, setters) {
1344
1447
  const local = new Set()
1345
- const collectDeclarations = node => {
1346
- if (ts.isVariableDeclaration(node)) for (const name of bindingNames(node.name)) local.add(name)
1347
- if (ts.isParameter(node)) for (const name of bindingNames(node.name)) local.add(name)
1348
- if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) local.add(node.name.text)
1349
- 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)
1350
1456
  }
1351
- collectDeclarations(declarationRoot)
1352
-
1353
1457
  const stateNames = new Set(setters.values())
1354
1458
  const captures = new Set()
1355
1459
  const visit = node => {
1356
1460
  if (ts.isTypeNode(node)) return
1357
- if (ts.isIdentifier(node) && isReferenceIdentifier(node) && !local.has(node.text) && !setters.has(node.text) && !stateNames.has(node.text) && !nativeGlobals.has(node.text)) {
1358
- 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)
1359
1464
  }
1360
1465
  ts.forEachChild(node, visit)
1361
1466
  }
@@ -1377,7 +1482,7 @@ function isReferenceIdentifier(node) {
1377
1482
  (ts.isVariableDeclaration(parent) && parent.name === node) ||
1378
1483
  (ts.isParameter(parent) && parent.name === node) ||
1379
1484
  (ts.isFunctionDeclaration(parent) && parent.name === node) ||
1380
- (ts.isBindingElement(parent) && parent.name === node) ||
1485
+ (ts.isBindingElement(parent) && (parent.name === node || parent.propertyName === node)) ||
1381
1486
  ts.isImportSpecifier(parent) || ts.isImportClause(parent)) return false
1382
1487
  return true
1383
1488
  }
@@ -1397,6 +1502,43 @@ function isShadowedByParameter(node, scopeRoot) {
1397
1502
  return false
1398
1503
  }
1399
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
+
1400
1542
  function settersForNode(node, settersByFunction) {
1401
1543
  for (let current = node.parent; current; current = current.parent) {
1402
1544
  if (!ts.isFunctionDeclaration(current) && !ts.isFunctionExpression(current) && !ts.isArrowFunction(current)) continue
@@ -1477,6 +1619,42 @@ function sourceNodeError(node, fallbackSource, message) {
1477
1619
  return new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
1478
1620
  }
1479
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
+
1480
1658
  function printClientImports(entries, handlerPath) {
1481
1659
  const unique = new Map(entries.map(entry => [`${entry.target}:${entry.kind}:${entry.imported ?? ""}:${entry.local}`, entry]))
1482
1660
  const groups = Map.groupBy(unique.values(), entry => entry.target)
@@ -1594,24 +1772,33 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
1594
1772
  const stateNames = new Set(setters.values())
1595
1773
  const transformer = context => root => {
1596
1774
  const visitor = node => {
1597
- 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)) {
1598
1776
  return factory.createCallExpression(
1599
1777
  factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "set"),
1600
1778
  undefined,
1601
1779
  [factory.createStringLiteral(setters.get(node.expression.text)), ...node.arguments.map(argument => ts.visitNode(argument, visitor))]
1602
1780
  )
1603
1781
  }
1604
- 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)) {
1605
1792
  return factory.createCallExpression(
1606
1793
  factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
1607
1794
  undefined,
1608
1795
  [factory.createStringLiteral(node.text)]
1609
1796
  )
1610
1797
  }
1611
- if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text)) {
1798
+ if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
1612
1799
  return factory.createPropertyAssignment(node.name, scopeRead(factory, node.name.text))
1613
1800
  }
1614
- if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node)) {
1801
+ if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
1615
1802
  return scopeRead(factory, node.text)
1616
1803
  }
1617
1804
  return ts.visitEachChild(node, visitor, context)
@@ -1640,6 +1827,17 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
1640
1827
  }
1641
1828
  }
1642
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
+
1643
1841
  function printReactiveBinding({ exportName, expression, captures, states }) {
1644
1842
  const factory = ts.factory
1645
1843
  const transformer = context => root => {
@@ -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.5",
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",