| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -540,10 +540,44 @@ export class ApplicationCommon { | |||
| 540 | 540 | }); | |
| 541 | 541 | } | |
| 542 | 542 | ||
| 543 | + /** | ||
| 544 | + * @internal - retire the windows behind discarded window-session ids. | ||
| 545 | + * | ||
| 546 | + * A session id can only stand in for a window whose native surface is already gone. | ||
| 547 | + * iOS reports sessions discarded while the app was not running on the next launch, | ||
| 548 | + * and such an id can name the session driving the app now, so an id match alone is | ||
| 549 | + * no evidence that the window is finished with. Retiring an attached window tears | ||
| 550 | + * down the UI in use - its root view unloads and nothing ever reloads it - so only | ||
| 551 | + * detached windows are retired. Ids matching no window are ignored: they routinely | ||
| 552 | + * belong to windows this JS context has never seen. | ||
| 553 | + */ | ||
| 554 | + _retireDiscardedWindows(ids: string[]): void { | ||
| 555 | + for (const id of ids) { | ||
| 556 | + const nativeWindow = id ? this.getWindowById(id) : undefined; | ||
| 557 | + | ||
| 558 | + if (!nativeWindow) { | ||
| 559 | + continue; | ||
| 560 | + } | ||
| 561 | + | ||
| 562 | + if (nativeWindow.state === 'attached') { | ||
| 563 | + Trace.write(`Ignoring a discarded session for window '${id}': its surface is still attached.`, Trace.categories.NativeLifecycle, Trace.messageType.warn); | ||
| 564 | + | ||
| 565 | + continue; | ||
| 566 | + } | ||
| 567 | + | ||
| 568 | + nativeWindow._notifyEvent(NativeWindowEvents.close); | ||
| 569 | + this._unregisterWindow(nativeWindow); | ||
| 570 | + } | ||
| 571 | + } | ||
| 572 | + | ||
| 543 | 573 | /** | |
| 544 | 574 | * @internal - Unregister a NativeWindow when its native surface is gone for good. | |
| 545 | 575 | */ | |
| 546 | 576 | _unregisterWindow(nativeWindow: NativeWindow): void { | |
| 577 | + if (!nativeWindow._surfaceGone) { | ||
| 578 | + Trace.write(`Unregistering window '${nativeWindow.id}' while its native surface is still live. A window may only be retired once the platform has reported the surface gone.`, Trace.categories.NativeLifecycle, Trace.messageType.error); | ||
| 579 | + } | ||
| 580 | + | ||
| 547 | 581 | const idx = this._windows.indexOf(nativeWindow); | |
| 548 | 582 | if (idx >= 0) { | |
| 549 | 583 | this._windows.splice(idx, 1); | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -112,6 +112,15 @@ describe('ApplicationCommon window registry', () => { | |||
| 112 | 112 | setActiveWindow(undefined); | |
| 113 | 113 | }); | |
| 114 | 114 | ||
| 115 | + /** | ||
| 116 | + * Retires a window the way a platform does: the disconnect callback records that the | ||
| 117 | + * surface is gone, and only then is the window unregistered. | ||
| 118 | + */ | ||
| 119 | + function retire(window: WindowBase): void { | ||
| 120 | + window._surfaceGone = true; | ||
| 121 | + app._unregisterWindow(asWindow(window)); | ||
| 122 | + } | ||
| 123 | + | ||
| 115 | 124 | function record(...eventNames: string[]): Array<{ eventName: string; window: WindowBase }> { | |
| 116 | 125 | const recorded: Array<{ eventName: string; window: WindowBase }> = []; | |
| 117 | 126 | for (const eventName of eventNames) { | |
@@ -224,7 +233,7 @@ describe('ApplicationCommon window registry', () => { | |||
| 224 | 233 | app._registerWindow(window); | |
| 225 | 234 | const recorded = record('windowClose'); | |
| 226 | 235 | ||
| 227 | - app._unregisterWindow(window); | ||
| 236 | + retire(window); | ||
| 228 | 237 | ||
| 229 | 238 | expect(recorded.map((entry) => entry.window)).toEqual([window]); | |
| 230 | 239 | expect(app.getWindows()).toEqual([]); | |
@@ -244,7 +253,7 @@ describe('ApplicationCommon window registry', () => { | |||
| 244 | 253 | detached._detach(); | |
| 245 | 254 | ||
| 246 | 255 | const recorded = record('windowClose', 'primaryWindowChanged'); | |
| 247 | - app._unregisterWindow(primary); | ||
| 256 | + retire(primary); | ||
| 248 | 257 | ||
| 249 | 258 | expect(events).toEqual(['windowClose', 'primaryWindowChanged']); | |
| 250 | 259 | expect(recorded[1].window).toBe(successor); | |
@@ -261,7 +270,7 @@ describe('ApplicationCommon window registry', () => { | |||
| 261 | 270 | detached._detach(); | |
| 262 | 271 | ||
| 263 | 272 | record('windowClose', 'primaryWindowChanged'); | |
| 264 | - app._unregisterWindow(primary); | ||
| 273 | + retire(primary); | ||
| 265 | 274 | ||
| 266 | 275 | expect(events).toEqual(['windowClose']); | |
| 267 | 276 | expect(primary.isPrimary).toBe(false); | |
@@ -275,7 +284,7 @@ describe('ApplicationCommon window registry', () => { | |||
| 275 | 284 | app._registerWindow(secondary); | |
| 276 | 285 | ||
| 277 | 286 | record('windowClose', 'primaryWindowChanged'); | |
| 278 | - app._unregisterWindow(secondary); | ||
| 287 | + retire(secondary); | ||
| 279 | 288 | ||
| 280 | 289 | expect(events).toEqual(['windowClose']); | |
| 281 | 290 | expect(app.primaryWindow).toBe(primary); | |
@@ -308,7 +317,7 @@ describe('ApplicationCommon window registry', () => { | |||
| 308 | 317 | it('falls back to the primary window once the active one closes', () => { | |
| 309 | 318 | secondary._notifyEvent(NativeWindowEvents.activate); | |
| 310 | 319 | ||
| 311 | - app._unregisterWindow(secondary); | ||
| 320 | + retire(secondary); | ||
| 312 | 321 | ||
| 313 | 322 | expect(app.activeWindow).toBe(primary); | |
| 314 | 323 | }); | |
@@ -320,4 +329,80 @@ describe('ApplicationCommon window registry', () => { | |||
| 320 | 329 | expect(app.activeWindow).toBe(primary); | |
| 321 | 330 | }); | |
| 322 | 331 | }); | |
| 332 | + | ||
| 333 | + describe('discarded window sessions', () => { | ||
| 334 | + let attached: TestWindow; | ||
| 335 | + let detached: TestWindow; | ||
| 336 | + | ||
| 337 | + beforeEach(() => { | ||
| 338 | + attached = new TestWindow('scene-live', true).withContent(); | ||
| 339 | + detached = new TestWindow('scene-gone').withContent(); | ||
| 340 | + | ||
| 341 | + app._registerWindow(attached); | ||
| 342 | + app._registerWindow(detached); | ||
| 343 | + | ||
| 344 | + detached._detach(); | ||
| 345 | + }); | ||
| 346 | + | ||
| 347 | + it('retires a window whose surface is already gone', () => { | ||
| 348 | + record('windowClose'); | ||
| 349 | + | ||
| 350 | + // The window drops its listeners in `_destroy`, so a listener only sees `close` | ||
| 351 | + // if it is raised before the window is unregistered. | ||
| 352 | + let closed = false; | ||
| 353 | + detached.on(NativeWindowEvents.close, () => { | ||
| 354 | + closed = true; | ||
| 355 | + }); | ||
| 356 | + | ||
| 357 | + app._retireDiscardedWindows(['scene-gone']); | ||
| 358 | + | ||
| 359 | + expect(app.getWindowById('scene-gone')).toBeUndefined(); | ||
| 360 | + expect(closed).toBe(true); | ||
| 361 | + expect(events).toEqual(['windowClose']); | ||
| 362 | + }); | ||
| 363 | + | ||
| 364 | + /** | ||
| 365 | + * iOS reports sessions discarded in an earlier run on the next launch, and such an | ||
| 366 | + * id can name the session driving the app now. Retiring on the id alone would unload | ||
| 367 | + * the live root view and every frame under it, and nothing reloads a root view whose | ||
| 368 | + * window has left the registry - navigation then queues forever behind `Frame.isLoaded`. | ||
| 369 | + */ | ||
| 370 | + it('leaves an attached window alone when a discarded id names it', () => { | ||
| 371 | + const rootView = attached.rootView as any; | ||
| 372 | + let unloaded = false; | ||
| 373 | + | ||
| 374 | + rootView.isLoaded = true; | ||
| 375 | + rootView.callUnloaded = () => { | ||
| 376 | + unloaded = true; | ||
| 377 | + }; | ||
| 378 | + | ||
| 379 | + app._retireDiscardedWindows(['scene-live']); | ||
| 380 | + | ||
| 381 | + expect(app.getWindowById('scene-live')).toBe(attached); | ||
| 382 | + expect(attached.state).toBe('attached'); | ||
| 383 | + expect(unloaded).toBe(false); | ||
| 384 | + expect(events).toEqual([]); | ||
| 385 | + }); | ||
| 386 | + | ||
| 387 | + it('ignores ids that match no window', () => { | ||
| 388 | + record('windowClose'); | ||
| 389 | + | ||
| 390 | + app._retireDiscardedWindows(['scene-never-seen']); | ||
| 391 | + | ||
| 392 | + expect(app.getWindows()).toEqual([attached, detached]); | ||
| 393 | + expect(events).toEqual([]); | ||
| 394 | + }); | ||
| 395 | + | ||
| 396 | + it('retires every detached window named in one discard', () => { | ||
| 397 | + const alsoDetached = new TestWindow('scene-gone-too').withContent(); | ||
| 398 | + app._registerWindow(alsoDetached); | ||
| 399 | + alsoDetached._detach(); | ||
| 400 | + | ||
| 401 | + app._retireDiscardedWindows(['scene-gone', 'scene-live', 'scene-gone-too']); | ||
| 402 | + | ||
| 403 | + expect(app.getWindowById('scene-gone')).toBeUndefined(); | ||
| 404 | + expect(app.getWindowById('scene-gone-too')).toBeUndefined(); | ||
| 405 | + expect(app.getWindowById('scene-live')).toBe(attached); | ||
| 406 | + }); | ||
| 407 | + }); | ||
| 323 | 408 | }); | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -153,6 +153,8 @@ function initNativeScriptLifecycleCallbacks() { | |||
| 153 | 153 | // there and wedging the app as never-suspended. | |
| 154 | 154 | Application.android._setWindowActive(nativeWindow, false, activity); | |
| 155 | 155 | ||
| 156 | + nativeWindow._surfaceGone = true; | ||
| 157 | + | ||
| 156 | 158 | // A destroyed activity only ends the window session when it is finishing — | |
| 157 | 159 | // otherwise Android is recreating it and the same window is reused. | |
| 158 | 160 | const isClosing = activity.isFinishing(); | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -469,6 +469,8 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { | |||
| 469 | 469 | Application.ios._setWindowInForeground(nativeWindow, false, windowScene); | |
| 470 | 470 | ||
| 471 | 471 | if (nativeWindow) { | |
| 472 | + nativeWindow._surfaceGone = true; | ||
| 473 | + | ||
| 472 | 474 | // A disconnect only ends the window session when the app asked for it — | |
| 473 | 475 | // otherwise iOS may reconnect the same session later. A window with no session | |
| 474 | 476 | // identity is the exception: a reconnect could never be matched back to it. | |
@@ -1386,25 +1388,26 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication | |||
| 1386 | 1388 | // --- NativeWindow registry --- | |
| 1387 | 1389 | ||
| 1388 | 1390 | /** | |
| 1389 | - * @internal - iOS reports discarded sessions for windows this JS context may never | ||
| 1390 | - * have seen (they can arrive on a later launch), so unknown ids are ignored. | ||
| 1391 | + * @internal - hands the discarded sessions' ids to the window registry, which decides | ||
| 1392 | + * which of them name a window that is actually finished with. | ||
| 1391 | 1393 | */ | |
| 1392 | 1394 | _onSceneSessionsDiscarded(sessions: NSSet<UISceneSession>): void { | |
| 1393 | 1395 | const all = sessions?.allObjects; | |
| 1394 | 1396 | if (!all) { | |
| 1395 | 1397 | return; | |
| 1396 | 1398 | } | |
| 1397 | 1399 | ||
| 1400 | + const ids: string[] = []; | ||
| 1401 | + | ||
| 1398 | 1402 | for (let i = 0; i < all.count; i++) { | |
| 1399 | 1403 | const persistentIdentifier = all.objectAtIndex(i)?.persistentIdentifier; | |
| 1400 | - const nativeWindow = persistentIdentifier ? this.getWindowById(`${persistentIdentifier}`) : undefined; | ||
| 1401 | - if (!nativeWindow) { | ||
| 1402 | - continue; | ||
| 1403 | - } | ||
| 1404 | 1404 | ||
| 1405 | - nativeWindow._notifyEvent(NativeWindowEvents.close); | ||
| 1406 | - this._unregisterWindow(nativeWindow); | ||
| 1405 | + if (persistentIdentifier) { | ||
| 1406 | + ids.push(`${persistentIdentifier}`); | ||
| 1407 | + } | ||
| 1407 | 1408 | } | |
| 1409 | + | ||
| 1410 | + this._retireDiscardedWindows(ids); | ||
| 1408 | 1411 | } | |
| 1409 | 1412 | ||
| 1410 | 1413 | /** | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -415,6 +415,8 @@ export abstract class NativeWindow extends WindowBase { | |||
| 415 | 415 | * this window for the same reason — it is still this window's content. | |
| 416 | 416 | */ | |
| 417 | 417 | _detach(): void { | |
| 418 | + this._surfaceGone = true; | ||
| 419 | + | ||
| 418 | 420 | // Take a final reading while the surface can still answer and the root view is | |
| 419 | 421 | // still up: from here on these are what the window reports. | |
| 420 | 422 | this.orientation(); | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,39 @@ | |||
| 1 | + import { describe, it, expect } from 'vitest'; | ||
| 2 | + import { IOSNativeWindow } from './native-window.ios'; | ||
| 3 | + | ||
| 4 | + function sceneWithSession(persistentIdentifier?: string): any { | ||
| 5 | + return persistentIdentifier ? { session: { persistentIdentifier } } : { session: {} }; | ||
| 6 | + } | ||
| 7 | + | ||
| 8 | + const uiWindow: any = {}; | ||
| 9 | + | ||
| 10 | + /** | ||
| 11 | + * The flag decides whether a disconnect detaches the window for a later reconnect or ends | ||
| 12 | + * it outright, so it has to mean "this id came from the scene's session" and nothing looser. | ||
| 13 | + */ | ||
| 14 | + describe('IOSNativeWindow session identity', () => { | ||
| 15 | + it('recognises an id taken from the scene session', () => { | ||
| 16 | + const window = new IOSNativeWindow(sceneWithSession('ABC-123'), uiWindow, 'ABC-123'); | ||
| 17 | + | ||
| 18 | + expect(window._hasSessionIdentity).toBe(true); | ||
| 19 | + }); | ||
| 20 | + | ||
| 21 | + it('rejects a hand-minted id even when the window has a scene', () => { | ||
| 22 | + const window = new IOSNativeWindow(sceneWithSession('ABC-123'), uiWindow, 'embedded-main'); | ||
| 23 | + | ||
| 24 | + expect(window._hasSessionIdentity).toBe(false); | ||
| 25 | + }); | ||
| 26 | + | ||
| 27 | + it('rejects a hand-minted id on a window with no scene', () => { | ||
| 28 | + const window = new IOSNativeWindow(undefined, uiWindow, 'main'); | ||
| 29 | + | ||
| 30 | + expect(window._hasSessionIdentity).toBe(false); | ||
| 31 | + }); | ||
| 32 | + | ||
| 33 | + it('rejects a scene whose session carries no persistent identifier', () => { | ||
| 34 | + const window = new IOSNativeWindow(sceneWithSession(), uiWindow); | ||
| 35 | + | ||
| 36 | + expect(window._hasSessionIdentity).toBe(false); | ||
| 37 | + expect(window.id).toMatch(/^window-\d+$/); | ||
| 38 | + }); | ||
| 39 | + }); | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -29,7 +29,12 @@ export class IOSNativeWindow extends NativeWindow { | |||
| 29 | 29 | ||
| 30 | 30 | constructor(scene: UIWindowScene | undefined, window: UIWindow, id?: string, isPrimary = false, role: WindowRole = 'application') { | |
| 31 | 31 | super(id, isPrimary, role); | |
| 32 | - this._hasSessionIdentity = !!id; | ||
| 32 | + | ||
| 33 | + // Only an id taken from the scene's session can be matched back to that session. | ||
| 34 | + // Hand-minted ids ('main', 'embedded-main') are ids all the same, so their mere | ||
| 35 | + // presence says nothing. | ||
| 36 | + const sessionId = scene?.session?.persistentIdentifier; | ||
| 37 | + this._hasSessionIdentity = !!sessionId && id === `${sessionId}`; | ||
| 33 | 38 | this._scene = scene; | |
| 34 | 39 | this._window = window; | |
| 35 | 40 | } | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -189,6 +189,17 @@ describe('WindowBase lifecycle', () => { | |||
| 189 | 189 | expect(events).toEqual(['attached', 'detached', 'attached', 'close', 'deactivate']); | |
| 190 | 190 | }); | |
| 191 | 191 | ||
| 192 | + it('records the surface as gone on detach and clears it on re-attach', () => { | ||
| 193 | + const window = new TestWindow(); | ||
| 194 | + expect(window._surfaceGone).toBe(false); | ||
| 195 | + | ||
| 196 | + window._detach(); | ||
| 197 | + expect(window._surfaceGone).toBe(true); | ||
| 198 | + | ||
| 199 | + window._setState('attached'); | ||
| 200 | + expect(window._surfaceGone).toBe(false); | ||
| 201 | + }); | ||
| 202 | + | ||
| 192 | 203 | it('keeps listeners through a detach so a re-attached window still notifies them', () => { | |
| 193 | 204 | const window = new TestWindow(); | |
| 194 | 205 | const events: string[] = []; | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -35,6 +35,15 @@ export abstract class WindowBase extends Observable { | |||
| 35 | 35 | private _state: WindowState = 'attached'; | |
| 36 | 36 | private _isPrimary: boolean; | |
| 37 | 37 | ||
| 38 | + /** | ||
| 39 | + * @internal – whether the native surface behind this window is known to be gone. | ||
| 40 | + * | ||
| 41 | + * Only the platform's disconnect callback can establish this, and {@link state} cannot | ||
| 42 | + * stand in for it: a disconnect that ends the session unregisters the window while it is | ||
| 43 | + * still `attached`, so an attached window is not necessarily a live one. | ||
| 44 | + */ | ||
| 45 | + _surfaceGone = false; | ||
| 46 | + | ||
| 38 | 47 | constructor(id?: string, isPrimary = false, role: WindowRole = 'application') { | |
| 39 | 48 | super(); | |
| 40 | 49 | this._id = id || `window-${++_windowIdCounter}`; | |
@@ -69,6 +78,12 @@ export abstract class WindowBase extends Observable { | |||
| 69 | 78 | * @internal | |
| 70 | 79 | */ | |
| 71 | 80 | _setState(value: WindowState): void { | |
| 81 | + // A window only ever attaches to a surface that exists, so attaching is what clears | ||
| 82 | + // the record of the previous one going away. | ||
| 83 | + if (value === 'attached') { | ||
| 84 | + this._surfaceGone = false; | ||
| 85 | + } | ||
| 86 | + | ||
| 72 | 87 | this._state = value; | |
| 73 | 88 | } | |
| 74 | 89 | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments