Skip to content

Commit

Permalink
feat(react-server): support server css and fix FOUC (#54)
Browse files Browse the repository at this point in the history
  • Loading branch information
hi-ogawa authored Apr 29, 2024
1 parent 6c896f7 commit c8679b2
Show file tree
Hide file tree
Showing 12 changed files with 430 additions and 20 deletions.
5 changes: 4 additions & 1 deletion examples/react-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,7 @@ pnpm cf-release
- [x] server action
- [x] basic form action
- [x] `encodeReply/decodeReply/decodeAction/decodeFormState/useActionState`
- [ ] css style
- [x] css
- [x] client
- [x] server
- [ ] code split
96 changes: 89 additions & 7 deletions examples/react-server/e2e/basic.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { type Page, expect, test } from "@playwright/test";
import {
createEditor,
createReloadChecker,
testNoJs,
usePageErrorChecker,
waitForHydration,
} from "./helper";

test("client-component", async ({ page }) => {
usePageErrorChecker(page);
await page.goto("/");
await page.getByText("hydrated: true").click();
await waitForHydration(page);
await page.getByTestId("client-component").getByText("Count: 0").click();
await page
.getByTestId("client-component")
Expand All @@ -12,13 +20,14 @@ test("client-component", async ({ page }) => {
});

test("server-action @js", async ({ page }) => {
usePageErrorChecker(page);
await page.goto("/");
await page.getByText("hydrated: true").click();
await waitForHydration(page);
await testServerAction(page);
});

test("server-action @nojs", async ({ browser }) => {
const page = await browser.newPage({ javaScriptEnabled: false });
testNoJs("server-action @nojs", async ({ page }) => {
usePageErrorChecker(page);
await page.goto("/");
await testServerAction(page);
});
Expand All @@ -38,13 +47,14 @@ async function testServerAction(page: Page) {
}

test("useActionState @js", async ({ page }) => {
usePageErrorChecker(page);
await page.goto("/");
await page.getByText("hydrated: true").click();
await waitForHydration(page);
await testUseActionState(page, { js: true });
});

test("useActionState @nojs", async ({ browser }) => {
const page = await browser.newPage({ javaScriptEnabled: false });
testNoJs("useActionState @nojs", async ({ page }) => {
usePageErrorChecker(page);
await page.goto("/");
await testUseActionState(page, { js: false });
});
Expand All @@ -63,3 +73,75 @@ async function testUseActionState(page: Page, options: { js: boolean }) {
await page.getByPlaceholder("Answer?").press("Enter");
await page.getByText("Correct! (tried 2 times)").click();
}

test("css basic @js", async ({ page }) => {
usePageErrorChecker(page);
await page.goto("/");
await waitForHydration(page);
await testCssBasic(page);
});

testNoJs("css basic @nojs", async ({ page }) => {
usePageErrorChecker(page);
await page.goto("/");
await testCssBasic(page);
});

async function testCssBasic(page: Page) {
await expect(
page.getByTestId("server-action").getByRole("button", { name: "+" }),
).toHaveCSS("background-color", "rgb(220, 220, 255)");
await expect(
page.getByTestId("client-component").getByRole("button", { name: "+" }),
).toHaveCSS("background-color", "rgb(255, 220, 220)");
}

test("css hmr server @dev", async ({ page }) => {
usePageErrorChecker(page);
await page.goto("/");
await waitForHydration(page);

await using editor = await createEditor("src/routes/_server.css");
await using _ = await createReloadChecker(page);

await expect(
page.getByTestId("server-action").getByRole("button", { name: "+" }),
).toHaveCSS("background-color", "rgb(220, 220, 255)");
await editor.edit((data) =>
data.replace("rgb(220, 220, 255)", "rgb(199, 199, 255)"),
);
await expect(
page.getByTestId("server-action").getByRole("button", { name: "+" }),
).toHaveCSS("background-color", "rgb(199, 199, 255)");
await editor.edit((data) =>
data.replace("rgb(199, 199, 255)", "rgb(123, 123, 255)"),
);
await expect(
page.getByTestId("server-action").getByRole("button", { name: "+" }),
).toHaveCSS("background-color", "rgb(123, 123, 255)");
});

test("css hmr client @dev", async ({ page }) => {
usePageErrorChecker(page);
await page.goto("/");
await waitForHydration(page);

await using editor = await createEditor("src/routes/_client.css");
await using _ = await createReloadChecker(page);

await expect(
page.getByTestId("client-component").getByRole("button", { name: "+" }),
).toHaveCSS("background-color", "rgb(255, 220, 220)");
await editor.edit((data) =>
data.replace("rgb(255, 220, 220)", "rgb(255, 199, 199)"),
);
await expect(
page.getByTestId("client-component").getByRole("button", { name: "+" }),
).toHaveCSS("background-color", "rgb(255, 199, 199)");
await editor.edit((data) =>
data.replace("rgb(255, 199, 199)", "rgb(255, 123, 123)"),
);
await expect(
page.getByTestId("client-component").getByRole("button", { name: "+" }),
).toHaveCSS("background-color", "rgb(255, 123, 123)");
});
63 changes: 63 additions & 0 deletions examples/react-server/e2e/helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import fs from "fs";
import { type Page, expect, test } from "@playwright/test";

export const testNoJs = test.extend({
javaScriptEnabled: ({}, use) => use(false),
});

const pageErrorsMap = new WeakMap<Page, Error[]>();

test.afterEach(({ page }) => {
const errors = pageErrorsMap.get(page);
if (errors) {
expect(errors).toEqual([]);
}
});

export function usePageErrorChecker(page: Page) {
const pageErrors: Error[] = [];
pageErrorsMap.set(page, pageErrors);
page.on("pageerror", (e) => pageErrors.push(e));
}

export async function createEditor(filepath: string) {
let init = await fs.promises.readFile(filepath, "utf-8");
let data = init;
return {
async edit(editFn: (data: string) => Promise<string> | string) {
data = await editFn(data);
await fs.promises.writeFile(filepath, data);
},
async [Symbol.asyncDispose]() {
await fs.promises.writeFile(filepath, init);
},
};
}

export async function createReloadChecker(page: Page) {
async function reset() {
await page.evaluate(() => {
const el = document.createElement("meta");
el.setAttribute("name", "x-reload-check");
document.head.append(el);
});
}

async function check() {
await expect(page.locator(`meta[name="x-reload-check"]`)).toBeAttached({
timeout: 1,
});
}

await reset();

return {
check,
reset,
[Symbol.asyncDispose]: check,
};
}

export async function waitForHydration(page: Page) {
await page.getByText("hydrated: true").click();
}
Binary file added examples/react-server/public/favicon.ico
Binary file not shown.
11 changes: 9 additions & 2 deletions examples/react-server/src/__snapshots__/basic.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ exports[`basic 1`] = `
0
</div>
<button
class="server-btn"
name="value"
value="-1"
>
-1
</button>
<button
class="server-btn"
name="value"
value="1"
>
Expand All @@ -58,6 +60,7 @@ exports[`basic 1`] = `
1 + 1 =
</div>
<input
class="client-input"
name="answer"
placeholder="Answer?"
required=""
Expand All @@ -84,10 +87,14 @@ exports[`basic 1`] = `
Count:
0
</div>
<button>
<button
class="client-btn"
>
-1
</button>
<button>
<button
class="client-btn"
>
+1
</button>
</div>
Expand Down
8 changes: 6 additions & 2 deletions examples/react-server/src/features/bootstrap/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from "node:fs";
import { tinyassert, typedBoolean } from "@hiogawa/utils";
import type { Manifest, PluginOption, ViteDevServer } from "vite";
import { $__global } from "../../global";
import { VIRTUAL_COPY_SERVER_CSS } from "../style/plugin";
import { createVirtualPlugin } from "../utils/plugin";

export const ENTRY_CLIENT_BOOTSTRAP = "virtual:entry-client-bootstrap";
Expand All @@ -17,13 +18,15 @@ export function vitePluginEntryBootstrap(): PluginOption {
if ($__global.server) {
// wrapper entry to ensure client entry runs after vite/react inititialization
return `
import "${VIRTUAL_COPY_SERVER_CSS}";
for (let i = 0; !window.__vite_plugin_react_preamble_installed__; i++) {
await new Promise(resolve => setTimeout(resolve, 10 * (2 ** i)));
}
import("/src/entry-client");
`;
} else {
return `
import "${VIRTUAL_COPY_SERVER_CSS}";
import "/src/entry-client";
`;
}
Expand All @@ -43,9 +46,10 @@ export function vitePluginEntryBootstrap(): PluginOption {
"utf-8",
),
);
// TODO: split css per-route?
const css = Object.values(manifest).flatMap((v) => v.css ?? []);
const entry = manifest[ENTRY_CLIENT_BOOTSTRAP];
tinyassert(entry);
const css = entry.css ?? [];
// preload only direct dynamic import for client references map
const js =
entry.dynamicImports
Expand All @@ -56,8 +60,8 @@ export function vitePluginEntryBootstrap(): PluginOption {
...js.map((href) => `<link rel="modulepreload" href="/${href}" />`),
].join("\n");
ssrAssets = {
bootstrapModules: [`/${entry.file}`],
head,
bootstrapModules: [`/${entry.file}`],
};
}
return `export default ${JSON.stringify(ssrAssets)}`;
Expand Down
Loading

0 comments on commit c8679b2

Please sign in to comment.