@kudzujs/core 0.5.5 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -1
- package/framework/README.md +2 -1
- package/framework/build.mjs +235 -37
- package/framework/core.d.ts +4 -0
- package/framework/core.mjs +40 -3
- package/framework/effect-runtime.js +39 -0
- package/framework/list-runtime.js +50 -42
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
HTML-first TSX framework with synchronous state semantics and no virtual DOM.
|
|
8
8
|
|
|
9
|
-
Kudzu keeps the familiar function-component, props, children, event-handler,
|
|
9
|
+
Kudzu keeps the familiar function-component, props, children, event-handler, `useState`, and mount-effect shape. Static components compile to HTML. Simple interactions compile to small behavior commands, while normal sync or async JavaScript handlers and mount effects compile to external ESM.
|
|
10
10
|
|
|
11
11
|
> Experimental `0.4.x`: the compiler API and supported TSX surface may change.
|
|
12
12
|
|
|
@@ -308,6 +308,27 @@ The original component remains reusable across multiple lists and ordinary JSX.
|
|
|
308
308
|
|
|
309
309
|
Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a local-state `.map`, one identifier callback parameter, one intrinsic JSX root or top-level local or relative-imported row component, and `key={item.<field>}`. Row components accept destructured projected props and top-level single-`const` calculations before one intrinsic return. A list alias may only be rendered once and cannot be read by other JavaScript. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, imported helpers used inside calculations, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Package or namespace row imports, same-file exported rows, reusable aliases, prop spreads/defaults/rest, children, nested item conditions, lists, or component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
|
|
310
310
|
|
|
311
|
+
## Mount Effects
|
|
312
|
+
|
|
313
|
+
Browser-only initial work uses the familiar empty-dependency effect shape:
|
|
314
|
+
|
|
315
|
+
```tsx
|
|
316
|
+
import { useEffect, useState } from "@kudzujs/core"
|
|
317
|
+
|
|
318
|
+
const [items, setItems] = useState([])
|
|
319
|
+
|
|
320
|
+
useEffect(async () => {
|
|
321
|
+
const response = await fetch("/api/items")
|
|
322
|
+
setItems(await response.json())
|
|
323
|
+
}, [])
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Kudzu does not execute the effect during static rendering and does not ship the component. It emits one route-specific effect entry that invokes the compiled callback against existing logical state and direct DOM commit capabilities. Effects may update reactive text, attributes, conditions, and keyed lists. Multiple effects start independently in source order, and one synchronous or asynchronous failure is reported without suppressing later effects.
|
|
327
|
+
|
|
328
|
+
Only inline block-bodied callbacks with a literal empty dependency array are supported. Dependencies, cleanup or other return values, callback parameters, and non-serializable captures are rejected at build time. Pages without effects receive no effect entry and retain their existing output.
|
|
329
|
+
|
|
330
|
+
A matched mount-fetch benchmark renders a title and two keyed rows from local JSON. With one warm-up and seven rotating clean builds, Kudzu shipped initial HTML, 3.4 KB initial JS gzip, 8.1 KB total output, and built in 374 ms. React CSR shipped no initial content, 59.3 KB initial JS gzip, 189.2 KB total output, and built in 992 ms. Hand-written ESM shipped 534 B initial JS gzip, 1.2 KB total output, and built in 210 ms. Fresh-profile Chrome medians to loaded data were 157.9 ms, 166.5 ms, and 153.4 ms respectively.
|
|
331
|
+
|
|
311
332
|
## Normal JavaScript
|
|
312
333
|
|
|
313
334
|
Command-only setters use the smallest optimized path. Conditions, local variables, browser globals, events, and `async`/`await` compile to external ESM without `eval`, `new Function`, or inline executable code.
|
|
@@ -377,6 +398,7 @@ Supported:
|
|
|
377
398
|
- Static trusted `dangerouslySetInnerHTML`
|
|
378
399
|
- Base-path deployments, multiple CSS files, and `afterBuild`
|
|
379
400
|
- Primitive `useState` bindings
|
|
401
|
+
- Mount-only `useEffect(fn, [])` compiled to route-specific ESM
|
|
380
402
|
- Synchronous and async event handlers
|
|
381
403
|
- Relative imported helpers in native handlers
|
|
382
404
|
- Serializable component-local captures
|
package/framework/README.md
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
- `binding-runtime.js`: optional generic attributes, form properties, comment-bounded text patches, and conditional range patches.
|
|
9
9
|
- `list-runtime.js`: optional keyed list validation, external item-expression evaluation, item-local conditional ranges, dynamic styles and item-handler scopes, moves, and cleanup.
|
|
10
10
|
- `serialization.js`: capture deserialization shared by binding and native handlers.
|
|
11
|
+
- `effect-runtime.js`: optional state and capture context for route-specific mount-effect entries.
|
|
11
12
|
- `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
|
|
12
13
|
- `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
|
|
13
14
|
- `*.d.ts`: public TypeScript and JSX declarations.
|
|
14
15
|
|
|
15
|
-
Static routes receive no browser runtime. Command routes receive `runtime.js`; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js
|
|
16
|
+
Static routes receive no browser runtime. Command routes receive `runtime.js`; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`; mount effects add `effect-runtime.js` and one route-specific entry. List builds remove unused text-range, attribute, event, expression, condition, seed, and mount branches. Effect builds omit capture deserialization entirely when every effect scope is empty. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators and their bundled relative TypeScript helpers live under `dist/assets/handlers/`; shared helper chunks are emitted only when multiple handler entries need them. The dev server derives stable state identities from route-unique state variable names in each route plan; every state sharing a duplicate name is omitted. It then injects its SSE reload, short-lived full-URL-scoped logical-state snapshot, and build-error client into responses only, never into `dist/`. Snapshots are consumed even when the next page is static or broken. Reload restoration covers compatible framework state, not uncontrolled DOM state, focus, selection, or imperative mutations.
|
|
16
17
|
|
|
17
18
|
Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
|
package/framework/build.mjs
CHANGED
|
@@ -46,6 +46,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
46
46
|
let listStyleCount = 0
|
|
47
47
|
let stateSeedCount = 0
|
|
48
48
|
const plans = []
|
|
49
|
+
const effectEntries = []
|
|
49
50
|
const emittedRoutes = new Set()
|
|
50
51
|
const styleUrls = cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`))
|
|
51
52
|
|
|
@@ -58,17 +59,20 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
58
59
|
for (const { params, props } of entries) {
|
|
59
60
|
const route = routeFromPage(pageFile, params)
|
|
60
61
|
const routePath = withBase(base, `/${route}`)
|
|
62
|
+
const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
|
|
61
63
|
if (emittedRoutes.has(routePath)) throw new Error(`Duplicate route: ${routePath}`)
|
|
62
64
|
emittedRoutes.add(routePath)
|
|
63
65
|
const result = await renderPage(module.default, {
|
|
64
66
|
...(module.metadata ?? {}),
|
|
65
67
|
styles: styleUrls.length ? styleUrls : false,
|
|
66
|
-
base
|
|
68
|
+
base,
|
|
69
|
+
effectAsset: assetPath(base, `assets/${effectPath}`)
|
|
67
70
|
}, props)
|
|
68
71
|
const routeDirectory = join(outputDirectory, route)
|
|
69
72
|
await mkdir(routeDirectory, { recursive: true })
|
|
70
73
|
await writeFile(join(routeDirectory, "index.html"), result.html)
|
|
71
74
|
plans.push({ route: routePath, ...result.plan })
|
|
75
|
+
if (result.hasEffects) effectEntries.push({ path: effectPath, effects: result.plan.effects })
|
|
72
76
|
if (result.hasBehaviors) behaviorCount++
|
|
73
77
|
if (result.hasBindings) bindingCount++
|
|
74
78
|
if (result.hasLists) listCount++
|
|
@@ -83,19 +87,37 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
83
87
|
const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
|
|
84
88
|
const hasTextBindings = plans.some(plan => plan.bindings.some(binding => binding.target === "text"))
|
|
85
89
|
const hasListConditions = plans.some(plan => plan.lists.some(list => list.conditions))
|
|
90
|
+
const hasListTextRanges = plans.some(plan => plan.lists.some(list => list.textRanges))
|
|
91
|
+
const hasListAttributes = plans.some(plan => plan.lists.some(list => list.attributes))
|
|
92
|
+
const hasListEvents = plans.some(plan => plan.lists.some(list => list.events))
|
|
93
|
+
const hasListExpressions = plans.some(plan => plan.lists.some(list => list.expressions))
|
|
94
|
+
const hasListExpressionAttributes = plans.some(plan => plan.lists.some(list => list.expressionAttributes))
|
|
95
|
+
const hasListSeeds = plans.some(plan => plan.lists.some(list => list.seed))
|
|
96
|
+
const hasListAsyncParts = hasListExpressions || hasListExpressionAttributes || hasListConditions
|
|
97
|
+
const hasListMounts = hasListConditions || plans.some(plan => plan.lists.some(list => list.mount))
|
|
86
98
|
const hasNestedStateCaptures = hasNestedCaptureState(plans)
|
|
87
99
|
const hasSetterCaptures = hasCaptureType(plans, "setter")
|
|
100
|
+
const hasEffectCaptures = plans.some(plan => plan.effects.some(effect => Object.keys(effect.scope).length))
|
|
88
101
|
const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
|
|
89
102
|
const hasNativeHandlers = nativeModules.length > 0
|
|
103
|
+
const hasEffects = effectEntries.length > 0
|
|
90
104
|
if (behaviorCount) {
|
|
91
105
|
const runtimeFile = bindingCount || listCount || hasNativeHandlers ? "./shared-runtime.js" : "./runtime.js"
|
|
92
106
|
const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
|
|
93
107
|
await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
|
|
94
108
|
}
|
|
95
|
-
if (bindingCount || hasNativeHandlers) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify, {
|
|
109
|
+
if (bindingCount || hasNativeHandlers || hasEffectCaptures) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify, {
|
|
96
110
|
"globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures),
|
|
97
111
|
"globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
|
|
98
112
|
})
|
|
113
|
+
if (hasEffects) {
|
|
114
|
+
let effectRuntime = await readFile(new URL("./effect-runtime.js", import.meta.url), "utf8")
|
|
115
|
+
effectRuntime = hasEffectCaptures ? effectRuntime.replace('"./serialization.js"', '"./kudzu-serialization.js"') : effectRuntime.replace(/^import[^\n]+\n/, "")
|
|
116
|
+
await writeBundledJavaScript(join(assetsDirectory, "kudzu-effect.js"), effectRuntime, minify, {
|
|
117
|
+
"globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures),
|
|
118
|
+
"globalThis.__KUDZU_EFFECT_CAPTURES__": String(hasEffectCaptures)
|
|
119
|
+
})
|
|
120
|
+
}
|
|
99
121
|
if (bindingCount || listStyleCount) await writeJavaScript(join(assetsDirectory, "kudzu-style.js"), await readFile(new URL("./style.js", import.meta.url), "utf8"), minify)
|
|
100
122
|
if (bindingCount) {
|
|
101
123
|
const bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
|
|
@@ -118,7 +140,17 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
118
140
|
}`
|
|
119
141
|
listRuntime = listRuntime.replace(" /* list-style */", listStyleCount ? stylePatch : "")
|
|
120
142
|
if (listStyleCount) listRuntime = `import { serializeStyle } from "./kudzu-style.js"\n${listRuntime}`
|
|
121
|
-
await writeBundledJavaScript(join(assetsDirectory, "kudzu-list.js"), listRuntime, minify, {
|
|
143
|
+
await writeBundledJavaScript(join(assetsDirectory, "kudzu-list.js"), listRuntime, minify, {
|
|
144
|
+
__KUDZU_LIST_CONDITIONS__: String(hasListConditions),
|
|
145
|
+
__KUDZU_LIST_TEXT_RANGES__: String(hasListTextRanges),
|
|
146
|
+
__KUDZU_LIST_ATTRIBUTES__: String(hasListAttributes),
|
|
147
|
+
__KUDZU_LIST_EVENTS__: String(hasListEvents),
|
|
148
|
+
__KUDZU_LIST_EXPRESSIONS__: String(hasListExpressions),
|
|
149
|
+
__KUDZU_LIST_EXPRESSION_ATTRIBUTES__: String(hasListExpressionAttributes),
|
|
150
|
+
__KUDZU_LIST_SEEDS__: String(hasListSeeds),
|
|
151
|
+
__KUDZU_LIST_ASYNC_PARTS__: String(hasListAsyncParts),
|
|
152
|
+
__KUDZU_LIST_MOUNTS__: String(hasListMounts)
|
|
153
|
+
})
|
|
122
154
|
}
|
|
123
155
|
if (hasNativeHandlers) {
|
|
124
156
|
const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
|
|
@@ -133,6 +165,11 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
133
165
|
await mkdir(resolve(output, ".."), { recursive: true })
|
|
134
166
|
await writeJavaScript(output, handlerModule.code, minify)
|
|
135
167
|
}
|
|
168
|
+
for (const entry of effectEntries) {
|
|
169
|
+
const output = join(assetsDirectory, entry.path)
|
|
170
|
+
await mkdir(dirname(output), { recursive: true })
|
|
171
|
+
await writeJavaScript(output, printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base), minify)
|
|
172
|
+
}
|
|
136
173
|
const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
|
|
137
174
|
for (const file of clientModules) {
|
|
138
175
|
const output = join(assetsDirectory, clientModulePath(file))
|
|
@@ -182,6 +219,32 @@ function specializeNativeRuntime(source, events, modules) {
|
|
|
182
219
|
return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
|
|
183
220
|
}
|
|
184
221
|
|
|
222
|
+
function printEffectEntry(effects, output, handlerModules, assetsDirectory, base) {
|
|
223
|
+
const moduleUrls = [...new Set(effects.map(effect => effect.module))]
|
|
224
|
+
const modules = moduleUrls.map(url => {
|
|
225
|
+
const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
|
|
226
|
+
if (!module) throw new Error(`Effect handler module was not emitted: ${url}`)
|
|
227
|
+
return module
|
|
228
|
+
})
|
|
229
|
+
const imports = [
|
|
230
|
+
`import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
|
|
231
|
+
`import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
|
|
232
|
+
...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
|
|
233
|
+
]
|
|
234
|
+
const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
|
|
235
|
+
return `${imports.join("\n")}
|
|
236
|
+
const effects = ${inlineJson(effects)}
|
|
237
|
+
const modules = new Map([${entries}])
|
|
238
|
+
for (const effect of effects) {
|
|
239
|
+
try {
|
|
240
|
+
const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, effect.scope))
|
|
241
|
+
if (result && typeof result.then === "function") result.catch(error => console.error(error))
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.error(error)
|
|
244
|
+
}
|
|
245
|
+
}`
|
|
246
|
+
}
|
|
247
|
+
|
|
185
248
|
function hasCaptureType(value, type) {
|
|
186
249
|
if (!value || typeof value !== "object") return false
|
|
187
250
|
if (value.type === type) return true
|
|
@@ -373,6 +436,7 @@ function escapeHtml(value) {
|
|
|
373
436
|
async function compile(file, sourceFiles, sourceIndex, base) {
|
|
374
437
|
const source = sourceIndex.get(file)
|
|
375
438
|
const nativeHandlers = []
|
|
439
|
+
const effectHandlers = []
|
|
376
440
|
const reactiveBindings = []
|
|
377
441
|
const listExpressions = []
|
|
378
442
|
const clientImports = new Set()
|
|
@@ -385,7 +449,7 @@ async function compile(file, sourceFiles, sourceIndex, base) {
|
|
|
385
449
|
jsx: ts.JsxEmit.ReactJSX,
|
|
386
450
|
jsxImportSource: "@kudzujs/core"
|
|
387
451
|
},
|
|
388
|
-
transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, sourceIndex, clientImports)] },
|
|
452
|
+
transformers: { before: [createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, sourceIndex, clientImports)] },
|
|
389
453
|
reportDiagnostics: true
|
|
390
454
|
})
|
|
391
455
|
|
|
@@ -398,10 +462,11 @@ async function compile(file, sourceFiles, sourceIndex, base) {
|
|
|
398
462
|
await mkdir(resolve(output, ".."), { recursive: true })
|
|
399
463
|
await writeFile(output, result.outputText)
|
|
400
464
|
|
|
401
|
-
if (!nativeHandlers.length && !reactiveBindings.length && !listExpressions.length) return undefined
|
|
465
|
+
if (!nativeHandlers.length && !effectHandlers.length && !reactiveBindings.length && !listExpressions.length) return undefined
|
|
466
|
+
const callbacks = [...nativeHandlers, ...effectHandlers]
|
|
402
467
|
const moduleSource = [
|
|
403
|
-
printClientImports(
|
|
404
|
-
...
|
|
468
|
+
printClientImports(callbacks.flatMap(handler => handler.imports), handlerPath),
|
|
469
|
+
...callbacks.map(handler => printNativeHandler(handler)),
|
|
405
470
|
...reactiveBindings.map(entry => printReactiveBinding(entry)),
|
|
406
471
|
...listExpressions.map(entry => printListExpression(entry))
|
|
407
472
|
].join("\n")
|
|
@@ -411,15 +476,16 @@ async function compile(file, sourceFiles, sourceIndex, base) {
|
|
|
411
476
|
})
|
|
412
477
|
const moduleErrors = moduleResult.diagnostics?.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) ?? []
|
|
413
478
|
if (moduleErrors.length) throw new Error(moduleErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
|
|
414
|
-
return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, clientImports: [...clientImports] }
|
|
479
|
+
return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, hasEffects: effectHandlers.length > 0, clientImports: [...clientImports] }
|
|
415
480
|
}
|
|
416
481
|
|
|
417
|
-
function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports) {
|
|
482
|
+
function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports) {
|
|
418
483
|
return context => sourceFile => {
|
|
419
484
|
const factory = context.factory
|
|
420
485
|
sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
|
|
421
486
|
ts.setParentRecursive(sourceFile, false)
|
|
422
487
|
const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
|
|
488
|
+
const hasUseEffectImport = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && statement.moduleSpecifier.text === "@kudzujs/core" && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useEffect"))
|
|
423
489
|
const importedSources = new Map()
|
|
424
490
|
const importedSource = target => {
|
|
425
491
|
let imported = importedSources.get(target)
|
|
@@ -628,6 +694,31 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
628
694
|
return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
|
|
629
695
|
}
|
|
630
696
|
|
|
697
|
+
if (hasUseEffectImport && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useEffect") {
|
|
698
|
+
if (node.arguments.length !== 2) fail(node, "useEffect() requires exactly a callback and literal empty dependency array")
|
|
699
|
+
const [callback, dependencies] = node.arguments
|
|
700
|
+
if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) fail(callback, "useEffect() callback must be an inline function")
|
|
701
|
+
if (ts.isFunctionExpression(callback) && callback.name) fail(callback, "useEffect() callback function must be anonymous")
|
|
702
|
+
if (callback.parameters.length) fail(callback, "useEffect() callback cannot declare parameters")
|
|
703
|
+
if (!ts.isArrayLiteralExpression(dependencies) || dependencies.elements.length) fail(dependencies, "useEffect() dependencies must be a literal empty array")
|
|
704
|
+
if (!nearestFunction(node)) fail(node, "useEffect() cannot be used outside a Kudzu component")
|
|
705
|
+
if (returnsCleanup(callback)) fail(callback, "useEffect() cleanup functions are not supported")
|
|
706
|
+
if (!ts.isBlock(callback.body)) fail(callback, "useEffect() callback must use a block body")
|
|
707
|
+
if (returnsEffectValue(callback)) fail(callback, "useEffect() return values are not supported")
|
|
708
|
+
const setters = settersForNode(node, settersByFunction)
|
|
709
|
+
const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, importBindings, clientImports, "effect", undefined, true)
|
|
710
|
+
usesBehavior = true
|
|
711
|
+
return factory.updateCallExpression(node, node.expression, node.typeArguments, [
|
|
712
|
+
callback,
|
|
713
|
+
dependencies,
|
|
714
|
+
factory.createStringLiteral(handlerUrl),
|
|
715
|
+
factory.createStringLiteral(descriptor.exportName),
|
|
716
|
+
descriptor.states,
|
|
717
|
+
descriptor.scope,
|
|
718
|
+
factory.createStringLiteral(sourceLocation(node, sourceFile))
|
|
719
|
+
])
|
|
720
|
+
}
|
|
721
|
+
|
|
631
722
|
if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && node.initializer.expression.text === "useState" && node.initializer.arguments.length === 1) {
|
|
632
723
|
const stateElement = node.name.elements[0]
|
|
633
724
|
if (!stateElement || !ts.isBindingElement(stateElement) || !ts.isIdentifier(stateElement.name)) return node
|
|
@@ -1273,26 +1364,37 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
|
|
|
1273
1364
|
const optimized = compileOptimizedEvent(expression, setters, factory)
|
|
1274
1365
|
if (optimized) return optimized
|
|
1275
1366
|
|
|
1367
|
+
const descriptor = compileNativeCallback(expression, setters, factory, nativeHandlers, importBindings, clientImports, "handler", listItem)
|
|
1368
|
+
return factory.createCallExpression(factory.createIdentifier("__kNativeBehavior"), undefined, [
|
|
1369
|
+
factory.createStringLiteral(handlerUrl),
|
|
1370
|
+
factory.createStringLiteral(descriptor.exportName),
|
|
1371
|
+
descriptor.states,
|
|
1372
|
+
descriptor.scope
|
|
1373
|
+
])
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function compileNativeCallback(expression, setters, factory, entries, importBindings, clientImports, prefix, listItem, deferValues = false) {
|
|
1276
1377
|
const allCaptures = nativeCaptureNames(expression, setters)
|
|
1277
1378
|
const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
|
|
1278
1379
|
const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
|
|
1279
1380
|
for (const entry of imports) clientImports.add(entry.target)
|
|
1280
1381
|
const usedStates = nativeStateNames(expression, setters)
|
|
1281
|
-
const exportName =
|
|
1282
|
-
|
|
1283
|
-
const
|
|
1284
|
-
factory.
|
|
1285
|
-
factory.createIdentifier(name)
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
factory.
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1382
|
+
const exportName = `${prefix}${entries.length}`
|
|
1383
|
+
entries.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))) })
|
|
1384
|
+
const value = name => deferValues
|
|
1385
|
+
? factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), factory.createIdentifier(name))
|
|
1386
|
+
: factory.createIdentifier(name)
|
|
1387
|
+
return {
|
|
1388
|
+
exportName,
|
|
1389
|
+
states: factory.createArrayLiteralExpression([...usedStates].map(name => factory.createArrayLiteralExpression([
|
|
1390
|
+
factory.createStringLiteral(name),
|
|
1391
|
+
value(name)
|
|
1392
|
+
]))),
|
|
1393
|
+
scope: factory.createArrayLiteralExpression([...captures].map(name => factory.createArrayLiteralExpression([
|
|
1292
1394
|
factory.createStringLiteral(name),
|
|
1293
|
-
name === listItem ? factory.createCallExpression(factory.createIdentifier("__kListItem"), undefined, []) :
|
|
1395
|
+
name === listItem ? factory.createCallExpression(factory.createIdentifier("__kListItem"), undefined, []) : value(name)
|
|
1294
1396
|
])))
|
|
1295
|
-
|
|
1397
|
+
}
|
|
1296
1398
|
}
|
|
1297
1399
|
|
|
1298
1400
|
function nativeStateNames(expression, setters) {
|
|
@@ -1303,7 +1405,8 @@ function referencedStateNames(root, setters, scopeRoot = root) {
|
|
|
1303
1405
|
const stateNames = new Set(setters.values())
|
|
1304
1406
|
const used = new Set()
|
|
1305
1407
|
const visit = node => {
|
|
1306
|
-
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text)) used.add(setters.get(node.expression.text))
|
|
1408
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text) && !isShadowedIdentifier(node.expression, scopeRoot)) used.add(setters.get(node.expression.text))
|
|
1409
|
+
if (ts.isIdentifier(node) && setters.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, scopeRoot)) used.add(setters.get(node.text))
|
|
1307
1410
|
if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, scopeRoot)) used.add(node.text)
|
|
1308
1411
|
ts.forEachChild(node, visit)
|
|
1309
1412
|
}
|
|
@@ -1342,20 +1445,22 @@ function referencedImportedBindings(expression, imports) {
|
|
|
1342
1445
|
|
|
1343
1446
|
function captureNames(declarationRoot, referenceRoot, setters) {
|
|
1344
1447
|
const local = new Set()
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1448
|
+
if (!isFunctionLike(declarationRoot)) {
|
|
1449
|
+
const collectDeclarations = node => {
|
|
1450
|
+
if (ts.isVariableDeclaration(node)) for (const name of bindingNames(node.name)) local.add(name)
|
|
1451
|
+
if (ts.isParameter(node)) for (const name of bindingNames(node.name)) local.add(name)
|
|
1452
|
+
if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) local.add(node.name.text)
|
|
1453
|
+
ts.forEachChild(node, collectDeclarations)
|
|
1454
|
+
}
|
|
1455
|
+
collectDeclarations(declarationRoot)
|
|
1350
1456
|
}
|
|
1351
|
-
collectDeclarations(declarationRoot)
|
|
1352
|
-
|
|
1353
1457
|
const stateNames = new Set(setters.values())
|
|
1354
1458
|
const captures = new Set()
|
|
1355
1459
|
const visit = node => {
|
|
1356
1460
|
if (ts.isTypeNode(node)) return
|
|
1357
|
-
if (ts.isIdentifier(node)
|
|
1358
|
-
|
|
1461
|
+
if (ts.isIdentifier(node)) {
|
|
1462
|
+
const declared = isFunctionLike(declarationRoot) ? isShadowedIdentifier(node, declarationRoot) : local.has(node.text)
|
|
1463
|
+
if (isReferenceIdentifier(node) && !declared && !setters.has(node.text) && !stateNames.has(node.text) && !nativeGlobals.has(node.text)) captures.add(node.text)
|
|
1359
1464
|
}
|
|
1360
1465
|
ts.forEachChild(node, visit)
|
|
1361
1466
|
}
|
|
@@ -1377,7 +1482,7 @@ function isReferenceIdentifier(node) {
|
|
|
1377
1482
|
(ts.isVariableDeclaration(parent) && parent.name === node) ||
|
|
1378
1483
|
(ts.isParameter(parent) && parent.name === node) ||
|
|
1379
1484
|
(ts.isFunctionDeclaration(parent) && parent.name === node) ||
|
|
1380
|
-
(ts.isBindingElement(parent) && parent.name === node) ||
|
|
1485
|
+
(ts.isBindingElement(parent) && (parent.name === node || parent.propertyName === node)) ||
|
|
1381
1486
|
ts.isImportSpecifier(parent) || ts.isImportClause(parent)) return false
|
|
1382
1487
|
return true
|
|
1383
1488
|
}
|
|
@@ -1397,6 +1502,43 @@ function isShadowedByParameter(node, scopeRoot) {
|
|
|
1397
1502
|
return false
|
|
1398
1503
|
}
|
|
1399
1504
|
|
|
1505
|
+
function isShadowedIdentifier(node, scopeRoot) {
|
|
1506
|
+
if (isShadowedByParameter(node, scopeRoot)) return true
|
|
1507
|
+
if (node === scopeRoot) return false
|
|
1508
|
+
if (isFunctionLike(scopeRoot) && scopeRoot.name?.text === node.text) return true
|
|
1509
|
+
if (isFunctionLike(scopeRoot) && functionVarDeclaresName(scopeRoot, node.text)) return true
|
|
1510
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
1511
|
+
if (current === scopeRoot) break
|
|
1512
|
+
if (ts.isBlock(current) && current.statements.some(statement => statementDeclaresName(statement, node.text))) return true
|
|
1513
|
+
if (ts.isCaseBlock(current) && current.clauses.some(clause => clause.statements.some(statement => statementDeclaresName(statement, node.text)))) return true
|
|
1514
|
+
if (ts.isCatchClause(current) && current.variableDeclaration && bindingNames(current.variableDeclaration.name).includes(node.text)) return true
|
|
1515
|
+
if ((ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current)) && loopDeclaresName(current, node.text)) return true
|
|
1516
|
+
if (isFunctionLike(current) && functionVarDeclaresName(current, node.text)) return true
|
|
1517
|
+
}
|
|
1518
|
+
return false
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function statementDeclaresName(statement, name) {
|
|
1522
|
+
if (ts.isVariableStatement(statement)) return statement.declarationList.declarations.some(declaration => bindingNames(declaration.name).includes(name))
|
|
1523
|
+
return (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name?.text === name
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function loopDeclaresName(loop, name) {
|
|
1527
|
+
const declaration = ts.isForStatement(loop) ? loop.initializer : loop.initializer
|
|
1528
|
+
return declaration && ts.isVariableDeclarationList(declaration) && declaration.declarations.some(entry => bindingNames(entry.name).includes(name))
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function functionVarDeclaresName(fn, name) {
|
|
1532
|
+
let found = false
|
|
1533
|
+
const visit = node => {
|
|
1534
|
+
if (found || node !== fn.body && isFunctionLike(node)) return
|
|
1535
|
+
if (ts.isVariableDeclarationList(node) && (node.flags & ts.NodeFlags.BlockScoped) === 0 && node.declarations.some(entry => bindingNames(entry.name).includes(name))) found = true
|
|
1536
|
+
if (!found) ts.forEachChild(node, visit)
|
|
1537
|
+
}
|
|
1538
|
+
if (fn.body) visit(fn.body)
|
|
1539
|
+
return found
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1400
1542
|
function settersForNode(node, settersByFunction) {
|
|
1401
1543
|
for (let current = node.parent; current; current = current.parent) {
|
|
1402
1544
|
if (!ts.isFunctionDeclaration(current) && !ts.isFunctionExpression(current) && !ts.isArrowFunction(current)) continue
|
|
@@ -1477,6 +1619,42 @@ function sourceNodeError(node, fallbackSource, message) {
|
|
|
1477
1619
|
return new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
|
|
1478
1620
|
}
|
|
1479
1621
|
|
|
1622
|
+
function sourceLocation(node, fallbackSource) {
|
|
1623
|
+
const original = ts.getOriginalNode(node)
|
|
1624
|
+
const sourceFile = original.getSourceFile?.()?.fileName ? original.getSourceFile() : fallbackSource
|
|
1625
|
+
const position = sourceFile.getLineAndCharacterOfPosition(original.getStart(sourceFile))
|
|
1626
|
+
return `${sourceFile.fileName}:${position.line + 1}:${position.character + 1}`
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
function returnsCleanup(callback) {
|
|
1630
|
+
if (ts.isArrowFunction(callback) && !ts.isBlock(callback.body)) {
|
|
1631
|
+
const body = unwrapExpression(callback.body)
|
|
1632
|
+
return ts.isArrowFunction(body) || ts.isFunctionExpression(body)
|
|
1633
|
+
}
|
|
1634
|
+
let found = false
|
|
1635
|
+
const visit = node => {
|
|
1636
|
+
if (found || node !== callback.body && isFunctionLike(node)) return
|
|
1637
|
+
if (ts.isReturnStatement(node) && node.expression) {
|
|
1638
|
+
const expression = unwrapExpression(node.expression)
|
|
1639
|
+
if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) found = true
|
|
1640
|
+
}
|
|
1641
|
+
if (!found) ts.forEachChild(node, visit)
|
|
1642
|
+
}
|
|
1643
|
+
visit(callback.body)
|
|
1644
|
+
return found
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
function returnsEffectValue(callback) {
|
|
1648
|
+
let found = false
|
|
1649
|
+
const visit = node => {
|
|
1650
|
+
if (found || node !== callback.body && isFunctionLike(node)) return
|
|
1651
|
+
if (ts.isReturnStatement(node) && node.expression) found = true
|
|
1652
|
+
if (!found) ts.forEachChild(node, visit)
|
|
1653
|
+
}
|
|
1654
|
+
visit(callback.body)
|
|
1655
|
+
return found
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1480
1658
|
function printClientImports(entries, handlerPath) {
|
|
1481
1659
|
const unique = new Map(entries.map(entry => [`${entry.target}:${entry.kind}:${entry.imported ?? ""}:${entry.local}`, entry]))
|
|
1482
1660
|
const groups = Map.groupBy(unique.values(), entry => entry.target)
|
|
@@ -1594,24 +1772,33 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
|
|
|
1594
1772
|
const stateNames = new Set(setters.values())
|
|
1595
1773
|
const transformer = context => root => {
|
|
1596
1774
|
const visitor = node => {
|
|
1597
|
-
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text)) {
|
|
1775
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text) && !isShadowedIdentifier(node.expression, expression)) {
|
|
1598
1776
|
return factory.createCallExpression(
|
|
1599
1777
|
factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "set"),
|
|
1600
1778
|
undefined,
|
|
1601
1779
|
[factory.createStringLiteral(setters.get(node.expression.text)), ...node.arguments.map(argument => ts.visitNode(argument, visitor))]
|
|
1602
1780
|
)
|
|
1603
1781
|
}
|
|
1604
|
-
if (ts.
|
|
1782
|
+
if (ts.isShorthandPropertyAssignment(node) && setters.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
|
|
1783
|
+
return factory.createPropertyAssignment(node.name, setterReference(factory, setters.get(node.name.text)))
|
|
1784
|
+
}
|
|
1785
|
+
if (ts.isIdentifier(node) && setters.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
|
|
1786
|
+
return setterReference(factory, setters.get(node.text))
|
|
1787
|
+
}
|
|
1788
|
+
if (ts.isShorthandPropertyAssignment(node) && stateNames.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
|
|
1789
|
+
return factory.createPropertyAssignment(node.name, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"), undefined, [factory.createStringLiteral(node.name.text)]))
|
|
1790
|
+
}
|
|
1791
|
+
if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
|
|
1605
1792
|
return factory.createCallExpression(
|
|
1606
1793
|
factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
|
|
1607
1794
|
undefined,
|
|
1608
1795
|
[factory.createStringLiteral(node.text)]
|
|
1609
1796
|
)
|
|
1610
1797
|
}
|
|
1611
|
-
if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text)) {
|
|
1798
|
+
if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
|
|
1612
1799
|
return factory.createPropertyAssignment(node.name, scopeRead(factory, node.name.text))
|
|
1613
1800
|
}
|
|
1614
|
-
if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node)) {
|
|
1801
|
+
if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
|
|
1615
1802
|
return scopeRead(factory, node.text)
|
|
1616
1803
|
}
|
|
1617
1804
|
return ts.visitEachChild(node, visitor, context)
|
|
@@ -1640,6 +1827,17 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
|
|
|
1640
1827
|
}
|
|
1641
1828
|
}
|
|
1642
1829
|
|
|
1830
|
+
function setterReference(factory, stateName) {
|
|
1831
|
+
return factory.createArrowFunction(
|
|
1832
|
+
undefined,
|
|
1833
|
+
undefined,
|
|
1834
|
+
[factory.createParameterDeclaration(undefined, undefined, "value")],
|
|
1835
|
+
undefined,
|
|
1836
|
+
factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
|
|
1837
|
+
factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "set"), undefined, [factory.createStringLiteral(stateName), factory.createIdentifier("value")])
|
|
1838
|
+
)
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1643
1841
|
function printReactiveBinding({ exportName, expression, captures, states }) {
|
|
1644
1842
|
const factory = ts.factory
|
|
1645
1843
|
const transformer = context => root => {
|
package/framework/core.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type StateSetter<T> = (value: T | ((previous: T) => T)) => void
|
|
2
2
|
|
|
3
3
|
export function useState<T>(initialValue: T): [T, StateSetter<T>]
|
|
4
|
+
export function useEffect(effect: () => void | Promise<void>, dependencies: readonly []): void
|
|
4
5
|
|
|
5
6
|
export interface RefObject<T> {
|
|
6
7
|
readonly current: T | null
|
|
@@ -46,11 +47,13 @@ export function renderPage<Props = Record<string, never>>(
|
|
|
46
47
|
manifest?: string
|
|
47
48
|
styles?: boolean | string[]
|
|
48
49
|
base?: string
|
|
50
|
+
effectAsset?: string
|
|
49
51
|
},
|
|
50
52
|
props?: Props
|
|
51
53
|
): Promise<{
|
|
52
54
|
html: string
|
|
53
55
|
hasBehaviors: boolean
|
|
56
|
+
hasEffects: boolean
|
|
54
57
|
hasBindings: boolean
|
|
55
58
|
hasLists: boolean
|
|
56
59
|
hasListStyles: boolean
|
|
@@ -62,6 +65,7 @@ export function renderPage<Props = Record<string, never>>(
|
|
|
62
65
|
commands?: Array<[string, string, unknown]>
|
|
63
66
|
native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
|
|
64
67
|
}>
|
|
68
|
+
effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }>
|
|
65
69
|
bindings: Array<{
|
|
66
70
|
target: string
|
|
67
71
|
state?: string
|
package/framework/core.mjs
CHANGED
|
@@ -44,6 +44,16 @@ export function useState(initialValue, name) {
|
|
|
44
44
|
return [signal, setter]
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
export function useEffect(callback, dependencies, module, handler, states, scope, source) {
|
|
48
|
+
if (!renderContext) throw new Error("useEffect() can only run while rendering a Kudzu component")
|
|
49
|
+
if (typeof callback !== "function" || !Array.isArray(dependencies) || dependencies.length || !module || !handler) {
|
|
50
|
+
throw new Error("useEffect() must be compiled with a literal empty dependency array")
|
|
51
|
+
}
|
|
52
|
+
renderContext.effects.push({ module, handler, states, scope, source })
|
|
53
|
+
renderContext.hasBehaviors = true
|
|
54
|
+
renderContext.hasEffects = true
|
|
55
|
+
}
|
|
56
|
+
|
|
47
57
|
export function useRef(initialValue) {
|
|
48
58
|
if (!renderContext) throw new Error("useRef() can only run while rendering a Kudzu component")
|
|
49
59
|
if (initialValue !== null) throw new Error("Kudzu DOM refs must initialize with null")
|
|
@@ -82,8 +92,14 @@ export function nativeBehavior(module, handler, states, scope) {
|
|
|
82
92
|
[nativeBehaviorMarker]: true,
|
|
83
93
|
module,
|
|
84
94
|
handler,
|
|
95
|
+
...nativeDescriptor(states, scope)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function nativeDescriptor(states, scope) {
|
|
100
|
+
return {
|
|
85
101
|
states: Object.fromEntries(states.map(([name, signal]) => {
|
|
86
|
-
if (!signal?.[signalMarker]) throw new Error("A native
|
|
102
|
+
if (!signal?.[signalMarker]) throw new Error("A native callback must target framework state")
|
|
87
103
|
return [name, signal.id]
|
|
88
104
|
})),
|
|
89
105
|
scope: Object.fromEntries(scope.map(([name, value]) => [name, value?.[signalMarker] ? { type: "state", id: value.id } : serializeCapture(name, value, new Set())]))
|
|
@@ -226,10 +242,21 @@ function serializeCapture(name, value, seen) {
|
|
|
226
242
|
}
|
|
227
243
|
|
|
228
244
|
export async function renderPage(component, metadata = {}, props = {}) {
|
|
229
|
-
renderContext = { nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], textBindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false, hasListStyles: false }
|
|
245
|
+
renderContext = { nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasBindings: false, hasLists: false, hasListStyles: false }
|
|
230
246
|
|
|
231
247
|
try {
|
|
232
248
|
const body = await renderNode({ type: component, props })
|
|
249
|
+
renderContext.effects = renderContext.effects.map(effect => {
|
|
250
|
+
try {
|
|
251
|
+
return {
|
|
252
|
+
module: effect.module,
|
|
253
|
+
handler: effect.handler,
|
|
254
|
+
...nativeDescriptor(effect.states.map(([name, read]) => [name, read()]), effect.scope.map(([name, read]) => [name, read()]))
|
|
255
|
+
}
|
|
256
|
+
} catch (error) {
|
|
257
|
+
throw new Error(`${effect.source} ${error.message}`)
|
|
258
|
+
}
|
|
259
|
+
})
|
|
233
260
|
const title = escapeHtml(metadata.title ?? "Kudzu")
|
|
234
261
|
const head = renderMetadata(metadata)
|
|
235
262
|
const styles = metadata.styles === false
|
|
@@ -247,6 +274,9 @@ export async function renderPage(component, metadata = {}, props = {}) {
|
|
|
247
274
|
const listRuntime = renderContext.hasLists
|
|
248
275
|
? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-list.js")}"></script>`
|
|
249
276
|
: ""
|
|
277
|
+
const effectRuntime = renderContext.hasEffects
|
|
278
|
+
? `<script type="module" src="${escapeAttribute(metadata.effectAsset)}"></script>`
|
|
279
|
+
: ""
|
|
250
280
|
const listStates = new Set(renderContext.lists.map(list => list.state))
|
|
251
281
|
const seededListStates = new Set(renderContext.lists.filter(list => list.seed && !renderContext.textStates.has(list.state) && !renderContext.conditionStates.has(list.state)).map(list => list.state))
|
|
252
282
|
const initialState = renderContext.hasBehaviors
|
|
@@ -263,8 +293,9 @@ export async function renderPage(component, metadata = {}, props = {}) {
|
|
|
263
293
|
: ""
|
|
264
294
|
|
|
265
295
|
return {
|
|
266
|
-
html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}</head><body${state}${textBindings}>${body}${runtime}${bindingRuntime}${listRuntime}${nativeRuntime}</body></html>`,
|
|
296
|
+
html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}</head><body${state}${textBindings}>${body}${runtime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}</body></html>`,
|
|
267
297
|
hasBehaviors: renderContext.hasBehaviors,
|
|
298
|
+
hasEffects: renderContext.hasEffects,
|
|
268
299
|
hasBindings: renderContext.hasBindings,
|
|
269
300
|
hasLists: renderContext.hasLists,
|
|
270
301
|
hasListStyles: renderContext.hasListStyles,
|
|
@@ -272,6 +303,7 @@ export async function renderPage(component, metadata = {}, props = {}) {
|
|
|
272
303
|
plan: {
|
|
273
304
|
states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
|
|
274
305
|
events: renderContext.events,
|
|
306
|
+
effects: renderContext.effects,
|
|
275
307
|
bindings: renderContext.bindings,
|
|
276
308
|
conditions: renderContext.conditions,
|
|
277
309
|
lists: renderContext.lists
|
|
@@ -552,6 +584,11 @@ async function renderList(node, namespace, selectValue) {
|
|
|
552
584
|
const template = await renderNode(node.render({}), namespace, selectValue)
|
|
553
585
|
if (template.includes("data-k-native-")) descriptor.mount = true
|
|
554
586
|
if (template.includes("data-k-list-condition")) descriptor.conditions = true
|
|
587
|
+
if (template.includes("data-k-list-text-end")) descriptor.textRanges = true
|
|
588
|
+
if (template.includes("data-k-list-attrs")) descriptor.attributes = true
|
|
589
|
+
if (template.includes("data-k-list-events")) descriptor.events = true
|
|
590
|
+
if (template.includes("data-k-list-expression=")) descriptor.expressions = true
|
|
591
|
+
if (template.includes("data-k-list-expression-attrs")) descriptor.expressionAttributes = true
|
|
555
592
|
const seed = listSeed(node.items.value, renderContext.listFields)
|
|
556
593
|
if (seed) descriptor.seed = seed
|
|
557
594
|
let current = ""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { deserialize } from "./serialization.js"
|
|
2
|
+
|
|
3
|
+
export function createEffectContext(state, stateIds, commit, serializedScope = {}) {
|
|
4
|
+
const changed = new Set()
|
|
5
|
+
let scheduled = false
|
|
6
|
+
|
|
7
|
+
const flush = () => {
|
|
8
|
+
scheduled = false
|
|
9
|
+
const ids = [...changed]
|
|
10
|
+
changed.clear()
|
|
11
|
+
for (const id of ids) commit(id, state.get(id))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const setId = (id, value) => {
|
|
15
|
+
const current = state.get(id)
|
|
16
|
+
state.set(id, typeof value === "function" ? value(current) : value)
|
|
17
|
+
changed.add(id)
|
|
18
|
+
if (!scheduled) {
|
|
19
|
+
scheduled = true
|
|
20
|
+
queueMicrotask(flush)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const scope = globalThis.__KUDZU_EFFECT_CAPTURES__
|
|
25
|
+
? Object.fromEntries(Object.entries(serializedScope).map(([name, value]) => [name, deserialize(value, id => state.get(id), globalThis.__KUDZU_CAPTURE_SETTER__ ? setId : undefined)]))
|
|
26
|
+
: undefined
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
get(name) {
|
|
30
|
+
return state.get(stateIds[name])
|
|
31
|
+
},
|
|
32
|
+
scope(name) {
|
|
33
|
+
return globalThis.__KUDZU_EFFECT_CAPTURES__ ? serializedScope[name]?.type === "state" ? state.get(serializedScope[name].id) : scope[name] : undefined
|
|
34
|
+
},
|
|
35
|
+
set(name, value) {
|
|
36
|
+
setId(stateIds[name], value)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -3,11 +3,11 @@ import { browserState, mountDom, registerCommitter, registerMountHook, registerU
|
|
|
3
3
|
const listTargets = new Map()
|
|
4
4
|
const listRegistrations = new WeakMap()
|
|
5
5
|
const mountedLists = new WeakSet()
|
|
6
|
-
const imports = new Map()
|
|
7
|
-
const revisions = new WeakMap()
|
|
6
|
+
const imports = __KUDZU_LIST_ASYNC_PARTS__ ? new Map() : undefined
|
|
7
|
+
const revisions = __KUDZU_LIST_ASYNC_PARTS__ ? new WeakMap() : undefined
|
|
8
8
|
const itemParts = new WeakMap()
|
|
9
|
-
const conditionOwners = new WeakMap()
|
|
10
|
-
const itemPartsSelector = `[data-k-list-text],[data-k-list-attrs],[data-k-list-events],[data-k-list-expression],[data-k-list-expression-attrs]${__KUDZU_LIST_CONDITIONS__ ? ",[data-k-list-condition]" : ""}`
|
|
9
|
+
const conditionOwners = __KUDZU_LIST_CONDITIONS__ ? new WeakMap() : undefined
|
|
10
|
+
const itemPartsSelector = `[data-k-list-text]${__KUDZU_LIST_ATTRIBUTES__ ? ",[data-k-list-attrs]" : ""}${__KUDZU_LIST_EVENTS__ ? ",[data-k-list-events]" : ""}${__KUDZU_LIST_EXPRESSIONS__ ? ",[data-k-list-expression]" : ""}${__KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? ",[data-k-list-expression-attrs]" : ""}${__KUDZU_LIST_CONDITIONS__ ? ",[data-k-list-condition]" : ""}`
|
|
11
11
|
|
|
12
12
|
function commitLists(id) {
|
|
13
13
|
const lists = listTargets.get(id)
|
|
@@ -34,13 +34,13 @@ function mountLists(root) {
|
|
|
34
34
|
const templateRoot = start.content.firstElementChild
|
|
35
35
|
const parts = listItemPartPlan(templateRoot)
|
|
36
36
|
for (const root of roots) __KUDZU_LIST_CONDITIONS__ && descriptor.conditions ? listItemParts(root) : mapListItemParts(parts, root)
|
|
37
|
-
if (descriptor.seed && !browserState.has(descriptor.state)) browserState.set(descriptor.state, roots.map((root, index) => seedListItem(root, descriptor, index)))
|
|
37
|
+
if (__KUDZU_LIST_SEEDS__ && descriptor.seed && !browserState.has(descriptor.state)) browserState.set(descriptor.state, roots.map((root, index) => seedListItem(root, descriptor, index)))
|
|
38
38
|
const items = browserState.get(descriptor.state)
|
|
39
39
|
const list = {
|
|
40
40
|
start,
|
|
41
41
|
descriptor,
|
|
42
42
|
parts,
|
|
43
|
-
seedFields: descriptor.seed && Object.keys(descriptor.seed),
|
|
43
|
+
seedFields: __KUDZU_LIST_SEEDS__ && descriptor.seed && Object.keys(descriptor.seed),
|
|
44
44
|
roots: new Map(roots.map((node, index) => [keyToken(descriptor.keys[index]), node])),
|
|
45
45
|
values: new Map(),
|
|
46
46
|
container: roots[0]?.parentNode,
|
|
@@ -77,7 +77,7 @@ function updateList(list) {
|
|
|
77
77
|
const key = item?.[list.descriptor.key]
|
|
78
78
|
if (!validListKey(key)) throw new Error(`Keyed list key "${list.descriptor.key}" must be a string or finite number`)
|
|
79
79
|
assertListItem(item)
|
|
80
|
-
const seededValue = list.seedFields && seededListValue(item, list.seedFields, list.descriptor.seed)
|
|
80
|
+
const seededValue = __KUDZU_LIST_SEEDS__ ? list.seedFields && seededListValue(item, list.seedFields, list.descriptor.seed) : undefined
|
|
81
81
|
if (seededValue === undefined) assertListValue(item, seen, true)
|
|
82
82
|
const token = keyToken(key)
|
|
83
83
|
if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
|
|
@@ -108,13 +108,13 @@ function updateList(list) {
|
|
|
108
108
|
}
|
|
109
109
|
for (const [token, node] of list.roots) {
|
|
110
110
|
if (keys.has(token)) continue
|
|
111
|
-
if (list.descriptor.mount) {
|
|
111
|
+
if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) {
|
|
112
112
|
unmountDom(node)
|
|
113
113
|
node.remove()
|
|
114
114
|
} else node.remove()
|
|
115
115
|
}
|
|
116
116
|
if (added) {
|
|
117
|
-
if (list.descriptor.mount) mountDom(additions)
|
|
117
|
+
if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) mountDom(additions)
|
|
118
118
|
parent.insertBefore(additions, list.boundary)
|
|
119
119
|
list.container ??= parent
|
|
120
120
|
}
|
|
@@ -142,8 +142,8 @@ function updateList(list) {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
function fillListItem(root, item) {
|
|
145
|
-
const revision = (revisions.get(root) ?? 0) + 1
|
|
146
|
-
revisions.set(root, revision)
|
|
145
|
+
const revision = __KUDZU_LIST_ASYNC_PARTS__ ? (revisions.get(root) ?? 0) + 1 : 0
|
|
146
|
+
if (__KUDZU_LIST_ASYNC_PARTS__) revisions.set(root, revision)
|
|
147
147
|
const parts = listItemParts(root)
|
|
148
148
|
fillListParts(root, parts, item, revision)
|
|
149
149
|
}
|
|
@@ -159,30 +159,38 @@ function fillListParts(root, parts, item, revision) {
|
|
|
159
159
|
node.textContent = value
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
|
-
|
|
163
|
-
patchListText(marker, "template[data-k-list-text-end]", item?.[field])
|
|
162
|
+
if (__KUDZU_LIST_TEXT_RANGES__) {
|
|
163
|
+
for (const [marker, field] of parts.texts) patchListText(marker, "template[data-k-list-text-end]", item?.[field])
|
|
164
164
|
}
|
|
165
|
-
|
|
166
|
-
for (const [
|
|
167
|
-
|
|
168
|
-
for (const [node, events] of parts.events) {
|
|
169
|
-
for (const [event, native] of JSON.parse(events)) {
|
|
170
|
-
native.scope = Object.fromEntries(Object.entries(native.scope).map(([name, value]) => [name, value?.type === "list-item" ? serializeItem(item) : value]))
|
|
171
|
-
node.dataset[`kNative${capitalize(event)}`] = JSON.stringify(native)
|
|
165
|
+
if (__KUDZU_LIST_ATTRIBUTES__) {
|
|
166
|
+
for (const [node, attributes] of parts.attributes) {
|
|
167
|
+
for (const [target, field] of attributes) patchBinding(node, target, item?.[field])
|
|
172
168
|
}
|
|
173
169
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
170
|
+
if (__KUDZU_LIST_EVENTS__) {
|
|
171
|
+
for (const [node, events] of parts.events) {
|
|
172
|
+
for (const [event, native] of JSON.parse(events)) {
|
|
173
|
+
native.scope = Object.fromEntries(Object.entries(native.scope).map(([name, value]) => [name, value?.type === "list-item" ? serializeItem(item) : value]))
|
|
174
|
+
node.dataset[`kNative${capitalize(event)}`] = JSON.stringify(native)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
178
177
|
}
|
|
179
|
-
|
|
180
|
-
for (const [
|
|
181
|
-
evaluate(
|
|
182
|
-
if (revisions.get(root) === revision && root.isConnected)
|
|
178
|
+
if (__KUDZU_LIST_EXPRESSIONS__) {
|
|
179
|
+
for (const [marker, descriptor] of parts.expressions) {
|
|
180
|
+
evaluate(descriptor, item).then(value => {
|
|
181
|
+
if (revisions.get(root) === revision && root.isConnected) patchListText(marker, "template[data-k-list-expression-end]", value)
|
|
183
182
|
}).catch(error => console.error(error))
|
|
184
183
|
}
|
|
185
184
|
}
|
|
185
|
+
if (__KUDZU_LIST_EXPRESSION_ATTRIBUTES__) {
|
|
186
|
+
for (const [node, attributes] of parts.expressionAttributes) {
|
|
187
|
+
for (const [target, module, handler] of attributes) {
|
|
188
|
+
evaluate({ module, handler }, item).then(value => {
|
|
189
|
+
if (revisions.get(root) === revision && root.isConnected) patchBinding(node, target, value)
|
|
190
|
+
}).catch(error => console.error(error))
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
186
194
|
if (__KUDZU_LIST_CONDITIONS__) {
|
|
187
195
|
for (const [marker, descriptor] of parts.conditions) {
|
|
188
196
|
evaluate(descriptor, item).then(value => {
|
|
@@ -198,10 +206,10 @@ function listItemParts(root) {
|
|
|
198
206
|
parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [], conditions: [] }
|
|
199
207
|
for (const node of matching(root, itemPartsSelector)) {
|
|
200
208
|
if (node.hasAttribute("data-k-list-text")) (node.tagName === "TEMPLATE" ? parts.texts : parts.directTexts).push([node, node.dataset.kListText])
|
|
201
|
-
if (node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
|
|
202
|
-
if (node.hasAttribute("data-k-list-events")) parts.events.push([node, node.dataset.kListEvents])
|
|
203
|
-
if (node.hasAttribute("data-k-list-expression")) parts.expressions.push([node, JSON.parse(node.dataset.kListExpression)])
|
|
204
|
-
if (node.hasAttribute("data-k-list-expression-attrs")) parts.expressionAttributes.push([node, JSON.parse(node.dataset.kListExpressionAttrs)])
|
|
209
|
+
if (__KUDZU_LIST_ATTRIBUTES__ && node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
|
|
210
|
+
if (__KUDZU_LIST_EVENTS__ && node.hasAttribute("data-k-list-events")) parts.events.push([node, node.dataset.kListEvents])
|
|
211
|
+
if (__KUDZU_LIST_EXPRESSIONS__ && node.hasAttribute("data-k-list-expression")) parts.expressions.push([node, JSON.parse(node.dataset.kListExpression)])
|
|
212
|
+
if (__KUDZU_LIST_EXPRESSION_ATTRIBUTES__ && node.hasAttribute("data-k-list-expression-attrs")) parts.expressionAttributes.push([node, JSON.parse(node.dataset.kListExpressionAttrs)])
|
|
205
213
|
if (__KUDZU_LIST_CONDITIONS__ && node.hasAttribute("data-k-list-condition")) {
|
|
206
214
|
parts.conditions.push([node, JSON.parse(node.dataset.kListCondition)])
|
|
207
215
|
conditionOwners.set(node, root)
|
|
@@ -217,11 +225,11 @@ function listItemPartPlan(template) {
|
|
|
217
225
|
const parts = listItemParts(template)
|
|
218
226
|
return {
|
|
219
227
|
directTexts: parts.directTexts.map(([node, field]) => [indexes.get(node), field]),
|
|
220
|
-
texts: parts.texts.map(([node, field]) => [indexes.get(node), field]),
|
|
221
|
-
attributes: parts.attributes.map(([node, attributes]) => [indexes.get(node), attributes]),
|
|
222
|
-
events: parts.events.map(([node, events]) => [indexes.get(node), events]),
|
|
223
|
-
expressions: parts.expressions.map(([node, descriptor]) => [indexes.get(node), descriptor]),
|
|
224
|
-
expressionAttributes: parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes]),
|
|
228
|
+
texts: __KUDZU_LIST_TEXT_RANGES__ ? parts.texts.map(([node, field]) => [indexes.get(node), field]) : [],
|
|
229
|
+
attributes: __KUDZU_LIST_ATTRIBUTES__ ? parts.attributes.map(([node, attributes]) => [indexes.get(node), attributes]) : [],
|
|
230
|
+
events: __KUDZU_LIST_EVENTS__ ? parts.events.map(([node, events]) => [indexes.get(node), events]) : [],
|
|
231
|
+
expressions: __KUDZU_LIST_EXPRESSIONS__ ? parts.expressions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : [],
|
|
232
|
+
expressionAttributes: __KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes]) : [],
|
|
225
233
|
conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : []
|
|
226
234
|
}
|
|
227
235
|
}
|
|
@@ -230,11 +238,11 @@ function mapListItemParts(parts, root) {
|
|
|
230
238
|
const target = [root, ...root.querySelectorAll("*")]
|
|
231
239
|
itemParts.set(root, {
|
|
232
240
|
directTexts: parts.directTexts.map(([index, field]) => [target[index], field]),
|
|
233
|
-
texts: parts.texts.map(([index, field]) => [target[index], field]),
|
|
234
|
-
attributes: parts.attributes.map(([index, attributes]) => [target[index], attributes]),
|
|
235
|
-
events: parts.events.map(([index, events]) => [target[index], events]),
|
|
236
|
-
expressions: parts.expressions.map(([index, descriptor]) => [target[index], descriptor]),
|
|
237
|
-
expressionAttributes: parts.expressionAttributes.map(([index, attributes]) => [target[index], attributes]),
|
|
241
|
+
texts: __KUDZU_LIST_TEXT_RANGES__ ? parts.texts.map(([index, field]) => [target[index], field]) : [],
|
|
242
|
+
attributes: __KUDZU_LIST_ATTRIBUTES__ ? parts.attributes.map(([index, attributes]) => [target[index], attributes]) : [],
|
|
243
|
+
events: __KUDZU_LIST_EVENTS__ ? parts.events.map(([index, events]) => [target[index], events]) : [],
|
|
244
|
+
expressions: __KUDZU_LIST_EXPRESSIONS__ ? parts.expressions.map(([index, descriptor]) => [target[index], descriptor]) : [],
|
|
245
|
+
expressionAttributes: __KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? parts.expressionAttributes.map(([index, attributes]) => [target[index], attributes]) : [],
|
|
238
246
|
conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([index, descriptor]) => {
|
|
239
247
|
conditionOwners.set(target[index], root)
|
|
240
248
|
return [target[index], descriptor]
|