@kudzujs/core 0.5.10 → 0.6.0
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/GOAL_A.md +175 -0
- package/README.md +69 -7
- package/framework/README.md +6 -1
- package/framework/build.mjs +510 -28
- package/framework/core.d.ts +7 -3
- package/framework/core.mjs +100 -29
- package/framework/effect-runtime.js +3 -1
- package/framework/list-runtime.js +13 -6
- package/framework/navigation-runtime.js +220 -0
- package/package.json +2 -1
package/framework/build.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createServer } from "node:http"
|
|
2
|
-
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
3
3
|
import { cp, mkdir, readFile, readdir, rm, stat, watch, writeFile } from "node:fs/promises"
|
|
4
4
|
import { dirname, extname, join, relative, resolve, sep } from "node:path"
|
|
5
5
|
import { pathToFileURL } from "node:url"
|
|
@@ -20,6 +20,14 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
20
20
|
const config = await loadConfig()
|
|
21
21
|
const base = normalizeBase(config.base)
|
|
22
22
|
const configuredStyles = normalizeStyles(config.styles, base)
|
|
23
|
+
const navigationRoutes = normalizeNavigation(config.navigation)
|
|
24
|
+
const navigationSet = new Set(navigationRoutes)
|
|
25
|
+
const browserNavigationRoutes = navigationRoutes.map(route => withBase(base, route))
|
|
26
|
+
const navigationAsset = assetPath(base, "assets/kudzu-navigation.js")
|
|
27
|
+
const navigationId = navigationRoutes.length ? createHash("sha256").update(JSON.stringify([...navigationRoutes].sort())).digest("hex").slice(0, 16) : undefined
|
|
28
|
+
const applicationId = navigationId ? `a-${navigationId}` : undefined
|
|
29
|
+
const layoutId = navigationId ? `l-${navigationId}` : undefined
|
|
30
|
+
let navigationLayout
|
|
23
31
|
await rm(workDirectory, { recursive: true, force: true })
|
|
24
32
|
await rm(outputDirectory, { recursive: true, force: true })
|
|
25
33
|
await mkdir(workDirectory, { recursive: true })
|
|
@@ -54,6 +62,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
54
62
|
const paramEntries = []
|
|
55
63
|
const rewrites = []
|
|
56
64
|
const emittedRoutes = new Set()
|
|
65
|
+
const emittedApplicationRoutes = new Set()
|
|
57
66
|
const styleUrls = [...new Set([
|
|
58
67
|
...cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)),
|
|
59
68
|
...configuredStyles
|
|
@@ -64,6 +73,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
64
73
|
const compiledFile = compiledPath(pageFile)
|
|
65
74
|
const module = await import(`${pathToFileURL(compiledFile).href}?v=${Date.now()}`)
|
|
66
75
|
if (typeof module.default !== "function") throw new Error(`${relative(root, pageFile)} must export a default component`)
|
|
76
|
+
if (Object.hasOwn(module, "layout") && typeof module.layout !== "function") throw layoutExportError(pageFile, sourceIndex.get(pageFile))
|
|
67
77
|
|
|
68
78
|
const runtimeSchema = runtimeRouteSchema(module, pageFile)
|
|
69
79
|
if (runtimeSchema) {
|
|
@@ -80,11 +90,20 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
80
90
|
const entries = runtimeSchema ? [{ params: {}, props: {} }] : await staticPathEntries(module, pageFile)
|
|
81
91
|
for (const { params, props } of entries) {
|
|
82
92
|
const route = runtimeSchema?.route ?? routeFromPage(pageFile, params)
|
|
93
|
+
const applicationRoute = `/${route}`
|
|
83
94
|
const routePath = withBase(base, `/${route}`)
|
|
95
|
+
const navigable = navigationSet.has(applicationRoute)
|
|
84
96
|
const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
|
|
85
97
|
const paramPath = `params/${route}/index.js`
|
|
86
98
|
if (emittedRoutes.has(routePath)) throw new Error(`Duplicate route: ${routePath}`)
|
|
87
99
|
emittedRoutes.add(routePath)
|
|
100
|
+
emittedApplicationRoutes.add(applicationRoute)
|
|
101
|
+
if (navigable) {
|
|
102
|
+
if (runtimeSchema) throw new Error(`kudzu.config navigation route ${JSON.stringify(routePath)} must be an exact static route; runtime bracket routes are not supported`)
|
|
103
|
+
if (typeof module.layout !== "function") throw new Error(`kudzu.config navigation route ${JSON.stringify(routePath)} must export a layout function so Kudzu can emit route markers`)
|
|
104
|
+
if (navigationLayout && navigationLayout !== module.layout) throw new Error("kudzu.config navigation routes must export the same layout function identity")
|
|
105
|
+
navigationLayout = module.layout
|
|
106
|
+
}
|
|
88
107
|
const result = await renderPage(module.default, {
|
|
89
108
|
...(module.metadata ?? {}),
|
|
90
109
|
styles: styleUrls.length ? styleUrls : false,
|
|
@@ -92,14 +111,15 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
92
111
|
runtimeAsset: runtimePlaceholder,
|
|
93
112
|
effectAsset: assetPath(base, `assets/${effectPath}`),
|
|
94
113
|
paramAsset: assetPath(base, `assets/${paramPath}`),
|
|
95
|
-
runtimeParams: runtimeSchema?.params
|
|
96
|
-
|
|
114
|
+
runtimeParams: runtimeSchema?.params,
|
|
115
|
+
...(navigable ? { navigationAsset, applicationId, layoutId } : {})
|
|
116
|
+
}, props, module.layout)
|
|
97
117
|
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)
|
|
118
|
+
const usesDependencyRuntime = !navigable && hasDependencies && !result.plan.effects.some(effect => effect.owner) && !result.hasBindings && !result.hasLists && !result.plan.events.some(event => event.native)
|
|
99
119
|
pageEntries.push({ route, html: result.html, usesDependencyRuntime })
|
|
100
120
|
plans.push({ route: routePath, ...result.plan })
|
|
101
121
|
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 })
|
|
122
|
+
if (result.hasEffects) effectEntries.push({ path: effectPath, effects: runtimeEffects(result.plan.effects, navigable), paramPath: result.hasParams ? paramPath : undefined, usesDependencyRuntime, navigable })
|
|
103
123
|
if (result.hasBehaviors) {
|
|
104
124
|
behaviorCount++
|
|
105
125
|
if (!usesDependencyRuntime) regularBehaviorCount++
|
|
@@ -114,6 +134,8 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
114
134
|
}
|
|
115
135
|
}
|
|
116
136
|
|
|
137
|
+
for (const route of navigationRoutes) if (!emittedApplicationRoutes.has(route)) throw new Error(`kudzu.config navigation route ${JSON.stringify(route)} is not an exact emitted route`)
|
|
138
|
+
|
|
117
139
|
const assetsDirectory = join(outputDirectory, "assets")
|
|
118
140
|
await mkdir(assetsDirectory, { recursive: true })
|
|
119
141
|
const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
|
|
@@ -126,6 +148,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
126
148
|
const hasListExpressions = plans.some(plan => plan.lists.some(list => list.expressions))
|
|
127
149
|
const hasListExpressionAttributes = plans.some(plan => plan.lists.some(list => list.expressionAttributes))
|
|
128
150
|
const hasListSeeds = plans.some(plan => plan.lists.some(list => list.seed))
|
|
151
|
+
const hasListEffects = plans.some(plan => plan.lists.some(list => list.effects))
|
|
129
152
|
const hasListAsyncParts = hasListExpressions || hasListExpressionAttributes || hasListConditions
|
|
130
153
|
const hasListMounts = hasListConditions || plans.some(plan => plan.lists.some(list => list.mount))
|
|
131
154
|
const hasNestedStateCaptures = hasNestedCaptureState(plans)
|
|
@@ -134,7 +157,8 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
134
157
|
const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
|
|
135
158
|
const hasNativeHandlers = nativeModules.length > 0
|
|
136
159
|
const hasEffects = effectEntries.length > 0
|
|
137
|
-
const
|
|
160
|
+
const hasNavigableEffects = effectEntries.some(entry => entry.navigable)
|
|
161
|
+
const hasSharedRuntime = bindingCount || listCount || hasNativeHandlers || navigationRoutes.length
|
|
138
162
|
const hasDependencyRuntime = pageEntries.some(entry => entry.usesDependencyRuntime)
|
|
139
163
|
const runtimeName = usesDependencyRuntime => usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
|
|
140
164
|
for (const entry of pageEntries) {
|
|
@@ -143,9 +167,10 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
143
167
|
const html = entry.html.replace(runtimePlaceholder, escapeAttribute(assetPath(base, `assets/${runtimeName(entry.usesDependencyRuntime)}`)))
|
|
144
168
|
await writeFile(join(routeDirectory, "index.html"), html)
|
|
145
169
|
}
|
|
146
|
-
if (behaviorCount && (hasSharedRuntime || regularBehaviorCount)) {
|
|
170
|
+
if (navigationRoutes.length || behaviorCount && (hasSharedRuntime || regularBehaviorCount)) {
|
|
147
171
|
const runtimeFile = hasSharedRuntime ? "./shared-runtime.js" : "./runtime.js"
|
|
148
|
-
|
|
172
|
+
let runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, regularStateSeedCount > 0)
|
|
173
|
+
if (hasNavigableEffects) runtime = runtime.replace("export function registerCommitter(commit) {\n committers.push(commit)\n}", "export function registerCommitter(commit) {\n committers.push(commit)\n return () => {\n const index = committers.indexOf(commit)\n if (index !== -1) committers.splice(index, 1)\n }\n}")
|
|
149
174
|
await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
|
|
150
175
|
}
|
|
151
176
|
if (hasDependencyRuntime) {
|
|
@@ -166,10 +191,11 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
166
191
|
}
|
|
167
192
|
if (bindingCount || listStyleCount) await writeJavaScript(join(assetsDirectory, "kudzu-style.js"), await readFile(new URL("./style.js", import.meta.url), "utf8"), minify)
|
|
168
193
|
if (bindingCount) {
|
|
169
|
-
|
|
194
|
+
let bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
|
|
170
195
|
.replace('"./shared-runtime.js"', '"./kudzu.js"')
|
|
171
196
|
.replace('"./serialization.js"', '"./kudzu-serialization.js"')
|
|
172
197
|
.replace('"./style.js"', '"./kudzu-style.js"')
|
|
198
|
+
if (navigationRoutes.length) bindingRuntime = specializeNavigationTextDescriptors(bindingRuntime)
|
|
173
199
|
await writeBundledJavaScript(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime, minify, {
|
|
174
200
|
"globalThis.__KUDZU_TEXT_BINDINGS__": String(hasTextBindings),
|
|
175
201
|
"globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures)
|
|
@@ -194,6 +220,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
194
220
|
__KUDZU_LIST_EXPRESSIONS__: String(hasListExpressions),
|
|
195
221
|
__KUDZU_LIST_EXPRESSION_ATTRIBUTES__: String(hasListExpressionAttributes),
|
|
196
222
|
__KUDZU_LIST_SEEDS__: String(hasListSeeds),
|
|
223
|
+
__KUDZU_LIST_EFFECTS__: String(hasListEffects),
|
|
197
224
|
__KUDZU_LIST_ASYNC_PARTS__: String(hasListAsyncParts),
|
|
198
225
|
__KUDZU_LIST_MOUNTS__: String(hasListMounts)
|
|
199
226
|
})
|
|
@@ -206,6 +233,14 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
206
233
|
"globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
|
|
207
234
|
})
|
|
208
235
|
}
|
|
236
|
+
if (navigationRoutes.length) {
|
|
237
|
+
const navigationRuntime = (await readFile(new URL("./navigation-runtime.js", import.meta.url), "utf8"))
|
|
238
|
+
.replace("__KUDZU_NAVIGATION_ROUTES__", inlineJson(browserNavigationRoutes))
|
|
239
|
+
.replace("__KUDZU_APPLICATION_ID__", JSON.stringify(applicationId))
|
|
240
|
+
.replace("__KUDZU_LAYOUT_ID__", JSON.stringify(layoutId))
|
|
241
|
+
.replace('"./shared-runtime.js"', '"./kudzu.js"')
|
|
242
|
+
await writeJavaScript(join(assetsDirectory, "kudzu-navigation.js"), specializeNavigationEffects(navigationRuntime, hasNavigableEffects), minify)
|
|
243
|
+
}
|
|
209
244
|
for (const handlerModule of handlerModules) {
|
|
210
245
|
const output = join(assetsDirectory, handlerModule.path)
|
|
211
246
|
await mkdir(resolve(output, ".."), { recursive: true })
|
|
@@ -219,7 +254,9 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
219
254
|
for (const entry of effectEntries) {
|
|
220
255
|
const output = join(assetsDirectory, entry.path)
|
|
221
256
|
await mkdir(dirname(output), { recursive: true })
|
|
222
|
-
await writeJavaScript(output,
|
|
257
|
+
await writeJavaScript(output, entry.navigable
|
|
258
|
+
? printNavigableEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base)
|
|
259
|
+
: printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath, runtimeName(entry.usesDependencyRuntime)), minify)
|
|
223
260
|
}
|
|
224
261
|
const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
|
|
225
262
|
for (const file of clientModules) {
|
|
@@ -265,6 +302,43 @@ function specializeEvents(source, events) {
|
|
|
265
302
|
return source.replace(/const eventNames = \[[^\n]+\]/, `const eventNames = ${JSON.stringify(events)}`)
|
|
266
303
|
}
|
|
267
304
|
|
|
305
|
+
function specializeNavigationEffects(source, enabled) {
|
|
306
|
+
if (enabled) return source
|
|
307
|
+
return source
|
|
308
|
+
.replace("const noDispose = async () => {}\nlet routeDispose = noDispose\nlet layoutDispose = noDispose\nconst ready = mountInitial()\n", "")
|
|
309
|
+
.replace(`addEventListener("pagehide", event => {
|
|
310
|
+
if (event.persisted) return
|
|
311
|
+
++revision
|
|
312
|
+
request?.abort()
|
|
313
|
+
void (async () => {
|
|
314
|
+
await routeDispose()
|
|
315
|
+
await layoutDispose()
|
|
316
|
+
})()
|
|
317
|
+
})
|
|
318
|
+
`, "")
|
|
319
|
+
.replace(`
|
|
320
|
+
async function mountInitial() {
|
|
321
|
+
try {
|
|
322
|
+
const effects = await loadCapabilities(validate(document))
|
|
323
|
+
layoutDispose = await effects?.mountLayoutEffects?.() ?? noDispose
|
|
324
|
+
routeDispose = await effects?.mountRouteEffects?.() ?? noDispose
|
|
325
|
+
} catch (error) {
|
|
326
|
+
console.error(error)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
`, "")
|
|
330
|
+
.replace(" await ready\n", "")
|
|
331
|
+
.replace(" const effects = await loadCapabilities(parsed)\n", " await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))\n")
|
|
332
|
+
.replace(" await routeDispose()\n if (current !== revision) return\n", "")
|
|
333
|
+
.replace(" routeDispose = await effects?.mountRouteEffects?.() ?? noDispose\n", "")
|
|
334
|
+
.replace(`
|
|
335
|
+
async function loadCapabilities(parsed) {
|
|
336
|
+
const modules = await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))
|
|
337
|
+
return modules.find(module => typeof module.mountRouteEffects === "function")
|
|
338
|
+
}
|
|
339
|
+
`, "")
|
|
340
|
+
}
|
|
341
|
+
|
|
268
342
|
function specializeNativeRuntime(source, events, modules) {
|
|
269
343
|
const imports = modules.map((module, index) => `import * as __kNativeModule${index} from ${JSON.stringify(module)}`).join("\n")
|
|
270
344
|
const entries = modules.map((module, index) => `[${JSON.stringify(module)}, __kNativeModule${index}]`).join(",")
|
|
@@ -274,6 +348,7 @@ function specializeNativeRuntime(source, events, modules) {
|
|
|
274
348
|
function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath, runtimeName) {
|
|
275
349
|
const hasCleanup = effects.some(effect => effect.cleanup)
|
|
276
350
|
const hasDependencies = effects.some(effect => effect.dependencies?.length)
|
|
351
|
+
const hasOwners = effects.some(effect => effect.owner)
|
|
277
352
|
const moduleUrls = [...new Set(effects.map(effect => effect.module))]
|
|
278
353
|
const modules = moduleUrls.map(url => {
|
|
279
354
|
const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
|
|
@@ -281,7 +356,7 @@ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base
|
|
|
281
356
|
return module
|
|
282
357
|
})
|
|
283
358
|
const imports = [
|
|
284
|
-
hasCleanup || hasDependencies
|
|
359
|
+
hasCleanup || hasDependencies || hasOwners
|
|
285
360
|
? `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}\nconst { browserState, commitDom } = __kRuntime`
|
|
286
361
|
: `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}`,
|
|
287
362
|
`import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
|
|
@@ -289,6 +364,7 @@ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base
|
|
|
289
364
|
...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
|
|
290
365
|
]
|
|
291
366
|
const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
|
|
367
|
+
if (hasOwners) return printOwnedEffectEntry(imports, effects, entries)
|
|
292
368
|
if (effects.length === 1 && effects[0].dependencies?.length === 1) return printSingleDependencyEffect(imports, effects[0], hasCleanup)
|
|
293
369
|
const disposal = hasCleanup ? `
|
|
294
370
|
let disposed = false
|
|
@@ -299,6 +375,7 @@ const dispose = root => {
|
|
|
299
375
|
pending.clear()
|
|
300
376
|
for (const record of records) invokeCleanup(record)
|
|
301
377
|
}
|
|
378
|
+
|
|
302
379
|
if (__kRuntime.registerUnmountHook) __kRuntime.registerUnmountHook(dispose)
|
|
303
380
|
addEventListener("pagehide", event => {
|
|
304
381
|
if (event.persisted) return
|
|
@@ -438,6 +515,325 @@ addEventListener("pagehide", event => {
|
|
|
438
515
|
})`
|
|
439
516
|
}
|
|
440
517
|
|
|
518
|
+
function printNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, base) {
|
|
519
|
+
const moduleUrls = [...new Set(effects.map(effect => effect.module))]
|
|
520
|
+
const modules = moduleUrls.map(url => {
|
|
521
|
+
const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
|
|
522
|
+
if (!module) throw new Error(`Effect handler module was not emitted: ${url}`)
|
|
523
|
+
return module
|
|
524
|
+
})
|
|
525
|
+
const imports = [
|
|
526
|
+
`import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
|
|
527
|
+
`import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
|
|
528
|
+
...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
|
|
529
|
+
]
|
|
530
|
+
const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
|
|
531
|
+
return `${imports.join("\n")}
|
|
532
|
+
const effects = ${inlineJson(effects)}
|
|
533
|
+
const modules = new Map([${entries}])
|
|
534
|
+
export const mountLayoutEffects = () => mount("layout")
|
|
535
|
+
export const mountRouteEffects = () => mount("route")
|
|
536
|
+
function mount(lifetime) {
|
|
537
|
+
let active = true
|
|
538
|
+
let flushing
|
|
539
|
+
const records = effects.filter(effect => effect.lifetime === lifetime).map((effect, index) => ({ effect, index, values: undefined, cleanup: undefined, token: undefined }))
|
|
540
|
+
const dependencies = new Map()
|
|
541
|
+
const pending = new Set()
|
|
542
|
+
let scheduled = false
|
|
543
|
+
for (const record of records) for (const id of record.effect.dependencies ?? []) {
|
|
544
|
+
const subscribers = dependencies.get(id) ?? new Set()
|
|
545
|
+
subscribers.add(record)
|
|
546
|
+
dependencies.set(id, subscribers)
|
|
547
|
+
}
|
|
548
|
+
const unsubscribe = dependencies.size ? __kRuntime.registerCommitter(id => {
|
|
549
|
+
if (!active) return
|
|
550
|
+
for (const record of dependencies.get(id) ?? []) pending.add(record)
|
|
551
|
+
if (pending.size && !scheduled && !flushing) {
|
|
552
|
+
scheduled = true
|
|
553
|
+
queueMicrotask(flush)
|
|
554
|
+
}
|
|
555
|
+
}) : undefined
|
|
556
|
+
for (const record of records) {
|
|
557
|
+
try {
|
|
558
|
+
record.values = readDependencies(record)
|
|
559
|
+
invoke(record)
|
|
560
|
+
} catch (error) {
|
|
561
|
+
console.error(error)
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
async function flush() {
|
|
565
|
+
scheduled = false
|
|
566
|
+
if (!active) return pending.clear()
|
|
567
|
+
const operation = (async () => {
|
|
568
|
+
const selected = [...pending].sort((left, right) => left.index - right.index)
|
|
569
|
+
pending.clear()
|
|
570
|
+
const changed = []
|
|
571
|
+
for (const record of selected) {
|
|
572
|
+
try {
|
|
573
|
+
const values = readDependencies(record)
|
|
574
|
+
if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) {
|
|
575
|
+
record.values = values
|
|
576
|
+
changed.push(record)
|
|
577
|
+
}
|
|
578
|
+
} catch (error) {
|
|
579
|
+
console.error(error)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
for (const record of changed) await cleanup(record)
|
|
583
|
+
if (active) for (const record of changed) invoke(record)
|
|
584
|
+
})()
|
|
585
|
+
flushing = operation
|
|
586
|
+
try { await operation } finally {
|
|
587
|
+
if (flushing === operation) flushing = undefined
|
|
588
|
+
if (active && pending.size && !scheduled) {
|
|
589
|
+
scheduled = true
|
|
590
|
+
queueMicrotask(flush)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function readDependencies(record) {
|
|
595
|
+
return (record.effect.dependencies ?? []).map(id => {
|
|
596
|
+
const value = __kRuntime.browserState.get(id)
|
|
597
|
+
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")
|
|
598
|
+
return value
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
function invoke(record) {
|
|
602
|
+
const token = { active: true }
|
|
603
|
+
record.token = token
|
|
604
|
+
try {
|
|
605
|
+
const effect = record.effect
|
|
606
|
+
const result = modules.get(effect.module)[effect.handler](createEffectContext(__kRuntime.browserState, effect.states, __kRuntime.commitDom, effect.scope, () => active && token.active && record.token === token))
|
|
607
|
+
if (effect.cleanup && typeof result === "function") record.cleanup = result
|
|
608
|
+
else if (result && typeof result.then === "function") result.catch(error => console.error(error))
|
|
609
|
+
} catch (error) {
|
|
610
|
+
console.error(error)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
async function cleanup(record) {
|
|
614
|
+
if (record.token) record.token.active = false
|
|
615
|
+
record.token = undefined
|
|
616
|
+
const current = record.cleanup
|
|
617
|
+
record.cleanup = undefined
|
|
618
|
+
if (!current) return
|
|
619
|
+
try { await current() } catch (error) { console.error(error) }
|
|
620
|
+
}
|
|
621
|
+
let disposal
|
|
622
|
+
return async function dispose() {
|
|
623
|
+
if (disposal) return disposal
|
|
624
|
+
disposal = (async () => {
|
|
625
|
+
active = false
|
|
626
|
+
unsubscribe?.()
|
|
627
|
+
pending.clear()
|
|
628
|
+
for (const record of records) if (record.token) record.token.active = false
|
|
629
|
+
if (flushing) await flushing
|
|
630
|
+
for (const record of records) await cleanup(record)
|
|
631
|
+
})()
|
|
632
|
+
return disposal
|
|
633
|
+
}
|
|
634
|
+
}`
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function runtimeEffects(effects, lifetimes = false) {
|
|
638
|
+
return effects.map(effect => ({
|
|
639
|
+
module: effect.module,
|
|
640
|
+
handler: effect.handler,
|
|
641
|
+
...(effect.dependencies ? { dependencies: effect.dependencies } : {}),
|
|
642
|
+
...(effect.cleanup ? { cleanup: true } : {}),
|
|
643
|
+
...(effect.owner ? { owner: effect.owner } : {}),
|
|
644
|
+
...(effect.list ? { list: true } : {}),
|
|
645
|
+
...(lifetimes && effect.lifetime ? { lifetime: effect.lifetime } : {}),
|
|
646
|
+
states: effect.states,
|
|
647
|
+
scope: effect.scope
|
|
648
|
+
}))
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function printOwnedEffectEntry(imports, effects, entries) {
|
|
652
|
+
return `${imports.join("\n")}
|
|
653
|
+
const effects = ${inlineJson(effects)}
|
|
654
|
+
const modules = new Map([${entries}])
|
|
655
|
+
const records = effects.map((effect, index) => effect.list ? undefined : createRecord(effect, index)).filter(Boolean)
|
|
656
|
+
const listTemplates = new Map(effects.map((effect, index) => effect.list ? [effect.owner, { effect, index }] : undefined).filter(Boolean))
|
|
657
|
+
const owners = new Map(records.filter(record => record.effect.owner).map(record => [record.effect.owner, record]))
|
|
658
|
+
const listRegistrations = new WeakMap()
|
|
659
|
+
const mountedRecords = new Set(records.filter(record => record.mounted))
|
|
660
|
+
const dependencies = new Map()
|
|
661
|
+
const pending = new Set()
|
|
662
|
+
let scheduled = false
|
|
663
|
+
let flushing = false
|
|
664
|
+
let active = true
|
|
665
|
+
for (const record of records) registerDependencies(record)
|
|
666
|
+
function createRecord(effect, index) {
|
|
667
|
+
return { effect, index, mounted: !effect.owner, marker: undefined, version: 0, values: undefined, cleanup: undefined, disposal: undefined }
|
|
668
|
+
}
|
|
669
|
+
function registerDependencies(record) {
|
|
670
|
+
for (const id of record.effect.dependencies ?? []) {
|
|
671
|
+
const subscribers = dependencies.get(id) ?? new Set()
|
|
672
|
+
subscribers.add(record)
|
|
673
|
+
dependencies.set(id, subscribers)
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
function unregisterDependencies(record) {
|
|
677
|
+
for (const id of record.effect.dependencies ?? []) {
|
|
678
|
+
const subscribers = dependencies.get(id)
|
|
679
|
+
subscribers?.delete(record)
|
|
680
|
+
if (!subscribers?.size) dependencies.delete(id)
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
__kRuntime.registerCommitter(id => {
|
|
684
|
+
if (!active) return
|
|
685
|
+
for (const record of dependencies.get(id) ?? []) if (record.mounted) pending.add(record)
|
|
686
|
+
schedule()
|
|
687
|
+
})
|
|
688
|
+
__kRuntime.registerMountHook(root => {
|
|
689
|
+
if (!active) return
|
|
690
|
+
for (const marker of matching(root)) {
|
|
691
|
+
if (marker.dataset.kEffects) {
|
|
692
|
+
if (listRegistrations.has(marker)) continue
|
|
693
|
+
const rowRecords = JSON.parse(marker.dataset.kEffects).map(owner => {
|
|
694
|
+
const template = listTemplates.get(owner)
|
|
695
|
+
if (!template) throw new Error("Keyed row effect template was not emitted")
|
|
696
|
+
const record = createRecord(template.effect, template.index)
|
|
697
|
+
registerDependencies(record)
|
|
698
|
+
mount(record, marker)
|
|
699
|
+
return record
|
|
700
|
+
})
|
|
701
|
+
listRegistrations.set(marker, rowRecords)
|
|
702
|
+
continue
|
|
703
|
+
}
|
|
704
|
+
const record = owners.get(marker.dataset.kEffect)
|
|
705
|
+
if (!record?.mounted) mount(record, marker)
|
|
706
|
+
}
|
|
707
|
+
})
|
|
708
|
+
__kRuntime.registerUnmountHook(root => {
|
|
709
|
+
if (root === document) {
|
|
710
|
+
if (!active) return
|
|
711
|
+
active = false
|
|
712
|
+
pending.clear()
|
|
713
|
+
for (const record of [...mountedRecords]) unmount(record, record.effect.list)
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
for (const marker of matching(root)) {
|
|
717
|
+
const rowRecords = listRegistrations.get(marker)
|
|
718
|
+
if (rowRecords) {
|
|
719
|
+
for (const record of rowRecords) unmount(record, true)
|
|
720
|
+
listRegistrations.delete(marker)
|
|
721
|
+
continue
|
|
722
|
+
}
|
|
723
|
+
const record = owners.get(marker.dataset.kEffect)
|
|
724
|
+
if (record?.marker === marker) unmount(record)
|
|
725
|
+
}
|
|
726
|
+
})
|
|
727
|
+
for (const record of records) if (record.mounted) start(record)
|
|
728
|
+
__kRuntime.mountDom(document)
|
|
729
|
+
addEventListener("pagehide", event => {
|
|
730
|
+
if (!event.persisted) __kRuntime.unmountDom(document)
|
|
731
|
+
})
|
|
732
|
+
function matching(root) {
|
|
733
|
+
const selector = "template[data-k-effect],[data-k-effects]"
|
|
734
|
+
return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
|
|
735
|
+
}
|
|
736
|
+
function mount(record, marker) {
|
|
737
|
+
record.mounted = true
|
|
738
|
+
record.marker = marker
|
|
739
|
+
mountedRecords.add(record)
|
|
740
|
+
const version = ++record.version
|
|
741
|
+
const begin = () => {
|
|
742
|
+
if (!active || !record.mounted || record.version !== version || !marker.isConnected) return
|
|
743
|
+
start(record)
|
|
744
|
+
}
|
|
745
|
+
if (record.disposal) record.disposal.then(begin)
|
|
746
|
+
else begin()
|
|
747
|
+
}
|
|
748
|
+
function unmount(record, dynamic = false) {
|
|
749
|
+
if (!record.mounted) return
|
|
750
|
+
record.mounted = false
|
|
751
|
+
record.marker = undefined
|
|
752
|
+
mountedRecords.delete(record)
|
|
753
|
+
if (dynamic) unregisterDependencies(record)
|
|
754
|
+
record.version++
|
|
755
|
+
pending.delete(record)
|
|
756
|
+
invokeCleanup(record)
|
|
757
|
+
}
|
|
758
|
+
function start(record) {
|
|
759
|
+
try {
|
|
760
|
+
record.values = readDependencies(record)
|
|
761
|
+
invoke(record)
|
|
762
|
+
} catch (error) {
|
|
763
|
+
console.error(error)
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
function schedule() {
|
|
767
|
+
if (!pending.size || scheduled || flushing) return
|
|
768
|
+
scheduled = true
|
|
769
|
+
queueMicrotask(flush)
|
|
770
|
+
}
|
|
771
|
+
async function flush() {
|
|
772
|
+
scheduled = false
|
|
773
|
+
if (!active) return pending.clear()
|
|
774
|
+
flushing = true
|
|
775
|
+
try {
|
|
776
|
+
const selected = [...pending].filter(record => record.mounted).sort((left, right) => left.index - right.index)
|
|
777
|
+
pending.clear()
|
|
778
|
+
const changed = []
|
|
779
|
+
for (const record of selected) {
|
|
780
|
+
try {
|
|
781
|
+
const values = readDependencies(record)
|
|
782
|
+
if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) {
|
|
783
|
+
record.values = values
|
|
784
|
+
changed.push([record, record.version])
|
|
785
|
+
}
|
|
786
|
+
} catch (error) {
|
|
787
|
+
console.error(error)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
for (const [record] of changed) await invokeCleanup(record)
|
|
791
|
+
if (active) for (const [record, version] of changed) if (record.mounted && record.version === version) invoke(record)
|
|
792
|
+
} finally {
|
|
793
|
+
flushing = false
|
|
794
|
+
if (active) schedule()
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
function readDependencies(record) {
|
|
798
|
+
return (record.effect.dependencies ?? []).map(id => {
|
|
799
|
+
const value = browserState.get(id)
|
|
800
|
+
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")
|
|
801
|
+
return value
|
|
802
|
+
})
|
|
803
|
+
}
|
|
804
|
+
function invoke(record) {
|
|
805
|
+
try {
|
|
806
|
+
const effect = record.effect
|
|
807
|
+
const scope = effect.list
|
|
808
|
+
? Object.fromEntries(Object.entries(effect.scope).map(([name, value]) => [name, value?.type === "list-item" ? JSON.parse(record.marker.dataset.kEffectItem) : value]))
|
|
809
|
+
: effect.scope
|
|
810
|
+
const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, scope))
|
|
811
|
+
if (effect.cleanup && typeof result === "function") record.cleanup = result
|
|
812
|
+
else if (result && typeof result.then === "function") result.catch(error => console.error(error))
|
|
813
|
+
} catch (error) {
|
|
814
|
+
console.error(error)
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
function invokeCleanup(record) {
|
|
818
|
+
if (record.disposal) return record.disposal
|
|
819
|
+
const cleanup = record.cleanup
|
|
820
|
+
record.cleanup = undefined
|
|
821
|
+
if (!cleanup) return Promise.resolve()
|
|
822
|
+
const disposal = (async () => {
|
|
823
|
+
try {
|
|
824
|
+
await cleanup()
|
|
825
|
+
} catch (error) {
|
|
826
|
+
console.error(error)
|
|
827
|
+
}
|
|
828
|
+
})()
|
|
829
|
+
record.disposal = disposal
|
|
830
|
+
disposal.finally(() => {
|
|
831
|
+
if (record.disposal === disposal) record.disposal = undefined
|
|
832
|
+
})
|
|
833
|
+
return disposal
|
|
834
|
+
}`
|
|
835
|
+
}
|
|
836
|
+
|
|
441
837
|
function printSingleDependencyEffect(imports, effect, hasCleanup) {
|
|
442
838
|
const disposal = hasCleanup ? `
|
|
443
839
|
const dispose = root => {
|
|
@@ -875,10 +1271,12 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
875
1271
|
const listValues = new WeakMap()
|
|
876
1272
|
const listEventItems = new WeakMap()
|
|
877
1273
|
const listConditions = new WeakMap()
|
|
1274
|
+
const listEffectEntries = new WeakMap()
|
|
878
1275
|
let usesBehavior = false
|
|
879
1276
|
let usesBinding = false
|
|
880
1277
|
let usesConditional = false
|
|
881
1278
|
let usesList = false
|
|
1279
|
+
let usesListEffects = false
|
|
882
1280
|
|
|
883
1281
|
const collect = node => {
|
|
884
1282
|
if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer)) {
|
|
@@ -983,6 +1381,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
983
1381
|
}))
|
|
984
1382
|
const componentSpecializations = new WeakMap()
|
|
985
1383
|
const specializedDeclarations = new WeakSet()
|
|
1384
|
+
const keyedComponentCalls = new Set(rawRenderedLists.map(({ parts }) => parts.root))
|
|
986
1385
|
for (const name of listComponentNames) {
|
|
987
1386
|
let component = components.get(name)
|
|
988
1387
|
const local = Boolean(component)
|
|
@@ -995,7 +1394,11 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
995
1394
|
if (local && isExportedDeclaration(component.declaration)) fail(component.declaration, `Keyed list component ${name} cannot be exported`)
|
|
996
1395
|
const calls = jsxTagUses(sourceFile, name)
|
|
997
1396
|
if (local && identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `Keyed list component ${name} may only be referenced as JSX`)
|
|
998
|
-
for (const call of calls)
|
|
1397
|
+
for (const call of calls) {
|
|
1398
|
+
const specialization = specializeComponentCall(call, component.function, sourceFile, factory, context, fail)
|
|
1399
|
+
if (specialization.effects.length && !keyedComponentCalls.has(call)) fail(call, "Effectful keyed row components may only be used directly as keyed map rows")
|
|
1400
|
+
componentSpecializations.set(call, specialization)
|
|
1401
|
+
}
|
|
999
1402
|
if (local) specializedDeclarations.add(component.declaration)
|
|
1000
1403
|
}
|
|
1001
1404
|
const renderedLists = new WeakMap()
|
|
@@ -1003,7 +1406,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
1003
1406
|
if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
|
|
1004
1407
|
const specialization = componentSpecializations.get(originalParts.root)
|
|
1005
1408
|
const root = specialization?.root ?? originalParts.root
|
|
1006
|
-
|
|
1409
|
+
let callback = root === originalParts.root ? originalParts.callback : factory.updateArrowFunction(
|
|
1007
1410
|
originalParts.callback,
|
|
1008
1411
|
originalParts.callback.modifiers,
|
|
1009
1412
|
originalParts.callback.typeParameters,
|
|
@@ -1023,6 +1426,20 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
1023
1426
|
validateListExpression(calculation, parts.item, originalParts.root, fail)
|
|
1024
1427
|
}
|
|
1025
1428
|
validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions)
|
|
1429
|
+
if (specialization?.effects.length) {
|
|
1430
|
+
usesListEffects = true
|
|
1431
|
+
const statements = specialization.effects.map(entry => {
|
|
1432
|
+
const call = factory.updateCallExpression(entry.call, factory.createIdentifier("__kListUseEffect"), entry.call.typeArguments, entry.call.arguments)
|
|
1433
|
+
synthesizeTree(call)
|
|
1434
|
+
const effectSource = entry.source.getSourceFile()
|
|
1435
|
+
listEffectEntries.set(call, { item: parts.item, source: entry.source, sourceFile: effectSource, imports: clientImportBindings(effectSource, effectSource.fileName, sourceFiles) })
|
|
1436
|
+
return factory.createExpressionStatement(call)
|
|
1437
|
+
})
|
|
1438
|
+
callback = factory.updateArrowFunction(callback, callback.modifiers, callback.typeParameters, callback.parameters, callback.type, callback.equalsGreaterThanToken, factory.createBlock([...statements, factory.createReturnStatement(root)], true))
|
|
1439
|
+
ts.setParentRecursive(callback, false)
|
|
1440
|
+
callback.parent = originalParts.callback.parent
|
|
1441
|
+
parts.callback = callback
|
|
1442
|
+
}
|
|
1026
1443
|
renderedLists.set(node, parts)
|
|
1027
1444
|
}
|
|
1028
1445
|
|
|
@@ -1066,25 +1483,33 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
1066
1483
|
return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
|
|
1067
1484
|
}
|
|
1068
1485
|
|
|
1069
|
-
|
|
1070
|
-
|
|
1486
|
+
const listEffect = ts.isCallExpression(node) ? listEffectEntries.get(node) : undefined
|
|
1487
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && (hasUseEffectImport && node.expression.text === "useEffect" || listEffect)) {
|
|
1488
|
+
const effectFail = (target, message) => {
|
|
1489
|
+
if (listEffect) throw sourceNodeError(listEffect.source, listEffect.sourceFile, message)
|
|
1490
|
+
fail(target, message)
|
|
1491
|
+
}
|
|
1492
|
+
if (node.arguments.length !== 2) effectFail(node, "useEffect() requires exactly a callback and literal dependency array")
|
|
1071
1493
|
const [callback, dependencies] = node.arguments
|
|
1072
|
-
if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback))
|
|
1073
|
-
if (ts.isFunctionExpression(callback) && callback.name)
|
|
1074
|
-
if (callback.asteriskToken)
|
|
1075
|
-
if (callback.parameters.length)
|
|
1076
|
-
if (!ts.isArrayLiteralExpression(dependencies))
|
|
1494
|
+
if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) effectFail(callback, "useEffect() callback must be an inline function")
|
|
1495
|
+
if (ts.isFunctionExpression(callback) && callback.name) effectFail(callback, "useEffect() callback function must be anonymous")
|
|
1496
|
+
if (callback.asteriskToken) effectFail(callback, "useEffect() callback cannot be a generator")
|
|
1497
|
+
if (callback.parameters.length) effectFail(callback, "useEffect() callback cannot declare parameters")
|
|
1498
|
+
if (!ts.isArrayLiteralExpression(dependencies)) effectFail(dependencies, "useEffect() dependencies must be a literal array")
|
|
1499
|
+
if (listEffect && dependencies.elements.some(dependency => referencesIdentifier(dependency, listEffect.item))) {
|
|
1500
|
+
effectFail(dependencies, "useEffect() item-property dependencies are not supported in keyed lists; use [] or primitive Kudzu state identifiers")
|
|
1501
|
+
}
|
|
1077
1502
|
const invalidDependency = dependencies.elements.find(dependency => !ts.isIdentifier(dependency))
|
|
1078
|
-
if (invalidDependency)
|
|
1503
|
+
if (invalidDependency) effectFail(invalidDependency, "useEffect() dependencies must be direct state or runtime parameter identifiers")
|
|
1079
1504
|
if (!nearestFunction(node)) fail(node, "useEffect() cannot be used outside a Kudzu component")
|
|
1080
|
-
if (!ts.isBlock(callback.body))
|
|
1505
|
+
if (!ts.isBlock(callback.body)) effectFail(callback, "useEffect() callback must use a block body")
|
|
1081
1506
|
const returns = effectReturns(callback)
|
|
1082
|
-
if (returns.invalid)
|
|
1507
|
+
if (returns.invalid) effectFail(returns.invalid, "useEffect() return values must be inline cleanup functions")
|
|
1083
1508
|
const invalidCleanup = returns.cleanups.find(cleanup => cleanup.parameters.length || cleanup.asteriskToken)
|
|
1084
|
-
if (invalidCleanup)
|
|
1085
|
-
if (returns.cleanup && callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword))
|
|
1509
|
+
if (invalidCleanup) effectFail(invalidCleanup, "useEffect() cleanup functions cannot declare parameters or be generators")
|
|
1510
|
+
if (returns.cleanup && callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) effectFail(callback, "useEffect() async callbacks cannot return cleanup functions")
|
|
1086
1511
|
const setters = settersForNode(node, settersByFunction)
|
|
1087
|
-
const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, importBindings, clientImports, "effect",
|
|
1512
|
+
const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, listEffect?.imports ?? importBindings, clientImports, "effect", listEffect?.item, true, returns.cleanup)
|
|
1088
1513
|
usesBehavior = true
|
|
1089
1514
|
return factory.updateCallExpression(node, node.expression, node.typeArguments, [
|
|
1090
1515
|
callback,
|
|
@@ -1093,7 +1518,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
1093
1518
|
factory.createStringLiteral(descriptor.exportName),
|
|
1094
1519
|
descriptor.states,
|
|
1095
1520
|
descriptor.scope,
|
|
1096
|
-
factory.createStringLiteral(sourceLocation(node, sourceFile)),
|
|
1521
|
+
factory.createStringLiteral(listEffect ? sourceLocation(listEffect.source, listEffect.sourceFile) : sourceLocation(node, sourceFile)),
|
|
1097
1522
|
returns.cleanup ? factory.createTrue() : factory.createFalse()
|
|
1098
1523
|
])
|
|
1099
1524
|
}
|
|
@@ -1207,6 +1632,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
1207
1632
|
behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listItem"), factory.createIdentifier("__kListItem")))
|
|
1208
1633
|
behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listConditional"), factory.createIdentifier("__kListConditional")))
|
|
1209
1634
|
}
|
|
1635
|
+
if (usesListEffects) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useEffect"), factory.createIdentifier("__kListUseEffect")))
|
|
1210
1636
|
if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
|
|
1211
1637
|
const behaviorImport = factory.createImportDeclaration(
|
|
1212
1638
|
undefined,
|
|
@@ -1429,6 +1855,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
|
|
|
1429
1855
|
|
|
1430
1856
|
let returned
|
|
1431
1857
|
const calculations = []
|
|
1858
|
+
const effectCalls = []
|
|
1432
1859
|
if (!ts.isBlock(component.body)) {
|
|
1433
1860
|
returned = component.body
|
|
1434
1861
|
} else {
|
|
@@ -1436,6 +1863,10 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
|
|
|
1436
1863
|
const last = statements.pop()
|
|
1437
1864
|
if (!last || !ts.isReturnStatement(last) || !last.expression) fail(component.body, "Keyed list component must end with one JSX return")
|
|
1438
1865
|
for (const statement of statements) {
|
|
1866
|
+
if (ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression) && ts.isIdentifier(statement.expression.expression) && statement.expression.expression.text === "useEffect") {
|
|
1867
|
+
effectCalls.push(statement.expression)
|
|
1868
|
+
continue
|
|
1869
|
+
}
|
|
1439
1870
|
if (!ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0 || statement.declarationList.declarations.length !== 1) fail(statement, "Keyed list component locals must be single const declarations")
|
|
1440
1871
|
const declaration = statement.declarationList.declarations[0]
|
|
1441
1872
|
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration, "Keyed list component locals must be initialized identifiers")
|
|
@@ -1454,7 +1885,8 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
|
|
|
1454
1885
|
if (key) root = addJsxAttribute(root, cloneAst(key, factory, context), factory)
|
|
1455
1886
|
ts.setParentRecursive(root, false)
|
|
1456
1887
|
root.parent = call.parent
|
|
1457
|
-
|
|
1888
|
+
const effects = effectCalls.map(source => ({ source, call: substituteClone(source, substitutions, factory, context) }))
|
|
1889
|
+
return { root, calculations, effects }
|
|
1458
1890
|
}
|
|
1459
1891
|
|
|
1460
1892
|
function substituteClone(root, substitutions, factory, context) {
|
|
@@ -1487,6 +1919,16 @@ function cloneAst(root, factory, context) {
|
|
|
1487
1919
|
return visit(root)
|
|
1488
1920
|
}
|
|
1489
1921
|
|
|
1922
|
+
function synthesizeTree(root) {
|
|
1923
|
+
const visit = node => {
|
|
1924
|
+
ts.setTextRange(node, { pos: -1, end: -1 })
|
|
1925
|
+
ts.setOriginalNode(node, undefined)
|
|
1926
|
+
ts.forEachChild(node, visit)
|
|
1927
|
+
}
|
|
1928
|
+
visit(root)
|
|
1929
|
+
return root
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1490
1932
|
function addJsxAttribute(root, attribute, factory) {
|
|
1491
1933
|
if (ts.isJsxSelfClosingElement(root)) {
|
|
1492
1934
|
return factory.updateJsxSelfClosingElement(root, root.tagName, root.typeArguments, factory.updateJsxAttributes(root.attributes, [attribute, ...root.attributes.properties]))
|
|
@@ -2142,6 +2584,22 @@ function parseSourceFile(file, source) {
|
|
|
2142
2584
|
return ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
|
|
2143
2585
|
}
|
|
2144
2586
|
|
|
2587
|
+
function layoutExportError(file, source) {
|
|
2588
|
+
const sourceFile = parseSourceFile(file, source)
|
|
2589
|
+
for (const statement of sourceFile.statements) {
|
|
2590
|
+
if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
2591
|
+
const specifier = statement.exportClause.elements.find(entry => entry.name.text === "layout")
|
|
2592
|
+
if (specifier) return sourceNodeError(specifier, sourceFile, "layout export must be a function")
|
|
2593
|
+
}
|
|
2594
|
+
if (ts.isVariableStatement(statement) && statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
|
|
2595
|
+
const declaration = statement.declarationList.declarations.find(entry => ts.isIdentifier(entry.name) && entry.name.text === "layout")
|
|
2596
|
+
if (declaration) return sourceNodeError(declaration, sourceFile, "layout export must be a function")
|
|
2597
|
+
}
|
|
2598
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === "layout") return sourceNodeError(statement, sourceFile, "layout export must be a function")
|
|
2599
|
+
}
|
|
2600
|
+
return new Error(`${relative(root, file)} layout export must be a function`)
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2145
2603
|
function clientModulePath(file) {
|
|
2146
2604
|
return `modules/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
|
|
2147
2605
|
}
|
|
@@ -2399,6 +2857,30 @@ function normalizeStyles(value, base) {
|
|
|
2399
2857
|
})
|
|
2400
2858
|
}
|
|
2401
2859
|
|
|
2860
|
+
export function normalizeNavigation(value) {
|
|
2861
|
+
if (value === undefined) return []
|
|
2862
|
+
if (!isPlainRecord(value)) throw new Error("kudzu.config navigation must be a plain object")
|
|
2863
|
+
if (Object.keys(value).some(key => key !== "routes")) throw new Error("kudzu.config navigation only supports routes")
|
|
2864
|
+
if (!Array.isArray(value.routes) || !value.routes.length) throw new Error("kudzu.config navigation.routes must be a nonempty array")
|
|
2865
|
+
const routes = value.routes.map((route, index) => {
|
|
2866
|
+
if (typeof route !== "string" || !route.startsWith("/") || route.startsWith("//") || /[?#\\\0]/.test(route) || /%(?:2f|5c)/i.test(route)) throw new Error(`kudzu.config navigation.routes[${index}] must be a root-relative path without query, hash, or traversal`)
|
|
2867
|
+
let decoded
|
|
2868
|
+
try { decoded = decodeURIComponent(route) } catch { throw new Error(`kudzu.config navigation.routes[${index}] must be a root-relative path without query, hash, or traversal`) }
|
|
2869
|
+
if (decoded.split("/").includes("..") || /[?#\\\0]/.test(decoded)) throw new Error(`kudzu.config navigation.routes[${index}] must be a root-relative path without query, hash, or traversal`)
|
|
2870
|
+
return route
|
|
2871
|
+
})
|
|
2872
|
+
if (new Set(routes).size !== routes.length) throw new Error("kudzu.config navigation.routes must contain unique paths")
|
|
2873
|
+
return routes
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
function specializeNavigationTextDescriptors(source) {
|
|
2877
|
+
const dynamic = source
|
|
2878
|
+
.replace("const textDescriptors = globalThis.__KUDZU_TEXT_BINDINGS__ && typeof document !== \"undefined\" ? JSON.parse(document.body.dataset.kTextBindings ?? \"[]\") : []", "const textDescriptors = () => globalThis.__KUDZU_TEXT_BINDINGS__ ? JSON.parse(document.body.dataset.kTextBindings ?? \"[]\") : []")
|
|
2879
|
+
.replace("const descriptor = textDescriptors[Number(node.data.slice(\"k-text:\".length))]", "const descriptor = textDescriptors()[Number(node.data.slice(\"k-text:\".length))]")
|
|
2880
|
+
if (dynamic === source) throw new Error("Navigation text descriptor specialization did not match binding-runtime.js")
|
|
2881
|
+
return dynamic
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2402
2884
|
function normalizeBase(value) {
|
|
2403
2885
|
if (value == null || value === "" || value === "/") return ""
|
|
2404
2886
|
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")
|