Skip to content

Commit

Permalink
lazy and preloadAll
Browse files Browse the repository at this point in the history
  • Loading branch information
wille committed Sep 13, 2024
1 parent c96da88 commit ab3ac5d
Show file tree
Hide file tree
Showing 10 changed files with 145 additions and 34 deletions.
54 changes: 45 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

This plugin will significantly speed up your server rendered Vite application by preloading dynamically imported React components and their stylesheets as early as possible. It will also ensure that the stylesheet of the lazy component is included in the initial HTML to avoid a Flash Of Unstyled Content (FOUC).

Similar to [loadable-components](https://loadable-components.com/) but built for Vite.
This plugin is different to [vite-plugin-preload](https://www.npmjs.com/package/vite-plugin-preload) because it evaluates used modules at render time rather than including every single module in the HTML at build time.

Includes functionality similar to [loadable-components](https://loadable-components.com/) where you can create `<link rel=preload>` tags and `Link: </s.js>; rel=preloadmodule;` headers using `getLinkTags()` and `getLinkHeaders()`

#### See [./playground](./playground/) for a basic setup with preloading

Expand Down Expand Up @@ -56,9 +58,12 @@ export default defineConfig({
### Setup on the server in your render handler

```tsx
import { ChunkCollectorContext } from 'vite-preload';
import { ChunkCollectorContext, preloadAll } from 'vite-preload';

async function handler(req, res) {
// Preload all async chunks on the server otherwise the first render will trigger the suspense fallback because the lazy import has not been resolved
await preloadAll();

function handler(req, res) {
const collector = createChunkCollector({
manifest: './dist/client/.vite/manifest.json',
entry: 'index.html',
Expand Down Expand Up @@ -109,18 +114,44 @@ function handler(req, res) {
}
```

## Options

`createChunkCollector(options)`
- `manifest`: string/object - path to the vite manifest or the manifest object (defaults to `./dist/client/.vite/manifest.json`)
- `entry`: string - entry name, defaults to `index.html`
- `preloadFonts`: true/false - Include fonts, true by default
- `preloadAssets`: true/false - Include assets like images and fonts

`ChunkCollector`
- `getTags()`: Returns a string with `<link>` tags to be included in the HTML head
- `getLinkHeaders()`: Returns a list with `Link` header values

## Migrating from `loadable-components`

Replace all `loadable(() => import('./module'))` with `React.lazy(() => import('./module'))` and evaluate if it performs well enough for your use case.
Replace all

```tsx
import loadable from '@loadable/component'
loadable(() => import('./module'))
```
with
```tsx
import { lazy } from 'vite-preload'
lazy(() => import('./module'))
```
and evaluate if it performs well enough for your use case.

Look into the examples below for other ways to optimize your app.

## Usage with `React.lazy`

React.lazy works with Server Rendering using the React Streaming APIs like [renderToPipeableStream](https://react.dev/reference/react-dom/server/renderToPipeableStream)

vite-preload exports a `React.lazy` wrapper that supports preloading using `Component.preload()` and `preloadAll()`

```tsx
import { lazy, Suspense } from 'react';
import { Suspense } from 'react';
import { lazy, preloadAll } from 'vite-preload';

const Card = lazy(() => import('./Card'));

Expand All @@ -135,11 +166,13 @@ function App() {
}
```

### Server

> [!NOTE]
> React.lazy has some undeterministic behaviour in server rendering.
>
> - The first render on the server will always trigger the suspense fallback. One solution to fix this is to use something similar to [react-lazy-with-preload](https://npmjs.com/packages/react-lazy-with-preload) and .preload() every single lazy import on the server
> - Larger components in large projects that takes time to load will trigger the suspense fallback on the client side, even if the component is server rendered. This might be avoided by preloading all async routes before hydration and skipping the top level Suspense boundary.
> - The first render on the server will always trigger the suspense fallback. Use `await preloadAll()` on the server to preload all async components before rendering the app.
> - Larger components in large projects that takes time to load will trigger the suspense fallback on the client side, even if the component is server rendered. This might be avoided by preloading all async routes before hydration with `await preloadAll()` or skipping the top level Suspense boundary.

## Usage with `react-router`
Expand Down Expand Up @@ -233,21 +266,24 @@ The manifest entry for this chunk would look similar to
"css": [
"assets/lazy-component-DHcjhLCQ.css"
]
},
}
```


## Server
The React server uses the context provider to catch these hook calls and map them to the corresponding client chunks extracted from the manifest

```tsx
import { ChunkCollectorContext } from 'vite-preload';
import { ChunkCollectorContext, preloadAll } from 'vite-preload';

const collector = createChunkCollector({
manifest: './dist/client/.vite/manifest.json',
entry: 'index.html',
});

// Preload all async components before rendering the app to avoid the first render to trigger the suspense fallback
await preloadAll();

renderToPipeableNodeStream(
<ChunkCollectorContext collector={collector}>
<App />
Expand Down
9 changes: 8 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
"@babel/parser": "^7.25.0",
"@babel/traverse": "^7.25.0",
"@babel/types": "^7.25.0",
"mime": "^4"
"mime": "^4",
"react-lazy-with-preload": "^2.2.1"
},
"keywords": [
"react",
Expand Down
55 changes: 40 additions & 15 deletions playground/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
"dependencies": {
"compression": "^1.7.4",
"express": "^4.19.2",
"react": "19.0.0-rc-e210d081-20240909",
"react-dom": "19.0.0-rc-e210d081-20240909",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-lazy-with-preload": "^2.2.1",
"sirv": "^2.0.4"
},
"devDependencies": {
Expand Down
4 changes: 2 additions & 2 deletions playground/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,12 @@ app.use('*', async (req, res) => {
res.writeEarlyHints({
link: collector.getLinkHeaders(),
});
await setTimeout(2000);
await setTimeout(1000);
}

const [head, rest] = template.split(`<!--app-html-->`);

const { pipe } = render(url, collector, {
const { pipe } = await render(url, collector, {
nonce,
onShellError() {
res.status(500);
Expand Down
7 changes: 4 additions & 3 deletions playground/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import React, { Suspense } from 'react';
import { Suspense } from 'react';
import reactLogo from './assets/react.svg';
import './App.css';
import { lazy } from '../../src';

function slowImport(promise: () => Promise<any>) {
return () => {
return new Promise<any>((resolve) => {
setTimeout(() => resolve(promise()), 2000);
setTimeout(() => resolve(promise()), 1000);
});
};
}

// Works also with SSR as expected
const Card = React.lazy(slowImport(() => import('./Card')));
const Card = lazy(slowImport(() => import('./Card')));

export default function App() {
return (
Expand Down
5 changes: 4 additions & 1 deletion playground/src/entry-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import {
} from 'react-dom/server';
import App from './App';
import { ChunkCollector, ChunkCollectorContext } from '../../src';
import { preloadAll } from '../../src';

export function render(
export async function render(
_url: string,
collector: ChunkCollector,
options?: RenderToPipeableStreamOptions
) {
await preloadAll();

return renderToPipeableStream(
<React.StrictMode>
<ChunkCollectorContext collector={collector}>
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as ChunkCollectorContext } from './ChunkCollectorContext';
export { createChunkCollector, ChunkCollector } from './collector';
export * from './utils';
export { lazy, preloadAll } from './lazy';
36 changes: 36 additions & 0 deletions src/lazy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ComponentType } from 'react';
import { lazyWithPreload } from 'react-lazy-with-preload';

let preloads: (() => Promise<any>)[] = [];

/**
* Drop in replacement for React.lazy that also supports preloading.
*
* Must be used to be able to call `preloadAll()` on the server.
*
* @example const LazyComponent = lazyWithPreload(() => import('./Component'));
*/
export function lazy<T extends ComponentType<any>>(
factory: () => Promise<{
default: T;
}>
) {
const z = lazyWithPreload(factory);
preloads.push(z.preload);
return z;
}

/**
* Preload all detected lazy() components.
*
* Should be used on the server to resolve all lazy imports before rendering to avoid the Suspense loading state to be triggered on the first render.
*/
export async function preloadAll() {
// Preload all lazy components up to a depth of 3
for (let i = 0; i < 3 && preloads.length > 0; i++) {
console.log('Preloading', preloads.length, 'lazy components');
const _preloads = preloads;
preloads = [];
await Promise.all(_preloads.map((preload) => preload()));
}
}

0 comments on commit ab3ac5d

Please sign in to comment.