@kudzujs/core 0.4.0 → 0.4.3
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 +17 -6
- package/framework/README.md +3 -2
- package/framework/build.mjs +281 -37
- package/framework/core.d.ts +3 -0
- package/framework/core.mjs +42 -10
- package/framework/dev-state.js +98 -0
- package/framework/list-runtime.js +59 -11
- package/framework/native-runtime.js +31 -12
- package/package.json +1 -1
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 `/`.
|
|
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
|
|
|
@@ -150,16 +150,25 @@ Map local array state directly to one keyed JSX element per item:
|
|
|
150
150
|
|
|
151
151
|
```tsx
|
|
152
152
|
const [items, setItems] = useState([
|
|
153
|
-
{ id: 1, name: "Oak" },
|
|
154
|
-
{ id: 2, name: "Pine" }
|
|
153
|
+
{ id: 1, name: "Oak", done: false },
|
|
154
|
+
{ id: 2, name: "Pine", done: true }
|
|
155
155
|
])
|
|
156
156
|
|
|
157
|
-
<ul>{items.map(item =>
|
|
157
|
+
<ul>{items.map(item =>
|
|
158
|
+
<li
|
|
159
|
+
key={item.id}
|
|
160
|
+
className={item.done ? "done" : "active"}
|
|
161
|
+
aria-label={`${item.name} item`}
|
|
162
|
+
>
|
|
163
|
+
{item.name.toUpperCase()}
|
|
164
|
+
<button onClick={() => setItems(items.filter(entry => entry.id !== item.id))}>Remove</button>
|
|
165
|
+
</li>
|
|
166
|
+
)}</ul>
|
|
158
167
|
```
|
|
159
168
|
|
|
160
|
-
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.
|
|
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.
|
|
161
170
|
|
|
162
|
-
The
|
|
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>`.
|
|
163
172
|
|
|
164
173
|
## Normal JavaScript
|
|
165
174
|
|
|
@@ -181,6 +190,8 @@ async function load() {
|
|
|
181
190
|
|
|
182
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.
|
|
183
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.
|
|
194
|
+
|
|
184
195
|
## Rendering
|
|
185
196
|
|
|
186
197
|
```text
|
package/framework/README.md
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
- `runtime.js`: command-only runtime for direct state-to-text patches.
|
|
7
7
|
- `shared-runtime.js`: command runtime with capability commit and DOM lifecycle hooks, emitted only when needed.
|
|
8
8
|
- `binding-runtime.js`: optional generic attributes, form properties, and conditional range patches.
|
|
9
|
-
- `list-runtime.js`: optional keyed list validation,
|
|
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.
|
package/framework/build.mjs
CHANGED
|
@@ -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 })
|
|
@@ -108,48 +112,149 @@ function specializeRuntime(source, events, hasStateSeed) {
|
|
|
108
112
|
.replace(" if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)\n", "")
|
|
109
113
|
}
|
|
110
114
|
|
|
111
|
-
export
|
|
112
|
-
|
|
115
|
+
export function parseDevPort(value) {
|
|
116
|
+
if (value === undefined || value.trim() === "") return 3000
|
|
117
|
+
if (!/^\d+$/.test(value)) throw new Error(`Invalid dev server port: ${value}`)
|
|
118
|
+
const port = Number(value)
|
|
119
|
+
if (port > 65535) throw new Error(`Invalid dev server port: ${value}`)
|
|
120
|
+
return port
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
|
|
124
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid dev server port: ${port}`)
|
|
125
|
+
|
|
126
|
+
let buildError
|
|
127
|
+
let revision = 0
|
|
128
|
+
const session = randomUUID()
|
|
129
|
+
try {
|
|
130
|
+
await build()
|
|
131
|
+
revision++
|
|
132
|
+
} catch (error) {
|
|
133
|
+
buildError = errorText(error)
|
|
134
|
+
console.error(error)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const clients = new Set()
|
|
113
138
|
|
|
114
139
|
const server = createServer(async (request, response) => {
|
|
115
140
|
try {
|
|
116
|
-
const
|
|
141
|
+
const url = new URL(request.url, "http://localhost")
|
|
142
|
+
const pathname = decodeURIComponent(url.pathname)
|
|
143
|
+
if (pathname === "/__kudzu_reload") {
|
|
144
|
+
response.writeHead(200, {
|
|
145
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
146
|
+
"cache-control": "no-cache, no-transform",
|
|
147
|
+
connection: "keep-alive"
|
|
148
|
+
})
|
|
149
|
+
response.write(": connected\n\n")
|
|
150
|
+
clients.add(response)
|
|
151
|
+
request.on("close", () => clients.delete(response))
|
|
152
|
+
if (buildError) sendEvent(response, "build-error", buildError)
|
|
153
|
+
else if (url.searchParams.get("session") !== session || url.searchParams.get("revision") !== String(revision)) sendEvent(response, "reload")
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
if (pathname === "/__kudzu_dev.js") {
|
|
157
|
+
response.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" })
|
|
158
|
+
response.end(await readFile(new URL("./dev-state.js", import.meta.url)))
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
117
162
|
const relativePath = pathname.replace(/^\/+/, "")
|
|
118
163
|
let file = resolve(outputDirectory, relativePath)
|
|
119
164
|
if (!file.startsWith(`${outputDirectory}${sep}`) && file !== outputDirectory) throw new Error("Invalid path")
|
|
120
165
|
|
|
121
166
|
if ((await exists(file)) && (await stat(file)).isDirectory()) file = join(file, "index.html")
|
|
122
167
|
if (!(await exists(file)) && !extname(file)) file = join(file, "index.html")
|
|
123
|
-
const
|
|
124
|
-
|
|
168
|
+
const isHtml = extname(file) === ".html"
|
|
169
|
+
const content = isHtml
|
|
170
|
+
? injectDevClient(buildError ? errorPage(buildError) : await readFile(file, "utf8"), session, revision, buildError ? [] : await devSchema(pathname))
|
|
171
|
+
: await readFile(file)
|
|
172
|
+
response.writeHead(200, {
|
|
173
|
+
"content-type": contentType(file),
|
|
174
|
+
"cache-control": "no-store"
|
|
175
|
+
})
|
|
125
176
|
response.end(content)
|
|
126
177
|
} catch {
|
|
127
|
-
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" })
|
|
178
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" })
|
|
128
179
|
response.end("Not found")
|
|
129
180
|
}
|
|
130
181
|
})
|
|
131
182
|
|
|
132
|
-
server.listen(
|
|
183
|
+
server.listen(port, "127.0.0.1", () => console.log(`Kudzu dev server: http://127.0.0.1:${server.address().port}`))
|
|
133
184
|
|
|
134
185
|
let timer
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
186
|
+
let rebuilding = false
|
|
187
|
+
let pending = false
|
|
188
|
+
let changedFile
|
|
189
|
+
const rebuild = async () => {
|
|
190
|
+
if (rebuilding) {
|
|
191
|
+
pending = true
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
rebuilding = true
|
|
195
|
+
do {
|
|
196
|
+
pending = false
|
|
139
197
|
try {
|
|
140
198
|
await build({ quiet: true })
|
|
141
|
-
|
|
199
|
+
buildError = undefined
|
|
200
|
+
revision++
|
|
201
|
+
console.log(`Rebuilt after ${changedFile ?? "source change"}`)
|
|
202
|
+
for (const client of clients) sendEvent(client, "reload")
|
|
142
203
|
} catch (error) {
|
|
204
|
+
buildError = errorText(error)
|
|
143
205
|
console.error(error)
|
|
206
|
+
for (const client of clients) sendEvent(client, "build-error", buildError)
|
|
144
207
|
}
|
|
145
|
-
}
|
|
208
|
+
} while (pending)
|
|
209
|
+
rebuilding = false
|
|
210
|
+
}
|
|
211
|
+
const watcher = watch(sourceDirectory, { recursive: true })
|
|
212
|
+
for await (const event of watcher) {
|
|
213
|
+
changedFile = event.filename
|
|
214
|
+
clearTimeout(timer)
|
|
215
|
+
timer = setTimeout(rebuild, 80)
|
|
146
216
|
}
|
|
147
217
|
}
|
|
148
218
|
|
|
219
|
+
function injectDevClient(html, session, revision, schema) {
|
|
220
|
+
return `${html}${devClient(session, revision, schema)}`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function devSchema(pathname) {
|
|
224
|
+
try {
|
|
225
|
+
const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
|
|
226
|
+
const route = pathname.replace(/\/(?:index\.html)?$/, "") || "/"
|
|
227
|
+
return stateSchema(plan.routes.find(entry => entry.route === route)?.states ?? [])
|
|
228
|
+
} catch {
|
|
229
|
+
return []
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function inlineJson(value) {
|
|
234
|
+
return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029")
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function errorPage(error) {
|
|
238
|
+
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>`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function errorText(error) {
|
|
242
|
+
return String(error?.message ?? error)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function sendEvent(response, event, data = "") {
|
|
246
|
+
response.write(`event: ${event}\n${String(data).replaceAll("\r", "").split("\n").map(line => `data: ${line}\n`).join("")}\n`)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function escapeHtml(value) {
|
|
250
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
251
|
+
}
|
|
252
|
+
|
|
149
253
|
async function compile(file) {
|
|
150
254
|
const source = await readFile(file, "utf8")
|
|
151
255
|
const nativeHandlers = []
|
|
152
256
|
const reactiveBindings = []
|
|
257
|
+
const listExpressions = []
|
|
153
258
|
const handlerPath = `handlers/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
|
|
154
259
|
const result = ts.transpileModule(source, {
|
|
155
260
|
fileName: file,
|
|
@@ -159,7 +264,7 @@ async function compile(file) {
|
|
|
159
264
|
jsx: ts.JsxEmit.ReactJSX,
|
|
160
265
|
jsxImportSource: "@kudzujs/core"
|
|
161
266
|
},
|
|
162
|
-
transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, `/assets/${handlerPath}`)] },
|
|
267
|
+
transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, `/assets/${handlerPath}`)] },
|
|
163
268
|
reportDiagnostics: true
|
|
164
269
|
})
|
|
165
270
|
|
|
@@ -172,10 +277,11 @@ async function compile(file) {
|
|
|
172
277
|
await mkdir(resolve(output, ".."), { recursive: true })
|
|
173
278
|
await writeFile(output, result.outputText)
|
|
174
279
|
|
|
175
|
-
if (!nativeHandlers.length && !reactiveBindings.length) return undefined
|
|
280
|
+
if (!nativeHandlers.length && !reactiveBindings.length && !listExpressions.length) return undefined
|
|
176
281
|
const moduleSource = [
|
|
177
282
|
...nativeHandlers.map(handler => printNativeHandler(handler)),
|
|
178
|
-
...reactiveBindings.map(entry => printReactiveBinding(entry))
|
|
283
|
+
...reactiveBindings.map(entry => printReactiveBinding(entry)),
|
|
284
|
+
...listExpressions.map(entry => printListExpression(entry))
|
|
179
285
|
].join("\n")
|
|
180
286
|
const moduleResult = ts.transpileModule(moduleSource, {
|
|
181
287
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
|
|
@@ -186,12 +292,13 @@ async function compile(file) {
|
|
|
186
292
|
return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0 }
|
|
187
293
|
}
|
|
188
294
|
|
|
189
|
-
function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
295
|
+
function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, handlerUrl) {
|
|
190
296
|
return context => sourceFile => {
|
|
191
297
|
const factory = context.factory
|
|
192
298
|
const settersByFunction = new Map()
|
|
193
299
|
const functions = new Map()
|
|
194
|
-
const
|
|
300
|
+
const listValues = new WeakMap()
|
|
301
|
+
const listEventItems = new WeakMap()
|
|
195
302
|
let usesBehavior = false
|
|
196
303
|
let usesBinding = false
|
|
197
304
|
let usesConditional = false
|
|
@@ -237,13 +344,20 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
|
237
344
|
return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, initializer)
|
|
238
345
|
}
|
|
239
346
|
|
|
347
|
+
if (ts.isJsxExpression(node) && node.expression && listValues.has(node.expression)) {
|
|
348
|
+
return factory.updateJsxExpression(node, compileListValue(node.expression, listValues.get(node.expression), factory, listExpressions, handlerUrl))
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && listValues.has(node.initializer.expression)) {
|
|
352
|
+
return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, compileListValue(node.initializer.expression, listValues.get(node.initializer.expression), factory, listExpressions, handlerUrl)))
|
|
353
|
+
}
|
|
354
|
+
|
|
240
355
|
if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
|
|
241
356
|
const listParts = keyedListParts(node.expression, settersForNode(node, settersByFunction))
|
|
242
357
|
if (listParts) {
|
|
243
358
|
if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
|
|
244
|
-
validateKeyedList(listParts, sourceFile,
|
|
359
|
+
validateKeyedList(listParts, sourceFile, settersForNode(node, settersByFunction), listValues, listEventItems)
|
|
245
360
|
usesBehavior = true
|
|
246
|
-
usesBinding = true
|
|
247
361
|
usesList = true
|
|
248
362
|
return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, [
|
|
249
363
|
listParts.state,
|
|
@@ -269,7 +383,6 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
|
269
383
|
|
|
270
384
|
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !/^on/i.test(node.name.getText()) && !["style", "key", "ref", "dangerouslysetinnerhtml"].includes(node.name.getText().toLowerCase())) {
|
|
271
385
|
const expression = node.initializer.expression
|
|
272
|
-
if (listFieldExpressions.has(expression)) return node
|
|
273
386
|
const setters = settersForNode(node, settersByFunction)
|
|
274
387
|
const usedStates = referencedStateNames(expression, setters)
|
|
275
388
|
const captures = captureNames(expression, expression, setters)
|
|
@@ -283,7 +396,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
|
283
396
|
|
|
284
397
|
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.getText())) {
|
|
285
398
|
const setters = settersForNode(node, settersByFunction)
|
|
286
|
-
const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl)
|
|
399
|
+
const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl, listEventItems.get(node))
|
|
287
400
|
if (event) {
|
|
288
401
|
usesBehavior = true
|
|
289
402
|
return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, event))
|
|
@@ -302,7 +415,12 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
|
302
415
|
if (nativeHandlers.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("nativeBehavior"), factory.createIdentifier("__kNativeBehavior")))
|
|
303
416
|
if (usesBinding) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("binding"), factory.createIdentifier("__kBinding")))
|
|
304
417
|
if (usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("conditional"), factory.createIdentifier("__kConditional")))
|
|
305
|
-
if (usesList)
|
|
418
|
+
if (usesList) {
|
|
419
|
+
behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("list"), factory.createIdentifier("__kList")))
|
|
420
|
+
behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listExpression"), factory.createIdentifier("__kListExpression")))
|
|
421
|
+
behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listField"), factory.createIdentifier("__kListField")))
|
|
422
|
+
behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listItem"), factory.createIdentifier("__kListItem")))
|
|
423
|
+
}
|
|
306
424
|
if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
|
|
307
425
|
const behaviorImport = factory.createImportDeclaration(
|
|
308
426
|
undefined,
|
|
@@ -331,7 +449,7 @@ function keyedListParts(expression, setters) {
|
|
|
331
449
|
return { state, callback, root, item: callback.parameters[0].name.text, keyField: field }
|
|
332
450
|
}
|
|
333
451
|
|
|
334
|
-
function validateKeyedList(parts, sourceFile,
|
|
452
|
+
function validateKeyedList(parts, sourceFile, setters, listValues, listEventItems) {
|
|
335
453
|
const fail = (node, message) => {
|
|
336
454
|
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
|
337
455
|
throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
|
|
@@ -341,27 +459,112 @@ function validateKeyedList(parts, sourceFile, listFieldExpressions) {
|
|
|
341
459
|
if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(node, "Keyed list items must use intrinsic JSX elements")
|
|
342
460
|
}
|
|
343
461
|
const visit = node => {
|
|
462
|
+
if (ts.isJsxFragment(node)) fail(node, "Fragments are not supported in keyed lists")
|
|
344
463
|
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
|
|
345
|
-
if (node !== parts.root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map") fail(node, "Nested keyed lists are not supported")
|
|
464
|
+
if (node !== parts.root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) fail(node, "Nested keyed lists are not supported")
|
|
346
465
|
if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression, parts.item)) fail(node, "Keyed list item spreads are not supported")
|
|
347
|
-
if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.getText()))
|
|
466
|
+
if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.getText())) {
|
|
467
|
+
listEventItems.set(node, parts.item)
|
|
468
|
+
return
|
|
469
|
+
}
|
|
348
470
|
if (ts.isJsxExpression(node) && node.expression) {
|
|
349
471
|
const expression = unwrapExpression(node.expression)
|
|
472
|
+
if (conditionalParts(expression) && containsJsx(expression)) fail(node, "Nested reactive conditions are not supported in keyed lists")
|
|
350
473
|
const field = directProperty(expression, parts.item)
|
|
351
|
-
const isRootKey =
|
|
474
|
+
const isRootKey = ts.isJsxAttribute(node.parent) && node.parent.name.getText() === "key"
|
|
475
|
+
if (field && ["__proto__", "constructor", "prototype"].includes(field)) fail(node, `Keyed list item property "${field}" is not supported`)
|
|
352
476
|
if (field && ts.isJsxAttribute(node.parent) && ["style", "ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
|
|
353
|
-
if (isRootKey
|
|
354
|
-
|
|
477
|
+
if (isRootKey) return
|
|
478
|
+
if (field) {
|
|
479
|
+
listValues.set(node.expression, { field })
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
if (referencesIdentifier(expression, parts.item)) {
|
|
483
|
+
validateListExpression(expression, parts.item, node, fail)
|
|
484
|
+
if (ts.isJsxAttribute(node.parent) && ["style", "ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
|
|
485
|
+
listValues.set(node.expression, { item: parts.item })
|
|
355
486
|
return
|
|
356
487
|
}
|
|
357
|
-
if (conditionalParts(expression)) fail(node, "Nested reactive conditions are not supported in keyed lists")
|
|
358
|
-
if (referencesIdentifier(expression, parts.item)) fail(node, `Keyed list item expressions must be direct ${parts.item}.<field> reads`)
|
|
359
488
|
}
|
|
360
489
|
ts.forEachChild(node, visit)
|
|
361
490
|
}
|
|
362
491
|
visit(parts.root)
|
|
363
492
|
}
|
|
364
493
|
|
|
494
|
+
const pureListMethods = new Set(["at", "charAt", "charCodeAt", "concat", "endsWith", "includes", "indexOf", "join", "lastIndexOf", "padEnd", "padStart", "repeat", "replace", "replaceAll", "slice", "startsWith", "substring", "toLowerCase", "toUpperCase", "trim", "trimEnd", "trimStart"])
|
|
495
|
+
const mutatingListMethods = new Set(["copyWithin", "fill", "pop", "push", "reverse", "shift", "sort", "splice", "unshift"])
|
|
496
|
+
const pureMathMethods = new Set(["abs", "ceil", "floor", "max", "min", "pow", "round", "sign", "sqrt", "trunc"])
|
|
497
|
+
const pureListGlobals = new Set(["Boolean", "Infinity", "Math", "NaN", "Number", "String", "undefined"])
|
|
498
|
+
const assignmentOperators = new Set([
|
|
499
|
+
ts.SyntaxKind.EqualsToken, ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.MinusEqualsToken,
|
|
500
|
+
ts.SyntaxKind.AsteriskEqualsToken, ts.SyntaxKind.AsteriskAsteriskEqualsToken, ts.SyntaxKind.SlashEqualsToken,
|
|
501
|
+
ts.SyntaxKind.PercentEqualsToken, ts.SyntaxKind.LessThanLessThanEqualsToken, ts.SyntaxKind.GreaterThanGreaterThanEqualsToken,
|
|
502
|
+
ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, ts.SyntaxKind.AmpersandEqualsToken, ts.SyntaxKind.BarEqualsToken,
|
|
503
|
+
ts.SyntaxKind.CaretEqualsToken, ts.SyntaxKind.BarBarEqualsToken, ts.SyntaxKind.AmpersandAmpersandEqualsToken,
|
|
504
|
+
ts.SyntaxKind.QuestionQuestionEqualsToken
|
|
505
|
+
])
|
|
506
|
+
|
|
507
|
+
function validateListExpression(expression, item, source, fail) {
|
|
508
|
+
const visit = node => {
|
|
509
|
+
if (ts.isElementAccessExpression(node) && referencesIdentifier(node.expression, item)) {
|
|
510
|
+
const key = node.argumentExpression
|
|
511
|
+
if (!ts.isStringLiteral(key) && !ts.isNumericLiteral(key)) fail(source, "Derived keyed list item computed properties require a direct string or numeric literal key")
|
|
512
|
+
if (ts.isStringLiteral(key) && ["__proto__", "constructor", "prototype"].includes(key.text)) fail(source, `Derived keyed list item property "${key.text}" is not supported`)
|
|
513
|
+
}
|
|
514
|
+
if (ts.isPropertyAccessExpression(node) && ["__proto__", "constructor", "prototype"].includes(node.name.text) || ts.isElementAccessExpression(node) && ts.isStringLiteral(node.argumentExpression) && ["__proto__", "constructor", "prototype"].includes(node.argumentExpression.text)) {
|
|
515
|
+
fail(source, "Derived keyed list item expressions cannot read __proto__, prototype, or constructor")
|
|
516
|
+
}
|
|
517
|
+
if (ts.isBinaryExpression(node) && assignmentOperators.has(node.operatorToken.kind) || ts.isPostfixUnaryExpression(node) || ts.isPrefixUnaryExpression(node) && [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(node.operator)) {
|
|
518
|
+
fail(source, "Derived keyed list item expressions must be pure; assignments and updates are not supported")
|
|
519
|
+
}
|
|
520
|
+
if (ts.isDeleteExpression(node) || ts.isAwaitExpression(node) || ts.isNewExpression(node) || ts.isYieldExpression(node)) {
|
|
521
|
+
fail(source, "Derived keyed list item expressions must be synchronous and side-effect free; delete, await, yield, and new are not supported")
|
|
522
|
+
}
|
|
523
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isTaggedTemplateExpression(node)) {
|
|
524
|
+
fail(source, "Derived keyed list item expressions cannot create or invoke arbitrary functions")
|
|
525
|
+
}
|
|
526
|
+
if (ts.isCallExpression(node)) {
|
|
527
|
+
if (ts.isPropertyAccessExpression(node.expression)) {
|
|
528
|
+
const method = node.expression.name.text
|
|
529
|
+
if (mutatingListMethods.has(method)) fail(source, `Derived keyed list item expressions cannot call mutating method "${method}"`)
|
|
530
|
+
const receiver = node.expression.expression
|
|
531
|
+
const mathCall = ts.isIdentifier(receiver) && receiver.text === "Math" && pureMathMethods.has(method)
|
|
532
|
+
if (!mathCall && !pureListMethods.has(method)) fail(source, `Derived keyed list item expressions cannot call arbitrary method "${method}"`)
|
|
533
|
+
} else if (!ts.isIdentifier(node.expression) || !["Boolean", "Number", "String"].includes(node.expression.text)) {
|
|
534
|
+
fail(source, "Derived keyed list item expressions cannot call arbitrary functions")
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (ts.isIdentifier(node) && isReferenceIdentifier(node) && node.text !== item && !pureListGlobals.has(node.text)) {
|
|
538
|
+
fail(source, `Derived keyed list item expression identifier "${node.text}" is not allowed`)
|
|
539
|
+
}
|
|
540
|
+
ts.forEachChild(node, visit)
|
|
541
|
+
}
|
|
542
|
+
visit(expression)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function containsJsx(root) {
|
|
546
|
+
let found = false
|
|
547
|
+
const visit = node => {
|
|
548
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) found = true
|
|
549
|
+
if (!found) ts.forEachChild(node, visit)
|
|
550
|
+
}
|
|
551
|
+
visit(root)
|
|
552
|
+
return found
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function compileListExpression(read, expression, item, factory, listExpressions, handlerUrl) {
|
|
556
|
+
const exportName = `listExpression${listExpressions.length}`
|
|
557
|
+
listExpressions.push({ exportName, expression, item })
|
|
558
|
+
return factory.createCallExpression(factory.createIdentifier("__kListExpression"), undefined, [read, factory.createStringLiteral(handlerUrl), factory.createStringLiteral(exportName)])
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function compileListValue(expression, entry, factory, listExpressions, handlerUrl) {
|
|
562
|
+
const read = factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), expression)
|
|
563
|
+
return entry.field
|
|
564
|
+
? factory.createCallExpression(factory.createIdentifier("__kListField"), undefined, [read, factory.createStringLiteral(entry.field)])
|
|
565
|
+
: compileListExpression(read, expression, entry.item, factory, listExpressions, handlerUrl)
|
|
566
|
+
}
|
|
567
|
+
|
|
365
568
|
function directProperty(expression, objectName) {
|
|
366
569
|
const value = unwrapExpression(expression)
|
|
367
570
|
if (!ts.isPropertyAccessExpression(value) || !ts.isIdentifier(value.expression)) return undefined
|
|
@@ -454,10 +657,11 @@ function factoryNull() {
|
|
|
454
657
|
return ts.factory.createNull()
|
|
455
658
|
}
|
|
456
659
|
|
|
457
|
-
function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl) {
|
|
660
|
+
function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl, listItem) {
|
|
458
661
|
if (ts.isIdentifier(expression)) expression = functions.get(expression.text)
|
|
459
662
|
if (!expression || (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression) && !ts.isFunctionDeclaration(expression))) return undefined
|
|
460
663
|
|
|
664
|
+
rejectNativeEventControls(expression)
|
|
461
665
|
const optimized = compileOptimizedEvent(expression, setters, factory)
|
|
462
666
|
if (optimized) return optimized
|
|
463
667
|
|
|
@@ -469,18 +673,45 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
|
|
|
469
673
|
factory.createStringLiteral(name),
|
|
470
674
|
factory.createIdentifier(name)
|
|
471
675
|
]))
|
|
472
|
-
const scope = [...captures].map(name => factory.createArrayLiteralExpression([
|
|
473
|
-
factory.createStringLiteral(name),
|
|
474
|
-
factory.createIdentifier(name)
|
|
475
|
-
]))
|
|
476
676
|
return factory.createCallExpression(factory.createIdentifier("__kNativeBehavior"), undefined, [
|
|
477
677
|
factory.createStringLiteral(handlerUrl),
|
|
478
678
|
factory.createStringLiteral(exportName),
|
|
479
679
|
factory.createArrayLiteralExpression(states),
|
|
480
|
-
factory.createArrayLiteralExpression(
|
|
680
|
+
factory.createArrayLiteralExpression([...captures].map(name => factory.createArrayLiteralExpression([
|
|
681
|
+
factory.createStringLiteral(name),
|
|
682
|
+
name === listItem ? factory.createCallExpression(factory.createIdentifier("__kListItem"), undefined, []) : factory.createIdentifier(name)
|
|
683
|
+
])))
|
|
481
684
|
])
|
|
482
685
|
}
|
|
483
686
|
|
|
687
|
+
function rejectNativeEventControls(expression) {
|
|
688
|
+
const controls = new Set(["preventDefault", "stopPropagation", "stopImmediatePropagation"])
|
|
689
|
+
const found = new Set()
|
|
690
|
+
const eventAliases = new Set()
|
|
691
|
+
const parameter = expression.parameters[0]?.name
|
|
692
|
+
if (parameter && ts.isIdentifier(parameter)) eventAliases.add(parameter.text)
|
|
693
|
+
const visit = node => {
|
|
694
|
+
if (ts.isIdentifier(node) && controls.has(node.text)) found.add(node.text)
|
|
695
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isIdentifier(unwrapEventAlias(node.initializer)) && eventAliases.has(unwrapEventAlias(node.initializer).text)) {
|
|
696
|
+
eventAliases.add(node.name.text)
|
|
697
|
+
}
|
|
698
|
+
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)
|
|
699
|
+
if (ts.isElementAccessExpression(node) && ts.isIdentifier(unwrapEventAlias(node.expression)) && eventAliases.has(unwrapEventAlias(node.expression).text)) {
|
|
700
|
+
if (ts.isStringLiteral(node.argumentExpression) && controls.has(node.argumentExpression.text)) found.add(node.argumentExpression.text)
|
|
701
|
+
else if (!ts.isStringLiteral(node.argumentExpression)) for (const control of controls) found.add(control)
|
|
702
|
+
}
|
|
703
|
+
ts.forEachChild(node, visit)
|
|
704
|
+
}
|
|
705
|
+
for (const parameter of expression.parameters) visit(parameter)
|
|
706
|
+
visit(expression.body)
|
|
707
|
+
if (found.size) throw new Error(`Delegated native handlers do not support event control methods: ${[...found].sort().join(", ")}`)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function unwrapEventAlias(node) {
|
|
711
|
+
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isNonNullExpression(node) || ts.isSatisfiesExpression(node)) return unwrapEventAlias(node.expression)
|
|
712
|
+
return node
|
|
713
|
+
}
|
|
714
|
+
|
|
484
715
|
function nativeStateNames(expression, setters) {
|
|
485
716
|
return referencedStateNames(expression.body, setters, expression)
|
|
486
717
|
}
|
|
@@ -681,6 +912,19 @@ function printReactiveBinding({ exportName, expression, captures, states }) {
|
|
|
681
912
|
}
|
|
682
913
|
}
|
|
683
914
|
|
|
915
|
+
function printListExpression({ exportName, expression, item }) {
|
|
916
|
+
const declaration = ts.factory.createFunctionDeclaration(
|
|
917
|
+
[ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
|
|
918
|
+
undefined,
|
|
919
|
+
exportName,
|
|
920
|
+
undefined,
|
|
921
|
+
[ts.factory.createParameterDeclaration(undefined, undefined, item)],
|
|
922
|
+
undefined,
|
|
923
|
+
ts.factory.createBlock([ts.factory.createReturnStatement(expression)], true)
|
|
924
|
+
)
|
|
925
|
+
return ts.createPrinter().printNode(ts.EmitHint.Unspecified, declaration, expression.getSourceFile())
|
|
926
|
+
}
|
|
927
|
+
|
|
684
928
|
function scopeRead(factory, name) {
|
|
685
929
|
return factory.createCallExpression(
|
|
686
930
|
factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "scope"),
|
package/framework/core.d.ts
CHANGED
|
@@ -8,6 +8,9 @@ export function binding(value: unknown, module: string, handler: string, states:
|
|
|
8
8
|
export function bindingValue(value: unknown): unknown
|
|
9
9
|
export function conditional(kind: "and" | "ternary", value: unknown, truthy: () => unknown, falsy: () => unknown, module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
|
|
10
10
|
export function list(items: unknown, keyField: string, render: (item: unknown) => unknown): unknown
|
|
11
|
+
export function listField(read: () => unknown, field: string): unknown
|
|
12
|
+
export function listExpression(read: () => unknown, module: string, handler: string): unknown
|
|
13
|
+
export function listItem(): unknown
|
|
11
14
|
|
|
12
15
|
export function renderPage(
|
|
13
16
|
component: (props: Record<string, never>) => unknown | Promise<unknown>,
|
package/framework/core.mjs
CHANGED
|
@@ -5,6 +5,8 @@ const bindingMarker = Symbol("kudzu.binding")
|
|
|
5
5
|
const conditionalMarker = Symbol("kudzu.conditional")
|
|
6
6
|
const listMarker = Symbol("kudzu.list")
|
|
7
7
|
const listFieldMarker = Symbol("kudzu.listField")
|
|
8
|
+
const listExpressionMarker = Symbol("kudzu.listExpression")
|
|
9
|
+
const listItemMarker = Symbol("kudzu.listItem")
|
|
8
10
|
const noSelectValue = Symbol("kudzu.no-select-value")
|
|
9
11
|
|
|
10
12
|
let renderContext
|
|
@@ -79,13 +81,27 @@ export function list(items, keyField, render) {
|
|
|
79
81
|
return { [listMarker]: true, items, keyField, render }
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
export function listField(read, field) {
|
|
85
|
+
return { [listFieldMarker]: true, field, value: renderContext?.listTemplate ? undefined : read() }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function listExpression(read, module, handler) {
|
|
89
|
+
const value = renderContext?.listTemplate ? undefined : read()
|
|
90
|
+
if (value && typeof value.then === "function") throw new Error("Derived keyed list item expressions must return synchronous values")
|
|
91
|
+
return { [listExpressionMarker]: true, module, handler, value }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function listItem() {
|
|
95
|
+
return { [listItemMarker]: true }
|
|
96
|
+
}
|
|
97
|
+
|
|
82
98
|
function validListKey(key) {
|
|
83
99
|
return typeof key === "string" || typeof key === "number" && Number.isFinite(key)
|
|
84
100
|
}
|
|
85
101
|
|
|
86
102
|
function assertListItem(item) {
|
|
87
103
|
const prototype = item && typeof item === "object" ? Object.getPrototypeOf(item) : undefined
|
|
88
|
-
if (!item || Array.isArray(item) || prototype !== Object.prototype
|
|
104
|
+
if (!item || Array.isArray(item) || prototype !== Object.prototype) throw new Error("Keyed list items must be ordinary plain objects")
|
|
89
105
|
}
|
|
90
106
|
|
|
91
107
|
function assertListValue(value, seen) {
|
|
@@ -93,7 +109,7 @@ function assertListValue(value, seen) {
|
|
|
93
109
|
if (!value || typeof value !== "object") throw new Error(`Keyed list items must contain only JSON-safe values`)
|
|
94
110
|
if (seen.has(value)) throw new Error("Keyed list items must not contain cycles")
|
|
95
111
|
const prototype = Object.getPrototypeOf(value)
|
|
96
|
-
if (!Array.isArray(value) && prototype !== Object.prototype
|
|
112
|
+
if (!Array.isArray(value) && prototype !== Object.prototype) throw new Error("Keyed list items must contain only arrays and ordinary plain objects")
|
|
97
113
|
if (Object.getOwnPropertySymbols(value).length) throw new Error("Keyed list items must not contain symbols")
|
|
98
114
|
seen.add(value)
|
|
99
115
|
const descriptors = Object.getOwnPropertyDescriptors(value)
|
|
@@ -139,6 +155,7 @@ function bindingDescriptor(value) {
|
|
|
139
155
|
}
|
|
140
156
|
|
|
141
157
|
function serializeCapture(name, value, seen) {
|
|
158
|
+
if (value?.[listItemMarker]) return { type: "list-item" }
|
|
142
159
|
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
|
143
160
|
if (typeof value === "number") {
|
|
144
161
|
return Number.isFinite(value) && !Object.is(value, -0) ? value : { type: "number", value: String(value) }
|
|
@@ -170,7 +187,7 @@ function serializeCapture(name, value, seen) {
|
|
|
170
187
|
}
|
|
171
188
|
|
|
172
189
|
export async function renderPage(component, metadata = {}) {
|
|
173
|
-
renderContext = { nextState: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false }
|
|
190
|
+
renderContext = { nextState: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false }
|
|
174
191
|
|
|
175
192
|
try {
|
|
176
193
|
const body = await renderNode({ type: component, props: {} })
|
|
@@ -290,6 +307,10 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
290
307
|
if (node?.[listFieldMarker]) {
|
|
291
308
|
return `<template data-k-list-text="${escapeAttribute(node.field)}"></template>${escapeHtml(node.value ?? "")}<template data-k-list-text-end></template>`
|
|
292
309
|
}
|
|
310
|
+
if (node?.[listExpressionMarker]) {
|
|
311
|
+
const descriptor = { module: node.module, handler: node.handler }
|
|
312
|
+
return `<template data-k-list-expression='${escapeJsonAttribute(descriptor)}'></template>${escapeHtml(node.value ?? "")}<template data-k-list-expression-end></template>`
|
|
313
|
+
}
|
|
293
314
|
if (!node || typeof node !== "object" || !("type" in node)) {
|
|
294
315
|
throw new Error(`Cannot render ${String(node)}`)
|
|
295
316
|
}
|
|
@@ -308,6 +329,8 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
308
329
|
let attributes = ""
|
|
309
330
|
const attributeBindings = []
|
|
310
331
|
const listAttributes = []
|
|
332
|
+
const listExpressionAttributes = []
|
|
333
|
+
const listEvents = []
|
|
311
334
|
|
|
312
335
|
if (renderContext.listRoot) {
|
|
313
336
|
const root = renderContext.listRoot
|
|
@@ -327,15 +350,17 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
327
350
|
}
|
|
328
351
|
|
|
329
352
|
if (/^on[A-Z]/.test(rawName)) {
|
|
330
|
-
|
|
353
|
+
const event = rawName.slice(2).toLowerCase()
|
|
331
354
|
if (value?.[behaviorMarker]) {
|
|
332
355
|
const commands = JSON.stringify(value.commands)
|
|
333
356
|
attributes += ` data-k-on-${event}='${escapeJsonAttribute(value.commands)}'`
|
|
334
357
|
renderContext.events.push({ event, commands: value.commands })
|
|
335
358
|
} else if (value?.[nativeBehaviorMarker]) {
|
|
336
|
-
const
|
|
359
|
+
const template = { module: value.module, handler: value.handler, states: value.states, scope: value.scope }
|
|
360
|
+
const native = template
|
|
337
361
|
attributes += ` data-k-native-${event}='${escapeJsonAttribute(native)}'`
|
|
338
362
|
renderContext.events.push({ event, native })
|
|
363
|
+
if (renderContext.listDepth && Object.values(template.scope).some(entry => entry?.type === "list-item")) listEvents.push([event, template])
|
|
339
364
|
renderContext.hasNativeBehaviors = true
|
|
340
365
|
} else {
|
|
341
366
|
throw new Error(`${rawName} must reference a compilable event handler`)
|
|
@@ -351,6 +376,11 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
351
376
|
listAttributes.push([name, value.field])
|
|
352
377
|
continue
|
|
353
378
|
}
|
|
379
|
+
if (value?.[listExpressionMarker]) {
|
|
380
|
+
attributes += renderAttribute(name, value.value)
|
|
381
|
+
listExpressionAttributes.push([name, value.module, value.handler])
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
354
384
|
if (value?.[signalMarker] || value?.[bindingMarker]) {
|
|
355
385
|
const initialValue = value[signalMarker] ? value.value : value.value
|
|
356
386
|
const reactive = value[signalMarker] || Object.keys(value.states).length > 0 || Object.keys(value.scopeStates).length > 0 || Object.keys(value.scopeBindings).length > 0
|
|
@@ -382,6 +412,8 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
382
412
|
|
|
383
413
|
if (attributeBindings.length) attributes += ` data-k-bind-attrs='${escapeJsonAttribute(attributeBindings)}'`
|
|
384
414
|
if (listAttributes.length) attributes += ` data-k-list-attrs='${escapeJsonAttribute(listAttributes)}'`
|
|
415
|
+
if (listExpressionAttributes.length) attributes += ` data-k-list-expression-attrs='${escapeJsonAttribute(listExpressionAttributes)}'`
|
|
416
|
+
if (listEvents.length) attributes += ` data-k-list-events='${escapeJsonAttribute(listEvents)}'`
|
|
385
417
|
|
|
386
418
|
if (tag === "option" && selectValue !== noSelectValue && String(optionValue(props)) === (selectValue == null ? "" : String(selectValue))) attributes += " selected"
|
|
387
419
|
|
|
@@ -394,17 +426,16 @@ async function renderList(node, namespace, selectValue) {
|
|
|
394
426
|
if (namespace) throw new Error(`Reactive keyed lists are not supported inside ${namespace}`)
|
|
395
427
|
const id = `l${renderContext.nextList++}`
|
|
396
428
|
const descriptor = { id, state: node.items.id, key: node.keyField }
|
|
397
|
-
const itemProxy = value => new Proxy({}, {
|
|
398
|
-
get: (_, field) => ({ [listFieldMarker]: true, field: String(field), value: value?.[field] })
|
|
399
|
-
})
|
|
400
429
|
renderContext.listDepth++
|
|
401
430
|
try {
|
|
431
|
+
renderContext.listTemplate = true
|
|
402
432
|
renderContext.listRoot = { id, template: true }
|
|
403
|
-
const template = await renderNode(node.render(
|
|
433
|
+
const template = await renderNode(node.render({}), namespace, selectValue)
|
|
404
434
|
let current = ""
|
|
435
|
+
renderContext.listTemplate = false
|
|
405
436
|
for (const item of node.items.value) {
|
|
406
437
|
renderContext.listRoot = { id, key: item[node.keyField], template: false }
|
|
407
|
-
current += await renderNode(node.render(
|
|
438
|
+
current += await renderNode(node.render(item), namespace, selectValue)
|
|
408
439
|
}
|
|
409
440
|
renderContext.lists.push(descriptor)
|
|
410
441
|
renderContext.hasBehaviors = true
|
|
@@ -412,6 +443,7 @@ async function renderList(node, namespace, selectValue) {
|
|
|
412
443
|
return `<template data-k-list='${escapeJsonAttribute(descriptor)}'>${template}</template>${current}<template data-k-list-end="${id}"></template>`
|
|
413
444
|
} finally {
|
|
414
445
|
renderContext.listRoot = undefined
|
|
446
|
+
renderContext.listTemplate = false
|
|
415
447
|
renderContext.listDepth--
|
|
416
448
|
}
|
|
417
449
|
}
|
|
@@ -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
|
+
}
|
|
@@ -3,6 +3,8 @@ import { browserState, mountDom, registerCommitter, registerMountHook, registerU
|
|
|
3
3
|
const listTargets = new Map()
|
|
4
4
|
const listRegistrations = new WeakMap()
|
|
5
5
|
const mountedLists = new WeakSet()
|
|
6
|
+
const imports = new Map()
|
|
7
|
+
const revisions = new WeakMap()
|
|
6
8
|
|
|
7
9
|
function commitLists(id) {
|
|
8
10
|
const lists = listTargets.get(id)
|
|
@@ -97,20 +99,62 @@ function updateList(list) {
|
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
function fillListItem(root, item) {
|
|
102
|
+
const revision = (revisions.get(root) ?? 0) + 1
|
|
103
|
+
revisions.set(root, revision)
|
|
100
104
|
for (const marker of matching(root, "template[data-k-list-text]")) {
|
|
101
|
-
|
|
102
|
-
let end = marker.nextSibling
|
|
103
|
-
while (end && !(end.nodeType === Node.ELEMENT_NODE && end.matches("template[data-k-list-text-end]"))) end = end.nextSibling
|
|
104
|
-
if (!end) throw new Error("Keyed list text marker has no end")
|
|
105
|
-
const range = marker.ownerDocument.createRange()
|
|
106
|
-
range.setStartAfter(marker)
|
|
107
|
-
range.setEndBefore(end)
|
|
108
|
-
range.deleteContents()
|
|
109
|
-
end.before(marker.ownerDocument.createTextNode(value == null ? "" : String(value)))
|
|
105
|
+
patchListText(marker, "template[data-k-list-text-end]", item?.[marker.dataset.kListText])
|
|
110
106
|
}
|
|
111
107
|
for (const node of matching(root, "[data-k-list-attrs]")) {
|
|
112
108
|
for (const [target, field] of JSON.parse(node.dataset.kListAttrs)) patchBinding(node, target, item?.[field])
|
|
113
109
|
}
|
|
110
|
+
for (const node of matching(root, "[data-k-list-events]")) {
|
|
111
|
+
for (const [event, native] of JSON.parse(node.dataset.kListEvents)) {
|
|
112
|
+
native.scope = Object.fromEntries(Object.entries(native.scope).map(([name, value]) => [name, value?.type === "list-item" ? serializeItem(item) : value]))
|
|
113
|
+
node.dataset[`kNative${capitalize(event)}`] = JSON.stringify(native)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const marker of matching(root, "template[data-k-list-expression]")) {
|
|
117
|
+
evaluate(JSON.parse(marker.dataset.kListExpression), item).then(value => {
|
|
118
|
+
if (revisions.get(root) === revision && root.isConnected) patchListText(marker, "template[data-k-list-expression-end]", value)
|
|
119
|
+
}).catch(error => console.error(error))
|
|
120
|
+
}
|
|
121
|
+
for (const node of matching(root, "[data-k-list-expression-attrs]")) {
|
|
122
|
+
for (const [target, module, handler] of JSON.parse(node.dataset.kListExpressionAttrs)) {
|
|
123
|
+
evaluate({ module, handler }, item).then(value => {
|
|
124
|
+
if (revisions.get(root) === revision && root.isConnected) patchBinding(node, target, value)
|
|
125
|
+
}).catch(error => console.error(error))
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function patchListText(marker, endSelector, value) {
|
|
131
|
+
let end = marker.nextSibling
|
|
132
|
+
while (end && !(end.nodeType === Node.ELEMENT_NODE && end.matches(endSelector))) end = end.nextSibling
|
|
133
|
+
if (!end) throw new Error("Keyed list text marker has no end")
|
|
134
|
+
const range = marker.ownerDocument.createRange()
|
|
135
|
+
range.setStartAfter(marker)
|
|
136
|
+
range.setEndBefore(end)
|
|
137
|
+
range.deleteContents()
|
|
138
|
+
end.before(marker.ownerDocument.createTextNode(value == null ? "" : String(value)))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function evaluate(descriptor, item) {
|
|
142
|
+
let module = imports.get(descriptor.module)
|
|
143
|
+
if (!module) {
|
|
144
|
+
module = import(descriptor.module)
|
|
145
|
+
imports.set(descriptor.module, module)
|
|
146
|
+
}
|
|
147
|
+
return module.then(exports => {
|
|
148
|
+
const value = exports[descriptor.handler](item)
|
|
149
|
+
if (value && typeof value.then === "function") throw new Error("Derived keyed list item expressions must return synchronous values")
|
|
150
|
+
return value
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function serializeItem(value) {
|
|
155
|
+
if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number") return value
|
|
156
|
+
if (Array.isArray(value)) return { type: "array", value: value.map(serializeItem) }
|
|
157
|
+
return { type: "object", nullPrototype: false, value: Object.entries(value).map(([key, entry]) => [key, serializeItem(entry)]) }
|
|
114
158
|
}
|
|
115
159
|
|
|
116
160
|
function patchBinding(node, target, value) {
|
|
@@ -142,7 +186,7 @@ function validListKey(key) {
|
|
|
142
186
|
|
|
143
187
|
function assertListItem(item) {
|
|
144
188
|
const prototype = item && typeof item === "object" ? Object.getPrototypeOf(item) : undefined
|
|
145
|
-
if (!item || Array.isArray(item) || prototype !== Object.prototype
|
|
189
|
+
if (!item || Array.isArray(item) || prototype !== Object.prototype) throw new Error("Keyed list items must be ordinary plain objects")
|
|
146
190
|
}
|
|
147
191
|
|
|
148
192
|
function assertListValue(value, seen) {
|
|
@@ -150,7 +194,7 @@ function assertListValue(value, seen) {
|
|
|
150
194
|
if (!value || typeof value !== "object") throw new Error("Keyed list items must contain only JSON-safe values")
|
|
151
195
|
if (seen.has(value)) throw new Error("Keyed list items must not contain cycles")
|
|
152
196
|
const prototype = Object.getPrototypeOf(value)
|
|
153
|
-
if (!Array.isArray(value) && prototype !== Object.prototype
|
|
197
|
+
if (!Array.isArray(value) && prototype !== Object.prototype) throw new Error("Keyed list items must contain only arrays and ordinary plain objects")
|
|
154
198
|
if (Object.getOwnPropertySymbols(value).length) throw new Error("Keyed list items must not contain symbols")
|
|
155
199
|
seen.add(value)
|
|
156
200
|
const descriptors = Object.getOwnPropertyDescriptors(value)
|
|
@@ -183,3 +227,7 @@ function matching(root, selector) {
|
|
|
183
227
|
function isStringBooleanAttribute(name) {
|
|
184
228
|
return name.startsWith("aria-") || name.startsWith("data-")
|
|
185
229
|
}
|
|
230
|
+
|
|
231
|
+
function capitalize(value) {
|
|
232
|
+
return value[0].toUpperCase() + value.slice(1)
|
|
233
|
+
}
|
|
@@ -39,19 +39,38 @@ if (typeof document !== "undefined") {
|
|
|
39
39
|
const eventNames = ["click", "input", "change", "submit", "keydown", "keyup"]
|
|
40
40
|
for (const eventName of eventNames) {
|
|
41
41
|
document.addEventListener(eventName, event => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
let modulePromise = modules.get(native.module)
|
|
47
|
-
if (!modulePromise) {
|
|
48
|
-
modulePromise = import(native.module)
|
|
49
|
-
modules.set(native.module, modulePromise)
|
|
42
|
+
try {
|
|
43
|
+
dispatchNative(event, snapshotNativeTargets(event, eventName), modules).catch(error => console.error(error))
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error(error)
|
|
50
46
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
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)}`]) })
|
|
56
|
+
}
|
|
57
|
+
return targets
|
|
58
|
+
}
|
|
59
|
+
|
|
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
|
+
}
|
|
55
74
|
}
|
|
56
75
|
}
|
|
57
76
|
|