From 2bc7aa89252bcaf5c95afd3e5d28f00663987b30 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 18 Oct 2026 17:44:46 +0800 Subject: [PATCH] fix(md): give islands the page frontmatter when they hydrate Since #475, an island on a Markdown page saw the page frontmatter through `useFrontmatter()` during server rendering but `{}` in the browser, so frameworks reported a hydration mismatch and re-rendered it. A page that renders islands now includes its frontmatter once as a JSON script. `initIslands` reads it and passes it to each adapter's island hydration, which provides it the same way the server renderer does (React and Solid context, Svelte hydrate context, Vue provide). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/pages-routing/markdown.md | 4 +- packages/react/src/plugin-islands.ts | 17 +++- .../integration/md-layout-frontmatter.test.ts | 53 ++++++++++++ packages/solid/src/plugin-islands.ts | 25 +++++- .../integration/md-layout-frontmatter.test.ts | 51 ++++++++++++ packages/svelte/src/plugin-islands.ts | 13 ++- .../integration/md-layout-frontmatter.test.ts | 59 +++++++++++++ packages/void/src/pages/client.ts | 16 +++- packages/void/src/pages/serialize.ts | 15 ++++ packages/void/test/unit/deferred-xss.test.ts | 18 +++- packages/vue/src/plugin-islands.ts | 11 ++- .../integration/md-island-frontmatter.test.ts | 62 ++++++++++++++ test/island-page-browser.ts | 83 +++++++++++++++++++ 13 files changed, 412 insertions(+), 15 deletions(-) create mode 100644 packages/vue/test/integration/md-island-frontmatter.test.ts create mode 100644 test/island-page-browser.ts diff --git a/docs/guide/pages-routing/markdown.md b/docs/guide/pages-routing/markdown.md index 2d4c4b593..52718ae90 100644 --- a/docs/guide/pages-routing/markdown.md +++ b/docs/guide/pages-routing/markdown.md @@ -137,7 +137,7 @@ Welcome to Void. Here's an interactive demo: ``` - **`'; + type IslandRenderer = { renderIslandPage( page: { component: string; props: Record }, @@ -49,8 +54,56 @@ export default function Layout({ children }) { ); expect(html).toContain('

Hello from frontmatter

'); expect(html).toContain('Body'); + // No island on the page, so the frontmatter stays on the server. + expect(html).not.toContain('__void_frontmatter'); } finally { await server.close(); rmSync(root, { recursive: true, force: true }); } }); + +it('an island reads the same frontmatter in the browser as on the server', async () => { + const root = mkdtempSync(join(import.meta.dirname, '__tmp_md_island_frontmatter_')); + mkdirSync(join(root, 'pages')); + mkdirSync(join(root, 'components')); + writeFileSync( + join(root, 'pages', 'layout.island.tsx'), + `import Title from "../components/Title" with { island: "load" }; +export default function Layout({ children }) { + return
{children}</div>; +}`, + ); + writeFileSync( + join(root, 'components', 'Title.tsx'), + `import { createElement } from "react"; +import { useFrontmatter } from "@void/md"; +export default function Title() { + return createElement("p", { id: "island-fm" }, String(useFrontmatter().title)); +}`, + ); + writeFileSync( + join(root, 'pages', 'index.md'), + '---\ntitle: Hello from frontmatter\n---\n\n# Body\n', + ); + + let visit: IslandPageVisit | undefined; + try { + visit = await openIslandPage(root, { + alias: { '@void/react': join(import.meta.dirname, '../../src/runtime/index.ts') }, + plugins: [ + ...voidMarkdown(), + { name: 'void-react:pages' }, + ...islandsPlugin(join(root, 'pages')), + ], + }); + expect(visit.html).toContain(ISLAND_FRONTMATTER_SCRIPT); + await waitForIslandsHydrated(visit.page); + expect(await visit.page.textContent('#island-fm')).toBe('Hello from frontmatter'); + // A server/browser mismatch makes React log a hydration error and re-render the island. + expect(visit.diagnostics().toLowerCase()).not.toContain('hydrat'); + expect(visit.diagnostics()).not.toContain('pageerror'); + } finally { + await visit?.close(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/solid/src/plugin-islands.ts b/packages/solid/src/plugin-islands.ts index 3b6e97ce8..4147f4e41 100644 --- a/packages/solid/src/plugin-islands.ts +++ b/packages/solid/src/plugin-islands.ts @@ -258,6 +258,7 @@ import { renderToStringAsync, generateHydrationScript } from "solid-js/web"; import { createComponent } from "solid-js"; import { SharedContext, ErrorsContext, ParamsContext, FrontmatterContext } from "@void/solid"; import { renderHeadToString, renderHtmlAttrs, renderBodyAttrs } from "void/pages-head"; +import { islandFrontmatterScript } from "void/pages-server"; ${generateComponentManifest(scan, pagesDir)} ${mdScriptIdsCode} @@ -311,6 +312,11 @@ export async function renderIslandPage(pageObj, assetTags) { const html = await renderToStringAsync(() => createComponent(ServerApp, {})); + // Islands hydrate as separate roots. Send the page frontmatter so their + // useFrontmatter() returns the same value in the browser. + const __frontmatterScript = __pagesNeedingClientJS.has(pageObj.component) && html.includes("data-island=") + ? islandFrontmatterScript(__pageModule.frontmatter) + : ""; const __mdScriptAttr = __mdClientScriptIds.has(pageObj.component) ? \` data-md-script="\${escapeAttr(pageObj.component)}"\` : ""; const headHtml = pageObj.head ? renderHeadToString(pageObj.head) : ""; const htmlAttrs = pageObj.head ? renderHtmlAttrs(pageObj.head) : ""; @@ -321,7 +327,7 @@ export async function renderIslandPage(pageObj, assetTags) { <head>\${headHtml}\${assetTags.css}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.preloads : ""}\${__pagesNeedingClientJS.has(pageObj.component) ? generateHydrationScript() : ""}</head> <body\${bodyAttrs}> <div id="app"\${__mdScriptAttr}>\${html}</div> - \${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.body : ""} + \${__frontmatterScript}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.body : ""} </body> </html>\`; } @@ -367,12 +373,25 @@ function generateIslandClientEntry( // providers and layouts). render() replaces the server HTML with a fresh // client render — the brief re-render is acceptable for interactive islands. lines.push(` -async function mountIsland(el, mod, props) { +async function mountIsland(el, mod, props, frontmatter) { const { render } = await import("solid-js/web"); const { createComponent } = await import("solid-js"); const Component = mod.default || mod; + const { FrontmatterContext } = + Object.keys(frontmatter).length > 0 ? await import("@void/solid") : {}; el.textContent = ""; - render(() => createComponent(Component, props), el); + render( + () => + FrontmatterContext + ? createComponent(FrontmatterContext.Provider, { + value: frontmatter, + get children() { + return createComponent(Component, props); + }, + }) + : createComponent(Component, props), + el, + ); el.setAttribute("data-hydrated", "true"); } diff --git a/packages/solid/test/integration/md-layout-frontmatter.test.ts b/packages/solid/test/integration/md-layout-frontmatter.test.ts index 3e8c80a23..015f089f3 100644 --- a/packages/solid/test/integration/md-layout-frontmatter.test.ts +++ b/packages/solid/test/integration/md-layout-frontmatter.test.ts @@ -6,9 +6,14 @@ import { join } from 'node:path'; import { createServer } from 'vite'; import solid from 'vite-plugin-solid'; import { expect, it } from 'vite-plus/test'; +import { waitForIslandsHydrated } from '../../../../test/browser-helpers.ts'; +import { openIslandPage, type IslandPageVisit } from '../../../../test/island-page-browser.ts'; import { voidMarkdown } from '../../../md/src/plugin.ts'; import { islandsPlugin } from '../../src/plugin-islands.ts'; +const ISLAND_FRONTMATTER_SCRIPT = + '<script type="application/json" id="__void_frontmatter">{"title":"Hello from frontmatter"}</script>'; + type IslandRenderer = { renderIslandPage( page: { component: string; props: Record<string, unknown> }, @@ -55,8 +60,54 @@ export default function Layout(props) { ); expect(html).toContain('<p id="fm">Hello from frontmatter</p>'); expect(html).toContain('Body</h1>'); + // No island on the page, so the frontmatter stays on the server. + expect(html).not.toContain('__void_frontmatter'); } finally { await server.close(); rmSync(root, { recursive: true, force: true }); } }); + +it('an island reads the same frontmatter in the browser as on the server', async () => { + const root = mkdtempSync(join(import.meta.dirname, '__tmp_md_island_frontmatter_')); + mkdirSync(join(root, 'pages')); + mkdirSync(join(root, 'components')); + writeFileSync( + join(root, 'pages/layout.island.tsx'), + `import Title from "../components/Title" with { island: "load" }; +export default function Layout(props) { + return <div><Title />{props.children}</div>; +}`, + ); + writeFileSync( + join(root, 'components/Title.tsx'), + `import { useFrontmatter } from "@void/md"; +export default function Title() { + return <p id="island-fm">{String(useFrontmatter().title)}</p>; +}`, + ); + writeFileSync( + join(root, 'pages', 'index.md'), + '---\ntitle: Hello from frontmatter\n---\n\n# Body\n', + ); + + let visit: IslandPageVisit | undefined; + try { + visit = await openIslandPage(root, { + alias: { '@void/solid': join(import.meta.dirname, '../../src/runtime/index.ts') }, + plugins: [ + ...voidMarkdown(), + solid({ ssr: true }), + { name: 'void-solid:pages' }, + ...islandsPlugin(join(root, 'pages')), + ], + }); + expect(visit.html).toContain(ISLAND_FRONTMATTER_SCRIPT); + await waitForIslandsHydrated(visit.page); + expect(await visit.page.textContent('#island-fm')).toBe('Hello from frontmatter'); + expect(visit.diagnostics()).not.toContain('pageerror'); + } finally { + await visit?.close(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/svelte/src/plugin-islands.ts b/packages/svelte/src/plugin-islands.ts index a2da2d683..215609919 100644 --- a/packages/svelte/src/plugin-islands.ts +++ b/packages/svelte/src/plugin-islands.ts @@ -341,6 +341,7 @@ function generateIslandRenderer( import { render } from "svelte/server"; import App from "@void/svelte/App.svelte"; import { renderHeadToString, renderHtmlAttrs, renderBodyAttrs } from "void/pages-head"; +import { islandFrontmatterScript } from "void/pages-server"; ${generateComponentManifest(scan, pagesDir)} ${mdScriptIdsCode} @@ -372,6 +373,11 @@ export async function renderIslandPage(pageObj, assetTags) { }, }); + // Islands hydrate as separate roots. Send the page frontmatter so their + // useFrontmatter() returns the same value in the browser. + const __frontmatterScript = __pagesNeedingClientJS.has(pageObj.component) && html.includes("data-island=") + ? islandFrontmatterScript(__pageModule.frontmatter) + : ""; const __mdScriptAttr = __mdClientScriptIds.has(pageObj.component) ? \` data-md-script="\${escapeAttr(pageObj.component)}"\` : ""; const headHtml = pageObj.head ? renderHeadToString(pageObj.head) : ""; const htmlAttrs = pageObj.head ? renderHtmlAttrs(pageObj.head) : ""; @@ -382,7 +388,7 @@ export async function renderIslandPage(pageObj, assetTags) { <head>\${headHtml}\${head}\${assetTags.css}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.preloads : ""}</head> <body\${bodyAttrs}> <div id="app"\${__mdScriptAttr}>\${html}</div> - \${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.body : ""} + \${__frontmatterScript}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.body : ""} </body> </html>\`; } @@ -430,10 +436,11 @@ function generateIslandClientEntry( lines.push('};'); lines.push(` -async function hydrateIsland(el, mod, props) { +async function hydrateIsland(el, mod, props, frontmatter) { const { hydrate } = await import("svelte"); const Component = mod.default || mod; - hydrate(Component, { target: el, props }); + const context = new Map([["__void_frontmatter", frontmatter]]); + hydrate(Component, { target: el, props, context }); el.setAttribute("data-hydrated", "true"); } diff --git a/packages/svelte/test/integration/md-layout-frontmatter.test.ts b/packages/svelte/test/integration/md-layout-frontmatter.test.ts index 14e16ee05..0183658da 100644 --- a/packages/svelte/test/integration/md-layout-frontmatter.test.ts +++ b/packages/svelte/test/integration/md-layout-frontmatter.test.ts @@ -6,9 +6,14 @@ import { join } from 'node:path'; import { svelte } from '@sveltejs/vite-plugin-svelte'; import { createServer } from 'vite'; import { expect, it } from 'vite-plus/test'; +import { waitForIslandsHydrated } from '../../../../test/browser-helpers.ts'; +import { openIslandPage, type IslandPageVisit } from '../../../../test/island-page-browser.ts'; import { voidMarkdown } from '../../../md/src/plugin.ts'; import { islandsPlugin } from '../../src/plugin-islands.ts'; +const ISLAND_FRONTMATTER_SCRIPT = + '<script type="application/json" id="__void_frontmatter">{"title":"Hello from frontmatter"}</script>'; + type IslandRenderer = { renderIslandPage( page: { component: string; props: Record<string, unknown> }, @@ -57,8 +62,62 @@ it('a layout reads the frontmatter of the Markdown page it wraps', async () => { ); expect(html).toContain('<p id="fm">Hello from frontmatter</p>'); expect(html).toContain('Body</h1>'); + // No island on the page, so the frontmatter stays on the server. + expect(html).not.toContain('__void_frontmatter'); } finally { await server.close(); rmSync(root, { recursive: true, force: true }); } }); + +it('an island reads the same frontmatter in the browser as on the server', async () => { + const root = mkdtempSync(join(import.meta.dirname, '__tmp_md_island_frontmatter_')); + mkdirSync(join(root, 'pages')); + mkdirSync(join(root, 'components')); + writeFileSync( + join(root, 'pages/layout.island.svelte'), + `<script> + import Title from "../components/Title.svelte" with { island: "load" }; + let { children } = $props(); +</script> +<div><Title />{@render children()}</div> +`, + ); + writeFileSync( + join(root, 'components/Title.svelte'), + `<script> + import { useFrontmatter } from "@void/md"; + const frontmatter = useFrontmatter(); +</script> +<p id="island-fm">{String(frontmatter.title)}</p> +`, + ); + writeFileSync( + join(root, 'pages', 'index.md'), + '---\ntitle: Hello from frontmatter\n---\n\n# Body\n', + ); + + let visit: IslandPageVisit | undefined; + try { + visit = await openIslandPage(root, { + alias: { + '@void/svelte/App.svelte': join(import.meta.dirname, '../../src/runtime/App.svelte'), + }, + plugins: [ + ...voidMarkdown(), + svelte(), + { name: 'void-svelte:pages' }, + ...islandsPlugin(join(root, 'pages')), + ], + }); + expect(visit.html).toContain(ISLAND_FRONTMATTER_SCRIPT); + await waitForIslandsHydrated(visit.page); + expect(await visit.page.textContent('#island-fm')).toBe('Hello from frontmatter'); + // A server/browser mismatch makes Svelte warn about hydration and re-render the island. + expect(visit.diagnostics().toLowerCase()).not.toContain('hydrat'); + expect(visit.diagnostics()).not.toContain('pageerror'); + } finally { + await visit?.close(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/void/src/pages/client.ts b/packages/void/src/pages/client.ts index 070f04c1f..9bf271cb4 100644 --- a/packages/void/src/pages/client.ts +++ b/packages/void/src/pages/client.ts @@ -16,6 +16,7 @@ import { type VoidRouterAdapter as PageNavigationAdapter, type PrefetchOptions, } from './navigation-engine.ts'; +import { ISLAND_FRONTMATTER_ID } from './serialize.ts'; export { getValidationErrors, isModifiedEvent, mergeDataIntoHref } from './adapter-helpers.ts'; export { @@ -471,15 +472,26 @@ export function scheduleHydration( export function initIslands( islands: Record<string, () => Promise<unknown>>, - hydrateFn: (el: Element, mod: unknown, props: Record<string, unknown>) => void | Promise<void>, + hydrateFn: ( + el: Element, + mod: unknown, + props: Record<string, unknown>, + frontmatter: Record<string, unknown>, + ) => void | Promise<void>, ): void { + // Islands hydrate as separate roots, so they read the page frontmatter here + // to match what useFrontmatter() returned during server rendering. + const frontmatterText = document.getElementById(ISLAND_FRONTMATTER_ID)?.textContent; + const frontmatter = frontmatterText ? JSON.parse(frontmatterText) : {}; + const hydrate = (el: Element, mod: unknown, props: Record<string, unknown>) => + hydrateFn(el, mod, props, frontmatter); document.querySelectorAll('[data-island]').forEach((el) => { const id = el.getAttribute('data-island')!; const props = JSON.parse(el.getAttribute('data-props') || '{}'); const strategy = el.getAttribute('data-hydrate') || 'load'; const loader = islands[id]; if (loader) { - scheduleHydration(el, loader, props, strategy, hydrateFn); + scheduleHydration(el, loader, props, strategy, hydrate); } }); } diff --git a/packages/void/src/pages/serialize.ts b/packages/void/src/pages/serialize.ts index ffdf0c6ff..a97d8b1d7 100644 --- a/packages/void/src/pages/serialize.ts +++ b/packages/void/src/pages/serialize.ts @@ -9,3 +9,18 @@ import { escapeScriptJson } from './script-json.ts'; export function serializePageData(pageObj: unknown): string { return escapeScriptJson(JSON.stringify(pageObj)); } + +/** Element id of the Markdown page frontmatter that islands read when they hydrate. */ +export const ISLAND_FRONTMATTER_ID = '__void_frontmatter'; + +/** + * Script tag that carries a Markdown page's frontmatter to its islands, so + * `useFrontmatter()` returns the same value in the browser as on the server. + * Empty when the page has no frontmatter. + */ +export function islandFrontmatterScript(frontmatter: Record<string, unknown> | undefined): string { + if (!frontmatter || Object.keys(frontmatter).length === 0) { + return ''; + } + return `<script type="application/json" id="${ISLAND_FRONTMATTER_ID}">${serializePageData(frontmatter)}</script>`; +} diff --git a/packages/void/test/unit/deferred-xss.test.ts b/packages/void/test/unit/deferred-xss.test.ts index a2131c204..6ebbeb955 100644 --- a/packages/void/test/unit/deferred-xss.test.ts +++ b/packages/void/test/unit/deferred-xss.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vite-plus/test'; import { safeJsonStringify } from '../../src/pages/protocol.ts'; -import { serializePageData } from '../../src/pages/serialize.ts'; +import { islandFrontmatterScript, serializePageData } from '../../src/pages/serialize.ts'; describe.each([ ['serializePageData', serializePageData], @@ -40,3 +40,19 @@ describe('safeJsonStringify', () => { expect(safeJsonStringify(value)).toBe('null'); }); }); + +describe('islandFrontmatterScript', () => { + it('keeps a frontmatter value from closing the script tag', () => { + const script = islandFrontmatterScript({ title: '</script><script>alert(1)</script>' }); + + expect(script.indexOf('</script>')).toBe(script.length - '</script>'.length); + expect(JSON.parse(script.slice(script.indexOf('>') + 1, -'</script>'.length))).toEqual({ + title: '</script><script>alert(1)</script>', + }); + }); + + it('is empty without frontmatter', () => { + expect(islandFrontmatterScript(undefined)).toBe(''); + expect(islandFrontmatterScript({})).toBe(''); + }); +}); diff --git a/packages/vue/src/plugin-islands.ts b/packages/vue/src/plugin-islands.ts index e204adbca..81767c1e6 100644 --- a/packages/vue/src/plugin-islands.ts +++ b/packages/vue/src/plugin-islands.ts @@ -263,6 +263,7 @@ function generateIslandRenderer( import { createSSRApp, h, reactive, provide } from "vue"; import { renderToString } from "vue/server-renderer"; import { renderHeadToString, renderHtmlAttrs, renderBodyAttrs } from "void/pages-head"; +import { islandFrontmatterScript } from "void/pages-server"; ${generateComponentManifest(scan, pagesDir)} ${mdScriptIdsCode} @@ -299,6 +300,11 @@ export async function renderIslandPage(pageObj, assetTags) { }); const html = await renderToString(app); + // Islands hydrate as separate roots. Send the page frontmatter so their + // useFrontmatter() returns the same value in the browser. + const __frontmatterScript = __pagesNeedingClientJS.has(pageObj.component) && html.includes("data-island=") + ? islandFrontmatterScript(__pageModule.frontmatter) + : ""; const __mdScriptAttr = __mdClientScriptIds.has(pageObj.component) ? \` data-md-script="\${escapeAttr(pageObj.component)}"\` : ""; const headHtml = pageObj.head ? renderHeadToString(pageObj.head) : ""; const htmlAttrs = pageObj.head ? renderHtmlAttrs(pageObj.head) : ""; @@ -309,7 +315,7 @@ export async function renderIslandPage(pageObj, assetTags) { <head>\${headHtml}\${assetTags.css}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.preloads : ""}</head> <body\${bodyAttrs}> <div id="app"\${__mdScriptAttr}>\${html}</div> - \${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.body : ""} + \${__frontmatterScript}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.body : ""} </body> </html>\`; } @@ -368,10 +374,11 @@ function generateIslandClientEntry( lines.push('};'); lines.push(` -async function hydrateIsland(el, mod, props) { +async function hydrateIsland(el, mod, props, frontmatter) { const { createSSRApp, h } = await import("vue"); const Component = mod.default || mod; const app = createSSRApp({ render: () => h(Component, props) }); + app.provide("__void_frontmatter", frontmatter); app.mount(el); el.setAttribute("data-hydrated", "true"); } diff --git a/packages/vue/test/integration/md-island-frontmatter.test.ts b/packages/vue/test/integration/md-island-frontmatter.test.ts new file mode 100644 index 000000000..47c50fe36 --- /dev/null +++ b/packages/vue/test/integration/md-island-frontmatter.test.ts @@ -0,0 +1,62 @@ +// The Markdown plugin source needs its ambient module declarations in this package's typecheck. +// eslint-disable-next-line typescript/triple-slash-reference -- env.d.ts has no exports to import. +/// <reference path="../../../md/src/env.d.ts" /> +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import vue from '@vitejs/plugin-vue'; +import { expect, it } from 'vite-plus/test'; +import { waitForIslandsHydrated } from '../../../../test/browser-helpers.ts'; +import { openIslandPage, type IslandPageVisit } from '../../../../test/island-page-browser.ts'; +import { voidMarkdown } from '../../../md/src/plugin.ts'; +import { islandsPlugin } from '../../src/plugin-islands.ts'; + +const ISLAND_FRONTMATTER_SCRIPT = + '<script type="application/json" id="__void_frontmatter">{"title":"Hello from frontmatter"}</script>'; + +it('an island reads the same frontmatter in the browser as on the server', async () => { + const root = mkdtempSync(join(import.meta.dirname, '__tmp_md_island_frontmatter_')); + mkdirSync(join(root, 'pages')); + mkdirSync(join(root, 'components')); + writeFileSync( + join(root, 'pages/layout.island.vue'), + `<script setup> +import Title from "../components/Title.vue" with { island: "load" }; +</script> +<template><div><Title /><slot /></div></template> +`, + ); + writeFileSync( + join(root, 'components/Title.vue'), + `<script setup> +import { useFrontmatter } from "@void/md"; +const frontmatter = useFrontmatter(); +</script> +<template><p id="island-fm">{{ String(frontmatter.title) }}</p></template> +`, + ); + writeFileSync( + join(root, 'pages', 'index.md'), + '---\ntitle: Hello from frontmatter\n---\n\n# Body\n', + ); + + let visit: IslandPageVisit | undefined; + try { + visit = await openIslandPage(root, { + plugins: [ + ...voidMarkdown(), + vue(), + { name: 'void-vue:pages' }, + ...islandsPlugin(join(root, 'pages')), + ], + }); + expect(visit.html).toContain(ISLAND_FRONTMATTER_SCRIPT); + await waitForIslandsHydrated(visit.page); + expect(await visit.page.textContent('#island-fm')).toBe('Hello from frontmatter'); + // A server/browser mismatch makes Vue warn about hydration. + expect(visit.diagnostics().toLowerCase()).not.toContain('hydrat'); + expect(visit.diagnostics()).not.toContain('pageerror'); + } finally { + await visit?.close(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/island-page-browser.ts b/test/island-page-browser.ts new file mode 100644 index 000000000..cc6d45e46 --- /dev/null +++ b/test/island-page-browser.ts @@ -0,0 +1,83 @@ +import { writeFileSync } from 'node:fs'; +import { chromium, type Page } from 'playwright'; +import { createServer, type PluginOption } from 'vite'; +import { collectBrowserDiagnostics } from './browser-helpers.ts'; + +type IslandRenderer = { + renderIslandPage( + page: { component: string; props: Record<string, unknown> }, + assetTags: { css: string; preloads: string; body: string }, + ): Promise<string>; +}; + +export type IslandPageVisit = Readonly<{ + /** The HTML the server sent, before any island hydrated. */ + html: string; + page: Page; + diagnostics: () => string; + close: () => Promise<void>; +}>; + +/** + * Serve `pages/index` under `root` through an adapter's island renderer in Vite + * dev and open it in Chromium. `plugins` must include the adapter's + * `islandsPlugin` and anything its pages need. + */ +export async function openIslandPage( + root: string, + // Plugins come from each package's own Vite types, so take them loosely here. + options: { plugins: ReadonlyArray<object>; alias?: Record<string, string> }, +): Promise<IslandPageVisit> { + writeFileSync(`${root}/entry.js`, 'import "virtual:void-islands-client";\n'); + const browser = await chromium.launch(); + const server = await createServer({ + root, + configFile: false, + cacheDir: `${root}/.vite`, + logLevel: 'silent', + appType: 'custom', + server: { port: 0, host: '127.0.0.1' }, + resolve: { alias: options.alias }, + plugins: [ + ...(options.plugins as Array<PluginOption>), + { + name: 'test:render-island-index', + configureServer(viteServer) { + viteServer.middlewares.use(async (request, response, next) => { + if (request.url !== '/') { + next(); + return; + } + try { + const renderer = (await viteServer.ssrLoadModule( + 'virtual:void-islands-renderer', + )) as IslandRenderer; + const html = await renderer.renderIslandPage( + { component: 'index', props: {} }, + { css: '', preloads: '', body: '<script type="module" src="/entry.js"></script>' }, + ); + response.setHeader('Content-Type', 'text/html'); + response.end(html); + } catch (error) { + next(error); + } + }); + }, + }, + ], + }); + const close = async () => { + await server.close(); + await browser.close(); + }; + try { + await server.listen(); + const page = await browser.newPage(); + const diagnostics = collectBrowserDiagnostics(page); + const response = await page.goto(server.resolvedUrls!.local[0]); + return { html: await response!.text(), page, diagnostics, close }; + } catch (error) { + await close(); + throw error; + } +}