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
21 changes: 21 additions & 0 deletions packages/docs/src/pages/api/FunstackStatic.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,27 @@ funstackStatic({
});
```

### ssr (optional)

**Type:** `boolean`
**Default:** `false`

Enable server-side rendering of the App component.

When `false` (default), only the Root shell is rendered to HTML at build time. The App component's RSC payload is fetched separately and rendered client-side using `createRoot`. This results in faster initial HTML delivery but requires JavaScript to display the App content.

When `true`, both the Root and App components are fully rendered to HTML. The client hydrates the existing HTML using `hydrateRoot`, which can improve perceived performance and SEO since the full content is visible before JavaScript loads.

```typescript
funstackStatic({
root: "./src/root.tsx",
app: "./src/App.tsx",
ssr: true, // Enable full SSR
});
```

**Note:** In both modes, React Server Components are used - the `ssr` option only controls whether the App's HTML is pre-rendered or rendered client-side.

## Full Example

```typescript
Expand Down
18 changes: 18 additions & 0 deletions packages/docs/src/pages/learn/HowItWorks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,24 @@ The Root component is special in two ways:

The Root entrypoint is a FUNSTACK Static counterpart to the `index.html` file in traditional SPAs. It allows you to still leverage some of the benefits of server components for defining the HTML shell of your application.

## Server-Side Rendering

By default, FUNSTACK Static only renders the Root shell to HTML. The App component is rendered client-side from its RSC payload. This behavior keeps the initial HTML small and fast to deliver.

If you want the App component to also be rendered to HTML (for better SEO or perceived performance), you can enable the `ssr` option:

```typescript
funstackStatic({
root: "./src/root.tsx",
app: "./src/App.tsx",
ssr: true,
});
```

With `ssr: true`, the full page content is visible in the HTML before JavaScript loads. The client then hydrates the existing HTML instead of rendering from scratch.

Note that in both modes, React Server Components are still used - the `ssr` option only controls whether the App's HTML is pre-rendered at build time or rendered client-side.

## See Also

- [React Server Components](/funstack-static/learn/rsc) - Understanding RSC in depth
Expand Down
54 changes: 37 additions & 17 deletions packages/static/src/client/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { devMainRscPath } from "../rsc/request";
import { appClientManifestVar, type AppClientManifest } from "./globals";
import { withBasePath } from "../util/basePath";

import { ssr as ssrEnabled } from "virtual:funstack/config";

async function devMain() {
let setPayload: (v: RscPayload) => void;

Expand All @@ -33,7 +35,7 @@ async function devMain() {
);
setPayload(payload);
}
// hydration

const browserRoot = (
<React.StrictMode>
<GlobalErrorBoundary>
Expand All @@ -48,8 +50,11 @@ async function devMain() {
) {
// This happens when SSR failed on server
createRoot(document).render(browserRoot);
} else {
} else if (ssrEnabled) {
hydrateRoot(document, browserRoot);
} else {
// SSR off: Root shell is static HTML, mount App client-side
createRoot(document).render(browserRoot);
}

// implement server HMR by triggering re-fetch/render of RSC upon server code change
Expand All @@ -70,24 +75,39 @@ async function prodMain() {
function BrowserRoot() {
return payload.root;
}
const browserRoot = <BrowserRoot />;
const appRootId: string = manifest.marker;

const appMarker = document.getElementById(appRootId);
if (!appMarker) {
throw new Error(
`Failed to find app root element by id "${appRootId}". This is likely a bug.`,
);
}
const appRoot = appMarker.parentElement;
if (!appRoot) {
throw new Error(
`App root element has no parent element. This is likely a bug.`,
if (ssrEnabled) {
// SSR on: full tree was SSR'd, hydrate from RSC payload
const browserRoot = (
<React.StrictMode>
<GlobalErrorBoundary>
<BrowserRoot />
</GlobalErrorBoundary>
</React.StrictMode>
);
}
appMarker.remove();

createRoot(appRoot).render(browserRoot);
hydrateRoot(document, browserRoot);
} else {
// SSR off: Root shell only, mount App client-side
const browserRoot = <BrowserRoot />;
const appRootId = manifest.marker!;

const appMarker = document.getElementById(appRootId);
if (!appMarker) {
throw new Error(
`Failed to find app root element by id "${appRootId}". This is likely a bug.`,
);
}
const appRoot = appMarker.parentElement;
if (!appRoot) {
throw new Error(
`App root element has no parent element. This is likely a bug.`,
);
}
appMarker.remove();

createRoot(appRoot).render(browserRoot);
}
}

if (import.meta.env.DEV) {
Expand Down
2 changes: 1 addition & 1 deletion packages/static/src/client/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ const globalPrefix = "FUNSTACK_STATIC_";
export const appClientManifestVar = `${globalPrefix}appClientManifest`;

export interface AppClientManifest {
marker: string;
marker?: string;
stream: string;
}
15 changes: 15 additions & 0 deletions packages/static/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,21 @@ export interface FunstackStaticOptions {
* @default dist/public
*/
publicOutDir?: string;
/**
* Enable server-side rendering of the App component.
* When false, only the Root shell is SSR'd and the App renders client-side.
* When true, both Root and App are SSR'd and the client hydrates.
*
* @default false
*/
ssr?: boolean;
}

export default function funstackStatic({
root,
app,
publicOutDir = "dist/public",
ssr = false,
}: FunstackStaticOptions): (Plugin | Plugin[])[] {
let resolvedRootEntry: string = "__uninitialized__";
let resolvedAppEntry: string = "__uninitialized__";
Expand Down Expand Up @@ -88,6 +97,9 @@ export default function funstackStatic({
if (id === "virtual:funstack/app") {
return "\0virtual:funstack/app";
}
if (id === "virtual:funstack/config") {
return "\0virtual:funstack/config";
}
},
load(id) {
if (id === "\0virtual:funstack/root") {
Expand All @@ -96,6 +108,9 @@ export default function funstackStatic({
if (id === "\0virtual:funstack/app") {
return `export { default } from "${resolvedAppEntry}";`;
}
if (id === "\0virtual:funstack/config") {
return `export const ssr = ${JSON.stringify(ssr)};`;
}
},
},
{
Expand Down
134 changes: 94 additions & 40 deletions packages/static/src/rsc/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ export type RscPayload = {
root: React.ReactNode;
};

async function devMainRSCStream() {
import { ssr as ssrEnabled } from "virtual:funstack/config";

async function loadEntries() {
const Root = (await import("virtual:funstack/root")).default;
const App = (await import("virtual:funstack/app")).default;

Expand All @@ -26,40 +28,65 @@ async function devMainRSCStream() {
"Failed to load RSC app entry module. Check your entry file to ensure it has a default export.",
);
}

const rootRscStream = renderToReadableStream<RscPayload>({
root: (
<Root>
<App />
</Root>
),
});
return rootRscStream;
return { Root, App };
}

/**
* Entrypoint to serve HTML response in dev environment
*/
export async function serveHTML(): Promise<Response> {
const marker = generateAppMarker();

const rootRscStream = await devMainRSCStream();
const { Root, App } = await loadEntries();

const ssrEntryModule = await import.meta.viteRsc.loadModule<
typeof import("../ssr/entry")
>("ssr");
const ssrResult = await ssrEntryModule.renderHTML(rootRscStream, {
appEntryMarker: marker,
build: false,
});

// respond html
return new Response(ssrResult.stream, {
status: ssrResult.status,
headers: {
"Content-type": "text/html",
},
});
if (ssrEnabled) {
// SSR on: single RSC stream with full tree
const rootRscStream = renderToReadableStream<RscPayload>({
root: (
<Root>
<App />
</Root>
),
});
const ssrResult = await ssrEntryModule.renderHTML(rootRscStream, {
appEntryMarker: marker,
build: false,
ssr: true,
});
return new Response(ssrResult.stream, {
status: ssrResult.status,
headers: { "Content-type": "text/html" },
});
} else {
// SSR off: shell RSC for SSR, full RSC for client
const shellRscStream = renderToReadableStream<RscPayload>({
root: (
<Root>
<span id={marker} />
</Root>
),
});
const clientRscStream = renderToReadableStream<RscPayload>({
root: (
<Root>
<App />
</Root>
),
});
const ssrResult = await ssrEntryModule.renderHTML(shellRscStream, {
appEntryMarker: marker,
build: false,
ssr: false,
clientRscStream,
});
return new Response(ssrResult.stream, {
status: ssrResult.status,
headers: { "Content-type": "text/html" },
});
}
}

class ServeRSCError extends Error {
Expand All @@ -82,8 +109,15 @@ export async function serveRSC(request: Request): Promise<Response> {
const url = new URL(request.url);
const pathname = stripBasePath(url.pathname);
if (pathname === devMainRscPath) {
// root RSC stream is requested
const rootRscStream = await devMainRSCStream();
// root RSC stream is requested (HMR re-fetch always sends full tree)
const { Root, App } = await loadEntries();
const rootRscStream = renderToReadableStream<RscPayload>({
root: (
<Root>
<App />
</Root>
),
});
return new Response(rootRscStream, {
status: 200,
headers: {
Expand Down Expand Up @@ -133,21 +167,40 @@ export async function serveRSC(request: Request): Promise<Response> {
*/
export async function build() {
const marker = generateAppMarker();

const Root = (await import("virtual:funstack/root")).default;
const App = (await import("virtual:funstack/app")).default;

const rootRscStream = renderToReadableStream<RscPayload>({
root: (
<Root>
<span id={marker} />
</Root>
),
});

const appRscStream = renderToReadableStream<RscPayload>({
root: <App />,
});
const { Root, App } = await loadEntries();

let rootRscStream: ReadableStream<Uint8Array>;
let appRscStream: ReadableStream<Uint8Array>;

if (ssrEnabled) {
// SSR on: both streams have full tree
rootRscStream = renderToReadableStream<RscPayload>({
root: (
<Root>
<App />
</Root>
),
});
appRscStream = renderToReadableStream<RscPayload>({
root: (
<Root>
<App />
</Root>
),
});
} else {
// SSR off: root stream has shell, app stream has App only
rootRscStream = renderToReadableStream<RscPayload>({
root: (
<Root>
<span id={marker} />
</Root>
),
});
appRscStream = renderToReadableStream<RscPayload>({
root: <App />,
});
}

const ssrEntryModule = await import.meta.viteRsc.loadModule<
typeof import("../ssr/entry")
Expand All @@ -156,6 +209,7 @@ export async function build() {
const ssrResult = await ssrEntryModule.renderHTML(rootRscStream, {
appEntryMarker: marker,
build: true,
ssr: ssrEnabled,
});

return {
Expand Down
Loading