Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 `<ClientReady>` when the browser-only subtree also has async work that should finish before the fallback disappears. It skips the subtree during SSG like `<ClientOnly>`, then keeps showing the fallback until the client subtree has mounted and its initial `<Await>` boundary has resolved.

```tsx
import { ClientReady } from 'retend-server';

function App() {
return (
<ClientReady fallback={<BootScreen />}>
<Dashboard />
</ClientReady>
);
}
```

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 `<ClientOnly>` when a component:
Expand Down
4 changes: 2 additions & 2 deletions packages/retend-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
104 changes: 104 additions & 0 deletions packages/retend-server/source/client-boundaries.js
Original file line number Diff line number Diff line change
@@ -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 = () => (
* <ClientOnly fallback={<p>Loading...</p>}>
* <WindowSizeDependentComponent />
* </ClientOnly>
* );
* ```
*/
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 = () => (
* <ClientReady fallback={<p>Starting...</p>}>
* <Dashboard />
* </ClientReady>
* );
* ```
*/
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,
}),
];
Comment on lines +84 to +103

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Fallback/content overlap 🐞 Bug ≡ Correctness

ClientReady can briefly render the resolved client subtree while still rendering fallback,
because ready is flipped in an onSetup effect that runs after the DOM has already been updated
to show the Await content. This can produce a flash/layout shift (content appears before fallback
is removed) and breaks the promise that the fallback remains until the subtree is ready, then
disappears.
Agent Prompt
### Issue description
`ClientReady` currently renders fallback via a separate `If(ready)` sibling, but only sets `ready` in an `onSetup` effect inside the awaited subtree. Because Retend applies DOM updates before running `onSetup` effects (and activation is delayed), the awaited content can become visible while the fallback is still rendered, causing a brief overlap/layout shift.

### Issue Context
`Await` already supports `fallback` and will keep rendering it until its initial async dependencies resolve. `ClientOnly` can also render the same `fallback` during SSR/before mount.

### Fix Focus Areas
- packages/retend-server/source/client-boundaries.js[84-103]

### Suggested fix
Refactor `ClientReady` to rely on `ClientOnly` + `Await` fallback instead of a separate `ready` cell:

```js
export function ClientReady(props) {
  const { children, fallback } = props;

  return ClientOnly({
    fallback,
    children: () =>
      Await({
        fallback: fallback ?? null,
        children,
      }),
  });
}
```

This keeps the fallback in exactly one place and prevents the “content appears before fallback is removed” window.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}
51 changes: 0 additions & 51 deletions packages/retend-server/source/client-only.js

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -287,3 +287,103 @@ describe('ClientOnly', () => {
});
});
});

describe('ClientReady', () => {
describe('SSR (serialization)', () => {
vDomSetup({ markDynamicNodes: true });

it('should render fallback during SSR', () => {
const template = () => (
<div id="root">
<ClientReady fallback={<span id="fallback">Loading...</span>}>
<span id="client-content">Client</span>
</ClientReady>
</div>
);

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 <span id="delayed">{delay}</span>;
};

const Content = () => {
onSetup(() => loaded.set(true));
return <Delayed />;
};

const template = () => (
<div id="root">
<ClientReady fallback={<span id="fallback">Boot</span>}>
<Content />
</ClientReady>
</div>
);

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 <span id="spa-delayed">{delay}</span>;
};

const template = () => (
<div id="root">
<ClientReady fallback={<span id="spa-fallback">Boot</span>}>
<Delayed />
</ClientReady>
</div>
);

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