@webjsdev/core 0.7.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.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `Suspense({ fallback, children })`: deferred boundary for streaming SSR.
3
+ *
4
+ * ```js
5
+ * import { html, Suspense } from '@webjsdev/core';
6
+ *
7
+ * export default function Page() {
8
+ * return html`
9
+ * <h1>Catalogue</h1>
10
+ * ${Suspense({ fallback: html`<p>Loading…</p>`, children: fetchExpensive() })}
11
+ * `;
12
+ * }
13
+ *
14
+ * async function fetchExpensive() {
15
+ * const items = await db.item.findMany();
16
+ * return html`<ul>${items.map(i => html`<li>${i.name}</li>`)}</ul>`;
17
+ * }
18
+ * ```
19
+ *
20
+ * The server emits the fallback immediately, closes no tags, and keeps the
21
+ * response stream open. When the children promise resolves, the resolved
22
+ * HTML streams as a `<template data-webjs-resolve="ID">…</template>` plus
23
+ * a tiny inline script that swaps the fallback for the real content. No
24
+ * hydration runtime required: just a `replaceWith` call.
25
+ *
26
+ * Nested Suspense works: a resolved template can itself contain Suspense,
27
+ * whose fallback is emitted inside the template until its own promise lands.
28
+ */
29
+ const SUSPENSE = Symbol.for('webjs.suspense');
30
+
31
+ /**
32
+ * @typedef {{ _$webjsSuspense: true, fallback: unknown, children: unknown }} SuspenseBoundary
33
+ *
34
+ * @param {{ fallback: unknown, children: unknown | Promise<unknown> }} props
35
+ * @returns {SuspenseBoundary}
36
+ */
37
+ export function Suspense(props) {
38
+ return { _$webjsSuspense: true, fallback: props.fallback, children: props.children };
39
+ }
40
+
41
+ /** @param {unknown} x */
42
+ export function isSuspense(x) {
43
+ return !!x && typeof x === 'object' && /** @type any */ (x)._$webjsSuspense === true;
44
+ }
45
+
46
+ export { SUSPENSE };
package/src/task.js ADDED
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Task: a ReactiveController for managing async data fetching inside
3
+ * web components.
4
+ *
5
+ * ## When to use
6
+ *
7
+ * Use Task when a component needs to fetch data and render
8
+ * loading / error / success states. Task handles `AbortController`
9
+ * automatically: navigating away or re-running cancels the previous
10
+ * in-flight request, preventing race conditions and wasted work.
11
+ *
12
+ * Good fits:
13
+ * - Search-as-you-type / autocomplete
14
+ * - Lazy-loaded data panels that fetch when a tab becomes active
15
+ * - Infinite scroll / pagination within a component
16
+ * - Any component-scoped async operation (geocoding, AI completions, etc.)
17
+ *
18
+ * ## When NOT to use
19
+ *
20
+ * - For **page-level** data loading, use async page functions or server
21
+ * actions instead: they run on the server, stream via Suspense, and
22
+ * produce real HTML for crawlers.
23
+ * - For one-shot fire-and-forget mutations (form submit, delete), a plain
24
+ * `async` method on the component is usually simpler.
25
+ *
26
+ * ## Quick example
27
+ *
28
+ * ```js
29
+ * import { WebComponent, html } from '@webjsdev/core';
30
+ * import { Task } from '@webjsdev/core/task';
31
+ *
32
+ * class UserSearch extends WebComponent {
33
+ * static properties = { query: { type: String } };
34
+ *
35
+ * _search = new Task(this, {
36
+ * task: async (query, { signal }) => {
37
+ * const res = await fetch(`/api/users?q=${query}`, { signal });
38
+ * if (!res.ok) throw new Error('Search failed');
39
+ * return res.json();
40
+ * },
41
+ * args: () => [this.query],
42
+ * });
43
+ *
44
+ * render() {
45
+ * return html`
46
+ * <input .value=${this.query}
47
+ * @input=${e => this.query = e.target.value}>
48
+ * ${this._search.render({
49
+ * initial: () => html`<p>Type to search</p>`,
50
+ * pending: () => html`<p>Searching…</p>`,
51
+ * complete: (users)=> html`<ul>${users.map(u => html`<li>${u.name}</li>`)}</ul>`,
52
+ * error: (err) => html`<p class="error">${err.message}</p>`,
53
+ * })}
54
+ * `;
55
+ * }
56
+ * }
57
+ * UserSearch.register('user-search');
58
+ * ```
59
+ *
60
+ * ### Manual control
61
+ *
62
+ * Set `autoRun: false` to disable automatic re-runs on arg changes and
63
+ * call `task.run()` explicitly (e.g. on button click):
64
+ *
65
+ * ```js
66
+ * this._export = new Task(this, {
67
+ * task: async (format, { signal }) => { … },
68
+ * args: () => [this.format],
69
+ * autoRun: false,
70
+ * });
71
+ *
72
+ * // In render:
73
+ * html`<button @click=${() => this._export.run()}>Export</button>`
74
+ * ```
75
+ *
76
+ * @module
77
+ */
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // TaskStatus enum
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Possible states of a Task.
85
+ *
86
+ * | Value | Name | Meaning |
87
+ * | ----- | --------- | ------------------------------------ |
88
+ * | 0 | INITIAL | Task has never run. |
89
+ * | 1 | PENDING | Task is in-flight (awaiting). |
90
+ * | 2 | COMPLETE | Task resolved successfully. |
91
+ * | 3 | ERROR | Task rejected or threw. |
92
+ *
93
+ * @readonly
94
+ * @enum {number}
95
+ */
96
+ export const TaskStatus = /** @type {const} */ ({
97
+ INITIAL: 0,
98
+ PENDING: 1,
99
+ COMPLETE: 2,
100
+ ERROR: 3,
101
+ });
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Task controller
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * A ReactiveController that runs an async function, tracks its lifecycle
109
+ * (initial → pending → complete | error), and triggers host re-renders
110
+ * on state transitions.
111
+ *
112
+ * ## AI guidance
113
+ *
114
+ * - **`task.render()`** provides declarative async UI. Pass an object
115
+ * with `initial`, `pending`, `complete`, and `error` callbacks and
116
+ * Task returns the right template for the current status:
117
+ * ```js
118
+ * task.render({
119
+ * pending: () => html`Loading…`,
120
+ * complete: (data) => html`${data.name}`,
121
+ * error: (err) => html`Error: ${err.message}`,
122
+ * })
123
+ * ```
124
+ * Omitted callbacks return `undefined` (renders nothing).
125
+ *
126
+ * - **AbortController** is managed automatically. Each call to `run()`
127
+ * aborts the previous in-flight request. The `{ signal }` object
128
+ * passed as the last argument to your task function is wired to the
129
+ * internal AbortController: pass it to `fetch()`, `ReadableStream`,
130
+ * or any other signal-aware API.
131
+ *
132
+ * - **`args`** is a function that returns an array. It is re-evaluated
133
+ * on every `hostUpdate()`. When `autoRun` is true (the default) and
134
+ * the args have changed (shallow identity comparison per element),
135
+ * the task automatically re-runs. This is how search-as-you-type and
136
+ * reactive data loading work.
137
+ *
138
+ * - When the host disconnects (e.g. the component is removed from the
139
+ * DOM), any in-flight task is aborted automatically: no cleanup
140
+ * needed.
141
+ *
142
+ * @template T
143
+ */
144
+ export class Task {
145
+ /**
146
+ * @param {import('./component.js').WebComponent} host
147
+ * The host component that owns this task.
148
+ * @param {{
149
+ * task: (...args: [...unknown[], { signal: AbortSignal }]) => Promise<T>,
150
+ * args?: () => unknown[],
151
+ * autoRun?: boolean
152
+ * }} options
153
+ * - `task`: The async function to execute. Receives the spread args
154
+ * from `args()` followed by an options object with `{ signal }`.
155
+ * - `args`: A function returning an array of arguments. Re-evaluated
156
+ * on every host update cycle. Defaults to `() => []`.
157
+ * - `autoRun`: When true (default), the task re-runs automatically
158
+ * whenever `args()` returns values that differ from the previous
159
+ * run. Set to false for manual-only triggering via `run()`.
160
+ */
161
+ constructor(host, { task, args, autoRun = true }) {
162
+ /** @type {import('./component.js').WebComponent} */
163
+ this._host = host;
164
+ /** @type {(...args: any[]) => Promise<T>} */
165
+ this._taskFn = task;
166
+ /** @type {() => unknown[]} */
167
+ this._argsFn = args || (() => []);
168
+ /** @type {boolean} */
169
+ this._autoRun = autoRun;
170
+
171
+ /** @type {number}: current TaskStatus */
172
+ this._status = TaskStatus.INITIAL;
173
+ /** @type {T | undefined} */
174
+ this._value = undefined;
175
+ /** @type {unknown} */
176
+ this._error = undefined;
177
+
178
+ /** @type {AbortController | null} */
179
+ this._abortController = null;
180
+
181
+ /**
182
+ * Snapshot of args from the last run, used for shallow comparison.
183
+ * @type {unknown[] | null}
184
+ */
185
+ this._prevArgs = null;
186
+
187
+ if (typeof host.addController === 'function') {
188
+ host.addController(this);
189
+ }
190
+ }
191
+
192
+ // ---- Read-only public properties ----------------------------------------
193
+
194
+ /**
195
+ * Current status of the task.
196
+ * @returns {number} One of the `TaskStatus` enum values.
197
+ */
198
+ get status() {
199
+ return this._status;
200
+ }
201
+
202
+ /**
203
+ * The resolved value when status is `COMPLETE`, otherwise `undefined`.
204
+ * @returns {T | undefined}
205
+ */
206
+ get value() {
207
+ return this._value;
208
+ }
209
+
210
+ /**
211
+ * The rejection reason when status is `ERROR`, otherwise `undefined`.
212
+ * @returns {unknown}
213
+ */
214
+ get error() {
215
+ return this._error;
216
+ }
217
+
218
+ // ---- Public methods -----------------------------------------------------
219
+
220
+ /**
221
+ * Manually trigger (or re-trigger) the task.
222
+ *
223
+ * Any in-flight invocation is aborted before the new one starts. The
224
+ * `args()` function is called to produce the current arguments.
225
+ *
226
+ * @returns {Promise<void>} Resolves when the task finishes (or is
227
+ * superseded). Does not reject: errors are captured in `this.error`.
228
+ */
229
+ async run() {
230
+ // Abort previous.
231
+ this.abort();
232
+
233
+ const ac = new AbortController();
234
+ this._abortController = ac;
235
+
236
+ const args = this._argsFn();
237
+ this._prevArgs = args;
238
+
239
+ this._status = TaskStatus.PENDING;
240
+ this._error = undefined;
241
+ this._requestHostUpdate();
242
+
243
+ try {
244
+ const result = await this._taskFn(...args, { signal: ac.signal });
245
+
246
+ // Guard: if this invocation was superseded while awaiting, discard.
247
+ if (ac.signal.aborted) return;
248
+
249
+ this._value = result;
250
+ this._status = TaskStatus.COMPLETE;
251
+ this._error = undefined;
252
+ } catch (err) {
253
+ if (ac.signal.aborted) return;
254
+
255
+ this._error = err;
256
+ this._status = TaskStatus.ERROR;
257
+ this._value = undefined;
258
+ } finally {
259
+ if (this._abortController === ac) {
260
+ this._abortController = null;
261
+ }
262
+ }
263
+
264
+ this._requestHostUpdate();
265
+ }
266
+
267
+ /**
268
+ * Abort the current in-flight task (if any).
269
+ *
270
+ * The AbortController's signal is set to aborted, which cancels any
271
+ * `fetch()` or other signal-aware work inside the task function. The
272
+ * task's status is **not** changed: it remains at whatever state it
273
+ * was before the abort. A subsequent `run()` will transition to
274
+ * PENDING.
275
+ */
276
+ abort() {
277
+ if (this._abortController) {
278
+ this._abortController.abort();
279
+ this._abortController = null;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Declarative async UI helper.
285
+ *
286
+ * Returns the result of calling the callback matching the current
287
+ * status. Omitted callbacks return `undefined`.
288
+ *
289
+ * ```js
290
+ * task.render({
291
+ * initial: () => html`<p>Ready</p>`,
292
+ * pending: () => html`<p>Loading…</p>`,
293
+ * complete: (data) => html`<p>${data}</p>`,
294
+ * error: (err) => html`<p>Error: ${err.message}</p>`,
295
+ * })
296
+ * ```
297
+ *
298
+ * @param {{
299
+ * initial?: () => unknown,
300
+ * pending?: () => unknown,
301
+ * complete?: (value: T) => unknown,
302
+ * error?: (error: unknown) => unknown,
303
+ * }} handlers
304
+ * @returns {unknown} The template result for the current status.
305
+ */
306
+ render(handlers = {}) {
307
+ switch (this._status) {
308
+ case TaskStatus.INITIAL:
309
+ return handlers.initial?.();
310
+ case TaskStatus.PENDING:
311
+ return handlers.pending?.();
312
+ case TaskStatus.COMPLETE:
313
+ return handlers.complete?.(/** @type {T} */ (this._value));
314
+ case TaskStatus.ERROR:
315
+ return handlers.error?.(this._error);
316
+ default:
317
+ return undefined;
318
+ }
319
+ }
320
+
321
+ // ---- ReactiveController lifecycle ---------------------------------------
322
+
323
+ /**
324
+ * Called before the host renders. When `autoRun` is enabled, checks
325
+ * whether `args()` have changed and re-runs the task if so.
326
+ */
327
+ hostUpdate() {
328
+ if (!this._autoRun) return;
329
+
330
+ const nextArgs = this._argsFn();
331
+
332
+ if (!this._argsEqual(this._prevArgs, nextArgs)) {
333
+ // Don't await: the run will trigger another host update when it
334
+ // transitions status.
335
+ this.run();
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Called after the host has rendered. Currently a no-op; required by
341
+ * the ReactiveController interface.
342
+ */
343
+ hostUpdated() {}
344
+
345
+ /**
346
+ * Called when the host connects to the DOM. Currently a no-op -
347
+ * initial run (if autoRun) happens on the first `hostUpdate()`.
348
+ */
349
+ hostConnected() {}
350
+
351
+ /**
352
+ * Called when the host disconnects from the DOM. Aborts any in-flight
353
+ * task to prevent updates to an unmounted component.
354
+ */
355
+ hostDisconnected() {
356
+ this.abort();
357
+ }
358
+
359
+ // ---- Internal -----------------------------------------------------------
360
+
361
+ /** @private */
362
+ _requestHostUpdate() {
363
+ if (typeof this._host.requestUpdate === 'function') {
364
+ this._host.requestUpdate();
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Shallow comparison of two args arrays by identity (`===`).
370
+ * Returns true if both are non-null, same length, and every element
371
+ * is strictly equal.
372
+ *
373
+ * @param {unknown[] | null} a
374
+ * @param {unknown[]} b
375
+ * @returns {boolean}
376
+ * @private
377
+ */
378
+ _argsEqual(a, b) {
379
+ if (a === null) return false;
380
+ if (a.length !== b.length) return false;
381
+ for (let i = 0; i < a.length; i++) {
382
+ if (a[i] !== b[i]) return false;
383
+ }
384
+ return true;
385
+ }
386
+ }
package/src/testing.js ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Testing utilities for webjs components.
3
+ *
4
+ * **When to use (AI hint):** Import from `'@webjsdev/core/testing'` in test files
5
+ * to create, render, and interact with web components in a Node.js test
6
+ * environment using linkedom (a lightweight DOM implementation).
7
+ *
8
+ * ```js
9
+ * import { fixture, waitForUpdate } from '@webjsdev/core/testing';
10
+ * import { html } from '@webjsdev/core';
11
+ *
12
+ * const el = await fixture(html`<my-counter count="5"></my-counter>`);
13
+ * assert.equal(el.shadowRoot.textContent.trim(), '5');
14
+ *
15
+ * el.setAttribute('count', '10');
16
+ * await waitForUpdate(el);
17
+ * assert.equal(el.shadowRoot.textContent.trim(), '10');
18
+ * ```
19
+ *
20
+ * @module testing
21
+ */
22
+
23
+ /**
24
+ * Create a component from a TemplateResult, connect it to a DOM,
25
+ * and wait for its first render to complete.
26
+ *
27
+ * Uses linkedom (if available) to create a minimal DOM environment
28
+ * in Node.js. Falls back to the global `document` in a browser.
29
+ *
30
+ * **When to use (AI hint):** Use `fixture()` in unit tests to quickly
31
+ * mount a component and assert against its rendered shadow DOM.
32
+ * This is the primary test helper: start every component test with it.
33
+ *
34
+ * @param {import('./html.js').TemplateResult | string} template
35
+ * Either a `html\`…\`` result or a raw HTML string.
36
+ * @returns {Promise<Element>} The first child element of the rendered container.
37
+ */
38
+ export async function fixture(template) {
39
+ const container = getContainer();
40
+ if (typeof template === 'string') {
41
+ container.innerHTML = template;
42
+ } else if (template && typeof template === 'object' && template._$webjs === 'template') {
43
+ // Render using the server renderer to produce HTML with DSD,
44
+ // then parse it into the container.
45
+ const { renderToString } = await import('./render-server.js');
46
+ const html = await renderToString(template, { ssr: true });
47
+ container.innerHTML = html;
48
+ } else {
49
+ throw new Error('fixture() expects an html`…` template or an HTML string');
50
+ }
51
+
52
+ // Wait a microtask for component connectedCallback + first render
53
+ await new Promise((r) => setTimeout(r, 0));
54
+
55
+ const el = container.firstElementChild;
56
+ if (!el) throw new Error('fixture() produced no element');
57
+ return el;
58
+ }
59
+
60
+ /**
61
+ * Wait for a component's next render cycle to complete.
62
+ *
63
+ * **When to use (AI hint):** Call after `setAttribute()`, a property
64
+ * assignment, `requestUpdate()`, or a signal `set()` the component
65
+ * subscribes to, any change that triggers a re-render. The re-render
66
+ * is async (microtask-batched), so you need to await before asserting.
67
+ *
68
+ * @param {Element} el The component element.
69
+ * @returns {Promise<void>}
70
+ */
71
+ export async function waitForUpdate(el) {
72
+ // WebComponent batches via queueMicrotask, so two microtask yields
73
+ // is sufficient: one for the scheduling microtask, one for any
74
+ // cascading updates.
75
+ await new Promise((r) => setTimeout(r, 0));
76
+ await new Promise((r) => setTimeout(r, 0));
77
+ }
78
+
79
+ /**
80
+ * Simulate a click event on an element.
81
+ *
82
+ * **When to use (AI hint):** Use to test click handlers in components
83
+ * without a real browser. Works with elements inside shadow DOM.
84
+ *
85
+ * @param {Element} el The element to click.
86
+ */
87
+ export function click(el) {
88
+ el.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
89
+ }
90
+
91
+ /**
92
+ * Query an element inside a component's shadow root.
93
+ *
94
+ * **When to use (AI hint):** Shorthand for `el.shadowRoot.querySelector(sel)`.
95
+ * Returns null if no shadow root or no match.
96
+ *
97
+ * @param {Element} el The component host element.
98
+ * @param {string} selector CSS selector.
99
+ * @returns {Element | null}
100
+ */
101
+ export function shadowQuery(el, selector) {
102
+ return el.shadowRoot?.querySelector(selector) ?? null;
103
+ }
104
+
105
+ /**
106
+ * Get all matching elements inside a component's shadow root.
107
+ *
108
+ * @param {Element} el The component host element.
109
+ * @param {string} selector CSS selector.
110
+ * @returns {NodeList}
111
+ */
112
+ export function shadowQueryAll(el, selector) {
113
+ return el.shadowRoot?.querySelectorAll(selector) ?? [];
114
+ }
115
+
116
+ /* ------------------------------------------------------------------ */
117
+ /* Internal: DOM container management */
118
+ /* ------------------------------------------------------------------ */
119
+
120
+ let _doc = null;
121
+ let _container = null;
122
+
123
+ function getContainer() {
124
+ if (_container) {
125
+ _container.innerHTML = '';
126
+ return _container;
127
+ }
128
+
129
+ // Browser environment
130
+ if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
131
+ _container = document.createElement('div');
132
+ document.body?.appendChild(_container);
133
+ return _container;
134
+ }
135
+
136
+ // Node.js environment: use linkedom
137
+ try {
138
+ const { parseHTML } = /** @type {any} */ (
139
+ // Dynamic import syntax can't be used in a sync function,
140
+ // but linkedom is a CJS package that works with createRequire.
141
+ (() => {
142
+ const { createRequire } = /** @type {any} */ (globalThis).__webjs_require ||
143
+ (() => { throw new Error('no require'); })();
144
+ return createRequire(import.meta.url)('linkedom');
145
+ })()
146
+ );
147
+ const { document: doc } = parseHTML('<!DOCTYPE html><html><body></body></html>');
148
+ _doc = doc;
149
+ _container = doc.createElement('div');
150
+ doc.body.appendChild(_container);
151
+ return _container;
152
+ } catch {
153
+ throw new Error(
154
+ 'fixture() requires a DOM environment. In Node.js, install linkedom: npm i -D linkedom'
155
+ );
156
+ }
157
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `<webjs-frame id="...">`: escape-hatch partial-swap region.
3
+ *
4
+ * The framework's primary partial-nav mechanism auto-derives swap
5
+ * boundaries from the folder structure (each `layout.{js,ts}`'s
6
+ * children-slot is wrapped in a `<!--wj:children:<segment>-->` marker
7
+ * pair). For 99% of apps that's all you need.
8
+ *
9
+ * `<webjs-frame>` is the escape hatch for the 1%: partial-swap
10
+ * regions that are NOT tied to a folder layout. Typical use cases:
11
+ *
12
+ * - A widget on a marketing page that updates on a navigation
13
+ * without changing the URL hierarchy.
14
+ * - A tabbed UI where each tab navigates to a sub-route but the
15
+ * surrounding tab strip should not re-render.
16
+ * - Lazy-loaded cards on a dashboard where a click swaps just the
17
+ * card's content.
18
+ *
19
+ * Usage:
20
+ *
21
+ * html`
22
+ * <section>
23
+ * <h2>Activity</h2>
24
+ * <webjs-frame id="activity">
25
+ * <!-- contents that should be swapped on internal nav -->
26
+ * <p>Showing today's activity.</p>
27
+ * </webjs-frame>
28
+ * </section>
29
+ * `
30
+ *
31
+ * On a link click, the router checks `closest('webjs-frame')` from
32
+ * the click target. If a frame is found AND the incoming response
33
+ * contains a `<webjs-frame id="<matching-id>">`, the swap is scoped
34
+ * to that frame's children: outer DOM is untouched. If no matching
35
+ * frame appears in the response, the router falls through to the
36
+ * normal layout-marker mechanism (and then to full body swap).
37
+ *
38
+ * The element is **light DOM**: no shadow root, no slot mechanics.
39
+ * Children are normal light-DOM children that the router replaces
40
+ * via the same keyed reconciler used for layout marker swaps. This
41
+ * means scroll position, input values, and `<details>` open state
42
+ * are preserved for any matched elements inside the frame.
43
+ *
44
+ * @element webjs-frame
45
+ * @attr {string} id: Required. The frame's identifier, used by
46
+ * `closest()` on the client and `querySelector` on the response.
47
+ */
48
+ // Defined lazily inside an IIFE so that importing this module on the
49
+ // server (Node) doesn't trip a `HTMLElement is not defined` reference
50
+ // error. Server-side renderers never touch the class: the element
51
+ // is always rendered as `<webjs-frame id=...>` plain HTML by the
52
+ // renderToString pipeline.
53
+ const WebjsFrame = (typeof HTMLElement !== 'undefined')
54
+ ? class WebjsFrame extends HTMLElement {
55
+ // No shadow root and no render(). The element exists purely as a
56
+ // swap anchor with an addressable `id`. Children are normal
57
+ // light-DOM children. The router does all the work.
58
+ }
59
+ : /** @type {any} */ (null);
60
+
61
+ if (typeof customElements !== 'undefined' && WebjsFrame && !customElements.get('webjs-frame')) {
62
+ customElements.define('webjs-frame', WebjsFrame);
63
+ }
64
+
65
+ export { WebjsFrame };