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');
+ });
+ });
+});