Skip to content

Commit

Permalink
feat(qwik-city): improve scroll restoration, and 'popstate' and 'hash…
Browse files Browse the repository at this point in the history
…change' navigations in SPAs

- Support MPA-like and customizable scroll restoration in SPA
- Race condition when restoring scroll positions
- Prevent full-page reload when popstate happens before manual navigation
- Fix hash navigation in SPA
  • Loading branch information
billykwok committed Apr 24, 2023
1 parent 6b34cfe commit 044ac09
Show file tree
Hide file tree
Showing 20 changed files with 704 additions and 325 deletions.
34 changes: 32 additions & 2 deletions packages/docs/src/routes/docs/(qwikcity)/api/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -262,9 +262,39 @@ export default function Root() {
## `<RouterOutlet>`

This component is responsible for rendering the matched route at a given moment, it used internally the [`useContent()`](/docs/(qwikcity)/api/index.mdx#usecontent) and to render the current page, as well as all the nested layouts.
The `RouterOutlet` component is responsible for rendering the matched route at a given moment, it uses internally the [`useContent()`](/docs/(qwikcity)/api/index.mdx#usecontent) and to render the current page, as well as all the nested layouts.

This component is usually located as a child of `<body>`, in most of the starters you will find it in the `src/root.tsx` file:
This component is usually located as a child of `<body>`, in most of the starters you will find it in the `src/root.tsx` file (refer to the example in `QwikCityProvider`).

It accepts an optional `restoreScroll$` prop that can be used to customize the scrolling behavior of SPAs. The function supplied to `restoreScroll$` is called with the following arguments upon navigation events:

```ts
type RestoreScroll = (
type: 'initial' | 'form' | 'link' | 'popstate';
fromUrl: URL;
toUrlSettled: Promise<URL>;
) => void;
```

There are two built-in scroll restoration presets provided by QwikCity - `toTopAlways` and `toLastPositionOnPopState`.

- `toTopAlways` (default), as the name suggested, always scrolls the page to the top of the window when navigating to new or previous pages.
- `toLastPositionOnPopState` mimics how browsers handle MPA - scrolling the page to the top when navigating to a new page, but to the last-visited position when navigating with a `popstate` event (e.g. back/next button).

> Note that both presets treat hash navigation as an exception and always scroll to the hashed element when it is available and specified in the URL.
You can also implement your own scroll restoration logic by supplying a function with the above signature. The `toUrlSettled` promise allows you to wait for DOM updates to complete before setting scroll positions.

```ts
const restoreScroll: RestoreScroll = $(async (type, fromUrl, toUrlSettled) => {
// do something before navigation, e.g. saving current scroll position to sessionStorage
prepare(type, fromUrl);
// wait for final URL to be resolved and DOM to settle
const toUrl = await toUrlSettled;
// handle the actual scroll restoration
handleScroll(type, fromUrl, toUrl);
});
```

## `<Form>`

Expand Down
2 changes: 1 addition & 1 deletion packages/qwik-city/buildtime/build-layout.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { testAppSuite } from '../utils/test-suite';
const test = testAppSuite('Build Layout');

test('total layouts', ({ layouts }) => {
equal(layouts.length, 7, JSON.stringify(layouts, null, 2));
equal(layouts.length, 8, JSON.stringify(layouts, null, 2));
});

test('nested named layout', ({ assertLayout }) => {
Expand Down
38 changes: 34 additions & 4 deletions packages/qwik-city/runtime/src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { CookieOptions } from '@builder.io/qwik-city/middleware/request-handler'
import { CookieValue } from '@builder.io/qwik-city/middleware/request-handler';
import { DeferReturn } from '@builder.io/qwik-city/middleware/request-handler';
import { JSXNode } from '@builder.io/qwik';
import { PropFunction } from '@builder.io/qwik';
import { QRL } from '@builder.io/qwik';
import { QwikIntrinsicElements } from '@builder.io/qwik';
import { QwikJSX } from '@builder.io/qwik';
Expand Down Expand Up @@ -264,6 +265,9 @@ export type LoaderSignal<T> = T extends () => ValueOrPromise<infer B> ? Readonly
// @public (undocumented)
export type MenuData = [pathname: string, menuLoader: MenuModuleLoader];

// @public (undocumented)
export type NavigationType = 'initial' | 'form' | 'link' | 'popstate';

// Warning: (ae-forgotten-export) The symbol "RouteModule" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
Expand All @@ -283,8 +287,14 @@ export interface PageModule extends RouteModule {
// @public (undocumented)
export type PathParams = Record<string, string>;

// Warning: (ae-forgotten-export) The symbol "QwikCityMockProps" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
export interface QwikCityMockProps {
// (undocumented)
params?: Record<string, string>;
// (undocumented)
url?: string;
}

// @public (undocumented)
export const QwikCityMockProvider: Component<QwikCityMockProps>;

Expand Down Expand Up @@ -327,6 +337,9 @@ export { RequestHandler }
// @public (undocumented)
export type ResolvedDocumentHead = Required<DocumentHeadValue>;

// @alpha (undocumented)
export type RestoreScroll = (navigationType: NavigationType, fromUrl: URL, toUrl: Promise<URL>) => void | Promise<void>;

// @public (undocumented)
export const routeAction$: ActionConstructor;

Expand Down Expand Up @@ -365,10 +378,21 @@ export interface RouteLocation {
}

// @public (undocumented)
export type RouteNavigate = QRL<(path?: string, forceReload?: boolean) => Promise<void>>;
export type RouteNavigate = QRL<(path?: string, options?: {
type?: Exclude<NavigationType, 'initial'>;
forceReload?: boolean;
} | boolean) => Promise<void>>;

// @public (undocumented)
export const RouterOutlet: Component<RouterOutletProps>;

// @public (undocumented)
export const RouterOutlet: Component< {}>;
export interface RouterOutletProps {
// Warning: (ae-incompatible-release-tags) The symbol "restoreScroll$" is marked as @public, but its signature references "RestoreScroll" which is marked as @alpha
//
// (undocumented)
restoreScroll$?: PropFunction<RestoreScroll>;
}

// Warning: (ae-forgotten-export) The symbol "ServerFunction" needs to be exported by the entry point index.d.ts
//
Expand All @@ -394,6 +418,12 @@ export interface StaticGenerate {
// @public (undocumented)
export type StaticGenerateHandler = () => Promise<StaticGenerate> | StaticGenerate;

// @alpha (undocumented)
export const toLastPositionOnPopState: QRL<RestoreScroll>;

// @alpha (undocumented)
export const toTopAlways: QRL<RestoreScroll>;

// Warning: (ae-forgotten-export) The symbol "ContentState" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
Expand Down
127 changes: 33 additions & 94 deletions packages/qwik-city/runtime/src/client-navigate.ts
Original file line number Diff line number Diff line change
@@ -1,106 +1,45 @@
import type { QPrefetchData } from './service-worker/types';
import type { SimpleURL } from './types';
import { isSameOriginDifferentPathname, isSamePath, toPath, toUrl } from './utils';
import type { Signal } from '@builder.io/qwik';
import type { NavigationType } from './types';
import { isSameOrigin, isSamePath, toPath } from './utils';

export const clientNavigate = (
win: ClientHistoryWindow,
newUrl: URL,
routeNavigate: Signal<string>
) => {
const currentUrl = win.location;
if (isSameOriginDifferentPathname(currentUrl, newUrl)) {
// current browser url and route path are different
// see if we should scroll to the hash after the url update
handleScroll(win, currentUrl, newUrl);
export const dispatchPrefetchEvent = (...links: string[]) =>
document.dispatchEvent(new CustomEvent('qprefetch', { detail: { links } }));

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

if (!win._qCityHistory) {
// only add event listener once
win._qCityHistory = 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);
// current browser url and route path are different
// update the route path
routeNavigate.value = toPath(new URL(currentUrl.href));
}
});

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

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

if (isSamePath(previousUrl, newUrl)) {
// same route after path change

if (previousUrl.hash !== newHash) {
// hash has changed on the same route

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

if (newHash) {
// hash has changed on the same route and there's a hash
// scroll to the element if it exists
scrollToHashId(doc, newHash);
} else {
// hash has changed on the same route, but now there's no hash
win.scrollTo(0, 0);
export const clientNavigate = (win: Window, navType: NavigationType, fromUrl: URL, toUrl: URL) => {
if (isSameOrigin(fromUrl, toUrl)) {
if (navType === 'popstate') {
clientHistoryState.id = win.history.state?.id ?? 0;
} else {
const samePath = isSamePath(fromUrl, toUrl);
const sameHash = fromUrl.hash === toUrl.hash;
// push to history for path or hash changes
if (!samePath || !sameHash) {
win.history.pushState({ id: ++clientHistoryState.id }, '', toPath(toUrl));
}
}
} else {
// different route after change

if (newHash) {
// 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;
}
// mimic native hashchange event
if (navType === 'link' && samePath && !sameHash) {
win.dispatchEvent(
new win.HashChangeEvent('hashchange', { newURL: toUrl.href, oldURL: fromUrl.href })
);
}
} else {
// different route and there isn't a hash
await domWait();
win.scrollTo(0, 0);
}
}
};

const domWait = () => new Promise((resolve) => setTimeout(resolve, 12));
/**
* @alpha
* @returns A unique opaque id representing the current client history entry
*/
export const getHistoryId = () => '' + clientHistoryState.id;

const scrollToHashId = (doc: Document, hash: string) => {
const elmId = hash.slice(1);
const elm = doc.getElementById(elmId);
if (elm) {
// found element to scroll to
elm.scrollIntoView();
}
return elm;
};
/**
* @internal
*/
export const resetHistoryId = () => (clientHistoryState.id = 0);

export const dispatchPrefetchEvent = (prefetchData: QPrefetchData) => {
if (typeof document !== 'undefined') {
document.dispatchEvent(new CustomEvent('qprefetch', { detail: prefetchData }));
}
};
const clientHistoryState = { id: 0 };

export interface ClientHistoryWindow extends Window {
_qCityHistory?: 1;
_qCityPopstateFallback?: () => void;
declare global {
interface Window {
HashChangeEvent: new (type: string, eventInitDict?: HashChangeEventInit) => HashChangeEvent;
}
}

0 comments on commit 044ac09

Please sign in to comment.