UNPKG

chrome-devtools-frontend

Version:
607 lines (505 loc) • 20.8 kB
// Copyright 2023 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import type * as Common from '../common/common.js'; import type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js'; import * as Protocol from '../../generated/protocol.js'; import {SDKModel} from './SDKModel.js'; import {Capability, type Target} from './Target.js'; import {TargetManager} from './TargetManager.js'; import { Events as ResourceTreeModelEvents, PrimaryPageChangeType, ResourceTreeModel, type ResourceTreeFrame, } from './ResourceTreeModel.js'; export interface WithId<I, V> { id: I; value: V; } // Holds preloading related information. // // - SpeculationRule rule sets // - Preloading attempts // - Relationship between rule sets and preloading attempts export class PreloadingModel extends SDKModel<EventTypes> { private agent: ProtocolProxyApi.PreloadApi; private loaderIds: Protocol.Network.LoaderId[] = []; private targetJustAttached: boolean = true; private lastPrimaryPageModel: PreloadingModel|null = null; private documents: Map<Protocol.Network.LoaderId, DocumentPreloadingData> = new Map<Protocol.Network.LoaderId, DocumentPreloadingData>(); private getFeatureFlagsPromise: Promise<FeatureFlags>; constructor(target: Target) { super(target); target.registerPreloadDispatcher(new PreloadDispatcher(this)); this.agent = target.preloadAgent(); void this.agent.invoke_enable(); this.getFeatureFlagsPromise = this.getFeatureFlags(); const targetInfo = target.targetInfo(); if (targetInfo !== undefined && targetInfo.subtype === 'prerender') { this.lastPrimaryPageModel = TargetManager.instance().primaryPageTarget()?.model(PreloadingModel) || null; } TargetManager.instance().addModelListener( ResourceTreeModel, ResourceTreeModelEvents.PrimaryPageChanged, this.onPrimaryPageChanged, this); } override dispose(): void { super.dispose(); TargetManager.instance().removeModelListener( ResourceTreeModel, ResourceTreeModelEvents.PrimaryPageChanged, this.onPrimaryPageChanged, this); void this.agent.invoke_disable(); } private ensureDocumentPreloadingData(loaderId: Protocol.Network.LoaderId): void { if (this.documents.get(loaderId) === undefined) { this.documents.set(loaderId, new DocumentPreloadingData()); } } private currentLoaderId(): Protocol.Network.LoaderId|null { // Target is just attached and didn't received CDP events that we can infer loaderId. if (this.targetJustAttached) { return null; } if (this.loaderIds.length === 0) { throw new Error('unreachable'); } return this.loaderIds[this.loaderIds.length - 1]; } private currentDocument(): DocumentPreloadingData|null { const loaderId = this.currentLoaderId(); return loaderId === null ? null : this.documents.get(loaderId) || null; } // Returns a rule set of the current page. // // Returns reference. Don't save returned values. // Returned value may or may not be updated as the time grows. getRuleSetById(id: Protocol.Preload.RuleSetId): Protocol.Preload.RuleSet|null { return this.currentDocument()?.ruleSets.getById(id) || null; } // Returns rule sets of the current page. // // Returns array of pairs of id and reference. Don't save returned references. // Returned values may or may not be updated as the time grows. getAllRuleSets(): WithId<Protocol.Preload.RuleSetId, Protocol.Preload.RuleSet>[] { return this.currentDocument()?.ruleSets.getAll() || []; } // Returns a preloading attempt of the current page. // // Returns reference. Don't save returned values. // Returned value may or may not be updated as the time grows. getPreloadingAttemptById(id: PreloadingAttemptId): PreloadingAttempt|null { const document = this.currentDocument(); if (document === null) { return null; } return document.preloadingAttempts.getById(id, document.sources) || null; } // Returs preloading attempts of the current page that triggered by the rule set with `ruleSetId`. // `ruleSetId === null` means "do not filter". // // Returns array of pairs of id and reference. Don't save returned references. // Returned values may or may not be updated as the time grows. getPreloadingAttempts(ruleSetId: Protocol.Preload.RuleSetId|null): WithId<PreloadingAttemptId, PreloadingAttempt>[] { const document = this.currentDocument(); if (document === null) { return []; } return document.preloadingAttempts.getAll(ruleSetId, document.sources); } // Returs preloading attempts of the previousPgae. // // Returns array of pairs of id and reference. Don't save returned references. // Returned values may or may not be updated as the time grows. getPreloadingAttemptsOfPreviousPage(): WithId<PreloadingAttemptId, PreloadingAttempt>[] { if (this.loaderIds.length <= 1) { return []; } const document = this.documents.get(this.loaderIds[this.loaderIds.length - 2]); if (document === undefined) { return []; } return document.preloadingAttempts.getAll(null, document.sources); } private onPrimaryPageChanged( event: Common.EventTarget.EventTargetEvent<{frame: ResourceTreeFrame, type: PrimaryPageChangeType}>): void { const {frame, type} = event.data; // Model of prerendered page's target will hands over. Do nothing for the initiator page. if (this.lastPrimaryPageModel === null && type === PrimaryPageChangeType.Activation) { return; } if (this.lastPrimaryPageModel !== null && type !== PrimaryPageChangeType.Activation) { return; } if (this.lastPrimaryPageModel !== null && type === PrimaryPageChangeType.Activation) { // Hand over from the model of the last primary page. this.loaderIds = this.lastPrimaryPageModel.loaderIds; for (const [loaderId, prev] of this.lastPrimaryPageModel.documents.entries()) { this.ensureDocumentPreloadingData(loaderId); this.documents.get(loaderId)?.mergePrevious(prev); } } this.lastPrimaryPageModel = null; // Note that at this timing ResourceTreeFrame.loaderId is ensured to // be non empty and Protocol.Network.LoaderId because it is filled // by ResourceTreeFrame.navigate. const currentLoaderId = frame.loaderId as Protocol.Network.LoaderId; // Holds histories for two pages at most. this.loaderIds.push(currentLoaderId); this.loaderIds = this.loaderIds.slice(-2); this.ensureDocumentPreloadingData(currentLoaderId); for (const loaderId of this.documents.keys()) { if (!this.loaderIds.includes(loaderId)) { this.documents.delete(loaderId); } } this.dispatchEventToListeners(Events.ModelUpdated); } onRuleSetUpdated(event: Protocol.Preload.RuleSetUpdatedEvent): void { const ruleSet = event.ruleSet; const loaderId = ruleSet.loaderId; // Infer current loaderId if DevTools is opned at the current page. if (this.currentLoaderId() === null) { this.loaderIds = [loaderId]; this.targetJustAttached = false; } this.ensureDocumentPreloadingData(loaderId); this.documents.get(loaderId)?.ruleSets.upsert(ruleSet); this.dispatchEventToListeners(Events.ModelUpdated); } onRuleSetRemoved(event: Protocol.Preload.RuleSetRemovedEvent): void { const id = event.id; for (const document of this.documents.values()) { document.ruleSets.delete(id); } this.dispatchEventToListeners(Events.ModelUpdated); } onPreloadingAttemptSourcesUpdated(event: Protocol.Preload.PreloadingAttemptSourcesUpdatedEvent): void { const loaderId = event.loaderId; this.ensureDocumentPreloadingData(loaderId); const document = this.documents.get(loaderId); if (document === undefined) { return; } document.sources.update(event.preloadingAttemptSources); document.preloadingAttempts.maybeRegisterNotTriggered(document.sources); this.dispatchEventToListeners(Events.ModelUpdated); } onPrefetchStatusUpdated(event: Protocol.Preload.PrefetchStatusUpdatedEvent): void { const loaderId = event.key.loaderId; this.ensureDocumentPreloadingData(loaderId); const attempt: PrefetchAttemptInternal = { action: Protocol.Preload.SpeculationAction.Prefetch, key: event.key, status: convertPreloadingStatus(event.status), prefetchStatus: event.prefetchStatus || null, }; this.documents.get(loaderId)?.preloadingAttempts.upsert(attempt); this.dispatchEventToListeners(Events.ModelUpdated); } onPrerenderStatusUpdated(event: Protocol.Preload.PrerenderStatusUpdatedEvent): void { const loaderId = event.key.loaderId; this.ensureDocumentPreloadingData(loaderId); const attempt: PrerenderAttemptInternal = { action: Protocol.Preload.SpeculationAction.Prerender, key: event.key, status: convertPreloadingStatus(event.status), prerenderStatus: event.prerenderStatus || null, }; this.documents.get(loaderId)?.preloadingAttempts.upsert(attempt); this.dispatchEventToListeners(Events.ModelUpdated); } private async getFeatureFlags(): Promise<FeatureFlags> { const preloadingHoldbackPromise = this.target().systemInfo().invoke_getFeatureState({ featureState: 'PreloadingHoldback', }); const prerender2HoldbackPromise = this.target().systemInfo().invoke_getFeatureState({ featureState: 'PrerenderHoldback', }); return { preloadingHoldback: (await preloadingHoldbackPromise).featureEnabled, prerender2Holdback: (await prerender2HoldbackPromise).featureEnabled, }; } async onPreloadEnabledStateUpdated(event: Protocol.Preload.PreloadEnabledStateUpdatedEvent): Promise<void> { const featureFlags = await this.getFeatureFlagsPromise; const warnings = { featureFlagPreloadingHoldback: featureFlags.preloadingHoldback, featureFlagPrerender2Holdback: featureFlags.prerender2Holdback, ...event, }; this.dispatchEventToListeners(Events.WarningsUpdated, warnings); } } SDKModel.register(PreloadingModel, {capabilities: Capability.DOM, autostart: false}); // TODO(crbug.com/1167717): Make this a const enum again // eslint-disable-next-line rulesdir/const_enum export enum Events { ModelUpdated = 'ModelUpdated', WarningsUpdated = 'WarningsUpdated', } export type EventTypes = { [Events.ModelUpdated]: void, [Events.WarningsUpdated]: PreloadWarnings, }; class PreloadDispatcher implements ProtocolProxyApi.PreloadDispatcher { private model: PreloadingModel; constructor(model: PreloadingModel) { this.model = model; } ruleSetUpdated(event: Protocol.Preload.RuleSetUpdatedEvent): void { this.model.onRuleSetUpdated(event); } ruleSetRemoved(event: Protocol.Preload.RuleSetRemovedEvent): void { this.model.onRuleSetRemoved(event); } preloadingAttemptSourcesUpdated(event: Protocol.Preload.PreloadingAttemptSourcesUpdatedEvent): void { this.model.onPreloadingAttemptSourcesUpdated(event); } prefetchStatusUpdated(event: Protocol.Preload.PrefetchStatusUpdatedEvent): void { this.model.onPrefetchStatusUpdated(event); } prerenderAttemptCompleted(_: Protocol.Preload.PrerenderAttemptCompletedEvent): void { } prerenderStatusUpdated(event: Protocol.Preload.PrerenderStatusUpdatedEvent): void { this.model.onPrerenderStatusUpdated(event); } preloadEnabledStateUpdated(event: Protocol.Preload.PreloadEnabledStateUpdatedEvent): void { void this.model.onPreloadEnabledStateUpdated(event); } } class DocumentPreloadingData { ruleSets: RuleSetRegistry = new RuleSetRegistry(); preloadingAttempts: PreloadingAttemptRegistry = new PreloadingAttemptRegistry(); sources: SourceRegistry = new SourceRegistry(); mergePrevious(prev: DocumentPreloadingData): void { // Note that CDP events Preload.ruleSetUpdated/Deleted and // Preload.preloadingAttemptSourcesUpdated with a loaderId are emitted to target that bounded to // a document with the loaderId. On the other hand, prerendering activation changes targets // of Preload.prefetch/prerenderStatusUpdated, i.e. activated page receives those events for // triggering outcome "Success". if (!this.ruleSets.isEmpty() || !this.sources.isEmpty()) { throw new Error('unreachable'); } this.ruleSets = prev.ruleSets; this.preloadingAttempts.mergePrevious(prev.preloadingAttempts); this.sources = prev.sources; } } class RuleSetRegistry { private map: Map<Protocol.Preload.RuleSetId, Protocol.Preload.RuleSet> = new Map<Protocol.Preload.RuleSetId, Protocol.Preload.RuleSet>(); isEmpty(): boolean { return this.map.size === 0; } // Returns reference. Don't save returned values. // Returned values may or may not be updated as the time grows. getById(id: Protocol.Preload.RuleSetId): Protocol.Preload.RuleSet|null { return this.map.get(id) || null; } // Returns reference. Don't save returned values. // Returned values may or may not be updated as the time grows. getAll(): WithId<Protocol.Preload.RuleSetId, Protocol.Preload.RuleSet>[] { return Array.from(this.map.entries()).map(([id, value]) => ({id, value})); } upsert(ruleSet: Protocol.Preload.RuleSet): void { this.map.set(ruleSet.id, ruleSet); } delete(id: Protocol.Preload.RuleSetId): void { this.map.delete(id); } } // Protocol.Preload.PreloadingStatus|'NotTriggered' // // A renderer sends SpeculationCandidate to the browser process and the // browser process checks eligibilities, and starts PreloadingAttempt. // // In the frontend, "NotTriggered" is used to denote that a // PreloadingAttempt is waiting for at trigger event (eg: // mousedown/mouseover). All PreloadingAttempts will start off as // "NotTriggered", but "eager" preloading attempts (attempts not // actually waiting for any trigger) will be processed by the browser // immediately, and will not stay in this state for long. // // TODO(https://crbug.com/1384419): Add NotEligible. export const enum PreloadingStatus { NotTriggered = 'NotTriggered', Pending = 'Pending', Running = 'Running', Ready = 'Ready', Success = 'Success', Failure = 'Failure', NotSupported = 'NotSupported', } function convertPreloadingStatus(status: Protocol.Preload.PreloadingStatus): PreloadingStatus { switch (status) { case Protocol.Preload.PreloadingStatus.Pending: return PreloadingStatus.Pending; case Protocol.Preload.PreloadingStatus.Running: return PreloadingStatus.Running; case Protocol.Preload.PreloadingStatus.Ready: return PreloadingStatus.Ready; case Protocol.Preload.PreloadingStatus.Success: return PreloadingStatus.Success; case Protocol.Preload.PreloadingStatus.Failure: return PreloadingStatus.Failure; case Protocol.Preload.PreloadingStatus.NotSupported: return PreloadingStatus.NotSupported; } throw new Error('unreachable'); } export type PreloadingAttemptId = string; export type PreloadingAttempt = PrefetchAttempt|PrerenderAttempt; export interface PrefetchAttempt { action: Protocol.Preload.SpeculationAction.Prefetch; key: Protocol.Preload.PreloadingAttemptKey; status: PreloadingStatus; prefetchStatus: Protocol.Preload.PrefetchStatus|null; ruleSetIds: Protocol.Preload.RuleSetId[]; nodeIds: Protocol.DOM.BackendNodeId[]; } export interface PrerenderAttempt { action: Protocol.Preload.SpeculationAction.Prerender; key: Protocol.Preload.PreloadingAttemptKey; status: PreloadingStatus; prerenderStatus: Protocol.Preload.PrerenderFinalStatus|null; ruleSetIds: Protocol.Preload.RuleSetId[]; nodeIds: Protocol.DOM.BackendNodeId[]; } export type PreloadingAttemptInternal = PrefetchAttemptInternal|PrerenderAttemptInternal; export interface PrefetchAttemptInternal { action: Protocol.Preload.SpeculationAction.Prefetch; key: Protocol.Preload.PreloadingAttemptKey; status: PreloadingStatus; prefetchStatus: Protocol.Preload.PrefetchStatus|null; } export interface PrerenderAttemptInternal { action: Protocol.Preload.SpeculationAction.Prerender; key: Protocol.Preload.PreloadingAttemptKey; status: PreloadingStatus; prerenderStatus: Protocol.Preload.PrerenderFinalStatus|null; } function makePreloadingAttemptId(key: Protocol.Preload.PreloadingAttemptKey): PreloadingAttemptId { let action; switch (key.action) { case Protocol.Preload.SpeculationAction.Prefetch: action = 'Prefetch'; break; case Protocol.Preload.SpeculationAction.Prerender: action = 'Prerender'; break; } let targetHint; switch (key.targetHint) { case undefined: targetHint = 'undefined'; break; case Protocol.Preload.SpeculationTargetHint.Blank: targetHint = 'Blank'; break; case Protocol.Preload.SpeculationTargetHint.Self: targetHint = 'Self'; break; } return `${key.loaderId}:${action}:${key.url}:${targetHint}`; } class PreloadingAttemptRegistry { private map: Map<PreloadingAttemptId, PreloadingAttemptInternal> = new Map<PreloadingAttemptId, PreloadingAttemptInternal>(); private enrich(attempt: PreloadingAttemptInternal, source: Protocol.Preload.PreloadingAttemptSource|null): PreloadingAttempt { let ruleSetIds: Protocol.Preload.RuleSetId[] = []; let nodeIds: Protocol.DOM.BackendNodeId[] = []; if (source !== null) { ruleSetIds = source.ruleSetIds; nodeIds = source.nodeIds; } return { ...attempt, ruleSetIds, nodeIds, }; } // Returns reference. Don't save returned values. // Returned values may or may not be updated as the time grows. getById(id: PreloadingAttemptId, sources: SourceRegistry): PreloadingAttempt|null { const attempt = this.map.get(id) || null; if (attempt === null) { return null; } return this.enrich(attempt, sources.getById(id)); } // Returs preloading attempts that triggered by the rule set with `ruleSetId`. // `ruleSetId === null` means "do not filter". // // Returns reference. Don't save returned values. // Returned values may or may not be updated as the time grows. getAll(ruleSetId: Protocol.Preload.RuleSetId|null, sources: SourceRegistry): WithId<PreloadingAttemptId, PreloadingAttempt>[] { return [...this.map.entries()] .map(([id, value]) => ({id, value: this.enrich(value, sources.getById(id))})) .filter(({value}) => !ruleSetId || value.ruleSetIds.includes(ruleSetId)); } upsert(attempt: PreloadingAttemptInternal): void { const id = makePreloadingAttemptId(attempt.key); this.map.set(id, attempt); } maybeRegisterNotTriggered(sources: SourceRegistry): void { for (const [id, {key}] of sources.entries()) { if (this.map.get(id) !== undefined) { continue; } let attempt: PreloadingAttemptInternal; switch (key.action) { case Protocol.Preload.SpeculationAction.Prefetch: attempt = { action: Protocol.Preload.SpeculationAction.Prefetch, key, status: PreloadingStatus.NotTriggered, prefetchStatus: null, }; break; case Protocol.Preload.SpeculationAction.Prerender: attempt = { action: Protocol.Preload.SpeculationAction.Prerender, key, status: PreloadingStatus.NotTriggered, prerenderStatus: null, }; break; } this.map.set(id, attempt); } } mergePrevious(prev: PreloadingAttemptRegistry): void { for (const [id, attempt] of this.map.entries()) { prev.map.set(id, attempt); } this.map = prev.map; } } class SourceRegistry { private map: Map<PreloadingAttemptId, Protocol.Preload.PreloadingAttemptSource> = new Map<PreloadingAttemptId, Protocol.Preload.PreloadingAttemptSource>(); entries(): IterableIterator<[PreloadingAttemptId, Protocol.Preload.PreloadingAttemptSource]> { return this.map.entries(); } isEmpty(): boolean { return this.map.size === 0; } getById(id: PreloadingAttemptId): Protocol.Preload.PreloadingAttemptSource|null { return this.map.get(id) || null; } update(sources: Protocol.Preload.PreloadingAttemptSource[]): void { this.map = new Map(sources.map(s => [makePreloadingAttemptId(s.key), s])); } } interface FeatureFlags { preloadingHoldback: boolean; prerender2Holdback: boolean; } export interface PreloadWarnings { featureFlagPreloadingHoldback: boolean; featureFlagPrerender2Holdback: boolean; disabledByPreference: boolean; disabledByDataSaver: boolean; disabledByBatterySaver: boolean; }