diff --git a/packages/core/src/eventHandler.ts b/packages/core/src/eventHandler.ts index 1eea3b978..0c8972723 100644 --- a/packages/core/src/eventHandler.ts +++ b/packages/core/src/eventHandler.ts @@ -15,6 +15,7 @@ class EventHandler { public init() { if (typeof window !== 'undefined') { window.addEventListener('popstate', this.handlePopstateEvent.bind(this)) + window.addEventListener('scroll', debounce(Scroll.onWindowScroll.bind(Scroll), 100), true) } if (typeof document !== 'undefined') { @@ -71,7 +72,7 @@ class EventHandler { url.hash = window.location.hash history.replaceState({ ...currentPage.get(), url: url.href }) - Scroll.reset(currentPage.get()) + Scroll.reset() return } @@ -81,7 +82,8 @@ class EventHandler { .decrypt(state.page) .then((data) => { currentPage.setQuietly(data, { preserveState: false }).then(() => { - Scroll.restore(currentPage.get()) + Scroll.restore(history.getScrollRegions()) + Scroll.restoreDocument() fireNavigateEvent(currentPage.get()) }) }) diff --git a/packages/core/src/history.ts b/packages/core/src/history.ts index 5db7605bf..03846d2ad 100644 --- a/packages/core/src/history.ts +++ b/packages/core/src/history.ts @@ -1,16 +1,18 @@ import { decryptHistory, encryptHistory, historySessionStorageKeys } from './encryption' import { page as currentPage } from './page' +import Queue from './queue' import { SessionStorage } from './sessionStorage' -import { Page } from './types' +import { Page, ScrollRegion } from './types' const isServer = typeof window === 'undefined' +const queue = new Queue>() + class History { public rememberedState = 'rememberedState' as const public scrollRegions = 'scrollRegions' as const public preserveUrl = false protected current: Partial = {} - protected queue: (() => Promise)[] = [] // We need initialState for `restore` protected initialState: Partial | null = null @@ -36,19 +38,18 @@ class History { } if (this.preserveUrl) { - cb && cb(); + cb && cb() - return; + return } this.current = page - this.addToQueue(() => { + queue.add(() => { return this.getPageData(page).then((data) => { window.history.pushState( { page: data, - timestamp: Date.now(), }, '', page.url, @@ -66,13 +67,7 @@ class History { } public processQueue(): Promise { - const next = this.queue.shift() - - if (next) { - return next().then(() => this.processQueue()) - } - - return Promise.resolve() + return queue.process() } public decrypt(page: Page | null = null): Promise { @@ -101,6 +96,42 @@ class History { return pageData instanceof ArrayBuffer ? decryptHistory(pageData) : Promise.resolve(pageData) } + public saveScrollPositions(scrollRegions: ScrollRegion[]): void { + queue.add(() => { + return Promise.resolve().then(() => { + this.doReplaceState( + { + page: window.history.state.page, + scrollRegions, + }, + this.current.url!, + ) + }) + }) + } + + public saveDocumentScrollPosition(scrollRegion: ScrollRegion): void { + queue.add(() => { + return Promise.resolve().then(() => { + this.doReplaceState( + { + page: window.history.state.page, + documentScrollPosition: scrollRegion, + }, + this.current.url!, + ) + }) + }) + } + + public getScrollRegions(): ScrollRegion[] { + return window.history.state.scrollRegions || [] + } + + public getDocumentScrollPosition(): ScrollRegion { + return window.history.state.documentScrollPosition || { top: 0, left: 0 } + } + public replaceState(page: Page, cb: (() => void) | null = null): void { currentPage.merge(page) @@ -109,21 +140,19 @@ class History { } if (this.preserveUrl) { - cb && cb(); + cb && cb() - return; + return } this.current = page - this.addToQueue(() => { + queue.add(() => { return this.getPageData(page).then((data) => { - window.history.replaceState( + this.doReplaceState( { page: data, - timestamp: Date.now(), }, - '', page.url, ) @@ -132,9 +161,23 @@ class History { }) } - protected addToQueue(fn: () => Promise): void { - this.queue.push(fn) - this.processQueue() + protected doReplaceState( + data: { + page: Page | ArrayBuffer + scrollRegions?: ScrollRegion[] + documentScrollPosition?: ScrollRegion + }, + url: string, + ): void { + window.history.replaceState( + { + ...data, + scrollRegions: data.scrollRegions ?? window.history.state?.scrollRegions, + documentScrollPosition: data.documentScrollPosition ?? window.history.state?.documentScrollPosition, + }, + '', + url, + ) } public getState(key: keyof Page, defaultValue?: T): any { @@ -166,4 +209,8 @@ class History { } } +if (window.history.scrollRestoration) { + window.history.scrollRestoration = 'manual' +} + export const history = new History() diff --git a/packages/core/src/initialVisit.ts b/packages/core/src/initialVisit.ts index 67d602748..d4689801f 100644 --- a/packages/core/src/initialVisit.ts +++ b/packages/core/src/initialVisit.ts @@ -27,11 +27,13 @@ export class InitialVisit { return false } + const scrollRegions = history.getScrollRegions() + history .decrypt() .then((data) => { currentPage.set(data, { preserveScroll: true, preserveState: true }).then(() => { - Scroll.restore(currentPage.get()) + Scroll.restore(scrollRegions) fireNavigateEvent(currentPage.get()) }) }) @@ -62,9 +64,8 @@ export class InitialVisit { .decrypt() .then(() => { const rememberedState = history.getState(history.rememberedState, {}) - const scrollRegions = history.getState(history.scrollRegions, []) + const scrollRegions = history.getScrollRegions() currentPage.remember(rememberedState) - currentPage.scrollRegions(scrollRegions) currentPage .set(currentPage.get(), { @@ -73,7 +74,7 @@ export class InitialVisit { }) .then(() => { if (locationVisit.preserveScroll) { - Scroll.restore(currentPage.get()) + Scroll.restore(scrollRegions) } fireNavigateEvent(currentPage.get()) diff --git a/packages/core/src/page.ts b/packages/core/src/page.ts index a47327e60..1b2ea0d1c 100644 --- a/packages/core/src/page.ts +++ b/packages/core/src/page.ts @@ -56,8 +56,8 @@ class CurrentPage { return } - page.scrollRegions ??= [] page.rememberedState ??= {} + const location = typeof window !== 'undefined' ? window.location : new URL(page.url) replace = replace || isSameUrlWithoutHash(hrefToUrl(page.url), location) @@ -69,6 +69,9 @@ class CurrentPage { this.page = page this.cleared = false + this.page = page + this.cleared = false + if (isNewComponent) { this.fireEventsFor('newComponent') } @@ -81,14 +84,26 @@ class CurrentPage { return this.swap({ component, page, preserveState }).then(() => { if (!preserveScroll) { - Scroll.reset(page) + Scroll.reset() } - eventHandler.fireInternalEvent('loadDeferredProps') - - if (!replace) { - fireNavigateEvent(page) + if (this.isFirstPageLoad) { + this.fireEventsFor('firstLoad') } + + this.isFirstPageLoad = false + + return this.swap({ component, page, preserveState }).then(() => { + if (!preserveScroll) { + Scroll.reset() + } + + eventHandler.fireInternalEvent('loadDeferredProps') + + if (!replace) { + fireNavigateEvent(page) + } + }) }) }) }) @@ -133,10 +148,6 @@ class CurrentPage { this.page.rememberedState = data } - public scrollRegions(regions: Page['scrollRegions']): void { - this.page.scrollRegions = regions - } - public swap({ component, page, diff --git a/packages/core/src/queue.ts b/packages/core/src/queue.ts new file mode 100644 index 000000000..999733baf --- /dev/null +++ b/packages/core/src/queue.ts @@ -0,0 +1,27 @@ +export default class Queue { + protected items: (() => T)[] = [] + protected processingPromise: Promise | null = null + + public add(item: () => T) { + this.items.push(item) + return this.process() + } + + public process() { + this.processingPromise ??= this.processNext().then(() => { + this.processingPromise = null + }) + + return this.processingPromise + } + + protected processNext(): Promise { + const next = this.items.shift() + + if (next) { + return Promise.resolve(next()).then(() => this.processNext()) + } + + return Promise.resolve() + } +} diff --git a/packages/core/src/response.ts b/packages/core/src/response.ts index 1ad7f5ef2..c02f72281 100644 --- a/packages/core/src/response.ts +++ b/packages/core/src/response.ts @@ -3,44 +3,13 @@ import { fireErrorEvent, fireInvalidEvent, firePrefetchedEvent, fireSuccessEvent import { history } from './history' import modal from './modal' import { page as currentPage } from './page' +import Queue from './queue' import { RequestParams } from './requestParams' import { SessionStorage } from './sessionStorage' import { ActiveVisit, ErrorBag, Errors, Page } from './types' import { hrefToUrl, isSameUrlWithoutHash, setHashIfSameUrl } from './url' -class ResponseQueue { - protected queue: Response[] = [] - protected processing = false - - public add(response: Response) { - this.queue.push(response) - } - - public async process(): Promise { - if (this.processing) { - return Promise.resolve() - } - - this.processing = true - await this.processQueue() - this.processing = false - - return Promise.resolve() - } - - protected async processQueue(): Promise { - const nextResponse = this.queue.shift() - - if (nextResponse) { - await nextResponse.process() - return this.processQueue() - } - - return Promise.resolve() - } -} - -const queue = new ResponseQueue() +const queue = new Queue>() export class Response { constructor( @@ -60,8 +29,7 @@ export class Response { } public async handle() { - queue.add(this) - return queue.process() + return queue.add(() => this.process()) } public async process() { diff --git a/packages/core/src/router.ts b/packages/core/src/router.ts index 209639104..586b6d918 100644 --- a/packages/core/src/router.ts +++ b/packages/core/src/router.ts @@ -152,7 +152,7 @@ export class Router { if (!currentPage.isCleared() && !visit.preserveUrl) { // Save scroll regions for the current page - Scroll.save(currentPage.get()) + Scroll.save() } const requestParams: PendingVisit & VisitCallbacks = { diff --git a/packages/core/src/scroll.ts b/packages/core/src/scroll.ts index 8e528bc34..df1bb51be 100644 --- a/packages/core/src/scroll.ts +++ b/packages/core/src/scroll.ts @@ -1,23 +1,21 @@ import { history } from './history' -import { page as currentPage } from './page' -import { Page } from './types' +import { ScrollRegion } from './types' export class Scroll { - public static save(page: Page): void { - history.replaceState({ - ...page, - scrollRegions: Array.from(this.regions()).map((region) => ({ + public static save(): void { + history.saveScrollPositions( + Array.from(this.regions()).map((region) => ({ top: region.scrollTop, left: region.scrollLeft, })), - }) + ) } protected static regions(): NodeListOf { return document.querySelectorAll('[scroll-region]') } - public static reset(page: Page): void { + public static reset(): void { if (typeof window !== 'undefined') { window.scrollTo(0, 0) } @@ -31,7 +29,7 @@ export class Scroll { } }) - this.save(page) + this.save() if (window.location.hash) { // We're using a setTimeout() here as a workaround for a bug in the React adapter where the @@ -40,13 +38,11 @@ export class Scroll { } } - public static restore(page: Page): void { - if (!page.scrollRegions) { - return - } + public static restore(scrollRegions: ScrollRegion[]): void { + this.restoreDocument() this.regions().forEach((region: Element, index: number) => { - const scrollPosition = page.scrollRegions[index] + const scrollPosition = scrollRegions[index] if (!scrollPosition) { return @@ -61,11 +57,26 @@ export class Scroll { }) } + public static restoreDocument(): void { + const scrollPosition = history.getDocumentScrollPosition() + + if (typeof window !== 'undefined') { + window.scrollTo(scrollPosition.left, scrollPosition.top) + } + } + public static onScroll(event: Event): void { const target = event.target as Element if (typeof target.hasAttribute === 'function' && target.hasAttribute('scroll-region')) { - this.save(currentPage.get()) + this.save() } } + + public static onWindowScroll(): void { + history.saveDocumentScrollPosition({ + top: window.scrollY, + left: window.scrollX, + }) + } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 860c8793a..8537b269b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -43,12 +43,15 @@ export interface Page { deferredProps?: Record mergeProps?: string[] - /** @internal */ - scrollRegions: Array<{ top: number; left: number }> /** @internal */ rememberedState: Record } +export type ScrollRegion = { + top: number + left: number +} + export interface ClientSideVisitOptions { component?: Page['component'] url?: Page['url'] diff --git a/packages/svelte/test-app/Pages/Svelte/PropsAndPageStore.svelte b/packages/svelte/test-app/Pages/Svelte/PropsAndPageStore.svelte index 17eb623bb..b3c57900e 100644 --- a/packages/svelte/test-app/Pages/Svelte/PropsAndPageStore.svelte +++ b/packages/svelte/test-app/Pages/Svelte/PropsAndPageStore.svelte @@ -1,9 +1,9 @@