From bce557cc2dc767628bed6aac87301a1be7c5431b Mon Sep 17 00:00:00 2001 From: rxliuli Date: Tue, 4 Nov 2025 05:03:50 +0800 Subject: init commit --- shared/utils/src/history.ts | 168 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 shared/utils/src/history.ts (limited to 'shared/utils/src/history.ts') diff --git a/shared/utils/src/history.ts b/shared/utils/src/history.ts new file mode 100644 index 0000000..498f7d1 --- /dev/null +++ b/shared/utils/src/history.ts @@ -0,0 +1,168 @@ +import type { Logger, LoggerFactory } from '@amp/web-apps-logger'; +import { LruMap } from './lru-map'; +import type { ScrollableElement } from './try-scroll'; +import { tryScroll } from './try-scroll'; +import { removeHost } from './url'; +import { generateUuid } from './uuid'; + +export interface Options { + getScrollablePageElement(): ScrollableElement | null; +} + +type Id = string; +const HISTORY_SIZE_LIMIT = 10; + +interface WithScrollPosition { + scrollY: number; + state: State; +} +/** + * We are using a currentStateId on this class to always store the state id instead of saving + * it on the window.history.state because there seems to be a bug in Safari where it is mutating + * the window.history.state to null after our Sign In flow which includes multiple iframes + * and multiple internal state changes inside the iframes. We can move back to window.history.state storing the id + * if the Safari Issue is fixed in future. + */ +export class History { + private readonly log: Logger; + private readonly states: LruMap>; + private readonly getScrollablePageElement: () => ScrollableElement | null; + private currentStateId: string | undefined; + + constructor( + loggerFactory: LoggerFactory, + options: Options, + sizeLimit: number = HISTORY_SIZE_LIMIT, + ) { + this.log = loggerFactory.loggerFor('History'); + this.states = new LruMap(sizeLimit); + this.getScrollablePageElement = options.getScrollablePageElement; + } + + // Update page data but keep scroll position + updateState(update: (state?: State) => State): void { + if (!this.currentStateId) { + this.log.warn( + 'failed: encountered a null currentStateId inside updateState', + ); + return; + } + + const currentState = this.states.get(this.currentStateId); + const newState = update(currentState?.state); + this.log.info('updateState', newState, this.currentStateId); + this.states.set(this.currentStateId, { + ...(currentState as WithScrollPosition), + state: newState, + }); + } + + replaceState(state: State, url: string | null): void { + const id = generateId(); + this.log.info('replaceState', state, url, id); + window.history.replaceState({ id }, '', this.removeHost(url)); + this.currentStateId = id; + this.states.set(id, { state, scrollY: 0 }); + this.scrollTop = 0; + } + + pushState(state: State, url: string | null): void { + const id = generateId(); + this.log.info('pushState', state, url, id); + window.history.pushState({ id }, '', this.removeHost(url)); + this.currentStateId = id; + this.states.set(id, { state, scrollY: 0 }); + this.scrollTop = 0; + } + + beforeTransition(): void { + const { state } = window.history; + + if (!state) { + return; + } + + const oldState = this.states.get(state.id); + if (!oldState) { + this.log.info( + 'current history state evicted from LRU, not saving scroll position', + ); + return; + } + + const { scrollTop } = this; + + this.states.set(state.id, { + ...oldState, + scrollY: scrollTop, + }); + + this.log.info('saving scroll position', scrollTop); + } + + private removeHost(url: string | null): string | undefined { + if (!url) { + this.log.warn('received null URL'); + return; + } + + // TODO: rdar://77982655 (Investigate router improvements): host mismatch? + return removeHost(url); + } + + onPopState( + listener: (url: string, state: State | undefined) => void, + ): void { + window.addEventListener('popstate', (event: PopStateEvent): void => { + this.currentStateId = event.state?.id; + + if (!this.currentStateId) { + this.log.warn( + 'encountered a null event.state.id in onPopState event: ', + window.location.href, + ); + } + + this.log.info('popstate', this.states, this.currentStateId); + const state = this.currentStateId + ? this.states.get(this.currentStateId) + : undefined; + listener(window.location.href, state?.state); + + if (!state) { + return; + } + + const { scrollY } = state; + + this.log.info('restoring scroll to', scrollY); + + tryScroll(this.log, () => this.getScrollablePageElement(), scrollY); + }); + } + + private get scrollTop(): number { + return this.getScrollablePageElement()?.scrollTop || 0; + } + + private set scrollTop(scrollTop: number) { + const element = this.getScrollablePageElement(); + if (element) { + element.scrollTop = scrollTop; + } + } + + // TODO: rdar://77982655 (Investigate router improvements): offPopState? +} + +/** + * Generate a (unique) id for storing in window.history.state. + * + * @return the generated ID + */ +function generateId(): Id { + // The use of something random (and not say, an incrementing counter) is important + // here. These states can survive refreshes so the IDs used must be globally unique + // (and not just unique to the current page load). + return generateUuid(); +} -- cgit v1.2.3