mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-28 15:23:51 +00:00
Bug 1546984
- Add scroll restoration to about:addons r=mstriemer
- Attach scroll offsets to history entries in about:addons. - For other views, start at the top of the page, regardless of the scroll position of the (likely unrelated) previous view. Differential Revision: https://phabricator.services.mozilla.com/D44826 --HG-- extra : moz-landing-system : lando
This commit is contained in:
parent
f52aaf9b27
commit
04e816fcac
@ -1396,6 +1396,11 @@ class AddonDetails extends HTMLElement {
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// When a details view is rendered again, the default details view is
|
||||
// unconditionally shown. So if any other tab is selected, do not save
|
||||
// the current scroll offset, but start at the top of the page instead.
|
||||
ScrollOffsets.canRestore = this.deck.selectedViewName === "details";
|
||||
}
|
||||
}
|
||||
|
||||
@ -3167,6 +3172,40 @@ function getTelemetryViewName(el) {
|
||||
return el.closest("[current-view]").getAttribute("current-view");
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for saving and restoring the scroll offsets when a previously loaded
|
||||
* view is accessed again.
|
||||
*/
|
||||
var ScrollOffsets = {
|
||||
_key: null,
|
||||
_offsets: new Map(),
|
||||
canRestore: true,
|
||||
|
||||
setView(historyEntryId) {
|
||||
this._key = historyEntryId;
|
||||
this.canRestore = true;
|
||||
},
|
||||
|
||||
getPosition() {
|
||||
if (!this.canRestore) {
|
||||
return { top: 0, left: 0 };
|
||||
}
|
||||
let { scrollTop: top, scrollLeft: left } = document.documentElement;
|
||||
return { top, left };
|
||||
},
|
||||
|
||||
save() {
|
||||
if (this._key) {
|
||||
this._offsets.set(this._key, this.getPosition());
|
||||
}
|
||||
},
|
||||
|
||||
restore() {
|
||||
let { top = 0, left = 0 } = this._offsets.get(this._key) || {};
|
||||
window.scrollTo({ top, left, behavior: "auto" });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Called from extensions.js once, when about:addons is loading.
|
||||
*/
|
||||
@ -3193,7 +3232,7 @@ function initialize(opts) {
|
||||
* resolve once the view has been updated to conform with other about:addons
|
||||
* views.
|
||||
*/
|
||||
async function show(type, param, { isKeyboardNavigation }) {
|
||||
async function show(type, param, { isKeyboardNavigation, historyEntryId }) {
|
||||
let container = document.createElement("div");
|
||||
container.setAttribute("current-view", type);
|
||||
if (type == "list") {
|
||||
@ -3222,10 +3261,26 @@ async function show(type, param, { isKeyboardNavigation }) {
|
||||
} else {
|
||||
throw new Error(`Unknown view type: ${type}`);
|
||||
}
|
||||
|
||||
ScrollOffsets.save();
|
||||
ScrollOffsets.setView(historyEntryId);
|
||||
mainEl.textContent = "";
|
||||
mainEl.appendChild(container);
|
||||
|
||||
// Most content has been rendered at this point. The only exception are
|
||||
// recommendations in the discovery pane and extension/theme list, because
|
||||
// they rely on remote data. If loaded before, then these may be rendered
|
||||
// within one tick, so wait a frame before restoring scroll offsets.
|
||||
return new Promise(resolve => {
|
||||
window.requestAnimationFrame(() => {
|
||||
ScrollOffsets.restore();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function hide() {
|
||||
ScrollOffsets.save();
|
||||
ScrollOffsets.setView(null);
|
||||
mainEl.textContent = "";
|
||||
}
|
||||
|
@ -654,6 +654,11 @@ var gViewController = {
|
||||
currentViewId: "",
|
||||
currentViewObj: null,
|
||||
currentViewRequest: 0,
|
||||
// All historyEntryId values must be unique within one session, because the
|
||||
// IDs are used to map history entries to page state. It is not possible to
|
||||
// see whether a historyEntryId was used in history entries before this page
|
||||
// was loaded, so start counting from a random value to avoid collisions.
|
||||
nextHistoryEntryId: Math.floor(Math.random() * 2 ** 32),
|
||||
viewObjects: {},
|
||||
viewChangeCallback: null,
|
||||
initialViewSelected: false,
|
||||
@ -766,6 +771,7 @@ var gViewController = {
|
||||
var state = {
|
||||
view: aViewId,
|
||||
previousView: this.currentViewId,
|
||||
historyEntryId: ++this.nextHistoryEntryId,
|
||||
isKeyboardNavigation,
|
||||
};
|
||||
if (!isRefresh) {
|
||||
@ -785,6 +791,7 @@ var gViewController = {
|
||||
var state = {
|
||||
view: aViewId,
|
||||
previousView: null,
|
||||
historyEntryId: ++this.nextHistoryEntryId,
|
||||
};
|
||||
gHistory.replaceState(state);
|
||||
this.loadViewInternal(aViewId, null, state);
|
||||
@ -794,6 +801,7 @@ var gViewController = {
|
||||
var state = {
|
||||
view: aViewId,
|
||||
previousView: null,
|
||||
historyEntryId: ++this.nextHistoryEntryId,
|
||||
};
|
||||
gHistory.replaceState(state);
|
||||
|
||||
|
@ -83,6 +83,7 @@ skip-if = true # Bug 1449071 - Frequent failures
|
||||
skip-if = (os == 'win' && processor == 'aarch64') # aarch64 has no plugin support, bug 1525174 and 1547495
|
||||
[browser_html_recent_updates.js]
|
||||
[browser_html_recommendations.js]
|
||||
[browser_html_scroll_restoration.js]
|
||||
[browser_html_updates.js]
|
||||
[browser_html_warning_messages.js]
|
||||
[browser_installssl.js]
|
||||
|
@ -0,0 +1,226 @@
|
||||
/* eslint max-len: ["error", 80] */
|
||||
"use strict";
|
||||
|
||||
const { AddonTestUtils } = ChromeUtils.import(
|
||||
"resource://testing-common/AddonTestUtils.jsm"
|
||||
);
|
||||
|
||||
AddonTestUtils.initMochitest(this);
|
||||
const server = AddonTestUtils.createHttpServer();
|
||||
const TEST_API_URL = `http://localhost:${server.identity.primaryPort}/discoapi`;
|
||||
|
||||
const EXT_ID_EXTENSION = "extension@example.com";
|
||||
const EXT_ID_THEME = "theme@example.com";
|
||||
|
||||
let requestCount = 0;
|
||||
server.registerPathHandler("/discoapi", (request, response) => {
|
||||
// This test is expected to load the results only once, and then cache the
|
||||
// results.
|
||||
is(++requestCount, 1, "Expect only one discoapi request");
|
||||
|
||||
let results = {
|
||||
results: [
|
||||
{
|
||||
addon: {
|
||||
authors: [{ name: "Some author" }],
|
||||
current_version: {
|
||||
files: [{ platform: "all", url: "data:," }],
|
||||
},
|
||||
url: "data:,",
|
||||
guid: "recommendation@example.com",
|
||||
type: "extension",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
response.write(JSON.stringify(results));
|
||||
});
|
||||
|
||||
add_task(async function setup() {
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [["extensions.getAddons.discovery.api_url", TEST_API_URL]],
|
||||
});
|
||||
|
||||
let mockProvider = new MockProvider();
|
||||
mockProvider.createAddons([
|
||||
{
|
||||
id: EXT_ID_EXTENSION,
|
||||
name: "Mock 1",
|
||||
type: "extension",
|
||||
userPermissions: {
|
||||
origins: ["<all_urls>"],
|
||||
permissions: ["tabs"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: EXT_ID_THEME,
|
||||
name: "Mock 2",
|
||||
type: "theme",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
async function switchToView(win, type, param = "") {
|
||||
let loaded = waitForViewLoad(win);
|
||||
win.managerWindow.gViewController.loadView(`addons://${type}/${param}`);
|
||||
await loaded;
|
||||
await waitForStableLayout(win);
|
||||
}
|
||||
|
||||
// delta = -1 = go back.
|
||||
// delta = +1 = go forwards.
|
||||
async function historyGo(win, delta, expectedViewType) {
|
||||
let loaded = waitForViewLoad(win);
|
||||
win.managerWindow.history.go(delta);
|
||||
await loaded;
|
||||
is(
|
||||
win.managerWindow.gViewController.currentViewId,
|
||||
expectedViewType,
|
||||
"Expected view after history navigation"
|
||||
);
|
||||
await waitForStableLayout(win);
|
||||
}
|
||||
|
||||
async function waitForStableLayout(win) {
|
||||
// In the test, it is important that the layout is fully stable before we
|
||||
// consider the view loaded, because those affect the offset calculations.
|
||||
await TestUtils.waitForCondition(
|
||||
() => isLayoutStable(win),
|
||||
"Waiting for layout to stabilize"
|
||||
);
|
||||
}
|
||||
|
||||
function isLayoutStable(win) {
|
||||
// <message-bar> elements may affect the layout of a page, and therefore we
|
||||
// should check whether its embedded style sheet has finished loading.
|
||||
for (let bar of win.document.querySelectorAll("message-bar")) {
|
||||
// Check for the existence of a CSS property from message-bar.css.
|
||||
if (!win.getComputedStyle(bar).getPropertyValue("--message-bar-icon-url")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getAddonCard(win, addonId) {
|
||||
return win.document.querySelector(`addon-card[addon-id="${addonId}"]`);
|
||||
}
|
||||
|
||||
function getScrollOffset(win) {
|
||||
let { scrollTop: top, scrollLeft: left } = win.document.documentElement;
|
||||
return { top, left };
|
||||
}
|
||||
|
||||
// Scroll an element into view. The purpose of this is to simulate a real-world
|
||||
// scenario where the user has moved part of the UI is in the viewport.
|
||||
function scrollTopLeftIntoView(elem) {
|
||||
elem.scrollIntoView({ block: "start", inline: "start" });
|
||||
// Sanity check: In this test, a large padding has been added to the top and
|
||||
// left of the document. So when an element has been scrolled into view, the
|
||||
// top and left offsets must be non-zero.
|
||||
assertNonZeroScrollOffsets(getScrollOffset(elem.ownerGlobal));
|
||||
}
|
||||
|
||||
function assertNonZeroScrollOffsets(offsets) {
|
||||
ok(offsets.left, "Should have scrolled to the right");
|
||||
ok(offsets.top, "Should have scrolled down");
|
||||
}
|
||||
|
||||
function checkScrollOffset(win, expected, msg = "") {
|
||||
let actual = getScrollOffset(win);
|
||||
is(actual.top, expected.top, `Top scroll offset - ${msg}`);
|
||||
is(actual.left, expected.left, `Left scroll offset - ${msg}`);
|
||||
}
|
||||
|
||||
add_task(async function test_scroll_restoration() {
|
||||
let win = await loadInitialView("discover");
|
||||
|
||||
// Wait until the recommendations have been loaded. These are cached after
|
||||
// the first load, so we only need to wait once, at the start of the test.
|
||||
await win.document.querySelector("recommended-addon-list").cardsReady;
|
||||
|
||||
// Force scrollbar to appear, by adding enough space at the top and left.
|
||||
win.document.body.style.paddingTop = "200vh";
|
||||
win.document.body.style.paddingLeft = "100vw";
|
||||
win.document.body.style.width = "200vw";
|
||||
|
||||
checkScrollOffset(win, { top: 0, left: 0 }, "initial page load");
|
||||
|
||||
scrollTopLeftIntoView(win.document.querySelector("recommended-addon-card"));
|
||||
let discoOffsets = getScrollOffset(win);
|
||||
assertNonZeroScrollOffsets(discoOffsets);
|
||||
|
||||
// Switch from disco pane to extension list
|
||||
|
||||
await switchToView(win, "list", "extension");
|
||||
checkScrollOffset(win, { top: 0, left: 0 }, "initial extension list");
|
||||
|
||||
scrollTopLeftIntoView(getAddonCard(win, EXT_ID_EXTENSION));
|
||||
let extListOffsets = getScrollOffset(win);
|
||||
assertNonZeroScrollOffsets(extListOffsets);
|
||||
|
||||
// Switch from extension list to details view.
|
||||
|
||||
let loaded = waitForViewLoad(win);
|
||||
getAddonCard(win, EXT_ID_EXTENSION).click();
|
||||
await loaded;
|
||||
|
||||
checkScrollOffset(win, { top: 0, left: 0 }, "initial details view");
|
||||
scrollTopLeftIntoView(getAddonCard(win, EXT_ID_EXTENSION));
|
||||
let detailsOffsets = getScrollOffset(win);
|
||||
assertNonZeroScrollOffsets(detailsOffsets);
|
||||
|
||||
// Switch from details view back to extension list.
|
||||
|
||||
await historyGo(win, -1, "addons://list/extension");
|
||||
checkScrollOffset(win, extListOffsets, "back to extension list");
|
||||
|
||||
// Now scroll to the bottom-right corner, so we can check whether the scroll
|
||||
// offset is correctly restored when the extension view is loaded, even when
|
||||
// the recommendations are loaded after the initial render.
|
||||
ok(
|
||||
win.document.querySelector("recommended-addon-card"),
|
||||
"Recommendations have already been loaded"
|
||||
);
|
||||
win.document.body.scrollIntoView({ block: "end", inline: "end" });
|
||||
extListOffsets = getScrollOffset(win);
|
||||
assertNonZeroScrollOffsets(extListOffsets);
|
||||
|
||||
// Switch back from the extension list to the details view.
|
||||
|
||||
await historyGo(win, +1, `addons://detail/${EXT_ID_EXTENSION}`);
|
||||
checkScrollOffset(win, detailsOffsets, "details view with default tab");
|
||||
|
||||
// Switch from the default details tab to the permissions tab.
|
||||
// (this does not change the history).
|
||||
win.document.querySelector("named-deck-button[name='permissions']").click();
|
||||
|
||||
// Switch back from the details view to the extension list.
|
||||
|
||||
await historyGo(win, -1, "addons://list/extension");
|
||||
checkScrollOffset(win, extListOffsets, "bottom-right of extension list");
|
||||
ok(
|
||||
win.document.querySelector("recommended-addon-card"),
|
||||
"Recommendations should have been loaded again"
|
||||
);
|
||||
|
||||
// Switch back from extension list to the details view.
|
||||
|
||||
await historyGo(win, +1, `addons://detail/${EXT_ID_EXTENSION}`);
|
||||
// Scroll offsets are not remembered for the details view, because at the
|
||||
// time of leaving the details view, the non-default tab was selected.
|
||||
checkScrollOffset(win, { top: 0, left: 0 }, "details view, non-default tab");
|
||||
|
||||
// Switch back from the details view to the disco pane.
|
||||
|
||||
await historyGo(win, -2, "addons://discover/");
|
||||
checkScrollOffset(win, discoOffsets, "after switching back to disco pane");
|
||||
|
||||
// Switch from disco pane to theme list.
|
||||
|
||||
// Verifies that the extension list and theme lists are independent.
|
||||
await switchToView(win, "list", "theme");
|
||||
checkScrollOffset(win, { top: 0, left: 0 }, "initial theme list");
|
||||
|
||||
await closeView(win);
|
||||
});
|
Loading…
Reference in New Issue
Block a user