// Copyright 2021 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import '../../../ui/components/expandable_list/expandable_list.js'; import '../../../ui/components/report_view/report_view.js'; import '../../../ui/legacy/legacy.js'; import '../../../ui/kit/kit.js'; import * as Common from '../../../core/common/common.js'; import * as i18n from '../../../core/i18n/i18n.js'; import type * as Platform from '../../../core/platform/platform.js'; import * as SDK from '../../../core/sdk/sdk.js'; import * as Protocol from '../../../generated/protocol.js'; import * as Buttons from '../../../ui/components/buttons/buttons.js'; import type * as ExpandableList from '../../../ui/components/expandable_list/expandable_list.js'; import type * as ReportView from '../../../ui/components/report_view/report_view.js'; import * as Components from '../../../ui/legacy/components/utils/utils.js'; import * as UI from '../../../ui/legacy/legacy.js'; import {html, type LitTemplate, nothing, render, type TemplateResult} from '../../../ui/lit/lit.js'; import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js'; import {NotRestoredReasonDescription} from './BackForwardCacheStrings.js'; import backForwardCacheViewStyles from './backForwardCacheView.css.js'; const UIStrings = { /** * @description Title text in back/forward cache view of the Application panel */ mainFrame: 'Main Frame', /** * @description Title text in back/forward cache view of the Application panel */ backForwardCacheTitle: 'Back/forward cache', /** * @description Status text for the status of the main frame */ unavailable: 'unavailable', /** * @description Entry name text in the back/forward cache view of the Application panel */ url: 'URL', /** * @description Status text for the status of the back/forward cache status */ unknown: 'Unknown Status', /** * @description Status text for the status of the back/forward cache status indicating that * the back/forward cache was not used and a normal navigation occurred instead. */ normalNavigation: 'Not served from back/forward cache: to trigger back/forward cache, use Chrome\'s back/forward buttons, or use the test button below to automatically navigate away and back.', /** * @description Status text for the status of the back/forward cache status indicating that * the back/forward cache was used to restore the page instead of reloading it. */ restoredFromBFCache: 'Successfully served from back/forward cache.', /** * @description Label for a list of reasons which prevent the page from being eligible for * back/forward cache. These reasons are actionable i.e. they can be cleaned up to make the * page eligible for back/forward cache. */ pageSupportNeeded: 'Actionable', /** * @description Label for the completion of the back/forward cache test */ testCompleted: 'Back/forward cache test completed.', /** * @description Explanation for actionable items which prevent the page from being eligible * for back/forward cache. */ pageSupportNeededExplanation: 'These reasons are actionable i.e. they can be cleaned up to make the page eligible for back/forward cache.', /** * @description Label for a list of reasons which prevent the page from being eligible for * back/forward cache. These reasons are circumstantial / not actionable i.e. they cannot be * cleaned up by developers to make the page eligible for back/forward cache. */ circumstantial: 'Not Actionable', /** * @description Explanation for circumstantial/non-actionable items which prevent the page from being eligible * for back/forward cache. */ circumstantialExplanation: 'These reasons are not actionable i.e. caching was prevented by something outside of the direct control of the page.', /** * @description Label for a list of reasons which prevent the page from being eligible for * back/forward cache. These reasons are pending support by chrome i.e. in a future version * of chrome they will not prevent back/forward cache usage anymore. */ supportPending: 'Pending Support', /** * @description Label for the button to test whether BFCache is available for the page */ runTest: 'Test back/forward cache', /** * @description Label for the disabled button while the test is running */ runningTest: 'Running test', /** * @description Link Text about explanation of back/forward cache */ learnMore: 'Learn more: back/forward cache eligibility', /** * @description Link Text about unload handler */ neverUseUnload: 'Learn more: Never use unload handler', /** * @description Explanation for 'pending support' items which prevent the page from being eligible * for back/forward cache. */ supportPendingExplanation: 'Chrome support for these reasons is pending i.e. they will not prevent the page from being eligible for back/forward cache in a future version of Chrome.', /** * @description Text that precedes displaying a link to the extension which blocked the page from being eligible for back/forward cache. */ blockingExtensionId: 'Extension id: ', /** * @description Label for the 'Frames' section of the back/forward cache view, which shows a frame tree of the * page with reasons why the frames can't be cached. */ framesTitle: 'Frames', /** * @description Top level summary of the total number of issues found in a single frame. */ issuesInSingleFrame: '{n, plural, =1 {# issue found in 1 frame.} other {# issues found in 1 frame.}}', /** * @description Top level summary of the total number of issues found and the number of frames they were found in. * 'm' is never less than 2. * @example {3} m */ issuesInMultipleFrames: '{n, plural, =1 {# issue found in {m} frames.} other {# issues found in {m} frames.}}', /** * @description Shows the number of frames with a particular issue. */ framesPerIssue: '{n, plural, =1 {# frame} other {# frames}}', /** * @description Title for a frame in the frame tree that doesn't have a URL. Placeholder indicates which number frame with a blank URL it is. * @example {3} PH1 */ blankURLTitle: 'Blank URL [{PH1}]', /** * @description Shows the number of files with a particular issue. */ filesPerIssue: '{n, plural, =1 {# file} other {# files}}', } as const; const str_ = i18n.i18n.registerUIStrings('panels/application/components/BackForwardCacheView.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); const {widgetConfig} = UI.Widget; const enum ScreenStatusType { RUNNING = 'Running', RESULT = 'Result', } function renderMainFrameInformation( frame: SDK.ResourceTreeModel.ResourceTreeFrame|null, frameTreeData: {node: FrameTreeNodeData, frameCount: number, issueCount: number}|undefined, reasonToFramesMap: Map, screenStatus: ScreenStatusType, navigateAwayAndBack: () => Promise): TemplateResult { if (!frame) { // clang-format of return html` ${i18nString(UIStrings.mainFrame)} ${i18nString(UIStrings.unavailable)} `; // clang-format on } const isTestRunning = (screenStatus === ScreenStatusType.RUNNING); // Prevent running BFCache test on the DevTools window itself via DevTools on DevTools const isTestingForbidden = Common.ParsedURL.schemeIs(frame.url, 'devtools:'); // clang-format off return html` ${renderBackForwardCacheStatus(frame.backForwardCacheDetails.restoredFromCache)} ${i18nString(UIStrings.url)} ${frame.url} ${maybeRenderFrameTree(frameTreeData)} ${isTestRunning ? html` ${i18nString(UIStrings.runningTest)}`:` ${i18nString(UIStrings.runTest)} `} ${maybeRenderExplanations(frame.backForwardCacheDetails.explanations, frame.backForwardCacheDetails.explanationsTree, reasonToFramesMap)} ${i18nString(UIStrings.learnMore)} `; // clang-format on } function maybeRenderFrameTree( frameTreeData: {node: FrameTreeNodeData, frameCount: number, issueCount: number}|undefined): LitTemplate { if (!frameTreeData || (frameTreeData.frameCount === 0 && frameTreeData.issueCount === 0)) { return nothing; } function renderFrameTreeNode(node: FrameTreeNodeData): TemplateResult { // clang-format off return html`
  • ${node.iconName ? html` ` : nothing} ${node.text} ${node.children?.length ? html`
      ${node.children.map(child => renderFrameTreeNode(child))}
    ` : nothing}
  • `; // clang-format on } let title = ''; // The translation pipeline does not support nested plurals. We avoid this // here by pulling out the logic for one of the plurals into code instead. if (frameTreeData.frameCount === 1) { title = i18nString(UIStrings.issuesInSingleFrame, {n: frameTreeData.issueCount}); } else { title = i18nString(UIStrings.issuesInMultipleFrames, {n: frameTreeData.issueCount, m: frameTreeData.frameCount}); } // clang-format off return html` ${i18nString(UIStrings.framesTitle)}
  • ${title}
      ${renderFrameTreeNode(frameTreeData.node)}
  • `}>
    `; // clang-format on } function renderBackForwardCacheStatus(status: boolean|undefined): TemplateResult { switch (status) { case true: // clang-format off return html`
    ${i18nString(UIStrings.restoredFromBFCache)}
    `; // clang-format on case false: // clang-format off return html`
    ${i18nString(UIStrings.normalNavigation)}
    `; // clang-format on } // clang-format off return html` ${i18nString(UIStrings.unknown)} `; // clang-format on } function maybeRenderExplanations( explanations: Protocol.Page.BackForwardCacheNotRestoredExplanation[], explanationTree: Protocol.Page.BackForwardCacheNotRestoredExplanationTree|undefined, reasonToFramesMap: Map): LitTemplate { if (explanations.length === 0) { return nothing; } const pageSupportNeeded = explanations.filter( explanation => explanation.type === Protocol.Page.BackForwardCacheNotRestoredReasonType.PageSupportNeeded); const supportPending = explanations.filter( explanation => explanation.type === Protocol.Page.BackForwardCacheNotRestoredReasonType.SupportPending); const circumstantial = explanations.filter( explanation => explanation.type === Protocol.Page.BackForwardCacheNotRestoredReasonType.Circumstantial); // Disabled until https://crbug.com/1079231 is fixed. // clang-format off return html` ${renderExplanations(i18nString(UIStrings.pageSupportNeeded), i18nString(UIStrings.pageSupportNeededExplanation), pageSupportNeeded, reasonToFramesMap)} ${renderExplanations(i18nString(UIStrings.supportPending), i18nString(UIStrings.supportPendingExplanation), supportPending, reasonToFramesMap)} ${renderExplanations(i18nString(UIStrings.circumstantial), i18nString(UIStrings.circumstantialExplanation), circumstantial, reasonToFramesMap)}`; // clang-format on } function renderExplanations( category: Platform.UIString.LocalizedString, explainerText: Platform.UIString.LocalizedString, explanations: Protocol.Page.BackForwardCacheNotRestoredExplanation[], reasonToFramesMap: Map): TemplateResult { // Disabled until https://crbug.com/1079231 is fixed. // clang-format off return html` ${explanations.length > 0 ? html` ${category}
    ${explanations.map(explanation => renderReason(explanation, reasonToFramesMap.get(explanation.reason)))} ` : nothing}`; // clang-format on } function maybeRenderReasonContext(explanation: Protocol.Page.BackForwardCacheNotRestoredExplanation): LitTemplate { if (explanation.reason === Protocol.Page.BackForwardCacheNotRestoredReason.EmbedderExtensionSentMessageToCachedFrame && explanation.context) { const link = 'chrome://extensions/?id=' + explanation.context as Platform.DevToolsPath.UrlString; // clang-format off return html`${i18nString(UIStrings.blockingExtensionId)} ${explanation.context}`; // clang-format on } return nothing; } function renderFramesPerReason(frames: string[]|undefined): LitTemplate { if (frames === undefined || frames.length === 0) { return nothing; } const rows = [html`
    ${i18nString(UIStrings.framesPerIssue, {n: frames.length})}
    `]; rows.push(...frames.map(url => html`
    ${url}
    `)); return html`
    `; } function maybeRenderDeepLinkToUnload(explanation: Protocol.Page.BackForwardCacheNotRestoredExplanation): LitTemplate { if (explanation.reason === Protocol.Page.BackForwardCacheNotRestoredReason.UnloadHandlerExistsInMainFrame || explanation.reason === Protocol.Page.BackForwardCacheNotRestoredReason.UnloadHandlerExistsInSubFrame) { return html` ${i18nString(UIStrings.neverUseUnload)} `; } return nothing; } function maybeRenderJavaScriptDetails(details: Protocol.Page.BackForwardCacheBlockingDetails[]|undefined): LitTemplate { if (details === undefined || details.length === 0) { return nothing; } const maxLengthForDisplayedURLs = 50; const rows = [html`
    ${i18nString(UIStrings.filesPerIssue, {n: details.length})}
    `]; rows.push(...details.map(detail => html` `)); return html`
    `; } function renderReason( explanation: Protocol.Page.BackForwardCacheNotRestoredExplanation, frames: string[]|undefined): TemplateResult { // clang-format off return html` ${(explanation.reason in NotRestoredReasonDescription) ? html`
    ${NotRestoredReasonDescription[explanation.reason].name()} ${maybeRenderDeepLinkToUnload(explanation)} ${maybeRenderReasonContext(explanation)}
    ` : nothing}
    ${explanation.reason}
    ${maybeRenderJavaScriptDetails(explanation.details)} ${renderFramesPerReason(frames)}`; // clang-format on } interface ViewInput { frame: SDK.ResourceTreeModel.ResourceTreeFrame|null; frameTreeData: {node: FrameTreeNodeData, frameCount: number, issueCount: number}|undefined; reasonToFramesMap: Map; screenStatus: ScreenStatusType; navigateAwayAndBack: () => Promise; } type View = (input: ViewInput, output: undefined, target: HTMLElement) => void; const DEFAULT_VIEW: View = (input, output, target) => { // Disabled until https://crbug.com/1079231 is fixed. // clang-format off render(html` ${renderMainFrameInformation(input.frame, input.frameTreeData, input.reasonToFramesMap, input.screenStatus, input.navigateAwayAndBack)} `, target); // clang-format on }; export class BackForwardCacheView extends UI.Widget.Widget { #screenStatus = ScreenStatusType.RESULT; #historyIndex = 0; #view: View; constructor(view = DEFAULT_VIEW) { super({useShadowDom: true, delegatesFocus: true}); this.#view = view; this.#getMainResourceTreeModel()?.addEventListener( SDK.ResourceTreeModel.Events.PrimaryPageChanged, this.requestUpdate, this); this.#getMainResourceTreeModel()?.addEventListener( SDK.ResourceTreeModel.Events.BackForwardCacheDetailsUpdated, this.requestUpdate, this); this.requestUpdate(); } #getMainResourceTreeModel(): SDK.ResourceTreeModel.ResourceTreeModel|null { const mainTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); return mainTarget?.model(SDK.ResourceTreeModel.ResourceTreeModel) || null; } #getMainFrame(): SDK.ResourceTreeModel.ResourceTreeFrame|null { return this.#getMainResourceTreeModel()?.mainFrame || null; } override async performUpdate(): Promise { const reasonToFramesMap = new Map(); const frame = this.#getMainFrame(); const explanationTree = frame?.backForwardCacheDetails?.explanationsTree; if (explanationTree) { this.#buildReasonToFramesMap(explanationTree, {blankCount: 1}, reasonToFramesMap); } const frameTreeData = this.#buildFrameTreeDataRecursive(explanationTree, {blankCount: 1}); // Override the icon for the outermost frame. frameTreeData.node.iconName = 'frame'; const viewInput: ViewInput = { frame, frameTreeData, reasonToFramesMap, screenStatus: this.#screenStatus, navigateAwayAndBack: this.#navigateAwayAndBack.bind(this), }; this.#view(viewInput, undefined, this.contentElement); } #renderBackForwardCacheTestResult(): void { SDK.TargetManager.TargetManager.instance().removeModelListener( SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.FrameNavigated, this.#renderBackForwardCacheTestResult, this); this.#screenStatus = ScreenStatusType.RESULT; this.requestUpdate(); void this.updateComplete.then(() => { UI.ARIAUtils.LiveAnnouncer.alert(i18nString(UIStrings.testCompleted)); this.contentElement.focus(); }); } async #onNavigatedAway(): Promise { SDK.TargetManager.TargetManager.instance().removeModelListener( SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.FrameNavigated, this.#onNavigatedAway, this); await this.#waitAndGoBackInHistory(50); } async #waitAndGoBackInHistory(delay: number): Promise { const mainTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); const resourceTreeModel = mainTarget?.model(SDK.ResourceTreeModel.ResourceTreeModel); const historyResults = await resourceTreeModel?.navigationHistory(); if (!resourceTreeModel || !historyResults) { return; } // The navigation history can be delayed. If this is the case we wait and // check again later. Otherwise it would be possible to press the 'Test // BFCache' button again too soon, leading to the browser stepping back in // history without returning to the correct page. if (historyResults.currentIndex === this.#historyIndex) { window.setTimeout(this.#waitAndGoBackInHistory.bind(this, delay * 2), delay); } else { SDK.TargetManager.TargetManager.instance().addModelListener( SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.FrameNavigated, this.#renderBackForwardCacheTestResult, this); resourceTreeModel.navigateToHistoryEntry(historyResults.entries[historyResults.currentIndex - 1]); } } async #navigateAwayAndBack(): Promise { // Checking BFCache Compatibility const mainTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); const resourceTreeModel = mainTarget?.model(SDK.ResourceTreeModel.ResourceTreeModel); const historyResults = await resourceTreeModel?.navigationHistory(); if (!resourceTreeModel || !historyResults) { return; } this.#historyIndex = historyResults.currentIndex; this.#screenStatus = ScreenStatusType.RUNNING; this.requestUpdate(); // This event listener is removed inside of onNavigatedAway(). SDK.TargetManager.TargetManager.instance().addModelListener( SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.FrameNavigated, this.#onNavigatedAway, this); // We can know whether the current page can use BFCache // as the browser navigates to another unrelated page and goes back to the current page. // We chose "chrome://terms" because it must be cross-site. // Ideally, We want to have our own testing page like "chrome: //bfcache-test". void resourceTreeModel.navigate('chrome://terms' as Platform.DevToolsPath.UrlString); } // Builds a subtree of the frame tree, conaining only frames with BFCache issues and their ancestors. // Returns the root node, the number of frames in the subtree, and the number of issues in the subtree. #buildFrameTreeDataRecursive( explanationTree: Protocol.Page.BackForwardCacheNotRestoredExplanationTree|undefined, nextBlankURLCount: {blankCount: number}): {node: FrameTreeNodeData, frameCount: number, issueCount: number} { if (!explanationTree) { return {node: {text: ''}, frameCount: 0, issueCount: 0}; } let frameCount = 1; let issueCount = 0; const children: FrameTreeNodeData[] = []; let nodeUrlText = ''; if (explanationTree.url.length) { nodeUrlText = explanationTree.url; } else { nodeUrlText = i18nString(UIStrings.blankURLTitle, {PH1: nextBlankURLCount.blankCount}); nextBlankURLCount.blankCount += 1; } for (const explanation of explanationTree.explanations) { const child = {text: explanation.reason}; issueCount += 1; children.push(child); } for (const child of explanationTree.children) { const frameTreeData = this.#buildFrameTreeDataRecursive(child, nextBlankURLCount); if (frameTreeData.issueCount > 0) { children.push(frameTreeData.node); issueCount += frameTreeData.issueCount; frameCount += frameTreeData.frameCount; } } let node: FrameTreeNodeData = { text: `(${issueCount}) ${nodeUrlText}`, }; if (children.length) { node = {...node, children}; node.iconName = 'iframe'; } else if (!explanationTree.url.length) { // If the current node increased the blank count, but it has no children and // is therefore not shown, decrement the blank count again. nextBlankURLCount.blankCount -= 1; } return {node, frameCount, issueCount}; } #buildReasonToFramesMap( explanationTree: Protocol.Page.BackForwardCacheNotRestoredExplanationTree, nextBlankURLCount: {blankCount: number}, outputMap: Map): void { let url = explanationTree.url; if (url.length === 0) { url = i18nString(UIStrings.blankURLTitle, {PH1: nextBlankURLCount.blankCount}); nextBlankURLCount.blankCount += 1; } explanationTree.explanations.forEach(explanation => { let frames: string[]|undefined = outputMap.get(explanation.reason); if (frames === undefined) { frames = [url]; outputMap.set(explanation.reason, frames); } else { frames.push(url); } }); explanationTree.children.map(child => { this.#buildReasonToFramesMap(child, nextBlankURLCount, outputMap); }); } } interface FrameTreeNodeData { text: string; iconName?: string; children?: FrameTreeNodeData[]; }