@moku-labs/web 1.15.1 → 1.16.1
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/dist/browser.d.mts +222 -26
- package/dist/browser.mjs +452 -84
- package/dist/index.cjs +452 -84
- package/dist/index.d.cts +222 -26
- package/dist/index.d.mts +222 -26
- package/dist/index.mjs +452 -84
- package/dist/{render-DLZEOe4M.cjs → render-KdufA3_b.cjs} +23 -0
- package/dist/{render-BNe0s7fr.mjs → render-UO4nimWr.mjs} +23 -1
- package/dist/testing.d.mts +389 -0
- package/dist/testing.mjs +854 -0
- package/package.json +7 -1
package/dist/index.mjs
CHANGED
|
@@ -9143,6 +9143,18 @@ function createApi(ctx) {
|
|
|
9143
9143
|
*/
|
|
9144
9144
|
current() {
|
|
9145
9145
|
return ctx.state.currentUrl;
|
|
9146
|
+
},
|
|
9147
|
+
/**
|
|
9148
|
+
* Resolve a registered island's api by name (the cross-island seam). Returns
|
|
9149
|
+
* `undefined` when no provider with that name is currently registered.
|
|
9150
|
+
*
|
|
9151
|
+
* @param name - The provider island's component name.
|
|
9152
|
+
* @returns The provider's api, or `undefined`.
|
|
9153
|
+
* @example
|
|
9154
|
+
* app.spa.component("lightbox");
|
|
9155
|
+
*/
|
|
9156
|
+
component(name) {
|
|
9157
|
+
return ctx.state.componentApis.get(name);
|
|
9146
9158
|
}
|
|
9147
9159
|
};
|
|
9148
9160
|
}
|
|
@@ -9179,10 +9191,26 @@ const COMPONENT_HOOK_NAMES = [
|
|
|
9179
9191
|
];
|
|
9180
9192
|
//#endregion
|
|
9181
9193
|
//#region src/plugins/spa/components.ts
|
|
9194
|
+
/**
|
|
9195
|
+
* @file spa plugin — component lifecycle, mounting, the plugin-mirror authoring
|
|
9196
|
+
* surface (`createComponent` with a typed `{ state, render, events, api }` spec),
|
|
9197
|
+
* the per-instance state + microtask-batched render scheduler, declarative
|
|
9198
|
+
* delegated events, and the cross-island api registry.
|
|
9199
|
+
* @see README.md
|
|
9200
|
+
*/
|
|
9182
9201
|
/** Error prefix for spa fail-fast failures (spec/11 Part-3). */
|
|
9183
9202
|
const ERROR_PREFIX$2 = "[web]";
|
|
9184
9203
|
/** The set of legal hook names, frozen for O(1) membership checks. */
|
|
9185
9204
|
const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
|
|
9205
|
+
/** The spec-only keys that select the plugin-mirror form of {@link createComponent}. */
|
|
9206
|
+
const SPEC_KEYS = new Set([
|
|
9207
|
+
"state",
|
|
9208
|
+
"render",
|
|
9209
|
+
"events",
|
|
9210
|
+
"api"
|
|
9211
|
+
]);
|
|
9212
|
+
/** Synchronous re-entrancy cap for the render scheduler (a render that calls `ctx.flush`). */
|
|
9213
|
+
const MAX_RENDER_DEPTH = 25;
|
|
9186
9214
|
/**
|
|
9187
9215
|
* No-op link builder for the {@link EMPTY_ROUTE} slice (used when no route matched).
|
|
9188
9216
|
*
|
|
@@ -9201,40 +9229,346 @@ const EMPTY_ROUTE = {
|
|
|
9201
9229
|
url: noUrl
|
|
9202
9230
|
};
|
|
9203
9231
|
/**
|
|
9232
|
+
* No-op placeholder for an instance's `flush` slot until the real one is bound at mount.
|
|
9233
|
+
*
|
|
9234
|
+
* @example
|
|
9235
|
+
* const instance = { flush: noop };
|
|
9236
|
+
*/
|
|
9237
|
+
function noop() {}
|
|
9238
|
+
/** Cached promise for the lazy `./render` chunk (loaded at most once per module). */
|
|
9239
|
+
let renderChunk;
|
|
9240
|
+
/** The resolved VNode committer once the chunk loads (undefined until then). */
|
|
9241
|
+
let commitVNodeFunction;
|
|
9242
|
+
/**
|
|
9243
|
+
* Load the lazy `./render` chunk (once) and cache its `commitVNode` for synchronous
|
|
9244
|
+
* use by later renders. Awaited by a component's `mountPromise` so the test harness's
|
|
9245
|
+
* `settle()` can deterministically flush a VNode render.
|
|
9246
|
+
*
|
|
9247
|
+
* @returns A promise that resolves once `commitVNode` is available.
|
|
9248
|
+
* @example
|
|
9249
|
+
* await loadRenderChunk();
|
|
9250
|
+
*/
|
|
9251
|
+
async function loadRenderChunk() {
|
|
9252
|
+
renderChunk ??= import("./render-UO4nimWr.mjs");
|
|
9253
|
+
commitVNodeFunction = (await renderChunk).commitVNode;
|
|
9254
|
+
}
|
|
9255
|
+
/**
|
|
9256
|
+
* Commit a {@link RenderResult} into a host: `string` → `innerHTML`, `Node` →
|
|
9257
|
+
* `replaceChildren`, `void`/`undefined` → no-op (the render mutated the DOM itself), and
|
|
9258
|
+
* a Preact `VNode` → committed through the lazy gate (loading it on demand if needed).
|
|
9259
|
+
*
|
|
9260
|
+
* @param host - The island host element to render into.
|
|
9261
|
+
* @param result - The value returned by the component's `render`.
|
|
9262
|
+
* @example
|
|
9263
|
+
* commitResult(host, h(View, { items }));
|
|
9264
|
+
*/
|
|
9265
|
+
function commitResult(host, result) {
|
|
9266
|
+
if (result === void 0) return;
|
|
9267
|
+
if (typeof result === "string") {
|
|
9268
|
+
host.innerHTML = result;
|
|
9269
|
+
return;
|
|
9270
|
+
}
|
|
9271
|
+
if (result instanceof Node) {
|
|
9272
|
+
host.replaceChildren(result);
|
|
9273
|
+
return;
|
|
9274
|
+
}
|
|
9275
|
+
const vnode = result;
|
|
9276
|
+
if (commitVNodeFunction) {
|
|
9277
|
+
commitVNodeFunction(vnode, host);
|
|
9278
|
+
return;
|
|
9279
|
+
}
|
|
9280
|
+
loadRenderChunk().then(() => commitVNodeFunction?.(vnode, host)).catch(() => {});
|
|
9281
|
+
}
|
|
9282
|
+
/**
|
|
9283
|
+
* Run a component's `render(state, ctx)` and commit the result now. Guards against
|
|
9284
|
+
* synchronous re-entrancy (a render that calls `ctx.flush`) with a depth cap.
|
|
9285
|
+
*
|
|
9286
|
+
* @param instance - The instance to render.
|
|
9287
|
+
* @throws {Error} When the synchronous render depth exceeds {@link MAX_RENDER_DEPTH}.
|
|
9288
|
+
* @example
|
|
9289
|
+
* runRender(instance);
|
|
9290
|
+
*/
|
|
9291
|
+
function runRender(instance) {
|
|
9292
|
+
const render = instance.def.spec?.render;
|
|
9293
|
+
if (!render) return;
|
|
9294
|
+
if (instance.renderDepth > MAX_RENDER_DEPTH) throw new Error(`${ERROR_PREFIX$2} component "${instance.def.name}" render re-entered ${MAX_RENDER_DEPTH}+ times\n → a render must not synchronously trigger its own render (avoid ctx.flush() inside render)`);
|
|
9295
|
+
instance.renderDepth += 1;
|
|
9296
|
+
try {
|
|
9297
|
+
commitResult(instance.el, render(instance.state ?? {}, instance.ctx));
|
|
9298
|
+
} finally {
|
|
9299
|
+
instance.renderDepth -= 1;
|
|
9300
|
+
}
|
|
9301
|
+
}
|
|
9302
|
+
/**
|
|
9303
|
+
* Schedule a microtask-batched render for an instance (no-op when it has no `render`).
|
|
9304
|
+
* Multiple `ctx.set` calls in the same tick coalesce into a single render.
|
|
9305
|
+
*
|
|
9306
|
+
* @param instance - The instance to schedule a render for.
|
|
9307
|
+
* @example
|
|
9308
|
+
* scheduleRender(instance);
|
|
9309
|
+
*/
|
|
9310
|
+
function scheduleRender(instance) {
|
|
9311
|
+
if (!instance.def.spec?.render || instance.renderScheduled) return;
|
|
9312
|
+
instance.renderScheduled = true;
|
|
9313
|
+
queueMicrotask(() => {
|
|
9314
|
+
if (!instance.renderScheduled) return;
|
|
9315
|
+
instance.renderScheduled = false;
|
|
9316
|
+
runRender(instance);
|
|
9317
|
+
});
|
|
9318
|
+
}
|
|
9319
|
+
/**
|
|
9320
|
+
* Build the single per-instance {@link ComponentContext} reused by every hook, event
|
|
9321
|
+
* handler, and render. Route fields (`params`/`meta`/`locale`/`url`) and `data` read
|
|
9322
|
+
* through the instance so a navigation update is reflected without rebuilding the ctx;
|
|
9323
|
+
* `state`/`set`/`flush`/`cleanup`/`component` are bound to the instance + plugin state.
|
|
9324
|
+
*
|
|
9325
|
+
* @param state - The plugin state (for the cross-island `component` resolver).
|
|
9326
|
+
* @param instance - The instance the context is bound to.
|
|
9327
|
+
* @returns The instance-bound context.
|
|
9328
|
+
* @example
|
|
9329
|
+
* instance.ctx = buildContext(state, instance);
|
|
9330
|
+
*/
|
|
9331
|
+
function buildContext(state, instance) {
|
|
9332
|
+
return {
|
|
9333
|
+
el: instance.el,
|
|
9334
|
+
/**
|
|
9335
|
+
* The current page data payload (live; updated across navigations).
|
|
9336
|
+
*
|
|
9337
|
+
* @returns The page data.
|
|
9338
|
+
* @example
|
|
9339
|
+
* ctx.data;
|
|
9340
|
+
*/
|
|
9341
|
+
get data() {
|
|
9342
|
+
return instance.data;
|
|
9343
|
+
},
|
|
9344
|
+
/**
|
|
9345
|
+
* The matched route's path params (live; updated across navigations).
|
|
9346
|
+
*
|
|
9347
|
+
* @returns The route params.
|
|
9348
|
+
* @example
|
|
9349
|
+
* ctx.params.id;
|
|
9350
|
+
*/
|
|
9351
|
+
get params() {
|
|
9352
|
+
return instance.route.params;
|
|
9353
|
+
},
|
|
9354
|
+
/**
|
|
9355
|
+
* The matched route's `.meta()` bag (live; updated across navigations).
|
|
9356
|
+
*
|
|
9357
|
+
* @returns The route meta.
|
|
9358
|
+
* @example
|
|
9359
|
+
* ctx.meta.focus;
|
|
9360
|
+
*/
|
|
9361
|
+
get meta() {
|
|
9362
|
+
return instance.route.meta;
|
|
9363
|
+
},
|
|
9364
|
+
/**
|
|
9365
|
+
* The active locale for the current route (live; updated across navigations).
|
|
9366
|
+
*
|
|
9367
|
+
* @returns The locale code.
|
|
9368
|
+
* @example
|
|
9369
|
+
* ctx.locale;
|
|
9370
|
+
*/
|
|
9371
|
+
get locale() {
|
|
9372
|
+
return instance.route.locale;
|
|
9373
|
+
},
|
|
9374
|
+
/**
|
|
9375
|
+
* The named-route link builder for the current route.
|
|
9376
|
+
*
|
|
9377
|
+
* @returns The link builder.
|
|
9378
|
+
* @example
|
|
9379
|
+
* ctx.url("board", { id });
|
|
9380
|
+
*/
|
|
9381
|
+
get url() {
|
|
9382
|
+
return instance.route.url;
|
|
9383
|
+
},
|
|
9384
|
+
/**
|
|
9385
|
+
* The live per-instance state (`undefined` for legacy hooks-only islands).
|
|
9386
|
+
*
|
|
9387
|
+
* @returns The current state.
|
|
9388
|
+
* @example
|
|
9389
|
+
* ctx.state.count;
|
|
9390
|
+
*/
|
|
9391
|
+
get state() {
|
|
9392
|
+
return instance.state;
|
|
9393
|
+
},
|
|
9394
|
+
/**
|
|
9395
|
+
* Merge a patch into the per-instance state and schedule one batched render.
|
|
9396
|
+
*
|
|
9397
|
+
* @param patch - A partial state object, or an updater `(prev) => partial`.
|
|
9398
|
+
* @example
|
|
9399
|
+
* ctx.set(prev => ({ count: prev.count + 1 }));
|
|
9400
|
+
*/
|
|
9401
|
+
set(patch) {
|
|
9402
|
+
const previous = instance.state ?? {};
|
|
9403
|
+
const next = typeof patch === "function" ? patch(previous) : patch;
|
|
9404
|
+
instance.state = Object.assign({}, previous, next);
|
|
9405
|
+
scheduleRender(instance);
|
|
9406
|
+
},
|
|
9407
|
+
/**
|
|
9408
|
+
* Force a synchronous render now (drains any pending scheduled render).
|
|
9409
|
+
*
|
|
9410
|
+
* @example
|
|
9411
|
+
* ctx.flush();
|
|
9412
|
+
*/
|
|
9413
|
+
flush() {
|
|
9414
|
+
instance.flush();
|
|
9415
|
+
},
|
|
9416
|
+
/**
|
|
9417
|
+
* Register a disposer run on destroy (subscriptions, timers, manual listeners).
|
|
9418
|
+
*
|
|
9419
|
+
* @param dispose - The teardown function.
|
|
9420
|
+
* @example
|
|
9421
|
+
* ctx.cleanup(off);
|
|
9422
|
+
*/
|
|
9423
|
+
cleanup(dispose) {
|
|
9424
|
+
instance.cleanups.push(dispose);
|
|
9425
|
+
},
|
|
9426
|
+
/**
|
|
9427
|
+
* Resolve another island's registered api by name (`undefined` when absent).
|
|
9428
|
+
*
|
|
9429
|
+
* @param name - The provider island's component name.
|
|
9430
|
+
* @returns The provider's api, or `undefined`.
|
|
9431
|
+
* @example
|
|
9432
|
+
* ctx.component("lightbox");
|
|
9433
|
+
*/
|
|
9434
|
+
component(name) {
|
|
9435
|
+
return state.componentApis.get(name);
|
|
9436
|
+
}
|
|
9437
|
+
};
|
|
9438
|
+
}
|
|
9439
|
+
/**
|
|
9440
|
+
* Resolve the element a delegated handler should receive for an event: the host for a
|
|
9441
|
+
* host-level binding (empty selector), else the nearest ancestor of `event.target`
|
|
9442
|
+
* matching the selector that is still inside the host.
|
|
9443
|
+
*
|
|
9444
|
+
* @param host - The island host element.
|
|
9445
|
+
* @param event - The dispatched DOM event.
|
|
9446
|
+
* @param selector - The key's selector (empty string → host-level).
|
|
9447
|
+
* @returns The matched element, or `undefined` when nothing matches inside the host.
|
|
9448
|
+
* @example
|
|
9449
|
+
* const target = matchTarget(host, event, "[data-action]");
|
|
9450
|
+
*/
|
|
9451
|
+
function matchTarget(host, event, selector) {
|
|
9452
|
+
if (selector === "") return host;
|
|
9453
|
+
const target = event.target;
|
|
9454
|
+
if (!(target instanceof Element)) return void 0;
|
|
9455
|
+
const matched = target.closest(selector);
|
|
9456
|
+
return matched && host.contains(matched) ? matched : void 0;
|
|
9457
|
+
}
|
|
9458
|
+
/**
|
|
9459
|
+
* Attach a component's declarative `events` map: one real listener per event TYPE on
|
|
9460
|
+
* the host (dispatch walks `closest(selector)` for each registered selector), each
|
|
9461
|
+
* removed via the instance's cleanup registry on destroy.
|
|
9462
|
+
*
|
|
9463
|
+
* @param instance - The instance whose host the listeners attach to.
|
|
9464
|
+
* @param events - The declarative `{ "<type> <selector>": handler }` map.
|
|
9465
|
+
* @throws {Error} When a key has no event type.
|
|
9466
|
+
* @example
|
|
9467
|
+
* attachEvents(instance, { "click [data-action]": (ctx, e, el) => {} });
|
|
9468
|
+
*/
|
|
9469
|
+
function attachEvents(instance, events) {
|
|
9470
|
+
const host = instance.el;
|
|
9471
|
+
const byType = /* @__PURE__ */ new Map();
|
|
9472
|
+
for (const [key, handler] of Object.entries(events)) {
|
|
9473
|
+
const space = key.indexOf(" ");
|
|
9474
|
+
const type = (space === -1 ? key : key.slice(0, space)).trim();
|
|
9475
|
+
const selector = space === -1 ? "" : key.slice(space + 1).trim();
|
|
9476
|
+
if (type === "") throw new Error(`${ERROR_PREFIX$2} component "${instance.def.name}" event key must start with an event type: "${key}"\n → use "<type>" or "<type> <selector>" (e.g. "click [data-action]")`);
|
|
9477
|
+
const list = byType.get(type) ?? [];
|
|
9478
|
+
list.push({
|
|
9479
|
+
selector,
|
|
9480
|
+
handler
|
|
9481
|
+
});
|
|
9482
|
+
byType.set(type, list);
|
|
9483
|
+
}
|
|
9484
|
+
for (const [type, handlers] of byType) {
|
|
9485
|
+
const listener = (event) => {
|
|
9486
|
+
for (const { selector, handler } of handlers) {
|
|
9487
|
+
const target = matchTarget(host, event, selector);
|
|
9488
|
+
if (target) handler(instance.ctx, event, target);
|
|
9489
|
+
}
|
|
9490
|
+
};
|
|
9491
|
+
host.addEventListener(type, listener);
|
|
9492
|
+
instance.cleanups.push(() => host.removeEventListener(type, listener));
|
|
9493
|
+
}
|
|
9494
|
+
}
|
|
9495
|
+
/**
|
|
9204
9496
|
* Validate a single hook entry: its key must be a known hook name and its value
|
|
9205
9497
|
* must be a function. Throws fail-fast on the first violation.
|
|
9206
9498
|
*
|
|
9207
9499
|
* @param componentName - The owning component name (for error messages).
|
|
9208
|
-
* @param
|
|
9500
|
+
* @param source - The raw authoring object being validated.
|
|
9209
9501
|
* @param key - The hook key to validate.
|
|
9210
9502
|
* @throws {Error} If `key` is not in `COMPONENT_HOOK_NAMES`.
|
|
9211
9503
|
* @throws {TypeError} If the hook value is not a function.
|
|
9212
9504
|
* @example
|
|
9213
|
-
* validateHookEntry("counter",
|
|
9505
|
+
* validateHookEntry("counter", source, "onMount");
|
|
9214
9506
|
*/
|
|
9215
|
-
function validateHookEntry(componentName,
|
|
9216
|
-
if (!HOOK_NAME_SET.has(key)) throw new Error(`${ERROR_PREFIX$2} unknown component hook "${key}" on "${componentName}"\n → valid hooks: ${COMPONENT_HOOK_NAMES.join(", ")}`);
|
|
9217
|
-
if (typeof
|
|
9507
|
+
function validateHookEntry(componentName, source, key) {
|
|
9508
|
+
if (!HOOK_NAME_SET.has(key)) throw new Error(`${ERROR_PREFIX$2} unknown component hook "${key}" on "${componentName}"\n → valid hooks: ${COMPONENT_HOOK_NAMES.join(", ")}\n → spec keys: state, render, events, api`);
|
|
9509
|
+
if (typeof source[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2} component hook "${key}" on "${componentName}" must be a function\n → provide a function or omit the hook`);
|
|
9510
|
+
}
|
|
9511
|
+
/**
|
|
9512
|
+
* Validate the spec extras (`state`/`render`/`api` must be functions; `events` must be
|
|
9513
|
+
* a plain object of functions). Throws fail-fast on the first violation.
|
|
9514
|
+
*
|
|
9515
|
+
* @param componentName - The owning component name (for error messages).
|
|
9516
|
+
* @param extras - The partitioned spec extras to validate.
|
|
9517
|
+
* @throws {TypeError} If a present extra has the wrong shape.
|
|
9518
|
+
* @example
|
|
9519
|
+
* validateSpecExtras("board", { state: () => ({}) });
|
|
9520
|
+
*/
|
|
9521
|
+
function validateSpecExtras(componentName, extras) {
|
|
9522
|
+
for (const key of [
|
|
9523
|
+
"state",
|
|
9524
|
+
"render",
|
|
9525
|
+
"api"
|
|
9526
|
+
]) if (extras[key] !== void 0 && typeof extras[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2} component "${key}" on "${componentName}" must be a function\n → provide a function or omit it`);
|
|
9527
|
+
if (extras.events !== void 0) {
|
|
9528
|
+
const events = extras.events;
|
|
9529
|
+
if (!(typeof events === "object")) throw new TypeError(`${ERROR_PREFIX$2} component "events" on "${componentName}" must be an object of handlers`);
|
|
9530
|
+
for (const [key, handler] of Object.entries(events)) if (typeof handler !== "function") throw new TypeError(`${ERROR_PREFIX$2} component event "${key}" on "${componentName}" must be a function`);
|
|
9531
|
+
}
|
|
9218
9532
|
}
|
|
9219
9533
|
/**
|
|
9220
|
-
* Create a validated component definition.
|
|
9221
|
-
*
|
|
9222
|
-
*
|
|
9534
|
+
* Create a validated component definition. Accepts either the legacy hooks-only form
|
|
9535
|
+
* (`createComponent("counter", { onMount() {} })`) or the plugin-mirror spec form
|
|
9536
|
+
* (`createComponent("board", { state, render, events, api, ...hooks })`). Spec-only
|
|
9537
|
+
* keys (`state`/`render`/`events`/`api`) are partitioned out before hook-name
|
|
9538
|
+
* validation, so a real typo (e.g. `onMout`) still throws immediately while the spec
|
|
9539
|
+
* keys are accepted.
|
|
9223
9540
|
*
|
|
9224
9541
|
* @param name - Unique component name.
|
|
9225
|
-
* @param
|
|
9542
|
+
* @param spec - Lifecycle hooks, or the `{ state, render, events, api, ...hooks }` spec.
|
|
9226
9543
|
* @returns A `ComponentDef` ready to `register`.
|
|
9227
|
-
* @throws {Error} If `name` is empty,
|
|
9228
|
-
* `COMPONENT_HOOK_NAMES`, or any provided hook value is not a function.
|
|
9544
|
+
* @throws {Error} If `name` is empty, a hook key is unknown, or an extra/hook value has the wrong shape.
|
|
9229
9545
|
* @example
|
|
9230
|
-
* const counter = createComponent("counter", {
|
|
9231
|
-
*
|
|
9546
|
+
* const counter = createComponent("counter", { onMount({ el }) { el.textContent = "0"; } });
|
|
9547
|
+
* @example
|
|
9548
|
+
* const list = createComponent<{ items: string[] }>("list", {
|
|
9549
|
+
* state: () => ({ items: [] }),
|
|
9550
|
+
* render: (s) => h(List, { items: s.items })
|
|
9232
9551
|
* });
|
|
9233
9552
|
*/
|
|
9234
|
-
function createComponent(name,
|
|
9553
|
+
function createComponent(name, spec) {
|
|
9235
9554
|
if (name.trim() === "") throw new Error(`${ERROR_PREFIX$2} component name must be a non-empty string\n → pass a unique name to createComponent("name", hooks)`);
|
|
9236
|
-
|
|
9237
|
-
|
|
9555
|
+
const source = spec;
|
|
9556
|
+
const hooks = {};
|
|
9557
|
+
const extras = {};
|
|
9558
|
+
for (const key of Object.keys(source)) {
|
|
9559
|
+
if (SPEC_KEYS.has(key)) {
|
|
9560
|
+
extras[key] = source[key];
|
|
9561
|
+
continue;
|
|
9562
|
+
}
|
|
9563
|
+
validateHookEntry(name, source, key);
|
|
9564
|
+
hooks[key] = source[key];
|
|
9565
|
+
}
|
|
9566
|
+
validateSpecExtras(name, extras);
|
|
9567
|
+
return Object.keys(extras).length > 0 ? {
|
|
9568
|
+
name,
|
|
9569
|
+
hooks,
|
|
9570
|
+
spec: extras
|
|
9571
|
+
} : {
|
|
9238
9572
|
name,
|
|
9239
9573
|
hooks
|
|
9240
9574
|
};
|
|
@@ -9258,64 +9592,53 @@ function extractPageData(doc) {
|
|
|
9258
9592
|
}
|
|
9259
9593
|
}
|
|
9260
9594
|
/**
|
|
9261
|
-
*
|
|
9595
|
+
* Read the current page data, or `{}` in a headless (non-browser) context.
|
|
9262
9596
|
*
|
|
9263
|
-
* @
|
|
9264
|
-
* @param element - The element the instance binds to.
|
|
9265
|
-
* @param persistent - Whether the instance survives navigation.
|
|
9266
|
-
* @returns The constructed (not-yet-mounted) instance.
|
|
9597
|
+
* @returns The current page data payload.
|
|
9267
9598
|
* @example
|
|
9268
|
-
* const
|
|
9599
|
+
* const data = currentPageData();
|
|
9269
9600
|
*/
|
|
9270
|
-
function
|
|
9271
|
-
return {
|
|
9272
|
-
def: definition,
|
|
9273
|
-
el: element,
|
|
9274
|
-
persistent
|
|
9275
|
-
};
|
|
9601
|
+
function currentPageData() {
|
|
9602
|
+
return typeof document === "undefined" ? {} : extractPageData(document);
|
|
9276
9603
|
}
|
|
9277
9604
|
/**
|
|
9278
|
-
* Invokes a single lifecycle hook on an instance with its
|
|
9279
|
-
*
|
|
9605
|
+
* Invokes a single lifecycle hook on an instance with its bound context. Missing
|
|
9606
|
+
* hooks are skipped silently.
|
|
9280
9607
|
*
|
|
9281
9608
|
* @param instance - The instance whose hook to run.
|
|
9282
9609
|
* @param hook - The hook name to invoke.
|
|
9283
|
-
* @param ctx - The component context passed to the hook.
|
|
9284
9610
|
* @example
|
|
9285
|
-
* runHook(instance, "
|
|
9611
|
+
* runHook(instance, "onDestroy");
|
|
9286
9612
|
*/
|
|
9287
|
-
function runHook(instance, hook
|
|
9288
|
-
instance.def.hooks[hook]?.(ctx);
|
|
9613
|
+
function runHook(instance, hook) {
|
|
9614
|
+
instance.def.hooks[hook]?.(instance.ctx);
|
|
9289
9615
|
}
|
|
9290
9616
|
/**
|
|
9291
|
-
*
|
|
9292
|
-
*
|
|
9293
|
-
* when no route is supplied (headless, tests, public `scan()`).
|
|
9617
|
+
* Run an instance's registered cleanup disposers (LIFO) and unregister its api. Each
|
|
9618
|
+
* disposer runs in isolation so a throwing one never strands the others during teardown.
|
|
9294
9619
|
*
|
|
9295
|
-
* @param
|
|
9296
|
-
* @param
|
|
9297
|
-
* @param route - The matched-route slice for the current URL.
|
|
9298
|
-
* @returns The hook context.
|
|
9620
|
+
* @param state - The plugin state (for the api registry).
|
|
9621
|
+
* @param instance - The instance being disposed.
|
|
9299
9622
|
* @example
|
|
9300
|
-
*
|
|
9623
|
+
* disposeInstance(state, instance);
|
|
9301
9624
|
*/
|
|
9302
|
-
function
|
|
9303
|
-
|
|
9304
|
-
|
|
9305
|
-
|
|
9306
|
-
|
|
9307
|
-
|
|
9308
|
-
|
|
9309
|
-
url: route.url
|
|
9310
|
-
};
|
|
9625
|
+
function disposeInstance(state, instance) {
|
|
9626
|
+
for (let index = instance.cleanups.length - 1; index >= 0; index -= 1) try {
|
|
9627
|
+
instance.cleanups[index]?.();
|
|
9628
|
+
} catch {}
|
|
9629
|
+
instance.cleanups.length = 0;
|
|
9630
|
+
instance.renderScheduled = false;
|
|
9631
|
+
if (instance.api !== void 0 && state.componentApis.get(instance.def.name) === instance.api) state.componentApis.delete(instance.def.name);
|
|
9311
9632
|
}
|
|
9312
9633
|
/**
|
|
9313
|
-
* Mounts a single `data-component` element: classifies persistent vs
|
|
9314
|
-
*
|
|
9315
|
-
*
|
|
9634
|
+
* Mounts a single `data-component` element: classifies persistent vs page-specific,
|
|
9635
|
+
* builds the instance + its bound context, initializes per-instance `state`, registers
|
|
9636
|
+
* its `api`, attaches declarative `events`, fires `onCreate` then `onMount` (capturing
|
|
9637
|
+
* an async `onMount` + render-chunk load as `mountPromise`), schedules the initial
|
|
9638
|
+
* render, records it, and emits `spa:component-mount`. No-ops if the element is already
|
|
9316
9639
|
* mounted, has no component name, or names an unregistered component.
|
|
9317
9640
|
*
|
|
9318
|
-
* @param state - The plugin state (registeredComponents + instances).
|
|
9641
|
+
* @param state - The plugin state (registeredComponents + instances + componentApis).
|
|
9319
9642
|
* @param emit - The event emitter for spa:component-mount.
|
|
9320
9643
|
* @param swapArea - The swap-region element, or null when none was found.
|
|
9321
9644
|
* @param data - The current page data payload.
|
|
@@ -9330,10 +9653,40 @@ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE)
|
|
|
9330
9653
|
if (!name) return;
|
|
9331
9654
|
const definition = state.registeredComponents.get(name);
|
|
9332
9655
|
if (!definition) return;
|
|
9333
|
-
const instance =
|
|
9334
|
-
|
|
9335
|
-
|
|
9336
|
-
|
|
9656
|
+
const instance = {
|
|
9657
|
+
def: definition,
|
|
9658
|
+
el: element,
|
|
9659
|
+
persistent: swapArea ? !swapArea.contains(element) : true,
|
|
9660
|
+
ctx: void 0,
|
|
9661
|
+
state: void 0,
|
|
9662
|
+
api: void 0,
|
|
9663
|
+
route,
|
|
9664
|
+
data,
|
|
9665
|
+
cleanups: [],
|
|
9666
|
+
flush: noop,
|
|
9667
|
+
renderScheduled: false,
|
|
9668
|
+
renderDepth: 0,
|
|
9669
|
+
mountPromise: void 0
|
|
9670
|
+
};
|
|
9671
|
+
instance.ctx = buildContext(state, instance);
|
|
9672
|
+
instance.flush = () => {
|
|
9673
|
+
instance.renderScheduled = false;
|
|
9674
|
+
runRender(instance);
|
|
9675
|
+
};
|
|
9676
|
+
const spec = definition.spec;
|
|
9677
|
+
if (spec?.state) instance.state = spec.state(instance.ctx);
|
|
9678
|
+
if (spec?.api) {
|
|
9679
|
+
instance.api = spec.api(instance.ctx);
|
|
9680
|
+
state.componentApis.set(definition.name, instance.api);
|
|
9681
|
+
}
|
|
9682
|
+
if (spec?.events) attachEvents(instance, spec.events);
|
|
9683
|
+
runHook(instance, "onCreate");
|
|
9684
|
+
const onMountResult = definition.hooks.onMount?.(instance.ctx);
|
|
9685
|
+
if (spec?.render) scheduleRender(instance);
|
|
9686
|
+
const pending = [];
|
|
9687
|
+
if (spec?.render) pending.push(loadRenderChunk());
|
|
9688
|
+
if (onMountResult && typeof onMountResult.then === "function") pending.push(onMountResult);
|
|
9689
|
+
instance.mountPromise = pending.length > 0 ? Promise.all(pending).then(() => {}) : void 0;
|
|
9337
9690
|
state.instances.set(element, instance);
|
|
9338
9691
|
emit("spa:component-mount", {
|
|
9339
9692
|
name: definition.name,
|
|
@@ -9341,12 +9694,12 @@ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE)
|
|
|
9341
9694
|
});
|
|
9342
9695
|
}
|
|
9343
9696
|
/**
|
|
9344
|
-
* Scans the swap region, mounts components for matching `data-component`
|
|
9345
|
-
*
|
|
9346
|
-
*
|
|
9697
|
+
* Scans the swap region, mounts components for matching `data-component` elements,
|
|
9698
|
+
* classifies persistent (outside swap area) vs page-specific (inside), runs
|
|
9699
|
+
* `onCreate`/`onMount` + initial render, and emits `spa:component-mount` per instance.
|
|
9347
9700
|
* Already-mounted elements are skipped.
|
|
9348
9701
|
*
|
|
9349
|
-
* @param state - The plugin state (registeredComponents + instances).
|
|
9702
|
+
* @param state - The plugin state (registeredComponents + instances + componentApis).
|
|
9350
9703
|
* @param emit - The event emitter for spa:component-mount.
|
|
9351
9704
|
* @param swapSelector - CSS selector bounding page-specific components.
|
|
9352
9705
|
* @param route - The matched-route slice for the current URL (params/meta/locale/url).
|
|
@@ -9360,9 +9713,10 @@ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
|
|
|
9360
9713
|
for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element, route);
|
|
9361
9714
|
}
|
|
9362
9715
|
/**
|
|
9363
|
-
* Unmounts page-specific instances inside the swap region (runs `onUnMount`
|
|
9364
|
-
* then
|
|
9365
|
-
* Persistent instances (outside the swap area) are
|
|
9716
|
+
* Unmounts page-specific instances inside the swap region (runs `onUnMount` then
|
|
9717
|
+
* `onDestroy`, then their cleanup disposers + api unregister), removes them from state,
|
|
9718
|
+
* and emits `spa:component-unmount`. Persistent instances (outside the swap area) are
|
|
9719
|
+
* left in place.
|
|
9366
9720
|
*
|
|
9367
9721
|
* @param state - The plugin state holding live instances.
|
|
9368
9722
|
* @param emit - The event emitter for spa:component-unmount.
|
|
@@ -9370,12 +9724,13 @@ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
|
|
|
9370
9724
|
* unmountPageSpecific(state, emit);
|
|
9371
9725
|
*/
|
|
9372
9726
|
function unmountPageSpecific(state, emit) {
|
|
9373
|
-
const data =
|
|
9727
|
+
const data = currentPageData();
|
|
9374
9728
|
for (const [element, instance] of state.instances) {
|
|
9375
9729
|
if (instance.persistent) continue;
|
|
9376
|
-
|
|
9377
|
-
runHook(instance, "onUnMount"
|
|
9378
|
-
runHook(instance, "onDestroy"
|
|
9730
|
+
instance.data = data;
|
|
9731
|
+
runHook(instance, "onUnMount");
|
|
9732
|
+
runHook(instance, "onDestroy");
|
|
9733
|
+
disposeInstance(state, instance);
|
|
9379
9734
|
state.instances.delete(element);
|
|
9380
9735
|
emit("spa:component-unmount", {
|
|
9381
9736
|
name: instance.def.name,
|
|
@@ -9384,9 +9739,10 @@ function unmountPageSpecific(state, emit) {
|
|
|
9384
9739
|
}
|
|
9385
9740
|
}
|
|
9386
9741
|
/**
|
|
9387
|
-
* Disposes ALL live instances (persistent and page-specific) on teardown:
|
|
9388
|
-
*
|
|
9389
|
-
* the instance
|
|
9742
|
+
* Disposes ALL live instances (persistent and page-specific) on teardown: runs
|
|
9743
|
+
* `onUnMount` then `onDestroy`, then their cleanup disposers + api unregister, emits
|
|
9744
|
+
* `spa:component-unmount`, and clears the instance + api maps. Used by the kernel's
|
|
9745
|
+
* `dispose` on plugin stop.
|
|
9390
9746
|
*
|
|
9391
9747
|
* @param state - The plugin state holding live instances.
|
|
9392
9748
|
* @param emit - The event emitter for spa:component-unmount.
|
|
@@ -9394,17 +9750,19 @@ function unmountPageSpecific(state, emit) {
|
|
|
9394
9750
|
* unmountAll(state, emit);
|
|
9395
9751
|
*/
|
|
9396
9752
|
function unmountAll(state, emit) {
|
|
9397
|
-
const data =
|
|
9753
|
+
const data = currentPageData();
|
|
9398
9754
|
for (const [element, instance] of state.instances) {
|
|
9399
|
-
|
|
9400
|
-
runHook(instance, "onUnMount"
|
|
9401
|
-
runHook(instance, "onDestroy"
|
|
9755
|
+
instance.data = data;
|
|
9756
|
+
runHook(instance, "onUnMount");
|
|
9757
|
+
runHook(instance, "onDestroy");
|
|
9758
|
+
disposeInstance(state, instance);
|
|
9402
9759
|
emit("spa:component-unmount", {
|
|
9403
9760
|
name: instance.def.name,
|
|
9404
9761
|
el: element
|
|
9405
9762
|
});
|
|
9406
9763
|
}
|
|
9407
9764
|
state.instances.clear();
|
|
9765
|
+
state.componentApis.clear();
|
|
9408
9766
|
}
|
|
9409
9767
|
/**
|
|
9410
9768
|
* Fires `onNavStart` on every currently-mounted instance (persistent instances
|
|
@@ -9415,12 +9773,16 @@ function unmountAll(state, emit) {
|
|
|
9415
9773
|
* notifyNavStart(state);
|
|
9416
9774
|
*/
|
|
9417
9775
|
function notifyNavStart(state) {
|
|
9418
|
-
const data =
|
|
9419
|
-
for (const
|
|
9776
|
+
const data = currentPageData();
|
|
9777
|
+
for (const instance of state.instances.values()) {
|
|
9778
|
+
instance.data = data;
|
|
9779
|
+
runHook(instance, "onNavStart");
|
|
9780
|
+
}
|
|
9420
9781
|
}
|
|
9421
9782
|
/**
|
|
9422
9783
|
* Fires `onNavEnd` on persistent instances that survived the swap (page-specific
|
|
9423
|
-
* instances were already destroyed and re-created by the swap)
|
|
9784
|
+
* instances were already destroyed and re-created by the swap), updating their route
|
|
9785
|
+
* slice to the destination first.
|
|
9424
9786
|
*
|
|
9425
9787
|
* @param state - The plugin state holding live instances.
|
|
9426
9788
|
* @param route - The matched-route slice for the destination URL (params/meta/locale/url).
|
|
@@ -9428,8 +9790,13 @@ function notifyNavStart(state) {
|
|
|
9428
9790
|
* notifyNavEnd(state, route);
|
|
9429
9791
|
*/
|
|
9430
9792
|
function notifyNavEnd(state, route = EMPTY_ROUTE) {
|
|
9431
|
-
const data =
|
|
9432
|
-
for (const
|
|
9793
|
+
const data = currentPageData();
|
|
9794
|
+
for (const instance of state.instances.values()) {
|
|
9795
|
+
if (!instance.persistent) continue;
|
|
9796
|
+
instance.data = data;
|
|
9797
|
+
instance.route = route;
|
|
9798
|
+
runHook(instance, "onNavEnd");
|
|
9799
|
+
}
|
|
9433
9800
|
}
|
|
9434
9801
|
//#endregion
|
|
9435
9802
|
//#region src/plugins/spa/head.ts
|
|
@@ -9970,6 +10337,7 @@ function createState(_ctx) {
|
|
|
9970
10337
|
return {
|
|
9971
10338
|
registeredComponents: /* @__PURE__ */ new Map(),
|
|
9972
10339
|
instances: /* @__PURE__ */ new Map(),
|
|
10340
|
+
componentApis: /* @__PURE__ */ new Map(),
|
|
9973
10341
|
currentUrl: "",
|
|
9974
10342
|
destroyRouter: null,
|
|
9975
10343
|
started: false,
|
|
@@ -10208,7 +10576,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10208
10576
|
const commitDataRender = async (pathname, resolvedRender, signal) => {
|
|
10209
10577
|
if (signal?.aborted) return;
|
|
10210
10578
|
const { route, vnode, routeContext, region } = resolvedRender;
|
|
10211
|
-
const { renderVNode } = await import("./render-
|
|
10579
|
+
const { renderVNode } = await import("./render-UO4nimWr.mjs");
|
|
10212
10580
|
if (signal?.aborted) return;
|
|
10213
10581
|
syncDataHead(deps.head, route, routeContext);
|
|
10214
10582
|
unmountPageSpecific(state, emit);
|
|
@@ -10277,7 +10645,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10277
10645
|
return;
|
|
10278
10646
|
}
|
|
10279
10647
|
const { vnode, region } = resolvedRender;
|
|
10280
|
-
const { renderVNode } = await import("./render-
|
|
10648
|
+
const { renderVNode } = await import("./render-UO4nimWr.mjs");
|
|
10281
10649
|
renderVNode(vnode, region);
|
|
10282
10650
|
scanAndMount(state, emit, resolved.swapSelector, routeSlice);
|
|
10283
10651
|
};
|