@swiftbrowser/web 0.6.4 → 0.6.6

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.4",
3
+ "version": "0.6.6",
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
@@ -469,6 +469,39 @@ export class Animator {
469
469
  this.playOwnFrames = options.playOwnFrames;
470
470
  }
471
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
+
472
505
  /** Computed values of the animated properties, or null when not rendered. */
473
506
  snapshot(el: HTMLElement): StyleSnapshot | null {
474
507
  if (!el.isConnected) return null;
@@ -558,14 +591,25 @@ export class Animator {
558
591
  return false;
559
592
  };
560
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
+ };
561
600
  const animationFor = (chain: Iterable<HTMLElement>): Animation | undefined | false => {
562
601
  let depth = 0;
563
602
  for (const el of chain) {
564
- if (tokenRoots.has(el)) {
565
- // (below an item of a group the bar's own spring moves: not animated)
566
- if (groupsOnly.has(el) && depth > 1) return false;
567
- return tokenRoots.get(el) ?? { type: 'default' };
568
- }
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);
569
613
  depth++;
570
614
  }
571
615
  return commitAnimation ?? false;
@@ -574,7 +618,7 @@ export class Animator {
574
618
  // Style changes.
575
619
  for (const [el, rec] of pass.updated) {
576
620
  if (!rec.before || !el.isConnected || hidden(el)) continue;
577
- const animation = animationFor(ancestorsAndSelf(el));
621
+ const animation = animationAt(el);
578
622
  if (animation === false) continue;
579
623
  this.animateChanges(el, rec.before, animation);
580
624
  }
@@ -583,7 +627,7 @@ export class Animator {
583
627
  if (this.playOwnFrames) {
584
628
  for (const el of pass.updated.keys()) {
585
629
  if (!el.isConnected || hidden(el)) continue;
586
- const animation = animationFor(ancestorsAndSelf(el));
630
+ const animation = animationAt(el);
587
631
  if (animation !== false) this.playOwnFrames(el, animation);
588
632
  }
589
633
  }
@@ -602,7 +646,7 @@ export class Animator {
602
646
  this.retargetFrame(change, null);
603
647
  continue;
604
648
  }
605
- let animation = animationFor(ancestorsAndSelf(el));
649
+ let animation = animationAt(el);
606
650
  if (animation === false && change.kind === BAR_GLASS_KIND && glassScreens.has(el.parentElement)) {
607
651
  animation = TOOLBAR_ANIMATION;
608
652
  }
@@ -659,7 +703,7 @@ export class Animator {
659
703
  if (!el.isConnected || pass.updated.has(el)) continue;
660
704
  if (hasAncestorIn(el, pass.inserted) || hidden(el)) continue;
661
705
  if (this.selfAnimating.has(el.dataset['sbKind'] ?? '')) continue;
662
- const animation = animationFor(ancestorsAndSelf(el));
706
+ const animation = animationAt(el);
663
707
  if (animation === false) continue;
664
708
  this.enter(el, transitionOf(el) ?? implicitTransitionOf(el), animation);
665
709
  }
@@ -667,7 +711,7 @@ export class Animator {
667
711
  // Content transitions: the old text leaves as the new one arrives.
668
712
  for (const change of pass.contentChanges) {
669
713
  if (!change.el.isConnected || hidden(change.el)) continue;
670
- const animation = animationFor(ancestorsAndSelf(change.el));
714
+ const animation = animationAt(change.el);
671
715
  if (animation === false) continue;
672
716
  this.transitionContent(change, animation);
673
717
  }
@@ -999,10 +1043,6 @@ export function transitionOf(root: HTMLElement): Transition | undefined {
999
1043
  return undefined;
1000
1044
  }
1001
1045
 
1002
- function* ancestorsAndSelf(el: HTMLElement): Generator<HTMLElement> {
1003
- for (let node: HTMLElement | null = el; node; node = node.parentElement) yield node;
1004
- }
1005
-
1006
1046
  function hasAncestorIn(el: HTMLElement, set: ReadonlySet<HTMLElement>): boolean {
1007
1047
  for (let node = el.parentElement; node; node = node.parentElement) {
1008
1048
  if (set.has(node)) return true;
package/src/largeTitle.ts CHANGED
@@ -20,7 +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
+ import { runScrollSync, scrollTopOf, type ScrollSync } from './scrollSync';
24
24
 
25
25
  /** The scroll offset past which the title has gone under the bar. */
26
26
  const COLLAPSE_OFFSET = 44;
@@ -109,7 +109,7 @@ export function syncScrollUnderBar(scroller: HTMLElement): void {
109
109
  /** The sync as a read (`scrollTop`) and the writes from it (scrollSync.ts). */
110
110
  function scrollUnderBarSync(scroller: HTMLElement): ScrollSync<number | null> {
111
111
  return {
112
- measure: () => (scroller.dataset['sbUnderBar'] === undefined || !screenOf(scroller) ? null : scroller.scrollTop),
112
+ measure: () => (scroller.dataset['sbUnderBar'] === undefined || !screenOf(scroller) ? null : scrollTopOf(scroller)),
113
113
  apply: (offset) => {
114
114
  const screen = screenOf(scroller);
115
115
  if (offset === null || !screen) return;
package/src/layoutDom.ts CHANGED
@@ -42,7 +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
+ import { beginScrollSyncBatch, flushScrollSyncBatch, type ScrollExtent } from './scrollSync';
46
46
  import { BAR_GLASS_KIND, TOOLBAR_ANIMATION, TOOLBAR_TRANSITION, baseTiming, timingFor, transitionStart } from './animation';
47
47
 
48
48
  export interface LayoutDomContext {
@@ -696,19 +696,22 @@ function applyChrome(
696
696
  case 'list': {
697
697
  const content = el.querySelector<HTMLElement>(':scope > .sb-list-content');
698
698
  const size = result.contentSizes.get(node.id);
699
+ let extent: ScrollExtent | undefined;
699
700
  if (content && size) {
700
701
  content.style.width = `${size.width}px`;
701
702
  // Extra scrollable space at the end (the engine's frames are unchanged).
702
703
  content.style.height = `${size.height + bottomInset}px`;
704
+ extent = { content: size.height + bottomInset, viewport: frame.height };
703
705
  }
704
706
  bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
705
- bindScrollEdgeBottom(el, chrome?.['bottomInset']);
707
+ bindScrollEdgeBottom(el, chrome?.['bottomInset'], extent);
706
708
  return;
707
709
  }
708
710
  case 'scrollview': {
709
711
  const content = el.querySelector<HTMLElement>(':scope > .sb-scroll-content');
710
712
  const size = result.contentSizes.get(node.id);
711
713
  const rect = chrome?.['content'];
714
+ let extent: ScrollExtent | undefined;
712
715
  if (content && size) {
713
716
  const s = content.style;
714
717
  const axes = String(node.props['axes'] ?? 'vertical');
@@ -726,9 +729,12 @@ function applyChrome(
726
729
  s.marginTop = `${rect ? rect.y - frame.y : 0}px`;
727
730
  el.style.scrollPaddingLeft = before ? `${before}px` : '';
728
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
+ }
729
735
  }
730
736
  bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
731
- bindScrollEdgeBottom(el, String(node.props['axes'] ?? 'vertical') === 'horizontal' ? undefined : chrome?.['bottomInset']);
737
+ bindScrollEdgeBottom(el, String(node.props['axes'] ?? 'vertical') === 'horizontal' ? undefined : chrome?.['bottomInset'], extent);
732
738
  return;
733
739
  }
734
740
  case 'navscreen': {
package/src/renderer.ts CHANGED
@@ -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;
@@ -413,6 +414,8 @@ export class Renderer {
413
414
  private pendingMenu: { anchor: HTMLElement; id: ElementId; kind: 'menu' | 'contextmenu' } | null = null;
414
415
  /** Size last reported for every `geometry` element (whole px). */
415
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>();
416
419
  /** `geometry` and `layout` events collected by layout passes, sent once the ops are applied. */
417
420
  private pendingGeometry: (GeometryEvent | LayoutEvent)[] = [];
418
421
  /** Phase 6: dedupes `layout` events per element (src/layoutEvents.ts). */
@@ -938,11 +941,16 @@ export class Renderer {
938
941
 
939
942
  /** Apply a batch of ops in order. Throws `RendererError` on an invalid op. */
940
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();
941
948
  this.applying += 1;
942
949
  try {
943
950
  this.applyAll(ops);
944
951
  } finally {
945
952
  this.applying -= 1;
953
+ if (this.applying === 0) discardScrollOffsets();
946
954
  }
947
955
  // Geometry events go out after the whole batch (one batch per layout
948
956
  // pass); Swift's answer re-enters `applyOps` with the next pass.
@@ -986,7 +994,7 @@ export class Renderer {
986
994
  this.preSnapshots.clear();
987
995
  this.preRects.clear();
988
996
  const changes = this.layout();
989
- this.animator.commit(pass, op.animation, changes);
997
+ this.animator.commitNextFrame(pass, op.animation, changes);
990
998
  this.commits += 1;
991
999
  this.syncFocus();
992
1000
  this.performScrolls(op.animation != null);
@@ -1087,6 +1095,7 @@ export class Renderer {
1087
1095
  this.preRects.clear();
1088
1096
  this.closeMenu();
1089
1097
  this.reportedGeometry.clear();
1098
+ this.geometryReporters.clear();
1090
1099
  this.layoutReporter.clear();
1091
1100
  this.pendingGeometry = [];
1092
1101
  refreshSheets(this.root);
@@ -1260,8 +1269,9 @@ export class Renderer {
1260
1269
  */
1261
1270
  private collectGeometry(result: LayoutResult, framesOnly = false): void {
1262
1271
  let rootRect: DOMRect | null = null;
1263
- for (const [id, node] of this.nodes) {
1264
- 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;
1265
1275
  const reportsFrame = node.props['reportsFrame'] === true;
1266
1276
  if (framesOnly && !reportsFrame) continue;
1267
1277
  const frame = result.frames.get(id);
@@ -1522,8 +1532,10 @@ export class Renderer {
1522
1532
  el.dataset[KIND_ATTR] = kind;
1523
1533
  this.elements.set(id, el);
1524
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);
1525
1536
  applyProps(el, kind, props, this.assets);
1526
1537
  this.noteScroll(id, kind, props);
1538
+ if (kind === 'list' || kind === 'scrollview') trackScroller(el);
1527
1539
  if (kind === 'lazyspacer') this.lazySpacers.add(id);
1528
1540
  if (kind === 'host') {
1529
1541
  this.hosts.add(id);
@@ -1647,8 +1659,11 @@ export class Renderer {
1647
1659
  // Another Swift view now reads this element's size (a `geometry`
1648
1660
  // element kept for a new page's reader): it knows no size yet, so the
1649
1661
  // next layout pass reports the size even when it is unchanged.
1650
- if ((kind === 'geometry' || next['reportsSize'] === true) && next['reader'] !== node.props['reader']) {
1651
- 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);
1652
1667
  }
1653
1668
  node.props = next;
1654
1669
  // Phase 9: the next layout re-measures this subtree and its ancestors;
@@ -1883,6 +1898,7 @@ export class Renderer {
1883
1898
  this.boxes.delete(id);
1884
1899
  this.dirty.delete(id);
1885
1900
  this.reportedGeometry.delete(id);
1901
+ this.geometryReporters.delete(id);
1886
1902
  this.layoutReporter.forget(id);
1887
1903
  const node = this.nodes.get(id);
1888
1904
  const parentId = this.parents.get(id);
package/src/scrollEdge.ts CHANGED
@@ -17,7 +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
+ import { runScrollSync, scrollTopOf, type ScrollExtent, type ScrollSync } from './scrollSync';
21
21
 
22
22
  /**
23
23
  * How far content has to be under a bar for its edge effect to be at full
@@ -59,9 +59,10 @@ function ownerOf(scroller: HTMLElement): HTMLElement | null {
59
59
 
60
60
  /**
61
61
  * Called for every list and scroll view the layout reaches with the engine's
62
- * `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.
63
64
  */
64
- export function bindScrollEdgeBottom(scroller: HTMLElement, covered: Rect | undefined): void {
65
+ export function bindScrollEdgeBottom(scroller: HTMLElement, covered: Rect | undefined, extent?: ScrollExtent): void {
65
66
  const owner = ownerOf(scroller);
66
67
  if (covered === undefined || covered.height <= 0) {
67
68
  if (scroller.dataset['sbUnderBottom'] !== undefined) delete scroller.dataset['sbUnderBottom'];
@@ -74,20 +75,23 @@ export function bindScrollEdgeBottom(scroller: HTMLElement, covered: Rect | unde
74
75
  bound.add(scroller);
75
76
  scroller.addEventListener('scroll', () => syncScrollEdgeBottom(scroller), { passive: true });
76
77
  }
77
- syncScrollEdgeBottom(scroller);
78
+ syncScrollEdgeBottom(scroller, extent);
78
79
  }
79
80
 
80
- export function syncScrollEdgeBottom(scroller: HTMLElement): void {
81
- runScrollSync(scrollEdgeBottomSync(scroller));
81
+ export function syncScrollEdgeBottom(scroller: HTMLElement, extent?: ScrollExtent): void {
82
+ runScrollSync(scrollEdgeBottomSync(scroller, extent));
82
83
  }
83
84
 
84
85
  /** The sync as a read (the scroll metrics) and the writes from it (scrollSync.ts). */
85
- function scrollEdgeBottomSync(scroller: HTMLElement): ScrollSync<number | null> {
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;
86
93
  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,
94
+ measure: () => (scroller.dataset['sbUnderBottom'] === undefined || !ownerOf(scroller) ? null : remaining()),
91
95
  apply: (remaining) => applyScrollEdgeBottom(scroller, remaining),
92
96
  };
93
97
  }
package/src/scrollSync.ts CHANGED
@@ -1,12 +1,20 @@
1
1
  /**
2
- * Scroll-state syncs batched into one read phase.
2
+ * Scroll-state syncs batched into one read phase, before the pass writes.
3
3
  *
4
4
  * Binding a scroll view's under-bar and bottom-edge state (largeTitle.ts,
5
5
  * scrollEdge.ts) reads its scroll metrics and writes flags from them. Done
6
6
  * in the middle of a layout pass, each read follows DOM writes and forces
7
7
  * the browser to lay out again (six times on a split-view toggle with three
8
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.
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`).
10
18
  */
11
19
 
12
20
  export interface ScrollSync<T> {
@@ -14,7 +22,46 @@ export interface ScrollSync<T> {
14
22
  apply(value: T): void;
15
23
  }
16
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
+
17
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
+ }
18
65
 
19
66
  /** Run `sync` now, or queue it while a batch is open. */
20
67
  export function runScrollSync<T>(sync: ScrollSync<T>): void {
@@ -22,15 +69,21 @@ export function runScrollSync<T>(sync: ScrollSync<T>): void {
22
69
  else sync.apply(sync.measure());
23
70
  }
24
71
 
72
+ /** Opens a batch, reading the offsets now unless the pass read them before its writes. */
25
73
  export function beginScrollSyncBatch(): void {
26
74
  batch ??= [];
75
+ if (!offsets) readScrollOffsets();
27
76
  }
28
77
 
29
78
  /** Measure every queued sync, then apply them all; closes the batch. */
30
79
  export function flushScrollSyncBatch(): void {
31
80
  const queued = batch;
32
81
  batch = null;
33
- if (!queued) return;
82
+ if (!queued) {
83
+ discardScrollOffsets();
84
+ return;
85
+ }
34
86
  const values = queued.map((sync) => sync.measure());
87
+ discardScrollOffsets();
35
88
  queued.forEach((sync, i) => sync.apply(values[i]));
36
89
  }
package/src/styles.css CHANGED
@@ -2390,6 +2390,21 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2390
2390
  border-radius: 24px;
2391
2391
  }
2392
2392
 
2393
+ /* While a sheet slides in or out, the glass in it does not blur. What is
2394
+ painted over a backdrop filter is composited on its own, and WebKit on
2395
+ iOS does not draw such a layer while the card it is in runs an
2396
+ accelerated slide that starts mostly offscreen: a bar button's text was
2397
+ missing until the sheet had slid in, and vanished the moment a swipe let
2398
+ go of it (the capsule under it and the title beside it, painted with the
2399
+ card, kept sliding). Without the blur the text paints with the card. The
2400
+ capsules keep their translucent fill, over the card's own background for
2401
+ the 0.4 s. */
2402
+ .sb-sheet--in :is(.sb-bar-glass, .sb-navbar-back, .sb-navbar-more, .sb-button[data-sb-style="glass"]),
2403
+ .sb-sheet--out :is(.sb-bar-glass, .sb-navbar-back, .sb-navbar-more, .sb-button[data-sb-style="glass"]) {
2404
+ backdrop-filter: none;
2405
+ -webkit-backdrop-filter: none;
2406
+ }
2407
+
2393
2408
  /* iOS 26 press feedback on Liquid Glass (src/press.ts): while a pointer is
2394
2409
  down on a bar button or a glass button, the glass under it (a bar
2395
2410
  capsule, the back and More circles, the sidebar toggle, a `.glass`