sergiodxa/remix-utils

ServerOnly executing on the client

danthegoodman1 opened this issue · 3 comments

Describe the bug

With a shiki highlighter in a .server.ts file, imported into a Fence.tsx file, the browser console is throwing errors of highlighter.codeToHtml not being defined, and renders mismatching between server and client:

image
image

Your Example Website or App

not public atm

Steps to Reproduce the Bug or Issue

highlighter.server.ts

import * as shiki from "shiki"

export const highlighter = await shiki.getHighlighter({
  theme: "dracula-soft",
})

fetch.tsx

import { Children, ReactNode } from "react"
import { ServerOnly } from "remix-utils/server-only"
import { highlighter } from "./highlighter.server"

export function Fence(props: { children?: ReactNode; language: string }) {
  return (
    <ServerOnly>
      {() => {
        const code = Children.toArray(props.children)[0] as string
        const html = highlighter.codeToHtml(code.trim(), {
          lang: props.language,
        })
        return (
          <div
            dangerouslySetInnerHTML={{
              __html: html,
            }}
          />
        )
      }}
    </ServerOnly>
  )
}

export const fence = {
  render: "Fence",
  attributes: {
    language: {
      type: String,
    },
  },
}

Then render Fence in a route with nothing else (no loaders, etc.)

Expected behavior

Does not try to render on the client

Screenshots or Videos

No response

Platform

  • OS: [e.g. macOS, Windows, Linux]
  • Browser: [e.g. Chrome, Safari, Firefox]
  • Version: [e.g. 91.1]

Additional context

Discovery in Remix discord: https://discord.com/channels/770287896669978684/770287896669978687/1198623722815361148

And if I use a client fallback that does effectively the same code, I still get a hydration mismatch error

export function FenceClient(props: { children?: ReactNode; language: string }) {
  const code = Children.toArray(props.children)[0] as string
  const [html, setHTML] = useState("")
  async function loadHTML() {
    const res = await fetch("/syntax_highlighter", {
      method: "post",
      headers: {
        "content-type": "application/json",
      },
      body: JSON.stringify({
        code,
        language: props.language,
      } as SyntaxHighlightPayload),
    })
    setHTML(await res.text())
  }

  useEffect(() => {
    loadHTML()
  }, [])

  return (
    <div
      dangerouslySetInnerHTML={{
        __html: html,
      }}
    />
  )
}
export function Fence(props: { children?: ReactNode; language: string }) {
  return (
    <ServerOnly fallback={<FenceClient {...props} />}>
      {() => {
        const code = Children.toArray(props.children)[0] as string
        const html = highlighter.codeToHtml(code.trim(), {
          lang: props.language,
        })
        return (
          <div
            dangerouslySetInnerHTML={{
              __html: html,
            }}
          />
        )
      }}
    </ServerOnly>
  )
}
export interface SyntaxHighlightPayload {
  language: string
  code: string
}

export async function action(args: ActionFunctionArgs) {
  const data = (await args.request.json()) as SyntaxHighlightPayload
  const html = highlighter.codeToHtml(data.code.trim(), {
    lang: data.language,
  })
  return new Response(html)
}

I think the problem is that in highlighter.server.ts you're using a top-level await.

But ServerOnly is intended as the inverse of ClientOnly, in ClientOnly the children is rendered in the client and the fallback in the server, in ServerOnly the children is rendered in the server and the fallback in the browser.

If you can create a reproduction repo I could try to see what's happening.

I suspect that as well, as I use this in other places successfully like:

<ServerOnly>
            {() => {
              return (
                <TopNav
                  isAdmin={data.user?.isAdmin}
                  redirectTo={data.currentPath}
                  authed={!!data.user}
                  subscribed={!!data.user?.subscription}
                />
              )
            }}
          </ServerOnly>
          <ClientOnly>
            {() => {
              return (
                <TopNav
                  isAdmin={data.user?.isAdmin}
                  redirectTo={window.location.pathname}
                  authed={!!data.user}
                  subscribed={!!data.user?.subscription}
                />
              )
            }}
          </ClientOnly>