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

React 18 Streaming SSR #3658

Open
gshokanov opened this issue Jan 4, 2022 · 48 comments
Open

React 18 Streaming SSR #3658

gshokanov opened this issue Jan 4, 2022 · 48 comments
Labels
6.0 proposals for the next major of styled-components help wanted 🆘

Comments

@gshokanov
Copy link

gshokanov commented Jan 4, 2022

Hi folks,

React 18 will be rolled out in the near future and currently has an RC release that anyone can try out. I thought I would try the new streaming SSR mode with one of the projects I'm working on that uses styled-components and while working on an implementation I realised that styled-components doesn't have a compatible API yet.

Here's a link to the sandbox with the demonstration of the new streaming API by Dan Abramov. React 18 exposes a new function for rendering on the server called renderToPipeableStream. As part of it's API it exposes a pipe function that expects a target stream (typically a response stream). The existing styled-components API interleaveWithNodeStream expects a stream exposed by React which makes the whole thing incompatible with each other.

I tinkered with the whole thing a bit and the solution seems to be simple - expose a new API that wraps pipe function provided by React, returns a new function with the same API that uses the same internal Transformer stream logic used in interleaveWithNodeStream. The version I came up with looks something like this:

type PipeFn = (destination: streamInternal.Writable) => void;

export default class ServerStyleSheet {
 // existing code here...

  interleaveWithPipe(pipe: PipeFn): PipeFn {
    this._guard();
    this.seal();

    return (destination) => {
      const transformStream = this._getNodeTransformStream();

      transformStream.pipe(destination);
      pipe(transformStream);
    };
  }
}

_getNodeTransformStream creates the same stream as currently seen in interleaveWithNodeStream.

I got the whole thing working together and would be glad to contribute this as a PR, however I run into an interesting issue while working on it.

It seems that there's currently a difference in handling SSR on the main branch and on the legacy-v5 branch which I believe is the latest stable release. The difference pretty much comes down to this particular commit. React seems to be emitting rendered HTML in very small chunks, sometimes even without closing the current tag. Here's what I am talking about:

<div

data-kit
="
progress
"

role
="
progressbar
"
<!-- the rest goes on -->

Every line in the code sample above is a separate chunk emitted by React. Even attributes on tags are split between multiple chunks. Naturally this does not play well with the current implementation in main branch since ServerStyleSheet will insert a style tag after every chunk emitted by React which breaks the HTML and leads to garbage data being rendered to user. Interestingly, the implementation in legacy-v5 works since it does not insert a new style tag if there's no css that needs to be added to stream but this seems like a coincidence rather than something planned.

I wonder if it makes sense instead of copying the current logic from legacy-v5 branch to instead buffer the data emitted by React until it reaches a more reasonable size and then emit it alongside it's style tag if needed.

Would love to discuss it with someone with a deeper understanding of the codebase. I hope I got everything right, I'll happily answer any questions you may have. Any help with this one is much appreciated

@romeovs
Copy link

romeovs commented Apr 11, 2022

Is there any plan to support this? React 18 has been released this month and I would like to be able to switch to it.

@nathanschwarz
Copy link

any news regarding react 18 ssr support ?

@keeganstreet
Copy link
Contributor

Hey @gshokanov, I'm curious to know whether your spike included using Styled Components in a delayed content block that is wrapped in Suspense? I was assuming that sheet.collectStyles would run synchronously and not be able to collect the styles for the asynchronous SSR components.

@adbutterfield
Copy link

I came up with a pretty simple workaround, pipe renderToPipeableStream into a duplex stream, which you can then pass to interleaveWithNodeStream. You can see here: https://github.com/adbutterfield/fast-refresh-express/blob/react-18/server/renderToStream.ts

Of course you don't get true streaming render though.

Currently you get a hydration mismatch error, which I think is the same as this issue: facebook/react#24430

@switz
Copy link

switz commented Jul 1, 2022

Next.js has now rolled out streaming SSR support so this is now a big blocker for folks to opt-in to that.

https://nextjs.org/docs/advanced-features/react-18/streaming

Here's the style upgrade guide for library authors

@adbutterfield
Copy link

Maybe not ideal, but I got something working now. Might be a way to go if you want to upgrade to React 18 now, and then hopefully get all the benefits of streaming rendering sometime in the future.
You can see this repo here: https://github.com/adbutterfield/fast-refresh-express

@freese
Copy link

freese commented Sep 6, 2022

This is a pretty big blocker for many. In our team, we are working to upgrade to React18 and one of our main bets, to solve some remaining TTFB Issues, would be to use HTML Streaming or however you wish to call it.
We are prevented right now, due to the lack of support by styled-components.

Is this on the pipeline at all or not? I see the team is working actively on the beta v6, but I see no mentions at all.
A simple yes/no answer would suffice, so we can start searching for replacement or another solution.
Thank you for your work!

@beefchimi
Copy link

beefchimi commented Sep 13, 2022

@freese the best that I can determine is that useInsertionPoint was added to the codebase in the v6-beta.0 release:
https://github.com/styled-components/styled-components/releases/tag/v6.0.0-beta.0

This hook (as I understand it) is specifically for authors of css-in-js libraries for inserting global DOM nodes (like <style />)
https://reactjs.org/docs/hooks-reference.html#useinsertioneffect

I take this as a sign the authors are working towards a solution. Might not be fully realized until a v7 however. I'm only guessing.

Worth noting that React's official stance on this is:

Our preferred solution is to use <link rel="stylesheet"> for statically extracted styles and plain inline styles for dynamic values. E.g. <div style={{...}}>. You could however build a CSS-in-JS library that extracts static rules into external files using a compiler. That's what we use at Facebook (stylex).

@wmertens
Copy link
Contributor

Curious how this will be solved. How about keeping a buffer of styles while components are rendering and every time there's a chance to emit a <style> tag, empty the buffer into it?

@gaojude
Copy link

gaojude commented Oct 17, 2022

I managed to emit a style tag for each boundary component, but it was only possible by changing the ReactDOMServer code to expose a hook. Since React has made it clear in reactwg/react-18#110 that they would not support anything new upstream to accommodate this kind of CSS-in-JS problem, my solution would be a hack at this point and maybe a risky thing to be used in production.

Based on the same doc, it speculates that there will be performance implications from the concurrent mode of React 18, even if you could solve this streaming issue.

@wmertens
Copy link
Contributor

wmertens commented Nov 2, 2022

@kayakyakr
Copy link

Curious how this will be solved. How about keeping a buffer of styles while components are rendering and every time there's a chance to emit a <style> tag, empty the buffer into it?

Looks like this PR is doing something similar to this suggestion: #3821

Wondering if this gets us any closer to React18 SSR support. Huge blocker for us, so I'm interested to hear any contributor feedback on potential solutions.

@ericselkpc
Copy link

Emotion does React 18 streaming by inserting styles in the stream https://github.com/emotion-js/emotion/blob/92be52d894c7d81d013285e9dfe90820e6b178f8/packages/react/src/emotion-element.js#L149-L153

But it seems like Emotion doesn't support renderToPipeableStream either, otherwise it seems like emotion/styles might be a pretty simple drop-in replacement, the syntax looks identical to Styled Components.

emotion-js/emotion#2800

Hopefully one of these libraries is able to add support soon -- my massive React app is 50% CSS (most components have an equal amount of CSS vs JS/JSX), so the thought of migrating to something like CSS modules is keeping me up at night.

@wmertens
Copy link
Contributor

wmertens commented Dec 8, 2022

@ericselkpc take a look at https://github.com/wmertens/styled-vanilla-extract - it's for qwik right now but adding react would not be hard.

@ericselkpc
Copy link

@ericselkpc take a look at https://github.com/wmertens/styled-vanilla-extract - it's for qwik right now but adding react would not be hard.

Thanks. We use a lot of props in our styled components that would be difficult to migrate to inline styles or other methods. Very nice work though. I love the zero runtime idea, just maybe not practical in our case where content comes from CMS and would require a new build on each change to have full SSG instead of SSR.

@quantizor
Copy link
Contributor

React seems to be emitting rendered HTML in very small chunks, sometimes even without closing the current tag.

I think this code would need to change:

if (CLOSING_TAG_R.test(renderedHtml)) {
const endOfClosingTag = renderedHtml.indexOf('>') + 1;
const before = renderedHtml.slice(0, endOfClosingTag);
const after = renderedHtml.slice(endOfClosingTag);
this.push(before + html + after);
} else {
this.push(html + renderedHtml);
}

It seems like the chunks emitted by ReactPartialRenderer are even more granular than they were in React 17 and below.

@quantizor quantizor added help wanted 🆘 6.0 proposals for the next major of styled-components labels Dec 24, 2022
@Andarist
Copy link
Contributor

Andarist commented Mar 2, 2023

Hi, it's March - and it seems that I got heavily occupied with other OSS work and couldn't find the time for this one. I need to keep my word though so I'll be dusting off my work on this over the weekend and I plan to wrap it up soon. I already have a working PoC + some extra pointers from the Next.js team but it's the last 20% of the work that takes the most time 😉

@thomasmost
Copy link

thomasmost commented Mar 22, 2023

@Andarist since this issue is marked "help wanted"—is there anything I could do to help get this over the finish line? Alpha testing a forked version of the package? Code review? Pairing?

Thanks for all the hard work you've clearly already done on this :) as you can probably deduce, I'm excited to get this into my NextJS app 🙂

@thomasmost
Copy link

Thanks for all the hard work you've clearly already done on this :) as you can probably deduce, I'm excited to get this into my NextJS app 🙂

Actually, never mind! Next seems to have already resolved streaming SSR issues with styled-components — @Andarist you perhaps alluded to that here?

Oh, and integrating this with frameworks might look slightly different - it kinda depends on the framework. For instance, in Next.js you wouldn't actually use this integration at all.

@joacub
Copy link

joacub commented Mar 27, 2023

Thanks for all the hard work you've clearly already done on this :) as you can probably deduce, I'm excited to get this into my NextJS app 🙂

Actually, never mind! Next seems to have already resolved streaming SSR issues with styled-components — @Andarist you perhaps alluded to that here?

Oh, and integrating this with frameworks might look slightly different - it kinda depends on the framework. For instance, in Next.js you wouldn't actually use this integration at all.

o really?, and what is the solution and where is posted it ?

Thanks im waiting to have this too.

@tom-sherman
Copy link

@joacub See https://beta.nextjs.org/docs/styling/css-in-js#styled-components

@joacub
Copy link

joacub commented Mar 28, 2023

@joacub See https://beta.nextjs.org/docs/styling/css-in-js#styled-components

Thank you so much, I’m wondering if I can use the same approach for emotion

@ericselkpc
Copy link

Since I don't know NextJS, I'm having some trouble figuring out how good this news is for us using React without NextJS. Should we expect things to just work following the React + Styled Components docs now, or do we need to add some special work-arounds or no real options for us yet other than modifying React or Styled Components code?

@krrssna-g-s
Copy link

is there any plan to support on non framework react SSR apps with streaming?

@quantizor
Copy link
Contributor

@krrssna-g-s The current setup does not require a framework: https://styled-components.com/docs/advanced#streaming-rendering

I assume whatever the React 18 solution ends up being wouldn't require one either.

@Andarist
Copy link
Contributor

Andarist commented Apr 4, 2023

We spoke with @probablyup a few weeks back and we agreed that it's not worth adding new APIs to aid the current React 18 streaming APIs. I totally forgot to report this back here in the thread.

It's very unfortunate - I have some code lying around that implements this outside of Styled-Components (gonna clean it up and share it here if anybody is interested). The problem is that React plans to introduce new APIs to make this way easier for libraries like Styled-Components/Emotion. Of course, the ETA is a little bit unknown - but this is part of the work that is actively worked on in their repository. I'm optimistic that it won't take as long as Suspense/Time-Slicing took 😉

So given this fact... we are put in an uncomfortable position. We could add this new API today, to make it usable today for those who care but we'd have to do a bunch of work to ship something that will soon-ish be deprecated. At the same time, we wouldn't be able to drop it from Styled-Components immediately because the library has to maintain backward compatibility.

@Xiphe
Copy link

Xiphe commented Apr 4, 2023

That makes a lot of sense. Thanks for the update!

@EmilioAiolfi
Copy link

@Andarist can you share your solution? Please. Maybe it can help while we wait for the React team to apply the new solutions

@sdcooke
Copy link

sdcooke commented May 18, 2023

For anyone looking for a hacky way to do this (this doesn't require changes to any libraries but is quite hacky and not tested against every edge case), I threw this together and it seems to work reliably. When react streams content into a page it will always do that by sending a <script> tag so this injects the new styles at the start of each script tag. I also used this technique to inject fetched data into the page. Just call patchResponse(resp, sheet) on your response.

import * as express from "express";
import type { ServerStyleSheet } from "styled-components";

const STYLED_COMPONENTS_VERSION = "5.3.6";
const INITIAL_STYLES_TAG = `<style data-styled="active" data-styled-version="${STYLED_COMPONENTS_VERSION}">`;
const SCRIPT_TAG = "<script>";

export const patchResponse = (resp: express.Response, sheet: ServerStyleSheet) => {
  let response = "";
  let offset = 0;
  let initialStylesInjected = false;
  let lastScriptIndex = -1;
  let existingStyles = [] as string[];
  const decoder = new TextDecoder();

  // We patch the "write" method that react uses to output the HTML
  const write = resp.write.bind(resp);
  resp.write = (chunk: Buffer, ...args: any) => {
    const decodedChunk = decoder.decode(chunk);
    response += decodedChunk;
    const chunkLength = decodedChunk.length;

    if (!initialStylesInjected) {
      // As soon as we see the first <style> tag, we inject initial styles as
      // a <style> tag. For non-streamed rendering this means all styles will
      // be included without JS on render.
      const index = response.indexOf(INITIAL_STYLES_TAG);
      if (index > -1) {
        const styles = getNewStyles(sheet, existingStyles);
        if (styles.length) {
          chunk = inject(chunk, index - offset + INITIAL_STYLES_TAG.length, styles);
        }
        initialStylesInjected = true;
      }
    }

    // The streamed SSR is updated with script tags that are streamed into the
    // page by react. This code finds script tags and injects additional styles
    // into them before react hydrates the streamed section. This means styles
    // are instantly available.
    const scriptIndex = response.indexOf(SCRIPT_TAG, lastScriptIndex + 1);
    if (scriptIndex > -1) {
      let injectedScript = "";
      const styles = getNewStyles(sheet, existingStyles);
      if (styles) {
        injectedScript += `
document.querySelector("style[data-styled]").innerHTML += ${JSON.stringify(styles)};
`;
      }
      if (injectedScript) {
        chunk = inject(chunk, scriptIndex - offset + SCRIPT_TAG.length, injectedScript);
      }
      lastScriptIndex = scriptIndex;
    }

    offset += chunkLength;
    return write(chunk, ...args);
  };

  return resp;
};

const inject = (buffer: Buffer, at: number, str: string) =>
  Buffer.concat([buffer.subarray(0, at), Buffer.from(str, "utf-8"), buffer.subarray(at, buffer.length)]);

const SC_SPLIT = "/*!sc*/";

// sheet.getStyleTags() returns ALL style tags every time, so we manually dedupe the styles
// so they're not repeated down the page
// NOTE: data-styled="true" from getStyleTags, but data-styled="active" once it's rendered client side
const getNewStyles = (sheet: ServerStyleSheet, existingStyles: string[]) => {
  let styles = sheet
    .getStyleTags()
    .replace(`<style data-styled="true" data-styled-version="${STYLED_COMPONENTS_VERSION}">`, "")
    .replace("</style>", "")
    .trim();

  for (const style of existingStyles) {
    styles = styles.replace(style + SC_SPLIT, "");
  }

  existingStyles.push(...styles.split(SC_SPLIT));
  return styles;
};

@Andarist
Copy link
Contributor

I had a wrapper Writable that could be used with the writable response. One of the main reasons I went with this is that I wanted to support flush forwarding (the React team suggested this as being the best solution in the working group thread):

class WritableWithStyles extends Writable {
  constructor(writable) {
    super();
    this._writable = writable;
    this._buffered = "";
    this._pendingFlush = null;
    this._inserted = false;
    this._freezing = false;
  }
  _flushBufferSync() {
    const flushed = this._buffered;
    this._buffered = "";
    this._pendingFlush = null;

    if (flushed) {
      this._insertInto(flushed);
    }
  }
  _flushBuffer() {
    if (!this._pendingFlush) {
      this._pendingFlush = new Promise((resolve) => {
        setTimeout(async () => {
          this._flushBufferSync();
          resolve();
        }, 0);
      });
    }
  }
  _insertInto(content) {
    // While react is flushing chunks, we don't apply insertions
    if (this._freezing) {
      this._writable.write(content);
      return;
    }

    const insertion = sheet.getStyleTags();
    sheet.instance.clearTag();

    if (this._inserted) {
      this._writable.write(insertion);
      this._writable.write(content);
      this._freezing = true;
    } else {
      const index = content.indexOf("</head>");
      if (index !== -1) {
        const insertedHeadContent =
          content.slice(0, index) + insertion + content.slice(index);
        this._writable.write(insertedHeadContent);
        this._freezing = true;
        this._inserted = true;
      }
      if (
        process.env.NODE_ENV !== "production" &&
        insertion &&
        !this._inserted
      ) {
        console.error(
          `server inserted HTML couldn't be inserted into the stream. You are missing '<head/>' element in your layout - please add it.`
        );
      }
    }

    if (!this._inserted) {
      this._writable.write(content);
    } else {
      queueTask(() => {
        this._freezing = false;
      });
    }
  }
  _write(chunk, encoding, callback) {
    const strChunk = chunk.toString();
    this._buffered += strChunk;
    this._flushBuffer();
    callback();
  }
  flush() {
    this._pendingFlush = null;
    this._flushBufferSync();
    if (typeof this._writable.flush === "function") {
      this._writable.flush();
    }
  }
  _final() {
    if (this._pendingFlush) {
      return this._pendingFlush.then(() => {
        this._writable.end();
      });
    }
    this._writable.end();
  }
}

I can't find the slightly improved version right now but:

  1. this should use TextDecoder over chunk.toString()
  2. this should handle/propagate errors from the wrapped _writable
  3. _final should use callbacks and not promises, promises were only briefly supported by node 16 or smth like that
  4. it should accept sheet in the constructor or something, this version relies on the sheet created in the closure

@prohabibi
Copy link

Hi @Andarist !
Can't make it work your solution. I have this error. 😢

Can't collect styles once you've consumed a `ServerStyleSheet`'s styles! `ServerStyleSheet` is a one off instance for each server-side render cycle.

thanks!

@Andarist
Copy link
Contributor

Andarist commented Jun 5, 2023

This error is thrown only if you .seal() the ServerStyleSheet instance somehow (could be through interleaveWithNodeStream call). My code doesn't do any of that.

@LorenDorez
Copy link

@Andarist did you ever managed to find your updated version? I wanted to give this a try in a project while we wait for React to release those 'new' APIs to make this all easier

@thomasjm
Copy link

Is there perhaps a place where one could follow along with the development of the new React APIs? I'd love to get some visibility into what they will look like and when they might be ready.

@Andarist
Copy link
Contributor

Look for anything labeled with Float in the React PRs: https://github.com/facebook/react/pulls?q=is%3Apr+float+

@rurquia
Copy link

rurquia commented Oct 16, 2024

I'm quite new to SSR and NodeStreams but using Node docs and styled code i finally got to this:

const sheet: ServerStyleSheet = new ServerStyleSheet();			
const readerWriter = new Duplex({
                      		writableObjectMode: true,
                      		readableObjectMode: true,
                      		read() {},
                      		write(chunk, encoding, callback) {
                      			this.push(chunk, encoding);
                      			callback();
                      		},
                      		final() {
                      			this.push(null);
                      		},
                      	});
const { pipe, abort } = renderToPipeableStream(
				<StyleSheetManager sheet={sheet.instance}>
					<RootComponent {...rootProps} />
				</StyleSheetManager>,
				{
					bootstrapModules: [getEntryURL(manifest, mapLayoutToPage(rootProps?.root?.name || ''))],
					onShellReady() {
						readerWriter.write(html[0]);//Document start
						pipe(readerWriter);
					},
					onAllReady() {
						readerWriter.write(html[1]);//Document end
						readerWriter.push(null);
					},
					onError(x) {
						console.error(x);
						throw x;
					},
				}
			);
setTimeout(() => abort(), 10000);

To get the styles i used a part of styled-components' interleaveWithNodeStream method's code, as the entry param is a Readable i thought that it would work well cause i have a Duplex that extends Readable, so i took the Transform and piped my Duplex:

const transfrmStream = new Transform({
				objectMode: true,
				transform(
					chunk,
					/* encoding */
					_: string,
					callback: Function
				) {
					// Get the chunk and retrieve the sheet's CSS as an HTML chunk,
					// then reset its rules so we get only new ones for the next chunk
					const renderedHtml = chunk instanceof Uint8Array ? Buffer.from(chunk).toString() : chunk.toString();
					const html = sheet._emitSheetCSS();
					const CLOSING_TAG_R = /^\s*<\/[a-z]/i;

					sheet.instance.clearTag();

					// prepend style html to chunk, unless the start of the chunk is a
					// closing tag in which case append right after that
					if (CLOSING_TAG_R.test(renderedHtml)) {
						const endOfClosingTag = renderedHtml.indexOf('>') + 1;
						const before = renderedHtml.slice(0, endOfClosingTag);
						const after = renderedHtml.slice(endOfClosingTag);

						this.push(before + html + after);
					} else {
						this.push(html + renderedHtml);
					}

					callback();
				},
			});
readerWriter.pipe(transfrmStream).pipe(response);

As my dev environment uses vite.ssrLoadModule in that use case chunk's type is Uint8Array instead of string or buffer so i had tu add the objectMode config values to my Duplex and Tranform and protect the transform renderedHtml with const renderedHtml = chunk instanceof Uint8Array ? Buffer.from(chunk).toString() : chunk.toString();

UPDATE
As Transform is a Duplex implementation you can use the transform stream directly. There is no need of this anymore:

const readerWriter = new Duplex({
                      		writableObjectMode: true,
                      		readableObjectMode: true,
                      		read() {},
                      		write(chunk, encoding, callback) {
                      			this.push(chunk, encoding);
                      			callback();
                      		},
                      		final() {
                      			this.push(null);
                      		},
                      	});

The react API method onAllReady it's a bit tricky. If you have suspense inside your rendered component it will be executed outside the main execution thread and after onAllReady is executed. So if you want to recive that last chunks with the result of the suspense execution you will have to erase this line:

readerWriter.push(null);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
6.0 proposals for the next major of styled-components help wanted 🆘
Projects
None yet
Development

No branches or pull requests