fix(www, common): contain SSR bailouts in /pricing provider tree

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 <Suspense> 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.
This commit is contained in:
Alan Daniel committed 2026-04-29 13:27:47 -04:00
1 parent a73ae9c981
commit de8072665a
2 files changed
+110 -73

No files matched your search

+25 -10
View File
@@ -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 }) {
>
<TooltipProvider delayDuration={0}>
<CommandProvider>
<TelemetryTagManager />
<Toaster />
{/* 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). */}
<Suspense fallback={null}>
<TelemetryTagManager />
</Suspense>
<Suspense fallback={null}>
<Toaster />
</Suspense>
<Suspense fallback={null}>{children}</Suspense>
<WwwCommandMenu />
<PageTelemetry
API_URL={API_URL}
hasAcceptedConsent={hasAcceptedConsent}
enabled={IS_PLATFORM}
/>
<DevToolbar />
<Suspense fallback={null}>
<WwwCommandMenu />
</Suspense>
<Suspense fallback={null}>
<PageTelemetry
API_URL={API_URL}
hasAcceptedConsent={hasAcceptedConsent}
enabled={IS_PLATFORM}
/>
</Suspense>
<Suspense fallback={null}>
<DevToolbar />
</Suspense>
</CommandProvider>
</TooltipProvider>
</ThemeProvider>
+85 -63
View File
@@ -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