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).
Read more about Nested modules and loading performance
This plugin is different to 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 where you can create <link>
tags and Link
headers for the rendered chunks.
See ./playground
for a basic setup with preloading
Any lazy imported React component and its CSS will only be loaded once the parent module has been loaded and executed. This will lead to a Flash Of Unstyled Content (FOUC) and a delay in loading the required chunks for the React client.
This plugin will collect which modules are rendered on the server and help you inject <link>
tags in the HTML <head>
and Link
preload headers for which you can use with 103 Early Hints which you can use to make the browser work before React even started rendering on the server.
You can see the async chunks being loaded after the JS started executing (indicated by the red line)
You can see that the async chunks are loaded directly and instantly available once the JS starts executing
$ npm install vite-preload
Setup the Vite plugin to inject a helper function in your dynamically imported React components that will make lazy components detected on SSR.
import preloadPlugin from 'vite-preload/plugin';
export default defineConfig({
plugins: [
preloadPlugin(),
react(),
// ...
],
});
Important
Preloading does not show up in development mode. In development mode, Vite will inject CSS using inline style tags on demand, which will always come with some Flash Of Unstyled Content (FOUC). Read more
Note
If some of your modules does not get preloaded, make sure build.rollupOptions.output.experimentalMinChunkSize
is not set. Rollup might merge chunks so they are not mapped in the manifest and can't be found when calculating what chunks to 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();
const collector = createChunkCollector({
manifest: './dist/client/.vite/manifest.json',
entry: 'index.html',
})
const template = process.env.NODE_ENV === 'production' ? await fs.readFile('./dist/client/index.html', 'utf8') : undefined;
const [head, tail] = template.split('<!-- app-root -->')
// Write a HTTP 103 Early Hint way before React even started rendering with the entry chunks which we know we will be needed by the client.
// Chrome will only pick it up when running with HTTP/2 so try Firefox if you want to test it.
res.writeEarlyHints({
link: collector.getLinkHeaders()
})
const { pipe } = renderToPipeableNodeStream(
<ChunkCollectorContext collector={collector}>
<App />
</ChunkCollectorContext>,
{
// onShellReady also works but it will miss chunks that React does not consider a part of the shell, like server components or lazy components on the server that has not resolved. React will always suspend and show the fallback ui for lazy components on the first server render unless you preload all lazy components using something like react-lazy-with-preload first.
onAllReady() {
const linkTags = collector.getTags();
const linkHeaders = collector.getLinkHeaders();
// The link header below now contains
res.writeHead(200, {
'content-type': 'text/html',
'link': linkHeaders
})
// Inject <link rel=modulepreload> and <link rel=stylesheet> in the head. Without this the CSS for any lazy component would be loaded after the app has and cause a Flash of Unstyled Content (FOUC).
res.write(head.replace('</head>', `${linkTags}\n</head>`););
const transformStream = new Transform({
transform(chunk, encoding, callback) {
res.write(chunk, encoding);
callback();
},
});
pipe(transformStream)
transformStream.on('finish', () => {
res.end(tail);
});
}
}
);
}
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 toindex.html
preloadFonts
: true/false - Include fonts, true by defaultpreloadAssets
: true/false - Include assets like images and fonts
ChunkCollector
getTags()
: Returns a string with<link>
tags to be included in the HTML headgetLinkHeaders()
: Returns a list withLink
header values
Replace all
import loadable from '@loadable/component'
loadable(() => import('./module'))
with
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.
React.lazy works with Server Rendering using the React Streaming APIs like renderToPipeableStream
vite-preload exports a React.lazy
wrapper that supports preloading using Component.preload()
and preloadAll()
import { Suspense } from 'react';
import { lazy, preloadAll } from 'vite-preload';
const Card = lazy(() => import('./Card'));
function App() {
return (
<div>
<Suspense fallback={<p>Suspending...</p>}>
<Card />
</Suspense>
</div>
)
}
Note
React.lazy has some undeterministic behaviour in server rendering.
- 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.
React Router v6 supports lazy routes using the lazy
prop on the Route
component.
When navigating on the client side to a lazy route, the document will not repaint until the lazy route has been loaded, avoiding a flash of white like when using loadable-components with react-router. This might have a negative impact on your INP metric, so you might want to use deterministic loading of lazy routes like preloading the next step in the user flow or preloading them all in the background after the primary modules has been loaded.
Note
When hydrating a lazy route, the server rendered HTML will be thrown away, cause a hydration mismatch error, then load and render again. To prevent this so you will need preload all the lazy routes rendered by the server like in the example below. See https://reactrouter.com/en/main/guides/ssr#lazy-routes
import { Route } from 'react-router'
import { hydrateRoot } from 'react-dom/server'
function lazyRoute(dynamicImportFn: () => Promise<any>) {
return async () => {
const { default: Component } = await dynamicImportFn()
return { Component }
}
}
const routes = (
<Route lazy={lazyRoute(() => import('./Card'))} />
)
function loadLazyRoutes() {
const matches = matchRoutes(routes, window.location);
if (!matches) {
return
}
const promises = matches.map(match => {
if (!m.route.lazy) {
return
}
const routeModule = await m.route.lazy!()
m.route.Component = routeModule.Component
delete m.route.lazy
Object.assign(m.route, {
...routeModule,
lazy: undefined,
})
});
await Promise.all(promises)
}
async function main() {
await loadLazyRoutes()
ReactDOM.hydrateRoot(
<RouterProvider router={router} />,
document.getElementById('root')
)
}
The plugin will inject a hook in every dynamically imported React component. This is because we need to map the module id to the corresponding client chunks from the generated Vite manifest.json.
Below is what the plugin would inject in a module imported using React.lazy(() => import('./lazy-component'))
:
// src/lazy-component.tsx
import { __collectModule } from 'vite-preload/__internal';
import styles from './lazy-component.module.css';
export default function Lazy() {
__collectModule('src/lazy-component.ts'); // Injected by the plugin
return <div className={styles.div}>Hi</div>
}
The manifest entry for this chunk would look similar to
"src/lazy-component.tsx": {
"file": "assets/lazy-component-CbXeDPe5.js",
"name": "lazy-component",
"src": "src/lazy-component.tsx",
"isDynamicEntry": true,
"imports": [
"_vendor-1b2b3c4d.js",
],
"css": [
"assets/lazy-component-DHcjhLCQ.css"
]
}
The React server uses the context provider to catch these hook calls and map them to the corresponding client chunks extracted from the manifest
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 />
</ChunkCollectorContext>,
{
onAllReady() {
collector.getChunks()
// app.94184122.js
// app.94184122.css
// lazy-module.94184122.js
// lazy-module.94184122.css
}
}
)
- The 103 Early Hint will preload the entry chunks because they are known to be needed by the client. It's sent before React starts doing anything.
- The 200 OK response headers also contains the chunks of the lazy JS and CSS that were rendered.
- The head of the document uses the stylesheets, executes the primary module, and leaves the async modules as preloads for the browser to instantly use when a dynamic import is used.
HTTP/2 103
link: </assets/index-CG7aErjv.js>; rel=modulepreload; crossorigin
link: </assets/index-Be6T33si.css>; rel=preload; as=style; crossorigin
HTTP/2 200
content-type: text/html; charset=utf-8
link: </assets/index-CG7aErjv.js>; rel=preload; as=module; crossorigin
link: </assets/index-Be6T33si.css>; rel=preload; as=style; crossorigin
link: </assets/Card.tsx>; rel=modulepreload; crossorigin
link: </assets/Card.css>; rel=preload; as=style; crossorigin
<html>
<head>
...
<script type="module" crossorigin src="/assets/index-CG7aErjv.js"></script>
<link rel=modulepreload href="/assets/Card.tsx" crossorigin>
<link rel="stylesheet" crossorigin href="/assets/index-Be6T33si.css">
<link rel=stylesheet href="/assets/Card.css" crossorigin>
...
</head>
<body>
...
</body>
</html>