Skip to content

Commit

Permalink
fix: emit load/domcontentloaded events as reported by the browser (#1…
Browse files Browse the repository at this point in the history
…6861)

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.
  • Loading branch information
dgozman committed Aug 26, 2022
1 parent b93668e commit fea8772
Show file tree
Hide file tree
Showing 7 changed files with 61 additions and 76 deletions.
7 changes: 0 additions & 7 deletions packages/playwright-core/src/server/chromium/crPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, Pa
url: frame.url(),
name: frame.name(),
parentFrame: FrameDispatcher.fromNullable(scope, frame.parentFrame()),
loadStates: Array.from(frame._subtreeLifecycleEvents),
loadStates: Array.from(frame._firedLifecycleEvents),
});
this._frame = frame;
this.addObjectListener(Frame.Events.AddLifecycle, lifecycleEvent => {
Expand Down
84 changes: 37 additions & 47 deletions packages/playwright-core/src/server/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export type GotoResult = {

type ConsoleTagHandler = () => void;

type RegularLifecycleEvent = Exclude<types.LifecycleEvent, 'networkidle'>;

export type FunctionWithSource = (source: { context: BrowserContext, page: Page, frame: Frame}, ...args: any) => any;

export type NavigationEvent = {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -478,8 +474,8 @@ export class Frame extends SdkObject {
};

_id: string;
private _firedLifecycleEvents = new Set<types.LifecycleEvent>();
_subtreeLifecycleEvents = new Set<types.LifecycleEvent>();
_firedLifecycleEvents = new Set<types.LifecycleEvent>();
private _firedNetworkIdleSelf = false;
_currentDocument: DocumentInfo;
private _pendingDocument: DocumentInfo | undefined;
readonly _page: Page;
Expand Down Expand Up @@ -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();
}
Expand All @@ -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');
}

Expand Down Expand Up @@ -599,37 +597,26 @@ export class Frame extends SdkObject {
});
}

_recalculateLifecycle(frameThatAllowsRemovingLifecycleEvents?: Frame) {
const events = new Set<types.LifecycleEvent>(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.
// 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);
}
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`);
}
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`);
}
for (const event of this._subtreeLifecycleEvents) {
if (!events.has(event))
this.emit(Frame.Events.RemoveLifecycle, event);
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.
this._firedLifecycleEvents.delete('networkidle');
this.emit(Frame.Events.RemoveLifecycle, 'networkidle');
}
this._subtreeLifecycleEvents = events;
}

async raceNavigationAction(progress: Progress, options: types.GotoOptions, action: () => Promise<network.Response | null>): Promise<network.Response | null> {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -740,7 +727,7 @@ export class Frame extends SdkObject {

async _waitForLoadState(progress: Progress, state: types.LifecycleEvent): Promise<void> {
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;
}

Expand Down Expand Up @@ -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() {
Expand Down
13 changes: 2 additions & 11 deletions packages/playwright-core/src/server/webkit/wkPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion tests/library/ignorehttpserrors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions tests/page/page-goto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
12 changes: 6 additions & 6 deletions tests/page/page-wait-for-navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Frame>(f => page.once('frameattached', f));
await new Promise<void>(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 }) => {
Expand Down

0 comments on commit fea8772

Please sign in to comment.