Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: bulletproof SPA recovery #4558

Merged
merged 40 commits into from
Jun 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
b808539
outline requirements and todos
jordanw66 Jun 22, 2023
b1434d4
refined outline
jordanw66 Jun 22, 2023
73b042b
init-spa first draft & useNavigate bootstrap
jordanw66 Jun 22, 2023
096e365
finalize scroll restore api
jordanw66 Jun 23, 2023
7ad5b86
only scroll on manual + rename q:bootstrap to be more specific
jordanw66 Jun 23, 2023
5f35d50
always save scroll history
jordanw66 Jun 23, 2023
54fa64a
refine spa init script
jordanw66 Jun 23, 2023
6563514
Merge branch 'main' into feat/spa-recovery
jordanw66 Jun 23, 2023
8c785e3
impl handlers in init-spa script
jordanw66 Jun 23, 2023
ae013fd
patch history to always include scrollState
jordanw66 Jun 23, 2023
05d6349
fix scroll handler disabled after same-url pops
jordanw66 Jun 23, 2023
55f1920
remove external restoreScroll$ api
jordanw66 Jun 23, 2023
8e9bd9a
fix same-url pop scroll
jordanw66 Jun 23, 2023
b20697b
reorganize init scripts
jordanw66 Jun 23, 2023
28fa4ec
fix same-page hashes without navigate ctx
jordanw66 Jun 23, 2023
97f3830
remove restoreScroll$ api from docs
jordanw66 Jun 23, 2023
323f439
shrink scrollState footprint
jordanw66 Jun 23, 2023
eafaf87
impl spa shim proof-of-concept
jordanw66 Jun 23, 2023
7384c6f
Merge branch 'main' into feat/spa-recovery
jordanw66 Jun 23, 2023
287189e
cleanup
jordanw66 Jun 23, 2023
5efd9d9
misc
jordanw66 Jun 23, 2023
6128502
bundle and dynamic import of spa-init
jordanw66 Jun 24, 2023
fb3da22
Merge branch 'main' into feat/spa-recovery
jordanw66 Jun 24, 2023
f4cd239
fix spa shim to work in dev, prod, e2e, etc
jordanw66 Jun 24, 2023
394bc48
precache spa-init when upgrading to spa
jordanw66 Jun 24, 2023
4d94956
finalize comments
jordanw66 Jun 24, 2023
01fcaf6
finalize history patch
jordanw66 Jun 24, 2023
fa5cdea
Merge branch 'main' into feat/spa-recovery
jordanw66 Jun 25, 2023
19345a3
fix boostrap container and set display none
jordanw66 Jun 25, 2023
3ebca9d
clarify history patch
jordanw66 Jun 25, 2023
9ec9490
fix types in spa shim
jordanw66 Jun 25, 2023
c04243b
improve spa docs, add scroll restoration
jordanw66 Jun 25, 2023
0aa828b
fix isNavigating to actually wait until rendered
jordanw66 Jun 25, 2023
14be2f6
fix navigate hook, grab correct container
jordanw66 Jun 25, 2023
fbc9be4
optimize spa init, don't resume for hashes
jordanw66 Jun 25, 2023
d05a638
refactor spa init navigate bootstrap
jordanw66 Jun 25, 2023
862c515
fix link, fix useNavigate, fix and simplify scroll logic
jordanw66 Jun 26, 2023
d28d729
Merge branch 'main' into feat/spa-recovery
jordanw66 Jun 26, 2023
932003d
fix merge issue
jordanw66 Jun 26, 2023
622618d
final commit: same-page anchor reload scrolling
jordanw66 Jun 26, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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