DEV: Introduce history-store service (#24486)
This commit extracts the storage part of the route-scroll-manager into a dedicated service. This provides a key/value store which will reset for each navigation, and restore previous values when the user uses the back/forward buttons in their browser.
This gives us a reliable replacement for the old `DiscourseRoute.isPoppedState` function, which would not work under all situations.
Previously reverted in e6370decfd
. This version has been significantly refactored, and includes an additional system spec for the issue we identified.
This commit is contained in:
parent
d0117ff6e3
commit
ed1dece517
|
@ -33,6 +33,7 @@ function entranceDate(dt, showTime) {
|
||||||
export default Component.extend(CleansUp, {
|
export default Component.extend(CleansUp, {
|
||||||
router: service(),
|
router: service(),
|
||||||
session: service(),
|
session: service(),
|
||||||
|
historyStore: service(),
|
||||||
elementId: "topic-entrance",
|
elementId: "topic-entrance",
|
||||||
classNameBindings: ["visible::hidden"],
|
classNameBindings: ["visible::hidden"],
|
||||||
topic: null,
|
topic: null,
|
||||||
|
@ -166,10 +167,7 @@ export default Component.extend(CleansUp, {
|
||||||
},
|
},
|
||||||
|
|
||||||
_jumpTo(destination) {
|
_jumpTo(destination) {
|
||||||
this.session.set("lastTopicIdViewed", {
|
this.historyStore.set("lastTopicIdViewed", this.topic.id);
|
||||||
topicId: this.topic.id,
|
|
||||||
historyUuid: this.router.location.getState?.().uuid,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.cleanUp();
|
this.cleanUp();
|
||||||
DiscourseURL.routeTo(destination);
|
DiscourseURL.routeTo(destination);
|
||||||
|
|
|
@ -37,14 +37,8 @@ export function showEntrance(e) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navigateToTopic(topic, href) {
|
export function navigateToTopic(topic, href) {
|
||||||
const owner = getOwner(this);
|
const historyStore = getOwner(this).lookup("service:history-store");
|
||||||
const router = owner.lookup("service:router");
|
historyStore.set("lastTopicIdViewed", topic.id);
|
||||||
const session = owner.lookup("service:session");
|
|
||||||
|
|
||||||
session.set("lastTopicIdViewed", {
|
|
||||||
topicId: topic.id,
|
|
||||||
historyUuid: router.location.getState?.().uuid,
|
|
||||||
});
|
|
||||||
|
|
||||||
DiscourseURL.routeTo(href || topic.get("url"));
|
DiscourseURL.routeTo(href || topic.get("url"));
|
||||||
return false;
|
return false;
|
||||||
|
@ -52,6 +46,7 @@ export function navigateToTopic(topic, href) {
|
||||||
|
|
||||||
export default Component.extend({
|
export default Component.extend({
|
||||||
router: service(),
|
router: service(),
|
||||||
|
historyStore: service(),
|
||||||
tagName: "tr",
|
tagName: "tr",
|
||||||
classNameBindings: [":topic-list-item", "unboundClassNames", "topic.visited"],
|
classNameBindings: [":topic-list-item", "unboundClassNames", "topic.visited"],
|
||||||
attributeBindings: ["data-topic-id", "role", "ariaLevel:aria-level"],
|
attributeBindings: ["data-topic-id", "role", "ariaLevel:aria-level"],
|
||||||
|
@ -346,15 +341,11 @@ export default Component.extend({
|
||||||
|
|
||||||
_highlightIfNeeded: on("didInsertElement", function () {
|
_highlightIfNeeded: on("didInsertElement", function () {
|
||||||
// highlight the last topic viewed
|
// highlight the last topic viewed
|
||||||
const lastViewedTopicInfo = this.session.get("lastTopicIdViewed");
|
const lastViewedTopicId = this.historyStore.get("lastTopicIdViewed");
|
||||||
|
const isLastViewedTopic = lastViewedTopicId === this.topic.id;
|
||||||
const isLastViewedTopic =
|
|
||||||
lastViewedTopicInfo?.topicId === this.topic.id &&
|
|
||||||
lastViewedTopicInfo?.historyUuid ===
|
|
||||||
this.router.location.getState?.().uuid;
|
|
||||||
|
|
||||||
if (isLastViewedTopic) {
|
if (isLastViewedTopic) {
|
||||||
this.session.set("lastTopicIdViewed", null);
|
this.historyStore.delete("lastTopicIdViewed");
|
||||||
this.highlight({ isLastViewedTopic: true });
|
this.highlight({ isLastViewedTopic: true });
|
||||||
} else if (this.get("topic.highlight")) {
|
} else if (this.get("topic.highlight")) {
|
||||||
// highlight new topics that have been loaded from the server or the one we just created
|
// highlight new topics that have been loaded from the server or the one we just created
|
||||||
|
|
|
@ -42,6 +42,7 @@ const ApplicationRoute = DiscourseRoute.extend({
|
||||||
siteSettings: service(),
|
siteSettings: service(),
|
||||||
clientErrorHandler: service(),
|
clientErrorHandler: service(),
|
||||||
login: service(),
|
login: service(),
|
||||||
|
historyStore: service(),
|
||||||
|
|
||||||
get isOnlyOneExternalLoginMethod() {
|
get isOnlyOneExternalLoginMethod() {
|
||||||
return (
|
return (
|
||||||
|
@ -63,6 +64,12 @@ const ApplicationRoute = DiscourseRoute.extend({
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@action
|
||||||
|
willResolveModel(transition) {
|
||||||
|
this.historyStore.willResolveModel(transition);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
toggleMobileView() {
|
toggleMobileView() {
|
||||||
mobile.toggleMobileView();
|
mobile.toggleMobileView();
|
||||||
|
|
|
@ -21,6 +21,7 @@ class AbstractCategoryRoute extends DiscourseRoute {
|
||||||
@service store;
|
@service store;
|
||||||
@service topicTrackingState;
|
@service topicTrackingState;
|
||||||
@service("search") searchService;
|
@service("search") searchService;
|
||||||
|
@service historyStore;
|
||||||
|
|
||||||
queryParams = queryParams;
|
queryParams = queryParams;
|
||||||
|
|
||||||
|
@ -86,7 +87,7 @@ class AbstractCategoryRoute extends DiscourseRoute {
|
||||||
|
|
||||||
async _retrieveTopicList(category, transition, modelParams) {
|
async _retrieveTopicList(category, transition, modelParams) {
|
||||||
const findOpts = filterQueryParams(modelParams, this.routeConfig);
|
const findOpts = filterQueryParams(modelParams, this.routeConfig);
|
||||||
const extras = { cached: this.isPoppedState(transition) };
|
const extras = { cached: this.historyStore.isPoppedState };
|
||||||
|
|
||||||
let listFilter = `c/${Category.slugFor(category)}/${category.id}`;
|
let listFilter = `c/${Category.slugFor(category)}/${category.id}`;
|
||||||
if (findOpts.no_subcategories) {
|
if (findOpts.no_subcategories) {
|
||||||
|
|
|
@ -98,17 +98,18 @@ class AbstractTopicRoute extends DiscourseRoute {
|
||||||
@service store;
|
@service store;
|
||||||
@service topicTrackingState;
|
@service topicTrackingState;
|
||||||
@service currentUser;
|
@service currentUser;
|
||||||
|
@service historyStore;
|
||||||
|
|
||||||
queryParams = queryParams;
|
queryParams = queryParams;
|
||||||
templateName = "discovery/list";
|
templateName = "discovery/list";
|
||||||
controllerName = "discovery/list";
|
controllerName = "discovery/list";
|
||||||
|
|
||||||
async model(data, transition) {
|
async model(data) {
|
||||||
// attempt to stop early cause we need this to be called before .sync
|
// attempt to stop early cause we need this to be called before .sync
|
||||||
this.screenTrack.stop();
|
this.screenTrack.stop();
|
||||||
|
|
||||||
const findOpts = filterQueryParams(data),
|
const findOpts = filterQueryParams(data),
|
||||||
findExtras = { cached: this.isPoppedState(transition) };
|
findExtras = { cached: this.historyStore.isPoppedState };
|
||||||
|
|
||||||
const topicListPromise = findTopicList(
|
const topicListPromise = findTopicList(
|
||||||
this.store,
|
this.store,
|
||||||
|
|
|
@ -65,13 +65,6 @@ const DiscourseRoute = Route.extend({
|
||||||
|
|
||||||
return user.id === this.currentUser.id;
|
return user.id === this.currentUser.id;
|
||||||
},
|
},
|
||||||
|
|
||||||
isPoppedState(transition) {
|
|
||||||
return (
|
|
||||||
!transition._discourse_intercepted &&
|
|
||||||
(!!transition.intent.url || !!transition.queryParamsOnly)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default DiscourseRoute;
|
export default DiscourseRoute;
|
||||||
|
|
|
@ -26,6 +26,7 @@ export default class TagShowRoute extends DiscourseRoute {
|
||||||
@service store;
|
@service store;
|
||||||
@service topicTrackingState;
|
@service topicTrackingState;
|
||||||
@service("search") searchService;
|
@service("search") searchService;
|
||||||
|
@service historyStore;
|
||||||
|
|
||||||
queryParams = queryParams;
|
queryParams = queryParams;
|
||||||
controllerName = "discovery/list";
|
controllerName = "discovery/list";
|
||||||
|
@ -119,7 +120,7 @@ export default class TagShowRoute extends DiscourseRoute {
|
||||||
filter,
|
filter,
|
||||||
filteredQueryParams,
|
filteredQueryParams,
|
||||||
{
|
{
|
||||||
cached: this.isPoppedState(transition),
|
cached: this.historyStore.isPoppedState,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
import $ from "jquery";
|
import $ from "jquery";
|
||||||
import { Promise } from "rsvp";
|
import { Promise } from "rsvp";
|
||||||
import { ajax } from "discourse/lib/ajax";
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
@ -6,6 +7,7 @@ import DiscourseRoute from "discourse/routes/discourse";
|
||||||
import I18n from "discourse-i18n";
|
import I18n from "discourse-i18n";
|
||||||
|
|
||||||
export default DiscourseRoute.extend({
|
export default DiscourseRoute.extend({
|
||||||
|
historyStore: service(),
|
||||||
templateName: "user/bookmarks",
|
templateName: "user/bookmarks",
|
||||||
|
|
||||||
queryParams: {
|
queryParams: {
|
||||||
|
@ -13,11 +15,11 @@ export default DiscourseRoute.extend({
|
||||||
q: { refreshModel: true },
|
q: { refreshModel: true },
|
||||||
},
|
},
|
||||||
|
|
||||||
model(params, transition) {
|
model(params) {
|
||||||
const controller = this.controllerFor("user-activity-bookmarks");
|
const controller = this.controllerFor("user-activity-bookmarks");
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.isPoppedState(transition) &&
|
this.historyStore.isPoppedState &&
|
||||||
this.session.bookmarksModel &&
|
this.session.bookmarksModel &&
|
||||||
this.session.bookmarksModel.searchTerm === params.q
|
this.session.bookmarksModel.searchTerm === params.q
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { DEBUG } from "@glimmer/env";
|
||||||
|
import Service, { inject as service } from "@ember/service";
|
||||||
|
import { TrackedMap } from "@ember-compat/tracked-built-ins";
|
||||||
|
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
|
||||||
|
import { isTesting } from "discourse-common/config/environment";
|
||||||
|
import { bind } from "discourse-common/utils/decorators";
|
||||||
|
|
||||||
|
const HISTORY_SIZE = 100;
|
||||||
|
const HISTORIC_KEY = Symbol("historic");
|
||||||
|
const HANDLED_TRANSITIONS = new WeakSet();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This service provides a key-value store which can store per-route information.
|
||||||
|
* When navigating 'back' via browser controls, the service will restore the data
|
||||||
|
* for the appropriate route.
|
||||||
|
*/
|
||||||
|
@disableImplicitInjections
|
||||||
|
export default class HistoryStore extends Service {
|
||||||
|
@service router;
|
||||||
|
|
||||||
|
#routeData = new Map();
|
||||||
|
#uuid;
|
||||||
|
#pendingStore;
|
||||||
|
|
||||||
|
get #currentStore() {
|
||||||
|
if (this.#pendingStore) {
|
||||||
|
return this.#pendingStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.#dataFor(this.#uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify if the current route was accessed via the browser back/forward buttons
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
get isPoppedState() {
|
||||||
|
return !!this.get(HISTORIC_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a value from the current route's key/value store
|
||||||
|
*/
|
||||||
|
get(key) {
|
||||||
|
return this.#currentStore.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a value in the current route's key/value store. Will persist for the lifetime
|
||||||
|
* of the route, and will be restored if the user navigates 'back' to the route.
|
||||||
|
*/
|
||||||
|
set(key, value) {
|
||||||
|
return this.#currentStore.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a value from the current route's key/value store
|
||||||
|
*/
|
||||||
|
delete(key) {
|
||||||
|
return this.#currentStore.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
#pruneOldData() {
|
||||||
|
while (this.#routeData.size > HISTORY_SIZE) {
|
||||||
|
// JS Map guarantees keys will be returned in insertion order
|
||||||
|
const oldestUUID = this.#routeData.keys().next().value;
|
||||||
|
this.#routeData.delete(oldestUUID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#dataFor(uuid) {
|
||||||
|
let data = this.#routeData.get(uuid);
|
||||||
|
if (data) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
data = new TrackedMap();
|
||||||
|
this.#routeData.set(uuid, data);
|
||||||
|
this.#pruneOldData();
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the Application route when its willResolveModel hook
|
||||||
|
* is triggered by the ember router. Unfortunately this hook is
|
||||||
|
* not available as an event on the router service.
|
||||||
|
*/
|
||||||
|
@bind
|
||||||
|
willResolveModel(transition) {
|
||||||
|
if (HANDLED_TRANSITIONS.has(transition)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
HANDLED_TRANSITIONS.add(transition);
|
||||||
|
|
||||||
|
if (DEBUG && isTesting()) {
|
||||||
|
// Can't use window.history in tests
|
||||||
|
this.#pendingStore = new TrackedMap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.set(HISTORIC_KEY, true);
|
||||||
|
|
||||||
|
let pendingStoreForThisTransition;
|
||||||
|
|
||||||
|
if (this.#uuid === window.history.state?.uuid) {
|
||||||
|
// A normal ember transition. The history uuid will only change **after** models are resolved.
|
||||||
|
// To allow routes to store data for the upcoming uuid, we set up a temporary data store
|
||||||
|
// and then persist it if/when the transition succeeds.
|
||||||
|
pendingStoreForThisTransition = new TrackedMap();
|
||||||
|
} else {
|
||||||
|
// A transition initiated by the browser back/forward buttons. We might already have some stored
|
||||||
|
// data for this route. If so, take a copy of it and use that as the pending store. As with normal transitions,
|
||||||
|
// it'll be persisted if/when the transition succeeds.
|
||||||
|
pendingStoreForThisTransition = new TrackedMap(
|
||||||
|
this.#dataFor(window.history.state?.uuid)?.entries()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#pendingStore = pendingStoreForThisTransition;
|
||||||
|
transition
|
||||||
|
.then(() => {
|
||||||
|
this.#uuid = window.history.state?.uuid;
|
||||||
|
this.#routeData.set(this.#uuid, this.#pendingStore);
|
||||||
|
this.#pruneOldData();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (pendingStoreForThisTransition === this.#pendingStore) {
|
||||||
|
this.#pendingStore = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,7 +4,7 @@ import { disableImplicitInjections } from "discourse/lib/implicit-injections";
|
||||||
import { isTesting } from "discourse-common/config/environment";
|
import { isTesting } from "discourse-common/config/environment";
|
||||||
import { bind } from "discourse-common/utils/decorators";
|
import { bind } from "discourse-common/utils/decorators";
|
||||||
|
|
||||||
const MAX_SCROLL_LOCATIONS = 100;
|
const STORE_KEY = Symbol("scroll-location");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This service is responsible for managing scroll position when transitioning.
|
* This service is responsible for managing scroll position when transitioning.
|
||||||
|
@ -18,9 +18,7 @@ const MAX_SCROLL_LOCATIONS = 100;
|
||||||
@disableImplicitInjections
|
@disableImplicitInjections
|
||||||
export default class RouteScrollManager extends Service {
|
export default class RouteScrollManager extends Service {
|
||||||
@service router;
|
@service router;
|
||||||
|
@service historyStore;
|
||||||
scrollLocationHistory = new Map();
|
|
||||||
uuid;
|
|
||||||
|
|
||||||
scrollElement = isTesting()
|
scrollElement = isTesting()
|
||||||
? document.getElementById("ember-testing-container")
|
? document.getElementById("ember-testing-container")
|
||||||
|
@ -28,14 +26,10 @@ export default class RouteScrollManager extends Service {
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
routeWillChange() {
|
routeWillChange() {
|
||||||
if (!this.uuid) {
|
this.historyStore.set(STORE_KEY, [
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.scrollLocationHistory.set(this.uuid, [
|
|
||||||
this.scrollElement.scrollLeft,
|
this.scrollElement.scrollLeft,
|
||||||
this.scrollElement.scrollTop,
|
this.scrollElement.scrollTop,
|
||||||
]);
|
]);
|
||||||
this.#pruneOldScrollLocations();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
|
@ -44,34 +38,16 @@ export default class RouteScrollManager extends Service {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newUuid = this.router.location.getState?.().uuid;
|
|
||||||
|
|
||||||
if (newUuid === this.uuid) {
|
|
||||||
// routeDidChange fired without the history state actually changing. Most likely a refresh.
|
|
||||||
// Forget the previously-stored scroll location so that we scroll to the top
|
|
||||||
this.scrollLocationHistory.delete(this.uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.uuid = newUuid;
|
|
||||||
|
|
||||||
if (!this.#shouldScroll(transition.to)) {
|
if (!this.#shouldScroll(transition.to)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollLocation = this.scrollLocationHistory.get(this.uuid) || [0, 0];
|
const scrollLocation = this.historyStore.get(STORE_KEY) || [0, 0];
|
||||||
schedule("afterRender", () => {
|
schedule("afterRender", () => {
|
||||||
this.scrollElement.scrollTo(...scrollLocation);
|
this.scrollElement.scrollTo(...scrollLocation);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#pruneOldScrollLocations() {
|
|
||||||
while (this.scrollLocationHistory.size > MAX_SCROLL_LOCATIONS) {
|
|
||||||
// JS Map guarantees keys will be returned in insertion order
|
|
||||||
const oldestUUID = this.scrollLocationHistory.keys().next().value;
|
|
||||||
this.scrollLocationHistory.delete(oldestUUID);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#shouldScroll(routeInfo) {
|
#shouldScroll(routeInfo) {
|
||||||
// Leafmost route has priority
|
// Leafmost route has priority
|
||||||
for (let route = routeInfo; route; route = route.parent) {
|
for (let route = routeInfo; route; route = route.parent) {
|
||||||
|
|
|
@ -81,4 +81,26 @@ describe "Topic list focus", type: :system do
|
||||||
expect(page).to have_css("body.navigation-topics")
|
expect(page).to have_css("body.navigation-topics")
|
||||||
expect(focussed_topic_id).to eq(nil)
|
expect(focussed_topic_id).to eq(nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "refocusses properly when there are multiple pages of topics" do
|
||||||
|
extra_topics = Fabricate.times(25, :post).map(&:topic)
|
||||||
|
oldest_topic = Fabricate(:post).topic
|
||||||
|
oldest_topic.update(bumped_at: 1.day.ago)
|
||||||
|
|
||||||
|
visit("/latest")
|
||||||
|
|
||||||
|
# Scroll to bottom for infinite load
|
||||||
|
page.execute_script <<~JS
|
||||||
|
document.querySelectorAll('.topic-list-item')[24].scrollIntoView(true);
|
||||||
|
JS
|
||||||
|
|
||||||
|
# Click a topic
|
||||||
|
discovery.topic_list.visit_topic(oldest_topic)
|
||||||
|
expect(topic).to have_topic_title(oldest_topic.title)
|
||||||
|
|
||||||
|
# Going back to the topic-list should re-focus
|
||||||
|
page.go_back
|
||||||
|
expect(page).to have_css("body.navigation-topics")
|
||||||
|
expect(focussed_topic_id).to eq(oldest_topic.id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue