UNPKG

rwsdk

Version:

Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime

158 lines (157 loc) 6.64 kB
export const HISTORY_STATE_SCROLL_KEY = "__rwsdk_scroll_key"; export function createScrollRestoration() { const historyEntryKeyPrefix = Math.random().toString(36).slice(2); const scrollPositions = new Map(); let currentHistoryEntryKey = null; let nextHistoryEntryKey = 0; let pendingScroll = null; function createHistoryEntryKey() { nextHistoryEntryKey += 1; return `${historyEntryKeyPrefix}:${nextHistoryEntryKey}`; } function readHistoryState() { const state = window.history.state; return state && typeof state === "object" ? { ...state } : {}; } function getHistoryEntryKey(state) { const key = state[HISTORY_STATE_SCROLL_KEY]; return typeof key === "string" ? key : null; } function getScrollPositionFromState(state) { if (typeof state.scrollX === "number" || typeof state.scrollY === "number") { return { x: typeof state.scrollX === "number" ? state.scrollX : 0, y: typeof state.scrollY === "number" ? state.scrollY : 0, }; } return null; } function ensureCurrentHistoryEntryKey(state = readHistoryState()) { const existingKey = getHistoryEntryKey(state); if (existingKey) { currentHistoryEntryKey = existingKey; return existingKey; } const historyEntryKey = createHistoryEntryKey(); currentHistoryEntryKey = historyEntryKey; window.history.replaceState({ ...state, [HISTORY_STATE_SCROLL_KEY]: historyEntryKey }, "", window.location.href); return historyEntryKey; } function getCurrentHistoryEntryKeyForReplace(state) { const existingKey = getHistoryEntryKey(state) ?? currentHistoryEntryKey; if (existingKey) { currentHistoryEntryKey = existingKey; return existingKey; } const historyEntryKey = createHistoryEntryKey(); currentHistoryEntryKey = historyEntryKey; return historyEntryKey; } function getSavedScrollPosition(state) { const historyEntryKey = getHistoryEntryKey(state) ?? currentHistoryEntryKey; const savedPosition = historyEntryKey ? scrollPositions.get(historyEntryKey) : undefined; return savedPosition ?? getScrollPositionFromState(state); } function writeHistoryState(state, historyEntryKey, position) { window.history.replaceState({ ...state, [HISTORY_STATE_SCROLL_KEY]: historyEntryKey, scrollX: position.x, scrollY: position.y, }, "", window.location.href); } return { initialize() { // Take manual control of scroll restoration. With "auto", the browser // restores scroll immediately on popstate — before the RSC payload has // committed — which causes the old DOM to flash at the new scroll offset. if ("scrollRestoration" in window.history) { window.history.scrollRestoration = "manual"; } // Boot can happen after a reload, or after an older runtime wrote only // scrollX/scrollY. Seed the in-memory store from history.state so the // first commit can restore the saved position without per-scroll writes. const bootState = readHistoryState(); const bootHistoryEntryKey = ensureCurrentHistoryEntryKey(bootState); const bootScrollPosition = getSavedScrollPosition(bootState); if (bootScrollPosition) { scrollPositions.set(bootHistoryEntryKey, bootScrollPosition); pendingScroll = { x: bootScrollPosition.x, y: bootScrollPosition.y, behavior: "instant", }; } }, recordCurrentPosition(x, y) { if (!currentHistoryEntryKey) { return; } scrollPositions.set(currentHistoryEntryKey, { x, y }); }, flushCurrentPositionToHistoryState(x = window.scrollX, y = window.scrollY) { const state = readHistoryState(); const historyEntryKey = ensureCurrentHistoryEntryKey(state); const position = { x, y }; scrollPositions.set(historyEntryKey, position); writeHistoryState(state, historyEntryKey, position); }, pushEntry(href, url, initialPosition) { this.flushCurrentPositionToHistoryState(); const historyEntryKey = createHistoryEntryKey(); currentHistoryEntryKey = historyEntryKey; scrollPositions.set(historyEntryKey, initialPosition); window.history.pushState({ path: href, [HISTORY_STATE_SCROLL_KEY]: historyEntryKey, scrollX: initialPosition.x, scrollY: initialPosition.y, }, "", url); }, replaceEntry(href, url, initialPosition) { const state = readHistoryState(); const historyEntryKey = getCurrentHistoryEntryKeyForReplace(state); scrollPositions.set(historyEntryKey, initialPosition); window.history.replaceState({ ...state, path: href, [HISTORY_STATE_SCROLL_KEY]: historyEntryKey, scrollX: initialPosition.x, scrollY: initialPosition.y, }, "", url); }, restorePopStateScroll() { const state = readHistoryState(); const historyEntryKey = ensureCurrentHistoryEntryKey(state); const savedScrollPosition = getSavedScrollPosition(state) ?? { x: 0, y: 0, }; if (!scrollPositions.has(historyEntryKey)) { scrollPositions.set(historyEntryKey, savedScrollPosition); } pendingScroll = { x: savedScrollPosition.x, y: savedScrollPosition.y, behavior: "instant", }; }, setPendingScroll(nextPendingScroll) { pendingScroll = nextPendingScroll; this.recordCurrentPosition(nextPendingScroll.x, nextPendingScroll.y); }, applyPendingScroll() { if (!pendingScroll) return; const { x, y, behavior } = pendingScroll; pendingScroll = null; window.scrollTo({ top: y, left: x, behavior }); this.recordCurrentPosition(x, y); }, }; }