diff --git a/src/boot/session.ts b/src/boot/session.ts index ef4c1cad..9817769d 100644 --- a/src/boot/session.ts +++ b/src/boot/session.ts @@ -21,6 +21,7 @@ import { findDockEntryForWindowId, } from './geometry'; import type { WindowManager } from '../window-manager'; +import type { NativeWindowRestoreState } from '../native-windows'; import { workAreaRectOf } from '../work-area'; import type { Window } from '../window'; import type { DesktopConfig, Session, SessionWindow, WindowConfig } from '../types'; @@ -82,16 +83,20 @@ export function hasRestorableSession( } /** - * Reopen a native window by id. Supplied by `desktop.ts`, which owns - * the dispatch: shell built-ins (OS Settings, Bug Report) have their - * own openers, everything else routes to - * `nativeWindows.openById( id )`. + * Reopen a native window from its saved instance identity. Supplied by + * `desktop.ts`, which owns the dispatch: shell built-ins (OS Settings, + * Bug Report) have their own openers, everything else routes through + * the native-window registry using the stable base id. * * Returns `false` when nothing answers to that id — a plugin * deactivated since the session was saved. The restore skips those * silently; a missing plugin isn't an error worth surfacing at boot. */ -export type OpenNativeWindow = ( id: string ) => boolean; +export type OpenNativeWindow = ( + instanceId: string, + baseId?: string, + state?: NativeWindowRestoreState, +) => boolean; /** * Wait for a window to appear in the manager, for openers that don't @@ -149,10 +154,10 @@ function waitForWindow( * windows are reconstructed from their saved URL. Native windows * (`native: true` — OS Settings, Bug Report, anything registered via * `openstation_register_window()`) have no URL to iframe: they're - * reopened by asking their owner through `openNative`, with the saved - * geometry / desktop / state staged via - * `manager.seedWindowRestoreState()` so the opener's own config - * doesn't flatten them back to defaults. + * reopened by asking their owner through `openNative`. The stable + * `baseId` selects the registered definition while the saved instance + * id and restore-time state are passed separately, so duplicate native + * instances return under the same identities they were saved with. */ export async function restoreSession( manager: WindowManager, @@ -180,12 +185,11 @@ export async function restoreSession( ); } - // Stage every native window's saved geometry / desktop / state - // before triggering any open. The openers build their own - // `manager.open()` config from the registry and have no argument - // to carry restore-time values, so the manager merges these in by - // id as each window is constructed. - const nativeSeeds: Record< string, Partial< WindowConfig > > = {}; + // Reserve every saved native instance id before restoring in session + // order. Native windows can wait on lazy framework and app bundles; + // the reservations prevent a user opening another copy during that + // wait from claiming an id the session is about to use. + const nativeSeeds: Record< string, NativeWindowRestoreState > = {}; for ( const win of config.session.windows ) { if ( ! win.native ) { continue; @@ -209,7 +213,7 @@ export async function restoreSession( }; } if ( Object.keys( nativeSeeds ).length > 0 ) { - manager.seedWindowRestoreState( nativeSeeds ); + manager.seedWindowRestoreState( nativeSeeds, { reserveIds: true } ); } for ( const win of config.session.windows ) { @@ -218,7 +222,17 @@ export async function restoreSession( // id — a plugin deactivated since the session was saved — in // which case there's simply no window to restore. if ( win.native ) { - if ( ! openNative?.( win.id ) ) { + // Release this id immediately before its synchronous opener + // claims it as in-flight. The state itself is passed directly; + // nothing remains that a later fresh open could consume. + manager.discardWindowRestoreState( win.id ); + if ( + ! openNative?.( + win.id, + win.baseId || win.id, + nativeSeeds[ win.id ], + ) + ) { continue; } // Barrier: the openers are fire-and-forget, so without this @@ -304,6 +318,11 @@ export async function restoreSession( } } + // Every saved native id has either been claimed or skipped. Clear + // anything left by a missing opener before a later user action can + // reuse that generated id. + manager.discardWindowRestoreState(); + // Restore focus to whichever window the user left focused. If // that id is no longer around (e.g., the saved focus pointed at // a window we failed to reconstruct), `getById` returns diff --git a/src/desktop.ts b/src/desktop.ts index d3b1e218..65153ce4 100644 --- a/src/desktop.ts +++ b/src/desktop.ts @@ -134,6 +134,7 @@ import { createNativeWindowSync, createRegisterWindow, hydrateServerEntries, + type NativeWindowRestoreState, type WindowLifecycleHandlers, } from './native-windows'; import { iconsApi, renderDesktopIcons, type IconsApi } from './desktop-icons'; @@ -327,6 +328,7 @@ import type { DesktopConfig, DesktopWallpaperServerEntry, NativeWindowDef, + WindowConfig, } from './types'; import type { Window as DesktopWindow } from './window'; @@ -2282,7 +2284,8 @@ function init(): void { mode: modeController.api, // Bound late: `openNativeWindowById` is declared further down // the boot, and a recent is only ever opened from a tap. - openNative: ( id ) => openNativeWindowById( id ), + openNative: ( id, baseId, state ) => + openNativeWindowById( id, baseId, state ), } ); // Mio — the desk companion. A first-class shell layer (sibling @@ -3520,12 +3523,19 @@ function init(): void { * `manager.open` so the admin-bar button, the dock system tile, * and any future widget all reach the same window instance. */ - function openBugReport(): void { + function openBugReport( + instanceId = BUG_REPORT_WINDOW_ID, + state?: NativeWindowRestoreState, + ): void { // The window's stylesheet is a `deferredStyles` entry, not a // boot enqueue — inject on first open. ensureDeferredStyle( 'desktop-mode-bug-report' ); - void manager.open( { - id: BUG_REPORT_WINDOW_ID, + const bugReportConfig: Partial< WindowConfig > & { + id: string; + url: string; + title: string; + } = { + id: instanceId, baseId: BUG_REPORT_WINDOW_ID, url: `#${ BUG_REPORT_WINDOW_ID }`, title: 'Report a bug', @@ -3536,7 +3546,11 @@ function init(): void { height: 620, minWidth: 420, minHeight: 480, - } ); + ...state, + }; + void ( state + ? manager.openNew( bugReportConfig ) + : manager.open( bugReportConfig ) ); } // Admin-bar "Report a bug" button. Inline JS in @@ -3700,22 +3714,34 @@ function init(): void { * belonged to a plugin that has since been deactivated. Callers * treat that as "nothing to open", not as an error. */ - function openNativeWindowById( nativeId: string ): boolean { + function openNativeWindowById( + nativeId: string, + baseId?: string, + state?: NativeWindowRestoreState, + ): boolean { + const registeredId = baseId || nativeId; // Station Home is opt-in (OS Settings → Features). Refusing the // id here — not just in the URL remap above — is what keeps a // saved session from resurrecting the window for a user who // never opted in: every 1.1.2 session has it open, and restore // reopens native windows by id without consulting the remap. if ( - nativeId === 'desktop-mode-dashboard' && + registeredId === 'desktop-mode-dashboard' && osSettings.getOsSettingsSnapshot().stationHomeEnabled !== true ) { return false; } - if ( nativeId === BUG_REPORT_WINDOW_ID ) { - openBugReport(); + if ( registeredId === BUG_REPORT_WINDOW_ID ) { + openBugReport( nativeId, state ); return true; } + if ( state ) { + return nativeWindows.restoreById( + nativeId, + registeredId, + state, + ); + } return nativeWindows.openById( nativeId ); } diff --git a/src/mobile/constraints.ts b/src/mobile/constraints.ts index a044ec49..f470ed5e 100644 --- a/src/mobile/constraints.ts +++ b/src/mobile/constraints.ts @@ -45,6 +45,7 @@ import type { WindowManager, } from '../window-manager'; import { workAreaRectOf } from '../work-area'; +import type { OpenNativeWindow } from '../boot/session'; import type { MobileRecents } from './types'; const NS = 'openstation/mobile'; @@ -65,8 +66,8 @@ interface DisplacedGeometry { export interface MobileConstraintsDeps { manager: WindowManager; mode: OsModeApi; - /** Opens a native window by id; `false` when nothing answers. */ - openNative: ( id: string ) => boolean; + /** Opens or restores a native window; `false` when nothing answers. */ + openNative: OpenNativeWindow; } export interface MobileConstraints { @@ -365,13 +366,12 @@ export function installMobileConstraints( deps: MobileConstraintsDeps ): MobileC open( win ) { recentsApi.forget( win.id ); if ( win.native ) { - manager.seedWindowRestoreState( { - [ win.id ]: { + if ( + ! deps.openNative( win.id, win.baseId || win.id, { desktopId: win.desktopId, ...( win.params ? { params: win.params } : {} ), - }, - } ); - if ( ! deps.openNative( win.id ) ) { + } ) + ) { return; } return; diff --git a/src/native-windows.ts b/src/native-windows.ts index b4ef8ab3..cf74eb02 100644 --- a/src/native-windows.ts +++ b/src/native-windows.ts @@ -45,6 +45,7 @@ import type { NativeWindowServerEntry, NativeWindowTabEntry, NativeWindowWireEntry, + WindowConfig, } from './types'; import type { WindowManager } from './window-manager'; import type { Window as DesktopWindow } from './window'; @@ -956,6 +957,23 @@ export interface NativeWindowSync { }, ) => boolean; + /** + * Recreate one saved native-window instance through its registered + * owner. Unlike {@link openNewById}, the registry lookup uses the + * stable base id while the manager receives the exact saved instance + * id and restore-time state. + * + * Framework boot helper; callers opening a new window should use + * {@link openById} or {@link openNewById}. + * + * @internal + */ + restoreById: ( + instanceId: string, + baseId: string, + state: NativeWindowRestoreState, + ) => boolean; + /** * Load a registered native window's bundle (companions first, * then the window's own script) WITHOUT opening the window. @@ -984,6 +1002,21 @@ export interface NativeWindowSync { prewarmById: ( id: string ) => Promise< boolean >; } +/** State owned by session restore rather than a native-window definition. */ +export type NativeWindowRestoreState = Partial< + Pick< + WindowConfig, + | 'desktopId' + | 'x' + | 'y' + | 'width' + | 'height' + | 'initialState' + | 'params' + | 'gridSpan' + > +>; + /** * Declare a server-registered native window's tabs in the window * chrome. @@ -1591,6 +1624,38 @@ export function createNativeWindowSync( } ); }; + /** + * Rebuild a saved instance without asking the registry to recognise + * its generated id. The base id finds the owning definition; the + * saved id and state go straight to `openNew()` so no restore data + * can linger and retarget a later user-initiated open. + */ + const restoreFromEntry = ( + entry: NativeWindowServerEntry, + instanceId: string, + state: NativeWindowRestoreState, + ): void => { + const finalRender = buildRender( entry ); + const size = resolveSizeForEntry( entry ); + + void manager.openNew( { + id: instanceId, + baseId: entry.id, + native: true, + url: `#${ entry.id }`, + title: entry.title, + icon: entry.icon, + width: size.width, + height: size.height, + minWidth: entry.minWidth, + minHeight: entry.minHeight, + render: finalRender, + autofocus: entry.autofocus, + ownerHandle: entry.ownerHandle || entry.scriptHandle, + ...state, + } ); + }; + const registerTile = async ( entry: NativeWindowServerEntry, ): Promise< void > => { @@ -1722,6 +1787,19 @@ export function createNativeWindowSync( return true; }; + const restoreById = ( + instanceId: string, + baseId: string, + state: NativeWindowRestoreState, + ): boolean => { + const entry = entriesById.get( baseId ); + if ( ! entry ) { + return false; + } + restoreFromEntry( entry, instanceId, state ); + return true; + }; + // Persist the user's manually-resized size for EVERY window // (native and classic iframe-backed alike) so the next fresh // open lands at the same dimensions instead of the defaults. @@ -1901,7 +1979,14 @@ export function createNativeWindowSync( return apps?.prewarm?.( id ) === true; }; - return { sync, openById, openNewById, loadScriptById, prewarmById }; + return { + sync, + openById, + openNewById, + restoreById, + loadScriptById, + prewarmById, + }; } export function cloneTemplate( diff --git a/src/window-manager/index.ts b/src/window-manager/index.ts index 8e728d08..01b5f4f0 100644 --- a/src/window-manager/index.ts +++ b/src/window-manager/index.ts @@ -207,6 +207,15 @@ export class WindowManager { * Empty outside of session restore. */ private _pendingRestoreState = new Map< string, Partial< WindowConfig > >(); + /** Saved instance ids held for the framework's ordered boot restore. */ + private _reservedRestoreIds = new Set< string >(); + + /** + * Instance ids selected by window constructions that are waiting on + * the lazy window-system bundles. They are already claimed even + * though their Window objects are not in the stack yet. + */ + private _openingWindowIds = new Set< string >(); /** * The one prewarmed (hidden, speculative) window, if any — built by @@ -919,7 +928,9 @@ export class WindowManager { ): Promise< Window > { const baseId = config.baseId || config.id; const nextId = - config.id !== baseId && ! this.getById( config.id ) + config.id !== baseId && + ! this.getById( config.id ) && + ! this._openingWindowIds.has( config.id ) ? config.id : this.nextInstanceId( baseId ); const cascadeX = 40 + ( this.cascadeIndex % 8 ) * CASCADE_OFFSET; @@ -950,6 +961,7 @@ export class WindowManager { const staged = this._pendingRestoreState.get( config.id ); if ( staged ) { this._pendingRestoreState.delete( config.id ); + this._reservedRestoreIds.delete( config.id ); config = { ...config, ...staged }; } @@ -1200,10 +1212,20 @@ export class WindowManager { // case where the click races the preloads — session // restore at boot, or a plugin opening a window // programmatically right after init. - const [ system ] = await Promise.all( [ + this._openingWindowIds.add( config.id ); + const bundles = Promise.all( [ ensureWindowSystemLoaded( windowSystemBundleUrl() ), ensureShellOverlaysLoaded( shellOverlaysBundleUrl() ), ] ); + let loaded: Awaited< typeof bundles >; + try { + loaded = await bundles; + } catch ( err ) { + this._openingWindowIds.delete( config.id ); + throw err; + } + this._openingWindowIds.delete( config.id ); + const [ system ] = loaded; const win = system.createWindow( fullConfig ); // The restored placement stays with the window so the next // work-area change puts it back on the same cells, and the @@ -1439,7 +1461,11 @@ export class WindowManager { * stack. */ private nextInstanceId( baseId: string ): string { - const taken = new Set( this._stack.map( ( w ) => w.id ) ); + const taken = new Set( [ + ...this._stack.map( ( w ) => w.id ), + ...this._openingWindowIds, + ...this._reservedRestoreIds, + ] ); if ( ! taken.has( baseId ) ) { return baseId; } @@ -2678,16 +2704,39 @@ export class WindowManager { * before triggering the opens, and `createWindow` applies it to * whichever window claims each id. Entries are consumed on first * use, so a later user-initiated open of the same window is - * unaffected. Ids that never open (a plugin deactivated since the - * session was saved) simply leave a stale entry behind, which the - * next `seedWindowRestoreState` call clears. + * unaffected. Session restore explicitly discards ids whose owners + * are missing, then clears the staging map when it finishes. + * Its internal `reserveIds` option also keeps an in-progress saved + * id out of normal `openNew()` slot allocation until its turn. * * Call BEFORE the opens it should apply to. */ public seedWindowRestoreState( entries: Record< string, Partial< WindowConfig > >, + opts: { reserveIds?: boolean } = {}, ): void { this._pendingRestoreState = new Map( Object.entries( entries ) ); + this._reservedRestoreIds = opts.reserveIds + ? new Set( Object.keys( entries ) ) + : new Set(); + } + + /** + * Drop staged restore data for one id, or all ids when omitted. + * Session restore uses this immediately before passing state directly + * to a native owner, and after skipped owners, so abandoned state can + * never affect a later fresh window that reuses the id. + * + * @internal + */ + public discardWindowRestoreState( id?: string ): void { + if ( id ) { + this._pendingRestoreState.delete( id ); + this._reservedRestoreIds.delete( id ); + return; + } + this._pendingRestoreState.clear(); + this._reservedRestoreIds.clear(); } public seedDesktops( desktops: Desktop[], activeDesktopId: string ): void { diff --git a/tests/vitest/mobile-constraints.test.ts b/tests/vitest/mobile-constraints.test.ts index 72304dd5..c5d6b853 100644 --- a/tests/vitest/mobile-constraints.test.ts +++ b/tests/vitest/mobile-constraints.test.ts @@ -91,7 +91,6 @@ function fakeManager( wins: FakeWin[], desks: string[] = [ 'desktop-1' ] ) { wins.push( w ); return w; } ); - const seedWindowRestoreState = vi.fn(); // The manager's own rule: a move re-homes the window and nothing // else; the stack order stands for focus order, last is in front. const moveWindowToDesktop = vi.fn( ( id: string, desktopId: string ) => { @@ -112,9 +111,8 @@ function fakeManager( wins: FakeWin[], desks: string[] = [ 'desktop-1' ] ) { moveWindowToDesktop, focus, openNew, - seedWindowRestoreState, } as unknown as WindowManager; - return { manager, openNew, seedWindowRestoreState, moveWindowToDesktop, focus }; + return { manager, openNew, moveWindowToDesktop, focus }; } const sessionWin = ( id: string, over: Partial< SessionWindow > = {} ): SessionWindow => ( { @@ -241,7 +239,7 @@ describe( 'installMobileConstraints', () => { test( 'recents.open reopens an iframe window with its tabs, a native one through the registry', async () => { const mode = fakeMode( 'mobile' ); const wins: FakeWin[] = []; - const { manager, openNew, seedWindowRestoreState } = fakeManager( wins ); + const { manager, openNew } = fakeManager( wins ); const openNative = vi.fn( () => true ); const c = installMobileConstraints( { manager, mode: mode.api, openNative } ); const notify = vi.fn(); @@ -269,8 +267,10 @@ describe( 'installMobileConstraints', () => { expect( c.recents.list().map( ( r ) => r.id ) ).toEqual( [ 'n' ] ); c.recents.open( c.recents.list()[ 0 ] ); - expect( seedWindowRestoreState ).toHaveBeenCalledWith( { n: expect.objectContaining( { params: { post: 3 } } ) } ); - expect( openNative ).toHaveBeenCalledWith( 'n' ); + expect( openNative ).toHaveBeenCalledWith( 'n', 'n', { + desktopId: undefined, + params: { post: 3 }, + } ); expect( c.recents.list() ).toEqual( [] ); c.dispose(); } ); diff --git a/tests/vitest/native-windows-lazy-script.test.ts b/tests/vitest/native-windows-lazy-script.test.ts index 5428de23..1542395d 100644 --- a/tests/vitest/native-windows-lazy-script.test.ts +++ b/tests/vitest/native-windows-lazy-script.test.ts @@ -202,6 +202,45 @@ describe( 'native-windows — deferred bundle loading', () => { expect( render ).toHaveBeenCalledTimes( 1 ); } ); + test( 'a restored instance keeps its saved id while its bundle loads lazily', async () => { + const h = setupHarness(); + const e = entry( 'fleet-site' ); + installTemplate( e ); + const { sync, restoreById } = createNativeWindowSync( + depsFromHarness( h ), + ); + await sync( [ e ] ); + + const render = vi.fn(); + vi.mocked( vendorLoader.loadVendorScript ).mockImplementation( + async ( url: string ) => { + loaded.push( url ); + ( + window as unknown as { + openStationNativeWindows: Record< string, unknown >; + } + ).openStationNativeWindows[ 'fleet-site' ] = render; + }, + ); + + expect( + restoreById( 'fleet-site-3', 'fleet-site', { + params: { site: 'charlie' }, + } ), + ).toBe( true ); + await runRender( h.managerOpen ); + + expect( h.managerOpen.mock.calls[ 0 ][ 0 ] ).toEqual( + expect.objectContaining( { + id: 'fleet-site-3', + baseId: 'fleet-site', + params: { site: 'charlie' }, + } ), + ); + expect( loaded ).toEqual( [ 'https://example.test/fleet-site.js' ] ); + expect( render ).toHaveBeenCalledTimes( 1 ); + } ); + test( 'the second open reuses the loaded bundle', async () => { const h = setupHarness(); const e = entry( 'calculator' ); diff --git a/tests/vitest/native-windows-sync.test.ts b/tests/vitest/native-windows-sync.test.ts index 8330a067..276542d2 100644 --- a/tests/vitest/native-windows-sync.test.ts +++ b/tests/vitest/native-windows-sync.test.ts @@ -49,6 +49,7 @@ function setupHarness(): Harness { const managerOpen = vi.fn(); const manager = { open: managerOpen, + openNew: managerOpen, getById: () => null, getByBaseIdOnActiveDesktop: () => undefined, getFocused: () => null, @@ -303,6 +304,31 @@ describe( 'native-windows.createNativeWindowSync — live activation / deactivat expect( openById( 'calculator' ) ).toBe( false ); } ); + test( 'restoreById resolves a saved instance through its registered base id', async () => { + const h = setupHarness(); + const { sync, restoreById } = createNativeWindowSync( + depsFromHarness( h ), + ); + + await sync( [ entry( 'fleet-site' ) ] ); + expect( + restoreById( 'fleet-site-4', 'fleet-site', { + desktopId: 'desktop-2', + params: { site: 'bravo' }, + } ), + ).toBe( true ); + + expect( h.managerOpen ).toHaveBeenCalledWith( + expect.objectContaining( { + id: 'fleet-site-4', + baseId: 'fleet-site', + desktopId: 'desktop-2', + params: { site: 'bravo' }, + } ), + ); + expect( restoreById( 'gone-2', 'gone', {} ) ).toBe( false ); + } ); + describe( 'remembered window size (issue #203)', () => { test( 'openById uses the registered defaults when nothing is remembered', async () => { const h = setupHarness(); diff --git a/tests/vitest/session-restore-duplicates.test.ts b/tests/vitest/session-restore-duplicates.test.ts index 10ff4d0b..ec31334e 100644 --- a/tests/vitest/session-restore-duplicates.test.ts +++ b/tests/vitest/session-restore-duplicates.test.ts @@ -16,6 +16,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { restoreSession } from '../../src/boot/session'; +import type { NativeWindowRestoreState } from '../../src/native-windows'; import { WindowManager } from '../../src/window-manager'; import { clearHooksStub, installHooksStub } from './helpers/hooks-stub'; import type { DesktopConfig, Session, SessionWindow } from '../../src/types'; @@ -236,23 +237,28 @@ describe( 'restoreSession — native windows', () => { /** * Stand-in for the shell's `openNativeWindowById` — same contract: - * open the window for known ids, return false for anything the - * registry no longer knows about. + * resolve the saved instance through a known base id, return false + * for anything the registry no longer knows about. */ - const opener = ( known: string[] ) => ( id: string ) => { - if ( ! known.includes( id ) ) { + const opener = ( known: string[] ) => ( + id: string, + baseId = id, + state: NativeWindowRestoreState = {}, + ) => { + if ( ! known.includes( baseId ) ) { return false; } - void manager.open( { + void manager.openNew( { id, - baseId: id, + baseId, native: true, - url: `#${ id }`, + url: `#${ baseId }`, title: 'OS Settings', icon: 'dashicons-desktop', render: ( body: HTMLElement ) => { body.textContent = id; }, + ...state, } ); return true; }; @@ -311,6 +317,78 @@ describe( 'restoreSession — native windows', () => { ); } ); + test( 'restores multiple native instances through one registered base id', async () => { + const config = desktopConfig( [ + nativeEntry( { + id: 'fleet-site', + baseId: 'fleet-site', + params: { site: 'alpha' }, + } ), + nativeEntry( { + id: 'fleet-site-4', + baseId: 'fleet-site', + params: { site: 'bravo' }, + } ), + ] ); + + await restoreSession( + manager, + config, + desktop, + opener( [ 'fleet-site' ] ), + ); + + expect( manager.getAll().map( ( win ) => win.id ).sort() ).toEqual( [ + 'fleet-site', + 'fleet-site-4', + ] ); + expect( manager.getById( 'fleet-site' )?.config.params ).toEqual( { + site: 'alpha', + } ); + expect( manager.getById( 'fleet-site-4' )?.config.params ).toEqual( { + site: 'bravo', + } ); + } ); + + test( 'a fresh native instance opened during restore keeps its requested params', async () => { + const config = desktopConfig( [ + nativeEntry( { + id: 'fleet-site', + baseId: 'fleet-site', + params: { site: 'alpha' }, + } ), + nativeEntry( { + id: 'fleet-site-2', + baseId: 'fleet-site', + params: { site: 'bravo' }, + } ), + ] ); + + const restoring = restoreSession( + manager, + config, + desktop, + opener( [ 'fleet-site' ] ), + ); + const fresh = await manager.openNew( { + id: 'fleet-site', + baseId: 'fleet-site', + native: true, + url: '#fleet-site', + title: 'Fleet site', + icon: 'dashicons-admin-site', + params: { site: 'charlie' }, + render: () => undefined, + } ); + await restoring; + + expect( fresh.id ).toBe( 'fleet-site-3' ); + expect( fresh.config.params ).toEqual( { site: 'charlie' } ); + expect( manager.getById( 'fleet-site-2' )?.config.params ).toEqual( { + site: 'bravo', + } ); + } ); + test( 'skips a native window whose owner is gone, keeping the rest', async () => { const config = desktopConfig( [ nativeEntry( { id: 'gone-plugin-panel', baseId: 'gone-plugin-panel' } ), @@ -328,6 +406,43 @@ describe( 'restoreSession — native windows', () => { expect( manager.getById( 'edit-php' ) ).toBeDefined(); } ); + test( 'a skipped native restore cannot retarget a later reused instance id', async () => { + const config = desktopConfig( [ + nativeEntry( { + id: 'gone-plugin-panel-2', + baseId: 'gone-plugin-panel', + params: { site: 'saved-destination' }, + } ), + ] ); + + await restoreSession( manager, config, desktop, opener( [] ) ); + await manager.openNew( { + id: 'gone-plugin-panel', + baseId: 'gone-plugin-panel', + native: true, + url: '#gone-plugin-panel', + title: 'Panel', + icon: 'dashicons-admin-generic', + params: { site: 'first-fresh-destination' }, + render: () => undefined, + } ); + const reused = await manager.openNew( { + id: 'gone-plugin-panel', + baseId: 'gone-plugin-panel', + native: true, + url: '#gone-plugin-panel', + title: 'Panel', + icon: 'dashicons-admin-generic', + params: { site: 'second-fresh-destination' }, + render: () => undefined, + } ); + + expect( reused.id ).toBe( 'gone-plugin-panel-2' ); + expect( reused.config.params ).toEqual( { + site: 'second-fresh-destination', + } ); + } ); + test( 'restores native and iframe windows side by side, focus included', async () => { const config = desktopConfig( [ sessionWindow( { id: 'edit-php', baseId: 'edit-php' } ), @@ -486,4 +601,15 @@ describe( 'WindowManager.openNew — instance id allocation', () => { expect( first.id ).toBe( 'edit-php' ); expect( second.id ).toBe( 'edit-php-2' ); } ); + + test( 'concurrent opens reserve ids before lazy bundles settle', async () => { + const first = manager.openNew( cfg( 'edit-php', 'edit-php' ) ); + const second = manager.openNew( cfg( 'edit-php', 'edit-php' ) ); + const windows = await Promise.all( [ first, second ] ); + + expect( windows.map( ( win ) => win.id ) ).toEqual( [ + 'edit-php', + 'edit-php-2', + ] ); + } ); } );