@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.42",
3
+ "version": "0.7.43",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
@@ -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
- return;
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
- runWithTransition(() => {
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
- document.body.appendChild(tpl.cloneNode(true));
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
- if (!boundary) return;
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];