From 829ffe3a9bf66bc5f7fe5138f23ad6c2cf3b0827 Mon Sep 17 00:00:00 2001 From: Vyacheslav Borodin Date: Sun, 30 Aug 2026 22:18:27 +0200 Subject: [PATCH] fix(core): `fullscreen` join the pending transition instead of racing it --- .../core/browser/fullscreen/index.test.ts | 105 ++++++++++++++++++ projects/core/browser/fullscreen/index.ts | 30 ++++- 2 files changed, 131 insertions(+), 4 deletions(-) diff --git a/projects/core/browser/fullscreen/index.test.ts b/projects/core/browser/fullscreen/index.test.ts index f6d17d4..de959f0 100644 --- a/projects/core/browser/fullscreen/index.test.ts +++ b/projects/core/browser/fullscreen/index.test.ts @@ -304,4 +304,109 @@ describe(fullscreen.name, () => { expect(component.fs.isActive()).toBe(true); }); }); + + describe('concurrent transitions', () => { + // Replaces the instant mock with a controllable one: the test decides when the browser + // finishes the transition (resolves the native promise and fires `fullscreenchange`). + let finishRequest: () => void; + + beforeEach(() => { + requestFullscreenSpy.mockImplementation( + () => + new Promise(resolve => { + finishRequest = () => { + fullscreenElementValue = document.documentElement; + document.dispatchEvent(new Event('fullscreenchange')); + resolve(); + }; + }) + ); + }); + + it('should not issue a second native request while enter is pending', async () => { + const component = createComponent(); + + const first = component.fs.enter(); + const second = component.fs.enter(); + + finishRequest(); + await Promise.all([first, second]); + + expect(requestFullscreenSpy).toHaveBeenCalledTimes(1); + }); + + it('should make exit join a pending enter instead of racing it', async () => { + const component = createComponent(); + + const entering = component.fs.enter(); + const exiting = component.fs.exit(); + + finishRequest(); + await Promise.all([entering, exiting]); + + expect(exitFullscreenSpy).not.toHaveBeenCalled(); + expect(component.fs.isActive()).toBe(true); + }); + + it('should enter exactly once on a double toggle', async () => { + const component = createComponent(); + + const first = component.fs.toggle(); + const second = component.fs.toggle(); + + finishRequest(); + await Promise.all([first, second]); + + expect(requestFullscreenSpy).toHaveBeenCalledTimes(1); + expect(exitFullscreenSpy).not.toHaveBeenCalled(); + expect(component.fs.isActive()).toBe(true); + }); + + it('should allow a new transition once the pending one settles', async () => { + const component = createComponent(); + + const entering = component.fs.enter(); + finishRequest(); + await entering; + + await component.fs.exit(); + + expect(exitFullscreenSpy).toHaveBeenCalledTimes(1); + }); + + it('should release the pending transition when the native request rejects', async () => { + const component = createComponent(); + + requestFullscreenSpy.mockRejectedValueOnce(new TypeError('Permissions check failed')); + + await expect(component.fs.enter()).rejects.toThrow('Permissions check failed'); + + const retry = component.fs.enter(); + finishRequest(); + await retry; + + expect(requestFullscreenSpy).toHaveBeenCalledTimes(2); + expect(component.fs.isActive()).toBe(true); + }); + + it('should propagate the pending rejection to every joined caller', async () => { + const component = createComponent(); + + let rejectRequest: (error: Error) => void; + requestFullscreenSpy.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectRequest = reject; + }) + ); + + const first = component.fs.enter(); + const second = component.fs.enter(); + + rejectRequest!(new TypeError('Permissions check failed')); + + await expect(first).rejects.toThrow('Permissions check failed'); + await expect(second).rejects.toThrow('Permissions check failed'); + }); + }); }); diff --git a/projects/core/browser/fullscreen/index.ts b/projects/core/browser/fullscreen/index.ts index f48a6f5..39b4ce3 100644 --- a/projects/core/browser/fullscreen/index.ts +++ b/projects/core/browser/fullscreen/index.ts @@ -118,23 +118,45 @@ export function fullscreen(options?: FullscreenOptions): FullscreenRef { return current != null && current === toElement(target); }); + let transition: Promise | null = null; + + const runTransition = (startFn: () => Promise): Promise => { + const result = startFn().finally(() => { + transition = null; + }); + transition = result; + return result; + }; + const enter = async (): Promise => { + if (transition) { + return transition; + } + const el = toElement.untracked(target); ngDevMode && assertElement(el, 'fullscreen'); - if (getFullscreenElement(document) !== el) { - await el?.requestFullscreen(); + if (el && getFullscreenElement(document) !== el) { + await runTransition(() => el.requestFullscreen()); } }; const exit = async (): Promise => { + if (transition) { + return transition; + } + const el = toElement.untracked(target); ngDevMode && assertElement(el, 'fullscreen'); - if (getFullscreenElement(document) === el) { - await document.exitFullscreen(); + if (el && getFullscreenElement(document) === el) { + await runTransition(() => document.exitFullscreen()); } }; const toggle = async (): Promise => { + if (transition) { + return transition; + } + if (untracked(isActive)) { await exit(); } else {