Skip to content

Commit

Permalink
feat: bulletproof SPA recovery (#4558)
Browse files Browse the repository at this point in the history
  • Loading branch information
jordanw66 committed Jun 26, 2023
1 parent 57cde2f commit 82047a8
Show file tree
Hide file tree
Showing 16 changed files with 543 additions and 250 deletions.
41 changes: 0 additions & 41 deletions packages/docs/src/routes/docs/(qwikcity)/api/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -268,47 +268,6 @@ export default component$(() => {

> `QwikCityProvider` does not render any DOM element, not even the matched route, it merely initializes Qwik City core logic, because of this reason, it should not be used more than once in the same app.
It also 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;
toUrl: URL;
scrollState?: ScrollState;
) => () => void;
```

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

- `toTopAlways`, as the name suggests, always scrolls the page to the top of the window when navigating to new or previous pages.
- `toLastPositionOnPopState` (default) 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 browsers such as Chromium and Firefox natively scroll to hashes on regular navigation, popstates will ALWAYS restore. The `toLastPositionOnPopState` preset will replicate this behavior, while `toTopAlways` will always scroll to hashes.
You can also implement your own scroll restoration logic by supplying a function with the above signature, for example:

```ts
const restoreScroll: QRL<RestoreScroll> = $((type, fromUrl, toUrl, scrollState) => {
// Do something before navigation:
prepare();

return () => {
// Handle the actual scroll restoration:
let [scrollX, scrollY] = [0, 0];
if (type === 'popstate' && scrollState) {
scrollX = scrollState.scrollX;
scrollY = scrollState.scrollY;
}
window.scrollTo(scrollX, scrollY);
}
});
```

> Note that the returned inner void function MUST run synchronously with render, it is recommended to avoid expensive computations inside of it.
> The `scrollState` argument may be undefined when there is no scroll history, typically when type is not popstate, but also pops on custom `pushState`.
## `<RouterOutlet>`

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.
Expand Down
65 changes: 57 additions & 8 deletions packages/docs/src/routes/docs/(qwikcity)/routing/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -190,12 +190,17 @@ For the given example, the Qwik components will be rendered in the following ord
</RootLayout>
```

## SPA navigation
## SPA Navigation

Qwik provides the `<Link>` component and the `useNavigate()` hook to refresh and navigate between pages.
With Qwik, the distinction between MPA and SPA disappears; every app can be both at the same time.
The choice is no longer an architectural design determined at the beginning of the project,
instead this decision can be made for every link.

The `Link` is usually the recommend way to navigate because it uses the HTML `<a>` tag, which is the most accessible way to navigate between pages.
Qwik provides a `<Link>` component and `useNavigate()` hook.
These can be used to initiate an SPA refresh or navigation between pages.

The `Link` component is the recommend way to navigate as it uses the HTML `<a>` tag,
which is the most accessible way to move between pages.
However, if you need to navigate programmatically, you can use the `useNavigate()` hook.

```tsx
Expand All @@ -213,18 +218,19 @@ export default component$(() => {
});
```

### Refreshing
> The `Link` component uses the `useNavigate()` hook internally.
You can use the `Link` with the `reload` prop to refresh the page.
### Refreshing

You can also call the `nav()` function from the `useNavigate()` hook, without any arguments.
The `Link` with the `reload` prop can be used to refresh the current page.
You can also call the `nav()` function from the `useNavigate()` hook, without arguments.

```tsx
import { component$ } from '@builder.io/qwik';
import { Link, routeLoader$, useNavigate } from '@builder.io/qwik-city';

export const useServerTime = routeLoader$(() => {
// This will re-execute in the server when the page refreshes
// This will re-execute in the server when the page refreshes.
return Date.now();
});

Expand All @@ -242,10 +248,53 @@ export default component$(() => {
});
```

> When the page refreshes, all the matching `routeLoader$` and server handlers (`onRequest`) will reexecute in the server and the UI will re-render accordingly.
> When the page refreshes, all the matching `routeLoader$` and server handlers (`onRequest`) will re-execute in the server and the UI will re-render accordingly.
> While refreshing the page, the `isNavigating` boolean from `useLocation()` will be `true` until the page is fully rendered.
### Scroll Restoration

Qwik provides best-in-class scroll restoration for SPA that closely mimics the native browser experience.
Your users should receive the exact same experience they've come to expect natively from MPA,
except with all the added benefits of SPA.

After you use one of the above methods to navigate, the user is automatically upgraded to SPA.
This means that the current page, and the page they came from, now have an `SPA` context associated with them.

If a user then clicks a regular `<a>` tag, they will perform a regular navigation. This new page will have no SPA context,
and is effectively downgraded back to MPA. You can swap between these however you see fit,
and the user's experience will seamlessly switch between MPA and SPA as if it were all the same.

When a user re-visits the SPA-enabled history entries, such as with a refresh, back/forward button, browser session restarts, etc.,
Qwik will automatically restore their scroll position and bootstrap itself back into the SPA context as needed.

> The script required to provide this robust experience never loads, nor is it ever even sent to the user's browser,
> unless the history entry has had an SPA context. Pure MPA pages never load this script. This is the magic of Qwik.
> Scroll restoration in Qwik always occurs synchronously with render. When combined with Qwik's resumable and
> first-class SSR/MPA nature, the user should never experience scroll flashing.
Qwik's scroll restoration is entirely `history` based. This is different from many other frameworks
which rely on things like `sessionStorage`.

Qwik's ability to remember and restore scroll positions is extremely robust
and will survive everything from browser session restarts to users clearing their browser data,
which is not the case for these many other frameworks.

> **Notes on using `pushState()` and `replaceState()` during SPA:**
>
> On a page with an SPA context, Qwik will patch the `pushState()` and `replaceState()` functions on the `history` global.
> This is to ensure that any custom states you add as a developer, also receive the SPA context.
>
> While these are patched, states you `push` or `replace` should always be an actual `Object` type.
> This is because Qwik needs to be able to automatically append the SPA context to the state as a property.
>
> If you provide a value that is not an object, Qwik will create a new object for state and add your provided
> value to a new key: `{ _data: <your_value> }`
>
> Qwik will also warn you in the browser's console in `dev` mode when this occurs.

## Request Event

Each request handler, such as `onRequest`, `onGet`, `onPost`, etc., are passed in a `RequestEvent` object as the first argument to the handler. The `RequestEvent` object contains utility functions and properties to get and set values to the server's request and response. This object contains the following properties:
Expand Down
13 changes: 0 additions & 13 deletions packages/qwik-city/runtime/src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,6 @@ export interface QwikCityPlan {

// @public (undocumented)
export interface QwikCityProps {
// @alpha
restoreScroll$?: RestoreScroll;
viewTransition?: boolean;
}

Expand All @@ -342,11 +340,6 @@ export { RequestHandler }
// @public (undocumented)
export type ResolvedDocumentHead = Required<DocumentHeadValue>;

// Warning: (ae-forgotten-export) The symbol "ScrollState" needs to be exported by the entry point index.d.ts
//
// @alpha (undocumented)
export type RestoreScroll = (navigationType: NavigationType, fromUrl: URL, toUrl: URL, scrollState?: ScrollState) => () => void;

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

Expand Down Expand Up @@ -420,12 +413,6 @@ 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
10 changes: 6 additions & 4 deletions packages/qwik-city/runtime/src/client-navigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export const clientNavigate = (
const samePath = isSamePath(fromURL, toURL);
const sameHash = fromURL.hash === toURL.hash;

// TODO Refactor, some of this is redundant now.

if (!samePath || !sameHash) {
const newState = {
_qCityScroll: newScrollState(),
Expand All @@ -31,10 +33,10 @@ export const clientNavigate = (

export const newScrollState = (): ScrollState => {
return {
scrollX: 0,
scrollY: 0,
scrollWidth: 0,
scrollHeight: 0,
x: 0,
y: 0,
w: 0,
h: 0,
};
};

Expand Down
2 changes: 0 additions & 2 deletions packages/qwik-city/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ export type {
StaticGenerate,
RouteNavigate,
NavigationType,
RestoreScroll,
DeferReturn,
RequestEventBase,
JSONObject,
Expand All @@ -52,7 +51,6 @@ export {
QwikCityMockProvider,
} from './qwik-city-component';
export { type LinkProps, Link } from './link-component';
export { toTopAlways, toLastPositionOnPopState } from './scroll-restoration';
export { ServiceWorkerRegister } from './sw-component';
export { useDocumentHead, useLocation, useContent, useNavigate } from './use-functions';
export { routeAction$, routeActionQrl } from './server-functions';
Expand Down
3 changes: 0 additions & 3 deletions packages/qwik-city/runtime/src/init-popstate.txt

This file was deleted.

10 changes: 9 additions & 1 deletion packages/qwik-city/runtime/src/link-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,15 @@ export const Link = component$<LinkProps>((props) => {
)
: undefined;
const handleClick = event$(async (_: any, elm: HTMLAnchorElement) => {
if (elm.href) {
if (!elm.hasAttribute('preventdefault:click')) {
// Do not enter the nav pipeline if this is not a clientNavPath.
return;
}

if (elm.hasAttribute('q:nbs')) {
// Allow bootstrapping into useNavigate.
await nav(location.href, { type: 'popstate' });
} else if (elm.href) {
elm.setAttribute('aria-pressed', 'true');
await nav(elm.href, { forceReload: reload, replaceState });
elm.removeAttribute('aria-pressed');
Expand Down

0 comments on commit 82047a8

Please sign in to comment.