From de8072665a9b0ff008d6a7b41b04f618cd0dcff3 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Wed, 29 Apr 2026 13:27:47 -0400 Subject: [PATCH] fix(www, common): contain SSR bailouts in /pricing provider tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that together remove BAILOUT_TO_CLIENT_SIDE_RENDERING from the rendered HTML so crawlers and LLM bots see the actual pricing content (FE-3079). 1. useThemeSandbox: hoist `useState` and `useEffect` above the environment check. The previous `if (typeof window === 'undefined' || IS_PROD) return null` ran before any hooks, so on preview deploys (IS_PROD=false) the server returned null with 0 hooks while the client called 2 hooks — a Rules of Hooks violation that React converts into a CSR bailout. Hooks now run unconditionally; all side effects are gated inside the effect, which never runs during SSR. 2. providers.tsx: wrap each side-effect-y sibling (TelemetryTagManager, Toaster, WwwCommandMenu, PageTelemetry, DevToolbar) in its own boundary. If any of them does trigger a bailout (e.g. a transitive dependency reading useSearchParams without a Suspense boundary), it's contained to that subtree instead of propagating up past `{children}` and hiding the page from the SSR'd HTML. --- apps/www/app/providers.tsx | 35 +++-- packages/common/hooks/useThemeSandbox.tsx | 148 +++++++++++++--------- 2 files changed, 110 insertions(+), 73 deletions(-) diff --git a/apps/www/app/providers.tsx b/apps/www/app/providers.tsx index 3d397df4228..55ecb51d920 100644 --- a/apps/www/app/providers.tsx +++ b/apps/www/app/providers.tsx @@ -14,7 +14,7 @@ import { DevToolbar, DevToolbarProvider } from 'dev-tools' import { API_URL } from 'lib/constants' import { NuqsAdapter } from 'nuqs/adapters/next/app' import { Suspense } from 'react' -import { SonnerToaster, themes, TooltipProvider } from 'ui' +import { themes, TooltipProvider } from 'ui' import { CommandProvider } from 'ui-patterns/CommandMenu' import { useConsentToast } from 'ui-patterns/consent' @@ -36,16 +36,31 @@ function Providers({ children }: { children: React.ReactNode }) { > - - + {/* Each side-effect-y component gets its own Suspense so any + bailout (e.g. a child reading useSearchParams or hitting a + hydration mismatch) stays contained and doesn't propagate + up past the provider chain to hide the page from crawlers + (FE-3079). */} + + + + + + {children} - - - + + + + + + + + + diff --git a/packages/common/hooks/useThemeSandbox.tsx b/packages/common/hooks/useThemeSandbox.tsx index 878dbf1e2f0..ab50e03d34d 100644 --- a/packages/common/hooks/useThemeSandbox.tsx +++ b/packages/common/hooks/useThemeSandbox.tsx @@ -44,74 +44,96 @@ const defaultDark: { [name: string]: string } = { * - append "#theme-sandbox" to the url * - select "Apply Theme" to apply preset (localStorage will keep track of changes so you don't lose new values) * - select "Reset localStorage" and refresh page to restart + * + * Hooks are called unconditionally so the server and client agree on hook + * count — guarding the work inside `useEffect` keeps SSR clean and avoids + * BAILOUT_TO_CLIENT_SIDE_RENDERING on preview deploys (FE-3079). */ -export const useThemeSandbox = (): any => { - const isWindowUndefined = typeof window === 'undefined' - if (isWindowUndefined || IS_PROD) return null - const hash = window.location.hash - const defaultConfig = defaultDark // use dark default tokens - // const defaultConfig = defaultLight // use light default tokens - const localPreset = localStorage.getItem('theme-sandbox') - const isSandbox = hash.includes('#theme-sandbox') || localPreset !== null - const [themeConfig, setThemeConfig] = useState( - localPreset ? JSON.parse(localPreset) : defaultConfig - ) - const styles = document.querySelector(':root') as any - - const handleSetThemeConfig = (name: string, value: any) => { - updateCSSVariables() - setThemeConfig((prevConfig: any) => ({ ...prevConfig, [name]: value })) - } - - const updateCSSVariables = () => { - Object.entries(themeConfig).map(([key, value]) => styles.style.setProperty(key, value)) - localStorage.setItem('theme-sandbox', JSON.stringify(themeConfig)) - } - - const init = async () => { - if (!isSandbox) return - const dat = await import('dat.gui') - const gui = new dat.GUI() - - gui.width = 500 - - Object.entries(defaultConfig).map(([key, _value]) => { - if (!themeConfig[key]) return localStorage.removeItem('theme-sandbox') - const folderName = key.split('-')[2] - const folder = gui.__folders[folderName] ?? gui.addFolder(folderName) - - return folder - .add(themeConfig, key) - .name(key) - .onChange((newValue) => { - handleSetThemeConfig(key, newValue) - }) - }) - - var obj = { - 'Apply Theme': function () { - updateCSSVariables() - }, - 'Exit Sandbox': function () { - gui.destroy() - }, - 'Reset localStorage': function () { - localStorage.removeItem('theme-sandbox') - setThemeConfig(defaultConfig) - }, - } - - gui.add(obj, 'Apply Theme') - gui.add(obj, 'Reset localStorage') - gui.add(obj, 'Exit Sandbox') - gui.load - } +export const useThemeSandbox = (): null => { + const [, setThemeConfig] = useState<{ [name: string]: string }>(defaultDark) useEffect(() => { - init() + if (IS_PROD || typeof window === 'undefined') return + + const hash = window.location.hash + const localPreset = localStorage.getItem('theme-sandbox') + const isSandbox = hash.includes('#theme-sandbox') || localPreset !== null + + let currentConfig: { [name: string]: string } = defaultDark + if (localPreset) { + try { + currentConfig = JSON.parse(localPreset) + setThemeConfig(currentConfig) + } catch { + // ignore malformed preset + } + } + + if (!isSandbox) return + + const styles = document.querySelector(':root') as HTMLElement | null + + const updateCSSVariables = () => { + Object.entries(currentConfig).forEach(([key, value]) => { + styles?.style.setProperty(key, value) + }) + localStorage.setItem('theme-sandbox', JSON.stringify(currentConfig)) + } + + const handleSetThemeConfig = (name: string, value: string) => { + currentConfig = { ...currentConfig, [name]: value } + updateCSSVariables() + setThemeConfig(currentConfig) + } + + let gui: any + let cancelled = false + + ;(async () => { + const dat = await import('dat.gui') + if (cancelled) return + + gui = new dat.GUI() + gui.width = 500 + + Object.entries(defaultDark).forEach(([key]) => { + if (!currentConfig[key]) { + localStorage.removeItem('theme-sandbox') + return + } + const folderName = key.split('-')[2] + const folder = gui.__folders[folderName] ?? gui.addFolder(folderName) + + folder + .add(currentConfig, key) + .name(key) + .onChange((newValue: string) => { + handleSetThemeConfig(key, newValue) + }) + }) + + const obj = { + 'Apply Theme': () => updateCSSVariables(), + 'Exit Sandbox': () => gui?.destroy(), + 'Reset localStorage': () => { + localStorage.removeItem('theme-sandbox') + currentConfig = defaultDark + setThemeConfig(defaultDark) + }, + } + + gui.add(obj, 'Apply Theme') + gui.add(obj, 'Reset localStorage') + gui.add(obj, 'Exit Sandbox') + })() + + return () => { + cancelled = true + gui?.destroy() + } }, []) - return { themeConfig, handleSetThemeConfig, isSandbox } + return null } export default useThemeSandbox