Skip to content

Detect and warn about client-side globals in SSR bundle #807

@justinvdm

Description

@justinvdm

Problem

When building applications with RedwoodSDK, client-side libraries that access browser globals like navigator.platform, window, document, etc. at module load time can cause runtime crashes during SSR in Cloudflare Workers. These globals don't exist in the worker environment, leading to errors like:

TypeError: Cannot read properties of undefined (reading 'platform')

This issue has appeared with various libraries, including @xterm/xterm and components from the Vercel AI SDK.

Recommended Solution: Conditional Loading with React.lazy

The recommended approach is to prevent the component from being bundled for SSR altogether. This can be done by combining React.lazy with Vite's import.meta.env.SSR flag. This ensures the problematic code is never sent to the Cloudflare Worker environment.

To avoid a React hydration mismatch, the component must render null on the initial client-side render, matching what the server rendered. A useState and useEffect hook can be used to delay rendering the client-side component until after the initial mount.

Example Implementation

This example demonstrates how to safely load a client-only component (ClientTerminal) that uses browser globals.

// src/app/components/Terminal.tsx
"use client";

import type { JSX } from "react";
import { lazy, useEffect, useState, Suspense } from "react";

// Lazily import the client-side component.
const ClientTerminal = lazy(async () => {
  // If we're in an SSR environment, return a stub component that renders nothing.
  if (import.meta.env.SSR) {
    return { default: () => null as JSX.Element };
  }

  // On the client, import the actual component.
  // The 'default' property of the returned object is the component.
  const mod = await import("./ClientTerminal");
  return { default: mod.Terminal };
});

export function Terminal() {
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    // This effect runs only on the client, after the component has mounted.
    setIsMounted(true);
  }, []);

  // On the server and the initial client render, return null to avoid a hydration mismatch.
  // After mounting, render the actual client-side component inside a Suspense boundary.
  return isMounted ? (
    <Suspense fallback={<div>Loading Terminal...</div>}>
      <ClientTerminal />
    </Suspense>
  ) : null;
}

Proposed clientOnly() Utility

To simplify this pattern, a clientOnly() utility could be created. This would abstract the lazy loading and SSR check.

// Potential usage
import { clientOnly } from "rwsdk/runtime";
const ClientTerminal = clientOnly(() => import("./ClientTerminal"));

export function Terminal() {
  // ... same isMounted logic ...
  return isMounted ? <ClientTerminal /> : null;
}

Note: The feasibility of this utility is contingent on whether abstracting the import.meta.env.SSR check into a helper function still allows the bundler (Vite/Rollup) to effectively tree-shake the unused import from the SSR bundle. This needs to be investigated.

Build-Time Warnings

To help developers identify these issues proactively, we should implement build-time detection that scans the SSR bundle for common client-side global references.

When a global is found, the build tool should show a warning that:

  1. Identifies the specific global (e.g., navigator.platform).
  2. Shows where it's being accessed (file path and line number).
  3. Links to documentation explaining this pattern.
  4. Suggests wrapping the component using the React.lazy pattern described above.

Example Warning Output

⚠️  Client-side global detected in SSR bundle:
   → navigator.platform
   → Found in: node_modules/@xterm/xterm/lib/common/Platform.js:19

   This global is undefined in Cloudflare Workers and may cause runtime crashes.
   
   To fix this, load the component that uses this global only on the client.
   See: https://docs.redwoodjs.com/sdk/guides/client-only-components

Legacy Workaround (Not Recommended)

A previous workaround involved using Vite's define option to shim these globals in the SSR build.

Why It's Not Recommended

This approach does not remove the client-side code from the SSR bundle. Instead, it patches the environment by replacing global references at build time. This increases the SSR bundle size with code that will never be used and can lead to unexpected behavior if the shim is incomplete. The React.lazy approach is better because it correctly separates client and server code.

Example (for historical context)

// vite.config.mts (Not Recommended)
export default defineConfig({
  environments: {
    ssr: {
      define: {
        "navigator.platform": JSON.stringify("CloudflareWorkers"),
        "window": "globalThis",
      },
    },
  },
});

Metadata

Metadata

Assignees

No one assigned

    Labels

    1.xIssues planned for a minor release after 1.0

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions