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
30 changes: 30 additions & 0 deletions packages/docs/src/pages/api/FunstackStatic.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,36 @@ funstackStatic({

**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.

### clientInit (optional)

**Type:** `string`

Path to a module that runs on the client side **before React hydration**. Use this for client-side instrumentation like Sentry, analytics, or feature flags.

The module is imported for its side effects only - no exports are needed.

```typescript
funstackStatic({
root: "./src/root.tsx",
app: "./src/App.tsx",
clientInit: "./src/client-init.ts",
});
```

Example client init file:

```typescript
// src/client-init.ts
import * as Sentry from "@sentry/browser";

Sentry.init({
dsn: "https://your-sentry-dsn",
environment: import.meta.env.MODE,
});
```

**Note:** Errors in the client init module will propagate normally and prevent the app from rendering.

## Full Example

```typescript
Expand Down
16 changes: 15 additions & 1 deletion packages/static/e2e/fixture/src/Counter.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";

declare global {
interface Window {
__REACT_HYDRATED_TIMESTAMP__?: number;
}
}

export function Counter() {
const [count, setCount] = useState(0);

useEffect(() => {
// Record when React hydration completes (useEffect runs after hydration)
if (!window.__REACT_HYDRATED_TIMESTAMP__) {
window.__REACT_HYDRATED_TIMESTAMP__ = Date.now();
}
}, []);

return (
<button data-testid="counter" onClick={() => setCount((c) => c + 1)}>
Count: {count}
Expand Down
12 changes: 12 additions & 0 deletions packages/static/e2e/fixture/src/client-init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Client initialization - runs before React hydration
// Sets a global marker to verify execution timing

declare global {
interface Window {
__CLIENT_INIT_RAN__: boolean;
__CLIENT_INIT_TIMESTAMP__: number;
}
}

window.__CLIENT_INIT_RAN__ = true;
window.__CLIENT_INIT_TIMESTAMP__ = Date.now();
1 change: 1 addition & 0 deletions packages/static/e2e/fixture/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default defineConfig({
funstackStatic({
root: "./src/root.tsx",
app: "./src/App.tsx",
clientInit: "./src/client-init.ts",
}),
react(),
],
Expand Down
48 changes: 48 additions & 0 deletions packages/static/e2e/tests/client-init.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { expect, test } from "@playwright/test";

test.describe("Client initialization", () => {
test("clientInit module runs before React hydration", async ({ page }) => {
await page.goto("/");

// Wait for React hydration to complete
const counter = page.getByTestId("counter");
await counter.click();
await expect(counter).toHaveText("Count: 1");

// Verify client init ran
const clientInitRan = await page.evaluate(() => window.__CLIENT_INIT_RAN__);
expect(clientInitRan).toBe(true);

// Verify client init ran before React hydration
const timestamps = await page.evaluate(() => ({
clientInit: window.__CLIENT_INIT_TIMESTAMP__,
reactHydrated: window.__REACT_HYDRATED_TIMESTAMP__,
}));

expect(timestamps.clientInit).toBeDefined();
expect(timestamps.reactHydrated).toBeDefined();
expect(timestamps.clientInit).toBeLessThanOrEqual(
timestamps.reactHydrated!,
);
});

test("clientInit globals are available during React render", async ({
page,
}) => {
// Listen for console messages to verify no errors
const errors: string[] = [];
page.on("pageerror", (error) => {
errors.push(error.message);
});

await page.goto("/");

// Verify no errors occurred (client init should be available)
await page.waitForLoadState("networkidle");
expect(errors).toEqual([]);

// Verify the global is set
const clientInitRan = await page.evaluate(() => window.__CLIENT_INIT_RAN__);
expect(clientInitRan).toBe(true);
});
});
3 changes: 3 additions & 0 deletions packages/static/src/client/entry.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Client initialization - runs before React (side effects only)
import "virtual:funstack/client-init";

import {
createFromReadableStream,
createFromFetch,
Expand Down
20 changes: 20 additions & 0 deletions packages/static/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,24 @@ export interface FunstackStaticOptions {
* @default false
*/
ssr?: boolean;
/**
* Path to a module that runs on the client side before React hydration.
* Use this for client-side instrumentation like Sentry, analytics, or feature flags.
* The module is imported for its side effects only (no exports needed).
*/
clientInit?: string;
}

export default function funstackStatic({
root,
app,
publicOutDir = "dist/public",
ssr = false,
clientInit,
}: FunstackStaticOptions): (Plugin | Plugin[])[] {
let resolvedRootEntry: string = "__uninitialized__";
let resolvedAppEntry: string = "__uninitialized__";
let resolvedClientInitEntry: string | undefined;

return [
{
Expand Down Expand Up @@ -72,6 +80,9 @@ export default function funstackStatic({
configResolved(config) {
resolvedRootEntry = path.resolve(config.root, root);
resolvedAppEntry = path.resolve(config.root, app);
if (clientInit) {
resolvedClientInitEntry = path.resolve(config.root, clientInit);
}
},
// Needed for properly bundling @vitejs/plugin-rsc for browser.
// See: https://github.com/vitejs/vite-plugin-react/tree/79bf57cc8b9c77e33970ec2e876bd6d2f1568d5d/packages/plugin-rsc#using-vitejsplugin-rsc-as-a-framework-packages-dependencies
Expand Down Expand Up @@ -100,6 +111,9 @@ export default function funstackStatic({
if (id === "virtual:funstack/config") {
return "\0virtual:funstack/config";
}
if (id === "virtual:funstack/client-init") {
return "\0virtual:funstack/client-init";
}
},
load(id) {
if (id === "\0virtual:funstack/root") {
Expand All @@ -111,6 +125,12 @@ export default function funstackStatic({
if (id === "\0virtual:funstack/config") {
return `export const ssr = ${JSON.stringify(ssr)};`;
}
if (id === "\0virtual:funstack/client-init") {
if (resolvedClientInitEntry) {
return `import "${resolvedClientInitEntry}";`;
}
return "";
}
},
},
{
Expand Down
1 change: 1 addition & 0 deletions packages/static/src/virtual.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ declare module "virtual:funstack/app" {
declare module "virtual:funstack/config" {
export const ssr: boolean;
}
declare module "virtual:funstack/client-init" {}
2 changes: 1 addition & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ packages:
- packages/static/e2e/fixture

catalog:
'@types/node': ^25.1.0
"@types/node": ^25.1.0
react: ^19.2.4
react-dom: ^19.2.4
typescript: ^5.9.3
Expand Down