@kudzujs/core 0.5.7 → 0.5.10

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
@@ -126,17 +126,20 @@ Static trusted HTML can be rendered without a transform layer:
126
126
 
127
127
  The HTML is intentionally not sanitized. Use only trusted or previously sanitized build-time content. Reactive raw HTML, children on the same element, void elements, and keyed-list raw HTML are rejected.
128
128
 
129
- Every CSS file under `src` is copied to the same relative path under `dist/assets` and linked in deterministic order. Project-page deployments and post-build artifacts use `kudzu.config.mjs`:
129
+ Every CSS file under `src` is copied to the same relative path under `dist/assets` and linked in deterministic order. Stylesheets produced by another build step can be declared globally so Kudzu still emits them in every document `<head>`. Configured root-relative URLs receive `base`; absolute HTTP URLs are preserved. Project-page deployments and post-build artifacts use `kudzu.config.mjs`:
130
130
 
131
131
  ```js
132
132
  export default {
133
133
  base: "/newsletter",
134
+ styles: ["/assets/generated.css"],
134
135
  async afterBuild({ outDir, routes, plans, rewrites, base }) {
135
- // Write host rewrites, RSS, sitemap, or other static artifacts.
136
+ // Write generated.css, host rewrites, RSS, sitemap, or other static artifacts.
136
137
  }
137
138
  }
138
139
  ```
139
140
 
141
+ Do not render `<link rel="stylesheet">` from page or component JSX. Kudzu rejects direct static body stylesheets with a source location and catches computed JSX stylesheet output during rendering. Trusted `dangerouslySetInnerHTML` remains unparsed and is responsible for its own resource tags.
142
+
140
143
  ## State Semantics
141
144
 
142
145
  Kudzu intentionally differs from React's state snapshot behavior:
@@ -329,7 +332,7 @@ The original component remains reusable across multiple lists and ordinary JSX.
329
332
 
330
333
  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>`.
331
334
 
332
- ## Mount Effects
335
+ ## Effects
333
336
 
334
337
  Browser-only initial work uses the familiar empty-dependency effect shape:
335
338
 
@@ -346,10 +349,41 @@ useEffect(async () => {
346
349
 
347
350
  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.
348
351
 
349
- 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.
352
+ An effect may directly return an inline cleanup function:
353
+
354
+ ```tsx
355
+ useEffect(() => {
356
+ const onResize = () => console.log(window.innerWidth)
357
+ window.addEventListener("resize", onResize)
358
+
359
+ return () => window.removeEventListener("resize", onResize)
360
+ }, [])
361
+ ```
362
+
363
+ Cleanup runs once when the document leaves outside the browser back-forward cache. Effect-local resources and component state read by nested cleanup closures retain their setup-time values. Cleanup failures are isolated so later cleanups still run.
364
+
365
+ Literal arrays of direct primitive `useState` or `useParams` signal identifiers rerun after committed dependency changes:
366
+
367
+ ```tsx
368
+ const [event, setEvent] = useState("resize")
369
+
370
+ useEffect(() => {
371
+ const listener = () => console.log(event)
372
+ window.addEventListener(event, listener)
373
+ return () => window.removeEventListener(event, listener)
374
+ }, [event])
375
+ ```
376
+
377
+ Dependency values are limited to JSON-safe strings, finite numbers, booleans, and `null`; direct signal aliases are accepted, while expressions, property reads, ordinary props or locals, objects, spreads, and dynamic arrays fail the build. Kudzu compares dependencies with `Object.is`, coalesces multiple commits in one turn, invokes every affected previous cleanup in declaration order, awaits asynchronous cleanup, and then runs the affected setups in declaration order. The component itself is not rerun.
378
+
379
+ Effect callbacks must be inline and block-bodied. Named or dynamically obtained cleanup functions, cleanup parameters or generators, other return values, callback parameters, and non-serializable captures are rejected. Async effects cannot return cleanup functions; the cleanup itself may be async. Pages without effects receive no effect entry. Empty-dependency effects retain their smaller output, and dependency-only capability code is isolated to the routes that use `kudzu-deps.js` unless another capability already requires the shared runtime.
350
380
 
351
381
  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.
352
382
 
383
+ A matched resize-listener cleanup fixture, measured with the same warm-up and seven rotating clean builds, shipped 1.2 KB JavaScript gzip and built in 402 ms with Kudzu. Svelte shipped 10.1 KB and built in 861 ms, Vue shipped 23.6 KB and built in 768 ms, and React shipped 59.1 KB and built in 1,058 ms. Kudzu and the 127 B hand-written Astro baseline emitted initial HTML; the CSR fixtures did not.
384
+
385
+ In the matched dependency-rerun fixture, Kudzu shipped 1.5 KB JavaScript gzip and built in 429 ms. Svelte shipped 9.7 KB in 995 ms, Vue 23.8 KB in 943 ms, React 59.2 KB in 1,172 ms, and the hand-written Astro baseline 196 B in 969 ms. Kudzu and Astro emitted initial HTML; the CSR fixtures did not.
386
+
353
387
  ## Normal JavaScript
354
388
 
355
389
  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.
@@ -396,6 +430,7 @@ TSX
396
430
 
397
431
  - Static pages ship no client JavaScript.
398
432
  - Interactive pages receive only the runtime capabilities they use.
433
+ - Interactive route modules are discovered in the document head and retain deferred execution after HTML parsing, overlapping cold downloads with document transfer.
399
434
  - Production JavaScript is minified; development output stays readable.
400
435
  - Components are authoring units; no component tree is retained in the browser.
401
436
  - There is no VDOM, hydration pass, router, or client application runtime.
@@ -4,6 +4,7 @@
4
4
  - `core.mjs`: server-side JSX rendering, state slots, context providers, behavior metadata, and serializable capture validation.
5
5
  - `jsx-runtime.mjs`: automatic JSX runtime used by TypeScript.
6
6
  - `runtime.js`: command-only runtime for direct state-to-text patches.
7
+ - `dependency-runtime.js`: command runtime with commit observation for dependency effects.
7
8
  - `shared-runtime.js`: command runtime with capability commit and DOM lifecycle hooks, emitted only when needed.
8
9
  - `binding-runtime.js`: optional generic attributes, form properties, comment-bounded text patches, and conditional range patches.
9
10
  - `list-runtime.js`: optional keyed list validation, external item-expression evaluation, item-local conditional ranges, dynamic styles and item-handler scopes, moves, and cleanup.
@@ -13,6 +14,6 @@
13
14
  - `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
14
15
  - `*.d.ts`: public TypeScript and JSX declarations.
15
16
 
16
- Static routes receive no browser runtime. Command routes receive `runtime.js`; runtime bracket pages using `useParams()` add one route-specific pathname matcher; 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. Runtime fallback rewrites are ordered by specificity in `.kudzu/kudzu-plan.json` and passed to `afterBuild()`; exact static files take precedence in development. 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.
17
+ Static routes receive no browser runtime. Command routes receive `runtime.js`; dependency effects use route-specific `kudzu-deps.js` unless that route already requires shared commit hooks; runtime bracket pages using `useParams()` add one route-specific pathname matcher; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`; effects add `effect-runtime.js` and one route-specific entry. Generated module scripts live in the document head, so cold downloads overlap HTML transfer while standard module deferral preserves execution after parsing. A single effect with one dependency compiles to a direct runner; generic maps, sets, and ordering are reserved for larger effect graphs. Dependency commits coalesce in a microtask; affected cleanups are awaited in declaration order before replacement setups run. Document cleanup integrates with shared unmount hooks when present and otherwise disposes directly on non-persisted `pagehide`. 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. Runtime fallback rewrites are ordered by specificity in `.kudzu/kudzu-plan.json` and passed to `afterBuild()`; exact static files take precedence in development. 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.
17
18
 
18
- Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
19
+ Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime. Source CSS and global `kudzu.config` styles are emitted in document heads before `afterBuild()` runs; static stylesheet links in component JSX fail compilation instead of loading from the body.
@@ -19,6 +19,7 @@ const devClient = (session, revision, schema) => `<script>(()=>{const show=event
19
19
  export async function build({ quiet = false, minify = true } = {}) {
20
20
  const config = await loadConfig()
21
21
  const base = normalizeBase(config.base)
22
+ const configuredStyles = normalizeStyles(config.styles, base)
22
23
  await rm(workDirectory, { recursive: true, force: true })
23
24
  await rm(outputDirectory, { recursive: true, force: true })
24
25
  await mkdir(workDirectory, { recursive: true })
@@ -41,16 +42,23 @@ export async function build({ quiet = false, minify = true } = {}) {
41
42
  if (!pageFiles.length) throw new Error("No pages found in src/pages/")
42
43
 
43
44
  let behaviorCount = 0
45
+ let regularBehaviorCount = 0
44
46
  let bindingCount = 0
45
47
  let listCount = 0
46
48
  let listStyleCount = 0
47
- let stateSeedCount = 0
49
+ let regularStateSeedCount = 0
50
+ let dependencyStateSeedCount = 0
48
51
  const plans = []
52
+ const pageEntries = []
49
53
  const effectEntries = []
50
54
  const paramEntries = []
51
55
  const rewrites = []
52
56
  const emittedRoutes = new Set()
53
- const styleUrls = cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`))
57
+ const styleUrls = [...new Set([
58
+ ...cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)),
59
+ ...configuredStyles
60
+ ])]
61
+ const runtimePlaceholder = `/__kudzu_runtime_${randomUUID()}.js`
54
62
 
55
63
  for (const pageFile of pageFiles) {
56
64
  const compiledFile = compiledPath(pageFile)
@@ -81,21 +89,28 @@ export async function build({ quiet = false, minify = true } = {}) {
81
89
  ...(module.metadata ?? {}),
82
90
  styles: styleUrls.length ? styleUrls : false,
83
91
  base,
92
+ runtimeAsset: runtimePlaceholder,
84
93
  effectAsset: assetPath(base, `assets/${effectPath}`),
85
94
  paramAsset: assetPath(base, `assets/${paramPath}`),
86
95
  runtimeParams: runtimeSchema?.params
87
96
  }, props)
88
- const routeDirectory = join(outputDirectory, route)
89
- await mkdir(routeDirectory, { recursive: true })
90
- await writeFile(join(routeDirectory, "index.html"), result.html)
97
+ const hasDependencies = result.plan.effects.some(effect => effect.dependencies?.length)
98
+ const usesDependencyRuntime = hasDependencies && !result.hasBindings && !result.hasLists && !result.plan.events.some(event => event.native)
99
+ pageEntries.push({ route, html: result.html, usesDependencyRuntime })
91
100
  plans.push({ route: routePath, ...result.plan })
92
- if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params })
93
- if (result.hasEffects) effectEntries.push({ path: effectPath, effects: result.plan.effects, paramPath: result.hasParams ? paramPath : undefined })
94
- if (result.hasBehaviors) behaviorCount++
101
+ if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params, usesDependencyRuntime })
102
+ if (result.hasEffects) effectEntries.push({ path: effectPath, effects: result.plan.effects, paramPath: result.hasParams ? paramPath : undefined, usesDependencyRuntime })
103
+ if (result.hasBehaviors) {
104
+ behaviorCount++
105
+ if (!usesDependencyRuntime) regularBehaviorCount++
106
+ }
95
107
  if (result.hasBindings) bindingCount++
96
108
  if (result.hasLists) listCount++
97
109
  if (result.hasListStyles) listStyleCount++
98
- if (result.hasStateSeed) stateSeedCount++
110
+ if (result.hasStateSeed) {
111
+ if (usesDependencyRuntime) dependencyStateSeedCount++
112
+ else regularStateSeedCount++
113
+ }
99
114
  }
100
115
  }
101
116
 
@@ -119,11 +134,24 @@ export async function build({ quiet = false, minify = true } = {}) {
119
134
  const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
120
135
  const hasNativeHandlers = nativeModules.length > 0
121
136
  const hasEffects = effectEntries.length > 0
122
- if (behaviorCount) {
123
- const runtimeFile = bindingCount || listCount || hasNativeHandlers ? "./shared-runtime.js" : "./runtime.js"
124
- const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
137
+ const hasSharedRuntime = bindingCount || listCount || hasNativeHandlers
138
+ const hasDependencyRuntime = pageEntries.some(entry => entry.usesDependencyRuntime)
139
+ const runtimeName = usesDependencyRuntime => usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
140
+ for (const entry of pageEntries) {
141
+ const routeDirectory = join(outputDirectory, entry.route)
142
+ await mkdir(routeDirectory, { recursive: true })
143
+ const html = entry.html.replace(runtimePlaceholder, escapeAttribute(assetPath(base, `assets/${runtimeName(entry.usesDependencyRuntime)}`)))
144
+ await writeFile(join(routeDirectory, "index.html"), html)
145
+ }
146
+ if (behaviorCount && (hasSharedRuntime || regularBehaviorCount)) {
147
+ const runtimeFile = hasSharedRuntime ? "./shared-runtime.js" : "./runtime.js"
148
+ const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, regularStateSeedCount > 0)
125
149
  await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
126
150
  }
151
+ if (hasDependencyRuntime) {
152
+ const runtime = specializeRuntime(await readFile(new URL("./dependency-runtime.js", import.meta.url), "utf8"), commandEvents, dependencyStateSeedCount > 0)
153
+ await writeJavaScript(join(assetsDirectory, "kudzu-deps.js"), runtime, minify)
154
+ }
127
155
  if (bindingCount || hasNativeHandlers || hasEffectCaptures) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify, {
128
156
  "globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures),
129
157
  "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
@@ -186,12 +214,12 @@ export async function build({ quiet = false, minify = true } = {}) {
186
214
  for (const entry of paramEntries) {
187
215
  const output = join(assetsDirectory, entry.path)
188
216
  await mkdir(dirname(output), { recursive: true })
189
- await writeJavaScript(output, printParamEntry(entry.schema, entry.params, output, assetsDirectory, base), minify)
217
+ await writeJavaScript(output, printParamEntry(entry.schema, entry.params, output, assetsDirectory, base, runtimeName(entry.usesDependencyRuntime)), minify)
190
218
  }
191
219
  for (const entry of effectEntries) {
192
220
  const output = join(assetsDirectory, entry.path)
193
221
  await mkdir(dirname(output), { recursive: true })
194
- await writeJavaScript(output, printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath), minify)
222
+ await writeJavaScript(output, printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath, runtimeName(entry.usesDependencyRuntime)), minify)
195
223
  }
196
224
  const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
197
225
  for (const file of clientModules) {
@@ -243,7 +271,9 @@ function specializeNativeRuntime(source, events, modules) {
243
271
  return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
244
272
  }
245
273
 
246
- function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath) {
274
+ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath, runtimeName) {
275
+ const hasCleanup = effects.some(effect => effect.cleanup)
276
+ const hasDependencies = effects.some(effect => effect.dependencies?.length)
247
277
  const moduleUrls = [...new Set(effects.map(effect => effect.module))]
248
278
  const modules = moduleUrls.map(url => {
249
279
  const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
@@ -251,13 +281,118 @@ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base
251
281
  return module
252
282
  })
253
283
  const imports = [
254
- `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
284
+ hasCleanup || hasDependencies
285
+ ? `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}\nconst { browserState, commitDom } = __kRuntime`
286
+ : `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}`,
255
287
  `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
256
288
  ...(paramPath ? [`import ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, paramPath)))}`] : []),
257
289
  ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
258
290
  ]
259
291
  const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
260
- return `${imports.join("\n")}
292
+ if (effects.length === 1 && effects[0].dependencies?.length === 1) return printSingleDependencyEffect(imports, effects[0], hasCleanup)
293
+ const disposal = hasCleanup ? `
294
+ let disposed = false
295
+ const dispose = root => {
296
+ if (root !== document || disposed) return
297
+ disposed = true
298
+ active = false
299
+ pending.clear()
300
+ for (const record of records) invokeCleanup(record)
301
+ }
302
+ if (__kRuntime.registerUnmountHook) __kRuntime.registerUnmountHook(dispose)
303
+ addEventListener("pagehide", event => {
304
+ if (event.persisted) return
305
+ if (__kRuntime.unmountDom) __kRuntime.unmountDom(document)
306
+ else dispose(document)
307
+ })` : ""
308
+ if (hasDependencies) return `${imports.join("\n")}
309
+ const effects = ${inlineJson(effects)}
310
+ const modules = new Map([${entries}])
311
+ const records = effects.map((effect, index) => ({ effect, index, values: undefined, cleanup: undefined }))
312
+ const dependencies = new Map()
313
+ const pending = new Set()
314
+ let scheduled = false
315
+ let flushing = false
316
+ let active = true
317
+ for (const record of records) {
318
+ for (const id of record.effect.dependencies ?? []) {
319
+ const subscribers = dependencies.get(id) ?? new Set()
320
+ subscribers.add(record)
321
+ dependencies.set(id, subscribers)
322
+ }
323
+ }
324
+ __kRuntime.registerCommitter(id => {
325
+ if (!active) return
326
+ for (const record of dependencies.get(id) ?? []) pending.add(record)
327
+ schedule()
328
+ })
329
+ for (const record of records) {
330
+ try {
331
+ record.values = readDependencies(record)
332
+ invoke(record)
333
+ } catch (error) {
334
+ console.error(error)
335
+ }
336
+ }
337
+ function schedule() {
338
+ if (!pending.size || scheduled || flushing) return
339
+ scheduled = true
340
+ queueMicrotask(flush)
341
+ }
342
+ async function flush() {
343
+ scheduled = false
344
+ if (!active) return pending.clear()
345
+ flushing = true
346
+ try {
347
+ const selected = [...pending].sort((left, right) => left.index - right.index)
348
+ pending.clear()
349
+ const changed = []
350
+ for (const record of selected) {
351
+ try {
352
+ const values = readDependencies(record)
353
+ if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) {
354
+ record.values = values
355
+ changed.push(record)
356
+ }
357
+ } catch (error) {
358
+ console.error(error)
359
+ }
360
+ }
361
+ for (const record of changed) await invokeCleanup(record)
362
+ if (active) for (const record of changed) invoke(record)
363
+ } finally {
364
+ flushing = false
365
+ if (active) schedule()
366
+ }
367
+ }
368
+ function readDependencies(record) {
369
+ return (record.effect.dependencies ?? []).map(id => {
370
+ const value = browserState.get(id)
371
+ if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
372
+ return value
373
+ })
374
+ }
375
+ function invoke(record) {
376
+ try {
377
+ const effect = record.effect
378
+ const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, effect.scope))
379
+ if (effect.cleanup && typeof result === "function") record.cleanup = result
380
+ else if (result && typeof result.then === "function") result.catch(error => console.error(error))
381
+ } catch (error) {
382
+ console.error(error)
383
+ }
384
+ }
385
+ async function invokeCleanup(record) {
386
+ const cleanup = record.cleanup
387
+ record.cleanup = undefined
388
+ if (!cleanup) return
389
+ try {
390
+ await cleanup()
391
+ } catch (error) {
392
+ console.error(error)
393
+ }
394
+ }${disposal}`
395
+ if (!hasCleanup) return `${imports.join("\n")}
261
396
  const effects = ${inlineJson(effects)}
262
397
  const modules = new Map([${entries}])
263
398
  for (const effect of effects) {
@@ -268,10 +403,131 @@ for (const effect of effects) {
268
403
  console.error(error)
269
404
  }
270
405
  }`
406
+ return `${imports.join("\n")}
407
+ const effects = ${inlineJson(effects)}
408
+ const modules = new Map([${entries}])
409
+ const cleanups = []
410
+ for (const effect of effects) {
411
+ try {
412
+ const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, effect.scope))
413
+ if (effect.cleanup && typeof result === "function") cleanups.push(result)
414
+ else if (result && typeof result.then === "function") result.catch(error => console.error(error))
415
+ } catch (error) {
416
+ console.error(error)
417
+ }
418
+ }
419
+ let cleaned = false
420
+ const dispose = root => {
421
+ if (root !== document || cleaned) return
422
+ cleaned = true
423
+ for (const cleanup of cleanups) {
424
+ try {
425
+ const result = cleanup()
426
+ if (result && typeof result.then === "function") result.catch(error => console.error(error))
427
+ } catch (error) {
428
+ console.error(error)
429
+ }
430
+ }
431
+ cleanups.length = 0
432
+ }
433
+ if (__kRuntime.registerUnmountHook) __kRuntime.registerUnmountHook(dispose)
434
+ addEventListener("pagehide", event => {
435
+ if (event.persisted) return
436
+ if (__kRuntime.unmountDom) __kRuntime.unmountDom(document)
437
+ else dispose(document)
438
+ })`
439
+ }
440
+
441
+ function printSingleDependencyEffect(imports, effect, hasCleanup) {
442
+ const disposal = hasCleanup ? `
443
+ const dispose = root => {
444
+ if (root !== document || !active) return
445
+ active = false
446
+ pending = false
447
+ invokeCleanup()
448
+ }
449
+ if (__kRuntime.registerUnmountHook) __kRuntime.registerUnmountHook(dispose)
450
+ addEventListener("pagehide", event => {
451
+ if (event.persisted) return
452
+ if (__kRuntime.unmountDom) __kRuntime.unmountDom(document)
453
+ else dispose(document)
454
+ })` : ""
455
+ return `${imports.join("\n")}
456
+ const effect = ${inlineJson(effect)}
457
+ const dependency = effect.dependencies[0]
458
+ let value
459
+ let cleanup
460
+ let active = true
461
+ let pending = false
462
+ let scheduled = false
463
+ let running = false
464
+ __kRuntime.registerCommitter(id => {
465
+ if (active && id === dependency) {
466
+ pending = true
467
+ schedule()
468
+ }
469
+ })
470
+ try {
471
+ value = readDependency()
472
+ invoke()
473
+ } catch (error) {
474
+ console.error(error)
475
+ }
476
+ function schedule() {
477
+ if (!pending || scheduled || running) return
478
+ scheduled = true
479
+ queueMicrotask(flush)
480
+ }
481
+ async function flush() {
482
+ scheduled = false
483
+ if (!active) return
484
+ let next
485
+ try {
486
+ next = readDependency()
487
+ } catch (error) {
488
+ console.error(error)
489
+ return
490
+ }
491
+ pending = false
492
+ if (Object.is(next, value)) return
493
+ value = next
494
+ running = true
495
+ try {
496
+ await invokeCleanup()
497
+ if (active) invoke()
498
+ } finally {
499
+ running = false
500
+ if (active) schedule()
501
+ }
502
+ }
503
+ function readDependency() {
504
+ const next = browserState.get(dependency)
505
+ if (next !== null && typeof next !== "string" && typeof next !== "boolean" && !(typeof next === "number" && Number.isFinite(next) && !Object.is(next, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
506
+ return next
507
+ }
508
+ function invoke() {
509
+ try {
510
+ const result = __kEffectModule0[effect.handler](createEffectContext(browserState, effect.states, commitDom, effect.scope))
511
+ if (effect.cleanup && typeof result === "function") cleanup = result
512
+ else if (result && typeof result.then === "function") result.catch(error => console.error(error))
513
+ } catch (error) {
514
+ console.error(error)
515
+ }
516
+ }
517
+ async function invokeCleanup() {
518
+ const current = cleanup
519
+ cleanup = undefined
520
+ if (!current) return
521
+ try {
522
+ await current()
523
+ } catch (error) {
524
+ console.error(error)
525
+ }
526
+ }${disposal}`
271
527
  }
272
528
 
273
- function printParamEntry(schema, params, output, assetsDirectory, base) {
274
- return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}
529
+ function printParamEntry(schema, params, output, assetsDirectory, base, runtimeName) {
530
+ return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}
275
531
  const base = ${inlineJson(browserPath(base).slice(1).split("/").filter(Boolean).map(segment => decodeURIComponent(segment)))}
276
532
  const schema = ${inlineJson(schema.segments)}
277
533
  const params = ${inlineJson(params)}
@@ -465,7 +721,7 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
465
721
  }
466
722
 
467
723
  function injectDevClient(html, session, revision, schema) {
468
- return `${html}${devClient(session, revision, schema)}`
724
+ return `${html}${devClient(session, revision, schema).replace("binding|list|native", "binding|deps|list|native")}`
469
725
  }
470
726
 
471
727
  function stripBaseStrict(path, base) {
@@ -540,6 +796,10 @@ function escapeHtml(value) {
540
796
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
541
797
  }
542
798
 
799
+ function escapeAttribute(value) {
800
+ return escapeHtml(value).replaceAll('"', "&quot;").replaceAll("'", "&#39;")
801
+ }
802
+
543
803
  async function compile(file, sourceFiles, sourceIndex, base) {
544
804
  const source = sourceIndex.get(file)
545
805
  const nativeHandlers = []
@@ -589,6 +849,7 @@ async function compile(file, sourceFiles, sourceIndex, base) {
589
849
  function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports) {
590
850
  return context => sourceFile => {
591
851
  const factory = context.factory
852
+ const hasLinkElements = /<link/i.test(sourceFile.text)
592
853
  sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
593
854
  ts.setParentRecursive(sourceFile, false)
594
855
  const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
@@ -791,6 +1052,10 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
791
1052
  if (specializedDeclarations.has(node)) return node
792
1053
  if (componentSpecializations.has(node)) return ts.visitNode(componentSpecializations.get(node).root, visitor)
793
1054
 
1055
+ if (hasLinkElements && (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) && isStylesheetLink(node)) {
1056
+ fail(node, "Stylesheets must be placed under src/ or declared in kudzu.config styles so Kudzu can emit them in <head>")
1057
+ }
1058
+
794
1059
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
795
1060
  const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
796
1061
  return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
@@ -802,18 +1067,24 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
802
1067
  }
803
1068
 
804
1069
  if (hasUseEffectImport && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useEffect") {
805
- if (node.arguments.length !== 2) fail(node, "useEffect() requires exactly a callback and literal empty dependency array")
1070
+ if (node.arguments.length !== 2) fail(node, "useEffect() requires exactly a callback and literal dependency array")
806
1071
  const [callback, dependencies] = node.arguments
807
1072
  if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) fail(callback, "useEffect() callback must be an inline function")
808
1073
  if (ts.isFunctionExpression(callback) && callback.name) fail(callback, "useEffect() callback function must be anonymous")
1074
+ if (callback.asteriskToken) fail(callback, "useEffect() callback cannot be a generator")
809
1075
  if (callback.parameters.length) fail(callback, "useEffect() callback cannot declare parameters")
810
- if (!ts.isArrayLiteralExpression(dependencies) || dependencies.elements.length) fail(dependencies, "useEffect() dependencies must be a literal empty array")
1076
+ if (!ts.isArrayLiteralExpression(dependencies)) fail(dependencies, "useEffect() dependencies must be a literal array")
1077
+ const invalidDependency = dependencies.elements.find(dependency => !ts.isIdentifier(dependency))
1078
+ if (invalidDependency) fail(invalidDependency, "useEffect() dependencies must be direct state or runtime parameter identifiers")
811
1079
  if (!nearestFunction(node)) fail(node, "useEffect() cannot be used outside a Kudzu component")
812
- if (returnsCleanup(callback)) fail(callback, "useEffect() cleanup functions are not supported")
813
1080
  if (!ts.isBlock(callback.body)) fail(callback, "useEffect() callback must use a block body")
814
- if (returnsEffectValue(callback)) fail(callback, "useEffect() return values are not supported")
1081
+ const returns = effectReturns(callback)
1082
+ if (returns.invalid) fail(returns.invalid, "useEffect() return values must be inline cleanup functions")
1083
+ const invalidCleanup = returns.cleanups.find(cleanup => cleanup.parameters.length || cleanup.asteriskToken)
1084
+ if (invalidCleanup) fail(invalidCleanup, "useEffect() cleanup functions cannot declare parameters or be generators")
1085
+ if (returns.cleanup && callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) fail(callback, "useEffect() async callbacks cannot return cleanup functions")
815
1086
  const setters = settersForNode(node, settersByFunction)
816
- const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, importBindings, clientImports, "effect", undefined, true)
1087
+ const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, importBindings, clientImports, "effect", undefined, true, returns.cleanup)
817
1088
  usesBehavior = true
818
1089
  return factory.updateCallExpression(node, node.expression, node.typeArguments, [
819
1090
  callback,
@@ -822,7 +1093,8 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
822
1093
  factory.createStringLiteral(descriptor.exportName),
823
1094
  descriptor.states,
824
1095
  descriptor.scope,
825
- factory.createStringLiteral(sourceLocation(node, sourceFile))
1096
+ factory.createStringLiteral(sourceLocation(node, sourceFile)),
1097
+ returns.cleanup ? factory.createTrue() : factory.createFalse()
826
1098
  ])
827
1099
  }
828
1100
 
@@ -1227,6 +1499,19 @@ function jsxTagName(node) {
1227
1499
  return ts.isJsxElement(node) ? node.openingElement.tagName : ts.isJsxSelfClosingElement(node) ? node.tagName : undefined
1228
1500
  }
1229
1501
 
1502
+ function isStylesheetLink(node) {
1503
+ const element = ts.isJsxElement(node) ? node.openingElement : node
1504
+ if (!ts.isIdentifier(element.tagName) || element.tagName.text.toLowerCase() !== "link") return false
1505
+ const attribute = element.attributes.properties.find(property => ts.isJsxAttribute(property) && property.name.getText().toLowerCase() === "rel")
1506
+ if (!attribute?.initializer) return false
1507
+ const value = ts.isStringLiteral(attribute.initializer)
1508
+ ? attribute.initializer.text
1509
+ : ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression && (ts.isStringLiteral(attribute.initializer.expression) || ts.isNoSubstitutionTemplateLiteral(attribute.initializer.expression))
1510
+ ? attribute.initializer.expression.text
1511
+ : undefined
1512
+ return value?.toLowerCase().split(/\s+/).includes("stylesheet") ?? false
1513
+ }
1514
+
1230
1515
  function isContextProviderValue(node, contexts) {
1231
1516
  if (node.name.getText() !== "value") return false
1232
1517
  const element = node.parent?.parent
@@ -1240,7 +1525,7 @@ function isJsxSyntaxIdentifier(node) {
1240
1525
  }
1241
1526
 
1242
1527
  function isFunctionLike(node) {
1243
- return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node)
1528
+ return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node) || ts.isConstructorDeclaration(node)
1244
1529
  }
1245
1530
 
1246
1531
  function isDestructuredParameter(identifier, fn) {
@@ -1480,14 +1765,14 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
1480
1765
  ])
1481
1766
  }
1482
1767
 
1483
- function compileNativeCallback(expression, setters, factory, entries, importBindings, clientImports, prefix, listItem, deferValues = false) {
1768
+ function compileNativeCallback(expression, setters, factory, entries, importBindings, clientImports, prefix, listItem, deferValues = false, snapshotNested = false) {
1484
1769
  const allCaptures = nativeCaptureNames(expression, setters)
1485
1770
  const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
1486
1771
  const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
1487
1772
  for (const entry of imports) clientImports.add(entry.target)
1488
1773
  const usedStates = nativeStateNames(expression, setters)
1489
1774
  const exportName = `${prefix}${entries.length}`
1490
- entries.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))) })
1775
+ entries.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))), snapshotNested })
1491
1776
  const value = name => deferValues
1492
1777
  ? factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), factory.createIdentifier(name))
1493
1778
  : factory.createIdentifier(name)
@@ -1586,6 +1871,7 @@ function isReferenceIdentifier(node) {
1586
1871
  if ((ts.isPropertyAccessExpression(parent) && parent.name === node) ||
1587
1872
  (ts.isPropertyAssignment(parent) && parent.name === node) ||
1588
1873
  (ts.isMethodDeclaration(parent) && parent.name === node) ||
1874
+ ((ts.isGetAccessorDeclaration(parent) || ts.isSetAccessorDeclaration(parent)) && parent.name === node) ||
1589
1875
  (ts.isVariableDeclaration(parent) && parent.name === node) ||
1590
1876
  (ts.isParameter(parent) && parent.name === node) ||
1591
1877
  (ts.isFunctionDeclaration(parent) && parent.name === node) ||
@@ -1603,7 +1889,7 @@ function nearestFunction(node) {
1603
1889
 
1604
1890
  function isShadowedByParameter(node, scopeRoot) {
1605
1891
  for (let current = node.parent; current; current = current.parent) {
1606
- if ((ts.isFunctionDeclaration(current) || ts.isFunctionExpression(current) || ts.isArrowFunction(current)) && current.parameters.some(parameter => bindingNames(parameter.name).includes(node.text))) return true
1892
+ if (isFunctionLike(current) && current.parameters.some(parameter => bindingNames(parameter.name).includes(node.text))) return true
1607
1893
  if (current === scopeRoot) break
1608
1894
  }
1609
1895
  return false
@@ -1733,33 +2019,24 @@ function sourceLocation(node, fallbackSource) {
1733
2019
  return `${sourceFile.fileName}:${position.line + 1}:${position.character + 1}`
1734
2020
  }
1735
2021
 
1736
- function returnsCleanup(callback) {
1737
- if (ts.isArrowFunction(callback) && !ts.isBlock(callback.body)) {
1738
- const body = unwrapExpression(callback.body)
1739
- return ts.isArrowFunction(body) || ts.isFunctionExpression(body)
1740
- }
1741
- let found = false
2022
+ function effectReturns(callback) {
2023
+ let cleanup = false
2024
+ let invalid
2025
+ const cleanups = []
1742
2026
  const visit = node => {
1743
- if (found || node !== callback.body && isFunctionLike(node)) return
2027
+ if (invalid || node !== callback.body && isFunctionLike(node)) return
1744
2028
  if (ts.isReturnStatement(node) && node.expression) {
1745
2029
  const expression = unwrapExpression(node.expression)
1746
- if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) found = true
2030
+ if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) {
2031
+ cleanup = true
2032
+ cleanups.push(expression)
2033
+ }
2034
+ else invalid = node
1747
2035
  }
1748
- if (!found) ts.forEachChild(node, visit)
2036
+ if (!invalid) ts.forEachChild(node, visit)
1749
2037
  }
1750
2038
  visit(callback.body)
1751
- return found
1752
- }
1753
-
1754
- function returnsEffectValue(callback) {
1755
- let found = false
1756
- const visit = node => {
1757
- if (found || node !== callback.body && isFunctionLike(node)) return
1758
- if (ts.isReturnStatement(node) && node.expression) found = true
1759
- if (!found) ts.forEachChild(node, visit)
1760
- }
1761
- visit(callback.body)
1762
- return found
2039
+ return { cleanup, cleanups, invalid }
1763
2040
  }
1764
2041
 
1765
2042
  function printClientImports(entries, handlerPath) {
@@ -1874,9 +2151,13 @@ function relativeModulePath(from, to) {
1874
2151
  return path.startsWith(".") ? path : `./${path}`
1875
2152
  }
1876
2153
 
1877
- function printNativeHandler({ exportName, expression, captures, setters }) {
2154
+ function printNativeHandler({ exportName, expression, captures, setters, snapshotNested }) {
1878
2155
  const factory = ts.factory
1879
2156
  const stateNames = new Set(setters.values())
2157
+ const snapshotNames = snapshotNested ? nestedStateNames(expression, setters) : new Set()
2158
+ const snapshots = new Map([...snapshotNames].map(name => [name, factory.createUniqueName("__kEffectState")]))
2159
+ const captureSnapshotNames = snapshotNested ? nestedCaptureNames(expression, captures) : new Set()
2160
+ const captureSnapshots = new Map([...captureSnapshotNames].map(name => [name, factory.createUniqueName("__kEffectCapture")]))
1880
2161
  const transformer = context => root => {
1881
2162
  const visitor = node => {
1882
2163
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text) && !isShadowedIdentifier(node.expression, expression)) {
@@ -1893,9 +2174,11 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
1893
2174
  return setterReference(factory, setters.get(node.text))
1894
2175
  }
1895
2176
  if (ts.isShorthandPropertyAssignment(node) && stateNames.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
2177
+ if (snapshots.has(node.name.text) && insideNestedFunction(node, expression)) return factory.createPropertyAssignment(node.name, snapshots.get(node.name.text))
1896
2178
  return factory.createPropertyAssignment(node.name, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"), undefined, [factory.createStringLiteral(node.name.text)]))
1897
2179
  }
1898
2180
  if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
2181
+ if (snapshots.has(node.text) && insideNestedFunction(node, expression)) return snapshots.get(node.text)
1899
2182
  return factory.createCallExpression(
1900
2183
  factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
1901
2184
  undefined,
@@ -1903,9 +2186,11 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
1903
2186
  )
1904
2187
  }
1905
2188
  if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
2189
+ if (captureSnapshots.has(node.name.text) && insideNestedFunction(node, expression)) return factory.createPropertyAssignment(node.name, captureSnapshots.get(node.name.text))
1906
2190
  return factory.createPropertyAssignment(node.name, scopeRead(factory, node.name.text))
1907
2191
  }
1908
2192
  if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
2193
+ if (captureSnapshots.has(node.text) && insideNestedFunction(node, expression)) return captureSnapshots.get(node.text)
1909
2194
  return scopeRead(factory, node.text)
1910
2195
  }
1911
2196
  return ts.visitEachChild(node, visitor, context)
@@ -1914,9 +2199,17 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
1914
2199
  }
1915
2200
  const transformed = ts.transform(expression.body, [transformer])
1916
2201
  try {
1917
- const body = ts.isBlock(expression.body)
2202
+ let body = ts.isBlock(expression.body)
1918
2203
  ? transformed.transformed[0]
1919
2204
  : factory.createBlock([factory.createReturnStatement(transformed.transformed[0])], true)
2205
+ const snapshotDeclarations = [
2206
+ ...[...snapshots].map(([name, identifier]) => factory.createVariableDeclaration(identifier, undefined, undefined, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"), undefined, [factory.createStringLiteral(name)]))),
2207
+ ...[...captureSnapshots].map(([name, identifier]) => factory.createVariableDeclaration(identifier, undefined, undefined, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "scope"), undefined, [factory.createStringLiteral(name)])))
2208
+ ]
2209
+ if (snapshotDeclarations.length) body = factory.updateBlock(body, [
2210
+ factory.createVariableStatement(undefined, factory.createVariableDeclarationList(snapshotDeclarations, ts.NodeFlags.Const)),
2211
+ ...body.statements
2212
+ ])
1920
2213
  const modifiers = [factory.createModifier(ts.SyntaxKind.ExportKeyword)]
1921
2214
  if (expression.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) modifiers.push(factory.createModifier(ts.SyntaxKind.AsyncKeyword))
1922
2215
  const declaration = factory.createFunctionDeclaration(
@@ -1934,6 +2227,34 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
1934
2227
  }
1935
2228
  }
1936
2229
 
2230
+ function nestedCaptureNames(expression, captures) {
2231
+ const names = new Set()
2232
+ const visit = node => {
2233
+ if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node) && insideNestedFunction(node, expression) && !isShadowedIdentifier(node, expression)) names.add(node.text)
2234
+ ts.forEachChild(node, visit)
2235
+ }
2236
+ visit(expression.body)
2237
+ return names
2238
+ }
2239
+
2240
+ function nestedStateNames(expression, setters) {
2241
+ const states = new Set(setters.values())
2242
+ const names = new Set()
2243
+ const visit = node => {
2244
+ if (ts.isIdentifier(node) && states.has(node.text) && isReferenceIdentifier(node) && insideNestedFunction(node, expression) && !isShadowedIdentifier(node, expression)) names.add(node.text)
2245
+ ts.forEachChild(node, visit)
2246
+ }
2247
+ visit(expression.body)
2248
+ return names
2249
+ }
2250
+
2251
+ function insideNestedFunction(node, root) {
2252
+ for (let current = node.parent; current && current !== root; current = current.parent) {
2253
+ if (isFunctionLike(current)) return true
2254
+ }
2255
+ return false
2256
+ }
2257
+
1937
2258
  function setterReference(factory, stateName) {
1938
2259
  return factory.createArrowFunction(
1939
2260
  undefined,
@@ -2065,6 +2386,19 @@ async function loadConfig() {
2065
2386
  return {}
2066
2387
  }
2067
2388
 
2389
+ function normalizeStyles(value, base) {
2390
+ if (value === undefined) return []
2391
+ if (!Array.isArray(value)) throw new Error("kudzu.config styles must be an array of URLs")
2392
+ return value.map((style, index) => {
2393
+ if (typeof style !== "string" || !style) throw new Error(`kudzu.config styles[${index}] must be a non-empty URL`)
2394
+ if (style.startsWith("//")) throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`)
2395
+ if (style.startsWith("/")) return withBase(base, style)
2396
+ if (!/^https?:\/\//i.test(style)) throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`)
2397
+ try { new URL(style) } catch { throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`) }
2398
+ return style
2399
+ })
2400
+ }
2401
+
2068
2402
  function normalizeBase(value) {
2069
2403
  if (value == null || value === "" || value === "/") return ""
2070
2404
  if (typeof value !== "string" || !value.startsWith("/") || /[?#\0]/.test(value) || /%(?:2f|5c)/i.test(value)) throw new Error("kudzu.config base must be a root-relative path")
@@ -1,7 +1,9 @@
1
1
  export type StateSetter<T> = (value: T | ((previous: T) => T)) => void
2
+ export type EffectCleanup = () => void | Promise<void>
3
+ export type EffectDependency = string | number | boolean | null
2
4
 
3
5
  export function useState<T>(initialValue: T): [T, StateSetter<T>]
4
- export function useEffect(effect: () => void | Promise<void>, dependencies: readonly []): void
6
+ export function useEffect(effect: () => void | EffectCleanup | Promise<void>, dependencies: readonly EffectDependency[]): void
5
7
  export function useParams<Params extends Record<string, string> = Record<string, string>>(): Readonly<Params>
6
8
 
7
9
  export interface RefObject<T> {
@@ -48,6 +50,7 @@ export function renderPage<Props = Record<string, never>>(
48
50
  manifest?: string
49
51
  styles?: boolean | string[]
50
52
  base?: string
53
+ runtimeAsset?: string
51
54
  effectAsset?: string
52
55
  paramAsset?: string
53
56
  runtimeParams?: string[]
@@ -70,7 +73,7 @@ export function renderPage<Props = Record<string, never>>(
70
73
  commands?: Array<[string, string, unknown]>
71
74
  native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
72
75
  }>
73
- effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }>
76
+ effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown>; dependencies?: string[]; cleanup?: true }>
74
77
  bindings: Array<{
75
78
  target: string
76
79
  state?: string
@@ -64,16 +64,22 @@ function createSignal(id, value) {
64
64
  }
65
65
  }
66
66
 
67
- export function useEffect(callback, dependencies, module, handler, states, scope, source) {
67
+ export function useEffect(callback, dependencies, module, handler, states, scope, source, cleanup) {
68
68
  if (!renderContext) throw new Error("useEffect() can only run while rendering a Kudzu component")
69
- if (typeof callback !== "function" || !Array.isArray(dependencies) || dependencies.length || !module || !handler) {
70
- throw new Error("useEffect() must be compiled with a literal empty dependency array")
71
- }
72
- renderContext.effects.push({ module, handler, states, scope, source })
69
+ if (typeof callback !== "function" || !Array.isArray(dependencies) || !module || !handler) throw new Error("useEffect() must be compiled with a literal dependency array")
70
+ const dependencyIds = dependencies.map(dependency => {
71
+ if (!dependency?.[signalMarker] || !validEffectDependency(dependency.value)) throw new Error(`${source} useEffect() dependencies must be primitive Kudzu state or runtime parameter identifiers`)
72
+ return dependency.id
73
+ })
74
+ renderContext.effects.push({ module, handler, states, scope, source, ...(dependencyIds.length ? { dependencies: dependencyIds } : {}), ...(cleanup ? { cleanup: true } : {}) })
73
75
  renderContext.hasBehaviors = true
74
76
  renderContext.hasEffects = true
75
77
  }
76
78
 
79
+ function validEffectDependency(value) {
80
+ return value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0)
81
+ }
82
+
77
83
  export function useRef(initialValue) {
78
84
  if (!renderContext) throw new Error("useRef() can only run while rendering a Kudzu component")
79
85
  if (initialValue !== null) throw new Error("Kudzu DOM refs must initialize with null")
@@ -271,6 +277,8 @@ export async function renderPage(component, metadata = {}, props = {}) {
271
277
  return {
272
278
  module: effect.module,
273
279
  handler: effect.handler,
280
+ ...(effect.dependencies ? { dependencies: effect.dependencies } : {}),
281
+ ...(effect.cleanup ? { cleanup: true } : {}),
274
282
  ...nativeDescriptor(effect.states.map(([name, read]) => [name, read()]), effect.scope.map(([name, read]) => [name, read()]))
275
283
  }
276
284
  } catch (error) {
@@ -283,7 +291,7 @@ export async function renderPage(component, metadata = {}, props = {}) {
283
291
  ? ""
284
292
  : (Array.isArray(metadata.styles) ? metadata.styles : [assetPath(metadata.base, "assets/style.css")]).map(href => `<link rel="stylesheet" href="${escapeAttribute(href)}">`).join("")
285
293
  const runtime = renderContext.hasBehaviors
286
- ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu.js")}"></script>`
294
+ ? `<script type="module" src="${escapeAttribute(metadata.runtimeAsset ?? assetPath(metadata.base, "assets/kudzu.js"))}"></script>`
287
295
  : ""
288
296
  const nativeRuntime = renderContext.hasNativeBehaviors
289
297
  ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-native.js")}"></script>`
@@ -316,7 +324,7 @@ export async function renderPage(component, metadata = {}, props = {}) {
316
324
  : ""
317
325
 
318
326
  return {
319
- 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}${paramRuntime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}</body></html>`,
327
+ 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}${runtime}${paramRuntime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}</head><body${state}${textBindings}>${body}</body></html>`,
320
328
  hasBehaviors: renderContext.hasBehaviors,
321
329
  hasEffects: renderContext.hasEffects,
322
330
  hasParams: renderContext.hasParams,
@@ -475,6 +483,13 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
475
483
 
476
484
  const tag = node.type
477
485
  const props = node.props ?? {}
486
+ if (typeof tag === "string" && tag.toLowerCase() === "link") {
487
+ const rel = Object.entries(props).find(([name]) => name.toLowerCase() === "rel")?.[1]
488
+ const value = rel?.[signalMarker] || rel?.[bindingMarker] ? rel.value : rel
489
+ if (typeof value === "string" && value.toLowerCase().split(/\s+/).includes("stylesheet")) {
490
+ throw new Error("Stylesheets must be placed under src/ or declared in kudzu.config styles so Kudzu can emit them in <head>")
491
+ }
492
+ }
478
493
  const directListText = props.children?.[listFieldMarker] ? props.children : undefined
479
494
  const childSelectValue = tag === "select"
480
495
  ? Object.hasOwn(props, "value") ? bindingValue(props.value) : noSelectValue
@@ -0,0 +1,36 @@
1
+ export function applyCommands(state, commands, commit, log = console.log) {
2
+ const changed = new Set()
3
+ for (const [operation, id, operand] of commands) {
4
+ const current = state.get(id)
5
+ if (operation === "log") log(operand, current)
6
+ else {
7
+ state.set(id, operation === "add" ? current + operand : operand)
8
+ changed.add(id)
9
+ }
10
+ }
11
+ for (const id of changed) commit(id, state.get(id))
12
+ }
13
+
14
+ export const browserState = new Map()
15
+ const committers = []
16
+
17
+ export function registerCommitter(commit) {
18
+ committers.push(commit)
19
+ }
20
+
21
+ export function commitDom(id, value) {
22
+ for (const node of document.querySelectorAll(`[data-k-text="${id}"]`)) node.textContent = value
23
+ for (const commit of committers) commit(id)
24
+ }
25
+
26
+ if (typeof document !== "undefined") {
27
+ const initialState = document.body.dataset.kState
28
+ if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)
29
+ for (const node of document.querySelectorAll("[data-k-text]")) browserState.set(node.dataset.kText, JSON.parse(node.dataset.kValue))
30
+
31
+ const eventNames = ["click", "input", "change"]
32
+ for (const eventName of eventNames) document.addEventListener(eventName, event => {
33
+ const target = event.target.closest(`[data-k-on-${eventName}]`)
34
+ if (target) applyCommands(browserState, JSON.parse(target.getAttribute(`data-k-on-${eventName}`)), commitDom)
35
+ })
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.5.7",
3
+ "version": "0.5.10",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",