diff --git a/docs/SSR.md b/docs/SSR.md new file mode 100644 index 00000000..afd38dce --- /dev/null +++ b/docs/SSR.md @@ -0,0 +1,186 @@ +# Server-Side Rendering (SSR) Compatibility Guide + +This guide explains how to safely load ELIXIR Cloud Components in projects that use SSR frameworks (e.g. Next.js, Remix). + +## Quick Start + +All components are **safe to import during SSR** — importing them on the server will not crash. However, Web Components are a **client-side technology**: rendering and hydration occur in the browser via the Custom Elements API. + +### Next.js (App Router) + +Use dynamic imports with `ssr: false` to load components only on the client: + +```tsx +// app/components/MyComponent.tsx +"use client"; + +import dynamic from "next/dynamic"; + +// Dynamic import prevents SSR from trying to define custom elements +const EccComponents = dynamic( + () => import("@elixir-cloud/design").then((mod) => mod), + { ssr: false } +); + +export default function MyComponent() { + return ( +
+ Click me +
+ ); +} +``` + +Alternatively, mark the entire page as a Client Component: + +```tsx +// app/page.tsx +"use client"; + +import "@elixir-cloud/design"; + +export default function Page() { + return Click me; +} +``` + +### Remix + +Use `ClientOnly` wrappers or lazy loading: + +```tsx +import { ClientOnly } from "remix-utils/client-only"; + +export default function Page() { + return ( + Loading...

}> + {() => { + import("@elixir-cloud/design"); + return Click me; + }} +
+ ); +} +``` + +--- + +## How SSR Safety Works + +### Environment Guards + +Every `customElements.define()` call is wrapped in an environment check: + +```ts +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("tag-name") +) { + window.customElements.define("tag-name", ComponentClass); +} +``` + +This prevents: +- **Server crashes** — `customElements` doesn't exist in Node.js +- **Duplicate registrations** — safe for HMR and multiple imports + +### Deterministic IDs + +Components that need unique IDs (e.g., ``) use a monotonic counter instead of `Math.random()`. This avoids mismatches if the same module is loaded on both server and client. + +### Browser-Only Lifecycle + +All DOM access (`document`, `MutationObserver`, `window.setInterval`) is deferred to client-only Lit lifecycle hooks: + +| API | Used In | Lifecycle Hook | +|-----|---------|----------------| +| `document.addEventListener` | Select, MultiSelect | `connectedCallback()` | +| `MutationObserver` | Code editor | `firstUpdated()` | +| `document.documentElement` | Code editor | `isDarkMode()` (guarded) | +| `window.setInterval` | Select, Collapsible sub-components | `connectedCallback()` | +| `ace.edit()` | Code editor | `firstUpdated()` | + +These hooks only execute in the browser, so importing during SSR never triggers them. + +--- + +## SSR Utility API + +The `@elixir-cloud/design` package exports SSR helpers: + +```ts +import { + isBrowser, + isServer, + ssrSafeDefine, + generateDeterministicId, + resetIdCounter, +} from "@elixir-cloud/design"; +``` + +| Function | Description | +|----------|-------------| +| `isBrowser()` | Returns `true` in browser environments | +| `isServer()` | Returns `true` in Node.js / SSR | +| `ssrSafeDefine(tag, ctor)` | Safely registers a custom element | +| `generateDeterministicId(prefix)` | Counter-based ID generation | +| `resetIdCounter()` | Resets the ID counter (for testing) | + +--- + +## Declarative Shadow DOM (DSD) + +Lit has experimental support for Declarative Shadow DOM via `@lit-labs/ssr`. DSD allows a server response to include shadow roots as HTML so the browser can display styled content before JavaScript loads: + +```html + + + Click me + +``` + +### Current Status + +- **DSD-based server rendering is not yet implemented** in this library +- Components are safe to **import** on the server (no crashes) +- Rendering and hydration happen on the client via dynamic imports +- Future: integrate `@lit-labs/ssr` for server-side template rendering + +> **Note:** DSD requires browser support (`template[shadowrootmode]`). Chrome, Edge, and Firefox 123+ support it natively. Safari 16.4+ supports the older `shadowroot` attribute. + +--- + +## Constraints & Best Practices + +1. **Always use `"use client"` or dynamic imports** — Web Components need the browser DOM to render +2. **Don't access `this.shadowRoot` in constructors** — defer to `connectedCallback()` or `firstUpdated()` +3. **Avoid `Math.random()` in component state** — use `generateDeterministicId()` instead +4. **Don't rely on `window` or `document` at module scope** — gate behind `isBrowser()` +5. **Test SSR compatibility** — run `node -e "import('@elixir-cloud/design')"` to verify no crashes + +--- + +## Troubleshooting + +### `ReferenceError: customElements is not defined` +You're importing a component at the module level on the server. Use dynamic imports or `"use client"`. + +### Hydration mismatch warnings +Check for `Math.random()` or `Date.now()` in component state. Use deterministic values instead. + +### Flash of Unstyled Content (FOUC) +Add CSS to hide undefined custom elements until they upgrade: + +```css +:not(:defined) { + visibility: hidden; +} +``` + + + + diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/service-create/index.ts b/packages/ecc-client-elixir-cloud-registry/src/components/service-create/index.ts index dbfdb0bb..f450a6cc 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/components/service-create/index.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/components/service-create/index.ts @@ -3,10 +3,17 @@ import ECCClientElixirCloudRegistryServiceCreate from "./service-create.js"; export * from "./service-create.js"; export default ECCClientElixirCloudRegistryServiceCreate; -window.customElements.define( - "ecc-client-elixir-cloud-registry-service-create", - ECCClientElixirCloudRegistryServiceCreate -); +// SSR guard: customElements is not available in Node.js +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-client-elixir-cloud-registry-service-create") +) { + window.customElements.define( + "ecc-client-elixir-cloud-registry-service-create", + ECCClientElixirCloudRegistryServiceCreate + ); +} declare global { interface HTMLElementTagNameMap { diff --git a/packages/ecc-client-elixir-drs-filer/src/components/object-create/index.ts b/packages/ecc-client-elixir-drs-filer/src/components/object-create/index.ts index c2ab07b4..6769a6db 100644 --- a/packages/ecc-client-elixir-drs-filer/src/components/object-create/index.ts +++ b/packages/ecc-client-elixir-drs-filer/src/components/object-create/index.ts @@ -2,10 +2,17 @@ import ECCClientElixirDrsFilerObjectCreate from "./object-create.js"; export * from "./object-create.js"; -window.customElements.define( - "ecc-client-elixir-drs-filer-object-create", - ECCClientElixirDrsFilerObjectCreate -); +// SSR guard: customElements is not available in Node.js +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-client-elixir-drs-filer-object-create") +) { + window.customElements.define( + "ecc-client-elixir-drs-filer-object-create", + ECCClientElixirDrsFilerObjectCreate + ); +} declare global { interface HTMLElementTagNameMap { diff --git a/packages/ecc-client-elixir-ro-crate/src/components/about/index.ts b/packages/ecc-client-elixir-ro-crate/src/components/about/index.ts index d9f21492..df923193 100644 --- a/packages/ecc-client-elixir-ro-crate/src/components/about/index.ts +++ b/packages/ecc-client-elixir-ro-crate/src/components/about/index.ts @@ -3,10 +3,17 @@ import ECCClientRoCrateAbout from "./about.js"; export * from "./about.js"; export default ECCClientRoCrateAbout; -window.customElements.define( - "ecc-client-elixir-ro-crate-about", - ECCClientRoCrateAbout -); +// SSR guard: customElements is not available in Node.js +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-client-elixir-ro-crate-about") +) { + window.customElements.define( + "ecc-client-elixir-ro-crate-about", + ECCClientRoCrateAbout + ); +} declare global { interface HTMLElementTagNameMap { diff --git a/packages/ecc-client-elixir-trs-filer/src/components/tool-create/index.ts b/packages/ecc-client-elixir-trs-filer/src/components/tool-create/index.ts index 880d876e..5bc2ec34 100644 --- a/packages/ecc-client-elixir-trs-filer/src/components/tool-create/index.ts +++ b/packages/ecc-client-elixir-trs-filer/src/components/tool-create/index.ts @@ -3,10 +3,17 @@ import ECCClientElixirTrsToolCreate from "./tool-create.js"; export * from "./tool-create.js"; export default ECCClientElixirTrsToolCreate; -window.customElements.define( - "ecc-client-elixir-trs-tool-create", - ECCClientElixirTrsToolCreate -); +// SSR guard: customElements is not available in Node.js +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-client-elixir-trs-tool-create") +) { + window.customElements.define( + "ecc-client-elixir-trs-tool-create", + ECCClientElixirTrsToolCreate + ); +} declare global { interface HTMLElementTagNameMap { diff --git a/packages/ecc-client-ga4gh-drs/src/components/object/index.ts b/packages/ecc-client-ga4gh-drs/src/components/object/index.ts index 62213f1b..c97852db 100644 --- a/packages/ecc-client-ga4gh-drs/src/components/object/index.ts +++ b/packages/ecc-client-ga4gh-drs/src/components/object/index.ts @@ -2,8 +2,17 @@ import ECCClientGa4ghDrsObject from "./object.js"; export * from "./object.js"; -// Define the custom element -customElements.define("ecc-client-ga4gh-drs-object", ECCClientGa4ghDrsObject); +// SSR guard: customElements is not available in Node.js +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-client-ga4gh-drs-object") +) { + window.customElements.define( + "ecc-client-ga4gh-drs-object", + ECCClientGa4ghDrsObject + ); +} export default ECCClientGa4ghDrsObject; diff --git a/packages/ecc-client-ga4gh-drs/src/components/objects-list/index.ts b/packages/ecc-client-ga4gh-drs/src/components/objects-list/index.ts index 5a1f7d9e..dec95e01 100644 --- a/packages/ecc-client-ga4gh-drs/src/components/objects-list/index.ts +++ b/packages/ecc-client-ga4gh-drs/src/components/objects-list/index.ts @@ -3,10 +3,17 @@ import ECCClientGa4ghDrsObjects from "./objects.js"; export * from "./objects.js"; export default ECCClientGa4ghDrsObjects; -window.customElements.define( - "ecc-client-ga4gh-drs-objects", - ECCClientGa4ghDrsObjects -); +// SSR guard: customElements is not available in Node.js +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-client-ga4gh-drs-objects") +) { + window.customElements.define( + "ecc-client-ga4gh-drs-objects", + ECCClientGa4ghDrsObjects + ); +} declare global { interface HTMLElementTagNameMap { diff --git a/packages/ecc-client-ga4gh-service-registry/src/components/service/index.ts b/packages/ecc-client-ga4gh-service-registry/src/components/service/index.ts index 0f798bc5..be93c811 100644 --- a/packages/ecc-client-ga4gh-service-registry/src/components/service/index.ts +++ b/packages/ecc-client-ga4gh-service-registry/src/components/service/index.ts @@ -2,11 +2,17 @@ import ECCClientGa4ghServiceRegistryService from "./service.js"; export * from "./service.js"; -// Define the custom element -customElements.define( - "ecc-client-ga4gh-service-registry-service", - ECCClientGa4ghServiceRegistryService -); +// SSR guard: customElements is not available in Node.js +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-client-ga4gh-service-registry-service") +) { + window.customElements.define( + "ecc-client-ga4gh-service-registry-service", + ECCClientGa4ghServiceRegistryService + ); +} export default ECCClientGa4ghServiceRegistryService; diff --git a/packages/ecc-client-ga4gh-service-registry/src/components/services/index.ts b/packages/ecc-client-ga4gh-service-registry/src/components/services/index.ts index ba65da33..6bcd38b1 100644 --- a/packages/ecc-client-ga4gh-service-registry/src/components/services/index.ts +++ b/packages/ecc-client-ga4gh-service-registry/src/components/services/index.ts @@ -3,10 +3,17 @@ import { ECCClientGa4ghServiceRegistryServices } from "./services.js"; export * from "./services.js"; export default ECCClientGa4ghServiceRegistryServices; -window.customElements.define( - "ecc-client-ga4gh-service-registry-services", - ECCClientGa4ghServiceRegistryServices -); +// SSR guard: customElements is not available in Node.js +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-client-ga4gh-service-registry-services") +) { + window.customElements.define( + "ecc-client-ga4gh-service-registry-services", + ECCClientGa4ghServiceRegistryServices + ); +} declare global { interface HTMLElementTagNameMap { diff --git a/packages/ecc-client-ga4gh-tes/src/components/create-run/index.ts b/packages/ecc-client-ga4gh-tes/src/components/create-run/index.ts index 5c633d03..0b399133 100644 --- a/packages/ecc-client-ga4gh-tes/src/components/create-run/index.ts +++ b/packages/ecc-client-ga4gh-tes/src/components/create-run/index.ts @@ -3,10 +3,17 @@ import ECCCLientGa4ghTesCreateRun from "./create-run.js"; export * from "./create-run.js"; export default ECCCLientGa4ghTesCreateRun; -window.customElements.define( - "ecc-client-ga4gh-tes-create-run", - ECCCLientGa4ghTesCreateRun -); +// SSR guard: customElements is not available in Node.js +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-client-ga4gh-tes-create-run") +) { + window.customElements.define( + "ecc-client-ga4gh-tes-create-run", + ECCCLientGa4ghTesCreateRun + ); +} declare global { interface HTMLElementTagNameMap { diff --git a/packages/ecc-client-ga4gh-tes/src/components/runs/index.ts b/packages/ecc-client-ga4gh-tes/src/components/runs/index.ts index b7fa4234..9573a7a8 100644 --- a/packages/ecc-client-ga4gh-tes/src/components/runs/index.ts +++ b/packages/ecc-client-ga4gh-tes/src/components/runs/index.ts @@ -3,10 +3,17 @@ import ECCClientGa4ghTesRuns from "./runs.js"; export * from "./runs.js"; export default ECCClientGa4ghTesRuns; -window.customElements.define( - "ecc-client-ga4gh-tes-runs", - ECCClientGa4ghTesRuns -); +// SSR guard: customElements is not available in Node.js +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-client-ga4gh-tes-runs") +) { + window.customElements.define( + "ecc-client-ga4gh-tes-runs", + ECCClientGa4ghTesRuns + ); +} declare global { interface HTMLElementTagNameMap { diff --git a/packages/ecc-client-ga4gh-trs/src/components/tool/index.ts b/packages/ecc-client-ga4gh-trs/src/components/tool/index.ts index e5f43ad9..fec3a7f2 100644 --- a/packages/ecc-client-ga4gh-trs/src/components/tool/index.ts +++ b/packages/ecc-client-ga4gh-trs/src/components/tool/index.ts @@ -2,8 +2,17 @@ import ECCClientGa4ghTrsTool from "./tool.js"; export * from "./tool.js"; -// Define the custom element -customElements.define("ecc-client-ga4gh-trs-tool", ECCClientGa4ghTrsTool); +// SSR guard: customElements is not available in Node.js +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-client-ga4gh-trs-tool") +) { + window.customElements.define( + "ecc-client-ga4gh-trs-tool", + ECCClientGa4ghTrsTool + ); +} export default ECCClientGa4ghTrsTool; diff --git a/packages/ecc-client-ga4gh-trs/src/components/tools/index.ts b/packages/ecc-client-ga4gh-trs/src/components/tools/index.ts index a04c2b36..feaf91a4 100644 --- a/packages/ecc-client-ga4gh-trs/src/components/tools/index.ts +++ b/packages/ecc-client-ga4gh-trs/src/components/tools/index.ts @@ -3,10 +3,17 @@ import ECCClientGa4ghTrsTools from "./tools.js"; export * from "./tools.js"; export default ECCClientGa4ghTrsTools; -window.customElements.define( - "ecc-client-ga4gh-trs-tools", - ECCClientGa4ghTrsTools -); +// SSR guard: customElements is not available in Node.js +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-client-ga4gh-trs-tools") +) { + window.customElements.define( + "ecc-client-ga4gh-trs-tools", + ECCClientGa4ghTrsTools + ); +} declare global { interface HTMLElementTagNameMap { diff --git a/packages/ecc-client-ga4gh-wes/src/components/run-create/index.ts b/packages/ecc-client-ga4gh-wes/src/components/run-create/index.ts index bef29b6c..ad473e6d 100644 --- a/packages/ecc-client-ga4gh-wes/src/components/run-create/index.ts +++ b/packages/ecc-client-ga4gh-wes/src/components/run-create/index.ts @@ -3,11 +3,17 @@ import ECCClientGa4ghWesRunCreate from "./run-create.js"; export * from "./run-create.js"; export default ECCClientGa4ghWesRunCreate; -// Define the custom element -customElements.define( - "ecc-client-ga4gh-wes-run-create", - ECCClientGa4ghWesRunCreate -); +// SSR guard: customElements is not available in Node.js +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-client-ga4gh-wes-run-create") +) { + window.customElements.define( + "ecc-client-ga4gh-wes-run-create", + ECCClientGa4ghWesRunCreate + ); +} declare global { interface HTMLElementTagNameMap { diff --git a/packages/ecc-client-ga4gh-wes/src/components/run/index.ts b/packages/ecc-client-ga4gh-wes/src/components/run/index.ts index 146ea718..086296ad 100644 --- a/packages/ecc-client-ga4gh-wes/src/components/run/index.ts +++ b/packages/ecc-client-ga4gh-wes/src/components/run/index.ts @@ -3,8 +3,17 @@ import ECCClientGa4ghWesRun from "./run.js"; export * from "./run.js"; export default ECCClientGa4ghWesRun; -// Define the custom element -customElements.define("ecc-client-ga4gh-wes-run", ECCClientGa4ghWesRun); +// SSR guard: customElements is not available in Node.js +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-client-ga4gh-wes-run") +) { + window.customElements.define( + "ecc-client-ga4gh-wes-run", + ECCClientGa4ghWesRun + ); +} declare global { interface HTMLElementTagNameMap { diff --git a/packages/ecc-client-ga4gh-wes/src/components/runs/index.ts b/packages/ecc-client-ga4gh-wes/src/components/runs/index.ts index ddd1d9b3..1591990f 100644 --- a/packages/ecc-client-ga4gh-wes/src/components/runs/index.ts +++ b/packages/ecc-client-ga4gh-wes/src/components/runs/index.ts @@ -3,8 +3,17 @@ import ECCClientGa4ghWesRuns from "./runs.js"; export * from "./runs.js"; export default ECCClientGa4ghWesRuns; -// Define the custom element -customElements.define("ecc-client-ga4gh-wes-runs", ECCClientGa4ghWesRuns); +// SSR guard: customElements is not available in Node.js +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-client-ga4gh-wes-runs") +) { + window.customElements.define( + "ecc-client-ga4gh-wes-runs", + ECCClientGa4ghWesRuns + ); +} declare global { interface HTMLElementTagNameMap { diff --git a/packages/ecc-utils-design/src/components/code/code.ts b/packages/ecc-utils-design/src/components/code/code.ts index 7b550673..81f55f91 100644 --- a/packages/ecc-utils-design/src/components/code/code.ts +++ b/packages/ecc-utils-design/src/components/code/code.ts @@ -2,6 +2,7 @@ import { LitElement, html, css } from "lit"; import { property, state } from "lit/decorators.js"; import { ComponentStyles as TailwindStyles } from "./tw-styles.js"; import { GlobalStyles } from "../../global.js"; +import { isBrowser } from "../../ssr.js"; import "ace-builds/src-noconflict/ace.js"; import "ace-builds/src-noconflict/theme-cloud9_day.js"; import "ace-builds/src-noconflict/theme-cloud9_night.js"; @@ -646,6 +647,10 @@ export class EccUtilsDesignCode extends LitElement { } setupThemeObserver() { + // Guard: MutationObserver and document are not available on the server or in some + // constrained environments + if (!isBrowser() || typeof MutationObserver === "undefined") return; + // Set up mutation observer to watch for theme changes const observer = new MutationObserver(() => { this.updateEditorTheme(); @@ -664,6 +669,9 @@ export class EccUtilsDesignCode extends LitElement { } isDarkMode() { + // SSR guard: document is not available on the server + if (!isBrowser()) return false; + return ( document.documentElement.classList.contains("dark") || document.body.classList.contains("dark") || diff --git a/packages/ecc-utils-design/src/components/collapsible/collapsible.ts b/packages/ecc-utils-design/src/components/collapsible/collapsible.ts index a94013f4..8d3b08e0 100644 --- a/packages/ecc-utils-design/src/components/collapsible/collapsible.ts +++ b/packages/ecc-utils-design/src/components/collapsible/collapsible.ts @@ -2,6 +2,7 @@ import { LitElement, html, css, PropertyValues } from "lit"; import { property, state } from "lit/decorators.js"; import { ComponentStyles as TailwindStyles } from "./tw-styles.js"; import { GlobalStyles } from "../../global.js"; +import { generateDeterministicId } from "../../ssr.js"; // Global state manager for collapsible const collapsibleState = new Map< @@ -24,9 +25,8 @@ export class EccUtilsDesignCollapsible extends LitElement { `, ]; - @state() private collapsibleId = `collapsible-${Math.random() - .toString(36) - .substring(2, 9)}`; + // SSR-safe: deterministic IDs prevent hydration mismatches between server and client + @state() private collapsibleId = generateDeterministicId("collapsible"); @property({ type: Boolean }) open = false; @property({ type: Boolean }) disabled = false; diff --git a/packages/ecc-utils-design/src/components/multi-select/multi-select.ts b/packages/ecc-utils-design/src/components/multi-select/multi-select.ts index 2f8b228d..ddb76553 100644 --- a/packages/ecc-utils-design/src/components/multi-select/multi-select.ts +++ b/packages/ecc-utils-design/src/components/multi-select/multi-select.ts @@ -2,6 +2,7 @@ import { LitElement, html, css, PropertyValues } from "lit"; import { property, state } from "lit/decorators.js"; import { ComponentStyles as TailwindStyles } from "./tw-styles.js"; import { GlobalStyles } from "../../global.js"; +import { generateDeterministicId } from "../../ssr.js"; import "../checkbox/index.js"; function cn(...classes: (string | undefined | false)[]) { @@ -40,9 +41,8 @@ export class EccUtilsDesignMultiSelect extends LitElement { `, ]; - @state() private selectId = `multi-select-${Math.random() - .toString(36) - .substring(2, 9)}`; + // SSR-safe: deterministic IDs prevent hydration mismatches between server and client + @state() private selectId = generateDeterministicId("multi-select"); @property({ type: Array }) value: string[] = []; @property({ type: String }) placeholder = "Select options..."; diff --git a/packages/ecc-utils-design/src/components/select/select.ts b/packages/ecc-utils-design/src/components/select/select.ts index e716770a..d38a41bf 100644 --- a/packages/ecc-utils-design/src/components/select/select.ts +++ b/packages/ecc-utils-design/src/components/select/select.ts @@ -2,6 +2,7 @@ import { LitElement, html, css, PropertyValues } from "lit"; import { property, state } from "lit/decorators.js"; import { ComponentStyles as TailwindStyles } from "./tw-styles.js"; import { GlobalStyles } from "../../global.js"; +import { generateDeterministicId } from "../../ssr.js"; function cn(...classes: (string | undefined | false)[]) { return classes.filter(Boolean).join(" "); @@ -30,9 +31,8 @@ export class EccUtilsDesignSelect extends LitElement { `, ]; - @state() private selectId = `select-${Math.random() - .toString(36) - .substring(2, 9)}`; + // SSR-safe: deterministic IDs prevent hydration mismatches between server and client + @state() private selectId = generateDeterministicId("select"); @property({ type: String }) value = ""; @property({ type: Boolean }) disabled = false; diff --git a/packages/ecc-utils-design/src/index.ts b/packages/ecc-utils-design/src/index.ts index 96a7338f..5975427b 100644 --- a/packages/ecc-utils-design/src/index.ts +++ b/packages/ecc-utils-design/src/index.ts @@ -2,3 +2,4 @@ import "./components/index.js"; import "./events/index.js"; export * from "./components/index.js"; +export * from "./ssr.js"; diff --git a/packages/ecc-utils-design/src/ssr.ts b/packages/ecc-utils-design/src/ssr.ts new file mode 100644 index 00000000..f71fd456 --- /dev/null +++ b/packages/ecc-utils-design/src/ssr.ts @@ -0,0 +1,91 @@ +/** + * SSR (Server-Side Rendering) Utility Helpers + * + * These utilities ensure that Web Components can be safely imported + * during SSR (e.g. Next.js, Remix) without crashing due to missing + * browser globals (`window`, `document`, `customElements`). + * + * Note: This library does not render component templates on the server. + * Components are safe to *load* during SSR; rendering and hydration + * occur on the client. + * + * @module ssr + */ + +/** + * Returns `true` when running in a browser environment. + * + * Why: Node.js (used by SSR frameworks) does not have a `window` global. + * Any code that touches `window`, `document`, or `customElements` must be + * gated behind this check to avoid `ReferenceError` on the server. + */ +export function isBrowser(): boolean { + return typeof window !== "undefined"; +} + +/** + * Returns `true` when running on the server (Node.js / SSR). + */ +export function isServer(): boolean { + return typeof window === "undefined"; +} + +/** + * Safely registers a custom element, skipping registration on the server + * and preventing duplicate registrations on the client. + * + * Why: + * - `customElements` does not exist in Node.js → calling it crashes the import. + * - Calling `customElements.define()` twice with the same tag name throws a + * `DOMException` → the duplicate check prevents this in HMR / dev scenarios. + * + * @param tagName - The custom element tag name (e.g. "ecc-utils-design-button") + * @param constructor - The custom element class + */ +export function ssrSafeDefine( + tagName: string, + constructor: CustomElementConstructor +): void { + if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get(tagName) + ) { + window.customElements.define(tagName, constructor); + } +} + +/** + * Monotonically increasing counter used by `generateDeterministicId`. + * Resets per JS context (server request vs client page load), which is + * exactly what we need for SSR hydration: both server and client start + * counting from 0 in the same order, producing matching IDs. + */ +let idCounter = 0; + +/** + * Generates a deterministic, incrementing ID string. + * + * Why: `Math.random()` produces different values on server vs client, + * causing hydration mismatches. A monotonic counter produces the same + * sequence as long as components are instantiated in the same order. + * + * @param prefix - A human-readable prefix (e.g. "select", "multi-select") + * @returns A unique ID like "select-0", "select-1", etc. + */ +export function generateDeterministicId(prefix: string): string { + const id = idCounter; + idCounter += 1; + return `${prefix}-${id}`; +} + +/** + * Resets the deterministic ID counter. + * + * Useful in testing or when a fresh SSR request starts. + * Frameworks typically create a new JS context per request, so this + * is rarely needed in production. + */ +export function resetIdCounter(): void { + idCounter = 0; +}