@swiftbrowser/web 0.6.3 → 0.6.5

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.5",
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)';
@@ -464,6 +469,39 @@ export class Animator {
464
469
  this.playOwnFrames = options.playOwnFrames;
465
470
  }
466
471
 
472
+ /** Commits waiting for the next animation frame (`commitNextFrame`), in order. */
473
+ private pending: { pass: PassRecord; animation: Animation | undefined; frames: readonly FrameChange[] }[] = [];
474
+ private pendingFrame: number | null = null;
475
+
476
+ /**
477
+ * `commit` in the next animation frame's callback, before the frame
478
+ * paints. A commit reads what the pass just wrote (the updated elements'
479
+ * computed styles, the removed boxes' places): read in the pass, they
480
+ * make the browser lay its frames out once more; read in the frame's
481
+ * callback, they cost the frame its one layout and the tweens still start
482
+ * on it. In a hidden page nothing paints, so the commit runs now.
483
+ */
484
+ commitNextFrame(pass: PassRecord, animation: Animation | undefined, frames: readonly FrameChange[] = []): void {
485
+ if (typeof requestAnimationFrame !== 'function' || (typeof document !== 'undefined' && document.hidden)) {
486
+ this.flushCommits();
487
+ this.commit(pass, animation, frames);
488
+ return;
489
+ }
490
+ this.pending.push({ pass, animation, frames });
491
+ this.pendingFrame ??= requestAnimationFrame(() => this.flushCommits());
492
+ }
493
+
494
+ /** Run the commits waiting for the next frame now, in order. */
495
+ flushCommits(): void {
496
+ if (this.pendingFrame !== null) {
497
+ cancelAnimationFrame(this.pendingFrame);
498
+ this.pendingFrame = null;
499
+ }
500
+ const pending = this.pending;
501
+ this.pending = [];
502
+ for (const { pass, animation, frames } of pending) this.commit(pass, animation, frames);
503
+ }
504
+
467
505
  /** Computed values of the animated properties, or null when not rendered. */
468
506
  snapshot(el: HTMLElement): StyleSnapshot | null {
469
507
  if (!el.isConnected) return null;
@@ -534,14 +572,44 @@ export class Animator {
534
572
  const matchedPairs = this.matchedPairs(pass);
535
573
  const matchedTo = new Map<RemovedRecord, { from: DOMRect; to: DOMRect }>();
536
574
 
575
+ // What a screen below the top of its stack holds is not drawn once the
576
+ // push has settled (styles.css: `visibility: hidden`), so it is not
577
+ // animated: it jumps to its new boxes, as the rows of a list a pushed
578
+ // screen covers do when a split view's sidebar slides. During a push or
579
+ // pop the lower screen shows, sliding, so then it animates as usual.
580
+ const hiddenScreens = new Map<HTMLElement, boolean>();
581
+ const hidden = (el: HTMLElement): boolean => {
582
+ for (let screen = el.closest<HTMLElement>(BELOW_SCREEN); screen; screen = screen.parentElement?.closest<HTMLElement>(BELOW_SCREEN) ?? null) {
583
+ let settled = hiddenScreens.get(screen);
584
+ if (settled === undefined) {
585
+ const stack = screen.closest<HTMLElement>('.sb-navstack');
586
+ settled = stack !== null && stack.querySelector(NAV_IN_FLIGHT) === null;
587
+ hiddenScreens.set(screen, settled);
588
+ }
589
+ if (settled) return true;
590
+ }
591
+ return false;
592
+ };
593
+
594
+ // The animation of the token root `el`, found `depth` levels up.
595
+ const rootAnimation = (el: HTMLElement, depth: number): Animation | undefined | false => {
596
+ // (below an item of a group the bar's own spring moves: not animated)
597
+ if (groupsOnly.has(el) && depth > 1) return false;
598
+ return tokenRoots.get(el) ?? { type: 'default' };
599
+ };
537
600
  const animationFor = (chain: Iterable<HTMLElement>): Animation | undefined | false => {
538
601
  let depth = 0;
539
602
  for (const el of chain) {
540
- if (tokenRoots.has(el)) {
541
- // (below an item of a group the bar's own spring moves: not animated)
542
- if (groupsOnly.has(el) && depth > 1) return false;
543
- return tokenRoots.get(el) ?? { type: 'default' };
544
- }
603
+ if (tokenRoots.has(el)) return rootAnimation(el, depth);
604
+ depth++;
605
+ }
606
+ return commitAnimation ?? false;
607
+ };
608
+ // The same for an element in the page, up its ancestors (no chain built).
609
+ const animationAt = (el: HTMLElement): Animation | undefined | false => {
610
+ let depth = 0;
611
+ for (let node: HTMLElement | null = el; node; node = node.parentElement) {
612
+ if (tokenRoots.has(node)) return rootAnimation(node, depth);
545
613
  depth++;
546
614
  }
547
615
  return commitAnimation ?? false;
@@ -549,8 +617,8 @@ export class Animator {
549
617
 
550
618
  // Style changes.
551
619
  for (const [el, rec] of pass.updated) {
552
- if (!rec.before || !el.isConnected) continue;
553
- const animation = animationFor(ancestorsAndSelf(el));
620
+ if (!rec.before || !el.isConnected || hidden(el)) continue;
621
+ const animation = animationAt(el);
554
622
  if (animation === false) continue;
555
623
  this.animateChanges(el, rec.before, animation);
556
624
  }
@@ -558,8 +626,8 @@ export class Animator {
558
626
  // What elements animate themselves (a custom shape's path).
559
627
  if (this.playOwnFrames) {
560
628
  for (const el of pass.updated.keys()) {
561
- if (!el.isConnected) continue;
562
- const animation = animationFor(ancestorsAndSelf(el));
629
+ if (!el.isConnected || hidden(el)) continue;
630
+ const animation = animationAt(el);
563
631
  if (animation !== false) this.playOwnFrames(el, animation);
564
632
  }
565
633
  }
@@ -572,7 +640,13 @@ export class Animator {
572
640
  const { el, from, to } = change;
573
641
  if (!from || !el.isConnected || pass.inserted.has(el)) continue;
574
642
  if (this.selfAnimating.has(change.kind)) continue;
575
- let animation = animationFor(ancestorsAndSelf(el));
643
+ if (hidden(el)) {
644
+ // Not drawn: a tween still running on it (from a pass before the
645
+ // push settled) carries on to the new box; otherwise it jumps.
646
+ this.retargetFrame(change, null);
647
+ continue;
648
+ }
649
+ let animation = animationAt(el);
576
650
  if (animation === false && change.kind === BAR_GLASS_KIND && glassScreens.has(el.parentElement)) {
577
651
  animation = TOOLBAR_ANIMATION;
578
652
  }
@@ -590,8 +664,9 @@ export class Animator {
590
664
  // change still moves with an ancestor that jumped.
591
665
  const changed = new Set(frames.map((change) => change.el));
592
666
  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 };
667
+ if (changed.has(el) || !el.isConnected || pass.inserted.has(el) || hidden(el)) continue;
668
+ // (the inline frame the layout applied: no layout read mid-commit)
669
+ 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
670
  tweens.push({ change: { el, kind: el.dataset['sbKind'] ?? '', from: box, to: box }, from: box, animation: animation ?? { type: 'default' }, moved: false });
596
671
  }
597
672
  // An animated element under an ancestor that jumps (outside the animated
@@ -626,17 +701,17 @@ export class Animator {
626
701
  // Inserted elements: only subtree roots (a nested fade would compound).
627
702
  for (const el of pass.inserted) {
628
703
  if (!el.isConnected || pass.updated.has(el)) continue;
629
- if (hasAncestorIn(el, pass.inserted)) continue;
704
+ if (hasAncestorIn(el, pass.inserted) || hidden(el)) continue;
630
705
  if (this.selfAnimating.has(el.dataset['sbKind'] ?? '')) continue;
631
- const animation = animationFor(ancestorsAndSelf(el));
706
+ const animation = animationAt(el);
632
707
  if (animation === false) continue;
633
708
  this.enter(el, transitionOf(el) ?? implicitTransitionOf(el), animation);
634
709
  }
635
710
 
636
711
  // Content transitions: the old text leaves as the new one arrives.
637
712
  for (const change of pass.contentChanges) {
638
- if (!change.el.isConnected) continue;
639
- const animation = animationFor(ancestorsAndSelf(change.el));
713
+ if (!change.el.isConnected || hidden(change.el)) continue;
714
+ const animation = animationAt(change.el);
640
715
  if (animation === false) continue;
641
716
  this.transitionContent(change, animation);
642
717
  }
@@ -968,10 +1043,6 @@ export function transitionOf(root: HTMLElement): Transition | undefined {
968
1043
  return undefined;
969
1044
  }
970
1045
 
971
- function* ancestorsAndSelf(el: HTMLElement): Generator<HTMLElement> {
972
- for (let node: HTMLElement | null = el; node; node = node.parentElement) yield node;
973
- }
974
-
975
1046
  function hasAncestorIn(el: HTMLElement, set: ReadonlySet<HTMLElement>): boolean {
976
1047
  for (let node = el.parentElement; node; node = node.parentElement) {
977
1048
  if (set.has(node)) return true;
@@ -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, scrollTopOf, 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 : scrollTopOf(scroller)),
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, type ScrollExtent } 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 {
@@ -661,19 +696,22 @@ function applyChrome(
661
696
  case 'list': {
662
697
  const content = el.querySelector<HTMLElement>(':scope > .sb-list-content');
663
698
  const size = result.contentSizes.get(node.id);
699
+ let extent: ScrollExtent | undefined;
664
700
  if (content && size) {
665
701
  content.style.width = `${size.width}px`;
666
702
  // Extra scrollable space at the end (the engine's frames are unchanged).
667
703
  content.style.height = `${size.height + bottomInset}px`;
704
+ extent = { content: size.height + bottomInset, viewport: frame.height };
668
705
  }
669
706
  bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
670
- bindScrollEdgeBottom(el, chrome?.['bottomInset']);
707
+ bindScrollEdgeBottom(el, chrome?.['bottomInset'], extent);
671
708
  return;
672
709
  }
673
710
  case 'scrollview': {
674
711
  const content = el.querySelector<HTMLElement>(':scope > .sb-scroll-content');
675
712
  const size = result.contentSizes.get(node.id);
676
713
  const rect = chrome?.['content'];
714
+ let extent: ScrollExtent | undefined;
677
715
  if (content && size) {
678
716
  const s = content.style;
679
717
  const axes = String(node.props['axes'] ?? 'vertical');
@@ -691,9 +729,12 @@ function applyChrome(
691
729
  s.marginTop = `${rect ? rect.y - frame.y : 0}px`;
692
730
  el.style.scrollPaddingLeft = before ? `${before}px` : '';
693
731
  el.style.scrollPaddingRight = after ? `${after}px` : '';
732
+ if (axes !== 'horizontal') {
733
+ extent = { content: (rect ? rect.y - frame.y : 0) + size.height + bottomInset, viewport: frame.height };
734
+ }
694
735
  }
695
736
  bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
696
- bindScrollEdgeBottom(el, String(node.props['axes'] ?? 'vertical') === 'horizontal' ? undefined : chrome?.['bottomInset']);
737
+ bindScrollEdgeBottom(el, String(node.props['axes'] ?? 'vertical') === 'horizontal' ? undefined : chrome?.['bottomInset'], extent);
697
738
  return;
698
739
  }
699
740
  case 'navscreen': {
@@ -754,6 +795,13 @@ function applyChrome(
754
795
  // The bar pads its row 16 (styles.css); a split view's sidebar
755
796
  // toggle can push the back button further in (chrome.back).
756
797
  const indent = barRect ? Math.max(0, backRect.x - barRect.x - 16) : 0;
798
+ const was = parseFloat(back.style.marginLeft) || 0;
799
+ // A split view's sidebar sliding in or out moves it in with the
800
+ // column (tweened as an offset: it is laid out in the bar's row).
801
+ if (indent !== was && !isNew && chromeChanges) {
802
+ const size = { y: 0, width: backRect.width, height: backRect.height };
803
+ chromeChanges.push({ id: -1, el: back, kind: CHROME_KIND, from: { x: was, ...size }, to: { x: indent, ...size } });
804
+ }
757
805
  back.style.marginLeft = indent > 0 ? `${indent}px` : '';
758
806
  backLabel.dataset['sbLayoutLabel'] = text;
759
807
  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,
@@ -157,6 +157,7 @@ import { HoverTracker, applyPointerAndEffects, safeAreaOverlap } from './pointer
157
157
  import { colorMultiplyFilter } from './colorMultiply';
158
158
  import { largeTitleOf } from './largeTitle';
159
159
  import { applyEdgeEffect } from './scrollEdge';
160
+ import { discardScrollOffsets, readScrollOffsets, trackScroller } from './scrollSync';
160
161
  import { DEFAULT_TYPE_SIZE, TEXT_STYLE_WEIGHT, resolveTextStyle, textStyleVar } from './typography';
161
162
 
162
163
  export type EventCallback = (event: RendererEvent) => void;
@@ -340,6 +341,14 @@ export class Renderer {
340
341
  private passAnimates: boolean | null = null;
341
342
  /** The animation root's rectangle, read once per pass that animates. */
342
343
  private passRootRect: DOMRect | null = null;
344
+ /**
345
+ * What the Animator needs from before the pass, read in one go before its
346
+ * first write (`prereadPass`): the style snapshots of the elements its
347
+ * updates change and the rectangles of those its removes take out. Read
348
+ * op by op, between writes, each forced a style recalculation or layout.
349
+ */
350
+ private readonly preSnapshots = new Map<HTMLElement, StyleSnapshot | null>();
351
+ private readonly preRects = new Map<HTMLElement, { rect: DOMRect | null; matched: { id: string; el: HTMLElement; rect: DOMRect }[] }>();
343
352
  /**
344
353
  * The root's size as the engine last read it; null when it must be read
345
354
  * again. Reading it after a pass changed the DOM forces a style and layout
@@ -405,6 +414,8 @@ export class Renderer {
405
414
  private pendingMenu: { anchor: HTMLElement; id: ElementId; kind: 'menu' | 'contextmenu' } | null = null;
406
415
  /** Size last reported for every `geometry` element (whole px). */
407
416
  private readonly reportedGeometry = new Map<ElementId, { width: number; height: number; key: string }>();
417
+ /** The elements whose size is reported (`geometry`, or `reportsSize` props): what `collectGeometry` visits. */
418
+ private readonly geometryReporters = new Set<ElementId>();
408
419
  /** `geometry` and `layout` events collected by layout passes, sent once the ops are applied. */
409
420
  private pendingGeometry: (GeometryEvent | LayoutEvent)[] = [];
410
421
  /** Phase 6: dedupes `layout` events per element (src/layoutEvents.ts). */
@@ -820,6 +831,7 @@ export class Renderer {
820
831
  send: (event) => this.onEvent(event),
821
832
  nodeOf: (id) => this.nodes.get(id),
822
833
  openContextMenu: (anchor, id) => this.openContextMenu(anchor, id),
834
+ revealScreen: (screen) => this.revealScreen(screen),
823
835
  isMenuOpen: (id) => this.menu?.anchorId === id,
824
836
  closeMenu: () => this.closeMenu(),
825
837
  };
@@ -929,11 +941,16 @@ export class Renderer {
929
941
 
930
942
  /** Apply a batch of ops in order. Throws `RendererError` on an invalid op. */
931
943
  applyOps(ops: readonly Op[]): void {
944
+ // The scroll views' offsets, read while nothing is waiting to be laid
945
+ // out: the pass's syncs use them (scrollSync.ts) instead of reading
946
+ // after its writes.
947
+ if (this.applying === 0 && ops.length > 0) readScrollOffsets();
932
948
  this.applying += 1;
933
949
  try {
934
950
  this.applyAll(ops);
935
951
  } finally {
936
952
  this.applying -= 1;
953
+ if (this.applying === 0) discardScrollOffsets();
937
954
  }
938
955
  // Geometry events go out after the whole batch (one batch per layout
939
956
  // pass); Swift's answer re-enters `applyOps` with the next pass.
@@ -943,7 +960,10 @@ export class Renderer {
943
960
  private applyAll(ops: readonly Op[]): void {
944
961
  for (let index = 0; index < ops.length; index++) {
945
962
  const op = ops[index]!;
946
- if (this.passAnimates === null) this.passAnimates = this.passCanAnimate(ops, index);
963
+ if (this.passAnimates === null) {
964
+ this.passAnimates = this.passCanAnimate(ops, index);
965
+ if (this.passAnimates) this.prereadPass(ops, index);
966
+ }
947
967
  switch (op.op) {
948
968
  case 'create':
949
969
  this.create(op.id, op.kind, op.props, op);
@@ -971,8 +991,10 @@ export class Renderer {
971
991
  this.pass = emptyPass();
972
992
  this.passAnimates = null;
973
993
  this.passRootRect = null;
994
+ this.preSnapshots.clear();
995
+ this.preRects.clear();
974
996
  const changes = this.layout();
975
- this.animator.commit(pass, op.animation, changes);
997
+ this.animator.commitNextFrame(pass, op.animation, changes);
976
998
  this.commits += 1;
977
999
  this.syncFocus();
978
1000
  this.performScrolls(op.animation != null);
@@ -1011,6 +1033,48 @@ export class Renderer {
1011
1033
  return true;
1012
1034
  }
1013
1035
 
1036
+ /**
1037
+ * A covered screen is about to show without a pop (a back swipe): the
1038
+ * engine leaves covered screens as they were, so lay this one out now if
1039
+ * the stack resized or its content changed while it was covered.
1040
+ */
1041
+ private revealScreen(screen: HTMLElement): void {
1042
+ const id = Number(screen.dataset[ID_ATTR]);
1043
+ const node = Number.isInteger(id) ? this.nodes.get(id) : undefined;
1044
+ if (!node || !this.engine) return;
1045
+ this.engine.uncover(node);
1046
+ this.layout();
1047
+ }
1048
+
1049
+ /**
1050
+ * Before a pass that can animate writes anything: snapshot the styles of
1051
+ * the elements its updates will change and read the rectangles of those
1052
+ * its removes will take out, so the reads share the one clean layout
1053
+ * instead of each forcing a new one between the writes.
1054
+ */
1055
+ private prereadPass(ops: readonly Op[], start: number): void {
1056
+ for (let index = start; index < ops.length; index++) {
1057
+ const op = ops[index]!;
1058
+ if (op.op === 'commit') return;
1059
+ if (op.op === 'update') {
1060
+ const el = this.elements.get(op.id);
1061
+ if (el && !this.preSnapshots.has(el)) this.preSnapshots.set(el, this.animator.snapshot(el));
1062
+ } else if (op.op === 'remove') {
1063
+ const el = this.elements.get(op.id);
1064
+ if (!el || !el.parentElement || this.preRects.has(el)) continue;
1065
+ this.passRootRect ??= this.animator.rootRect();
1066
+ const matched: { id: string; el: HTMLElement; rect: DOMRect }[] = [];
1067
+ const candidates = el.dataset['sbMatched'] !== undefined ? [el] : [];
1068
+ candidates.push(...el.querySelectorAll<HTMLElement>('[data-sb-matched]'));
1069
+ for (const box of candidates) {
1070
+ const boxRect = this.animator.rectOf(box, this.passRootRect);
1071
+ if (boxRect) matched.push({ id: box.dataset['sbMatched']!, el: box, rect: boxRect });
1072
+ }
1073
+ this.preRects.set(el, { rect: this.animator.rectOf(el, this.passRootRect), matched });
1074
+ }
1075
+ }
1076
+ }
1077
+
1014
1078
  /** Detach everything under the root and forget all non-root elements. */
1015
1079
  reset(): void {
1016
1080
  stopVideos(this.root);
@@ -1027,8 +1091,11 @@ export class Renderer {
1027
1091
  this.pass = emptyPass();
1028
1092
  this.passAnimates = null;
1029
1093
  this.passRootRect = null;
1094
+ this.preSnapshots.clear();
1095
+ this.preRects.clear();
1030
1096
  this.closeMenu();
1031
1097
  this.reportedGeometry.clear();
1098
+ this.geometryReporters.clear();
1032
1099
  this.layoutReporter.clear();
1033
1100
  this.pendingGeometry = [];
1034
1101
  refreshSheets(this.root);
@@ -1202,8 +1269,9 @@ export class Renderer {
1202
1269
  */
1203
1270
  private collectGeometry(result: LayoutResult, framesOnly = false): void {
1204
1271
  let rootRect: DOMRect | null = null;
1205
- for (const [id, node] of this.nodes) {
1206
- if (node.kind !== 'geometry' && node.props['reportsSize'] !== true) continue;
1272
+ for (const id of this.geometryReporters) {
1273
+ const node = this.nodes.get(id);
1274
+ if (!node) continue;
1207
1275
  const reportsFrame = node.props['reportsFrame'] === true;
1208
1276
  if (framesOnly && !reportsFrame) continue;
1209
1277
  const frame = result.frames.get(id);
@@ -1464,8 +1532,10 @@ export class Renderer {
1464
1532
  el.dataset[KIND_ATTR] = kind;
1465
1533
  this.elements.set(id, el);
1466
1534
  this.nodes.set(id, { id, kind, props: asRecord(props), children: [] });
1535
+ if (kind === 'geometry' || (props as { reportsSize?: unknown }).reportsSize === true) this.geometryReporters.add(id);
1467
1536
  applyProps(el, kind, props, this.assets);
1468
1537
  this.noteScroll(id, kind, props);
1538
+ if (kind === 'list' || kind === 'scrollview') trackScroller(el);
1469
1539
  if (kind === 'lazyspacer') this.lazySpacers.add(id);
1470
1540
  if (kind === 'host') {
1471
1541
  this.hosts.add(id);
@@ -1589,8 +1659,11 @@ export class Renderer {
1589
1659
  // Another Swift view now reads this element's size (a `geometry`
1590
1660
  // element kept for a new page's reader): it knows no size yet, so the
1591
1661
  // next layout pass reports the size even when it is unchanged.
1592
- if ((kind === 'geometry' || next['reportsSize'] === true) && next['reader'] !== node.props['reader']) {
1593
- this.reportedGeometry.delete(id);
1662
+ if (kind === 'geometry' || next['reportsSize'] === true) {
1663
+ this.geometryReporters.add(id);
1664
+ if (next['reader'] !== node.props['reader']) this.reportedGeometry.delete(id);
1665
+ } else {
1666
+ this.geometryReporters.delete(id);
1594
1667
  }
1595
1668
  node.props = next;
1596
1669
  // Phase 9: the next layout re-measures this subtree and its ancestors;
@@ -1604,7 +1677,7 @@ export class Renderer {
1604
1677
  }
1605
1678
  // Remember how the element looked before, in case the pass is animated.
1606
1679
  const previous = this.pass.updated.get(el);
1607
- const before = previous ? previous.before : this.passAnimates !== false ? this.animator.snapshot(el) : null;
1680
+ const before = previous ? previous.before : this.preSnapshots.has(el) ? this.preSnapshots.get(el)! : this.passAnimates !== false ? this.animator.snapshot(el) : null;
1608
1681
  const oldToken = el.dataset[ANIMATION_TOKEN];
1609
1682
  // `update` replaces the props entirely, so drop whatever the previous
1610
1683
  // props produced before applying the new ones. The layout box is kept:
@@ -1771,10 +1844,14 @@ export class Renderer {
1771
1844
  const ancestors: HTMLElement[] = [];
1772
1845
  for (let a = parent; a && a !== this.root; a = a.parentElement!) ancestors.push(a);
1773
1846
  let rect: DOMRect | null = null;
1774
- const matched: { id: string; el: HTMLElement; rect: DOMRect }[] = [];
1847
+ let matched: { id: string; el: HTMLElement; rect: DOMRect }[] = [];
1848
+ const preread = this.preRects.get(el);
1775
1849
  // A bar toolbar's items (and the toolbar) leave with a transition of
1776
1850
  // their own in any pass (src/animation.ts, `implicitToolbars`).
1777
- if (this.passAnimates !== false || barToolbarOf(el, parent)) {
1851
+ if (preread) {
1852
+ rect = preread.rect;
1853
+ matched = preread.matched;
1854
+ } else if (this.passAnimates !== false || barToolbarOf(el, parent)) {
1778
1855
  this.passRootRect ??= this.animator.rootRect();
1779
1856
  rect = this.animator.rectOf(el, this.passRootRect);
1780
1857
  // `.matchedGeometryEffect`: where the matched boxes in the subtree were.
@@ -1821,6 +1898,7 @@ export class Renderer {
1821
1898
  this.boxes.delete(id);
1822
1899
  this.dirty.delete(id);
1823
1900
  this.reportedGeometry.delete(id);
1901
+ this.geometryReporters.delete(id);
1824
1902
  this.layoutReporter.forget(id);
1825
1903
  const node = this.nodes.get(id);
1826
1904
  const parentId = this.parents.get(id);
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, scrollTopOf, type ScrollExtent, 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
@@ -58,9 +59,10 @@ function ownerOf(scroller: HTMLElement): HTMLElement | null {
58
59
 
59
60
  /**
60
61
  * Called for every list and scroll view the layout reaches with the engine's
61
- * `bottomInset` rect (absent when no bar covers its bottom).
62
+ * `bottomInset` rect (absent when no bar covers its bottom) and the extent
63
+ * the pass gave it, so the sync needs no scroll metric the writes change.
62
64
  */
63
- export function bindScrollEdgeBottom(scroller: HTMLElement, covered: Rect | undefined): void {
65
+ export function bindScrollEdgeBottom(scroller: HTMLElement, covered: Rect | undefined, extent?: ScrollExtent): void {
64
66
  const owner = ownerOf(scroller);
65
67
  if (covered === undefined || covered.height <= 0) {
66
68
  if (scroller.dataset['sbUnderBottom'] !== undefined) delete scroller.dataset['sbUnderBottom'];
@@ -73,17 +75,31 @@ export function bindScrollEdgeBottom(scroller: HTMLElement, covered: Rect | unde
73
75
  bound.add(scroller);
74
76
  scroller.addEventListener('scroll', () => syncScrollEdgeBottom(scroller), { passive: true });
75
77
  }
76
- syncScrollEdgeBottom(scroller);
78
+ syncScrollEdgeBottom(scroller, extent);
77
79
  }
78
80
 
79
- export function syncScrollEdgeBottom(scroller: HTMLElement): void {
80
- if (scroller.dataset['sbUnderBottom'] === undefined) return;
81
+ export function syncScrollEdgeBottom(scroller: HTMLElement, extent?: ScrollExtent): void {
82
+ runScrollSync(scrollEdgeBottomSync(scroller, extent));
83
+ }
84
+
85
+ /** The sync as a read (the scroll metrics) and the writes from it (scrollSync.ts). */
86
+ function scrollEdgeBottomSync(scroller: HTMLElement, extent: ScrollExtent | undefined): ScrollSync<number | null> {
87
+ // Content is under the bar until the end has scrolled clear of it (the
88
+ // inset is room past the content's end). From a layout pass the lengths
89
+ // are the pass's own; from a scroll event they are read.
90
+ const remaining = extent
91
+ ? () => Math.max(extent.content, extent.viewport) - extent.viewport - scrollTopOf(scroller)
92
+ : () => scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
93
+ return {
94
+ measure: () => (scroller.dataset['sbUnderBottom'] === undefined || !ownerOf(scroller) ? null : remaining()),
95
+ apply: (remaining) => applyScrollEdgeBottom(scroller, remaining),
96
+ };
97
+ }
98
+
99
+ function applyScrollEdgeBottom(scroller: HTMLElement, remaining: number | null): void {
81
100
  const owner = ownerOf(scroller);
82
- if (!owner) return;
101
+ if (remaining === null || !owner) return;
83
102
  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
103
  setValue(owner, 'sbScrolledBottom', remaining > 1 ? '' : null);
88
104
  setProperty(owner, '--sb-edge-bottom', edgeStrength(remaining));
89
105
  setValue(owner, 'sbEdgeBottom', scroller.dataset['sbEdgeBottom'] ?? null);
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Scroll-state syncs batched into one read phase, before the pass writes.
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.
10
+ *
11
+ * The one metric the pass cannot know, the scroll offset, is read for every
12
+ * scroll view when the batch opens, before any frame is written: a frame
13
+ * does not move the offset, so the read costs the layout of what the ops
14
+ * changed rather than of every frame the pass applied. (A pass that makes
15
+ * the content shorter than the offset has the browser clamp it afterwards,
16
+ * which fires `scroll`, and the sync on it catches up.) The extents the
17
+ * frames change are the pass's own numbers (`ScrollExtent`).
18
+ */
19
+
20
+ export interface ScrollSync<T> {
21
+ measure(): T;
22
+ apply(value: T): void;
23
+ }
24
+
25
+ /** A scroll view's content and viewport lengths on its axis, as the pass sets them. */
26
+ export interface ScrollExtent {
27
+ content: number;
28
+ viewport: number;
29
+ }
30
+
31
+ let batch: ScrollSync<unknown>[] | null = null;
32
+ const scrollers = new Set<HTMLElement>();
33
+ let offsets: Map<HTMLElement, number> | null = null;
34
+
35
+ /** Reads this scroll view's offset before each pass (a view made in a pass starts at 0). */
36
+ export function trackScroller(scroller: HTMLElement): void {
37
+ scrollers.add(scroller);
38
+ if (offsets && !offsets.has(scroller)) offsets.set(scroller, 0);
39
+ }
40
+
41
+ /**
42
+ * Reads every tracked scroll view's offset: the renderer calls this before a
43
+ * pass's first op touches the page, where nothing is waiting to be laid out.
44
+ */
45
+ export function readScrollOffsets(): void {
46
+ offsets = new Map();
47
+ for (const scroller of scrollers) {
48
+ if (!scroller.isConnected) {
49
+ scrollers.delete(scroller);
50
+ continue;
51
+ }
52
+ offsets.set(scroller, scroller.scrollTop);
53
+ }
54
+ }
55
+
56
+ /** Forgets the offsets read for a pass (a pass that did not lay out). */
57
+ export function discardScrollOffsets(): void {
58
+ offsets = null;
59
+ }
60
+
61
+ /** The scroll offset read when the batch opened, else read now. */
62
+ export function scrollTopOf(scroller: HTMLElement): number {
63
+ return offsets?.get(scroller) ?? scroller.scrollTop;
64
+ }
65
+
66
+ /** Run `sync` now, or queue it while a batch is open. */
67
+ export function runScrollSync<T>(sync: ScrollSync<T>): void {
68
+ if (batch) batch.push(sync as ScrollSync<unknown>);
69
+ else sync.apply(sync.measure());
70
+ }
71
+
72
+ /** Opens a batch, reading the offsets now unless the pass read them before its writes. */
73
+ export function beginScrollSyncBatch(): void {
74
+ batch ??= [];
75
+ if (!offsets) readScrollOffsets();
76
+ }
77
+
78
+ /** Measure every queued sync, then apply them all; closes the batch. */
79
+ export function flushScrollSyncBatch(): void {
80
+ const queued = batch;
81
+ batch = null;
82
+ if (!queued) {
83
+ discardScrollOffsets();
84
+ return;
85
+ }
86
+ const values = queued.map((sync) => sync.measure());
87
+ discardScrollOffsets();
88
+ queued.forEach((sync, i) => sync.apply(values[i]));
89
+ }
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;