@webjsdev/core 0.7.9 → 0.7.11

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/src/testing.js CHANGED
@@ -57,6 +57,60 @@ export async function fixture(template) {
57
57
  return el;
58
58
  }
59
59
 
60
+ /**
61
+ * Server-render a template THEN hydrate it in the same browser session,
62
+ * awaiting the element's native `updateComplete` so the post-hydration DOM
63
+ * is observable deterministically.
64
+ *
65
+ * This is the SSR + hydrate entry, distinct from `fixture()`. `fixture()`
66
+ * server-renders and parses the HTML into the container, but it only waits
67
+ * two macrotasks and never awaits the real update cycle. `ssrFixture()`
68
+ * renders the SAME SSR markup (with DSD), lets the browser upgrade the
69
+ * custom element, then awaits the element's `updateComplete` promise (the
70
+ * actual render-cycle resolution), not a timer.
71
+ *
72
+ * **What it proves.** The SSR'd markup and the post-hydration DOM agree.
73
+ * Because the returned element is the live, hydrated element, a hydration
74
+ * mismatch (the server rendered one thing, the client rendered another) is
75
+ * observable: compare the SSR'd inner HTML against `el.innerHTML` /
76
+ * `el.shadowRoot.innerHTML` after this resolves and a divergence shows up.
77
+ *
78
+ * **When to use (AI hint):** Use `ssrFixture()` when the test is about the
79
+ * SSR-then-hydrate round-trip (does the server paint survive hydration, does
80
+ * a `.prop` decode back, does a signal-backed render match). Use the plain
81
+ * `fixture()` for a quick mount where the SSR-vs-hydrate distinction does not
82
+ * matter.
83
+ *
84
+ * Requires a real DOM (the WTR Chromium session). The component class must
85
+ * already be registered (the test imports its module, same as `fixture()`).
86
+ *
87
+ * @param {import('./html.js').TemplateResult | string} template
88
+ * Either a `html\`…\`` result or a raw HTML string.
89
+ * @returns {Promise<Element>} The hydrated root element.
90
+ */
91
+ export async function ssrFixture(template) {
92
+ const container = getContainer();
93
+ if (typeof template === 'string') {
94
+ container.innerHTML = template;
95
+ } else if (template && typeof template === 'object' && template._$webjs === 'template') {
96
+ const { renderToString } = await import('./render-server.js');
97
+ const html = await renderToString(template, { ssr: true });
98
+ container.innerHTML = html;
99
+ } else {
100
+ throw new Error('ssrFixture() expects an html`…` template or an HTML string');
101
+ }
102
+
103
+ const el = container.firstElementChild;
104
+ if (!el) throw new Error('ssrFixture() produced no element');
105
+
106
+ // Drive the real update cycle. The browser upgrades the SSR'd custom
107
+ // element on innerHTML assignment; its connectedCallback queues the first
108
+ // render. Awaiting the native updateComplete promise (not a timer) is the
109
+ // whole point: the test observes the post-hydration DOM deterministically.
110
+ await flushUpdate(el);
111
+ return el;
112
+ }
113
+
60
114
  /**
61
115
  * Wait for a component's next render cycle to complete.
62
116
  *
@@ -65,17 +119,105 @@ export async function fixture(template) {
65
119
  * subscribes to, any change that triggers a re-render. The re-render
66
120
  * is async (microtask-batched), so you need to await before asserting.
67
121
  *
122
+ * Awaits the element's native `updateComplete` promise when present (the
123
+ * real render-cycle resolution), falling back to a microtask flush for a
124
+ * plain element that has no `updateComplete` (back-compatible).
125
+ *
68
126
  * @param {Element} el The component element.
69
127
  * @returns {Promise<void>}
70
128
  */
71
129
  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.
130
+ await flushUpdate(el);
131
+ }
132
+
133
+ /**
134
+ * Await the real update cycle of an element. For a WebComponent this awaits
135
+ * the native `updateComplete` promise; for anything else it yields two
136
+ * macrotasks (the legacy behaviour) so cascading updates settle.
137
+ *
138
+ * @param {Element} el
139
+ * @returns {Promise<void>}
140
+ */
141
+ async function flushUpdate(el) {
142
+ const uc = el && /** @type {any} */ (el).updateComplete;
143
+ if (uc && typeof uc.then === 'function') {
144
+ await uc;
145
+ // A microtask yield lets any cascading child update flush too.
146
+ await Promise.resolve();
147
+ return;
148
+ }
75
149
  await new Promise((r) => setTimeout(r, 0));
76
150
  await new Promise((r) => setTimeout(r, 0));
77
151
  }
78
152
 
153
+ /**
154
+ * Assert an element's subtree has no accessibility violations, using the
155
+ * axe-core engine in the real browser. OPT-IN: nothing calls this for you,
156
+ * it is never a forced gate.
157
+ *
158
+ * axe-core is a TEST-ONLY peer: it is imported dynamically here, so it is
159
+ * NOT a hard dependency of `@webjsdev/core`. An app that does not do a11y
160
+ * testing never needs it. Install it where you run the test:
161
+ * `npm install -D axe-core`.
162
+ *
163
+ * On a violation it throws an Error whose message lists each violation's id,
164
+ * impact, a short help string, and the failing nodes' selectors, so the test
165
+ * failure is actionable. On zero violations it resolves.
166
+ *
167
+ * ```js
168
+ * import { ssrFixture, assertNoA11yViolations } from '@webjsdev/core/testing';
169
+ * const el = await ssrFixture(html`<my-form></my-form>`);
170
+ * await assertNoA11yViolations(el);
171
+ * // tune rules: await assertNoA11yViolations(el, { rules: { 'color-contrast': { enabled: false } } });
172
+ * ```
173
+ *
174
+ * @param {Element} el The element (subtree root) to scan.
175
+ * @param {object} [opts] Options passed through to `axe.run` (e.g. `rules`).
176
+ * @returns {Promise<void>}
177
+ */
178
+ export async function assertNoA11yViolations(el, opts) {
179
+ if (!el || typeof el !== 'object') {
180
+ throw new Error('assertNoA11yViolations() expects a DOM element');
181
+ }
182
+ let axe;
183
+ try {
184
+ const mod = await import('axe-core');
185
+ // axe-core ships a UMD bundle. Depending on how the test runner / bundler
186
+ // wraps it, the engine may sit on the namespace, on `.default`, on a
187
+ // nested `.default.default`, or only as the `window.axe` side-effect the
188
+ // UMD installs. Probe in that order for a `run` function.
189
+ const candidates = [mod, mod && mod.default, mod && mod.default && mod.default.default,
190
+ typeof globalThis !== 'undefined' ? globalThis.axe : undefined];
191
+ axe = candidates.find((c) => c && typeof c.run === 'function');
192
+ } catch {
193
+ throw new Error(
194
+ 'assertNoA11yViolations needs axe-core. Install it: npm install -D axe-core'
195
+ );
196
+ }
197
+ if (!axe || typeof axe.run !== 'function') {
198
+ throw new Error(
199
+ 'assertNoA11yViolations needs axe-core. Install it: npm install -D axe-core'
200
+ );
201
+ }
202
+
203
+ const results = await axe.run(el, opts || {});
204
+ const violations = results.violations || [];
205
+ if (violations.length === 0) return;
206
+
207
+ const lines = violations.map((v) => {
208
+ const targets = (v.nodes || [])
209
+ .map((n) => (n.target || []).join(' '))
210
+ .filter(Boolean)
211
+ .join(', ');
212
+ return ` - [${v.id}] (${v.impact || 'unknown'}) ${v.help}${targets ? ` -> ${targets}` : ''}`;
213
+ });
214
+ const err = new Error(
215
+ `a11y: ${violations.length} accessibility violation(s) found:\n${lines.join('\n')}`
216
+ );
217
+ err.name = 'AssertionError';
218
+ throw err;
219
+ }
220
+
79
221
  /**
80
222
  * Simulate a click event on an element.
81
223
  *
@@ -35,26 +35,174 @@
35
35
  * frame appears in the response, the router falls through to the
36
36
  * normal layout-marker mechanism (and then to full body swap).
37
37
  *
38
+ * External targeting (Turbo's `data-turbo-frame`): a trigger (an `<a>`
39
+ * or a `<form>`) that is NOT nested inside the frame can still drive it
40
+ * by carrying `data-webjs-frame="<id>"`. The router resolves the id via
41
+ * `getElementById` in the current document, so an external nav/sidebar
42
+ * link or a filter form drives a content frame it does not enclose. An
43
+ * id that does not resolve to a live `<webjs-frame>` warns once and falls
44
+ * back to a normal navigation (fail-safe, never a wrong-region swap). The
45
+ * reserved token `data-webjs-frame="_top"` on a trigger INSIDE a frame
46
+ * breaks OUT to a full-page navigation instead of swapping the frame.
47
+ *
48
+ * Busy state: while a frame's navigation is in flight the router sets the
49
+ * native `aria-busy="true"` on the frame element and clears it (to
50
+ * `"false"`) on completion, success, failure, or abort, so assistive tech
51
+ * announces the loading state and CSS can style `webjs-frame[aria-busy="true"]`.
52
+ * It also dispatches a bubbling `webjs:frame-busy` event on the frame at
53
+ * both edges (detail `{ frameId, busy: true }` then `{ frameId, busy: false }`),
54
+ * so app code can hook the start and finish.
55
+ *
38
56
  * The element is **light DOM**: no shadow root, no slot mechanics.
39
57
  * Children are normal light-DOM children that the router replaces
40
58
  * via the same keyed reconciler used for layout marker swaps. This
41
59
  * means scroll position, input values, and `<details>` open state
42
60
  * are preserved for any matched elements inside the frame.
43
61
  *
62
+ * With JS disabled a `data-webjs-frame` link is an inert attribute on a
63
+ * plain `<a href>`, so the click is a normal full-page navigation, the
64
+ * correct progressive-enhancement fallback.
65
+ *
66
+ * Self-loading (`src` + `loading`): a frame MAY fetch its own content. With
67
+ * a `src` attribute the frame self-fetches that URL as a frame nav and applies
68
+ * the matching `<webjs-frame id>` subtree from the response into itself, through
69
+ * the SAME router frame-swap path a click-driven frame nav uses (so the busy
70
+ * lifecycle, the navigation-error recovery, and the frame-missing fallback all
71
+ * apply). The `loading` attribute picks WHEN:
72
+ *
73
+ * - `loading="eager"` (or absent, the default): fetch on `connectedCallback`.
74
+ * - `loading="lazy"`: fetch when the frame first scrolls into view, reusing
75
+ * the same IntersectionObserver budget (`rootMargin: '200px'`) as a
76
+ * `static lazy = true` component.
77
+ *
78
+ * A `src` change after connect re-loads. Eager-connect, the lazy observer, and
79
+ * a `src` mutation never double-fire: a per-element loaded/loading guard keyed
80
+ * on the resolved URL coalesces them.
81
+ *
82
+ * PROGRESSIVE ENHANCEMENT CAVEAT: a `src`-driven frame is JS-DEPENDENT. The
83
+ * browser does NOT natively fetch `<webjs-frame src>` (unlike `<iframe>`), so
84
+ * with JS off the frame shows only whatever children were server-rendered into
85
+ * it. Use `src`/`loading` for DEFERRED content (comments, a recommendations
86
+ * rail, an expensive card) where a JS-off empty/placeholder state is acceptable.
87
+ * For content that MUST exist without JS, render it server-side into the frame
88
+ * instead of using `src` (the self-load then replaces those fallback children).
89
+ *
44
90
  * @element webjs-frame
45
91
  * @attr {string} id: Required. The frame's identifier, used by
46
- * `closest()` on the client and `querySelector` on the response.
92
+ * `closest()` on the client, `getElementById` for external
93
+ * `data-webjs-frame` targeting, and `querySelector` on the response.
94
+ * @attr {string} src: Optional. A same-origin URL the frame self-fetches as a
95
+ * frame nav, applying the matching `<webjs-frame id>` subtree into itself.
96
+ * @attr {string} loading: Optional. `eager` (default) fetches `src` on connect;
97
+ * `lazy` fetches when the frame scrolls into view.
98
+ * @fires webjs:frame-busy: Bubbling. Dispatched on the frame when its
99
+ * navigation starts (`detail.busy === true`) and finishes
100
+ * (`detail.busy === false`); `detail.frameId` names the frame.
47
101
  */
48
102
  // Defined lazily inside an IIFE so that importing this module on the
49
103
  // server (Node) doesn't trip a `HTMLElement is not defined` reference
50
104
  // 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.
105
+ // is always rendered as `<webjs-frame id=... src=... loading=...>` plain
106
+ // HTML by the renderToString pipeline.
53
107
  const WebjsFrame = (typeof HTMLElement !== 'undefined')
54
108
  ? 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.
109
+ constructor() {
110
+ super();
111
+ /**
112
+ * The `src` value most recently loaded (or loading). Guards against a
113
+ * double-load when eager connect, the lazy observer, and a `src`
114
+ * mutation all want to fire: a load only starts when the resolved URL
115
+ * differs from this. `null` means "nothing loaded yet".
116
+ * @type {string | null}
117
+ */
118
+ this._webjsLoadedSrc = null;
119
+ /**
120
+ * Teardown for the lazy IntersectionObserver subscription, so a
121
+ * disconnect or a `src` change stops observing.
122
+ * @type {(() => void) | null}
123
+ */
124
+ this._webjsLazyTeardown = null;
125
+ }
126
+
127
+ static get observedAttributes() { return ['src']; }
128
+
129
+ connectedCallback() {
130
+ this._webjsScheduleLoad();
131
+ }
132
+
133
+ disconnectedCallback() {
134
+ if (this._webjsLazyTeardown) { this._webjsLazyTeardown(); this._webjsLazyTeardown = null; }
135
+ }
136
+
137
+ /**
138
+ * @param {string} name
139
+ * @param {string | null} _old
140
+ * @param {string | null} _next
141
+ */
142
+ attributeChangedCallback(name, _old, _next) {
143
+ // Only react to a `src` change AFTER the element is connected (the
144
+ // initial attribute parse fires this before connectedCallback; the
145
+ // connect path handles the first load). A live `src` swap re-loads.
146
+ if (name === 'src' && this.isConnected) this._webjsScheduleLoad();
147
+ }
148
+
149
+ /**
150
+ * Decide whether and when to self-load based on `src` + `loading`,
151
+ * coalescing the three triggers (eager connect, lazy viewport, `src`
152
+ * mutation) so the frame never double-fetches the same URL.
153
+ */
154
+ _webjsScheduleLoad() {
155
+ const src = this.getAttribute('src');
156
+ // No src: nothing to self-load (a normal swap-anchor frame). Drop any
157
+ // pending lazy observation left over from a previous src.
158
+ if (!src) {
159
+ if (this._webjsLazyTeardown) { this._webjsLazyTeardown(); this._webjsLazyTeardown = null; }
160
+ this._webjsLoadedSrc = null;
161
+ return;
162
+ }
163
+ // Resolve so the dedupe key matches what loadFrame requests, and so a
164
+ // relative `src` compares stably.
165
+ let resolved = src;
166
+ try { resolved = new URL(src, location.href).href; } catch { /* keep raw */ }
167
+ // Already loaded (or loading) this exact URL: no double-load.
168
+ if (resolved === this._webjsLoadedSrc) return;
169
+
170
+ // A new src supersedes any in-flight lazy observation.
171
+ if (this._webjsLazyTeardown) { this._webjsLazyTeardown(); this._webjsLazyTeardown = null; }
172
+
173
+ const lazy = (this.getAttribute('loading') || 'eager').toLowerCase() === 'lazy';
174
+ if (lazy) {
175
+ // Defer the fetch until the frame enters the viewport, reusing the
176
+ // shared per-element IntersectionObserver. Mark the URL as claimed
177
+ // NOW so a redundant schedule (e.g. attributeChanged firing again
178
+ // with the same src) does not re-observe.
179
+ this._webjsLoadedSrc = resolved;
180
+ import('./lazy-loader.js').then(({ observeViewportOnce }) => {
181
+ // Bail if the src changed again while the import was in flight.
182
+ if (this._webjsLoadedSrc !== resolved || !this.isConnected) return;
183
+ this._webjsLazyTeardown = observeViewportOnce(this, () => {
184
+ this._webjsLazyTeardown = null;
185
+ this._webjsLoad(resolved);
186
+ });
187
+ });
188
+ } else {
189
+ this._webjsLoadedSrc = resolved;
190
+ this._webjsLoad(resolved);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Run the actual frame self-load through the router's frame-swap path.
196
+ * @param {string} resolved The already-resolved absolute URL.
197
+ */
198
+ _webjsLoad(resolved) {
199
+ import('./router-client.js').then(({ loadFrame }) => {
200
+ // The src may have changed again (or the frame disconnected) between
201
+ // scheduling and the import resolving; only load if we still own it.
202
+ if (this._webjsLoadedSrc !== resolved || !this.isConnected) return;
203
+ loadFrame(this, resolved);
204
+ });
205
+ }
58
206
  }
59
207
  : /** @type {any} */ (null);
60
208
 
@@ -0,0 +1,170 @@
1
+ /**
2
+ * `<webjs-stream action="..." target="...">`: a self-applying surgical
3
+ * DOM-update element (Turbo Streams parity, webjs-shaped).
4
+ *
5
+ * The router's primary mechanism swaps a whole layout-marker region (or a
6
+ * `<webjs-frame>`). That is coarse: a comment-append, an optimistic row
7
+ * removal, or a toast insertion has to redraw a whole region or hand-write
8
+ * DOM JS. `<webjs-stream>` is the element-level grammar for those surgical
9
+ * updates. The payload is plain HTML the browser already parses: a
10
+ * `<webjs-stream>` wrapping a single `<template>`, carrying an `action` and a
11
+ * `target` (an element id) or `targets` (a CSS selector). There is no protocol
12
+ * parser; the element clones its `<template>` content and applies it with
13
+ * native DOM methods, then removes itself.
14
+ *
15
+ * <webjs-stream action="append" target="comments">
16
+ * <template><li>Nice post!</li></template>
17
+ * </webjs-stream>
18
+ *
19
+ * Actions (mirroring Turbo's set):
20
+ *
21
+ * - `append` / `prepend`: add the template content as the last / first
22
+ * children of the target.
23
+ * - `before` / `after`: insert the template content as siblings before /
24
+ * after the target.
25
+ * - `replace`: replace the target element itself with the template content.
26
+ * - `update`: replace the target's children with the template content.
27
+ * - `remove`: remove the target element (no template needed).
28
+ *
29
+ * Two delivery paths share this ONE applier:
30
+ *
31
+ * - **HTTP (content-negotiated form response).** A `<form>` submission rides
32
+ * the client router, which sends `Accept: text/vnd.webjs-stream.html`. When
33
+ * the server answers with that content type, the router applies the
34
+ * `<webjs-stream>` elements in the body surgically (no region swap). With
35
+ * JS OFF the browser sends no such Accept, so the server returns a normal
36
+ * render/redirect and the form degrades to a full-page round-trip. The
37
+ * grammar is therefore additive and progressive-enhancement-safe.
38
+ * - **Live channel (`broadcast()` / `connectWS`).** A server message that is
39
+ * a `<webjs-stream>` HTML string is applied by `renderStream(message)` from
40
+ * a `connectWS` handler, so chat / notifications / presence reuse the same
41
+ * applier instead of bespoke per-app DOM code.
42
+ *
43
+ * The element is inert server-side: `renderToString` emits it as plain
44
+ * `<webjs-stream action=... target=...>` HTML and never touches the class
45
+ * (it is defined only when `HTMLElement` exists). A `<webjs-stream>` that is
46
+ * server-rendered into a page (rather than streamed in) self-applies on
47
+ * hydration exactly as a streamed one does, so there is no separate code path.
48
+ *
49
+ * @element webjs-stream
50
+ * @attr {string} action: Required. One of append / prepend / before / after /
51
+ * replace / update / remove.
52
+ * @attr {string} target: An element id to apply the action to.
53
+ * @attr {string} targets: A CSS selector matching one or more targets (applied
54
+ * to each). `target` and `targets` are mutually exclusive; `target` wins.
55
+ */
56
+
57
+ /** The actions that operate by inserting the template content. */
58
+ const INSERT_ACTIONS = new Set(['append', 'prepend', 'before', 'after', 'replace', 'update']);
59
+
60
+ /**
61
+ * Apply one resolved action against one target element.
62
+ * @param {string} action
63
+ * @param {Element} target
64
+ * @param {DocumentFragment | null} frag Cloned template content (null for remove).
65
+ */
66
+ function applyTo(action, target, frag) {
67
+ switch (action) {
68
+ case 'append': if (frag) target.append(frag); break;
69
+ case 'prepend': if (frag) target.prepend(frag); break;
70
+ case 'before': if (frag) target.before(frag); break;
71
+ case 'after': if (frag) target.after(frag); break;
72
+ case 'replace': if (frag) target.replaceWith(frag); break;
73
+ case 'update': if (frag) target.replaceChildren(frag); break;
74
+ case 'remove': target.remove(); break;
75
+ default: break; // unknown action: no-op
76
+ }
77
+ }
78
+
79
+ const WebjsStream = (typeof HTMLElement !== 'undefined')
80
+ ? class WebjsStream extends HTMLElement {
81
+ connectedCallback() {
82
+ // Apply once. A streamed-in element runs this on insertion; a
83
+ // server-rendered one runs it on upgrade. Either way it self-removes.
84
+ if (this._webjsApplied) return;
85
+ this._webjsApplied = true;
86
+ this._webjsApply();
87
+ }
88
+
89
+ _webjsApply() {
90
+ const action = (this.getAttribute('action') || '').toLowerCase();
91
+ const doc = this.ownerDocument || document;
92
+ // Resolve targets: an id via `target`, or a selector via `targets`.
93
+ /** @type {Element[]} */
94
+ let targets = [];
95
+ const id = this.getAttribute('target');
96
+ if (id) {
97
+ const el = doc.getElementById(id);
98
+ if (el) targets = [el];
99
+ } else {
100
+ const sel = this.getAttribute('targets');
101
+ if (sel) {
102
+ try { targets = Array.from(doc.querySelectorAll(sel)); } catch { targets = []; }
103
+ }
104
+ }
105
+ // The single child <template> carries the content for an insert action.
106
+ const tpl = INSERT_ACTIONS.has(action)
107
+ ? /** @type {HTMLTemplateElement | null} */ (this.querySelector('template'))
108
+ : null;
109
+ try {
110
+ for (const target of targets) {
111
+ // Clone per target so multiple `targets` each get their own nodes.
112
+ const frag = tpl ? /** @type {DocumentFragment} */ (tpl.content.cloneNode(true)) : null;
113
+ applyTo(action, target, frag);
114
+ }
115
+ } finally {
116
+ // The stream element itself never stays in the live DOM.
117
+ this.remove();
118
+ }
119
+ }
120
+ }
121
+ : /** @type {any} */ (null);
122
+
123
+ if (typeof customElements !== 'undefined' && WebjsStream && !customElements.get('webjs-stream')) {
124
+ customElements.define('webjs-stream', WebjsStream);
125
+ }
126
+
127
+ /**
128
+ * Apply a server-sent stream payload to the live DOM. The payload is HTML
129
+ * containing one or more `<webjs-stream>` elements (each wrapping a
130
+ * `<template>`); appending them to the document upgrades each one, which
131
+ * self-applies its action and self-removes. Use this from a `connectWS` /
132
+ * `broadcast` message handler so a live channel reuses the same applier as the
133
+ * HTTP path.
134
+ *
135
+ * connectWS('/feed', { onMessage: (m) => renderStream(m) });
136
+ *
137
+ * @param {string | DocumentFragment | Node} input The stream HTML (or parsed nodes).
138
+ * @param {Document} [doc] The document to apply into (defaults to `document`).
139
+ */
140
+ function renderStream(input, doc) {
141
+ const d = doc || (typeof document !== 'undefined' ? document : null);
142
+ if (!d) return;
143
+ /** @type {DocumentFragment | Node | null} */
144
+ let frag = null;
145
+ if (typeof input === 'string') {
146
+ const tpl = d.createElement('template');
147
+ tpl.innerHTML = input;
148
+ frag = tpl.content;
149
+ } else if (input && /** @type any */ (input).nodeType) {
150
+ frag = input; // a DocumentFragment or a single Node
151
+ } else {
152
+ return;
153
+ }
154
+ // Append ONLY the <webjs-stream> elements (each self-applies + self-removes on
155
+ // connect). Skipping the intervening whitespace text nodes a multi-action
156
+ // payload carries (the parts are joined by newlines) avoids accreting stray
157
+ // text nodes in <body> on a long-lived live channel.
158
+ const found = /** @type any */ (frag).querySelectorAll
159
+ ? Array.from(/** @type any */ (frag).querySelectorAll('webjs-stream'))
160
+ : [];
161
+ if (found.length) {
162
+ for (const s of found) d.body.append(s);
163
+ } else {
164
+ // No nested <webjs-stream> found (e.g. a single element passed directly):
165
+ // append the input verbatim so the caller's node still reaches the DOM.
166
+ d.body.append(frag);
167
+ }
168
+ }
169
+
170
+ export { WebjsStream, renderStream };