Skip to content

Commit

Permalink
chore: implement improved waitForNavigation
Browse files Browse the repository at this point in the history
  • Loading branch information
jrandolf committed Feb 19, 2024
1 parent 03ef7a6 commit fe4cc01
Show file tree
Hide file tree
Showing 6 changed files with 139 additions and 148 deletions.
183 changes: 107 additions & 76 deletions packages/puppeteer-core/src/bidi/Frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,18 @@ import type {Observable} from '../../third_party/rxjs/rxjs.js';
import {
combineLatest,
defer,
delayWhen,
filter,
first,
firstValueFrom,
from,
map,
merge,
mergeMap,
of,
raceWith,
switchMap,
startWith,
take,
zip,
} from '../../third_party/rxjs/rxjs.js';
import type {CDPSession} from '../api/CDPSession.js';
import type {ElementHandle} from '../api/ElementHandle.js';
Expand Down Expand Up @@ -120,10 +124,8 @@ export class BidiFrame extends Frame {
});
});

this.browsingContext.on('navigation', ({navigation}) => {
navigation.once('fragment', () => {
this.page().trustedEmitter.emit(PageEvent.FrameNavigated, this);
});
this.browsingContext.on('fragment', () => {
this.page().trustedEmitter.emit(PageEvent.FrameNavigated, this);
});
this.browsingContext.on('load', () => {
this.page().trustedEmitter.emit(PageEvent.Load, undefined);
Expand Down Expand Up @@ -282,23 +284,26 @@ export class BidiFrame extends Frame {
url: string,
options: GoToOptions = {}
): Promise<BidiHTTPResponse | null> {
const [response] = await Promise.all([
this.waitForNavigation(options),
// Some implementations currently only report errors when the
// readiness=interactive.
//
// Related: https://bugzilla.mozilla.org/show_bug.cgi?id=1846601
this.browsingContext.navigate(
url,
Bidi.BrowsingContext.ReadinessState.Interactive
),
]).catch(
return await firstValueFrom(
this.#waitForNavigation$({
...options,
// Some implementations currently only report errors when the
// readiness=interactive.
//
// Related: https://bugzilla.mozilla.org/show_bug.cgi?id=1846601
completion$: from(
this.browsingContext.navigate(
url,
Bidi.BrowsingContext.ReadinessState.Interactive
)
),
})
).catch(
rewriteNavigationError(
url,
options.timeout ?? this.timeoutSettings.navigationTimeout()
)
);
return response;
}

@throwIfDetached
Expand All @@ -321,64 +326,7 @@ export class BidiFrame extends Frame {
override async waitForNavigation(
options: WaitForOptions = {}
): Promise<BidiHTTPResponse | null> {
const {timeout: ms = this.timeoutSettings.navigationTimeout()} = options;

const frames = this.childFrames().map(frame => {
return frame.#detached$();
});
return await firstValueFrom(
combineLatest([
fromEmitterEvent(this.browsingContext, 'navigation').pipe(
switchMap(({navigation}) => {
return this.#waitForLoad$(options).pipe(
delayWhen(() => {
if (frames.length === 0) {
return of(undefined);
}
return combineLatest(frames);
}),
raceWith(
fromEmitterEvent(navigation, 'fragment'),
fromEmitterEvent(navigation, 'failed').pipe(
map(({url}) => {
throw new Error(`Navigation failed: ${url}`);
})
),
fromEmitterEvent(navigation, 'aborted').pipe(
map(({url}) => {
throw new Error(`Navigation aborted: ${url}`);
})
)
),
map(() => {
return navigation;
})
);
})
),
this.#waitForNetworkIdle$(options),
]).pipe(
map(([navigation]) => {
const request = navigation.request;
if (!request) {
return null;
}
const httpRequest = requests.get(request)!;
const lastRedirect = httpRequest.redirectChain().at(-1);
return (
lastRedirect !== undefined ? lastRedirect : httpRequest
).response();
}),
raceWith(
timeout(ms),
this.#detached$().pipe(
map(() => {
throw new TargetCloseError('Frame detached.');
})
)
)
)
);
return await firstValueFrom(this.#waitForNavigation$(options));
}

override waitForDevicePrompt(): never {
Expand Down Expand Up @@ -430,6 +378,89 @@ export class BidiFrame extends Frame {
return new BidiCdpSession(this, sessionId);
}

@throwIfDetached
#waitForNavigation$(
options: WaitForOptions & {
/**
* If defined, waitForNavigation$ will wait for this condition and a
* navigation.
*/
completion$?: Observable<unknown>;
} = {}
): Observable<BidiHTTPResponse | null> {
const {
timeout: ms = this.timeoutSettings.navigationTimeout(),
completion$,
} = options;

const enum State {
WaitingForRequest,
WaitingForResponse,
}

// Wait for the first navigation of any type.
let navigations$: Observable<unknown> = merge(
fromEmitterEvent(this.browsingContext, 'navigation'),
fromEmitterEvent(this.browsingContext, 'fragment')
).pipe(first());

// Wait for the completion condition to complete if defined.
if (completion$ !== undefined) {
navigations$ = zip(navigations$, completion$);
}

// An observable that returns the response to the first navigation request,
// if any.
const frames = this.childFrames();
const response$ = fromEmitterEvent(this.browsingContext, 'navigation').pipe(
take(1),
mergeMap(({navigation}) => {
// Wait for conditions before returning the response.
return combineLatest([
this.#waitForNetworkIdle$(options),
this.#waitForLoad$(options),
...frames.map(frame => {
return frame.#detached$();
}),
]).pipe(
map(() => {
if (!navigation.request) {
return null;
}
const httpRequest = requests.get(navigation.request)!;
const redirect = httpRequest.redirectChain().at(-1);
return (redirect !== undefined ? redirect : httpRequest).response();
}),
startWith(State.WaitingForResponse)
);
}),
startWith(State.WaitingForRequest)
);

return combineLatest([response$, navigations$]).pipe(
mergeMap(([stateOrResponse]) => {
switch (stateOrResponse) {
// If we are waiting for the navigation request, give up.
case State.WaitingForRequest:
return of(null);
// We shall also wait for the response.
case State.WaitingForResponse:
return of();
default:
return of(stateOrResponse);
}
}),
raceWith(
timeout(ms),
this.#detached$().pipe(
map(() => {
throw new TargetCloseError('Frame detached.');
})
)
)
);
}

@throwIfDetached
#waitForLoad$(options: WaitForOptions = {}): Observable<void> {
let {waitUntil = 'load'} = options;
Expand Down
35 changes: 24 additions & 11 deletions packages/puppeteer-core/src/bidi/core/BrowsingContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ export class BrowsingContext extends EventEmitter<{
/** The realm for the new dedicated worker */
realm: DedicatedWorkerRealm;
};
/** Emitted whenever the browsing context fragment navigates */
fragment: {
/** The new url */
url: string;
/** The timestamp of the navigation */
timestamp: Date;
};
}> {
static from(
userContext: UserContext,
Expand Down Expand Up @@ -214,35 +221,41 @@ export class BrowsingContext extends EventEmitter<{
if (info.context !== this.id) {
return;
}
this.#url = info.url;

for (const [id, request] of this.#requests) {
if (request.disposed) {
this.#requests.delete(id);
}
}
// If the navigation hasn't finished, then this is nested navigation. The
// current navigation will handle this.
if (this.#navigation !== undefined && !this.#navigation.disposed) {
return;

if (this.#navigation !== undefined) {
this.#navigation.dispose();
}

// Note the navigation ID is null for this event.
this.#navigation = Navigation.from(this);
this.#navigation = Navigation.from(this, info.url);

const navigationEmitter = this.#disposables.use(
new EventEmitter(this.#navigation)
);
for (const eventName of ['fragment', 'failed', 'aborted'] as const) {
navigationEmitter.once(eventName, ({url}) => {
for (const eventName of ['failed', 'aborted'] as const) {
navigationEmitter.once(eventName, () => {
navigationEmitter[disposeSymbol]();

this.#url = url;
});
}

this.emit('navigation', {navigation: this.#navigation});
});
sessionEmitter.on('browsingContext.fragmentNavigated', info => {
if (info.context !== this.id) {
return;
}
this.#url = info.url;

this.emit('fragment', {
url: info.url,
timestamp: new Date(info.timestamp),
});
});
sessionEmitter.on('network.beforeRequestSent', event => {
if (event.context !== this.id) {
return;
Expand Down
48 changes: 8 additions & 40 deletions packages/puppeteer-core/src/bidi/core/Navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,31 +26,30 @@ export interface NavigationInfo {
export class Navigation extends EventEmitter<{
/** Emitted when navigation has a request associated with it. */
request: Request;
/** Emitted when fragment navigation occurred. */
fragment: NavigationInfo;
/** Emitted when navigation failed. */
failed: NavigationInfo;
/** Emitted when navigation was aborted. */
aborted: NavigationInfo;
}> {
static from(context: BrowsingContext): Navigation {
const navigation = new Navigation(context);
static from(context: BrowsingContext, url: string): Navigation {
const navigation = new Navigation(context, url);
navigation.#initialize();
return navigation;
}

// keep-sorted start
#request: Request | undefined;
#navigation: Navigation | undefined;
readonly #browsingContext: BrowsingContext;
readonly #disposables = new DisposableStack();
readonly #id = new Deferred<string | null>();
readonly #url: string;
// keep-sorted end

private constructor(context: BrowsingContext) {
private constructor(context: BrowsingContext, url: string) {
super();
// keep-sorted start
this.#browsingContext = context;
this.#url = url;
// keep-sorted end
}

Expand Down Expand Up @@ -84,35 +83,7 @@ export class Navigation extends EventEmitter<{
const sessionEmitter = this.#disposables.use(
new EventEmitter(this.#session)
);
sessionEmitter.on('browsingContext.navigationStarted', info => {
if (
info.context !== this.#browsingContext.id ||
this.#navigation !== undefined
) {
return;
}
this.#navigation = Navigation.from(this.#browsingContext);
});

for (const eventName of [
'browsingContext.domContentLoaded',
'browsingContext.load',
] as const) {
sessionEmitter.on(eventName, info => {
if (
info.context !== this.#browsingContext.id ||
info.navigation === null ||
!this.#matches(info.navigation)
) {
return;
}

this.dispose();
});
}

for (const [eventName, event] of [
['browsingContext.fragmentNavigated', 'fragment'],
['browsingContext.navigationFailed', 'failed'],
['browsingContext.navigationAborted', 'aborted'],
] as const) {
Expand All @@ -136,9 +107,6 @@ export class Navigation extends EventEmitter<{
}

#matches(navigation: string | null): boolean {
if (this.#navigation !== undefined && !this.#navigation.disposed) {
return false;
}
if (!this.#id.resolved()) {
this.#id.resolve(navigation);
return true;
Expand All @@ -156,13 +124,13 @@ export class Navigation extends EventEmitter<{
get request(): Request | undefined {
return this.#request;
}
get navigation(): Navigation | undefined {
return this.#navigation;
get url(): string {
return this.#url;
}
// keep-sorted end

@inertIfDisposed
private dispose(): void {
dispose(): void {
this[disposeSymbol]();
}

Expand Down

0 comments on commit fe4cc01

Please sign in to comment.