@rdlabo/ionic-theme-ios27 1.0.4-beta.pr200.shadf4bf1ee4081 → 1.0.4-beta.pr206.sha6e297949cd2b

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.
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/native/runtime.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAGV,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,EAEpB,MAAM,eAAe,CAAC;AAkBvB,eAAO,MAAM,aAAa,GACxB,KAAK,QAAQ,EACb,QAAQ,mBAAmB,EAC3B,UAAS,oBAAyB,KACjC,OAAO,CAAC,mBAAmB,CAof7B,CAAC"}
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/native/runtime.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAGV,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,EAEpB,MAAM,eAAe,CAAC;AAkBvB,eAAO,MAAM,aAAa,GACxB,KAAK,QAAQ,EACb,QAAQ,mBAAmB,EAC3B,UAAS,oBAAyB,KACjC,OAAO,CAAC,mBAAmB,CA+jB7B,CAAC"}
@@ -39,6 +39,11 @@ export const createRuntime = async (doc, plugin, options = {}) => {
39
39
  let dirty = false;
40
40
  let pending = false;
41
41
  let stopped = false;
42
+ let tabSwitchHandoff = false;
43
+ let handoffUntil = 0;
44
+ let handoffAcrossPending = false;
45
+ let handoffInstant = false;
46
+ let modalPresentHandoff = false;
42
47
  let frame = 0;
43
48
  let updates = 0;
44
49
  let reason;
@@ -75,7 +80,7 @@ export const createRuntime = async (doc, plugin, options = {}) => {
75
80
  search.release(element);
76
81
  element.removeAttribute(marker);
77
82
  if (!stopped)
78
- crossfade.play(element, false);
83
+ crossfade.play(element, false, handoffInstant);
79
84
  if (element.getAttribute('aria-hidden') === 'true') {
80
85
  const previous = sources.get(element);
81
86
  if (previous == null)
@@ -186,6 +191,8 @@ export const createRuntime = async (doc, plugin, options = {}) => {
186
191
  frame = 0;
187
192
  dirty = false;
188
193
  pending = true;
194
+ handoffInstant = tabSwitchHandoff || handoffAcrossPending || modalPresentHandoff || win.performance.now() < handoffUntil;
195
+ modalPresentHandoff = false;
189
196
  try {
190
197
  const size = `${win.innerWidth}:${win.innerHeight}`;
191
198
  if (viewport !== size) {
@@ -208,15 +215,17 @@ export const createRuntime = async (doc, plugin, options = {}) => {
208
215
  const removed = Array.from(sources.keys()).filter((element) => !retained.has(element));
209
216
  if (removed.length) {
210
217
  removed.forEach(restore);
211
- await painted();
212
- if (stopped || dirty)
213
- return;
218
+ if (!handoffInstant) {
219
+ await painted();
220
+ if (stopped || dirty)
221
+ return;
222
+ }
214
223
  }
215
224
  const data = { viewportWidth: win.innerWidth, controls: candidates.map((candidate) => candidate.control) };
216
225
  const serialized = JSON.stringify(data);
217
226
  if (serialized === lastSnapshot && !forceRefresh)
218
227
  return;
219
- const snapshot = { ...data, revision: ++revision, transitionDuration: crossfade.duration() };
228
+ const snapshot = { ...data, revision: ++revision, transitionDuration: crossfade.duration(handoffInstant) };
220
229
  forceRefresh = false;
221
230
  updates++;
222
231
  updatingRevision = snapshot.revision;
@@ -255,7 +264,7 @@ export const createRuntime = async (doc, plugin, options = {}) => {
255
264
  for (const element of accepted.flatMap(candidateSources)) {
256
265
  if (!sources.has(element)) {
257
266
  sources.set(element, element.getAttribute('aria-hidden'));
258
- crossfade.play(element, true);
267
+ crossfade.play(element, true, handoffInstant);
259
268
  element.setAttribute(marker, '');
260
269
  element.setAttribute('aria-hidden', 'true');
261
270
  element.dispatchEvent(new CustomEvent('nativeUIShellChange'));
@@ -278,6 +287,10 @@ export const createRuntime = async (doc, plugin, options = {}) => {
278
287
  pendingActivations = [];
279
288
  if (forceRefresh)
280
289
  rejected = new WeakMap();
290
+ if (!stopped && (handoffAcrossPending || (handoffInstant && dirty))) {
291
+ handoffUntil = Math.max(handoffUntil, win.performance.now() + 400);
292
+ }
293
+ handoffAcrossPending = false;
281
294
  pending = false;
282
295
  if (dirty && !stopped)
283
296
  schedule();
@@ -301,12 +314,56 @@ export const createRuntime = async (doc, plugin, options = {}) => {
301
314
  getNativeSearchBindings(doc)
302
315
  .filter((binding) => page.contains(binding.footer))
303
316
  .forEach((binding) => search.retire(binding));
317
+ if (event.type === LIFECYCLE_WILL_LEAVE)
318
+ beginTabSwitchHandoffIfNeeded(page);
304
319
  pages.add(page);
305
320
  schedule();
306
321
  };
307
322
  const pageDid = (event) => {
308
323
  pages.delete(event.target);
309
324
  schedule();
325
+ if (tabSwitchHandoff && pages.size === 0)
326
+ endTabSwitchHandoff();
327
+ };
328
+ const tabOfPath = (tabs, pathname) => {
329
+ let best;
330
+ for (const button of Array.from(tabs.querySelectorAll('ion-tab-button'))) {
331
+ const tab = button.getAttribute('tab');
332
+ const href = button.getAttribute('href')?.split(/[?#]/)[0];
333
+ if (!tab || !href)
334
+ continue;
335
+ if (pathname === href || pathname.startsWith(`${href}/`)) {
336
+ if (!best || href.length > best.length)
337
+ best = { tab, length: href.length };
338
+ }
339
+ }
340
+ return best?.tab;
341
+ };
342
+ const armTabSwitchHandoff = () => {
343
+ tabSwitchHandoff = true;
344
+ handoffUntil = Math.max(handoffUntil, win.performance.now() + 400);
345
+ if (pending)
346
+ handoffAcrossPending = true;
347
+ };
348
+ const beginTabSwitchHandoffIfNeeded = (page) => {
349
+ const tabs = page.closest('ion-tabs');
350
+ if (!tabs)
351
+ return;
352
+ const bar = tabs.querySelector('ion-tab-bar');
353
+ const selected = (bar && 'selectedTab' in bar ? String(bar.selectedTab ?? '') : '') ||
354
+ tabs.querySelector('ion-tab-button.tab-selected')?.getAttribute('tab') ||
355
+ undefined;
356
+ const destination = tabOfPath(tabs, win.location.pathname);
357
+ if (selected && destination && selected !== destination)
358
+ armTabSwitchHandoff();
359
+ };
360
+ const endTabSwitchHandoff = () => {
361
+ win.requestAnimationFrame(() => {
362
+ if (stopped || !tabSwitchHandoff)
363
+ return;
364
+ tabSwitchHandoff = false;
365
+ schedule();
366
+ });
310
367
  };
311
368
  const motion = (event) => {
312
369
  const target = event.target;
@@ -331,9 +388,19 @@ export const createRuntime = async (doc, plugin, options = {}) => {
331
388
  on(doc, name, pageWill);
332
389
  for (const name of [LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE])
333
390
  on(doc, name, pageDid);
391
+ on(doc, 'ionTabsWillChange', () => {
392
+ armTabSwitchHandoff();
393
+ schedule();
394
+ });
395
+ on(doc, 'ionTabsDidChange', endTabSwitchHandoff);
334
396
  for (const name of overlayNames) {
335
397
  on(doc, `ion${name}WillPresent`, (event) => {
336
398
  presented.add(event.target);
399
+ if (name === 'Modal') {
400
+ modalPresentHandoff = true;
401
+ if (pending)
402
+ handoffInstant = true;
403
+ }
337
404
  schedule();
338
405
  });
339
406
  on(doc, `ion${name}DidDismiss`, (event) => {
@@ -352,7 +419,6 @@ export const createRuntime = async (doc, plugin, options = {}) => {
352
419
  for (const name of [
353
420
  'ionChange',
354
421
  'ionSelect',
355
- 'ionTabsDidChange',
356
422
  'ionImgDidLoad',
357
423
  CSS_MOTION_EVENTS.transitionEnd,
358
424
  CSS_MOTION_EVENTS.animationEnd,
@@ -1,7 +1,7 @@
1
1
  export declare const fadeMarker = "data-native-ui-shell-fading";
2
2
  export declare const createCrossfade: (win: Window) => {
3
- duration: () => 0 | 180;
4
- play(element: HTMLElement, toNative: boolean): void;
3
+ duration: (instant?: boolean) => 0 | 180;
4
+ play(element: HTMLElement, toNative: boolean, instant?: boolean): void;
5
5
  settled(): Promise<void>;
6
6
  destroy(): void;
7
7
  };
@@ -1 +1 @@
1
- {"version":3,"file":"crossfade.d.ts","sourceRoot":"","sources":["../../../src/native/shared/crossfade.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,UAAU,gCAAgC,CAAC;AAGxD,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM;;kBAezB,WAAW,YAAY,OAAO;;;CA8B/C,CAAC"}
1
+ {"version":3,"file":"crossfade.d.ts","sourceRoot":"","sources":["../../../src/native/shared/crossfade.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,UAAU,gCAAgC,CAAC;AAGxD,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM;;kBAezB,WAAW,YAAY,OAAO;;;CA+B/C,CAAC"}
@@ -15,14 +15,14 @@ export const createCrossfade = (win) => {
15
15
  };
16
16
  reduced.addEventListener('change', cancelAll);
17
17
  return {
18
- duration: () => (reduced.matches ? 0 : 180),
19
- play(element, toNative) {
18
+ duration: (instant = false) => (reduced.matches || instant ? 0 : 180),
19
+ play(element, toNative, instant = false) {
20
20
  const previous = active.get(element);
21
21
  const current = Number(win.getComputedStyle(element).opacity);
22
22
  previous?.cancel();
23
23
  active.delete(element);
24
24
  const opacity = Number(win.getComputedStyle(element).opacity);
25
- if (reduced.matches || !element.isConnected) {
25
+ if (reduced.matches || instant || !element.isConnected) {
26
26
  element.removeAttribute(fadeMarker);
27
27
  return;
28
28
  }
@@ -118,6 +118,8 @@ Custom host animation or transition declarations on the FAB, list or button keep
118
118
 
119
119
  The connection in `src/transition/ios.transition.ts` waits for native retirement before starting the Web animation. Interactive progress and completion/cancellation are queued while that retirement is in progress. Stationary shared tabs are retained. First render and transitions without an animation builder are covered by the startup runtime and Ionic lifecycle events.
120
120
 
121
+ Tab switches skip the Web/native crossfade so a retiring UIKit snapshot cannot linger over the next tab. Detection uses the router URL versus the still-selected tab on `ionViewWillLeave` (and vanilla `ionTabsWillChange` / `ionTabsDidChange` DOM events). Stack pushes and pops keep the normal 180ms handoff.
122
+
121
123
  Standard Ionic overlays suspend native projection until dismissal. Unsupported searchable-tab configurations use the existing Web animation and share this ownership with Web glass gestures. CSS motion of supported containing surfaces also causes temporary Web rendering.
122
124
 
123
125
  During retirement, the source is restored and allowed to paint before its native cover is removed. During acquisition, the source is hidden only after a successful, current native response. Delayed responses are revalidated per control: existing eligible controls retain their native cover while content updates catch up. Only removed or ineligible sources return to Web; newly acquired sources require an exact acknowledgement. Ordinary page mutations never call the global clear operation. UIKit tab instances and items are retained, and equal frames/selections are not reapplied. Duplicate/stale activations are discarded. WebKit and UIKit still render separately: the implementation avoids an intentional blank frame, but does not provide an OS-level atomic compositing guarantee. Validate custom transitions and overlays on the app's supported simulators before rollout; unknown overlay systems are outside the automatic integration contract.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rdlabo/ionic-theme-ios27",
3
3
  "private": false,
4
- "version": "1.0.4-beta.pr200.shadf4bf1ee4081",
4
+ "version": "1.0.4-beta.pr206.sha6e297949cd2b",
5
5
  "description": "iOS27 Theme for Ionic Framework",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
@@ -58,6 +58,16 @@ export const createRuntime = async (
58
58
  let dirty = false;
59
59
  let pending = false;
60
60
  let stopped = false;
61
+ /** True while a tab-switch handoff should skip crossfade. */
62
+ let tabSwitchHandoff = false;
63
+ /** Keep instant updates until this time — DidLeave often precedes the retire sync. */
64
+ let handoffUntil = 0;
65
+ /** Tab switch armed while a sync was already in flight; extend instant past that sync. */
66
+ let handoffAcrossPending = false;
67
+ /** Captured at the start of each sync so an in-flight update keeps a stable duration. */
68
+ let handoffInstant = false;
69
+ /** Retire native controls immediately as a modal starts covering the page. */
70
+ let modalPresentHandoff = false;
61
71
  let frame = 0;
62
72
  let updates = 0;
63
73
  let reason: string | undefined;
@@ -91,7 +101,7 @@ export const createRuntime = async (
91
101
  lastSnapshot = '';
92
102
  search.release(element);
93
103
  element.removeAttribute(marker);
94
- if (!stopped) crossfade.play(element, false);
104
+ if (!stopped) crossfade.play(element, false, handoffInstant);
95
105
  if (element.getAttribute('aria-hidden') === 'true') {
96
106
  const previous = sources.get(element);
97
107
  if (previous == null) element.removeAttribute('aria-hidden');
@@ -197,6 +207,8 @@ export const createRuntime = async (
197
207
  frame = 0;
198
208
  dirty = false;
199
209
  pending = true;
210
+ handoffInstant = tabSwitchHandoff || handoffAcrossPending || modalPresentHandoff || win.performance.now() < handoffUntil;
211
+ modalPresentHandoff = false;
200
212
  try {
201
213
  const size = `${win.innerWidth}:${win.innerHeight}`;
202
214
  // WebKit can resize before Ionic's fixed DOM positions catch up.
@@ -225,13 +237,17 @@ export const createRuntime = async (
225
237
  if (removed.length) {
226
238
  // Restore the source and let WebKit paint before removing its native cover.
227
239
  removed.forEach(restore);
228
- await painted();
229
- if (stopped || dirty) return;
240
+ // The outgoing tab is no longer visible, so waiting two frames only leaves its
241
+ // native snapshot over the destination. Stack transitions still need the paint.
242
+ if (!handoffInstant) {
243
+ await painted();
244
+ if (stopped || dirty) return;
245
+ }
230
246
  }
231
247
  const data = { viewportWidth: win.innerWidth, controls: candidates.map((candidate) => candidate.control) };
232
248
  const serialized = JSON.stringify(data);
233
249
  if (serialized === lastSnapshot && !forceRefresh) return;
234
- const snapshot: ShellSnapshot = { ...data, revision: ++revision, transitionDuration: crossfade.duration() };
250
+ const snapshot: ShellSnapshot = { ...data, revision: ++revision, transitionDuration: crossfade.duration(handoffInstant) };
235
251
  // A native visibility notification during this update must survive its ack.
236
252
  forceRefresh = false;
237
253
  updates++;
@@ -272,7 +288,7 @@ export const createRuntime = async (
272
288
  for (const element of accepted.flatMap(candidateSources)) {
273
289
  if (!sources.has(element)) {
274
290
  sources.set(element, element.getAttribute('aria-hidden'));
275
- crossfade.play(element, true);
291
+ crossfade.play(element, true, handoffInstant);
276
292
  element.setAttribute(marker, '');
277
293
  element.setAttribute('aria-hidden', 'true');
278
294
  element.dispatchEvent(new CustomEvent('nativeUIShellChange'));
@@ -294,6 +310,11 @@ export const createRuntime = async (
294
310
  pendingActivations = [];
295
311
  // A refresh received before the ack must also invalidate that ack's rejects.
296
312
  if (forceRefresh) rejected = new WeakMap();
313
+ // A tab switch mid-update (or a dirty follow-up) must keep instant through the retire sync.
314
+ if (!stopped && (handoffAcrossPending || (handoffInstant && dirty))) {
315
+ handoffUntil = Math.max(handoffUntil, win.performance.now() + 400);
316
+ }
317
+ handoffAcrossPending = false;
297
318
  pending = false;
298
319
  if (dirty && !stopped) schedule();
299
320
  else finishWaiters();
@@ -323,12 +344,55 @@ export const createRuntime = async (
323
344
  getNativeSearchBindings(doc)
324
345
  .filter((binding) => page.contains(binding.footer))
325
346
  .forEach((binding) => search.retire(binding));
347
+ // Angular updates the URL before willLeave, but keeps ion-tab-bar.selectedTab on the
348
+ // leaving tab until DidChange. A mismatch means this leave is a tab switch, not a stack push.
349
+ if (event.type === LIFECYCLE_WILL_LEAVE) beginTabSwitchHandoffIfNeeded(page);
326
350
  pages.add(page);
327
351
  schedule();
328
352
  };
329
353
  const pageDid: EventListener = (event) => {
330
354
  pages.delete(event.target as HTMLElement);
331
355
  schedule();
356
+ if (tabSwitchHandoff && pages.size === 0) endTabSwitchHandoff();
357
+ };
358
+ const tabOfPath = (tabs: Element, pathname: string) => {
359
+ let best: { tab: string; length: number } | undefined;
360
+ for (const button of Array.from(tabs.querySelectorAll('ion-tab-button'))) {
361
+ const tab = button.getAttribute('tab');
362
+ const href = button.getAttribute('href')?.split(/[?#]/)[0];
363
+ if (!tab || !href) continue;
364
+ if (pathname === href || pathname.startsWith(`${href}/`)) {
365
+ if (!best || href.length > best.length) best = { tab, length: href.length };
366
+ }
367
+ }
368
+ return best?.tab;
369
+ };
370
+ const armTabSwitchHandoff = () => {
371
+ tabSwitchHandoff = true;
372
+ // Retiring outgoing controls often lands in a later async sync than WillLeave/DidChange.
373
+ handoffUntil = Math.max(handoffUntil, win.performance.now() + 400);
374
+ // The in-flight sync already captured handoffInstant=false; hold instant for the next one.
375
+ if (pending) handoffAcrossPending = true;
376
+ };
377
+ const beginTabSwitchHandoffIfNeeded = (page: HTMLElement) => {
378
+ const tabs = page.closest('ion-tabs');
379
+ if (!tabs) return;
380
+ const bar = tabs.querySelector('ion-tab-bar');
381
+ const selected =
382
+ (bar && 'selectedTab' in bar ? String((bar as HTMLElement & { selectedTab?: string }).selectedTab ?? '') : '') ||
383
+ tabs.querySelector('ion-tab-button.tab-selected')?.getAttribute('tab') ||
384
+ undefined;
385
+ const destination = tabOfPath(tabs, win.location.pathname);
386
+ if (selected && destination && selected !== destination) armTabSwitchHandoff();
387
+ };
388
+ // DidChange / pageDid can land in the same turn as WillLeave. Clear the sticky flag on the
389
+ // next frame; handoffUntil / handoffAcrossPending still cover the async retire sync.
390
+ const endTabSwitchHandoff = () => {
391
+ win.requestAnimationFrame(() => {
392
+ if (stopped || !tabSwitchHandoff) return;
393
+ tabSwitchHandoff = false;
394
+ schedule();
395
+ });
332
396
  };
333
397
  const motion: EventListener = (event) => {
334
398
  const target = event.target as HTMLElement;
@@ -347,9 +411,21 @@ export const createRuntime = async (
347
411
  for (const name of Object.values(CSS_MOTION_EVENTS)) on(doc, name, motion);
348
412
  for (const name of [LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE]) on(doc, name, pageWill);
349
413
  for (const name of [LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE]) on(doc, name, pageDid);
414
+ on(doc, 'ionTabsWillChange', () => {
415
+ // Vanilla ion-tabs dispatches DOM events; @ionic/angular uses EventEmitters instead.
416
+ armTabSwitchHandoff();
417
+ schedule();
418
+ });
419
+ on(doc, 'ionTabsDidChange', endTabSwitchHandoff);
350
420
  for (const name of overlayNames) {
351
421
  on(doc, `ion${name}WillPresent`, (event) => {
352
422
  presented.add(event.target as HTMLElement);
423
+ if (name === 'Modal') {
424
+ modalPresentHandoff = true;
425
+ // WillPresent may arrive while another bridge update is awaiting its ack.
426
+ // Make that retirement instant too instead of racing the modal animation.
427
+ if (pending) handoffInstant = true;
428
+ }
353
429
  schedule();
354
430
  });
355
431
  on(doc, `ion${name}DidDismiss`, (event) => {
@@ -368,7 +444,6 @@ export const createRuntime = async (
368
444
  for (const name of [
369
445
  'ionChange',
370
446
  'ionSelect',
371
- 'ionTabsDidChange',
372
447
  'ionImgDidLoad',
373
448
  CSS_MOTION_EVENTS.transitionEnd,
374
449
  CSS_MOTION_EVENTS.animationEnd,
@@ -15,14 +15,15 @@ export const createCrossfade = (win: Window) => {
15
15
  };
16
16
  reduced.addEventListener('change', cancelAll);
17
17
  return {
18
- duration: () => (reduced.matches ? 0 : 180),
19
- play(element: HTMLElement, toNative: boolean) {
18
+ duration: (instant = false) => (reduced.matches || instant ? 0 : 180),
19
+ play(element: HTMLElement, toNative: boolean, instant = false) {
20
20
  const previous = active.get(element);
21
21
  const current = Number(win.getComputedStyle(element).opacity);
22
22
  previous?.cancel();
23
23
  active.delete(element);
24
24
  const opacity = Number(win.getComputedStyle(element).opacity);
25
- if (reduced.matches || !element.isConnected) {
25
+ // Tab switches skip the opacity handoff so a retiring native snapshot cannot linger.
26
+ if (reduced.matches || instant || !element.isConnected) {
26
27
  element.removeAttribute(fadeMarker);
27
28
  return;
28
29
  }