@webjsdev/core 0.7.38 → 0.7.39

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.38",
3
+ "version": "0.7.39",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
@@ -718,11 +718,30 @@ function setNavigating(on) {
718
718
  * appear multiple times in a document only if a layout transitively
719
719
  * includes itself (pathological; we take the outermost).
720
720
  *
721
+ * `recoverOrphans` (#994): register an OPEN marker whose closing
722
+ * `<!--/wj:children-->` comment is missing, using `end: null` (meaning "the
723
+ * children run to the end of the containing element"). Some browsers
724
+ * intermittently DROP the trailing close comment while parsing a soft-nav
725
+ * response, a parse/timing race that surfaced first on Android Chrome (a soft
726
+ * nav to `/blog` randomly dropped the navbar; #939/#940 confirmed the OPEN
727
+ * marker survived, `markers:1`, yet the router still fell to the destructive
728
+ * full-body swap) and was later reproduced on DESKTOP Chromium too, so this is
729
+ * NOT browser-specific, only rarer on faster hardware. Without both comments
730
+ * this walk registers no slot, so `longestSharedPath` finds nothing and
731
+ * `applySwap` wipes the outer layout (navbar). Recovering the orphaned open lets
732
+ * the correct scoped swap run and keeps the navbar (it sits BEFORE the open
733
+ * marker, so it is never in the swap range). The recovery keys off the SYMPTOM
734
+ * (a missing close), never any user-agent check, so it fixes every engine
735
+ * uniformly. Off by default so the exported helper's strict pairing is
736
+ * unchanged; the swap-decision and `X-Webjs-Have` call sites opt in.
737
+ *
721
738
  * @param {ParentNode} root
722
- * @returns {Map<string, { start: Comment, end: Comment }>}
739
+ * @param {{ recoverOrphans?: boolean }} [options]
740
+ * @returns {Map<string, { start: Comment, end: Comment | null }>}
723
741
  */
724
- export function collectChildrenSlots(root) {
725
- /** @type {Map<string, { start: Comment, end: Comment }>} */
742
+ export function collectChildrenSlots(root, options) {
743
+ const recoverOrphans = !!(options && options.recoverOrphans);
744
+ /** @type {Map<string, { start: Comment, end: Comment | null }>} */
726
745
  const slots = new Map();
727
746
  /** @type {{ path: string, start: Comment }[]} */
728
747
  const stack = [];
@@ -756,6 +775,18 @@ export function collectChildrenSlots(root) {
756
775
  }
757
776
  }
758
777
  visit(/** @type {Node} */ (root));
778
+
779
+ // #994: any open marker still on the stack was never closed (its
780
+ // `<!--/wj:children-->` was dropped by the browser's parser, any engine).
781
+ // Register it with a null end so the shared-path match succeeds and the scoped
782
+ // swap preserves the outer layout, instead of the destructive full-body
783
+ // fallback. Outermost first (stack order), never overwriting a slot a proper
784
+ // close already paired.
785
+ if (recoverOrphans) {
786
+ for (const frame of stack) {
787
+ if (!slots.has(frame.path)) slots.set(frame.path, { start: frame.start, end: null });
788
+ }
789
+ }
759
790
  return slots;
760
791
  }
761
792
 
@@ -1047,6 +1078,18 @@ async function performSubmission(href, method, body, frameId, form) {
1047
1078
  * @returns {string}
1048
1079
  */
1049
1080
  function buildHaveHeader() {
1081
+ // Strict pairing here, deliberately (#994): if the SOLE or OUTERMOST close
1082
+ // marker was dropped, its orphaned open is NOT reported, so `have` omits that
1083
+ // layout and the server returns the FULL page (with the outer layout's trailing
1084
+ // chrome) rather than a reduced marker-pair-only fragment. `applySwap` then
1085
+ // recovers the orphan and bounds the swap against that full page's
1086
+ // trailing-sibling count, preserving the navbar and the footer. Recovering here
1087
+ // would send `have=/`, get back a reduced fragment with no trailing chrome
1088
+ // (`tail === 0`), and sweep an unwrapped layout's footer. The extra bytes of a
1089
+ // full page on the rare orphaned nav are the price of that correctness. A
1090
+ // dropped INNER close in a nested layout is a separate, unfixed mispairing
1091
+ // limitation (the surviving outer close pairs with the inner open); the wrapped
1092
+ // `${children}` idiom keeps it harmless.
1050
1093
  const slots = collectChildrenSlots(document.body);
1051
1094
  return [...slots.keys()].join(',');
1052
1095
  }
@@ -2299,7 +2342,7 @@ function findInSlice(slice, id) {
2299
2342
  * swap settles. The View Transitions API snapshots and replaces DOM, so
2300
2343
  * elements can need a re-upgrade once the animation finishes.
2301
2344
  *
2302
- * @param {{ start: Comment, end: Comment } | undefined} range
2345
+ * @param {{ start: Comment, end: Comment | null } | undefined} range
2303
2346
  */
2304
2347
  function upgradeCustomElementsInRange(range) {
2305
2348
  if (!range || !range.start) return;
@@ -2493,19 +2536,33 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
2493
2536
  return;
2494
2537
  }
2495
2538
 
2496
- // 2. Auto-derived layout-marker swap.
2497
- const here = collectChildrenSlots(document.body);
2498
- const there = collectChildrenSlots(doc.body);
2539
+ // 2. Auto-derived layout-marker swap. Recover an orphaned open marker on
2540
+ // either side (#994): a dropped close comment must not force the destructive
2541
+ // path-3 fallback that wipes the outer layout (navbar).
2542
+ const here = collectChildrenSlots(document.body, { recoverOrphans: true });
2543
+ const there = collectChildrenSlots(doc.body, { recoverOrphans: true });
2499
2544
  const sharedPath = longestSharedPath(here, there);
2500
2545
 
2501
2546
  if (sharedPath) {
2547
+ const target = here.get(sharedPath);
2548
+ const source = there.get(sharedPath);
2549
+ // #994: a recovered orphan carries `end: null` (children run to the marker's
2550
+ // PARENT end). That is exactly right when the children fill their parent (the
2551
+ // shipped idiom wraps `${children}` in a `<main>`, so the close marker is the
2552
+ // parent's last child and trailing chrome like `<footer>` is a sibling
2553
+ // OUTSIDE it). But a layout that puts trailing content in the marker's OWN
2554
+ // parent would have that content swept (a live orphan) or duplicated (an
2555
+ // incoming orphan). When exactly one side is orphaned, bound it against the
2556
+ // well-formed side's trailing-sibling count so the trailing chrome is
2557
+ // preserved either way.
2558
+ boundRecoveredEnds(target, source);
2502
2559
  // ADD-ONLY head merge for the same reason: outer layout stays
2503
2560
  // mounted, its head-bound runtime state must not be invalidated.
2504
2561
  addNewHeadElements(doc.head);
2505
2562
  runWithTransition(() => {
2506
- swapMarkerRange(here.get(sharedPath), there.get(sharedPath), doc);
2563
+ swapMarkerRange(target, source, doc);
2507
2564
  blurOutgoingFocus();
2508
- }, () => upgradeCustomElementsInRange(here.get(sharedPath)));
2565
+ }, () => upgradeCustomElementsInRange(target));
2509
2566
  forwardSuspenseResolvers(doc.body);
2510
2567
  return;
2511
2568
  }
@@ -2562,6 +2619,78 @@ function blurOutgoingFocus() {
2562
2619
  /** @type any */ (a).blur();
2563
2620
  }
2564
2621
 
2622
+ /**
2623
+ * Count the siblings after `marker` up to the end of its parent. Used to
2624
+ * measure how much trailing outer-layout chrome sits after a close marker
2625
+ * WITHIN the marker's own parent (#994).
2626
+ *
2627
+ * @param {Comment} marker
2628
+ * @returns {number}
2629
+ */
2630
+ function trailingSiblingCount(marker) {
2631
+ let n = 0;
2632
+ for (let s = marker.nextSibling; s; s = s.nextSibling) n++;
2633
+ return n;
2634
+ }
2635
+
2636
+ /**
2637
+ * Give a recovered orphan (`end: null`, #994) a concrete bounded end so trailing
2638
+ * outer-layout chrome in the marker's own parent is preserved. Runs only when
2639
+ * EXACTLY one side is orphaned: the well-formed side's close marker reveals how
2640
+ * many trailing siblings sit after the children within the parent (`tail`), and
2641
+ * a same-layout nav shares that outer-layout structure, so the orphan's children
2642
+ * end `tail` nodes before its parent's end. A `tail` of zero (the shipped
2643
+ * wrap-`${children}`-in-`<main>` idiom, where the close is the parent's last
2644
+ * child) leaves `end: null` and the sweep-to-parent-end stays correct. Both-side
2645
+ * orphans keep `null` on both (symmetric sweep, nothing to align against).
2646
+ *
2647
+ * This works for a SOLE or OUTERMOST dropped close because `buildHaveHeader`
2648
+ * reports STRICTLY paired markers, so an orphaned live page omits the layout from
2649
+ * `have` and the server returns the FULL page (trailing chrome included,
2650
+ * `tail > 0`) rather than a reduced marker-pair-only fragment. Two cases it does
2651
+ * NOT cover: an incoming side whose close was lost during parse (rare: the
2652
+ * response is parsed from a complete string, not streamed), and a dropped INNER
2653
+ * close in a nested layout (the surviving outer close mispairs with the inner
2654
+ * open, so both slot ends are non-null and this bounding is skipped). The wrapped
2655
+ * `${children}` idiom keeps both harmless (the marker's parent holds only the
2656
+ * children, so any sweep stays inside it).
2657
+ *
2658
+ * @param {{ start: Comment, end: Comment | null } | undefined} target
2659
+ * @param {{ start: Comment, end: Comment | null } | undefined} source
2660
+ */
2661
+ function boundRecoveredEnds(target, source) {
2662
+ if (!target || !source) return;
2663
+ if (target.end === null && source.end !== null) target.end = boundOrphanEnd(target.start, source.end);
2664
+ else if (source.end === null && target.end !== null) source.end = boundOrphanEnd(source.start, target.end);
2665
+ }
2666
+
2667
+ /**
2668
+ * Compute the bounded end sentinel (exclusive) for an orphaned open marker,
2669
+ * preserving `tail` trailing siblings at the end of the marker's parent, where
2670
+ * `tail` is the well-formed counterpart's trailing-sibling count (#994).
2671
+ *
2672
+ * @param {Comment} orphanStart The orphan's open marker.
2673
+ * @param {Comment} wellFormedEnd The counterpart's real close marker.
2674
+ * @returns {Comment | Node | null} A node to stop before, or null (sweep to end).
2675
+ */
2676
+ function boundOrphanEnd(orphanStart, wellFormedEnd) {
2677
+ const tail = trailingSiblingCount(wellFormedEnd);
2678
+ if (tail === 0) return null;
2679
+ // Collect the orphan's children region (start.nextSibling to parent end).
2680
+ /** @type {Node[]} */
2681
+ const nodes = [];
2682
+ for (let n = orphanStart.nextSibling; n; n = n.nextSibling) nodes.push(n);
2683
+ // Preserve the last `tail` nodes by stopping before index `cut`. A `cut` at or
2684
+ // below zero is a degenerate mismatch (the well-formed side claims more
2685
+ // trailing siblings than the orphan has nodes at all, which a same-layout nav
2686
+ // should never produce): fall back to `null` (sweep to the parent end, a normal
2687
+ // full-region swap) rather than an empty range, which would duplicate or blank
2688
+ // the children.
2689
+ const cut = nodes.length - tail;
2690
+ if (cut <= 0) return null;
2691
+ return nodes[cut];
2692
+ }
2693
+
2565
2694
  /**
2566
2695
  * Replace nodes between `target.start` and `target.end` (exclusive) in the
2567
2696
  * live document with the nodes between `source.start` and `source.end` in
@@ -2569,8 +2698,13 @@ function blurOutgoingFocus() {
2569
2698
  * identity for matched elements + their live attributes (scroll, value,
2570
2699
  * etc.).
2571
2700
  *
2572
- * @param {{ start: Comment, end: Comment } | undefined} target
2573
- * @param {{ start: Comment, end: Comment } | undefined} source
2701
+ * A null `end` (an orphaned open marker recovered per #994) means "run to the
2702
+ * end of the containing element": the slice-collection loops stop at the natural
2703
+ * sibling end and `reconcileSiblings` appends before `null` (end of parent), so
2704
+ * a dropped close comment swaps the whole children region without a close marker.
2705
+ *
2706
+ * @param {{ start: Comment, end: Comment | null } | undefined} target
2707
+ * @param {{ start: Comment, end: Comment | null } | undefined} source
2574
2708
  * @param {Document} _doc
2575
2709
  */
2576
2710
  function swapMarkerRange(target, source, _doc) {
@@ -2634,7 +2768,7 @@ function swapMarkerRange(target, source, _doc) {
2634
2768
  *
2635
2769
  * @param {Node} parent
2636
2770
  * @param {Comment} startMarker
2637
- * @param {Comment} endMarker
2771
+ * @param {Comment | null} endMarker Null (recovered orphan, #994) appends at the parent end.
2638
2772
  * @param {Node[]} live
2639
2773
  * @param {Node[]} incoming
2640
2774
  */