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:
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>