rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
158 lines (157 loc) • 6.64 kB
JavaScript
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);
},
};
}