diff --git a/app/assets/javascripts/discourse/app/services/history-store.js b/app/assets/javascripts/discourse/app/services/history-store.js new file mode 100644 index 00000000000..4cc4f3adb94 --- /dev/null +++ b/app/assets/javascripts/discourse/app/services/history-store.js @@ -0,0 +1,92 @@ +import Service, { inject as service } from "@ember/service"; +import { TrackedMap } from "@ember-compat/tracked-built-ins"; +import { disableImplicitInjections } from "discourse/lib/implicit-injections"; +import { bind } from "discourse-common/utils/decorators"; + +const HISTORY_SIZE = 100; +const HISTORIC_KEY = Symbol("historic"); + +/** + * 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; + #route; + + constructor() { + super(...arguments); + this.router.on("routeDidChange", this.maybeRouteDidChange); + } + + get #data() { + // Check if route changed since we last checked the uuid. + // This can happen if some other logic has a routeDidChange + // handler that runs before ours. + this.maybeRouteDidChange(); + + const uuid = this.#uuid; + + let data = this.#routeData.get(uuid); + if (data) { + return data; + } + + data = new TrackedMap(); + this.#routeData.set(uuid, data); + this.#pruneOldData(); + + return data; + } + + get isPoppedState() { + return !!this.get(HISTORIC_KEY); + } + + get(key) { + return this.#data.get(key); + } + + set(key, value) { + return this.#data.set(key, value); + } + + delete(key) { + return this.#data.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); + } + } + + @bind + maybeRouteDidChange() { + if (this.#route === this.router.currentRoute) { + return; + } + this.#route = this.router.currentRoute; + this.#routeData.get(this.#uuid)?.set(HISTORIC_KEY, true); + + const newUuid = window.history.state?.uuid; + + if (this.#uuid === newUuid) { + // A refresh. Clear the state + this.#routeData.delete(newUuid); + } + + this.#uuid = newUuid; + } + + willDestroy() { + this.router.off("routeDidChange", this.maybeRouteDidChange); + } +} diff --git a/app/assets/javascripts/discourse/app/services/route-scroll-manager.js b/app/assets/javascripts/discourse/app/services/route-scroll-manager.js index 36330d6aa6d..51f20818534 100644 --- a/app/assets/javascripts/discourse/app/services/route-scroll-manager.js +++ b/app/assets/javascripts/discourse/app/services/route-scroll-manager.js @@ -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) {