| // Copyright 2022 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 DataGrid from '../../../ui/components/data_grid/data_grid.js'; |
| |
| import * as ChromeLink from '../../../ui/components/chrome_link/chrome_link.js'; |
| import * as i18n from '../../../core/i18n/i18n.js'; |
| import * as UI from '../../../ui/legacy/legacy.js'; |
| import * as Protocol from '../../../generated/protocol.js'; |
| import * as SDK from '../../../core/sdk/sdk.js'; |
| |
| import * as PreloadingComponents from './components/components.js'; |
| |
| // eslint-disable-next-line rulesdir/es_modules_import |
| import emptyWidgetStyles from '../../../ui/legacy/emptyWidget.css.js'; |
| import preloadingViewStyles from './preloadingView.css.js'; |
| |
| const UIStrings = { |
| /** |
| *@description Checkbox: If rule set is selected in rule set grid, filters preloading attempts in preloading attempts grid. |
| */ |
| checkboxFilterBySelectedRuleSet: 'Filter by selected rule set', |
| /** |
| *@description Text in grid: Rule set is valid |
| */ |
| validityValid: 'Valid', |
| /** |
| *@description Text in grid: Rule set must be a valid JSON object |
| */ |
| validityInvalid: 'Invalid', |
| /** |
| *@description Text in grid: Rule set contains invalid rules and they are ignored |
| */ |
| validitySomeRulesInvalid: 'Some rules invalid', |
| /** |
| *@description Text in grid and details: Preloading attempt is not yet triggered. |
| */ |
| statusNotTriggered: 'Not triggered', |
| /** |
| *@description Text in grid and details: Preloading attempt is eligible but pending. |
| */ |
| statusPending: 'Pending', |
| /** |
| *@description Text in grid and details: Preloading is running. |
| */ |
| statusRunning: 'Running', |
| /** |
| *@description Text in grid and details: Preloading finished and the result is ready for the next navigation. |
| */ |
| statusReady: 'Ready', |
| /** |
| *@description Text in grid and details: Ready, then used. |
| */ |
| statusSuccess: 'Success', |
| /** |
| *@description Text in grid and details: Preloading failed. |
| */ |
| statusFailure: 'Failure', |
| /** |
| *@description Title in infobar |
| */ |
| warningTitlePreloadingDisabledByFeatureFlag: 'Preloading was disabled, but is force-enabled now', |
| /** |
| *@description Detail in infobar |
| */ |
| warningDetailPreloadingDisabledByFeatureFlag: |
| 'Preloading is forced-enabled because DevTools is open. When DevTools is closed, prerendering will be disabled because this browser session is part of a holdback group used for performance comparisons.', |
| /** |
| *@description Title in infobar |
| */ |
| warningTitlePrerenderingDisabledByFeatureFlag: 'Prerendering was disabled, but is force-enabled now', |
| /** |
| *@description Detail in infobar |
| */ |
| warningDetailPrerenderingDisabledByFeatureFlag: |
| 'Prerendering is forced-enabled because DevTools is open. When DevTools is closed, prerendering will be disabled because this browser session is part of a holdback group used for performance comparisons.', |
| /** |
| *@description Title of preloading state disabled warning in infobar |
| */ |
| warningTitlePreloadingStateDisabled: 'Preloading is disabled', |
| /** |
| *@description Detail of preloading state disabled warning in infobar |
| *@example {chrome://settings/preloading} PH1 |
| *@example {chrome://extensions} PH2 |
| */ |
| warningDetailPreloadingStateDisabled: |
| 'Preloading is disabled because of user settings or an extension. Go to {PH1} to learn more, or go to {PH2} to disable the extension.', |
| /** |
| *@description Title in infobar when preloading is disabled by data saver. |
| */ |
| warningTitlePreloadingDisabledByDatasaver: 'Preloading is disabled', |
| /** |
| *@description Detail in infobar when preloading is disabled by data saver. |
| */ |
| warningDetailPreloadingDisabledByDatasaver: |
| 'Preloading is disabled because of the operating system\'s Data Saver mode.', |
| /** |
| *@description Title in infobar when preloading is disabled by battery saver. |
| */ |
| warningTitlePreloadingDisabledByBatterysaver: 'Preloading is disabled', |
| /** |
| *@description Detail in infobar when preloading is disabled by data saver. |
| */ |
| warningDetailPreloadingDisabledByBatterysaver: |
| 'Preloading is disabled because of the operating system\'s Battery Saver mode.', |
| /** |
| *@description Text of Preload pages settings |
| */ |
| preloadingPageSettings: 'Preload pages settings', |
| /** |
| *@description Text of Extension settings |
| */ |
| extensionSettings: 'Extensions settings', |
| }; |
| const str_ = i18n.i18n.registerUIStrings('panels/application/preloading/PreloadingView.ts', UIStrings); |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); |
| |
| class PreloadingUIUtils { |
| static action({key}: SDK.PreloadingModel.PreloadingAttempt): string { |
| // Use "prefetch"/"prerender" as is in SpeculationRules. |
| switch (key.action) { |
| case Protocol.Preload.SpeculationAction.Prefetch: |
| return i18n.i18n.lockedString('prefetch'); |
| case Protocol.Preload.SpeculationAction.Prerender: |
| return i18n.i18n.lockedString('prerender'); |
| } |
| } |
| |
| static status({status}: SDK.PreloadingModel.PreloadingAttempt): string { |
| // See content/public/browser/preloading.h PreloadingAttemptOutcome. |
| switch (status) { |
| case SDK.PreloadingModel.PreloadingStatus.NotTriggered: |
| return i18nString(UIStrings.statusNotTriggered); |
| case SDK.PreloadingModel.PreloadingStatus.Pending: |
| return i18nString(UIStrings.statusPending); |
| case SDK.PreloadingModel.PreloadingStatus.Running: |
| return i18nString(UIStrings.statusRunning); |
| case SDK.PreloadingModel.PreloadingStatus.Ready: |
| return i18nString(UIStrings.statusReady); |
| case SDK.PreloadingModel.PreloadingStatus.Success: |
| return i18nString(UIStrings.statusSuccess); |
| case SDK.PreloadingModel.PreloadingStatus.Failure: |
| return i18nString(UIStrings.statusFailure); |
| // NotSupported is used to handle unreachable case. For example, |
| // there is no code path for |
| // PreloadingTriggeringOutcome::kTriggeredButPending in prefetch, |
| // which is mapped to NotSupported. So, we regard it as an |
| // internal error. |
| case SDK.PreloadingModel.PreloadingStatus.NotSupported: |
| return i18n.i18n.lockedString('Internal error'); |
| } |
| } |
| |
| // Summary of error of rule set shown in grid. |
| static validity({errorType}: Protocol.Preload.RuleSet): string { |
| switch (errorType) { |
| case undefined: |
| return i18nString(UIStrings.validityValid); |
| case Protocol.Preload.RuleSetErrorType.SourceIsNotJsonObject: |
| return i18nString(UIStrings.validityInvalid); |
| case Protocol.Preload.RuleSetErrorType.InvalidRulesSkipped: |
| return i18nString(UIStrings.validitySomeRulesInvalid); |
| } |
| } |
| |
| // Where a rule set came from, shown in grid. |
| static location(ruleSet: Protocol.Preload.RuleSet): string { |
| if (ruleSet.backendNodeId !== undefined) { |
| return i18n.i18n.lockedString('<script>'); |
| } |
| |
| if (ruleSet.url !== undefined) { |
| return ruleSet.url; |
| } |
| |
| throw Error('unreachable'); |
| } |
| } |
| |
| interface FeatureFlags { |
| preloadingHoldback: boolean|null; |
| prerender2Holdback: boolean|null; |
| } |
| |
| // Holds PreloadingModel of current context |
| // |
| // There can be multiple Targets and PreloadingModels and they switch as |
| // time goes. For example: |
| // |
| // - Prerendering started and a user switched context with |
| // ExecutionContextSelector. This switching is bidirectional. |
| // - Prerendered page is activated. This switching is unidirectional. |
| // |
| // Context switching is managed by scoped target. This class handles |
| // switching events and holds PreloadingModel of current context. |
| // |
| // Note that switching at the timing of activation triggers handing over |
| // from the old model to the new model. See |
| // PreloadingMoedl.onPrimaryPageChanged. |
| class PreloadingModelProxy implements SDK.TargetManager.SDKModelObserver<SDK.PreloadingModel.PreloadingModel> { |
| private readonly view: PreloadingView; |
| model: SDK.PreloadingModel.PreloadingModel; |
| |
| constructor(view: PreloadingView, model: SDK.PreloadingModel.PreloadingModel) { |
| this.view = view; |
| |
| this.model = model; |
| this.model.addEventListener(SDK.PreloadingModel.Events.ModelUpdated, this.view.render, this.view); |
| } |
| |
| initialize(): void { |
| SDK.TargetManager.TargetManager.instance().observeModels(SDK.PreloadingModel.PreloadingModel, this, {scoped: true}); |
| } |
| |
| modelAdded(model: SDK.PreloadingModel.PreloadingModel): void { |
| // Ignore models/targets of non-outermost frames like iframe/FencedFrames. |
| if (model.target().outermostTarget() !== model.target()) { |
| return; |
| } |
| |
| this.model.removeEventListener(SDK.PreloadingModel.Events.ModelUpdated, this.view.render, this.view); |
| this.model = model; |
| this.model.addEventListener(SDK.PreloadingModel.Events.ModelUpdated, this.view.render, this.view); |
| |
| this.view.render(); |
| } |
| |
| modelRemoved(_model: SDK.PreloadingModel.PreloadingModel): void { |
| this.model.removeEventListener(SDK.PreloadingModel.Events.ModelUpdated, this.view.render, this.view); |
| } |
| } |
| |
| export class PreloadingView extends UI.Widget.VBox { |
| private readonly modelProxy: PreloadingModelProxy; |
| private focusedRuleSetId: Protocol.Preload.RuleSetId|null = null; |
| private focusedPreloadingAttemptId: SDK.PreloadingModel.PreloadingAttemptId|null = null; |
| private checkboxFilterBySelectedRuleSet: UI.Toolbar.ToolbarCheckbox; |
| |
| private readonly infobarContainer: HTMLDivElement; |
| private readonly hsplitUsedPreloading: UI.SplitWidget.SplitWidget; |
| private readonly hsplit: UI.SplitWidget.SplitWidget; |
| private readonly vsplitRuleSets: UI.SplitWidget.SplitWidget; |
| private readonly ruleSetGrid = new PreloadingComponents.RuleSetGrid.RuleSetGrid(); |
| private readonly ruleSetDetails = new PreloadingComponents.RuleSetDetailsReportView.RuleSetDetailsReportView(); |
| private readonly preloadingGrid = new PreloadingComponents.PreloadingGrid.PreloadingGrid(); |
| private readonly preloadingDetails = |
| new PreloadingComponents.PreloadingDetailsReportView.PreloadingDetailsReportView(); |
| private readonly usedPreloading = new PreloadingComponents.UsedPreloadingView.UsedPreloadingView(); |
| private readonly featureFlagWarningsPromise: Promise<void>; |
| |
| constructor(model: SDK.PreloadingModel.PreloadingModel) { |
| super(/* isWebComponent */ true, /* delegatesFocus */ false); |
| |
| this.modelProxy = new PreloadingModelProxy(this, model); |
| |
| // this (VBox) |
| // +- infobarContainer |
| // +- hsplitUsedPreloading |
| // +- hsplit |
| // +- vsplitRuleSets |
| // +- leftContainer |
| // +- RuleSetGrid |
| // +- rightContainer |
| // +- RuleSetDetailsReportView |
| // +- VBox |
| // +- toolbar (filtering) |
| // +- vsplitPreloadingAttempts |
| // +- leftContainer |
| // +- PreloadingGrid |
| // +- rightContainer |
| // +- PreloadingDetailsReportView |
| // +- VBox |
| // + UsedPreloadingReportView |
| // |
| // - If an row of RuleSetGrid selected, RuleSetDetailsReportView shows details of it. |
| // - If not, RuleSetDetailsReportView hides. |
| // |
| // - If an row of PreloadingGrid selected, PreloadingDetailsReportView shows details of it. |
| // - If not, PreloadingDetailsReportView shows some messages. |
| |
| this.infobarContainer = document.createElement('div'); |
| this.infobarContainer.classList.add('flex-none'); |
| this.contentElement.insertBefore(this.infobarContainer, this.contentElement.firstChild); |
| |
| this.ruleSetGrid.addEventListener('cellfocused', this.onRuleSetsGridCellFocused.bind(this)); |
| this.vsplitRuleSets = this.makeVsplit(this.ruleSetGrid, this.ruleSetDetails); |
| |
| const preloadingAttempts = new UI.Widget.VBox(); |
| |
| const toolbar = new UI.Toolbar.Toolbar('preloading-toolbar', preloadingAttempts.contentElement); |
| this.checkboxFilterBySelectedRuleSet = new UI.Toolbar.ToolbarCheckbox( |
| i18nString(UIStrings.checkboxFilterBySelectedRuleSet), /* tooltip? */ undefined, this.render.bind(this)); |
| this.checkboxFilterBySelectedRuleSet.setChecked(true); |
| toolbar.appendToolbarItem(this.checkboxFilterBySelectedRuleSet); |
| |
| this.preloadingGrid.addEventListener('cellfocused', this.onPreloadingGridCellFocused.bind(this)); |
| const vsplitPreloadingAttempts = this.makeVsplit(this.preloadingGrid, this.preloadingDetails); |
| vsplitPreloadingAttempts.show(preloadingAttempts.contentElement); |
| |
| this.hsplit = new UI.SplitWidget.SplitWidget( |
| /* isVertical */ false, |
| /* secondIsSidebar */ false, |
| /* settingName */ undefined, |
| /* defaultSidebarWidth */ undefined, |
| /* defaultSidebarHeight */ 200, |
| /* constraintsInDip */ undefined, |
| ); |
| this.hsplit.setSidebarWidget(this.vsplitRuleSets); |
| this.hsplit.setMainWidget(preloadingAttempts); |
| |
| const usedPreloadingContainer = new UI.Widget.VBox(); |
| usedPreloadingContainer.contentElement.appendChild(this.usedPreloading); |
| this.hsplitUsedPreloading = new UI.SplitWidget.SplitWidget( |
| /* isVertical */ false, |
| /* secondIsSidebar */ true, |
| /* settingName */ undefined, |
| /* defaultSidebarWidth */ undefined, |
| /* defaultSidebarHeight */ 50, |
| /* constraintsInDip */ undefined, |
| ); |
| this.hsplitUsedPreloading.setMainWidget(this.hsplit); |
| this.hsplitUsedPreloading.setSidebarWidget(usedPreloadingContainer); |
| |
| this.featureFlagWarningsPromise = this.getFeatureFlags().then(x => this.onGetFeatureFlags(x)); |
| } |
| |
| private makeVsplit(left: HTMLElement, right: HTMLElement): UI.SplitWidget.SplitWidget { |
| const leftContainer = new UI.Widget.VBox(); |
| leftContainer.setMinimumSize(0, 40); |
| leftContainer.contentElement.classList.add('overflow-auto'); |
| leftContainer.contentElement.appendChild(left); |
| |
| const rightContainer = new UI.Widget.VBox(); |
| rightContainer.setMinimumSize(0, 80); |
| rightContainer.contentElement.classList.add('overflow-auto'); |
| rightContainer.contentElement.appendChild(right); |
| |
| const vsplit = new UI.SplitWidget.SplitWidget( |
| /* isVertical */ true, |
| /* secondIsSidebar */ true, |
| /* settingName */ undefined, |
| /* defaultSidebarWidth */ 400, |
| /* defaultSidebarHeight */ undefined, |
| /* constraintsInDip */ undefined, |
| ); |
| vsplit.setMainWidget(leftContainer); |
| vsplit.setSidebarWidget(rightContainer); |
| |
| return vsplit; |
| } |
| |
| override wasShown(): void { |
| super.wasShown(); |
| |
| this.registerCSSFiles([emptyWidgetStyles, preloadingViewStyles]); |
| |
| this.hsplitUsedPreloading.show(this.contentElement); |
| |
| // Lazily initialize PreloadingModelProxy because this triggers a chain |
| // |
| // PreloadingModelProxy.initialize() |
| // -> TargetManager.observeModels() |
| // -> PreloadingModelProxy.modelAdded() |
| // -> PreloadingView.render() |
| // |
| // , and PreloadingView.onModelAdded() requires all members are |
| // initialized. So, here is the best timing. |
| this.modelProxy.initialize(); |
| } |
| |
| private updateRuleSetDetails(): void { |
| const id = this.focusedRuleSetId; |
| const ruleSet = id === null ? null : this.modelProxy.model.getRuleSetById(id); |
| this.ruleSetDetails.data = ruleSet; |
| |
| if (ruleSet === null) { |
| this.vsplitRuleSets.hideSidebar(); |
| } else { |
| this.vsplitRuleSets.showBoth(); |
| } |
| } |
| |
| private updatePreloadingDetails(): void { |
| const id = this.focusedPreloadingAttemptId; |
| const preloadingAttempt = id === null ? null : this.modelProxy.model.getPreloadingAttemptById(id); |
| if (preloadingAttempt === null) { |
| this.preloadingDetails.data = null; |
| } else { |
| const ruleSets = |
| preloadingAttempt.ruleSetIds.map(id => this.modelProxy.model.getRuleSetById(id)).filter(x => x !== null) as |
| Protocol.Preload.RuleSet[]; |
| this.preloadingDetails.data = { |
| preloadingAttempt, |
| ruleSets, |
| }; |
| } |
| // TODO(crbug.com/1384419): Add more information in PreloadEnabledState from |
| // backend to distinguish the details of the reasons why preloading is |
| // disabled. |
| switch (this.modelProxy.model.getPreloadEnabledState()) { |
| case Protocol.Preload.PreloadEnabledState.DisabledByPreference: { |
| const preloadingSettingLink = new ChromeLink.ChromeLink.ChromeLink(); |
| preloadingSettingLink.href = 'chrome://settings/cookies'; |
| preloadingSettingLink.textContent = i18nString(UIStrings.preloadingPageSettings); |
| const extensionSettingLink = new ChromeLink.ChromeLink.ChromeLink(); |
| extensionSettingLink.href = 'chrome://extensions'; |
| extensionSettingLink.textContent = i18nString(UIStrings.extensionSettings); |
| const detailsMessage = i18n.i18n.getFormatLocalizedString( |
| str_, UIStrings.warningDetailPreloadingStateDisabled, |
| {PH1: preloadingSettingLink, PH2: extensionSettingLink}); |
| this.showInfobar(i18nString(UIStrings.warningTitlePreloadingStateDisabled), detailsMessage); |
| break; |
| } |
| case Protocol.Preload.PreloadEnabledState.DisabledByDataSaver: { |
| this.showInfobar( |
| i18nString(UIStrings.warningTitlePreloadingDisabledByDatasaver), |
| i18nString(UIStrings.warningDetailPreloadingDisabledByDatasaver)); |
| break; |
| } |
| case Protocol.Preload.PreloadEnabledState.DisabledByBatterySaver: { |
| this.showInfobar( |
| i18nString(UIStrings.warningTitlePreloadingDisabledByBatterysaver), |
| i18nString(UIStrings.warningDetailPreloadingDisabledByBatterysaver)); |
| break; |
| } |
| default: |
| break; |
| } |
| } |
| |
| render(): void { |
| // Update rule sets grid |
| // |
| // Currently, all rule sets that appear in DevTools are valid. |
| // TODO(https://crbug.com/1384419): Add property `validity` to the CDP. |
| const ruleSetRows = this.modelProxy.model.getAllRuleSets().map(({id, value}) => ({ |
| id, |
| validity: PreloadingUIUtils.validity(value), |
| location: PreloadingUIUtils.location(value), |
| })); |
| this.ruleSetGrid.update(ruleSetRows); |
| |
| this.updateRuleSetDetails(); |
| |
| // Update preloaidng grid |
| const filteringRuleSetId = this.checkboxFilterBySelectedRuleSet.checked() ? this.focusedRuleSetId : null; |
| const preloadingAttemptRows = |
| this.modelProxy.model.getPreloadingAttempts(filteringRuleSetId).map(({id, value}) => ({ |
| id, |
| action: PreloadingUIUtils.action(value), |
| url: value.key.url, |
| status: PreloadingUIUtils.status(value), |
| })); |
| this.preloadingGrid.update(preloadingAttemptRows); |
| |
| this.updatePreloadingDetails(); |
| |
| this.usedPreloading.data = this.modelProxy.model.getPreloadingAttemptsOfPreviousPage().map(({value}) => value); |
| } |
| |
| private onRuleSetsGridCellFocused(event: Event): void { |
| const focusedEvent = event as DataGrid.DataGridEvents.BodyCellFocusedEvent; |
| this.focusedRuleSetId = |
| focusedEvent.data.row.cells.find(cell => cell.columnId === 'id')?.value as Protocol.Preload.RuleSetId; |
| this.render(); |
| } |
| |
| private onPreloadingGridCellFocused(event: Event): void { |
| const focusedEvent = event as DataGrid.DataGridEvents.BodyCellFocusedEvent; |
| this.focusedPreloadingAttemptId = focusedEvent.data.row.cells.find(cell => cell.columnId === 'id')?.value as |
| SDK.PreloadingModel.PreloadingAttemptId; |
| this.render(); |
| } |
| |
| async getFeatureFlags(): Promise<FeatureFlags> { |
| const preloadingHoldbackPromise = this.modelProxy.model.target().systemInfo().invoke_getFeatureState({ |
| featureState: 'PreloadingHoldback', |
| }); |
| const prerender2HoldbackPromise = this.modelProxy.model.target().systemInfo().invoke_getFeatureState({ |
| featureState: 'PrerenderHoldback', |
| }); |
| return { |
| preloadingHoldback: (await preloadingHoldbackPromise).featureEnabled ?? null, |
| prerender2Holdback: (await prerender2HoldbackPromise).featureEnabled ?? null, |
| }; |
| } |
| |
| // Shows warnings if features are disabled by feature flags. |
| private onGetFeatureFlags(flags: FeatureFlags): void { |
| if (flags.preloadingHoldback === true) { |
| this.showInfobar( |
| i18nString(UIStrings.warningTitlePreloadingDisabledByFeatureFlag), |
| i18nString(UIStrings.warningDetailPreloadingDisabledByFeatureFlag)); |
| } |
| |
| if (flags.prerender2Holdback === true) { |
| this.showInfobar( |
| i18nString(UIStrings.warningTitlePrerenderingDisabledByFeatureFlag), |
| i18nString(UIStrings.warningDetailPrerenderingDisabledByFeatureFlag)); |
| } |
| } |
| |
| private showInfobar(titleText: string, detailsMessage: string|Element): void { |
| const infobar = new UI.Infobar.Infobar( |
| UI.Infobar.Type.Warning, /* text */ titleText, /* actions? */ undefined, /* disableSetting? */ undefined); |
| infobar.setParentView(this); |
| infobar.createDetailsRowMessage(detailsMessage); |
| this.infobarContainer.appendChild(infobar.element); |
| } |
| |
| getInfobarContainerForTest(): HTMLDivElement { |
| return this.infobarContainer; |
| } |
| |
| getRuleSetGridForTest(): PreloadingComponents.RuleSetGrid.RuleSetGrid { |
| return this.ruleSetGrid; |
| } |
| |
| getRuleSetDetailsForTest(): PreloadingComponents.RuleSetDetailsReportView.RuleSetDetailsReportView { |
| return this.ruleSetDetails; |
| } |
| |
| getPreloadingGridForTest(): PreloadingComponents.PreloadingGrid.PreloadingGrid { |
| return this.preloadingGrid; |
| } |
| |
| getPreloadingDetailsForTest(): PreloadingComponents.PreloadingDetailsReportView.PreloadingDetailsReportView { |
| return this.preloadingDetails; |
| } |
| |
| getUsedPreloadingForTest(): PreloadingComponents.UsedPreloadingView.UsedPreloadingView { |
| return this.usedPreloading; |
| } |
| |
| getFeatureFlagWarningsPromiseForTest(): Promise<void> { |
| return this.featureFlagWarningsPromise; |
| } |
| |
| setCheckboxFilterBySelectedRuleSetForTest(checked: boolean): void { |
| this.checkboxFilterBySelectedRuleSet.setChecked(checked); |
| this.render(); |
| } |
| } |