diff --git a/docs/content/24-client-only.mdx b/docs/content/24-client-boundaries.mdx similarity index 63% rename from docs/content/24-client-only.mdx rename to docs/content/24-client-boundaries.mdx index 99f5a379..d9bab789 100644 --- a/docs/content/24-client-only.mdx +++ b/docs/content/24-client-boundaries.mdx @@ -1,6 +1,6 @@ --- -title: 'ClientOnly' -description: 'Render UI only in the browser for API-dependent components.' +title: 'ClientOnly and ClientReady' +description: 'Render UI only in the browser, with optional readiness gating for async client content.' --- # ClientOnly @@ -29,6 +29,24 @@ function App() { During SSG, the `fallback` is rendered instead. Once the JavaScript loads and the component mounts in the browser, the real `children` replace the fallback. +## ClientReady + +Use `` when the browser-only subtree also has async work that should finish before the fallback disappears. It skips the subtree during SSG like ``, then keeps showing the fallback until the client subtree has mounted and its initial `` boundary has resolved. + +```tsx +import { ClientReady } from 'retend-server'; + +function App() { + return ( + }> + + + ); +} +``` + +This is useful for app shells, boot screens, and browser-only dashboards where showing an empty or half-prepared client subtree would be more distracting than keeping the loading UI visible for a little longer. + ## When to Use It Use `` when a component: diff --git a/packages/retend-server/package.json b/packages/retend-server/package.json index 45937713..78c03b13 100644 --- a/packages/retend-server/package.json +++ b/packages/retend-server/package.json @@ -6,8 +6,8 @@ "type": "module", "exports": { ".": { - "types": "./dist/client-only.d.ts", - "import": "./dist/client-only.js" + "types": "./dist/client-boundaries.d.ts", + "import": "./dist/client-boundaries.js" }, "./server": { "types": "./dist/server.d.ts", diff --git a/packages/retend-server/source/client-boundaries.js b/packages/retend-server/source/client-boundaries.js new file mode 100644 index 00000000..febca637 --- /dev/null +++ b/packages/retend-server/source/client-boundaries.js @@ -0,0 +1,104 @@ +/** @import { JSX } from 'retend/jsx-runtime' */ + +import { Await, Cell, If, onSetup } from 'retend'; + +/** + * @typedef {Object} ClientOnlyProps + * @property {JSX.Children} children + * The content to render only on the client side. + * @property {JSX.Template} [fallback] + * Optional content to render during server-side rendering + * and before the client has mounted. + */ + +/** + * A component that only renders its children on the client side. + * + * During server-side rendering, the `fallback` is rendered instead + * (or nothing if no fallback is provided). Once the component is + * mounted on the client, it swaps to the real `children`. + * + * This is useful for components that depend on browser-only APIs + * (e.g., `window.innerWidth`) and would produce a server/client + * mismatch during hydration. + * + * @param {ClientOnlyProps} props + * @returns {JSX.Template} + * + * @example + * ```tsx + * import { ClientOnly } from 'retend-server'; + * + * const Page = () => ( + * Loading...

}> + * + *
+ * ); + * ``` + */ +export function ClientOnly(props) { + const { children, fallback } = props; + const mounted = Cell.source(false); + + onSetup(() => { + mounted.set(true); + }); + + return If(mounted, { + true: () => children, + false: () => fallback, + }); +} + +/** + * @typedef {Object} ClientReadyProps + * @property {JSX.Children} children + * The content to render after the app is running on the client and its + * initial async work has completed. + * @property {JSX.Template} [fallback] + * Optional content to render during server-side rendering, hydration, + * and the client subtree's initial async work. + */ + +/** + * A component that renders fallback content until a client-only subtree is + * mounted and its initial `Await` boundary has finished resolving. + * + * Unlike `ClientOnly`, this keeps the fallback visible while async cells inside + * the client subtree are still pending on the first client render. + * + * @param {ClientReadyProps} props + * @returns {JSX.Template} + * + * @example + * ```tsx + * import { ClientReady } from 'retend-server'; + * + * const App = () => ( + * Starting...

}> + * + *
+ * ); + * ``` + */ +export function ClientReady(props) { + const { children, fallback } = props; + const ready = Cell.source(false); + + const ClientReadyContent = () => { + onSetup(() => ready.set(true)); + return children; + }; + + return [ + ClientOnly({ + children: () => + Await({ + children: ClientReadyContent, + }), + }), + If(ready, { + false: () => fallback, + }), + ]; +} diff --git a/packages/retend-server/source/client-only.js b/packages/retend-server/source/client-only.js deleted file mode 100644 index fc080d48..00000000 --- a/packages/retend-server/source/client-only.js +++ /dev/null @@ -1,51 +0,0 @@ -/** @import { JSX } from 'retend/jsx-runtime' */ - -import { Cell, If, onSetup } from 'retend'; - -/** - * @typedef {Object} ClientOnlyProps - * @property {JSX.Template} children - * The content to render only on the client side. - * @property {JSX.Template} [fallback] - * Optional content to render during server-side rendering - * and before the client has mounted. - */ - -/** - * A component that only renders its children on the client side. - * - * During server-side rendering, the `fallback` is rendered instead - * (or nothing if no fallback is provided). Once the component is - * mounted on the client, it swaps to the real `children`. - * - * This is useful for components that depend on browser-only APIs - * (e.g., `window.innerWidth`) and would produce a server/client - * mismatch during hydration. - * - * @param {ClientOnlyProps} props - * @returns {JSX.Template} - * - * @example - * ```tsx - * import { ClientOnly } from 'retend-server'; - * - * const Page = () => ( - * Loading...

}> - * - *
- * ); - * ``` - */ -export function ClientOnly(props) { - const { children, fallback } = props; - const mounted = Cell.source(false); - - onSetup(() => { - mounted.set(true); - }); - - return If(mounted, { - true: () => children, - false: () => fallback, - }); -} diff --git a/tests/hydration/client-only.spec.tsx b/tests/hydration/client-boundaries.spec.tsx similarity index 73% rename from tests/hydration/client-only.spec.tsx rename to tests/hydration/client-boundaries.spec.tsx index 695548cf..4f149d2a 100644 --- a/tests/hydration/client-only.spec.tsx +++ b/tests/hydration/client-boundaries.spec.tsx @@ -5,7 +5,7 @@ import { onSetup, runPendingSetupEffects, } from 'retend'; -import { ClientOnly } from 'retend-server'; +import { ClientOnly, ClientReady } from 'retend-server'; import { describe, expect, it, vi } from 'vitest'; import { browserSetup, render, vDomSetup } from '../setup.tsx'; @@ -287,3 +287,103 @@ describe('ClientOnly', () => { }); }); }); + +describe('ClientReady', () => { + describe('SSR (serialization)', () => { + vDomSetup({ markDynamicNodes: true }); + + it('should render fallback during SSR', () => { + const template = () => ( +
+ Loading...}> + Client + +
+ ); + + const rendered = render(template) as unknown as Element; + expect(rendered.querySelector('#fallback')).not.toBeNull(); + expect(rendered.querySelector('#client-content')).toBeNull(); + }); + }); + + describe('Hydration', () => { + browserSetup(); + + it('should keep fallback visible until async client content resolves', async () => { + const loaded = Cell.source(false); + + const Delayed = () => { + const delay = Cell.derivedAsync(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + return 'Ready'; + }); + + return {delay}; + }; + + const Content = () => { + onSetup(() => loaded.set(true)); + return ; + }; + + const template = () => ( +
+ Boot}> + + +
+ ); + + const { document } = await setupHydration(template); + await runPendingSetupEffects(); + + expect(document.querySelector('#fallback')).not.toBeNull(); + expect(document.querySelector('#delayed')).toBeNull(); + expect(loaded.get()).toBe(false); + + await new Promise((resolve) => setTimeout(resolve, 50)); + await runPendingSetupEffects(); + + expect(document.querySelector('#fallback')).toBeNull(); + expect(document.querySelector('#delayed')?.textContent).toBe('Ready'); + expect(loaded.get()).toBe(true); + }); + }); + + describe('SPA (no hydration)', () => { + browserSetup(); + + it('should wait for initial async client content before removing fallback', async () => { + const Delayed = () => { + const delay = Cell.derivedAsync(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + return 'Ready'; + }); + + return {delay}; + }; + + const template = () => ( +
+ Boot}> + + +
+ ); + + const root = render(template); + document.body.append(root); + await runPendingSetupEffects(); + + expect(document.querySelector('#spa-fallback')).not.toBeNull(); + expect(document.querySelector('#spa-delayed')).toBeNull(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + await runPendingSetupEffects(); + + expect(document.querySelector('#spa-fallback')).toBeNull(); + expect(document.querySelector('#spa-delayed')?.textContent).toBe('Ready'); + }); + }); +});