@swiftbrowser/web 0.10.1 → 0.10.3

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.10.1",
3
+ "version": "0.10.3",
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": {
@@ -34,6 +34,8 @@
34
34
  * the entry and, once its animation ends, hides the live page until a new
35
35
  * frame is committed, so a pop that finished animating by then would stay
36
36
  * under the snapshot for Safari's 3 s watchdog, taking the touches unseen.
37
+ * A push is made at the renderer's `sb:precommit`, before the pushed screen
38
+ * is laid out (see `sync`), and this module never forces a layout.
37
39
  */
38
40
  import type { Renderer } from './renderer';
39
41
 
@@ -50,10 +52,28 @@ interface HistoryState {
50
52
  const stateOf = (value: unknown): HistoryState | null => (typeof value === 'object' && value !== null && (value as HistoryState).sbHistory === true ? (value as HistoryState) : null);
51
53
 
52
54
  function shown(win: Window, el: HTMLElement): boolean {
53
- if (el.hidden || el.getClientRects().length === 0) return false;
55
+ if (!el.isConnected) return false;
56
+ // Style only, never layout: a layout forced here before a push would hand
57
+ // Safari the pushed screen's size for the entry it covers (see `sync`).
58
+ for (let node: HTMLElement | null = el; node; node = node.parentElement) {
59
+ if (node.hidden || win.getComputedStyle(node).display === 'none') return false;
60
+ }
54
61
  return win.getComputedStyle(el).visibility !== 'hidden';
55
62
  }
56
63
 
64
+ /**
65
+ * A back the browser makes after a touch this close to a side of the page is
66
+ * its edge swipe (Safari's, Android's back gesture), which has animated the
67
+ * pop already: the renderer then does not slide the screen out again. The
68
+ * browser's back button sends no touch to the page, so its pop slides.
69
+ */
70
+ const SWIPE_EDGE_PX = 44;
71
+ /** How long after such a touch a back still counts as the swipe it started. */
72
+ const SWIPE_TOUCH_MS = 2000;
73
+
74
+ /** When `sync` runs: the renderer's `sb:precommit` (the pass's ops applied, not yet laid out), its `sb:commit`, or `enable`. */
75
+ export type SyncStage = 'precommit' | 'commit' | 'enabling';
76
+
57
77
  /** How long a back the browser made waits for the app to pop a level before what it shows is recorded as it is. */
58
78
  const DISMISS_WAIT_MS = 1000;
59
79
 
@@ -66,7 +86,9 @@ export class BrowserHistory {
66
86
  * split view clears its sidebar selection first, and the detail goes on
67
87
  * the next), so a commit that still shows the level is not a new one.
68
88
  */
69
- private dismissing: { target: number; from: number; until: number } | null = null;
89
+ private dismissing: { target: number; from: number; until: number; instant: boolean } | null = null;
90
+ /** When a touch last began at a side of the page (`SWIPE_EDGE_PX`): the start of the browser's edge swipe. */
91
+ private lastEdgeTouch = Number.NEGATIVE_INFINITY;
70
92
  /** `popstate` events to ignore: the ones our own `history.go` causes. */
71
93
  private ignorePops = 0;
72
94
  /**
@@ -103,13 +125,15 @@ export class BrowserHistory {
103
125
  if (!current) this.win.history.replaceState({ ...(this.win.history.state ?? {}), ...this.entry(0, this.selection()) }, '');
104
126
  this.watchReplaceState();
105
127
  this.renderer.interaction.nativeBackGesture = true;
128
+ this.root.addEventListener('sb:precommit', this.onPreCommit);
106
129
  this.root.addEventListener('sb:commit', this.onCommit);
107
130
  this.win.addEventListener('popstate', this.onPopState);
131
+ this.win.addEventListener('touchstart', this.onTouchStart, { capture: true, passive: true });
108
132
  // Enabled before the first commit (a mount, or a remount such as React's
109
133
  // StrictMode makes in development) the screen is not drawn yet: fewer
110
134
  // levels than the entry records is not the app popping, so only the
111
135
  // levels already shown are pushed here and the first commit settles the rest.
112
- this.sync(true);
136
+ this.sync('enabling');
113
137
  }
114
138
 
115
139
  dispose(): void {
@@ -121,8 +145,10 @@ export class BrowserHistory {
121
145
  this.landingUrl = null;
122
146
  this.syncOnArrival = false;
123
147
  this.unwatchReplaceState();
148
+ this.root.removeEventListener('sb:precommit', this.onPreCommit);
124
149
  this.root.removeEventListener('sb:commit', this.onCommit);
125
150
  this.win.removeEventListener('popstate', this.onPopState);
151
+ this.win.removeEventListener('touchstart', this.onTouchStart, { capture: true });
126
152
  }
127
153
 
128
154
  /**
@@ -220,11 +246,15 @@ export class BrowserHistory {
220
246
  if (!dismissing) return;
221
247
  dismissing.from = depth;
222
248
  dismissing.until = performance.now() + DISMISS_WAIT_MS;
223
- if (!this.dismissOne()) this.dismissing = null;
249
+ if (!this.dismissOne(dismissing.instant)) this.dismissing = null;
224
250
  }
225
251
 
226
- /** Asks the app to dismiss one level: the top sheet when nothing is pushed inside it, else the deepest visible stack's top screen. */
227
- private dismissOne(): boolean {
252
+ /**
253
+ * Asks the app to dismiss one level: the top sheet when nothing is pushed
254
+ * inside it, else the deepest visible stack's top screen. `instant`: the
255
+ * browser has animated the pop (its swipe), so the renderer does not.
256
+ */
257
+ private dismissOne(instant: boolean): boolean {
228
258
  const sheets = this.sheets();
229
259
  const top = sheets.at(-1) ?? this.root;
230
260
  const stacks = this.stacks(top)
@@ -236,6 +266,7 @@ export class BrowserHistory {
236
266
  const screen = this.topScreen(deepest.stack);
237
267
  const id = Number(screen?.dataset['sbId']);
238
268
  if (!Number.isInteger(id)) return false;
269
+ if (instant) this.renderer.popWithoutAnimation(id);
239
270
  this.renderer.tap(id); // what the back button sends
240
271
  return true;
241
272
  }
@@ -292,15 +323,31 @@ export class BrowserHistory {
292
323
  }
293
324
  }
294
325
 
295
- private readonly onCommit = (): void => this.sync();
326
+ private readonly onPreCommit = (): void => this.sync('precommit');
327
+ private readonly onCommit = (): void => this.sync('commit');
296
328
 
297
- /** After a commit: new levels get entries; levels the app dismissed itself take the history back with them; a moved sidebar selection is an entry too. `enabling`: nothing is drawn yet, so no level is taken as dismissed. */
298
- sync(enabling = false): void {
329
+ /**
330
+ * New levels get entries; levels the app dismissed itself take the history
331
+ * back with them; a moved sidebar selection is an entry too.
332
+ *
333
+ * The entries are pushed at the `precommit`, before the renderer lays the
334
+ * new screen out, and nothing else happens then: Safari sizes the entry
335
+ * being left from the page's last layout at the push (its snapshot's
336
+ * render tree size, more than half of which the page must have again
337
+ * before the snapshot comes down after a swipe back to it), so a push
338
+ * after that layout counted the pushed screen into the entry it covers,
339
+ * and a root lighter than the screen over it stayed under the snapshot
340
+ * until Safari's 3 s watchdog after every swipe back. The rest waits for
341
+ * the `commit`, when the pass is drawn. `enabling`: nothing is drawn yet,
342
+ * so no level is taken as dismissed.
343
+ */
344
+ sync(stage: SyncStage = 'commit'): void {
299
345
  if (!this.enabled) return;
346
+ const dismissing = this.dismissing;
347
+ if (stage === 'precommit' && dismissing) return;
300
348
  const depth = this.depth();
301
349
  const recorded = this.recorded();
302
350
  const selection = this.selection();
303
- const dismissing = this.dismissing;
304
351
  if (dismissing) {
305
352
  // Dismissing for a `popstate`: the entry already names the target depth.
306
353
  if (depth > dismissing.target && performance.now() < dismissing.until) {
@@ -320,7 +367,7 @@ export class BrowserHistory {
320
367
  }
321
368
  for (let level = recorded + 1; level <= depth; level++) this.win.history.pushState(this.entry(level, selection), '');
322
369
  } else if (depth < recorded) {
323
- if (enabling) return;
370
+ if (stage !== 'commit') return;
324
371
  // The app popped or dismissed on its own (back button, swipe, code): leave those entries.
325
372
  this.go(depth - recorded, depth);
326
373
  } else if (this.movingTo === null && selection !== this.recordedSelection()) {
@@ -336,6 +383,12 @@ export class BrowserHistory {
336
383
  this.restoringSelection = false;
337
384
  }
338
385
 
386
+ private readonly onTouchStart = (event: TouchEvent): void => {
387
+ const touch = event.touches[0];
388
+ if (!touch) return;
389
+ if (touch.clientX <= SWIPE_EDGE_PX || touch.clientX >= this.win.innerWidth - SWIPE_EDGE_PX) this.lastEdgeTouch = performance.now();
390
+ };
391
+
339
392
  private readonly onPopState = (event: PopStateEvent): void => {
340
393
  if (this.ignorePops > 0) {
341
394
  this.ignorePops -= 1;
@@ -350,8 +403,10 @@ export class BrowserHistory {
350
403
  // A move the browser makes replaces any back still in progress.
351
404
  this.dismissing = null;
352
405
  if (target.sbDepth < depth) {
353
- // Back: dismiss one level now, the rest as each pop lands.
354
- this.dismissing = { target: target.sbDepth, from: depth, until: 0 };
406
+ // Back: dismiss one level now, the rest as each pop lands. After a
407
+ // touch at a side of the page it is the browser's swipe, animated already.
408
+ const instant = performance.now() - this.lastEdgeTouch < SWIPE_TOUCH_MS;
409
+ this.dismissing = { target: target.sbDepth, from: depth, until: 0, instant };
355
410
  this.dismissFrom(depth);
356
411
  } else if (target.sbDepth > depth) {
357
412
  // Forward: a screen cannot be re-pushed; return to the entry that matches.
package/src/renderer.ts CHANGED
@@ -5,9 +5,11 @@
5
5
  * element tree as `LayoutNode`s; id 0 is the root container supplied by the
6
6
  * host page. Every op is applied to the DOM immediately; at `commit` the
7
7
  * layout engine (src/layout) computes a frame for every element and
8
- * `layoutDom.ts` positions the elements absolutely from it (a `sb:commit`
9
- * CustomEvent is dispatched on the root so tests and tooling can observe pass
10
- * boundaries). Animated passes are handed to the `Animator`.
8
+ * `layoutDom.ts` positions the elements absolutely from it (a `sb:precommit`
9
+ * CustomEvent is dispatched on the root once the pass's ops are applied and
10
+ * before that layout, and a `sb:commit` after it, so the browser history,
11
+ * tests and tooling can observe pass boundaries). Animated passes are handed
12
+ * to the `Animator`.
11
13
  *
12
14
  * Most kinds are a single DOM element whose children are the element's
13
15
  * children. Compound kinds (`navstack`, `navscreen`, `section`, `toggle`,
@@ -1062,6 +1064,10 @@ export class Renderer {
1062
1064
  this.passRootRect = null;
1063
1065
  this.preSnapshots.clear();
1064
1066
  this.preRects.clear();
1067
+ // Before the layout that first holds this pass's new screens: a
1068
+ // history entry pushed here (browserHistory.ts) is sized by Safari
1069
+ // from the page's last layout, without them.
1070
+ this.root.dispatchEvent(new CustomEvent('sb:precommit', { detail: { commits: this.commits + 1 } }));
1065
1071
  const changes = this.layout();
1066
1072
  this.animator.commitNextFrame(pass, op.animation, changes);
1067
1073
  this.commits += 1;
@@ -1304,6 +1310,24 @@ export class Renderer {
1304
1310
  this.onEvent({ type: 'tap', id });
1305
1311
  }
1306
1312
 
1313
+ /**
1314
+ * Screens the browser's own history move pops (`popWithoutAnimation`):
1315
+ * the browser has animated the pop already, so the renderer does not.
1316
+ */
1317
+ private instantPops = new Set<ElementId>();
1318
+
1319
+ /**
1320
+ * The next removal of screen `id` is a pop the browser has already shown
1321
+ * (its back swipe or button, `browserHistory.ts`): the screen goes without
1322
+ * the slide, and the screen it uncovers takes its place at once rather
1323
+ * than sliding back, so the pop does not play a second time.
1324
+ */
1325
+ popWithoutAnimation(id: ElementId): void {
1326
+ this.instantPops.add(id);
1327
+ // A pop the app refused leaves nothing to skip for long.
1328
+ window.setTimeout(() => this.instantPops.delete(id), NAV_TRANSITION_MS * 5);
1329
+ }
1330
+
1307
1331
  /** The repaint keep-alive (`keepRepainting`): its box, timer and deadline while it runs. */
1308
1332
  private repaintKeepAlive: { el: HTMLElement; timer: number; until: number; on: boolean } | null = null;
1309
1333
 
@@ -2215,7 +2239,8 @@ export class Renderer {
2215
2239
  // screen (`--sb-nav-from`), and the screen below is handed back to the
2216
2240
  // stylesheet so it finishes its slide to 0 as the new top screen.
2217
2241
  this.interaction.screenRemoved(el);
2218
- animatePop(stack, el);
2242
+ if (this.instantPops.delete(id)) settleWithoutTransition(stack);
2243
+ else animatePop(stack, el);
2219
2244
  }
2220
2245
  const isSheet = el.classList.contains('sb-sheet');
2221
2246
  if (isSheet && parent) animateSheetOut(parent, el);
@@ -3024,8 +3049,10 @@ function afterAnimation(target: HTMLElement, expectedMs: number, done: () => voi
3024
3049
 
3025
3050
  function animatePush(screen: HTMLElement): void {
3026
3051
  screen.classList.remove('sb-navscreen--push');
3027
- // Force a style flush so re-adding the class restarts the animation.
3028
- void screen.offsetWidth;
3052
+ // Flush the style (not the layout: a layout forced here, with the screen
3053
+ // just inserted, would size the history entry it covers by it, see
3054
+ // browserHistory.ts) so re-adding the class restarts the animation.
3055
+ void getComputedStyle(screen).animationName;
3029
3056
  screen.classList.add('sb-navscreen--push');
3030
3057
  afterAnimation(screen, NAV_TRANSITION_MS, () => screen.classList.remove('sb-navscreen--push'));
3031
3058
  }
@@ -3035,6 +3062,16 @@ function animatePush(screen: HTMLElement): void {
3035
3062
  * visual clone (stripped of element ids so it is invisible to the renderer)
3036
3063
  * slides away on top of the stack and is dropped afterwards.
3037
3064
  */
3065
+ /**
3066
+ * A pop the browser has already animated: the stack's screens take their new
3067
+ * places without a transition (styles.css, `data-sb-instant`) for the frames
3068
+ * the change lands in.
3069
+ */
3070
+ function settleWithoutTransition(stack: HTMLElement): void {
3071
+ stack.dataset['sbInstant'] = '';
3072
+ requestAnimationFrame(() => requestAnimationFrame(() => delete stack.dataset['sbInstant']));
3073
+ }
3074
+
3038
3075
  function animatePop(stack: HTMLElement, screen: HTMLElement): void {
3039
3076
  const clone = ghostClone(screen);
3040
3077
  clone.removeAttribute('inert');
package/src/styles.css CHANGED
@@ -1065,6 +1065,13 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1065
1065
  animation: sb-nav-push 0.4s var(--sb-nav-easing) both;
1066
1066
  }
1067
1067
 
1068
+ /* A pop the browser has animated already (its back swipe, renderer.ts
1069
+ popWithoutAnimation): the uncovered screen takes its place at once. */
1070
+ .sb-navstack[data-sb-instant] > .sb-slot > .sb-navscreen,
1071
+ .sb-navstack[data-sb-instant] > .sb-slot > .sb-navscreen::after {
1072
+ transition: none !important;
1073
+ }
1074
+
1068
1075
  .sb-navstack > .sb-navscreen--pop {
1069
1076
  z-index: 2;
1070
1077
  pointer-events: none;