@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.
- package/README.md +55 -0
- package/index.d.ts +28 -0
- package/index.js +39 -0
- package/package.json +46 -0
- package/src/component.d.ts +120 -0
- package/src/component.js +1061 -0
- package/src/context.js +327 -0
- package/src/css.js +54 -0
- package/src/directives.js +454 -0
- package/src/escape.js +15 -0
- package/src/expose.js +90 -0
- package/src/html.js +30 -0
- package/src/lazy-loader.js +107 -0
- package/src/nav.js +45 -0
- package/src/registry.js +190 -0
- package/src/render-client.js +1717 -0
- package/src/render-server.js +1128 -0
- package/src/repeat.js +37 -0
- package/src/rich-fetch.js +70 -0
- package/src/router-client.js +1434 -0
- package/src/serialize.js +452 -0
- package/src/signal.js +684 -0
- package/src/slot.js +860 -0
- package/src/suspense.js +46 -0
- package/src/task.js +386 -0
- package/src/testing.js +157 -0
- package/src/webjs-frame.js +65 -0
- package/src/websocket-client.js +111 -0
package/src/suspense.js
ADDED
|
@@ -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 };
|