Skip to content

Commit 4d62e6f

Browse files
committed
feat: add client-only islands via export const clientOnly = true
Islands marked with `export const clientOnly = true` skip server-side rendering entirely — Fresh renders an empty placeholder on the server and the component renders normally on the client. This supports libraries like Monaco Editor that reference browser globals at the module top level.
1 parent 673720c commit 4d62e6f

8 files changed

Lines changed: 130 additions & 12 deletions

File tree

docs/latest/concepts/islands.md

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,34 @@ import OtherIsland from "../islands/other-island.tsx";
118118
</div>;
119119
```
120120

121-
## Rendering islands on client only
121+
## Client-only islands
122122

123-
When using client-only APIs, like `EventSource` or `navigator.getUserMedia`, the
124-
component would error during server-side rendering. Use the `IS_BROWSER`
125-
constant from `fresh/runtime` to guard browser-only code. It is `false` on the
126-
server and `true` in the browser:
123+
Some libraries (e.g. Monaco Editor, certain charting libraries) reference
124+
browser globals like `document` at the module top level, which crashes during
125+
server-side rendering. You can mark an island as **client-only** by adding
126+
`export const clientOnly = true`. Fresh will skip executing the component on the
127+
server and render an empty placeholder instead. On the client, the component
128+
renders normally.
129+
130+
```tsx islands/my-editor.tsx
131+
export const clientOnly = true;
132+
133+
export default function MyEditor() {
134+
// Safe to use document, window, etc. — this code never runs on the server.
135+
return <div>{/* ... */}</div>;
136+
}
137+
```
138+
139+
> [warn]: Client-only islands produce no meaningful HTML on the server. This
140+
> means search engines will not see their content, and users will see an empty
141+
> placeholder until JavaScript loads. Use this only when the component truly
142+
> cannot run on the server.
143+
144+
### Using `IS_BROWSER` for a custom fallback
145+
146+
If the module itself can be loaded on the server but you only need to guard
147+
certain API calls, use the `IS_BROWSER` constant from `fresh/runtime` instead.
148+
This lets you return a meaningful SSR fallback:
127149

128150
```tsx islands/my-island.tsx
129151
import { IS_BROWSER } from "fresh/runtime";

packages/fresh/src/build_cache.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,9 @@ export class IslandPreparer {
103103
chunkName: string,
104104
modName: string,
105105
css: string[],
106+
clientOnly?: boolean,
106107
) {
108+
const isClientOnly = clientOnly ?? mod.clientOnly === true;
107109
for (const [name, value] of Object.entries(mod)) {
108110
if (typeof value !== "function") continue;
109111

@@ -117,6 +119,7 @@ export class IslandPreparer {
117119
fn,
118120
name: uniqueName,
119121
css,
122+
clientOnly: isClientOnly,
120123
});
121124
}
122125
}

packages/fresh/src/context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export interface Island {
9191
exportName: string;
9292
fn: ComponentType;
9393
css: string[];
94+
clientOnly: boolean;
9495
}
9596

9697
export type ServerIslandRegistry = Map<ComponentType, Island>;

packages/fresh/src/dev/dev_build_cache.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export interface IslandModChunk {
3535
server: string;
3636
browser: string | null;
3737
css: string[];
38+
clientOnly?: boolean;
3839
}
3940

4041
export type FsRouteFileNoMod<State> = Omit<FsRouteFile<State>, "mod"> & {
@@ -493,6 +494,9 @@ export async function generateSnapshotServer(
493494
const browser = JSON.stringify(item.browser);
494495
const name = JSON.stringify(item.name);
495496
const css = JSON.stringify(item.css);
497+
if (item.clientOnly) {
498+
return `islandPreparer.prepare(islands, ${item.name}, ${browser}, ${name}, ${css}, true);`;
499+
}
496500
return `islandPreparer.prepare(islands, ${item.name}, ${browser}, ${name}, ${css});`;
497501
}).join("\n");
498502

packages/fresh/src/runtime/client/reviver.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface IslandReq {
2121
name: string;
2222
propsIdx: number;
2323
key: string | null;
24+
clientOnly: boolean;
2425
start: Comment | Text;
2526
end: Comment | Text | null;
2627
}
@@ -269,11 +270,13 @@ function _walkInner(
269270
const name = parts[2];
270271
const propsIdx = parts[3];
271272
const key = parts[4];
273+
const clientOnly = parts[5] === "c";
272274
const found: IslandReq = {
273275
kind: RootKind.Island,
274276
name,
275277
propsIdx: Number(propsIdx),
276278
key: key === "" ? null : key,
279+
clientOnly,
277280
start: node as Comment,
278281
end: null,
279282
};

packages/fresh/src/runtime/server/preact_hooks.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -300,15 +300,19 @@ options[OptionsType.DIFF] = (vnode) => {
300300
}
301301
const propsIdx = islandProps.push({ slots: [], props }) - 1;
302302

303-
const child = h(originalType, props);
303+
const key = normalizeKey(vnode.key);
304+
const markerData = island!.clientOnly
305+
? `${island!.name}:${propsIdx}:${key}:c`
306+
: `${island!.name}:${propsIdx}:${key}`;
307+
308+
// For client-only islands, render an empty placeholder
309+
// instead of executing the component on the server.
310+
const child = island!.clientOnly
311+
? h("div", null)
312+
: h(originalType, props);
304313
PATCHED.add(child);
305314

306-
const key = normalizeKey(vnode.key);
307-
return wrapWithMarker(
308-
child,
309-
"island",
310-
`${island!.name}:${propsIdx}:${key}`,
311-
);
315+
return wrapWithMarker(child, "island", markerData);
312316
};
313317
}
314318
} else if (typeof vnode.type === "string") {
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useSignal } from "@preact/signals";
2+
import { useEffect } from "preact/hooks";
3+
4+
export const clientOnly = true;
5+
6+
export default function ClientOnlyIsland(
7+
props: { id?: string; label?: string },
8+
) {
9+
const active = useSignal(false);
10+
useEffect(() => {
11+
active.value = true;
12+
}, []);
13+
14+
return (
15+
<div
16+
id={props.id}
17+
class={active.value ? "client-only ready" : "client-only"}
18+
>
19+
<p class="label">{props.label ?? "rendered on client"}</p>
20+
<p class="check">
21+
{typeof document !== "undefined" ? "has-document" : "no-document"}
22+
</p>
23+
</div>
24+
);
25+
}

packages/fresh/tests/islands_test.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { FakeServer } from "../src/test_utils.ts";
3030
import { PARTIAL_SEARCH_PARAM } from "../src/constants.ts";
3131
import { ComputedSignal } from "./fixtures_islands/Computed.tsx";
3232
import { EnvIsland } from "./fixtures_islands/EnvIsland.tsx";
33+
import ClientOnlyIsland from "./fixtures_islands/ClientOnlyIsland.tsx";
3334

3435
Deno.env.set("FRESH_PUBLIC_TEST_FOO", "test-env-value");
3536
Deno.env.set("FRESH_PRIVATE_TEST_FOO", "i-should-not-be-visible");
@@ -847,3 +848,58 @@ Deno.test({
847848
});
848849
},
849850
});
851+
852+
Deno.test({
853+
name: "islands - client-only island renders placeholder in SSR",
854+
fn: async () => {
855+
const app = testApp()
856+
.get("/", (ctx) => {
857+
return ctx.render(
858+
<Doc>
859+
<ClientOnlyIsland id="co" label="hello" />
860+
</Doc>,
861+
);
862+
});
863+
864+
const server = new FakeServer(app.handler());
865+
const res = await server.get("/");
866+
const html = await res.text();
867+
868+
// SSR should contain a placeholder div, not the island's actual content
869+
const doc = parseHtml(html);
870+
expect(doc.querySelector(".client-only")).toBeNull();
871+
expect(doc.querySelector(".label")).toBeNull();
872+
873+
// The marker comment should have the :c flag for client-only
874+
expect(html).toContain("::c-->");
875+
},
876+
});
877+
878+
Deno.test({
879+
name: "islands - client-only island renders on client",
880+
fn: async () => {
881+
const app = testApp()
882+
.get("/", (ctx) => {
883+
return ctx.render(
884+
<Doc>
885+
<ClientOnlyIsland id="co" label="hello" />
886+
</Doc>,
887+
);
888+
});
889+
890+
await withBrowserApp(app, async (page, address) => {
891+
await page.goto(address, { waitUntil: "load" });
892+
await page.locator("#co.ready").wait();
893+
894+
const label = await page.evaluate(() => {
895+
return document.querySelector("#co .label")?.textContent;
896+
});
897+
expect(label).toEqual("hello");
898+
899+
const check = await page.evaluate(() => {
900+
return document.querySelector("#co .check")?.textContent;
901+
});
902+
expect(check).toEqual("has-document");
903+
});
904+
},
905+
});

0 commit comments

Comments
 (0)