Skip to content
Open
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
12 changes: 12 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 27 additions & 5 deletions docs/latest/concepts/islands.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,34 @@ import OtherIsland from "../islands/other-island.tsx";
</div>;
```

## Rendering islands on client only
## Client-only islands

When using client-only APIs, like `EventSource` or `navigator.getUserMedia`, the
component would error during server-side rendering. Use the `IS_BROWSER`
constant from `fresh/runtime` to guard browser-only code. It is `false` on the
server and `true` in the browser:
Some libraries (e.g. Monaco Editor, certain charting libraries) reference
browser globals like `document` at the module top level, which crashes during
server-side rendering. You can mark an island as **client-only** by adding
`export const clientOnly = true`. Fresh will skip executing the component on the
server and render an empty placeholder instead. On the client, the component
renders normally.

```tsx islands/my-editor.tsx
export const clientOnly = true;

export default function MyEditor() {
// Safe to use document, window, etc. — this code never runs on the server.
return <div>{/* ... */}</div>;
}
```

> [warn]: Client-only islands produce no meaningful HTML on the server. This
> means search engines will not see their content, and users will see an empty
> placeholder until JavaScript loads. Use this only when the component truly
> cannot run on the server.

### Using `IS_BROWSER` for a custom fallback

If the module itself can be loaded on the server but you only need to guard
certain API calls, use the `IS_BROWSER` constant from `fresh/runtime` instead.
This lets you return a meaningful SSR fallback:

```tsx islands/my-island.tsx
import { IS_BROWSER } from "fresh/runtime";
Expand Down
3 changes: 3 additions & 0 deletions packages/fresh/src/build_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ export class IslandPreparer {
chunkName: string,
modName: string,
css: string[],
clientOnly?: boolean,
) {
const isClientOnly = clientOnly ?? mod.clientOnly === true;
for (const [name, value] of Object.entries(mod)) {
if (typeof value !== "function") continue;

Expand All @@ -117,6 +119,7 @@ export class IslandPreparer {
fn,
name: uniqueName,
css,
clientOnly: isClientOnly,
});
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/fresh/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export interface Island {
exportName: string;
fn: ComponentType;
css: string[];
clientOnly: boolean;
}

export type ServerIslandRegistry = Map<ComponentType, Island>;
Expand Down
4 changes: 4 additions & 0 deletions packages/fresh/src/dev/dev_build_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface IslandModChunk {
server: string;
browser: string | null;
css: string[];
clientOnly?: boolean;
}

export type FsRouteFileNoMod<State> = Omit<FsRouteFile<State>, "mod"> & {
Expand Down Expand Up @@ -493,6 +494,9 @@ export async function generateSnapshotServer(
const browser = JSON.stringify(item.browser);
const name = JSON.stringify(item.name);
const css = JSON.stringify(item.css);
if (item.clientOnly) {
return `islandPreparer.prepare(islands, ${item.name}, ${browser}, ${name}, ${css}, true);`;
}
return `islandPreparer.prepare(islands, ${item.name}, ${browser}, ${name}, ${css});`;
}).join("\n");

Expand Down
3 changes: 3 additions & 0 deletions packages/fresh/src/runtime/client/reviver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface IslandReq {
name: string;
propsIdx: number;
key: string | null;
clientOnly: boolean;
start: Comment | Text;
end: Comment | Text | null;
}
Expand Down Expand Up @@ -269,11 +270,13 @@ function _walkInner(
const name = parts[2];
const propsIdx = parts[3];
const key = parts[4];
const clientOnly = parts[5] === "c";
const found: IslandReq = {
kind: RootKind.Island,
name,
propsIdx: Number(propsIdx),
key: key === "" ? null : key,
clientOnly,
start: node as Comment,
end: null,
};
Expand Down
18 changes: 11 additions & 7 deletions packages/fresh/src/runtime/server/preact_hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,15 +300,19 @@ options[OptionsType.DIFF] = (vnode) => {
}
const propsIdx = islandProps.push({ slots: [], props }) - 1;

const child = h(originalType, props);
const key = normalizeKey(vnode.key);
const markerData = island!.clientOnly
? `${island!.name}:${propsIdx}:${key}:c`
: `${island!.name}:${propsIdx}:${key}`;

// For client-only islands, render an empty placeholder
// instead of executing the component on the server.
const child = island!.clientOnly
? h("div", null)
: h(originalType, props);
PATCHED.add(child);

const key = normalizeKey(vnode.key);
return wrapWithMarker(
child,
"island",
`${island!.name}:${propsIdx}:${key}`,
);
return wrapWithMarker(child, "island", markerData);
};
}
} else if (typeof vnode.type === "string") {
Expand Down
25 changes: 25 additions & 0 deletions packages/fresh/tests/fixtures_islands/ClientOnlyIsland.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";

export const clientOnly = true;

export default function ClientOnlyIsland(
props: { id?: string; label?: string },
) {
const active = useSignal(false);
useEffect(() => {
active.value = true;
}, []);

return (
<div
id={props.id}
class={active.value ? "client-only ready" : "client-only"}
>
<p class="label">{props.label ?? "rendered on client"}</p>
<p class="check">
{typeof document !== "undefined" ? "has-document" : "no-document"}
</p>
</div>
);
}
56 changes: 56 additions & 0 deletions packages/fresh/tests/islands_test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { FakeServer } from "../src/test_utils.ts";
import { PARTIAL_SEARCH_PARAM } from "../src/constants.ts";
import { ComputedSignal } from "./fixtures_islands/Computed.tsx";
import { EnvIsland } from "./fixtures_islands/EnvIsland.tsx";
import ClientOnlyIsland from "./fixtures_islands/ClientOnlyIsland.tsx";

Deno.env.set("FRESH_PUBLIC_TEST_FOO", "test-env-value");
Deno.env.set("FRESH_PRIVATE_TEST_FOO", "i-should-not-be-visible");
Expand Down Expand Up @@ -847,3 +848,58 @@ Deno.test({
});
},
});

Deno.test({
name: "islands - client-only island renders placeholder in SSR",
fn: async () => {
const app = testApp()
.get("/", (ctx) => {
return ctx.render(
<Doc>
<ClientOnlyIsland id="co" label="hello" />
</Doc>,
);
});

const server = new FakeServer(app.handler());
const res = await server.get("/");
const html = await res.text();

// SSR should contain a placeholder div, not the island's actual content
const doc = parseHtml(html);
expect(doc.querySelector(".client-only")).toBeNull();
expect(doc.querySelector(".label")).toBeNull();

// The marker comment should have the :c flag for client-only
expect(html).toContain("::c-->");
},
});

Deno.test({
name: "islands - client-only island renders on client",
fn: async () => {
const app = testApp()
.get("/", (ctx) => {
return ctx.render(
<Doc>
<ClientOnlyIsland id="co" label="hello" />
</Doc>,
);
});

await withBrowserApp(app, async (page, address) => {
await page.goto(address, { waitUntil: "load" });
await page.locator("#co.ready").wait();

const label = await page.evaluate(() => {
return document.querySelector("#co .label")?.textContent;
});
expect(label).toEqual("hello");

const check = await page.evaluate(() => {
return document.querySelector("#co .check")?.textContent;
});
expect(check).toEqual("has-document");
});
},
});
1 change: 0 additions & 1 deletion packages/plugin-vite/demo/fixtures/commonjs_mod.cjs

This file was deleted.

1 change: 1 addition & 0 deletions packages/plugin-vite/demo/fixtures/commonjs_mod.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const value = "ok";
8 changes: 0 additions & 8 deletions packages/plugin-vite/demo/fixtures/maxmind.cjs

This file was deleted.

2 changes: 2 additions & 0 deletions packages/plugin-vite/demo/fixtures/maxmind.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import assert from "node:assert";
assert(true);
2 changes: 1 addition & 1 deletion packages/plugin-vite/demo/routes/tests/commonjs.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { value } from "../../fixtures/commonjs_mod.cjs";
import { value } from "../../fixtures/commonjs_mod.js";

export default function Page() {
return <h1>{value}</h1>;
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-vite/demo/routes/tests/maxmind.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as maxmind from "../../fixtures/maxmind.cjs";
import * as maxmind from "../../fixtures/maxmind.js";

export default function Page() {
// deno-lint-ignore no-console
Expand Down
Loading