Skip to content

Commit

Permalink
feat(qwik-city): support scroll restoration
Browse files Browse the repository at this point in the history
  • Loading branch information
billykwok committed Mar 6, 2023
1 parent a2d0cde commit f22793c
Show file tree
Hide file tree
Showing 2 changed files with 138 additions and 17 deletions.
116 changes: 100 additions & 16 deletions packages/qwik-city/runtime/src/client-navigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,97 @@ import type { SimpleURL } from './types';
import { isSameOriginDifferentPathname, isSamePath, toPath, toUrl } from './utils';
import type { Signal } from '@builder.io/qwik';

const QWIK_CITY_CLIENT_HISTORY = '_qCityHistory';
const QWIK_CITY_SCROLL_RECORD = '_qCityScroll';

const getScrollBox = (elm: Element): [width: number, height: number] => [
Math.max(elm.scrollWidth, elm.clientWidth),
Math.max(elm.scrollHeight, elm.clientHeight),
];

const updateScrollRecord = (scrollRecord: ScrollRecord, url: SimpleURL, win: Window) => {
scrollRecord[url.pathname + url.search] = [
win.scrollX,
win.scrollY,
...getScrollBox(win.document.documentElement),
];
};

const flushScrollRecordToStorage = (scrollRecord: ScrollRecord, storage: Storage) => {
try {
storage.setItem(QWIK_CITY_SCROLL_RECORD, JSON.stringify(scrollRecord));
} catch (e) {
console.error('Failed to save scroll positions', e);
}
};

const retrieveScrollRecordFromStorage = (storage: Storage): ScrollRecord => {
const scrollRecord = storage.getItem(QWIK_CITY_SCROLL_RECORD);
try {
return JSON.parse(scrollRecord!) || {};
} catch (e) {
console.error('Failed to parse scroll positions', e);
return {};
}
};

export const clientNavigate = (
win: ClientHistoryWindow,
newUrl: URL,
routeNavigate: Signal<string>
) => {
const currentUrl = win.location;
const storage = win.sessionStorage;
const clientHistoryInitialized = win[QWIK_CITY_CLIENT_HISTORY];

if (!clientHistoryInitialized) {
win[QWIK_CITY_SCROLL_RECORD] = retrieveScrollRecordFromStorage(storage);
}

const scrollRecord = win[QWIK_CITY_SCROLL_RECORD]!;
if (isSameOriginDifferentPathname(currentUrl, newUrl)) {
updateScrollRecord(scrollRecord, currentUrl, win);
flushScrollRecordToStorage(scrollRecord, storage);

// current browser url and route path are different
// see if we should scroll to the hash after the url update
// see if we should manage scroll position
handleScroll(win, currentUrl, newUrl);

// push the new route path to the history
win.history.pushState('', '', toPath(newUrl));
}

if (!win._qCityHistory) {
if (!clientHistoryInitialized) {
// only add event listener once
win._qCityHistory = 1;

win[QWIK_CITY_CLIENT_HISTORY] = 1;
win.addEventListener('popstate', () => {
// history pop event has happened
const currentUrl = win.location;
const previousUrl = toUrl(routeNavigate.value, currentUrl)!;

if (isSameOriginDifferentPathname(currentUrl, previousUrl)) {
handleScroll(win, previousUrl, currentUrl);
updateScrollRecord(scrollRecord, previousUrl, win);
flushScrollRecordToStorage(scrollRecord, storage);

// current browser url and route path are different
// update the route path
routeNavigate.value = toPath(currentUrl);

// provide scroll record to enable restoration if applicable
handleScroll(win, previousUrl, currentUrl, scrollRecord);
}
});

win.removeEventListener('popstate', win._qCityPopstateFallback!);
}
};

const handleScroll = async (win: Window, previousUrl: SimpleURL, newUrl: SimpleURL) => {
const handleScroll = async (
win: Window,
previousUrl: SimpleURL,
newUrl: SimpleURL,
restoration?: ScrollRecord
) => {
const doc = win.document;
const newHash = newUrl.hash;

Expand All @@ -50,7 +104,7 @@ const handleScroll = async (win: Window, previousUrl: SimpleURL, newUrl: SimpleU
// hash has changed on the same route

// wait for a moment while window gets settled
await domWait();
await domWait(win);

if (newHash) {
// hash has changed on the same route and there's a hash
Expand All @@ -68,21 +122,45 @@ const handleScroll = async (win: Window, previousUrl: SimpleURL, newUrl: SimpleU
// different route and there's a hash
// content may not have finished updating yet
// poll the dom querying for the element for a short time
for (let i = 0; i < 24; i++) {
await domWait();
if (scrollToHashId(doc, newHash)) {
break;
}
}
await domWaitUntil(win, () => scrollToHashId(doc, newHash));
} else {
// different route and there isn't a hash
await domWait();
win.scrollTo(0, 0);

// check if scroll position was recorded and should be restored
const xywh = restoration?.[newUrl.pathname + newUrl.search];
if (xywh) {
const docElm = doc.documentElement;
const [scrollX, scrollY, historicalWidth, historicalHeight] = xywh;
// record scroll box size before dom change
const [startWidth, startHeight] = getScrollBox(docElm);
await domWaitUntil(win, () => {
const [currentWidth, currentHeight] = getScrollBox(docElm);
return (
// bigger or smaller scroll box implies dom has changed
(startWidth !== currentWidth || startHeight !== currentHeight) &&
// and scroll box is at least as big as it was before
currentWidth >= historicalWidth &&
currentHeight >= historicalHeight
);
});
win.scrollTo(scrollX, scrollY);
} else {
win.scrollTo(0, 0);
}
}
}
};

const domWait = () => new Promise((resolve) => setTimeout(resolve, 12));
const domWait = (win: Window) => new Promise((resolve) => win.requestAnimationFrame(resolve));

const domWaitUntil = async (win: Window, condition: () => any) => {
for (let i = 0; i < 10; i++) {
await domWait(win);
if (condition()) {
break;
}
}
};

const scrollToHashId = (doc: Document, hash: string) => {
const elmId = hash.slice(1);
Expand All @@ -100,7 +178,13 @@ export const dispatchPrefetchEvent = (prefetchData: QPrefetchData) => {
}
};

type ScrollRecord = Record<
string,
[scrollX: number, scrollY: number, scrollWidth: number, scrollHeight: number]
>;

export interface ClientHistoryWindow extends Window {
_qCityHistory?: 1;
_qCityScroll?: ScrollRecord;
_qCityPopstateFallback?: () => void;
}
39 changes: 38 additions & 1 deletion packages/qwik-city/runtime/src/client-navigate.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ navTest('do not popstate if location is the same', () => {
win.firePopstate();
win.firePopstate();
routeNav.value = '/page-a';
equal(win.sessionStorageEntries.size, 1);
equal(win.sessionStorageEntries.get('_qCityScroll'), '{"/":[0,0,0,0]}');
});

navTest('pushState, popstate', () => {
Expand All @@ -30,6 +32,8 @@ navTest('pushState, popstate', () => {
equal(win.historyPaths[0], '/');
equal(win.location.href, 'http://qwik.dev/');
equal(routeNav.value, '/');
equal(win.sessionStorageEntries.size, 1);
equal(win.sessionStorageEntries.get('_qCityScroll'), '{"/":[0,0,0,0],"/page-a":[0,0,0,0]}');
});

navTest('pushState for different path', () => {
Expand All @@ -45,6 +49,8 @@ navTest('pushState for different path', () => {
equal(win.historyPaths[1], '/page-a');
equal(win.location.href, 'http://qwik.dev/page-a');
equal(routeNav.value, '/page-a');
equal(win.sessionStorageEntries.size, 1);
equal(win.sessionStorageEntries.get('_qCityScroll'), '{"/":[0,0,0,0]}');
});

navTest('do not pushState for same path', () => {
Expand All @@ -55,6 +61,7 @@ navTest('do not pushState for same path', () => {
clientNavigate(win, new URL(routeNav.value, win.location.href), routeNav);
equal(win.historyPaths.length, 1);
equal(routeNav.value, '/');
equal(win.sessionStorageEntries.size, 0);
});

navTest('add only one popstate listener', () => {
Expand Down Expand Up @@ -93,8 +100,17 @@ function createTestWindow(href: string): TestClientHistoryWindow {
const listeners = new Map<string, (() => void)[]>();
const location = new URL(href);
const historyPaths: string[] = [toPath(location)];
const sessionStorageEntries = new Map<string, string>();
let time = 0;
let frameId = 0;
let scrollX = 0;
let scrollY = 0;

return {
requestAnimationFrame: (cb: FrameRequestCallback): number => {
cb && cb(time++);
return frameId++;
},
addEventListener: (evName: string, cb: () => void) => {
let evListeners = listeners.get(evName);
if (!evListeners) {
Expand All @@ -116,6 +132,12 @@ function createTestWindow(href: string): TestClientHistoryWindow {
return location;
},
document: {
documentElement: {
scrollWidth: 0,
scrollHeight: 0,
clientWidth: 0,
clientHeight: 0,
},
getElementById: () => null,
},
history: {
Expand Down Expand Up @@ -149,13 +171,28 @@ function createTestWindow(href: string): TestClientHistoryWindow {
evListeners[evListeners.length - 1]();
}
},
scrollTo: (x: number, y: number) => {},
get scrollX() {
return scrollX;
},
get scrollY() {
return scrollY;
},
scrollTo: (x: number, y: number) => {
scrollX = x;
scrollY = y;
},
sessionStorageEntries,
sessionStorage: {
getItem: (key: string) => sessionStorageEntries.get(key),
setItem: (key: string, value: string) => sessionStorageEntries.set(key, value),
},
} as any;
}

interface TestClientHistoryWindow extends ClientHistoryWindow {
listeners: TestListeners;
historyPaths: string[];
sessionStorageEntries: Map<string, string>;
firePopstate: () => void;
}

Expand Down

0 comments on commit f22793c

Please sign in to comment.