mirror of
https://github.com/discourse/discourse.git
synced 2025-03-19 12:20:03 +08:00
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 e6370decfdcb87737e76b21fe1bbe033af08afaa. 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, {
|
||||
router: service(),
|
||||
session: service(),
|
||||
historyStore: service(),
|
||||
elementId: "topic-entrance",
|
||||
classNameBindings: ["visible::hidden"],
|
||||
topic: null,
|
||||
@ -166,10 +167,7 @@ export default Component.extend(CleansUp, {
|
||||
},
|
||||
|
||||
_jumpTo(destination) {
|
||||
this.session.set("lastTopicIdViewed", {
|
||||
topicId: this.topic.id,
|
||||
historyUuid: this.router.location.getState?.().uuid,
|
||||
});
|
||||
this.historyStore.set("lastTopicIdViewed", this.topic.id);
|
||||
|
||||
this.cleanUp();
|
||||
DiscourseURL.routeTo(destination);
|
||||
|
@ -37,14 +37,8 @@ export function showEntrance(e) {
|
||||
}
|
||||
|
||||
export function navigateToTopic(topic, href) {
|
||||
const owner = getOwner(this);
|
||||
const router = owner.lookup("service:router");
|
||||
const session = owner.lookup("service:session");
|
||||
|
||||
session.set("lastTopicIdViewed", {
|
||||
topicId: topic.id,
|
||||
historyUuid: router.location.getState?.().uuid,
|
||||
});
|
||||
const historyStore = getOwner(this).lookup("service:history-store");
|
||||
historyStore.set("lastTopicIdViewed", topic.id);
|
||||
|
||||
DiscourseURL.routeTo(href || topic.get("url"));
|
||||
return false;
|
||||
@ -52,6 +46,7 @@ export function navigateToTopic(topic, href) {
|
||||
|
||||
export default Component.extend({
|
||||
router: service(),
|
||||
historyStore: service(),
|
||||
tagName: "tr",
|
||||
classNameBindings: [":topic-list-item", "unboundClassNames", "topic.visited"],
|
||||
attributeBindings: ["data-topic-id", "role", "ariaLevel:aria-level"],
|
||||
@ -346,15 +341,11 @@ export default Component.extend({
|
||||
|
||||
_highlightIfNeeded: on("didInsertElement", function () {
|
||||
// highlight the last topic viewed
|
||||
const lastViewedTopicInfo = this.session.get("lastTopicIdViewed");
|
||||
|
||||
const isLastViewedTopic =
|
||||
lastViewedTopicInfo?.topicId === this.topic.id &&
|
||||
lastViewedTopicInfo?.historyUuid ===
|
||||
this.router.location.getState?.().uuid;
|
||||
const lastViewedTopicId = this.historyStore.get("lastTopicIdViewed");
|
||||
const isLastViewedTopic = lastViewedTopicId === this.topic.id;
|
||||
|
||||
if (isLastViewedTopic) {
|
||||
this.session.set("lastTopicIdViewed", null);
|
||||
this.historyStore.delete("lastTopicIdViewed");
|
||||
this.highlight({ isLastViewedTopic: true });
|
||||
} else if (this.get("topic.highlight")) {
|
||||
// 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(),
|
||||
clientErrorHandler: service(),
|
||||
login: service(),
|
||||
historyStore: service(),
|
||||
|
||||
get isOnlyOneExternalLoginMethod() {
|
||||
return (
|
||||
@ -63,6 +64,12 @@ const ApplicationRoute = DiscourseRoute.extend({
|
||||
return false;
|
||||
},
|
||||
|
||||
@action
|
||||
willResolveModel(transition) {
|
||||
this.historyStore.willResolveModel(transition);
|
||||
return true;
|
||||
},
|
||||
|
||||
actions: {
|
||||
toggleMobileView() {
|
||||
mobile.toggleMobileView();
|
||||
|
@ -21,6 +21,7 @@ class AbstractCategoryRoute extends DiscourseRoute {
|
||||
@service store;
|
||||
@service topicTrackingState;
|
||||
@service("search") searchService;
|
||||
@service historyStore;
|
||||
|
||||
queryParams = queryParams;
|
||||
|
||||
@ -86,7 +87,7 @@ class AbstractCategoryRoute extends DiscourseRoute {
|
||||
|
||||
async _retrieveTopicList(category, transition, modelParams) {
|
||||
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}`;
|
||||
if (findOpts.no_subcategories) {
|
||||
|
@ -98,17 +98,18 @@ class AbstractTopicRoute extends DiscourseRoute {
|
||||
@service store;
|
||||
@service topicTrackingState;
|
||||
@service currentUser;
|
||||
@service historyStore;
|
||||
|
||||
queryParams = queryParams;
|
||||
templateName = "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
|
||||
this.screenTrack.stop();
|
||||
|
||||
const findOpts = filterQueryParams(data),
|
||||
findExtras = { cached: this.isPoppedState(transition) };
|
||||
findExtras = { cached: this.historyStore.isPoppedState };
|
||||
|
||||
const topicListPromise = findTopicList(
|
||||
this.store,
|
||||
|
@ -65,13 +65,6 @@ const DiscourseRoute = Route.extend({
|
||||
|
||||
return user.id === this.currentUser.id;
|
||||
},
|
||||
|
||||
isPoppedState(transition) {
|
||||
return (
|
||||
!transition._discourse_intercepted &&
|
||||
(!!transition.intent.url || !!transition.queryParamsOnly)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default DiscourseRoute;
|
||||
|
@ -26,6 +26,7 @@ export default class TagShowRoute extends DiscourseRoute {
|
||||
@service store;
|
||||
@service topicTrackingState;
|
||||
@service("search") searchService;
|
||||
@service historyStore;
|
||||
|
||||
queryParams = queryParams;
|
||||
controllerName = "discovery/list";
|
||||
@ -119,7 +120,7 @@ export default class TagShowRoute extends DiscourseRoute {
|
||||
filter,
|
||||
filteredQueryParams,
|
||||
{
|
||||
cached: this.isPoppedState(transition),
|
||||
cached: this.historyStore.isPoppedState,
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
import $ from "jquery";
|
||||
import { Promise } from "rsvp";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
@ -6,6 +7,7 @@ import DiscourseRoute from "discourse/routes/discourse";
|
||||
import I18n from "discourse-i18n";
|
||||
|
||||
export default DiscourseRoute.extend({
|
||||
historyStore: service(),
|
||||
templateName: "user/bookmarks",
|
||||
|
||||
queryParams: {
|
||||
@ -13,11 +15,11 @@ export default DiscourseRoute.extend({
|
||||
q: { refreshModel: true },
|
||||
},
|
||||
|
||||
model(params, transition) {
|
||||
model(params) {
|
||||
const controller = this.controllerFor("user-activity-bookmarks");
|
||||
|
||||
if (
|
||||
this.isPoppedState(transition) &&
|
||||
this.historyStore.isPoppedState &&
|
||||
this.session.bookmarksModel &&
|
||||
this.session.bookmarksModel.searchTerm === params.q
|
||||
) {
|
||||
|
133
app/assets/javascripts/discourse/app/services/history-store.js
Normal file
133
app/assets/javascripts/discourse/app/services/history-store.js
Normal file
@ -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 { 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.
|
||||
@ -18,9 +18,7 @@ const MAX_SCROLL_LOCATIONS = 100;
|
||||
@disableImplicitInjections
|
||||
export default class RouteScrollManager extends Service {
|
||||
@service router;
|
||||
|
||||
scrollLocationHistory = new Map();
|
||||
uuid;
|
||||
@service historyStore;
|
||||
|
||||
scrollElement = isTesting()
|
||||
? document.getElementById("ember-testing-container")
|
||||
@ -28,14 +26,10 @@ export default class RouteScrollManager extends Service {
|
||||
|
||||
@bind
|
||||
routeWillChange() {
|
||||
if (!this.uuid) {
|
||||
return;
|
||||
}
|
||||
this.scrollLocationHistory.set(this.uuid, [
|
||||
this.historyStore.set(STORE_KEY, [
|
||||
this.scrollElement.scrollLeft,
|
||||
this.scrollElement.scrollTop,
|
||||
]);
|
||||
this.#pruneOldScrollLocations();
|
||||
}
|
||||
|
||||
@bind
|
||||
@ -44,34 +38,16 @@ export default class RouteScrollManager extends Service {
|
||||
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)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollLocation = this.scrollLocationHistory.get(this.uuid) || [0, 0];
|
||||
const scrollLocation = this.historyStore.get(STORE_KEY) || [0, 0];
|
||||
schedule("afterRender", () => {
|
||||
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) {
|
||||
// Leafmost route has priority
|
||||
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(focussed_topic_id).to eq(nil)
|
||||
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
|
||||
|
Loading…
x
Reference in New Issue
Block a user