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