@kudzujs/core 0.4.1 → 0.4.5

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
@@ -77,7 +77,7 @@ export default function HomePage() {
77
77
  npm run dev
78
78
  ```
79
79
 
80
- Pages live in `src/pages`; `index.tsx` maps to `/`. Production output is written to `dist/`.
80
+ Pages live in `src/pages`; `index.tsx` maps to `/`. `npm run dev` serves locally on `127.0.0.1`, reloads the browser after successful rebuilds, and shows build failures in an error overlay. Across that full-page reload, compatible Kudzu logical state is briefly preserved by route-unique state variable name for the current pathname, query, and hash, including controlled properties, conditions, and keyed-list arrays. Renamed, removed, and duplicate-named state is skipped. Uncontrolled DOM state, focus, selection, and imperative DOM mutations are not preserved. Set `PORT` to change the default port of `3000`. The development client and state snapshot are dev-only; production output in `dist/` is unaffected.
81
81
 
82
82
  ## State Semantics
83
83
 
@@ -166,7 +166,7 @@ const [items, setItems] = useState([
166
166
  )}</ul>
167
167
  ```
168
168
 
169
- Kudzu emits initial items as static HTML, then adds, removes, updates, and moves keyed elements directly. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Item-local handlers use delegated events and receive the latest JSON-safe item for their key, including after updates, additions, and reorders. The item remains stored once in shared list state; handler descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
169
+ Kudzu emits initial items as static HTML, then adds, removes, updates, and moves keyed elements directly. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Item-local handlers use direct DOM listeners and receive the latest JSON-safe item for their key, including after updates, additions, and reorders. The item remains stored once in shared list state; handler descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
170
170
 
171
171
  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 direct local-state `.map`, one identifier callback parameter, one intrinsic JSX root, and `key={item.<field>}`. 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, locals, imported helpers, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Nested conditions or lists, item spreads, component tags, fragments, and reactive `style`, `ref`, or `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
172
172
 
@@ -190,7 +190,7 @@ async function load() {
190
190
 
191
191
  Primitive values, arrays, plain objects, and destructured props can be captured by client handlers. Functions, symbols, bigints, cycles, class instances, and imported helper functions are not yet supported as captures.
192
192
 
193
- Native handlers are delegated after normal event bubbling and run from the target toward matching Kudzu ancestors in deterministic order. Delegated handlers cannot call or reference `preventDefault`, `stopPropagation`, or `stopImmediatePropagation`; the compiler rejects those methods because external ESM cannot apply them with correct synchronous DOM semantics.
193
+ Native handlers use direct DOM listeners with normal `currentTarget`, bubbling, default-action, and propagation semantics. Handler modules load before listener registration, so `preventDefault`, `stopPropagation`, and `stopImmediatePropagation` work synchronously as expected.
194
194
 
195
195
  ## Rendering
196
196
 
@@ -249,13 +249,13 @@ Same counter with initial value `7` and increment/decrement buttons:
249
249
 
250
250
  | Framework | Initial content | Initial JS gzip | Total output | Clean build |
251
251
  |---|---:|---:|---:|---:|
252
- | Kudzu | Yes | 487 B | 1.4 KB | **373 ms** |
253
- | Astro | Yes | **158 B** | **365 B** | 877 ms |
254
- | Svelte CSR | No | 10.5 KB | 26.9 KB | 837 ms |
255
- | Qwik CSR | No | 20.6 KB | 57.8 KB | 626 ms |
256
- | Vue CSR | No | 24.0 KB | 60.3 KB | 778 ms |
257
- | React CSR | No | 59.2 KB | 189.0 KB | 1049 ms |
258
- | Next.js | Yes | 182.1 KB | 652.2 KB | 3099 ms |
252
+ | Kudzu | Yes | 487 B | 1.4 KB | **363 ms** |
253
+ | Astro | Yes | **158 B** | **365 B** | 851 ms |
254
+ | Svelte CSR | No | 10.5 KB | 26.9 KB | 827 ms |
255
+ | Qwik CSR | No | 20.6 KB | 57.8 KB | 598 ms |
256
+ | Vue CSR | No | 24.0 KB | 60.3 KB | 762 ms |
257
+ | React CSR | No | 59.2 KB | 189.0 KB | 1018 ms |
258
+ | Next.js | Yes | 182.1 KB | 652.2 KB | 2989 ms |
259
259
 
260
260
  Astro produces the smallest hand-authored counter. Kudzu's advantage in this fixture is React-shaped state code with a sub-1 KB runtime, not the smallest possible JavaScript.
261
261
 
@@ -265,15 +265,31 @@ Same content and CSS across every fixture:
265
265
 
266
266
  | Framework | Initial content | Initial JS gzip | Total output | Clean build |
267
267
  |---|---:|---:|---:|---:|
268
- | Kudzu | Yes | **0 B** | 3.2 KB | **478 ms** |
269
- | Astro | Yes | **0 B** | **3.0 KB** | 1253 ms |
270
- | Svelte CSR | No | 10.2 KB | 27.2 KB | 1018 ms |
271
- | Qwik CSR | No | 20.2 KB | 59.6 KB | 784 ms |
272
- | Vue CSR | No | 24.2 KB | 62.3 KB | 960 ms |
273
- | React CSR | No | 59.8 KB | 192.3 KB | 1253 ms |
274
- | Next.js | Yes | 182.6 KB | 663.6 KB | 3880 ms |
275
-
276
- Benchmark snapshot collected on July 21, 2026 with Node 24.14.0 on an Intel i5-9500. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. These results compare the selected one-page fixtures, not ecosystem maturity, browser interaction speed, or each framework's full rendering options. Build times vary with machine load and filesystem cache.
268
+ | Kudzu | Yes | **0 B** | 3.2 KB | **380 ms** |
269
+ | Astro | Yes | **0 B** | **3.0 KB** | 965 ms |
270
+ | Svelte CSR | No | 10.2 KB | 27.2 KB | 824 ms |
271
+ | Qwik CSR | No | 20.2 KB | 59.6 KB | 594 ms |
272
+ | Vue CSR | No | 24.2 KB | 62.3 KB | 755 ms |
273
+ | React CSR | No | 59.8 KB | 192.3 KB | 1041 ms |
274
+ | Next.js | Yes | 182.6 KB | 663.6 KB | 2975 ms |
275
+
276
+ ### 1,000-item Keyed List
277
+
278
+ The list starts with 1,000 keyed items, then updates every label, reverses the order, removes odd IDs, and adds 500 items. Browser timings are medians from seven fresh headless Chrome runs.
279
+
280
+ | Framework | Initial content | Initial JS gzip | Total output | Build | Update | Reverse | Remove | Add | Operations total |
281
+ |---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
282
+ | Astro | Yes | **324 B** | **43.6 KB** | 867 ms | **3.5 ms** | **25.4 ms** | 10.7 ms | **18.0 ms** | **57.6 ms** |
283
+ | Kudzu | Yes | 5.7 KB | 180.8 KB | **417 ms** | 8.3 ms | 28.5 ms | **10.2 ms** | 20.5 ms | **67.5 ms** |
284
+ | Vue CSR | No | 24.3 KB | 61.3 KB | 771 ms | 9.7 ms | 31.3 ms | 9.4 ms | 18.1 ms | 68.5 ms |
285
+ | React CSR | No | 59.3 KB | 189.4 KB | 1024 ms | 8.7 ms | 32.3 ms | 12.4 ms | 18.1 ms | 71.5 ms |
286
+ | Next.js | Yes | 182.2 KB | 695.2 KB | 3049 ms | 6.7 ms | 35.0 ms | 16.3 ms | 19.4 ms | 77.4 ms |
287
+ | Qwik CSR | No | 22.2 KB | 64.1 KB | 602 ms | 14.5 ms | 19.9 ms | 31.3 ms | 18.9 ms | 84.6 ms |
288
+ | Svelte CSR | No | 12.9 KB | 33.1 KB | 859 ms | 5.2 ms | 65.4 ms | 10.7 ms | 19.9 ms | 101.2 ms |
289
+
290
+ Astro is the hand-authored native DOM baseline in the interactive fixtures. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. Kudzu's keyed-list operations total 67.5 ms, ahead of the React fixture's 71.5 ms and within 9.9 ms of Astro across all four operations.
291
+
292
+ Benchmark snapshot collected on July 22, 2026 with Node 24.14.0 on an Intel i5-9500. These results compare the selected one-page fixtures, not ecosystem maturity, browser interaction speed beyond the listed operations, or each framework's full rendering options. Build times vary with machine load and filesystem cache.
277
293
 
278
294
  ## Development
279
295
 
@@ -9,8 +9,9 @@
9
9
  - `list-runtime.js`: optional keyed list validation, external item-expression evaluation, dynamic item-handler scopes, moves, and cleanup.
10
10
  - `serialization.js`: capture deserialization shared by binding and native handlers.
11
11
  - `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
12
+ - `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
12
13
  - `*.d.ts`: public TypeScript and JSX declarations.
13
14
 
14
- 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 live under `dist/assets/handlers/`.
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 live under `dist/assets/handlers/`. The dev server derives stable state identities from route-unique 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.
15
16
 
16
17
  Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
@@ -1,9 +1,11 @@
1
1
  import { createServer } from "node:http"
2
+ import { randomUUID } from "node:crypto"
2
3
  import { cp, mkdir, readFile, readdir, rm, stat, watch, writeFile } from "node:fs/promises"
3
4
  import { extname, join, relative, resolve, sep } from "node:path"
4
5
  import { pathToFileURL } from "node:url"
5
6
  import ts from "typescript"
6
7
  import { renderPage } from "./core.mjs"
8
+ import { stateSchema } from "./dev-state.js"
7
9
 
8
10
  const root = process.cwd()
9
11
  const sourceDirectory = join(root, "src")
@@ -11,6 +13,8 @@ const pagesDirectory = join(sourceDirectory, "pages")
11
13
  const workDirectory = join(root, ".kudzu")
12
14
  const outputDirectory = join(root, "dist")
13
15
 
16
+ const devClient = (session, revision, schema) => `<script>(()=>{const show=event=>{let box=document.getElementById("__kudzu_error");if(!box){box=document.createElement("div");box.id="__kudzu_error";box.setAttribute("role","alert");box.setAttribute("aria-live","assertive");box.style.cssText="position:fixed;inset:0;z-index:2147483647;overflow:auto;padding:2rem;background:#200;color:#fff;font:16px/1.5 ui-monospace,monospace";const title=document.createElement("strong"),text=document.createElement("pre");title.textContent="Kudzu build error";text.style.whiteSpace="pre-wrap";box.append(title,text);document.body.append(box)}box.querySelector("pre").textContent=event.data};const schema=${inlineJson(schema)},route=location.pathname+location.search+location.hash,urls=[...document.querySelectorAll('script[type="module"][src]')].map(node=>node.src).filter(url=>/\\/assets\\/kudzu(?:-(?:binding|list|native))?\\.js$/.test(new URL(url).pathname));const devImport=import("/__kudzu_dev.js"),runtimeImports=Promise.allSettled(urls.map(url=>import(url)));const ready=(async()=>{const dev=await devImport,modules=await runtimeImports,runtime=modules.find(result=>result.status==="fulfilled"&&result.value.browserState instanceof Map&&typeof result.value.commitDom==="function")?.value;try{dev.restoreState(sessionStorage,route,runtime?.browserState,schema,runtime?.commitDom)}catch{}return{dev,runtime}})().catch(()=>({}));const events=new EventSource("/__kudzu_reload?session=${session}&revision=${revision}");let reloading=false;events.addEventListener("reload",async()=>{if(reloading)return;reloading=true;try{const{dev,runtime}=await ready;dev?.snapshotState(sessionStorage,route,runtime?.browserState,schema)}catch{}location.reload()});events.addEventListener("build-error",show)})()</script>`
17
+
14
18
  export async function build({ quiet = false } = {}) {
15
19
  await rm(workDirectory, { recursive: true, force: true })
16
20
  await rm(outputDirectory, { recursive: true, force: true })
@@ -60,12 +64,13 @@ export async function build({ quiet = false } = {}) {
60
64
  await mkdir(assetsDirectory, { recursive: true })
61
65
  const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
62
66
  const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
67
+ const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => `/assets/${module.path}`)
68
+ const hasNativeHandlers = nativeModules.length > 0
63
69
  if (behaviorCount) {
64
- const runtimeFile = bindingCount || listCount ? "./shared-runtime.js" : "./runtime.js"
70
+ const runtimeFile = bindingCount || listCount || hasNativeHandlers ? "./shared-runtime.js" : "./runtime.js"
65
71
  const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
66
72
  await writeFile(join(assetsDirectory, "kudzu.js"), runtime)
67
73
  }
68
- const hasNativeHandlers = handlerModules.some(module => module.hasNativeHandlers)
69
74
  if (bindingCount || hasNativeHandlers) await cp(new URL("./serialization.js", import.meta.url), join(assetsDirectory, "kudzu-serialization.js"))
70
75
  if (bindingCount) {
71
76
  const bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
@@ -80,9 +85,9 @@ export async function build({ quiet = false } = {}) {
80
85
  }
81
86
  if (hasNativeHandlers) {
82
87
  const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
83
- .replace('"./runtime.js"', '"./kudzu.js"')
88
+ .replace('"./shared-runtime.js"', '"./kudzu.js"')
84
89
  .replace('"./serialization.js"', '"./kudzu-serialization.js"')
85
- await writeFile(join(assetsDirectory, "kudzu-native.js"), specializeEvents(nativeRuntime, nativeEvents))
90
+ await writeFile(join(assetsDirectory, "kudzu-native.js"), specializeNativeRuntime(nativeRuntime, nativeEvents, nativeModules))
86
91
  }
87
92
  for (const handlerModule of handlerModules) {
88
93
  const output = join(assetsDirectory, handlerModule.path)
@@ -100,6 +105,12 @@ function specializeEvents(source, events) {
100
105
  return source.replace(/const eventNames = \[[^\n]+\]/, `const eventNames = ${JSON.stringify(events)}`)
101
106
  }
102
107
 
108
+ function specializeNativeRuntime(source, events, modules) {
109
+ const imports = modules.map((module, index) => `import * as __kNativeModule${index} from ${JSON.stringify(module)}`).join("\n")
110
+ const entries = modules.map((module, index) => `[${JSON.stringify(module)}, __kNativeModule${index}]`).join(",")
111
+ return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
112
+ }
113
+
103
114
  function specializeRuntime(source, events, hasStateSeed) {
104
115
  const specialized = specializeEvents(source, events)
105
116
  if (hasStateSeed) return specialized
@@ -108,44 +119,144 @@ function specializeRuntime(source, events, hasStateSeed) {
108
119
  .replace(" if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)\n", "")
109
120
  }
110
121
 
111
- export async function dev() {
112
- await build()
122
+ export function parseDevPort(value) {
123
+ if (value === undefined || value.trim() === "") return 3000
124
+ if (!/^\d+$/.test(value)) throw new Error(`Invalid dev server port: ${value}`)
125
+ const port = Number(value)
126
+ if (port > 65535) throw new Error(`Invalid dev server port: ${value}`)
127
+ return port
128
+ }
129
+
130
+ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
131
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid dev server port: ${port}`)
132
+
133
+ let buildError
134
+ let revision = 0
135
+ const session = randomUUID()
136
+ try {
137
+ await build()
138
+ revision++
139
+ } catch (error) {
140
+ buildError = errorText(error)
141
+ console.error(error)
142
+ }
143
+
144
+ const clients = new Set()
113
145
 
114
146
  const server = createServer(async (request, response) => {
115
147
  try {
116
- const pathname = decodeURIComponent(new URL(request.url, "http://localhost").pathname)
148
+ const url = new URL(request.url, "http://localhost")
149
+ const pathname = decodeURIComponent(url.pathname)
150
+ if (pathname === "/__kudzu_reload") {
151
+ response.writeHead(200, {
152
+ "content-type": "text/event-stream; charset=utf-8",
153
+ "cache-control": "no-cache, no-transform",
154
+ connection: "keep-alive"
155
+ })
156
+ response.write(": connected\n\n")
157
+ clients.add(response)
158
+ request.on("close", () => clients.delete(response))
159
+ if (buildError) sendEvent(response, "build-error", buildError)
160
+ else if (url.searchParams.get("session") !== session || url.searchParams.get("revision") !== String(revision)) sendEvent(response, "reload")
161
+ return
162
+ }
163
+ if (pathname === "/__kudzu_dev.js") {
164
+ response.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" })
165
+ response.end(await readFile(new URL("./dev-state.js", import.meta.url)))
166
+ return
167
+ }
168
+
117
169
  const relativePath = pathname.replace(/^\/+/, "")
118
170
  let file = resolve(outputDirectory, relativePath)
119
171
  if (!file.startsWith(`${outputDirectory}${sep}`) && file !== outputDirectory) throw new Error("Invalid path")
120
172
 
121
173
  if ((await exists(file)) && (await stat(file)).isDirectory()) file = join(file, "index.html")
122
174
  if (!(await exists(file)) && !extname(file)) file = join(file, "index.html")
123
- const content = await readFile(file)
124
- response.writeHead(200, { "content-type": contentType(file) })
175
+ const isHtml = extname(file) === ".html"
176
+ const content = isHtml
177
+ ? injectDevClient(buildError ? errorPage(buildError) : await readFile(file, "utf8"), session, revision, buildError ? [] : await devSchema(pathname))
178
+ : await readFile(file)
179
+ response.writeHead(200, {
180
+ "content-type": contentType(file),
181
+ "cache-control": "no-store"
182
+ })
125
183
  response.end(content)
126
184
  } catch {
127
- response.writeHead(404, { "content-type": "text/plain; charset=utf-8" })
185
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" })
128
186
  response.end("Not found")
129
187
  }
130
188
  })
131
189
 
132
- server.listen(3000, () => console.log("Kudzu dev server: http://localhost:3000"))
190
+ server.listen(port, "127.0.0.1", () => console.log(`Kudzu dev server: http://127.0.0.1:${server.address().port}`))
133
191
 
134
192
  let timer
135
- const watcher = watch(sourceDirectory, { recursive: true })
136
- for await (const event of watcher) {
137
- clearTimeout(timer)
138
- timer = setTimeout(async () => {
193
+ let rebuilding = false
194
+ let pending = false
195
+ let changedFile
196
+ const rebuild = async () => {
197
+ if (rebuilding) {
198
+ pending = true
199
+ return
200
+ }
201
+ rebuilding = true
202
+ do {
203
+ pending = false
139
204
  try {
140
205
  await build({ quiet: true })
141
- console.log(`Rebuilt after ${event.filename ?? "source change"}`)
206
+ buildError = undefined
207
+ revision++
208
+ console.log(`Rebuilt after ${changedFile ?? "source change"}`)
209
+ for (const client of clients) sendEvent(client, "reload")
142
210
  } catch (error) {
211
+ buildError = errorText(error)
143
212
  console.error(error)
213
+ for (const client of clients) sendEvent(client, "build-error", buildError)
144
214
  }
145
- }, 80)
215
+ } while (pending)
216
+ rebuilding = false
217
+ }
218
+ const watcher = watch(sourceDirectory, { recursive: true })
219
+ for await (const event of watcher) {
220
+ changedFile = event.filename
221
+ clearTimeout(timer)
222
+ timer = setTimeout(rebuild, 80)
223
+ }
224
+ }
225
+
226
+ function injectDevClient(html, session, revision, schema) {
227
+ return `${html}${devClient(session, revision, schema)}`
228
+ }
229
+
230
+ async function devSchema(pathname) {
231
+ try {
232
+ const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
233
+ const route = pathname.replace(/\/(?:index\.html)?$/, "") || "/"
234
+ return stateSchema(plan.routes.find(entry => entry.route === route)?.states ?? [])
235
+ } catch {
236
+ return []
146
237
  }
147
238
  }
148
239
 
240
+ function inlineJson(value) {
241
+ return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029")
242
+ }
243
+
244
+ function errorPage(error) {
245
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Kudzu build error</title></head><body><div id="__kudzu_error" role="alert" aria-live="assertive" style="position:fixed;inset:0;overflow:auto;padding:2rem;background:#200;color:#fff;font:16px/1.5 ui-monospace,monospace"><strong>Kudzu build error</strong><pre style="white-space:pre-wrap">${escapeHtml(error)}</pre></div></body></html>`
246
+ }
247
+
248
+ function errorText(error) {
249
+ return String(error?.message ?? error)
250
+ }
251
+
252
+ function sendEvent(response, event, data = "") {
253
+ response.write(`event: ${event}\n${String(data).replaceAll("\r", "").split("\n").map(line => `data: ${line}\n`).join("")}\n`)
254
+ }
255
+
256
+ function escapeHtml(value) {
257
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
258
+ }
259
+
149
260
  async function compile(file) {
150
261
  const source = await readFile(file, "utf8")
151
262
  const nativeHandlers = []
@@ -557,7 +668,6 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
557
668
  if (ts.isIdentifier(expression)) expression = functions.get(expression.text)
558
669
  if (!expression || (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression) && !ts.isFunctionDeclaration(expression))) return undefined
559
670
 
560
- rejectNativeEventControls(expression)
561
671
  const optimized = compileOptimizedEvent(expression, setters, factory)
562
672
  if (optimized) return optimized
563
673
 
@@ -580,34 +690,6 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
580
690
  ])
581
691
  }
582
692
 
583
- function rejectNativeEventControls(expression) {
584
- const controls = new Set(["preventDefault", "stopPropagation", "stopImmediatePropagation"])
585
- const found = new Set()
586
- const eventAliases = new Set()
587
- const parameter = expression.parameters[0]?.name
588
- if (parameter && ts.isIdentifier(parameter)) eventAliases.add(parameter.text)
589
- const visit = node => {
590
- if (ts.isIdentifier(node) && controls.has(node.text)) found.add(node.text)
591
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isIdentifier(unwrapEventAlias(node.initializer)) && eventAliases.has(unwrapEventAlias(node.initializer).text)) {
592
- eventAliases.add(node.name.text)
593
- }
594
- if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(node.left) && ts.isIdentifier(unwrapEventAlias(node.right)) && eventAliases.has(unwrapEventAlias(node.right).text)) eventAliases.add(node.left.text)
595
- if (ts.isElementAccessExpression(node) && ts.isIdentifier(unwrapEventAlias(node.expression)) && eventAliases.has(unwrapEventAlias(node.expression).text)) {
596
- if (ts.isStringLiteral(node.argumentExpression) && controls.has(node.argumentExpression.text)) found.add(node.argumentExpression.text)
597
- else if (!ts.isStringLiteral(node.argumentExpression)) for (const control of controls) found.add(control)
598
- }
599
- ts.forEachChild(node, visit)
600
- }
601
- for (const parameter of expression.parameters) visit(parameter)
602
- visit(expression.body)
603
- if (found.size) throw new Error(`Delegated native handlers do not support event control methods: ${[...found].sort().join(", ")}`)
604
- }
605
-
606
- function unwrapEventAlias(node) {
607
- if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isNonNullExpression(node) || ts.isSatisfiesExpression(node)) return unwrapEventAlias(node.expression)
608
- return node
609
- }
610
-
611
693
  function nativeStateNames(expression, setters) {
612
694
  return referencedStateNames(expression.body, setters, expression)
613
695
  }
@@ -320,6 +320,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
320
320
 
321
321
  const tag = node.type
322
322
  const props = node.props ?? {}
323
+ const directListText = props.children?.[listFieldMarker] ? props.children : undefined
323
324
  const childSelectValue = tag === "select"
324
325
  ? Object.hasOwn(props, "value") ? bindingValue(props.value) : noSelectValue
325
326
  : selectValue
@@ -414,12 +415,14 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
414
415
  if (listAttributes.length) attributes += ` data-k-list-attrs='${escapeJsonAttribute(listAttributes)}'`
415
416
  if (listExpressionAttributes.length) attributes += ` data-k-list-expression-attrs='${escapeJsonAttribute(listExpressionAttributes)}'`
416
417
  if (listEvents.length) attributes += ` data-k-list-events='${escapeJsonAttribute(listEvents)}'`
418
+ if (directListText) attributes += ` data-k-list-text="${escapeAttribute(directListText.field)}"`
417
419
 
418
420
  if (tag === "option" && selectValue !== noSelectValue && String(optionValue(props)) === (selectValue == null ? "" : String(selectValue))) attributes += " selected"
419
421
 
420
422
  const voidElements = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"])
421
423
  if (voidElements.has(tag)) return `<${tag}${attributes}>`
422
- return `<${tag}${attributes}>${await renderNode(props.children, childNamespace, childSelectValue)}</${tag}>`
424
+ const children = directListText ? escapeHtml(directListText.value ?? "") : await renderNode(props.children, childNamespace, childSelectValue)
425
+ return `<${tag}${attributes}>${children}</${tag}>`
423
426
  }
424
427
 
425
428
  async function renderList(node, namespace, selectValue) {
@@ -0,0 +1,98 @@
1
+ const maxAge = 10000
2
+
3
+ export function stateSchema(states) {
4
+ const occurrences = new Map()
5
+ for (const { name } of states) if (typeof name === "string") occurrences.set(name, (occurrences.get(name) ?? 0) + 1)
6
+ return states.flatMap(({ id, name }) => {
7
+ return typeof id === "string" && occurrences.get(name) === 1 ? [[id, name]] : []
8
+ })
9
+ }
10
+
11
+ export function snapshotState(storage, route, state, schema, now = Date.now()) {
12
+ try {
13
+ storage.removeItem(storageKey(route))
14
+ } catch {
15
+ return false
16
+ }
17
+ try {
18
+ const identities = new Map(schema)
19
+ const values = [...state].flatMap(([id, value]) => {
20
+ const identity = identities.get(id)
21
+ return typeof identity === "string" && jsonSafe(value) ? [[identity, value]] : []
22
+ })
23
+ if (values.length) storage.setItem(storageKey(route), JSON.stringify({ time: now, values }))
24
+ return values.length > 0
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ export function restoreState(storage, route, state, schema, commit, now = Date.now()) {
31
+ let snapshot
32
+ try {
33
+ const raw = storage.getItem(storageKey(route))
34
+ if (raw === null) return []
35
+ storage.removeItem(storageKey(route))
36
+ snapshot = JSON.parse(raw)
37
+ } catch {
38
+ return []
39
+ }
40
+
41
+ if (!(state instanceof Map) || !Array.isArray(schema) || typeof commit !== "function") return []
42
+ if (!snapshot || !Number.isFinite(snapshot.time) || now < snapshot.time || now - snapshot.time > maxAge || !Array.isArray(snapshot.values)) return []
43
+ const ids = new Map(schema.flatMap(entry => Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string" && typeof entry[1] === "string" ? [[entry[1], entry[0]]] : []))
44
+ const changes = []
45
+ const seen = new Set()
46
+ for (const entry of snapshot.values) {
47
+ if (!Array.isArray(entry) || entry.length !== 2) continue
48
+ const [identity, value] = entry
49
+ const id = ids.get(identity)
50
+ if (typeof identity !== "string" || seen.has(identity) || !state.has(id) || !jsonSafe(value) || shape(value) !== shape(state.get(id))) continue
51
+ seen.add(identity)
52
+ changes.push({ id, value, original: state.get(id) })
53
+ }
54
+ for (const { id, value } of changes) state.set(id, value)
55
+ try {
56
+ for (const { id } of changes) commit(id, state.get(id))
57
+ } catch {
58
+ for (const { id, original } of changes) state.set(id, original)
59
+ for (const { id } of changes) {
60
+ try { commit(id, state.get(id)) } catch {}
61
+ }
62
+ return []
63
+ }
64
+ return changes.map(({ id }) => id)
65
+ }
66
+
67
+ function storageKey(route) {
68
+ return `__kudzu_state:${route}`
69
+ }
70
+
71
+ function shape(value) {
72
+ if (Array.isArray(value)) return "array"
73
+ if (value === null) return "null"
74
+ return typeof value === "object" ? "object" : typeof value
75
+ }
76
+
77
+ function jsonSafe(value, seen = new Set()) {
78
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true
79
+ if (typeof value === "number") return Number.isFinite(value) && !Object.is(value, -0)
80
+ if (!value || typeof value !== "object" || seen.has(value) || Object.getOwnPropertySymbols(value).length) return false
81
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype) return false
82
+
83
+ const descriptors = Object.getOwnPropertyDescriptors(value)
84
+ if (Array.isArray(value)) {
85
+ if (Object.keys(descriptors).some(key => key !== "length" && !/^(0|[1-9]\d*)$/.test(key))) return false
86
+ if (Object.keys(value).length !== value.length) return false
87
+ }
88
+ seen.add(value)
89
+ for (const [key, descriptor] of Object.entries(descriptors)) {
90
+ if (Array.isArray(value) && key === "length") continue
91
+ if (!descriptor.enumerable || !("value" in descriptor) || !jsonSafe(descriptor.value, seen)) {
92
+ seen.delete(value)
93
+ return false
94
+ }
95
+ }
96
+ seen.delete(value)
97
+ return true
98
+ }
@@ -5,6 +5,8 @@ const listRegistrations = new WeakMap()
5
5
  const mountedLists = new WeakSet()
6
6
  const imports = new Map()
7
7
  const revisions = new WeakMap()
8
+ const itemParts = new WeakMap()
9
+ const itemPartsSelector = "[data-k-list-text],[data-k-list-attrs],[data-k-list-events],[data-k-list-expression],[data-k-list-expression-attrs]"
8
10
 
9
11
  function commitLists(id) {
10
12
  const lists = listTargets.get(id)
@@ -27,10 +29,13 @@ function mountLists(root) {
27
29
  mountedLists.add(start)
28
30
  const descriptor = JSON.parse(start.dataset.kList)
29
31
  const roots = [...start.ownerDocument.querySelectorAll("[data-k-list-item]")].filter(node => JSON.parse(node.dataset.kListItem)[0] === descriptor.id)
32
+ const items = browserState.get(descriptor.state)
33
+ for (const root of roots) listItemParts(root)
30
34
  const list = {
31
35
  start,
32
36
  descriptor,
33
37
  roots: new Map(roots.map(node => [keyToken(JSON.parse(node.dataset.kListItem)[1]), node])),
38
+ values: new Map(items.map(item => [keyToken(item[descriptor.key]), JSON.stringify(item)])),
34
39
  container: roots[0]?.parentNode,
35
40
  boundary: roots.length ? roots.at(-1).nextSibling : findEnd(start, descriptor.id)
36
41
  }
@@ -60,66 +65,105 @@ function updateList(list) {
60
65
  if (!Array.isArray(items)) throw new Error("Keyed list state must remain an array")
61
66
  const entries = []
62
67
  const keys = new Set()
68
+ const seen = new Set()
63
69
  for (const item of items) {
64
70
  const key = item?.[list.descriptor.key]
65
71
  if (!validListKey(key)) throw new Error(`Keyed list key "${list.descriptor.key}" must be a string or finite number`)
66
72
  assertListItem(item)
67
- assertListValue(item, new Set())
73
+ assertListValue(item, seen, true)
68
74
  const token = keyToken(key)
69
75
  if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
70
76
  keys.add(token)
71
- entries.push({ item, key, token })
77
+ entries.push({ item, key, token, value: JSON.stringify(item) })
72
78
  }
73
79
  const next = []
74
- for (const { item, key, token } of entries) {
80
+ const values = new Map()
81
+ const parent = list.container ?? list.start.parentNode
82
+ const additions = parent.ownerDocument.createDocumentFragment()
83
+ let added = false
84
+ for (const { item, key, token, value } of entries) {
75
85
  let node = list.roots.get(token)
76
86
  if (!node) {
77
- const fragment = list.start.content.cloneNode(true)
78
- node = fragment.querySelector(`[data-k-list-root="${list.descriptor.id}"]`)
87
+ node = list.start.content.firstElementChild?.cloneNode(true)
88
+ if (node?.dataset.kListRoot !== list.descriptor.id) node = undefined
79
89
  if (!node) throw new Error("Keyed list template has no root element")
80
90
  node.removeAttribute("data-k-list-root")
81
91
  node.dataset.kListItem = JSON.stringify([list.descriptor.id, key])
82
92
  fillListItem(node, item)
83
- ;(list.container ?? list.start.parentNode).insertBefore(fragment, list.boundary)
84
- list.container ??= node.parentNode
85
- mountDom(node)
86
- } else {
93
+ additions.append(node)
94
+ added = true
95
+ } else if (list.values.get(token) !== value) {
87
96
  fillListItem(node, item)
88
97
  }
89
98
  next.push([token, node])
99
+ values.set(token, value)
90
100
  }
91
101
  for (const [token, node] of list.roots) {
92
102
  if (keys.has(token)) continue
93
103
  unmountDom(node)
94
104
  node.remove()
95
105
  }
96
- const parent = list.container ?? list.start.parentNode
97
- for (const [, node] of next) parent.insertBefore(node, list.boundary)
106
+ if (added) {
107
+ mountDom(additions)
108
+ parent.insertBefore(additions, list.boundary)
109
+ list.container ??= parent
110
+ }
111
+ let anchor = list.boundary
112
+ let misplaced = 0
113
+ for (let index = next.length - 1; index >= 0; index--) {
114
+ const node = next[index][1]
115
+ if (node.nextSibling !== anchor) misplaced++
116
+ anchor = node
117
+ }
118
+ if (misplaced > next.length / 2) {
119
+ const reordered = parent.ownerDocument.createDocumentFragment()
120
+ reordered.append(...next.map(([, node]) => node))
121
+ parent.insertBefore(reordered, list.boundary)
122
+ } else if (misplaced) {
123
+ anchor = list.boundary
124
+ for (let index = next.length - 1; index >= 0; index--) {
125
+ const node = next[index][1]
126
+ if (node.nextSibling !== anchor) parent.insertBefore(node, anchor)
127
+ anchor = node
128
+ }
129
+ }
98
130
  list.roots = new Map(next)
131
+ list.values = values
99
132
  }
100
133
 
101
134
  function fillListItem(root, item) {
102
135
  const revision = (revisions.get(root) ?? 0) + 1
103
136
  revisions.set(root, revision)
104
- for (const marker of matching(root, "template[data-k-list-text]")) {
137
+ const parts = listItemParts(root)
138
+ for (const node of parts.directTexts) {
139
+ const text = item?.[node.dataset.kListText]
140
+ const value = text == null ? "" : String(text)
141
+ const current = node.firstChild
142
+ if (current?.nodeType === Node.TEXT_NODE && !current.nextSibling) {
143
+ if (current.data !== value) current.data = value
144
+ } else {
145
+ node.textContent = value
146
+ }
147
+ }
148
+ for (const marker of parts.texts) {
105
149
  patchListText(marker, "template[data-k-list-text-end]", item?.[marker.dataset.kListText])
106
150
  }
107
- for (const node of matching(root, "[data-k-list-attrs]")) {
108
- for (const [target, field] of JSON.parse(node.dataset.kListAttrs)) patchBinding(node, target, item?.[field])
151
+ for (const [node, attributes] of parts.attributes) {
152
+ for (const [target, field] of attributes) patchBinding(node, target, item?.[field])
109
153
  }
110
- for (const node of matching(root, "[data-k-list-events]")) {
154
+ for (const node of parts.events) {
111
155
  for (const [event, native] of JSON.parse(node.dataset.kListEvents)) {
112
156
  native.scope = Object.fromEntries(Object.entries(native.scope).map(([name, value]) => [name, value?.type === "list-item" ? serializeItem(item) : value]))
113
157
  node.dataset[`kNative${capitalize(event)}`] = JSON.stringify(native)
114
158
  }
115
159
  }
116
- for (const marker of matching(root, "template[data-k-list-expression]")) {
117
- evaluate(JSON.parse(marker.dataset.kListExpression), item).then(value => {
160
+ for (const [marker, descriptor] of parts.expressions) {
161
+ evaluate(descriptor, item).then(value => {
118
162
  if (revisions.get(root) === revision && root.isConnected) patchListText(marker, "template[data-k-list-expression-end]", value)
119
163
  }).catch(error => console.error(error))
120
164
  }
121
- for (const node of matching(root, "[data-k-list-expression-attrs]")) {
122
- for (const [target, module, handler] of JSON.parse(node.dataset.kListExpressionAttrs)) {
165
+ for (const [node, attributes] of parts.expressionAttributes) {
166
+ for (const [target, module, handler] of attributes) {
123
167
  evaluate({ module, handler }, item).then(value => {
124
168
  if (revisions.get(root) === revision && root.isConnected) patchBinding(node, target, value)
125
169
  }).catch(error => console.error(error))
@@ -127,15 +171,36 @@ function fillListItem(root, item) {
127
171
  }
128
172
  }
129
173
 
174
+ function listItemParts(root) {
175
+ let parts = itemParts.get(root)
176
+ if (parts) return parts
177
+ parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [] }
178
+ for (const node of matching(root, itemPartsSelector)) {
179
+ if (node.hasAttribute("data-k-list-text")) (node.tagName === "TEMPLATE" ? parts.texts : parts.directTexts).push(node)
180
+ if (node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
181
+ if (node.hasAttribute("data-k-list-events")) parts.events.push(node)
182
+ if (node.hasAttribute("data-k-list-expression")) parts.expressions.push([node, JSON.parse(node.dataset.kListExpression)])
183
+ if (node.hasAttribute("data-k-list-expression-attrs")) parts.expressionAttributes.push([node, JSON.parse(node.dataset.kListExpressionAttrs)])
184
+ }
185
+ itemParts.set(root, parts)
186
+ return parts
187
+ }
188
+
130
189
  function patchListText(marker, endSelector, value) {
131
190
  let end = marker.nextSibling
132
191
  while (end && !(end.nodeType === Node.ELEMENT_NODE && end.matches(endSelector))) end = end.nextSibling
133
192
  if (!end) throw new Error("Keyed list text marker has no end")
193
+ const text = value == null ? "" : String(value)
194
+ const current = marker.nextSibling
195
+ if (current?.nodeType === Node.TEXT_NODE && current.nextSibling === end) {
196
+ if (current.data !== text) current.data = text
197
+ return
198
+ }
134
199
  const range = marker.ownerDocument.createRange()
135
200
  range.setStartAfter(marker)
136
201
  range.setEndBefore(end)
137
202
  range.deleteContents()
138
- end.before(marker.ownerDocument.createTextNode(value == null ? "" : String(value)))
203
+ end.before(marker.ownerDocument.createTextNode(text))
139
204
  }
140
205
 
141
206
  function evaluate(descriptor, item) {
@@ -189,19 +254,19 @@ function assertListItem(item) {
189
254
  if (!item || Array.isArray(item) || prototype !== Object.prototype) throw new Error("Keyed list items must be ordinary plain objects")
190
255
  }
191
256
 
192
- function assertListValue(value, seen) {
257
+ function assertListValue(value, seen, root = false) {
193
258
  if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0)) return
194
259
  if (!value || typeof value !== "object") throw new Error("Keyed list items must contain only JSON-safe values")
195
260
  if (seen.has(value)) throw new Error("Keyed list items must not contain cycles")
196
- const prototype = Object.getPrototypeOf(value)
197
- if (!Array.isArray(value) && prototype !== Object.prototype) throw new Error("Keyed list items must contain only arrays and ordinary plain objects")
198
- if (Object.getOwnPropertySymbols(value).length) throw new Error("Keyed list items must not contain symbols")
261
+ if (!root && !Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype) throw new Error("Keyed list items must contain only arrays and ordinary plain objects")
199
262
  seen.add(value)
200
- const descriptors = Object.getOwnPropertyDescriptors(value)
201
- if (Array.isArray(value) && Object.keys(descriptors).some(key => key !== "length" && !/^(0|[1-9]\d*)$/.test(key))) throw new Error("Keyed list arrays must not contain custom properties")
263
+ const keys = Reflect.ownKeys(value)
264
+ if (keys.some(key => typeof key === "symbol")) throw new Error("Keyed list items must not contain symbols")
265
+ if (Array.isArray(value) && keys.some(key => key !== "length" && !/^(0|[1-9]\d*)$/.test(key))) throw new Error("Keyed list arrays must not contain custom properties")
202
266
  if (Array.isArray(value) && Object.keys(value).length !== value.length) throw new Error("Keyed list arrays must not contain holes")
203
- for (const [key, descriptor] of Object.entries(descriptors)) {
267
+ for (const key of keys) {
204
268
  if (Array.isArray(value) && key === "length") continue
269
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
205
270
  if (!descriptor.enumerable) throw new Error("Keyed list items must not contain non-enumerable properties")
206
271
  if (!("value" in descriptor)) throw new Error("Keyed list items must not contain accessors")
207
272
  assertListValue(descriptor.value, seen)
@@ -1,6 +1,8 @@
1
- import { browserState, commitDom } from "./runtime.js"
1
+ import { browserState, commitDom, registerMountHook, registerUnmountHook } from "./shared-runtime.js"
2
2
  import { deserialize } from "./serialization.js"
3
3
 
4
+ const registrations = new WeakMap()
5
+
4
6
  export function createNativeContext(state, stateIds, commit, serializedScope = {}) {
5
7
  const changed = new Set()
6
8
  let scheduled = false
@@ -34,54 +36,44 @@ export function createNativeContext(state, stateIds, commit, serializedScope = {
34
36
  }
35
37
 
36
38
  if (typeof document !== "undefined") {
37
- const modules = new Map()
38
-
39
39
  const eventNames = ["click", "input", "change", "submit", "keydown", "keyup"]
40
- for (const eventName of eventNames) {
41
- document.addEventListener(eventName, event => {
42
- try {
43
- dispatchNative(event, snapshotNativeTargets(event, eventName), modules).catch(error => console.error(error))
44
- } catch (error) {
45
- console.error(error)
46
- }
47
- })
48
- }
40
+ const modules = new Map([])
41
+ const mount = root => mountNative(root, eventNames, modules)
42
+ registerMountHook(mount)
43
+ registerUnmountHook(unmountNative)
44
+ mount(document)
49
45
  }
50
46
 
51
- function snapshotNativeTargets(event, eventName) {
52
- const selector = `[data-k-native-${eventName}]`
53
- const targets = []
54
- for (let target = event.target.closest(selector); target; target = target.parentElement?.closest(selector)) {
55
- targets.push({ target, native: JSON.parse(target.dataset[`kNative${capitalize(eventName)}`]) })
47
+ function mountNative(root, eventNames, modules) {
48
+ for (const eventName of eventNames) {
49
+ for (const node of matching(root, `[data-k-native-${eventName}]`)) {
50
+ const listeners = registrations.get(node) ?? new Map()
51
+ if (listeners.has(eventName)) continue
52
+ const listener = event => {
53
+ try {
54
+ const native = JSON.parse(node.dataset[`kNative${capitalize(eventName)}`])
55
+ const result = modules.get(native.module)[native.handler](createNativeContext(browserState, native.states, commitDom, native.scope), event)
56
+ if (result && typeof result.then === "function") result.catch(error => console.error(error))
57
+ } catch (error) {
58
+ console.error(error)
59
+ }
60
+ }
61
+ node.addEventListener(eventName, listener)
62
+ listeners.set(eventName, listener)
63
+ registrations.set(node, listeners)
64
+ }
56
65
  }
57
- return targets
58
66
  }
59
67
 
60
- async function dispatchNative(event, targets, modules) {
61
- for (const { target, native } of targets) {
62
- let modulePromise = modules.get(native.module)
63
- if (!modulePromise) {
64
- modulePromise = import(native.module)
65
- modules.set(native.module, modulePromise)
66
- }
67
- try {
68
- const module = await modulePromise
69
- const result = module[native.handler](createNativeContext(browserState, native.states, commitDom, native.scope), delegatedEvent(event, target))
70
- if (result && typeof result.then === "function") result.catch(error => console.error(error))
71
- } catch (error) {
72
- console.error(error)
73
- }
68
+ function unmountNative(root) {
69
+ for (const node of matching(root, "*")) {
70
+ for (const [eventName, listener] of registrations.get(node) ?? []) node.removeEventListener(eventName, listener)
71
+ registrations.delete(node)
74
72
  }
75
73
  }
76
74
 
77
- function delegatedEvent(event, currentTarget) {
78
- return new Proxy(event, {
79
- get(source, property) {
80
- if (property === "currentTarget") return currentTarget
81
- const value = Reflect.get(source, property, source)
82
- return typeof value === "function" ? value.bind(source) : value
83
- }
84
- })
75
+ function matching(root, selector) {
76
+ return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
85
77
  }
86
78
 
87
79
  function capitalize(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.4.1",
3
+ "version": "0.4.5",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",