@webjsdev/core 0.7.42 → 0.7.43
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/webjs-core-browser.js +5 -3
- package/dist/webjs-core-browser.js.map +2 -2
- package/dist/webjs-core.js +5 -3
- package/dist/webjs-core.js.map +2 -2
- package/package.json +1 -1
- package/src/router-client.js +208 -11
package/package.json
CHANGED
package/src/router-client.js
CHANGED
|
@@ -780,6 +780,24 @@ function devWarnFallback(cause, href) {
|
|
|
780
780
|
);
|
|
781
781
|
}
|
|
782
782
|
|
|
783
|
+
/**
|
|
784
|
+
* Dev-only, fire-once-per-id hint: a streamed Suspense resolution arrived but
|
|
785
|
+
* its boundary placeholder was not in the DOM, so it was dropped (#1051). This
|
|
786
|
+
* is benign when the navigation was superseded, degraded to a full load, or
|
|
787
|
+
* discarded, but a stuck skeleton that is NONE of those has no other signal (it
|
|
788
|
+
* is what made the #1048 view-transition race hard to diagnose). Never warns in
|
|
789
|
+
* production, never throws.
|
|
790
|
+
*
|
|
791
|
+
* @param {string} id the streamed boundary id that could not be applied
|
|
792
|
+
*/
|
|
793
|
+
function warnDropped(id) {
|
|
794
|
+
if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') return;
|
|
795
|
+
warnOnce(
|
|
796
|
+
`stream-drop:${id}`,
|
|
797
|
+
`[webjs] dropped a streamed Suspense resolve for "${id}": no #${id} boundary in the DOM. Benign if this navigation was superseded or degraded. A stuck skeleton here means the shell swap did not place the boundary.`
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
|
|
783
801
|
/**
|
|
784
802
|
* Dev-only, fire-once hint: the router forces an INSTANT scroll-to-top on a
|
|
785
803
|
* forward navigation (matching a native page load), so an app-level
|
|
@@ -2161,14 +2179,18 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
|
|
|
2161
2179
|
// now live, so stream the resolved boundaries in fast-before-slow. Detached
|
|
2162
2180
|
// (fire-and-forget) so the URL advance + navigate event do not wait on the
|
|
2163
2181
|
// slow boundary; each apply is guarded by the nav token so a newer navigation
|
|
2164
|
-
// stops it.
|
|
2182
|
+
// stops it. Gated on the swap COMMIT (`_swapCommit`): under an async view
|
|
2183
|
+
// transition the shell swap is deferred a frame, so applying a resolve before
|
|
2184
|
+
// the placeholder is in the DOM dropped the boundary and stuck the skeleton
|
|
2185
|
+
// (#1048). On the synchronous path `_swapCommit` is already resolved, so this
|
|
2186
|
+
// is a same-microtask no-op there.
|
|
2165
2187
|
if (streamCtx && (streamCtx.reader || streamCtx.rest)) {
|
|
2166
|
-
streamBoundariesProgressively(
|
|
2188
|
+
_swapCommit.then(() => streamBoundariesProgressively(
|
|
2167
2189
|
streamCtx.reader,
|
|
2168
2190
|
streamCtx.dec,
|
|
2169
2191
|
streamCtx.rest,
|
|
2170
2192
|
() => myToken === currentNavigationToken,
|
|
2171
|
-
);
|
|
2193
|
+
));
|
|
2172
2194
|
}
|
|
2173
2195
|
|
|
2174
2196
|
document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url: finalUrl, frameId, from: 'navigate' } }));
|
|
@@ -2450,10 +2472,19 @@ function runWithTransition(thunk, afterFinished) {
|
|
|
2450
2472
|
} else if (afterFinished) {
|
|
2451
2473
|
afterFinished();
|
|
2452
2474
|
}
|
|
2453
|
-
|
|
2475
|
+
// Resolve when the DOM MUTATION (the thunk) has actually committed, NOT when
|
|
2476
|
+
// the animation finishes. Under `startViewTransition` the thunk is deferred a
|
|
2477
|
+
// frame, so anything that reads the swapped-in DOM (a progressively-streamed
|
|
2478
|
+
// Suspense resolve, #1048) must await this, or it runs against the pre-swap
|
|
2479
|
+
// DOM and drops. `updateCallbackDone` is that signal; fall back to a resolved
|
|
2480
|
+
// promise if the browser does not expose it.
|
|
2481
|
+
return (t && t.updateCallbackDone && typeof t.updateCallbackDone.then === 'function')
|
|
2482
|
+
? t.updateCallbackDone.catch(() => {})
|
|
2483
|
+
: Promise.resolve();
|
|
2454
2484
|
}
|
|
2455
2485
|
thunk();
|
|
2456
2486
|
if (afterFinished) afterFinished();
|
|
2487
|
+
return Promise.resolve();
|
|
2457
2488
|
}
|
|
2458
2489
|
|
|
2459
2490
|
/**
|
|
@@ -2597,6 +2628,16 @@ function upgradeCustomElementsInRange(range) {
|
|
|
2597
2628
|
}
|
|
2598
2629
|
}
|
|
2599
2630
|
|
|
2631
|
+
/**
|
|
2632
|
+
* Resolves when the most recent `applySwap` DOM mutation has committed. Under an
|
|
2633
|
+
* async view transition the swap is deferred a frame, so the progressive
|
|
2634
|
+
* Suspense streamer must await this before applying resolves, or it targets the
|
|
2635
|
+
* pre-swap DOM (no placeholder yet) and drops the boundary (#1048). A resolved
|
|
2636
|
+
* promise on the synchronous (no-transition) path.
|
|
2637
|
+
* @type {Promise<void>}
|
|
2638
|
+
*/
|
|
2639
|
+
let _swapCommit = Promise.resolve();
|
|
2640
|
+
|
|
2600
2641
|
function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc) {
|
|
2601
2642
|
// SSR action seeding (#472): ingest any seed payload the incoming page
|
|
2602
2643
|
// carries BEFORE its components are grafted into the live DOM and upgrade, so
|
|
@@ -2771,13 +2812,17 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
2771
2812
|
// by node identity (it imports the incoming children first, then
|
|
2772
2813
|
// swaps the live permanent node into the imported tree), so the live
|
|
2773
2814
|
// `<audio>`/widget keeps running across the frame swap.
|
|
2774
|
-
|
|
2815
|
+
// Capture the swap commit like the other swap paths so a frame nav that
|
|
2816
|
+
// ALSO progressively streams a Suspense boundary gates its resolves on the
|
|
2817
|
+
// committed frame swap, not a stale prior _swapCommit (#1048).
|
|
2818
|
+
_swapCommit = runWithTransition(() => {
|
|
2775
2819
|
diffChildren(target, source);
|
|
2776
2820
|
reactivateScripts(target);
|
|
2777
2821
|
upgradeCustomElements(target);
|
|
2822
|
+
// Inside the swap so the placeholder exists before we resolve (#1048).
|
|
2823
|
+
forwardSuspenseResolvers(doc.body);
|
|
2778
2824
|
blurOutgoingFocus();
|
|
2779
2825
|
}, () => upgradeCustomElements(target));
|
|
2780
|
-
forwardSuspenseResolvers(doc.body);
|
|
2781
2826
|
return;
|
|
2782
2827
|
}
|
|
2783
2828
|
// The response did not carry the requested frame (source null), or the
|
|
@@ -2815,7 +2860,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
2815
2860
|
// ADD-ONLY head merge: the outer layout stays mounted, so its head-bound
|
|
2816
2861
|
// runtime state (Tailwind injection, etc.) must not be invalidated.
|
|
2817
2862
|
addNewHeadElements(doc.head);
|
|
2818
|
-
runWithTransition(() => {
|
|
2863
|
+
_swapCommit = runWithTransition(() => {
|
|
2819
2864
|
if (mode === 'replace') replaceBoundaryRange(live, incoming);
|
|
2820
2865
|
else swapMarkerRange(live, incoming, doc);
|
|
2821
2866
|
// No key sync is needed on the anchor's own comments: the plan's anchor
|
|
@@ -2832,9 +2877,12 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
2832
2877
|
// applySlotAssignments would wipe the freshly swapped content and
|
|
2833
2878
|
// restore the stale list.
|
|
2834
2879
|
resyncEnclosingHostSlots(live.start, incoming.start);
|
|
2880
|
+
// Resolve buffered Suspense boundaries INSIDE the swap so the placeholder
|
|
2881
|
+
// exists first. Doing this after `runWithTransition` returned raced the
|
|
2882
|
+
// async view-transition swap and stuck the skeleton (#1048).
|
|
2883
|
+
forwardSuspenseResolvers(doc.body);
|
|
2835
2884
|
blurOutgoingFocus();
|
|
2836
2885
|
}, () => upgradeCustomElementsInRange(live));
|
|
2837
|
-
forwardSuspenseResolvers(doc.body);
|
|
2838
2886
|
return;
|
|
2839
2887
|
}
|
|
2840
2888
|
|
|
@@ -2883,7 +2931,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
2883
2931
|
upgradeCustomElements(document.body);
|
|
2884
2932
|
blurOutgoingFocus();
|
|
2885
2933
|
};
|
|
2886
|
-
runWithTransition(doSwap, () => upgradeCustomElements(document.body));
|
|
2934
|
+
_swapCommit = runWithTransition(doSwap, () => upgradeCustomElements(document.body));
|
|
2887
2935
|
}
|
|
2888
2936
|
|
|
2889
2937
|
/**
|
|
@@ -3708,6 +3756,109 @@ function outerHTMLForDiff(el) {
|
|
|
3708
3756
|
return clone.outerHTML;
|
|
3709
3757
|
}
|
|
3710
3758
|
|
|
3759
|
+
/**
|
|
3760
|
+
* The one framework-owned keyed meta that must NEVER be reconciled: the CSP
|
|
3761
|
+
* nonce. A soft-nav response carries a FRESH per-request nonce, but the browser
|
|
3762
|
+
* enforces CSP against the nonce the ORIGINAL page load declared (see
|
|
3763
|
+
* `getCspNonce`), so overwriting the live `csp-nonce` meta with the incoming
|
|
3764
|
+
* one would make every later nonce-stamped script/preload violate the active
|
|
3765
|
+
* policy. Excluded from add/update/remove so the original meta survives verbatim.
|
|
3766
|
+
*/
|
|
3767
|
+
const META_KEY_CSP_NONCE = 'name=csp-nonce';
|
|
3768
|
+
|
|
3769
|
+
/**
|
|
3770
|
+
* Stable identity key for a `<meta>` that represents a single logical tag, so a
|
|
3771
|
+
* PAGE-SCOPED meta can be reconciled across a soft-nav head merge (#1046). A
|
|
3772
|
+
* meta with no identifying attribute returns null and is left to the add-only
|
|
3773
|
+
* path (added but never removed), since its identity is ambiguous.
|
|
3774
|
+
*
|
|
3775
|
+
* @param {Element} m
|
|
3776
|
+
* @returns {string | null}
|
|
3777
|
+
*/
|
|
3778
|
+
function metaIdentity(m) {
|
|
3779
|
+
const name = m.getAttribute('name');
|
|
3780
|
+
if (name) return 'name=' + name;
|
|
3781
|
+
const property = m.getAttribute('property');
|
|
3782
|
+
if (property) return 'property=' + property;
|
|
3783
|
+
const httpEquiv = m.getAttribute('http-equiv');
|
|
3784
|
+
if (httpEquiv) return 'http-equiv=' + httpEquiv;
|
|
3785
|
+
if (m.hasAttribute('charset')) return 'charset';
|
|
3786
|
+
return null;
|
|
3787
|
+
}
|
|
3788
|
+
|
|
3789
|
+
/**
|
|
3790
|
+
* Reconcile keyed `<meta>` tags across a soft-nav head merge (#1046). The
|
|
3791
|
+
* add-only merge (`addNewHeadElements`) never removes a stale head element, so a
|
|
3792
|
+
* PAGE-SCOPED meta the previous page added (a `view-transition` opt-in, a
|
|
3793
|
+
* per-page `robots` / `theme-color` / `description`, an `og:*` property) leaked
|
|
3794
|
+
* onto every later page. This pass gives each keyed meta the full add / update /
|
|
3795
|
+
* remove treatment: a meta present in the incoming head is added or synced, and
|
|
3796
|
+
* a live keyed meta ABSENT from the incoming head is removed.
|
|
3797
|
+
*
|
|
3798
|
+
* Safe against the `X-Webjs-Have` reduced-head optimization (#936): that
|
|
3799
|
+
* optimization only omits the shared app STYLESHEET (already on the client), and
|
|
3800
|
+
* this pass touches ONLY `<meta>` tags, never a stylesheet / link / script. The
|
|
3801
|
+
* incoming head always carries the target page's complete meta set (charset,
|
|
3802
|
+
* viewport, and the app-wide metas from the root layout appear in both heads, so
|
|
3803
|
+
* they are preserved), so "absent from the incoming head" means "this page does
|
|
3804
|
+
* not declare it", not "optimized away".
|
|
3805
|
+
*
|
|
3806
|
+
* A key may repeat (multiple `og:image`), so both sides are grouped into a LIST
|
|
3807
|
+
* per key and reconciled as a set: an unchanged set is left alone, else the live
|
|
3808
|
+
* copies are removed and the incoming set re-appended.
|
|
3809
|
+
*
|
|
3810
|
+
* @param {HTMLHeadElement} newHead
|
|
3811
|
+
*/
|
|
3812
|
+
function reconcileHeadMetas(newHead) {
|
|
3813
|
+
// A HEADLESS fragment response (a `<webjs-frame>` subtree) has no `<head>`, so
|
|
3814
|
+
// `parseHTML` leaves `newHead` empty. A real full head ALWAYS emits charset +
|
|
3815
|
+
// viewport, so "no `<meta>` at all in the incoming head" means "this is a
|
|
3816
|
+
// fragment, not a head to reconcile against". Skipping it here is what keeps a
|
|
3817
|
+
// frame swap from stripping every live page-scoped meta (viewport, og:*, ...).
|
|
3818
|
+
if (!newHead.querySelector('meta')) return;
|
|
3819
|
+
|
|
3820
|
+
/** @param {ParentNode} root @returns {Map<string, Element[]>} */
|
|
3821
|
+
const group = (root) => {
|
|
3822
|
+
const map = new Map();
|
|
3823
|
+
for (const el of root.querySelectorAll('meta')) {
|
|
3824
|
+
const key = metaIdentity(el);
|
|
3825
|
+
if (!key || key === META_KEY_CSP_NONCE) continue;
|
|
3826
|
+
const list = map.get(key);
|
|
3827
|
+
if (list) list.push(el); else map.set(key, [el]);
|
|
3828
|
+
}
|
|
3829
|
+
return map;
|
|
3830
|
+
};
|
|
3831
|
+
const incoming = group(newHead);
|
|
3832
|
+
const live = group(document.head);
|
|
3833
|
+
|
|
3834
|
+
// Add or replace each incoming key whose SET differs from the live set.
|
|
3835
|
+
for (const [key, incEls] of incoming) {
|
|
3836
|
+
const liveEls = live.get(key) || [];
|
|
3837
|
+
const incKey = incEls.map(outerHTMLForDiff).join('\n');
|
|
3838
|
+
const liveKey = liveEls.map(outerHTMLForDiff).join('\n');
|
|
3839
|
+
if (incKey === liveKey) continue;
|
|
3840
|
+
if (incEls.length === 1 && liveEls.length === 1) {
|
|
3841
|
+
// The common case (one description / theme-color / robots per page):
|
|
3842
|
+
// sync attributes IN PLACE so the live element keeps its DOM identity.
|
|
3843
|
+
// An app script holding a reference (a theme manager caching
|
|
3844
|
+
// meta[name=theme-color]) still points at the live tag after the nav,
|
|
3845
|
+
// and there is no remove/append churn for a content-only change.
|
|
3846
|
+
const cur = liveEls[0];
|
|
3847
|
+
for (const a of [...cur.attributes]) cur.removeAttribute(a.name);
|
|
3848
|
+
for (const a of incEls[0].attributes) cur.setAttribute(a.name, a.value);
|
|
3849
|
+
continue;
|
|
3850
|
+
}
|
|
3851
|
+
// Multi-element sets (repeated og:image): no unambiguous element-to-element
|
|
3852
|
+
// mapping exists, so replace the set wholesale.
|
|
3853
|
+
for (const el of liveEls) el.remove();
|
|
3854
|
+
for (const el of incEls) document.head.appendChild(cloneElementWithCorrectNonce(el));
|
|
3855
|
+
}
|
|
3856
|
+
// Remove a stale page-scoped key the incoming page does not declare at all.
|
|
3857
|
+
for (const [key, liveEls] of live) {
|
|
3858
|
+
if (!incoming.has(key)) for (const el of liveEls) el.remove();
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3711
3862
|
function addNewHeadElements(newHead) {
|
|
3712
3863
|
const newTitle = newHead.querySelector('title');
|
|
3713
3864
|
if (newTitle) document.title = newTitle.textContent || '';
|
|
@@ -3727,6 +3878,9 @@ function addNewHeadElements(newHead) {
|
|
|
3727
3878
|
}
|
|
3728
3879
|
if (el.tagName === 'BASE') continue;
|
|
3729
3880
|
if (el.tagName === 'TITLE') continue;
|
|
3881
|
+
// A keyed <meta> is add/update/remove reconciled below (#1046), so skip it
|
|
3882
|
+
// here to avoid appending a duplicate when its content changed.
|
|
3883
|
+
if (el.tagName === 'META' && metaIdentity(el)) continue;
|
|
3730
3884
|
if (!currentSet.has(outerHTMLForDiff(el))) {
|
|
3731
3885
|
if (el.tagName === 'SCRIPT') {
|
|
3732
3886
|
document.head.appendChild(
|
|
@@ -3737,6 +3891,10 @@ function addNewHeadElements(newHead) {
|
|
|
3737
3891
|
}
|
|
3738
3892
|
}
|
|
3739
3893
|
}
|
|
3894
|
+
|
|
3895
|
+
// Reconcile keyed <meta> tags so a stale page-scoped meta is removed, not
|
|
3896
|
+
// leaked onto every later page (#1046).
|
|
3897
|
+
reconcileHeadMetas(newHead);
|
|
3740
3898
|
}
|
|
3741
3899
|
|
|
3742
3900
|
/**
|
|
@@ -3797,6 +3955,12 @@ function mergeHead(newHead) {
|
|
|
3797
3955
|
// headline #936 symptom. Keeping it is safe: a genuinely stale sheet is
|
|
3798
3956
|
// dropped by the deploy-level hard reload (build-id mismatch), not here.
|
|
3799
3957
|
if (isPersistentHeadStyle(el)) continue;
|
|
3958
|
+
// Never remove the CSP nonce meta: the incoming full-body response carries a
|
|
3959
|
+
// FRESH per-request nonce, but the browser enforces CSP against the nonce the
|
|
3960
|
+
// ORIGINAL page load declared (see `getCspNonce`), so the live one must stay
|
|
3961
|
+
// (#1050). `outerHTMLForDiff` strips the nonce ATTRIBUTE but not the `content`
|
|
3962
|
+
// it lives in on this meta, so without this it looks "changed" and is dropped.
|
|
3963
|
+
if (el.tagName === 'META' && metaIdentity(el) === META_KEY_CSP_NONCE) continue;
|
|
3800
3964
|
if (!newSet.has(outerHTMLForDiff(el))) el.remove();
|
|
3801
3965
|
}
|
|
3802
3966
|
|
|
@@ -3804,6 +3968,10 @@ function mergeHead(newHead) {
|
|
|
3804
3968
|
if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
|
|
3805
3969
|
if (el.tagName === 'BASE') continue;
|
|
3806
3970
|
if (el.tagName === 'TITLE') continue;
|
|
3971
|
+
// Do not append the incoming per-request csp-nonce meta (the live original is
|
|
3972
|
+
// kept above), or the head would carry two and `getCspNonce` could read the
|
|
3973
|
+
// wrong one (#1050).
|
|
3974
|
+
if (el.tagName === 'META' && metaIdentity(el) === META_KEY_CSP_NONCE) continue;
|
|
3807
3975
|
if (!currentSet.has(outerHTMLForDiff(el))) {
|
|
3808
3976
|
if (el.tagName === 'SCRIPT') {
|
|
3809
3977
|
currentHead.appendChild(
|
|
@@ -3848,7 +4016,21 @@ function upgradeTree(root) {
|
|
|
3848
4016
|
*/
|
|
3849
4017
|
function forwardSuspenseResolvers(fetchedBody) {
|
|
3850
4018
|
for (const tpl of fetchedBody.querySelectorAll('template[data-webjs-resolve]')) {
|
|
3851
|
-
|
|
4019
|
+
const clone = /** @type {HTMLTemplateElement} */ (tpl.cloneNode(true));
|
|
4020
|
+
document.body.appendChild(clone);
|
|
4021
|
+
// Resolve SYNCHRONOUSLY against the just-swapped DOM instead of relying on
|
|
4022
|
+
// the inline MutationObserver. The observer fires on a microtask, which
|
|
4023
|
+
// races an async `startViewTransition` swap: with view transitions on, the
|
|
4024
|
+
// swap that places the `#<id>` placeholder is deferred a frame, so the
|
|
4025
|
+
// observer ran first, found no placeholder, and the skeleton stuck (#1048).
|
|
4026
|
+
// Called from INSIDE the swap thunk (below), the placeholder is already in
|
|
4027
|
+
// the DOM here, so this replaces it within the same commit (and inside any
|
|
4028
|
+
// wrapping view transition, so the transition captures the resolved
|
|
4029
|
+
// content, not the fallback). Falls back to the observer if the page-level
|
|
4030
|
+
// resolver global is somehow absent.
|
|
4031
|
+
const id = clone.getAttribute('data-webjs-resolve');
|
|
4032
|
+
const resolve = /** @type {any} */ (window).__webjsResolve;
|
|
4033
|
+
if (id && typeof resolve === 'function') resolve(id);
|
|
3852
4034
|
}
|
|
3853
4035
|
}
|
|
3854
4036
|
|
|
@@ -3952,7 +4134,22 @@ function takeResolveUnit(buf) {
|
|
|
3952
4134
|
*/
|
|
3953
4135
|
function applyStreamedResolve(id, content) {
|
|
3954
4136
|
const boundary = document.getElementById(id);
|
|
3955
|
-
|
|
4137
|
+
// A missing boundary is dropped (non-destructive), exactly as before. The
|
|
4138
|
+
// async-view-transition race that USED to drop a still-valid boundary (#1048)
|
|
4139
|
+
// is handled upstream: `streamBoundariesProgressively` is gated on the swap
|
|
4140
|
+
// COMMIT (`_swapCommit`), so the placeholder is already live by the time any
|
|
4141
|
+
// resolve is applied. A retry here would run OUTSIDE the streamer's
|
|
4142
|
+
// `isCurrent()` nav-token fence and could splice a superseded nav's content
|
|
4143
|
+
// into a recycled boundary id, so it is deliberately not attempted.
|
|
4144
|
+
if (!boundary) {
|
|
4145
|
+
// Dev-only diagnostic (#1051): the drop is benign for the normal reasons (a
|
|
4146
|
+
// superseded / degraded / discarded nav), but a stuck skeleton that ISN'T
|
|
4147
|
+
// one of those is otherwise silent, which is exactly what made #1048 hard to
|
|
4148
|
+
// find. Surface the dropped boundary so a future regression is one glance
|
|
4149
|
+
// away. Never in production, never throws, once per id.
|
|
4150
|
+
warnDropped(id);
|
|
4151
|
+
return;
|
|
4152
|
+
}
|
|
3956
4153
|
const tpl = document.createElement('template');
|
|
3957
4154
|
tpl.innerHTML = content;
|
|
3958
4155
|
const inserted = [...tpl.content.childNodes];
|