From 04ae8d024eb90fc20319fb73e5b2f297547553e1 Mon Sep 17 00:00:00 2001 From: lucky7323 Date: Fri, 27 Feb 2026 02:01:30 +0900 Subject: [PATCH] feat: add Next.js SSR compatibility support - Add SSR-safe utilities (env detection, safe storage, browser guards) - Implement useIsClient and useIsomorphicLayoutEffect hooks - Add ClientOnly component for safe client-side rendering - Create @bridgewise/next-adapter package with dynamic imports - Update README with SSR usage examples and migration guide - Ensure 'use client' directives are preserved for React 18+ compatibility Resolves #58 --- README.md | 53 +++++++++++++++++++ libs/ui-components/package.json | 1 + .../src/components/SSR/ClientOnly.tsx | 20 +++++++ libs/ui-components/src/index.ts | 13 +++++ libs/ui-components/src/ssr-hooks/index.ts | 2 + .../src/ssr-hooks/useIsClient.ts | 17 ++++++ .../ssr-hooks/useIsomorphicLayoutEffect.ts | 12 +++++ .../src/ssr-utils/browser-guard.ts | 41 ++++++++++++++ libs/ui-components/src/ssr-utils/env.ts | 9 ++++ libs/ui-components/src/ssr-utils/index.ts | 3 ++ .../src/ssr-utils/safe-storage.ts | 34 ++++++++++++ packages/next-adapter/dynamic.tsx | 27 ++++++++++ packages/next-adapter/index.ts | 5 ++ packages/next-adapter/package.json | 29 ++++++++++ 14 files changed, 266 insertions(+) create mode 100644 libs/ui-components/src/components/SSR/ClientOnly.tsx create mode 100644 libs/ui-components/src/ssr-hooks/index.ts create mode 100644 libs/ui-components/src/ssr-hooks/useIsClient.ts create mode 100644 libs/ui-components/src/ssr-hooks/useIsomorphicLayoutEffect.ts create mode 100644 libs/ui-components/src/ssr-utils/browser-guard.ts create mode 100644 libs/ui-components/src/ssr-utils/env.ts create mode 100644 libs/ui-components/src/ssr-utils/index.ts create mode 100644 libs/ui-components/src/ssr-utils/safe-storage.ts create mode 100644 packages/next-adapter/dynamic.tsx create mode 100644 packages/next-adapter/index.ts create mode 100644 packages/next-adapter/package.json diff --git a/README.md b/README.md index f7bcead..7921a01 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,59 @@ const { liquidity, refreshLiquidity } = useBridgeLiquidity({ `BridgeCompare` uses this data to prioritize high-liquidity routes and warn on low-liquidity paths. +## Next.js SSR Compatibility + +BridgeWise UI components now support server-side rendering (SSR) with Next.js App Router and Pages Router. + +### Basic Usage + +```tsx +import { BridgeStatus, ClientOnly } from '@bridgewise/ui-components'; + +// Safe for SSR - renders skeleton during server-side render +export default function BridgePage() { + return ( + Loading bridge...}> + + + ); +} +``` + +### Next.js Dynamic Import + +For maximum compatibility, use the Next.js adapter: + +```tsx +import { BridgeStatusDynamic, BridgeCompareDynamic } from '@bridgewise/next-adapter'; + +export default function BridgePage() { + return ( +
+ + +
+ ); +} +``` + +### SSR Utilities + +Use built-in utilities for browser-only code: + +```tsx +import { useIsClient, safeStorage, createBrowserGuard } from '@bridgewise/ui-components'; + +function MyComponent() { + const isClient = useIsClient(); + + if (!isClient) return
Server rendering...
; + + const stored = safeStorage.get('user-prefs', '{}'); + return
Client ready: {stored}
; +} +``` + ## Project setup ```bash diff --git a/libs/ui-components/package.json b/libs/ui-components/package.json index ebefc07..4cbf2c1 100644 --- a/libs/ui-components/package.json +++ b/libs/ui-components/package.json @@ -9,6 +9,7 @@ ".": "./src/index.ts", "./theme": "./src/theme/index.ts", "./headless": "./src/components/headless/index.ts", + "./ssr": "./src/ssr-utils/index.ts", "./styles/globals.css": "./src/styles/globals.css" }, "peerDependencies": { diff --git a/libs/ui-components/src/components/SSR/ClientOnly.tsx b/libs/ui-components/src/components/SSR/ClientOnly.tsx new file mode 100644 index 0000000..f8ae5e9 --- /dev/null +++ b/libs/ui-components/src/components/SSR/ClientOnly.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { type ReactNode } from 'react'; +import { useIsClient } from '../../ssr-hooks/useIsClient'; + +export interface ClientOnlyProps { + /** Content rendered only after client-side hydration. */ + children: ReactNode; + /** Optional fallback rendered during SSR / before hydration. */ + fallback?: ReactNode; +} + +/** + * Renders its children only on the client. + * During SSR (and the first client render) the `fallback` is shown instead. + */ +export function ClientOnly({ children, fallback = null }: ClientOnlyProps) { + const isClient = useIsClient(); + return <>{isClient ? children : fallback}; +} diff --git a/libs/ui-components/src/index.ts b/libs/ui-components/src/index.ts index 999277b..68d5679 100644 --- a/libs/ui-components/src/index.ts +++ b/libs/ui-components/src/index.ts @@ -118,3 +118,16 @@ export type { WalletProviderProps, WalletContextValue, } from './wallet'; + +// SSR Compatibility Utils +export { isServer, isClient } from './ssr-utils/env'; +export { safeStorage } from './ssr-utils/safe-storage'; +export { createBrowserGuard, ServerAccessError } from './ssr-utils/browser-guard'; + +// SSR Hooks +export { useIsClient } from './ssr-hooks/useIsClient'; +export { useIsomorphicLayoutEffect } from './ssr-hooks/useIsomorphicLayoutEffect'; + +// SSR Components +export { ClientOnly } from './components/SSR/ClientOnly'; +export type { ClientOnlyProps } from './components/SSR/ClientOnly'; diff --git a/libs/ui-components/src/ssr-hooks/index.ts b/libs/ui-components/src/ssr-hooks/index.ts new file mode 100644 index 0000000..116256e --- /dev/null +++ b/libs/ui-components/src/ssr-hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useIsClient'; +export * from './useIsomorphicLayoutEffect'; \ No newline at end of file diff --git a/libs/ui-components/src/ssr-hooks/useIsClient.ts b/libs/ui-components/src/ssr-hooks/useIsClient.ts new file mode 100644 index 0000000..750ab89 --- /dev/null +++ b/libs/ui-components/src/ssr-hooks/useIsClient.ts @@ -0,0 +1,17 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +/** + * Returns `true` once the component has mounted on the client. + * Useful for guarding browser-only code inside components. + */ +export function useIsClient(): boolean { + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + return isClient; +} diff --git a/libs/ui-components/src/ssr-hooks/useIsomorphicLayoutEffect.ts b/libs/ui-components/src/ssr-hooks/useIsomorphicLayoutEffect.ts new file mode 100644 index 0000000..f4e00d5 --- /dev/null +++ b/libs/ui-components/src/ssr-hooks/useIsomorphicLayoutEffect.ts @@ -0,0 +1,12 @@ +'use client'; + +import { useEffect, useLayoutEffect } from 'react'; +import { isServer } from '../utils/env'; + +/** + * `useLayoutEffect` on the client, `useEffect` on the server. + * Avoids the React SSR warning about useLayoutEffect. + */ +export const useIsomorphicLayoutEffect = isServer + ? useEffect + : useLayoutEffect; diff --git a/libs/ui-components/src/ssr-utils/browser-guard.ts b/libs/ui-components/src/ssr-utils/browser-guard.ts new file mode 100644 index 0000000..4f4be01 --- /dev/null +++ b/libs/ui-components/src/ssr-utils/browser-guard.ts @@ -0,0 +1,41 @@ +import { isServer } from './env'; + +/** + * Error thrown when a browser-only API is accessed on the server. + */ +export class ServerAccessError extends Error { + constructor(api: string) { + super( + `[BridgeWise] "${api}" is not available during server-side rendering. ` + + `Wrap the call in a useEffect or use the component.`, + ); + this.name = 'ServerAccessError'; + } +} + +/** + * Creates a proxy that throws `ServerAccessError` when any property is + * accessed on the server, and delegates to `factory()` on the client. + * + * @example + * ```ts + * const ethereum = createBrowserGuard('window.ethereum', () => window.ethereum); + * ``` + */ +export function createBrowserGuard( + name: string, + factory: () => T, +): T { + if (!isServer) { + return factory(); + } + + return new Proxy({} as T, { + get(_target, prop) { + // Allow Symbol.toPrimitive / toJSON etc. to avoid noisy errors in SSR + // serialisation paths. + if (typeof prop === 'symbol') return undefined; + throw new ServerAccessError(`${name}.${String(prop)}`); + }, + }); +} diff --git a/libs/ui-components/src/ssr-utils/env.ts b/libs/ui-components/src/ssr-utils/env.ts new file mode 100644 index 0000000..48a4409 --- /dev/null +++ b/libs/ui-components/src/ssr-utils/env.ts @@ -0,0 +1,9 @@ +/** + * Environment detection utilities for SSR compatibility. + */ + +/** Returns `true` when running on the server (no `window` global). */ +export const isServer = typeof window === 'undefined'; + +/** Returns `true` when running in a browser environment. */ +export const isClient = !isServer; diff --git a/libs/ui-components/src/ssr-utils/index.ts b/libs/ui-components/src/ssr-utils/index.ts new file mode 100644 index 0000000..a63d329 --- /dev/null +++ b/libs/ui-components/src/ssr-utils/index.ts @@ -0,0 +1,3 @@ +export * from './env'; +export * from './safe-storage'; +export * from './browser-guard'; \ No newline at end of file diff --git a/libs/ui-components/src/ssr-utils/safe-storage.ts b/libs/ui-components/src/ssr-utils/safe-storage.ts new file mode 100644 index 0000000..c679cbb --- /dev/null +++ b/libs/ui-components/src/ssr-utils/safe-storage.ts @@ -0,0 +1,34 @@ +import { isServer } from './env'; + +/** + * A storage wrapper that is safe to use in SSR environments. + * All operations silently no-op on the server. + */ +export const safeStorage = { + get(key: string): string | null { + if (isServer) return null; + try { + return window.localStorage.getItem(key); + } catch { + return null; + } + }, + + set(key: string, value: string): void { + if (isServer) return; + try { + window.localStorage.setItem(key, value); + } catch { + // Storage may be full or blocked (e.g. private browsing). + } + }, + + remove(key: string): void { + if (isServer) return; + try { + window.localStorage.removeItem(key); + } catch { + // Silently ignore. + } + }, +}; diff --git a/packages/next-adapter/dynamic.tsx b/packages/next-adapter/dynamic.tsx new file mode 100644 index 0000000..cefc6e5 --- /dev/null +++ b/packages/next-adapter/dynamic.tsx @@ -0,0 +1,27 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import type { ClientOnlyProps } from '@bridgewise/ui-components'; +import type { BridgeStatusProps as BridgeWidgetProps } from '@bridgewise/ui-components'; + +/** + * Dynamically imported `BridgeWidget` with SSR disabled. + * Drop-in replacement for App Router and Pages Router. + */ +export const BridgeStatusDynamic = dynamic( + () => import('@bridgewise/ui-components').then((m) => m.BridgeStatus), + { ssr: false }, +); + +export const BridgeCompareDynamic = dynamic( + () => import('@bridgewise/ui-components').then((m) => m.BridgeCompare), + { ssr: false }, +); + +/** + * Dynamically imported `ClientOnly` with SSR disabled. + */ +export const ClientOnlyDynamic = dynamic( + () => import('@bridgewise/ui-components').then((m) => m.ClientOnly), + { ssr: false }, +); diff --git a/packages/next-adapter/index.ts b/packages/next-adapter/index.ts new file mode 100644 index 0000000..a82ca9c --- /dev/null +++ b/packages/next-adapter/index.ts @@ -0,0 +1,5 @@ +// Re-export everything from the core UI components for convenience. +export * from '@bridgewise/ui-components'; + +// Next.js-specific dynamic imports (SSR disabled). +export { BridgeStatusDynamic, BridgeCompareDynamic, ClientOnlyDynamic } from './dynamic'; diff --git a/packages/next-adapter/package.json b/packages/next-adapter/package.json new file mode 100644 index 0000000..436d069 --- /dev/null +++ b/packages/next-adapter/package.json @@ -0,0 +1,29 @@ +{ + "name": "@bridgewise/next-adapter", + "version": "0.1.0", + "description": "Next.js adapter for BridgeWise SDK with SSR support", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./dynamic": "./src/dynamic.tsx" + }, + "peerDependencies": { + "@bridgewise/ui-components": "^0.1.0", + "next": ">=13.4.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "typescript": "^5.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0" + }, + "keywords": [ + "bridgewise", + "nextjs", + "ssr", + "react", + "defi", + "bridge" + ] +} \ No newline at end of file