Library Upgrade Guide: <script> (e.g. SSR frameworks) #114
sebmarkbage
announced in
Announcement
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
Library Upgrade Guide:
<script>
This is an upgrade guide for frameworks and custom app setups that manages loading JS modules and inserting script tags. In particular if they work to support SSR. E.g. next/dynamic, react-loadable, Webpack, etc.
Before React 18, the way you'd do server rendering is to first render all the content to figure out which scripts you might need. Perhaps you'd eagerly emit a few preload tags you know you'll need. You'd also emit some script tags containing the JSON data you used during server rendering so that you can use the same exact data during hydration. On the client, everything would wait for all the scripts to load and all the data to load before you hydrate.
In React 18, the best practice is flipped. You should ideally start hydration before lazy scripts have loaded and ideally even before the data is available.
Since you can stream content, you might also discover more dependencies that you didn't yet know about. So the order of priority isn't always that of the document order.
Don't use
defer
Using
<script src="..." defer>
lets you start loading a script but wait to execute it until the page has fully loaded. This is an anti-pattern in React 18 since it should allow you to stream content that's slow to load late in the stream. That means that the initial HTTP connection can remain open for longer than the main content which will prevent those scripts to start hydrating as early as possible. You should never use justdefer
with React 18 and instead useasync
which ensures the module is available as soon possible.Similarly, if you use ESM in the browser,
<script src="..." type="module">
isdefer
by default. You should always use modules withasync
. E.g.<script src="..." type="module" async>
Don't use
preload
In the past it might have been recommended that you'd use
<link rel="preload" href="..." as="script"/>
to start loading scripts as early as possible.The problem with
preload
is that it gives this resource the highest priority possible for the browser to download. Depending on browser. This might seem like it's high priority since you know you'll eventually need it, however, if there are other resources such as CSS and images pending, it also blocks the user from seeing the content. CSS, fonts, images should use thepreload
but not the scripts used for hydration.Before React 18's streaming support, you could have solved this by sorting CSS and images earlier in the document so that they're discovered earlier. With streaming support you might discover the need for more images later in the stream. This was already the case for systems that emitted important script tags as early as possible.
Instead, you can emit the script tags that you're actually going to use early in the document
<script src="..." async>
. This works better if you use lazily initialized module bodies. Otherwise they'll start executing the module scope at the wrong priority.Bootstrapping Hydration
In all setups at least one script tag is used to kick off the hydration (and other side-effects). This is the bootstrap script. I.e. there's a side-effectful module somewhere which ends up calling
hydrateRoot(container, ...)
.This should ideally happen as early as possible so that if some modules are still downloading we can at least start hydrating the components that have downloaded so far. It's also ideal to start hydrating before the full stream is done so that you can start hydrating the shell around the content that's still loading. This parallelizes CPU time with I/O.
However, currently, it can't be earlier than when at least the shell HTML has loaded. I.e. the container must at least be there. Therefore you can't insert this script tag too early in case it asynchronously executes before the rest of the shell HTML has loaded.
In other words, you want to insert this bootstrap script tag after the first shell has streamed but before the end of the stream. Luckily React has the
bootstrapScripts
option to do exactly this:If you use ESM, you use the
bootstrapModules
option instead.Since this script won't be discovered early, the browser can't start a parallel download of the file early. You can use a
preload
tag to solve that but that suffers from the same prioritization problem. If it's small enough, this might be the right tradeoff. You can tryprefetch
but that's not guaranteed to work. You can try priority hints, but that's not implemented in all browsers.Another technique is to load all modules early but not have them kick off hydration if they load early but instead export a global function.
Then inject an inline script into the HTML that invokes this function after the shell has loaded.
This also provides an opportunity to inject configuration info into the root shell.
Lazy Components
Libraries like react-loadable, next/dynamic etc. use a technique where if they discover a lazy component during server rendering, they emit those scripts early on in the stream since you know you'll need them. This is a great feature since you only download those paths if you know you're going to need them.
If these don't use Suspense then you also need to wait to start hydration until they've downloaded so that they're synchronously available when React renders. However, in React 18, these can start using Suspense by using React.lazy instead.
This allows React to start hydrating everything else without actually waiting for these chunks to download. This enables us to parallelize I/O and CPU time.
React.lazy just works out of the box because it uses dynamic
import(...)
. Once your bootstrap script has loaded, any module runtime (like Webpack), will have already loaded. When React sees one of these during hydration, it'll go fetch it. Then when it's ready, continue hydrating.However, this is not the optimal performance. For optimal performance you'll also want to have the client start downloading these scripts early. Again, without using
preload
.To do this, you can inject these chunks as
<script src="..." async>
tags earlier into the stream.NOTE: Unlike before React 18, you should not block the bootstrap script on these dependencies. In other words, you should not delay the boostrap module. That will lead to delaying the hydration and minimizing parallelization and early hydration of the shell.
Injecting Into the SSR Stream
To inject these early script tags as new Lazy Components are discovered you need to manually inject them into the HTML stream. This is the same technique as described for
<link>
tags.Today, you might do something like this to collect all the dependencies that were discovered during server rendering and then prepend and append them to the HTML.
This doesn't work for streaming since you won't have all the style tags up front. Instead, you'll need to collect all dependencies discovered up until a certain point and emit them before the corresponding HTML. Then after React has rendered some more, you need to generate new script tags.
To insert into the stream at the right timing, you'll need to provide an intermediate stream.
For Node.js that means creating a wrapper Writable.
Note that it's important to use a custom Writable instead of a Transform stream to support forwarding of the flush() command, to avoid GZIP buffering.
For a Web Streams, there's no GZIP flushing support anyway. Browser support is a little flaky but the principle is the same. Find a way to inject before whatever React writes.
Ensure that you've already written the
<!doctype html><html><head>
part before writing any script tags. Otherwise the script tags could be written before the doctype which would break the page.Note: React can write fractional HTML chunks so it's not safe to always inject HTML anywhere in a write call. The above technique relies on the fact that React won't render anything between writing. We assume that no more link tags will be collected between fractional writes. It is not safe to write things after React since there can be another write call coming after it.
It might seem counter-intuitive to insert the script tags before the HTML since it's usually considered best-practice to insert them at the end of the page rather than in the HTML. However, they won't significantly block the HTML since they're async. Even if they resolve early, they should ideally only parse the wrapper function. The script tags are also small, so they don't significantly block display of the HTML. Where as the HTML chunk can be large and so could significantly delay the start of the load of those scripts if they were at the bottom of it.
Selective Hydration and Double Scripts
With early Selective Hydration, we case start hydrating some parts of the page before even the stream is done. This means that you can start interacting with the page before it's fully loaded. Those interactions might themselves load other lazy components.
In edge cases those might load modules that the server also ends up streaming in later. In that case you might end up with the same script inserted twice. This is generally ok since if it's an ESM module, it's only loaded once anyway. If it's a Webpack module, they end up getting deduped in the runtime anyway but you might pay a little extra for parsing. This is a rare edge case though so it's not worth worrying about.
Lazily Execute Module Bodies
By default, JavaScript imports will execute the module bodies as part of the
import(...)
call or<script type="module" src="...">
. When you load large graphs of modules, most of which won't be used, this can be really slow. Even if it's just allocating classes and functions without side-effects.If you use a
preload
or other technique of loading it into the HTTP cache, you can have it already loaded and then lazilyimport(...)
it when you need it. The problem with this technique is that you get an asynchronous gap and React needs to suspend each time that happens. That adds a little bit of overhead when it happens but the more you use it, the more you add to the overhead.In React Server Components, all client components are lazy by default. That's why React Server Components depend on special bundler integration and doesn't work with plain browser ESM. The same problem can happen if you use a lot of React.lazy too though.
Note: If you're building a framework, React.lazy supports returning synchronously resolving thenables instead of standard promises. You might want to do that to optimize performance where lazy is used.
Webpack works by injecting a closure around each module in each
<script src="...">
but it doesn't actually execute this closure. It just ensures that it's available in memory when needed. Then you can synchronously execute that when needed. React Server Components uses this technique using Webpack specific runtime hooks.If you are working on another bundler you might want to look into ways to inject lazy synchronous module body execution into React.
If you use a bundler that emits ESM, you likely will hit suboptimal performance one way or another. If you have to compromise, it's likely best to eagerly execute all the chunks by injecting them as
<script type="module" src="...">
tags to avoid an async waterfall. Personally, my favorite technique is a bundler that emits ESM but also wraps its initialization code in a closure to lazily initialize it.Hydration Data
You don't just need code to hydrate. You also need the data that was used on the server. If you use a technique that fetches all data before rendering you can emit that data in the
bootstrapScriptContent
option as an inline script. That way it's available by the time the bootstrap script starts hydrating. That's the recommended approach for now.However, with only this technique streaming doesn't do much anyway. That's fine. The purpose of these guides is to ensure that old code can keep working, not to take full advantage of all future functionality yet.
The real trick is when you use Suspense to do you data fetching during the stream. In that case you might discover new data as you go. This isn't quite supported in the React 18 MVP. When it's fully supported this will be built-in so that the serialization is automatic.
However, if you want to experiment with it. The principle is the same as the lazy scripts. You start hydrating as early as possible with just a placeholder for the data. When the placeholder is missing data, and you try to access it, it suspends. As more data is discovered on the server, it's emitted as script tags into the HTML stream. On the client that is used as a signal to unsuspend the accessed data.
nonce
React Streaming Rendering injects inline scripts for updating the tree during streaming and for injecting the
bootstrapScriptContent
option. If you have a Content Security Policy that blocks inline scripts you can use thenonce
option to configure which key React should use for these scripts. It will not enable inline scripts rendered by React components using JSX. Those have to provide an explicit nonce.Future
The goal is to make it so that you don't have to manually inject script tags into the stream and that React just does all of that out of the box. The
bootstrapScriptSrcs
option is the first step to that.The next step will be to have data serialization with React Server Components work out of the box. That way if you load your data with Server Components, you don't have to manually wire up that data for hydration purposes. We might also add more options for other React I/O providers.
Actually loading new scripts using
import(...)
just works when you're already running a module runtime on the client. However, it leads to suboptimal performance when you know earlier that you'll need it later. Therefore we'll add a functionality to preload scripts from React. E.g.preloadScript(url)
andpreloadModule(url)
. On the client, these will make it possible to load the code into memory early without actually executing it until it's needed. On the server, we'll use this as a hint to insert early<script>
tags, priority hints or whatever is best at the time. This replaces the need for a special runtime for next/dynamic, react-loadable etc.After that I think there's no more needs to manually inject script tags.
Beta Was this translation helpful? Give feedback.
All reactions