css icon indicating copy to clipboard operation
css copied to clipboard

🐞 Remix Streaming SSR

Open itsMapleLeaf opened this issue 2 years ago • 10 comments

What happened?

StackBlitz: https://stackblitz.com/github/itsMapleLeaf/mastercss-remix-test?file=app%2Fentry.client.tsx,app%2Froot.tsx,app%2Froutes%2Findex.tsx

Before hydration, it briefly shows the styles I would expect (I think?), but after hydration, they all go away, and don't get updated. I think this is unexpected?

Version

v2.0.0-beta.105

Relevant log output

No response

What browsers are you seeing the problem on?

No response

itsMapleLeaf avatar Feb 02 '23 22:02 itsMapleLeaf

@itsMapleLeaf It's possible that the update of Remix in the past few months has caused the Master CSS JIT to not work. We've built a Remix example in CSS and will make a working version available asap. BTW, https://beta.css.master.co/docs is coming soon.

1aron avatar Feb 03 '23 08:02 1aron

@itsMapleLeaf We're facing a known issue that cannot be fixed.

Just import MasterCSS into entry.client.tsx

import { RemixBrowser } from '@remix-run/react'
import { startTransition, StrictMode } from 'react'
import { hydrateRoot } from 'react-dom/client'
import { MasterCSS } from '@master/css/dist'

function hydrate() {
    startTransition(() => {
        new MasterCSS()
        hydrateRoot(
            document,
            <StrictMode>
                <RemixBrowser />
            </StrictMode>
        )
    })
}

if (typeof requestIdleCallback === 'function') {
    requestIdleCallback(hydrate)
} else {
    // Safari doesn't support requestIdleCallback
    // https://caniuse.com/requestidlecallback
    setTimeout(hydrate, 1)
}

and got the error:

Uncaught Error: Hydration failed because the initial UI does not match what was rendered on the server.
    at throwOnHydrationMismatch (entry.client-U5T4FRNQ.js:9132:17)
    at tryToClaimNextHydratableInstance (entry.client-U5T4FRNQ.js:9153:15)
    at updateHostComponent (entry.client-U5T4FRNQ.js:14365:13)
    at beginWork (entry.client-U5T4FRNQ.js:15471:22)
    at beginWork$1 (entry.client-U5T4FRNQ.js:19251:22)
    at performUnitOfWork (entry.client-U5T4FRNQ.js:18696:20)
    at workLoopConcurrent (entry.client-U5T4FRNQ.js:18687:13)
    at renderRootConcurrent (entry.client-U5T4FRNQ.js:18662:15)
    at performConcurrentWorkOnRoot (entry.client-U5T4FRNQ.js:18178:46)
    at workLoop (entry.client-U5T4FRNQ.js:200:42)

Master CSS's JIT is just a simple JavaScript package; not sure how Remix works with React hydration. I just simply customized some scripts like console.log('test') in root.tsx and still got the same error.

Except for Remix, Master CSS's JIT currently works well with other frameworks.

1aron avatar Feb 04 '23 13:02 1aron

Hm, I think the hydration issue happens because MasterCSS adds a

import { MasterCSS } from "@master/css"
import { useEffect, useLayoutEffect } from "react"

const useIsomorphicEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect

let initialized = false
export function useMasterCSS() {
  useIsomorphicEffect(() => {
    if (initialized) return
    initialized = true
    new MasterCSS()
  })
}
import { useMasterCSS } from "./helpers/master-css"

export default function App() {
  useMasterCSS()

  // ...
}

So this is where hybrid rendering would come in. But I'm not sure how to set up hybrid rendering with streaming SSR 😅 I looked at the NextJS example, but it looks like MCSS expects all of the HTML to be available to generate the styles

itsMapleLeaf avatar Feb 04 '23 17:02 itsMapleLeaf

Hm, I think the hydration issue happens because MasterCSS adds a

import { MasterCSS } from "@master/css"
import { useEffect, useLayoutEffect } from "react"

const useIsomorphicEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect

let initialized = false
export function useMasterCSS() {
  useIsomorphicEffect(() => {
    if (initialized) return
    initialized = true
    new MasterCSS()
  })
}
import { useMasterCSS } from "./helpers/master-css"

export default function App() {
  useMasterCSS()

  // ...
}

So this is where hybrid rendering would come in. But I'm not sure how to set up hybrid rendering with streaming SSR 😅 I looked at the NextJS example, but it looks like MCSS expects all of the HTML to be available to generate the styles

You are a professional! Hybrid Rendering ( AOT + JIT ) and Progressive Rendering ( SSR + JIT ) of Master CSS v2.0 are designed to avoid FOUC for better SEO and loading speed.

The way to practice SSR is very simple, but it must rely on the framework's transformHTML Hook. This is an API built into Master CSS:

import { renderIntoHTML } from '@master/css'
import { config } from './master.css'

const html: string = `
     <html>
         <head></head>
         <body>
             <h1 class="text:center font:32">Hello World</h1>
         </body>
     </html>
`
const result = renderIntoHTML(html, config)

The official Remix guide isn't finished yet, and your attempts hastened its arrival.

1aron avatar Feb 04 '23 18:02 1aron

Good to know you're taking a closer look at this!

Here's how Remix's default SSR setup works, it uses renderToPipeableStream from React. Similarly, non-node templates use renderToStream which returns a native ReadableStream object.

view code
function handleBrowserRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext,
) {
  return new Promise((resolve, reject) => {
    let didError = false

    const { pipe, abort } = renderToPipeableStream(
      <RemixServer context={remixContext} url={request.url} />,
      {
        onShellReady() {
          const body = new PassThrough()

          responseHeaders.set("Content-Type", "text/html")

          resolve(
            new Response(body, {
              headers: responseHeaders,
              status: didError ? 500 : responseStatusCode,
            }),
          )

          pipe(body)
        },
        onShellError(err: unknown) {
          reject(err)
        },
        onError(error: unknown) {
          didError = true

          console.error(error)
        },
      },
    )

    setTimeout(abort, ABORT_DELAY)
  })
}

From what I can tell, the problem with using renderIntoHTML is it needs all of the HTML server-side up front without streaming, meaning we can't use features like defer.

Does MasterCSS have an API to support SSR with streaming? I think not using defer is a big compromise 😅

itsMapleLeaf avatar Feb 05 '23 03:02 itsMapleLeaf

@itsMapleLeaf The host of Master CSS is basically only the root document and the .shadowRoot of Shadow DOM, and <style title="master"> is used as a communication bridge between SSR/AOT and JIT.

Therefore, all streaming of Remix after the document may need to go through JIT, and there is currently no CSS architecture that can collect scans across streams and merge them into the current page.

We need to provide a .hydrate() to support SSR with streaming.

1aron avatar Feb 05 '23 10:02 1aron

The SSG stage of Master CSS progressive rendering is mainly used to pre-generate the critical CSS rules required to improve FOUC, FCP, and CLS, also speed up page loading.

Any class names generated by client-side dynamic programming should be hydrated by the Master CSS JIT.

Screenshot 2023-02-06 at 9 21 05 PM

Is there any reason to pre-generate CSS rules on the server side during the streaming phase after the document is loaded?

1aron avatar Feb 06 '23 13:02 1aron

Good to know you're taking a closer look at this!

Here's how Remix's default SSR setup works, it uses renderToPipeableStream from React. Similarly, non-node templates use renderToStream which returns a native ReadableStream object.

view code From what I can tell, the problem with using renderIntoHTML is it needs all of the HTML server-side up front without streaming, meaning we can't use features like defer.

Does MasterCSS have an API to support SSR with streaming? I think not using defer is a big compromise 😅

We have to know how Remix reinserts CSS text into the document in case of SSR streaming.

Screenshot 2023-02-06 at 10 09 30 PM

1aron avatar Feb 06 '23 14:02 1aron

That makes sense 🤔 The best way I can think of to insert the text into the streamed content is with react context

// entry.client.tsx
renderToPipeableStream(
  <CssContext.Provider value={cssText}>
    <RemixServer />
  </CssContext.Provider>
)
// root.tsx
export default function App() {
  const css = useContext(CssContext)
  return (
    <html>
      <head>
        <style dangerouslySetInnerHTML={{ __html: css }} />
      </head>
      {/* etc. */}
    </html>
  )
}

Also FWIW, a similar library named Twind has a built-in API for this: https://github.com/tw-in-js/twind/blob/main/examples/with-remix_react-v18/app/entry.server.tsx#L26

itsMapleLeaf avatar Feb 07 '23 04:02 itsMapleLeaf

That makes sense 🤔 The best way I can think of to insert the text into the streamed content is with react context

// entry.client.tsx
renderToPipeableStream(
  <CssContext.Provider value={cssText}>
    <RemixServer />
  </CssContext.Provider>
)
// root.tsx
export default function App() {
  const css = useContext(CssContext)
  return (
    <html>
      <head>
        <style dangerouslySetInnerHTML={{ __html: css }} />
      </head>
      {/* etc. */}
    </html>
  )
}

Also FWIW, a similar library named Twind has a built-in API for this: https://github.com/tw-in-js/twind/blob/main/examples/with-remix_react-v18/app/entry.server.tsx#L26

I think we have come to a conclusion, we will try to design related API in @master/css.react.

Thanks for your enthusiasm! This is very helpful!

1aron avatar Feb 07 '23 04:02 1aron