From fea8772d9550d8009519adde1b43a0f4c3372df5 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 26 Aug 2022 13:48:05 -0700 Subject: [PATCH] fix: emit load/domcontentloaded events as reported by the browser (#16861) Instead of requiring all frames in the subtree to receive a particular event, we rely on the browser's definition of load and DOMContentLoaded. This changes logic in a few edge cases: - Some browsers do not emit load event upon window.stop() at all. - DOMContentLoaded does not wait for subframes, so they might not be ready when passing `{ waitUntil: 'domcontentloaded' }`. `networkidle` preserves the old logic. --- .../src/server/chromium/crPage.ts | 7 -- .../src/server/dispatchers/frameDispatcher.ts | 2 +- packages/playwright-core/src/server/frames.ts | 84 ++++++++----------- .../src/server/webkit/wkPage.ts | 13 +-- tests/library/ignorehttpserrors.spec.ts | 2 +- tests/page/page-goto.spec.ts | 17 +++- tests/page/page-wait-for-navigation.spec.ts | 12 +-- 7 files changed, 61 insertions(+), 76 deletions(-) diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index ca53f8399b..5da8b1c9f1 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -435,7 +435,6 @@ class FrameSession { eventsHelper.addEventListener(this._client, 'Page.frameDetached', event => this._onFrameDetached(event.frameId, event.reason)), eventsHelper.addEventListener(this._client, 'Page.frameNavigated', event => this._onFrameNavigated(event.frame, false)), eventsHelper.addEventListener(this._client, 'Page.frameRequestedNavigation', event => this._onFrameRequestedNavigation(event)), - eventsHelper.addEventListener(this._client, 'Page.frameStoppedLoading', event => this._onFrameStoppedLoading(event.frameId)), eventsHelper.addEventListener(this._client, 'Page.javascriptDialogOpening', event => this._onDialog(event)), eventsHelper.addEventListener(this._client, 'Page.navigatedWithinDocument', event => this._onFrameNavigatedWithinDocument(event.frameId, event.url)), eventsHelper.addEventListener(this._client, 'Runtime.bindingCalled', event => this._onBindingCalled(event)), @@ -601,12 +600,6 @@ class FrameSession { this._page._frameManager.frameLifecycleEvent(event.frameId, 'domcontentloaded'); } - _onFrameStoppedLoading(frameId: string) { - if (this._eventBelongsToStaleFrame(frameId)) - return; - this._page._frameManager.frameStoppedLoading(frameId); - } - _handleFrameTree(frameTree: Protocol.Page.FrameTree) { this._onFrameAttached(frameTree.frame.id, frameTree.frame.parentId || null); this._onFrameNavigated(frameTree.frame, true); diff --git a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts index 4f69b42d38..a1f984c312 100644 --- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts @@ -48,7 +48,7 @@ export class FrameDispatcher extends Dispatcher { diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index cf2a8afd83..be8c5ca224 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -62,6 +62,8 @@ export type GotoResult = { type ConsoleTagHandler = () => void; +type RegularLifecycleEvent = Exclude; + export type FunctionWithSource = (source: { context: BrowserContext, page: Page, frame: Frame}, ...args: any) => any; export type NavigationEvent = { @@ -279,17 +281,11 @@ export class FrameManager { const frame = this._frames.get(frameId); if (frame) { this._removeFramesRecursively(frame); - // Recalculate subtree lifecycle for the whole tree - it should not be that big. - this._page.mainFrame()._recalculateLifecycle(); + this._page.mainFrame()._recalculateNetworkIdle(); } } - frameStoppedLoading(frameId: string) { - this.frameLifecycleEvent(frameId, 'domcontentloaded'); - this.frameLifecycleEvent(frameId, 'load'); - } - - frameLifecycleEvent(frameId: string, event: types.LifecycleEvent) { + frameLifecycleEvent(frameId: string, event: RegularLifecycleEvent) { const frame = this._frames.get(frameId); if (frame) frame._onLifecycleEvent(event); @@ -478,8 +474,8 @@ export class Frame extends SdkObject { }; _id: string; - private _firedLifecycleEvents = new Set(); - _subtreeLifecycleEvents = new Set(); + _firedLifecycleEvents = new Set(); + private _firedNetworkIdleSelf = false; _currentDocument: DocumentInfo; private _pendingDocument: DocumentInfo | undefined; readonly _page: Page; @@ -516,7 +512,6 @@ export class Frame extends SdkObject { this._parentFrame._childFrames.add(this); this._firedLifecycleEvents.add('commit'); - this._subtreeLifecycleEvents.add('commit'); if (id !== kDummyFrameId) this._startNetworkIdleTimer(); } @@ -525,23 +520,26 @@ export class Frame extends SdkObject { return this._detached; } - _onLifecycleEvent(event: types.LifecycleEvent) { + _onLifecycleEvent(event: RegularLifecycleEvent) { if (this._firedLifecycleEvents.has(event)) return; this._firedLifecycleEvents.add(event); - // Recalculate subtree lifecycle for the whole tree - it should not be that big. - this._page.mainFrame()._recalculateLifecycle(); + this.emit(Frame.Events.AddLifecycle, event); + if (this === this._page.mainFrame() && this._url !== 'about:blank') + debugLogger.log('api', ` "${event}" event fired`); + this._page.mainFrame()._recalculateNetworkIdle(); } _onClearLifecycle() { + for (const event of this._firedLifecycleEvents) + this.emit(Frame.Events.RemoveLifecycle, event); this._firedLifecycleEvents.clear(); - // Recalculate subtree lifecycle for the whole tree - it should not be that big. - this._page.mainFrame()._recalculateLifecycle(this); // Keep the current navigation request if any. this._inflightRequests = new Set(Array.from(this._inflightRequests).filter(request => request === this._currentDocument.request)); this._stopNetworkIdleTimer(); if (this._inflightRequests.size === 0) this._startNetworkIdleTimer(); + this._page.mainFrame()._recalculateNetworkIdle(this); this._onLifecycleEvent('commit'); } @@ -599,37 +597,26 @@ export class Frame extends SdkObject { }); } - _recalculateLifecycle(frameThatAllowsRemovingLifecycleEvents?: Frame) { - const events = new Set(this._firedLifecycleEvents); + _recalculateNetworkIdle(frameThatAllowsRemovingNetworkIdle?: Frame) { + let isNetworkIdle = this._firedNetworkIdleSelf; for (const child of this._childFrames) { - child._recalculateLifecycle(frameThatAllowsRemovingLifecycleEvents); - // We require a particular lifecycle event to be fired in the whole - // frame subtree, and then consider it done. - for (const event of events) { - if (!child._subtreeLifecycleEvents.has(event)) - events.delete(event); - } + child._recalculateNetworkIdle(frameThatAllowsRemovingNetworkIdle); + // We require networkidle event to be fired in the whole frame subtree, and then consider it done. + if (!child._firedLifecycleEvents.has('networkidle')) + isNetworkIdle = false; } - if (frameThatAllowsRemovingLifecycleEvents !== this) { - // Usually, lifecycle events are fired once and not removed after that, so we keep existing ones. + if (isNetworkIdle && !this._firedLifecycleEvents.has('networkidle')) { + this._firedLifecycleEvents.add('networkidle'); + this.emit(Frame.Events.AddLifecycle, 'networkidle'); + if (this === this._page.mainFrame() && this._url !== 'about:blank') + debugLogger.log('api', ` "networkidle" event fired`); + } + if (frameThatAllowsRemovingNetworkIdle !== this && this._firedLifecycleEvents.has('networkidle') && !isNetworkIdle) { + // Usually, networkidle is fired once and not removed after that. // However, when we clear them right before a new commit, this is allowed for a particular frame. - for (const event of this._subtreeLifecycleEvents) - events.add(event); + this._firedLifecycleEvents.delete('networkidle'); + this.emit(Frame.Events.RemoveLifecycle, 'networkidle'); } - const mainFrame = this._page.mainFrame(); - for (const event of events) { - // Checking whether we have already notified about this event. - if (!this._subtreeLifecycleEvents.has(event)) { - this.emit(Frame.Events.AddLifecycle, event); - if (this === mainFrame && this._url !== 'about:blank') - debugLogger.log('api', ` "${event}" event fired`); - } - } - for (const event of this._subtreeLifecycleEvents) { - if (!events.has(event)) - this.emit(Frame.Events.RemoveLifecycle, event); - } - this._subtreeLifecycleEvents = events; } async raceNavigationAction(progress: Progress, options: types.GotoOptions, action: () => Promise): Promise { @@ -706,7 +693,7 @@ export class Frame extends SdkObject { event = await sameDocument.promise; } - if (!this._subtreeLifecycleEvents.has(waitUntil)) + if (!this._firedLifecycleEvents.has(waitUntil)) await helper.waitForEvent(progress, this, Frame.Events.AddLifecycle, (e: types.LifecycleEvent) => e === waitUntil).promise; const request = event.newDocument ? event.newDocument.request : undefined; @@ -731,7 +718,7 @@ export class Frame extends SdkObject { if (navigationEvent.error) throw navigationEvent.error; - if (!this._subtreeLifecycleEvents.has(waitUntil)) + if (!this._firedLifecycleEvents.has(waitUntil)) await helper.waitForEvent(progress, this, Frame.Events.AddLifecycle, (e: types.LifecycleEvent) => e === waitUntil).promise; const request = navigationEvent.newDocument ? navigationEvent.newDocument.request : undefined; @@ -740,7 +727,7 @@ export class Frame extends SdkObject { async _waitForLoadState(progress: Progress, state: types.LifecycleEvent): Promise { const waitUntil = verifyLifecycle('state', state); - if (!this._subtreeLifecycleEvents.has(waitUntil)) + if (!this._firedLifecycleEvents.has(waitUntil)) await helper.waitForEvent(progress, this, Frame.Events.AddLifecycle, (e: types.LifecycleEvent) => e === waitUntil).promise; } @@ -1629,7 +1616,10 @@ export class Frame extends SdkObject { // after the frame was detached - probably a race in the Firefox itself. if (this._firedLifecycleEvents.has('networkidle') || this._detached) return; - this._networkIdleTimer = setTimeout(() => this._onLifecycleEvent('networkidle'), 500); + this._networkIdleTimer = setTimeout(() => { + this._firedNetworkIdleSelf = true; + this._page.mainFrame()._recalculateNetworkIdle(); + }, 500); } _stopNetworkIdleTimer() { diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 044d92c39c..4c7c54a66b 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -380,9 +380,8 @@ export class WKPage implements PageDelegate { eventsHelper.addEventListener(this._session, 'Page.willCheckNavigationPolicy', event => this._onWillCheckNavigationPolicy(event.frameId)), eventsHelper.addEventListener(this._session, 'Page.didCheckNavigationPolicy', event => this._onDidCheckNavigationPolicy(event.frameId, event.cancel)), eventsHelper.addEventListener(this._session, 'Page.frameScheduledNavigation', event => this._onFrameScheduledNavigation(event.frameId)), - eventsHelper.addEventListener(this._session, 'Page.frameStoppedLoading', event => this._onFrameStoppedLoading(event.frameId)), - eventsHelper.addEventListener(this._session, 'Page.loadEventFired', event => this._onLifecycleEvent(event.frameId, 'load')), - eventsHelper.addEventListener(this._session, 'Page.domContentEventFired', event => this._onLifecycleEvent(event.frameId, 'domcontentloaded')), + eventsHelper.addEventListener(this._session, 'Page.loadEventFired', event => this._page._frameManager.frameLifecycleEvent(event.frameId, 'load')), + eventsHelper.addEventListener(this._session, 'Page.domContentEventFired', event => this._page._frameManager.frameLifecycleEvent(event.frameId, 'domcontentloaded')), eventsHelper.addEventListener(this._session, 'Runtime.executionContextCreated', event => this._onExecutionContextCreated(event.context)), eventsHelper.addEventListener(this._session, 'Runtime.bindingCalled', event => this._onBindingCalled(event.contextId, event.argument)), eventsHelper.addEventListener(this._session, 'Console.messageAdded', event => this._onConsoleMessage(event)), @@ -450,14 +449,6 @@ export class WKPage implements PageDelegate { this._page._frameManager.frameRequestedNavigation(frameId); } - private _onFrameStoppedLoading(frameId: string) { - this._page._frameManager.frameStoppedLoading(frameId); - } - - private _onLifecycleEvent(frameId: string, event: types.LifecycleEvent) { - this._page._frameManager.frameLifecycleEvent(frameId, event); - } - private _handleFrameTree(frameTree: Protocol.Page.FrameResourceTree) { this._onFrameAttached(frameTree.frame.id, frameTree.frame.parentId || null); this._onFrameNavigated(frameTree.frame, true); diff --git a/tests/library/ignorehttpserrors.spec.ts b/tests/library/ignorehttpserrors.spec.ts index 02ccf3af77..5f52b1c940 100644 --- a/tests/library/ignorehttpserrors.spec.ts +++ b/tests/library/ignorehttpserrors.spec.ts @@ -53,7 +53,7 @@ it('should work with mixed content', async ({ browser, server, httpsServer }) => }); const context = await browser.newContext({ ignoreHTTPSErrors: true }); const page = await context.newPage(); - await page.goto(httpsServer.PREFIX + '/mixedcontent.html', { waitUntil: 'domcontentloaded' }); + await page.goto(httpsServer.PREFIX + '/mixedcontent.html', { waitUntil: 'load' }); expect(page.frames().length).toBe(2); // Make sure blocked iframe has functional execution context // @see https://github.com/GoogleChrome/puppeteer/issues/2709 diff --git a/tests/page/page-goto.spec.ts b/tests/page/page-goto.spec.ts index 3dd5e08bd6..e31d8bc82b 100644 --- a/tests/page/page-goto.spec.ts +++ b/tests/page/page-goto.spec.ts @@ -629,9 +629,20 @@ it('should properly wait for load', async ({ page, server, browserName }) => { ]); }); -it('should properly report window.stop()', async ({ page, server }) => { - server.setRoute('/module.js', async (req, res) => void 0); - await page.goto(server.PREFIX + '/window-stop.html'); +it('should not resolve goto upon window.stop()', async ({ browserName, page, server }) => { + let response; + server.setRoute('/module.js', (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/javascript' }); + response = res; + }); + let done = false; + page.goto(server.PREFIX + '/window-stop.html').then(() => done = true).catch(() => {}); + await server.waitForRequest('/module.js'); + expect(done).toBe(false); + await page.waitForTimeout(1000); // give it some time to erroneously resolve + response.end(''); + await page.waitForTimeout(1000); // give it more time to erroneously resolve + expect(done).toBe(browserName === 'firefox'); // Firefox fires DOMContentLoaded and load events in this case. }); it('should return from goto if new navigation is started', async ({ page, server, browserName, isAndroid }) => { diff --git a/tests/page/page-wait-for-navigation.spec.ts b/tests/page/page-wait-for-navigation.spec.ts index 8dad7dabea..92cb01f326 100644 --- a/tests/page/page-wait-for-navigation.spec.ts +++ b/tests/page/page-wait-for-navigation.spec.ts @@ -155,18 +155,18 @@ it('should work with DOM history.back()/history.forward()', async ({ page, serve expect(page.url()).toBe(server.PREFIX + '/second.html'); }); -it('should work when subframe issues window.stop()', async ({ page, server }) => { +it('should work when subframe issues window.stop()', async ({ browserName, page, server }) => { server.setRoute('/frames/style.css', (req, res) => {}); - const navigationPromise = page.goto(server.PREFIX + '/frames/one-frame.html'); + let done = false; + page.goto(server.PREFIX + '/frames/one-frame.html').then(() => done = true).catch(() => {}); const frame = await new Promise(f => page.once('frameattached', f)); await new Promise(fulfill => page.on('framenavigated', f => { if (f === frame) fulfill(); })); - await Promise.all([ - frame.evaluate(() => window.stop()), - navigationPromise - ]); + await frame.evaluate(() => window.stop()); + await page.waitForTimeout(2000); // give it some time to erroneously resolve + expect(done).toBe(browserName !== 'webkit'); // Chromium and Firefox issue load event in this case. }); it('should work with url match', async ({ page, server }) => {