@swiftbrowser/web 0.6.3 → 0.6.4

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": "@swiftbrowser/web",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
package/src/animation.ts CHANGED
@@ -116,6 +116,11 @@ export interface FrameChange {
116
116
  to: FrameRect;
117
117
  }
118
118
 
119
+ /** A screen below the top of its navigation stack (the renderer marks it). */
120
+ const BELOW_SCREEN = '.sb-navscreen[data-sb-nav-position="below"]';
121
+ /** A push or pop in progress in a stack: its top screen sliding in, or a popped screen's clone sliding out. */
122
+ const NAV_IN_FLIGHT = ':scope > .sb-slot > .sb-navscreen--push, :scope > .sb-navscreen--pop';
123
+
119
124
  export const DEFAULT_DURATION = 0.35;
120
125
  export const EASE_IN_OUT = 'cubic-bezier(0.42, 0, 0.58, 1)';
121
126
  export const EASE_IN = 'cubic-bezier(0.42, 0, 1, 1)';
@@ -534,6 +539,25 @@ export class Animator {
534
539
  const matchedPairs = this.matchedPairs(pass);
535
540
  const matchedTo = new Map<RemovedRecord, { from: DOMRect; to: DOMRect }>();
536
541
 
542
+ // What a screen below the top of its stack holds is not drawn once the
543
+ // push has settled (styles.css: `visibility: hidden`), so it is not
544
+ // animated: it jumps to its new boxes, as the rows of a list a pushed
545
+ // screen covers do when a split view's sidebar slides. During a push or
546
+ // pop the lower screen shows, sliding, so then it animates as usual.
547
+ const hiddenScreens = new Map<HTMLElement, boolean>();
548
+ const hidden = (el: HTMLElement): boolean => {
549
+ for (let screen = el.closest<HTMLElement>(BELOW_SCREEN); screen; screen = screen.parentElement?.closest<HTMLElement>(BELOW_SCREEN) ?? null) {
550
+ let settled = hiddenScreens.get(screen);
551
+ if (settled === undefined) {
552
+ const stack = screen.closest<HTMLElement>('.sb-navstack');
553
+ settled = stack !== null && stack.querySelector(NAV_IN_FLIGHT) === null;
554
+ hiddenScreens.set(screen, settled);
555
+ }
556
+ if (settled) return true;
557
+ }
558
+ return false;
559
+ };
560
+
537
561
  const animationFor = (chain: Iterable<HTMLElement>): Animation | undefined | false => {
538
562
  let depth = 0;
539
563
  for (const el of chain) {
@@ -549,7 +573,7 @@ export class Animator {
549
573
 
550
574
  // Style changes.
551
575
  for (const [el, rec] of pass.updated) {
552
- if (!rec.before || !el.isConnected) continue;
576
+ if (!rec.before || !el.isConnected || hidden(el)) continue;
553
577
  const animation = animationFor(ancestorsAndSelf(el));
554
578
  if (animation === false) continue;
555
579
  this.animateChanges(el, rec.before, animation);
@@ -558,7 +582,7 @@ export class Animator {
558
582
  // What elements animate themselves (a custom shape's path).
559
583
  if (this.playOwnFrames) {
560
584
  for (const el of pass.updated.keys()) {
561
- if (!el.isConnected) continue;
585
+ if (!el.isConnected || hidden(el)) continue;
562
586
  const animation = animationFor(ancestorsAndSelf(el));
563
587
  if (animation !== false) this.playOwnFrames(el, animation);
564
588
  }
@@ -572,6 +596,12 @@ export class Animator {
572
596
  const { el, from, to } = change;
573
597
  if (!from || !el.isConnected || pass.inserted.has(el)) continue;
574
598
  if (this.selfAnimating.has(change.kind)) continue;
599
+ if (hidden(el)) {
600
+ // Not drawn: a tween still running on it (from a pass before the
601
+ // push settled) carries on to the new box; otherwise it jumps.
602
+ this.retargetFrame(change, null);
603
+ continue;
604
+ }
575
605
  let animation = animationFor(ancestorsAndSelf(el));
576
606
  if (animation === false && change.kind === BAR_GLASS_KIND && glassScreens.has(el.parentElement)) {
577
607
  animation = TOOLBAR_ANIMATION;
@@ -590,8 +620,9 @@ export class Animator {
590
620
  // change still moves with an ancestor that jumped.
591
621
  const changed = new Set(frames.map((change) => change.el));
592
622
  for (const [el, animation] of tokenRoots) {
593
- if (changed.has(el) || !el.isConnected || pass.inserted.has(el)) continue;
594
- const box = { x: parseFloat(el.style.left) || 0, y: parseFloat(el.style.top) || 0, width: el.offsetWidth, height: el.offsetHeight };
623
+ if (changed.has(el) || !el.isConnected || pass.inserted.has(el) || hidden(el)) continue;
624
+ // (the inline frame the layout applied: no layout read mid-commit)
625
+ const box = { x: parseFloat(el.style.left) || 0, y: parseFloat(el.style.top) || 0, width: parseFloat(el.style.width) || 0, height: parseFloat(el.style.height) || 0 };
595
626
  tweens.push({ change: { el, kind: el.dataset['sbKind'] ?? '', from: box, to: box }, from: box, animation: animation ?? { type: 'default' }, moved: false });
596
627
  }
597
628
  // An animated element under an ancestor that jumps (outside the animated
@@ -626,7 +657,7 @@ export class Animator {
626
657
  // Inserted elements: only subtree roots (a nested fade would compound).
627
658
  for (const el of pass.inserted) {
628
659
  if (!el.isConnected || pass.updated.has(el)) continue;
629
- if (hasAncestorIn(el, pass.inserted)) continue;
660
+ if (hasAncestorIn(el, pass.inserted) || hidden(el)) continue;
630
661
  if (this.selfAnimating.has(el.dataset['sbKind'] ?? '')) continue;
631
662
  const animation = animationFor(ancestorsAndSelf(el));
632
663
  if (animation === false) continue;
@@ -635,7 +666,7 @@ export class Animator {
635
666
 
636
667
  // Content transitions: the old text leaves as the new one arrives.
637
668
  for (const change of pass.contentChanges) {
638
- if (!change.el.isConnected) continue;
669
+ if (!change.el.isConnected || hidden(change.el)) continue;
639
670
  const animation = animationFor(ancestorsAndSelf(change.el));
640
671
  if (animation === false) continue;
641
672
  this.transitionContent(change, animation);
@@ -97,6 +97,8 @@ export interface InteractionHost {
97
97
  nodeOf(id: ElementId): LayoutNode | undefined;
98
98
  /** Open the popover of a `contextmenu` element anchored to it. */
99
99
  openContextMenu(anchor: HTMLElement, id: ElementId): void;
100
+ /** A covered screen is about to show (a back swipe starts): lay it out if it is stale. */
101
+ revealScreen(screen: HTMLElement): void;
100
102
  /** Whether the popover of element `id` is open. */
101
103
  isMenuOpen(id: ElementId): boolean;
102
104
  closeMenu(): void;
@@ -550,6 +552,7 @@ export class InteractionController {
550
552
  c.screen.dataset['sbSwipeDragging'] = '';
551
553
  c.screen.style.transition = 'none';
552
554
  if (c.below) {
555
+ this.host.revealScreen(c.below);
553
556
  c.below.style.transition = 'none';
554
557
  c.below.style.visibility = 'visible';
555
558
  }
package/src/largeTitle.ts CHANGED
@@ -20,6 +20,7 @@
20
20
 
21
21
  import type { Rect } from './layout';
22
22
  import { edgeStrength, setProperty, setValue } from './scrollEdge';
23
+ import { runScrollSync, type ScrollSync } from './scrollSync';
23
24
 
24
25
  /** The scroll offset past which the title has gone under the bar. */
25
26
  const COLLAPSE_OFFSET = 44;
@@ -102,16 +103,24 @@ function releaseTitle(screen: HTMLElement, scroller: HTMLElement): void {
102
103
  }
103
104
 
104
105
  export function syncScrollUnderBar(scroller: HTMLElement): void {
105
- if (scroller.dataset['sbUnderBar'] === undefined) return;
106
- const screen = screenOf(scroller);
107
- if (!screen) return;
108
- const offset = scroller.scrollTop;
109
- setFlag(screen, 'sbScrolled', offset > 0);
110
- // The edge effect's strength ramps up over the first `EDGE_RAMP` px.
111
- setProperty(screen, '--sb-edge-top', edgeStrength(offset));
112
- const title = titles.get(screen);
113
- const carries = title !== undefined && title.parentElement === scroller;
114
- setFlag(screen, 'sbCollapsed', carries && offset > COLLAPSE_OFFSET);
106
+ runScrollSync(scrollUnderBarSync(scroller));
107
+ }
108
+
109
+ /** The sync as a read (`scrollTop`) and the writes from it (scrollSync.ts). */
110
+ function scrollUnderBarSync(scroller: HTMLElement): ScrollSync<number | null> {
111
+ return {
112
+ measure: () => (scroller.dataset['sbUnderBar'] === undefined || !screenOf(scroller) ? null : scroller.scrollTop),
113
+ apply: (offset) => {
114
+ const screen = screenOf(scroller);
115
+ if (offset === null || !screen) return;
116
+ setFlag(screen, 'sbScrolled', offset > 0);
117
+ // The edge effect's strength ramps up over the first `EDGE_RAMP` px.
118
+ setProperty(screen, '--sb-edge-top', edgeStrength(offset));
119
+ const title = titles.get(screen);
120
+ const carries = title !== undefined && title.parentElement === scroller;
121
+ setFlag(screen, 'sbCollapsed', carries && offset > COLLAPSE_OFFSET);
122
+ },
123
+ };
115
124
  }
116
125
 
117
126
  function setFlag(el: HTMLElement, key: string, on: boolean): void {
@@ -1228,6 +1228,8 @@ export class LayoutPass {
1228
1228
  private readonly rootContext: Context;
1229
1229
  /** Phase 9: where (and for what proposal) every node was last placed. */
1230
1230
  private readonly placed = new Map<LayoutNode, { key: string; x: number; y: number }>();
1231
+ /** Covered screens to lay out anyway (`uncover`): one a back swipe is about to show. */
1232
+ private readonly uncovered = new Set<LayoutNode>();
1231
1233
  /** Phase 9: the ids each node hid when it was last placed (menu items, tabs, ...). */
1232
1234
  private readonly hiddenBy = new Map<LayoutNode, number[]>();
1233
1235
  /** Phase 9: ids placed or hidden in the current `run`; what the DOM pass must visit. */
@@ -1389,9 +1391,22 @@ export class LayoutPass {
1389
1391
  for (const child of node.children) this.invalidateDown(child);
1390
1392
  }
1391
1393
 
1394
+ /**
1395
+ * A covered screen is about to show without a pop (a back swipe drags
1396
+ * the top screen aside): lay it out on the next run even though it is
1397
+ * covered, in case the stack resized or its content changed meanwhile.
1398
+ */
1399
+ uncover(screen: LayoutNode): void {
1400
+ this.uncovered.add(screen);
1401
+ // The run must reach the stack: its ancestors' plans go (not the
1402
+ // screen's own, so one nothing changed under is still skipped as placed).
1403
+ for (let current = this.parentOf(screen); current; current = this.parentOf(current)) this.forgetPlan(current);
1404
+ }
1405
+
1392
1406
  /** `node` left the tree: drop everything its subtree produced. */
1393
1407
  remove(node: LayoutNode): void {
1394
1408
  this.forgetPlan(node);
1409
+ this.uncovered.delete(node);
1395
1410
  if (node.id >= 0) {
1396
1411
  this.frames.delete(node.id);
1397
1412
  this.text.delete(node.id);
@@ -2996,7 +3011,24 @@ export class LayoutPass {
2996
3011
  const previous = index > 0 ? node.children[index - 1] : undefined;
2997
3012
  this.previousTitles.set(child, previous ? (str(previous.props['title']) ?? '') : '');
2998
3013
  });
2999
- for (const child of node.children) this.place(child, cctx, screenProposal, x, y);
3014
+ // A screen below the top one is not drawn once its push has settled
3015
+ // (the page hides it), so a screen that was laid out once keeps the
3016
+ // frames it has while it is covered, however the stack resizes or
3017
+ // its content changes meanwhile: on Food Truck's order detail the
3018
+ // covered Orders list is 89% of the boxes. Its plan is left as it
3019
+ // is, so when a pop uncovers it, `placedAlready` passes only if
3020
+ // nothing changed since those frames were made, else it is laid
3021
+ // out afresh. A screen never laid out (pushed under another in one
3022
+ // pass) is placed, so it has frames to show when uncovered.
3023
+ const top = node.children[node.children.length - 1];
3024
+ for (const child of node.children) {
3025
+ if (child !== top && child.id >= 0 && this.frames.has(child.id) && !this.uncovered.has(child)) {
3026
+ this.stats.skipped += 1;
3027
+ continue;
3028
+ }
3029
+ if (child === top) this.uncovered.delete(child);
3030
+ this.place(child, cctx, screenProposal, x, y);
3031
+ }
3000
3032
  return;
3001
3033
  }
3002
3034
  case 'navscreen':
package/src/layoutDom.ts CHANGED
@@ -42,6 +42,7 @@ import { bitmapLaidOut } from './bitmaps';
42
42
  import { drawRichText } from './richText';
43
43
  import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls } from './largeTitle';
44
44
  import { bindScrollEdgeBottom } from './scrollEdge';
45
+ import { beginScrollSyncBatch, flushScrollSyncBatch } from './scrollSync';
45
46
  import { BAR_GLASS_KIND, TOOLBAR_ANIMATION, TOOLBAR_TRANSITION, baseTiming, timingFor, transitionStart } from './animation';
46
47
 
47
48
  export interface LayoutDomContext {
@@ -80,8 +81,31 @@ const SWITCH_SIZE = { width: 51, height: 31 };
80
81
  const CHEVRON_SIZE = 14;
81
82
  const NAV_BAR_HEIGHT = 44;
82
83
 
84
+ /**
85
+ * The changes of the layout being applied, for `placeChrome` to report the
86
+ * chrome it moves (null outside `applyLayout`).
87
+ */
88
+ let chromeChanges: BoxChange[] | null = null;
89
+
90
+ /** Kind of a chrome node's `BoxChange` (no id): tweened with its element's pass. */
91
+ export const CHROME_KIND = 'chrome';
92
+
83
93
  export function applyLayout(ctx: LayoutDomContext, result: LayoutResult): BoxChange[] {
84
94
  const changes: BoxChange[] = [];
95
+ chromeChanges = changes;
96
+ // The scroll views' state syncs read scroll metrics; batched, they share
97
+ // one layout after the pass's writes instead of forcing one each.
98
+ beginScrollSyncBatch();
99
+ try {
100
+ applyFrames(ctx, result, changes);
101
+ } finally {
102
+ chromeChanges = null;
103
+ flushScrollSyncBatch();
104
+ }
105
+ return changes;
106
+ }
107
+
108
+ function applyFrames(ctx: LayoutDomContext, result: LayoutResult, changes: BoxChange[]): void {
85
109
  // Phase 9: an incremental result names what it touched; everything else
86
110
  // kept its frame, chrome and box. A one-off result is visited whole.
87
111
  const boxes = result.changed ? ctx.boxes : new Map<number, Rect>();
@@ -132,7 +156,6 @@ export function applyLayout(ctx: LayoutDomContext, result: LayoutResult): BoxCha
132
156
 
133
157
  ctx.boxes = boxes;
134
158
  ctx.dirty.clear();
135
- return changes;
136
159
  }
137
160
 
138
161
  const LIST_ROW_MIN_HEIGHT = 44;
@@ -307,7 +330,7 @@ function syncBarGlass(
307
330
  changes.push({ id: -1, el: glass, kind: BAR_GLASS_KIND, from: null, to: relative });
308
331
  }
309
332
  glass.dataset['sbToolbars'] = ids.join(' ');
310
- placeChrome(glass, rect, frame);
333
+ placeChrome(glass, rect, frame, false);
311
334
  }
312
335
  for (const extra of unmatched) transitionGlass(extra, true);
313
336
  }
@@ -346,19 +369,31 @@ function transitionGlass(glass: HTMLElement, exit: boolean): void {
346
369
  window.setTimeout(done, timing.duration + timing.delay + 100);
347
370
  }
348
371
 
349
- function placeChrome(node: HTMLElement | null, rect: Rect | undefined, origin: Rect): void {
372
+ /**
373
+ * Position a chrome node inside its element: `rect` is in screen coordinates.
374
+ * A node that was shown and moves or resizes is reported (kind `chrome`), so
375
+ * an animated pass tweens it with the boxes around it: a section's card, a
376
+ * row's badge or chevron follow their row as a split view's column widens,
377
+ * instead of jumping to the end (`report` false: the caller reports it).
378
+ */
379
+ function placeChrome(node: HTMLElement | null, rect: Rect | undefined, origin: Rect, report = true): void {
350
380
  if (!node) return;
351
381
  if (!rect) {
352
382
  node.hidden = true;
353
383
  return;
354
384
  }
355
- node.hidden = false;
356
385
  const s = node.style;
386
+ const to = { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width, height: rect.height };
387
+ if (report && chromeChanges && !node.hidden && s.position === 'absolute') {
388
+ const from = { x: parseFloat(s.left) || 0, y: parseFloat(s.top) || 0, width: parseFloat(s.width) || 0, height: parseFloat(s.height) || 0 };
389
+ if (!sameRect(from, to)) chromeChanges.push({ id: -1, el: node, kind: CHROME_KIND, from, to });
390
+ }
391
+ node.hidden = false;
357
392
  s.position = 'absolute';
358
- s.left = `${rect.x - origin.x}px`;
359
- s.top = `${rect.y - origin.y}px`;
360
- s.width = `${rect.width}px`;
361
- s.height = `${rect.height}px`;
393
+ s.left = `${to.x}px`;
394
+ s.top = `${to.y}px`;
395
+ s.width = `${to.width}px`;
396
+ s.height = `${to.height}px`;
362
397
  }
363
398
 
364
399
  function positionSheetCard(sheet: HTMLElement, frame: Rect): void {
@@ -754,6 +789,13 @@ function applyChrome(
754
789
  // The bar pads its row 16 (styles.css); a split view's sidebar
755
790
  // toggle can push the back button further in (chrome.back).
756
791
  const indent = barRect ? Math.max(0, backRect.x - barRect.x - 16) : 0;
792
+ const was = parseFloat(back.style.marginLeft) || 0;
793
+ // A split view's sidebar sliding in or out moves it in with the
794
+ // column (tweened as an offset: it is laid out in the bar's row).
795
+ if (indent !== was && !isNew && chromeChanges) {
796
+ const size = { y: 0, width: backRect.width, height: backRect.height };
797
+ chromeChanges.push({ id: -1, el: back, kind: CHROME_KIND, from: { x: was, ...size }, to: { x: indent, ...size } });
798
+ }
757
799
  back.style.marginLeft = indent > 0 ? `${indent}px` : '';
758
800
  backLabel.dataset['sbLayoutLabel'] = text;
759
801
  if (backLabel.textContent !== text) backLabel.textContent = text;
package/src/renderer.ts CHANGED
@@ -43,7 +43,7 @@ import {
43
43
  transitionOf,
44
44
  type PassRecord,
45
45
  type UpdatedRecord,
46
- } from './animation';
46
+ type StyleSnapshot } from './animation';
47
47
  import {
48
48
  CanvasTextMeasurer,
49
49
  LayoutPass,
@@ -340,6 +340,14 @@ export class Renderer {
340
340
  private passAnimates: boolean | null = null;
341
341
  /** The animation root's rectangle, read once per pass that animates. */
342
342
  private passRootRect: DOMRect | null = null;
343
+ /**
344
+ * What the Animator needs from before the pass, read in one go before its
345
+ * first write (`prereadPass`): the style snapshots of the elements its
346
+ * updates change and the rectangles of those its removes take out. Read
347
+ * op by op, between writes, each forced a style recalculation or layout.
348
+ */
349
+ private readonly preSnapshots = new Map<HTMLElement, StyleSnapshot | null>();
350
+ private readonly preRects = new Map<HTMLElement, { rect: DOMRect | null; matched: { id: string; el: HTMLElement; rect: DOMRect }[] }>();
343
351
  /**
344
352
  * The root's size as the engine last read it; null when it must be read
345
353
  * again. Reading it after a pass changed the DOM forces a style and layout
@@ -820,6 +828,7 @@ export class Renderer {
820
828
  send: (event) => this.onEvent(event),
821
829
  nodeOf: (id) => this.nodes.get(id),
822
830
  openContextMenu: (anchor, id) => this.openContextMenu(anchor, id),
831
+ revealScreen: (screen) => this.revealScreen(screen),
823
832
  isMenuOpen: (id) => this.menu?.anchorId === id,
824
833
  closeMenu: () => this.closeMenu(),
825
834
  };
@@ -943,7 +952,10 @@ export class Renderer {
943
952
  private applyAll(ops: readonly Op[]): void {
944
953
  for (let index = 0; index < ops.length; index++) {
945
954
  const op = ops[index]!;
946
- if (this.passAnimates === null) this.passAnimates = this.passCanAnimate(ops, index);
955
+ if (this.passAnimates === null) {
956
+ this.passAnimates = this.passCanAnimate(ops, index);
957
+ if (this.passAnimates) this.prereadPass(ops, index);
958
+ }
947
959
  switch (op.op) {
948
960
  case 'create':
949
961
  this.create(op.id, op.kind, op.props, op);
@@ -971,6 +983,8 @@ export class Renderer {
971
983
  this.pass = emptyPass();
972
984
  this.passAnimates = null;
973
985
  this.passRootRect = null;
986
+ this.preSnapshots.clear();
987
+ this.preRects.clear();
974
988
  const changes = this.layout();
975
989
  this.animator.commit(pass, op.animation, changes);
976
990
  this.commits += 1;
@@ -1011,6 +1025,48 @@ export class Renderer {
1011
1025
  return true;
1012
1026
  }
1013
1027
 
1028
+ /**
1029
+ * A covered screen is about to show without a pop (a back swipe): the
1030
+ * engine leaves covered screens as they were, so lay this one out now if
1031
+ * the stack resized or its content changed while it was covered.
1032
+ */
1033
+ private revealScreen(screen: HTMLElement): void {
1034
+ const id = Number(screen.dataset[ID_ATTR]);
1035
+ const node = Number.isInteger(id) ? this.nodes.get(id) : undefined;
1036
+ if (!node || !this.engine) return;
1037
+ this.engine.uncover(node);
1038
+ this.layout();
1039
+ }
1040
+
1041
+ /**
1042
+ * Before a pass that can animate writes anything: snapshot the styles of
1043
+ * the elements its updates will change and read the rectangles of those
1044
+ * its removes will take out, so the reads share the one clean layout
1045
+ * instead of each forcing a new one between the writes.
1046
+ */
1047
+ private prereadPass(ops: readonly Op[], start: number): void {
1048
+ for (let index = start; index < ops.length; index++) {
1049
+ const op = ops[index]!;
1050
+ if (op.op === 'commit') return;
1051
+ if (op.op === 'update') {
1052
+ const el = this.elements.get(op.id);
1053
+ if (el && !this.preSnapshots.has(el)) this.preSnapshots.set(el, this.animator.snapshot(el));
1054
+ } else if (op.op === 'remove') {
1055
+ const el = this.elements.get(op.id);
1056
+ if (!el || !el.parentElement || this.preRects.has(el)) continue;
1057
+ this.passRootRect ??= this.animator.rootRect();
1058
+ const matched: { id: string; el: HTMLElement; rect: DOMRect }[] = [];
1059
+ const candidates = el.dataset['sbMatched'] !== undefined ? [el] : [];
1060
+ candidates.push(...el.querySelectorAll<HTMLElement>('[data-sb-matched]'));
1061
+ for (const box of candidates) {
1062
+ const boxRect = this.animator.rectOf(box, this.passRootRect);
1063
+ if (boxRect) matched.push({ id: box.dataset['sbMatched']!, el: box, rect: boxRect });
1064
+ }
1065
+ this.preRects.set(el, { rect: this.animator.rectOf(el, this.passRootRect), matched });
1066
+ }
1067
+ }
1068
+ }
1069
+
1014
1070
  /** Detach everything under the root and forget all non-root elements. */
1015
1071
  reset(): void {
1016
1072
  stopVideos(this.root);
@@ -1027,6 +1083,8 @@ export class Renderer {
1027
1083
  this.pass = emptyPass();
1028
1084
  this.passAnimates = null;
1029
1085
  this.passRootRect = null;
1086
+ this.preSnapshots.clear();
1087
+ this.preRects.clear();
1030
1088
  this.closeMenu();
1031
1089
  this.reportedGeometry.clear();
1032
1090
  this.layoutReporter.clear();
@@ -1604,7 +1662,7 @@ export class Renderer {
1604
1662
  }
1605
1663
  // Remember how the element looked before, in case the pass is animated.
1606
1664
  const previous = this.pass.updated.get(el);
1607
- const before = previous ? previous.before : this.passAnimates !== false ? this.animator.snapshot(el) : null;
1665
+ const before = previous ? previous.before : this.preSnapshots.has(el) ? this.preSnapshots.get(el)! : this.passAnimates !== false ? this.animator.snapshot(el) : null;
1608
1666
  const oldToken = el.dataset[ANIMATION_TOKEN];
1609
1667
  // `update` replaces the props entirely, so drop whatever the previous
1610
1668
  // props produced before applying the new ones. The layout box is kept:
@@ -1771,10 +1829,14 @@ export class Renderer {
1771
1829
  const ancestors: HTMLElement[] = [];
1772
1830
  for (let a = parent; a && a !== this.root; a = a.parentElement!) ancestors.push(a);
1773
1831
  let rect: DOMRect | null = null;
1774
- const matched: { id: string; el: HTMLElement; rect: DOMRect }[] = [];
1832
+ let matched: { id: string; el: HTMLElement; rect: DOMRect }[] = [];
1833
+ const preread = this.preRects.get(el);
1775
1834
  // A bar toolbar's items (and the toolbar) leave with a transition of
1776
1835
  // their own in any pass (src/animation.ts, `implicitToolbars`).
1777
- if (this.passAnimates !== false || barToolbarOf(el, parent)) {
1836
+ if (preread) {
1837
+ rect = preread.rect;
1838
+ matched = preread.matched;
1839
+ } else if (this.passAnimates !== false || barToolbarOf(el, parent)) {
1778
1840
  this.passRootRect ??= this.animator.rootRect();
1779
1841
  rect = this.animator.rectOf(el, this.passRootRect);
1780
1842
  // `.matchedGeometryEffect`: where the matched boxes in the subtree were.
package/src/scrollEdge.ts CHANGED
@@ -17,6 +17,7 @@
17
17
 
18
18
  import type { EdgeEffectProps } from './protocol';
19
19
  import type { Rect } from './layout';
20
+ import { runScrollSync, type ScrollSync } from './scrollSync';
20
21
 
21
22
  /**
22
23
  * How far content has to be under a bar for its edge effect to be at full
@@ -77,13 +78,24 @@ export function bindScrollEdgeBottom(scroller: HTMLElement, covered: Rect | unde
77
78
  }
78
79
 
79
80
  export function syncScrollEdgeBottom(scroller: HTMLElement): void {
80
- if (scroller.dataset['sbUnderBottom'] === undefined) return;
81
+ runScrollSync(scrollEdgeBottomSync(scroller));
82
+ }
83
+
84
+ /** The sync as a read (the scroll metrics) and the writes from it (scrollSync.ts). */
85
+ function scrollEdgeBottomSync(scroller: HTMLElement): ScrollSync<number | null> {
86
+ return {
87
+ // Content is under the bar until the end has scrolled clear of it (the
88
+ // inset is room past the content's end).
89
+ measure: () =>
90
+ scroller.dataset['sbUnderBottom'] === undefined || !ownerOf(scroller) ? null : scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop,
91
+ apply: (remaining) => applyScrollEdgeBottom(scroller, remaining),
92
+ };
93
+ }
94
+
95
+ function applyScrollEdgeBottom(scroller: HTMLElement, remaining: number | null): void {
81
96
  const owner = ownerOf(scroller);
82
- if (!owner) return;
97
+ if (remaining === null || !owner) return;
83
98
  claimedBy.set(owner, scroller);
84
- // Content is under the bar until the end has scrolled clear of it (the
85
- // inset is room past the content's end).
86
- const remaining = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
87
99
  setValue(owner, 'sbScrolledBottom', remaining > 1 ? '' : null);
88
100
  setProperty(owner, '--sb-edge-bottom', edgeStrength(remaining));
89
101
  setValue(owner, 'sbEdgeBottom', scroller.dataset['sbEdgeBottom'] ?? null);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Scroll-state syncs batched into one read phase.
3
+ *
4
+ * Binding a scroll view's under-bar and bottom-edge state (largeTitle.ts,
5
+ * scrollEdge.ts) reads its scroll metrics and writes flags from them. Done
6
+ * in the middle of a layout pass, each read follows DOM writes and forces
7
+ * the browser to lay out again (six times on a split-view toggle with three
8
+ * lists). While a batch is open the syncs are queued instead, and `flush`
9
+ * measures all of them before applying any, so they share one layout.
10
+ */
11
+
12
+ export interface ScrollSync<T> {
13
+ measure(): T;
14
+ apply(value: T): void;
15
+ }
16
+
17
+ let batch: ScrollSync<unknown>[] | null = null;
18
+
19
+ /** Run `sync` now, or queue it while a batch is open. */
20
+ export function runScrollSync<T>(sync: ScrollSync<T>): void {
21
+ if (batch) batch.push(sync as ScrollSync<unknown>);
22
+ else sync.apply(sync.measure());
23
+ }
24
+
25
+ export function beginScrollSyncBatch(): void {
26
+ batch ??= [];
27
+ }
28
+
29
+ /** Measure every queued sync, then apply them all; closes the batch. */
30
+ export function flushScrollSyncBatch(): void {
31
+ const queued = batch;
32
+ batch = null;
33
+ if (!queued) return;
34
+ const values = queued.map((sync) => sync.measure());
35
+ queued.forEach((sync, i) => sync.apply(values[i]));
36
+ }
package/src/styles.css CHANGED
@@ -1059,6 +1059,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1059
1059
 
1060
1060
  .sb-navbar-back {
1061
1061
  appearance: none;
1062
+ /* Offset by a `left` tween while a split view's sidebar slides (layoutDom). */
1063
+ position: relative;
1062
1064
  grid-column: 1;
1063
1065
  justify-self: start;
1064
1066
  display: inline-flex;