Skip to content

Commit 58998de

Browse files
morinokamiclaude
andauthored
fix(md): run client scripts in builds without an .island. file (#681)
A client script in a Markdown page ran in dev but not after a production build, unless a file with `.island.` in its name existed under `pages/`. Without one, `dist/client/.vite/manifest.json` had no `virtual:void-md-client` entry, so the script never loaded. The `config` hook in `voidMarkdown()` added that entry only when `hasMdFilesWithScript(pagesDir) && hasIslandFiles(pagesDir)` was true. `hasIslandFiles` only read file names under `pages/`, so: - A Markdown page that imports an island did not count. The island hydrated, but the rest of the script did not run. - A `.island.` file outside `pages/` did not count. - Any matching name counted, even an empty `pages/_x.island.txt`. ## Fix The entry is now added when a page that the build keeps has client code. The check uses the same file list and `extractScript()` as `scanMdPages()`, so it agrees with what the entry loads. This also replaces the `/^<script\b/m` check. It matched a script with only island imports, a `<script>` in a code block and a `.dev` page, none of which has client code in a build. With a `.island.` file present, those apps built a 0-byte `md-client` chunk that their pages with JavaScript loaded. They now get no entry. The guide already describes this behavior, so the docs do not change. ## Tests - `packages/md/test/integration/client-script-build.test.ts` (new): builds a React app with no `.island.` file under `pages/` and opens it in Chromium. A script-only page and a page that also imports an island both run their scripts, the island responds to a click, and a page without client code ships no `<script>`. On `main` it fails because the manifest has no `virtual:void-md-client`. - `packages/md/test/unit/plugin-source-dir.test.ts`: covers which pages add the entry. Four cases fail on `main`, and four fail if only `hasIslandFiles` is removed. ## Verification - `vp test run`: 765 files pass, 1 skipped (15,607 tests pass, 29 skipped). - `vp run check`: 33/33 pass. - By hand on React, Vue, Svelte and Solid, with `vp build`, `vp preview` (Cloudflare target) and Chromium: client scripts run, and an app without client code gets no `md-client` entry. Svelte and Solid used an absolute island import because of the first issue below, and the Solid island did not respond because of the second. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2ecbb7f commit 58998de

3 files changed

Lines changed: 145 additions & 44 deletions

File tree

‎packages/md/src/plugin.ts‎

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createHash } from 'node:crypto';
2-
import { existsSync, readFileSync, readdirSync } from 'node:fs';
2+
import { existsSync, readFileSync } from 'node:fs';
33
import { dirname, join, relative, resolve } from 'pathslash';
44
import { glob } from 'tinyglobby';
55
import { normalizePath, type Plugin, type ResolvedConfig, type ViteDevServer } from 'vite';
@@ -57,32 +57,22 @@ function mdRouteName(file: string, env: 'dev' | 'prod' | null): string {
5757
return name.endsWith('/index') ? name.slice(0, -'/index'.length) : name;
5858
}
5959

60-
function hasMdFilesWithScript(dir: string): boolean {
61-
try {
62-
for (const entry of readdirSync(dir, { withFileTypes: true, recursive: true })) {
63-
if (entry.isFile() && entry.name.endsWith('.md')) {
64-
const fullPath = join(entry.parentPath, entry.name);
65-
const content = readFileSync(fullPath, 'utf-8');
66-
if (/^<script\b/m.test(content)) {
67-
return true;
68-
}
69-
}
70-
}
71-
} catch {
72-
// ignore
73-
}
74-
return false;
60+
/** The Markdown files that `environment` keeps, relative to `pagesDir`. */
61+
async function listMdFiles(pagesDir: string, environment: 'dev' | 'prod'): Promise<Array<string>> {
62+
const mdFiles = await glob('**/*.md', { cwd: pagesDir });
63+
// Skip pages for the other environment, so their scripts are not added either.
64+
return mdFiles.filter((file) => {
65+
const fileEnv = pageFileEnvironment(file);
66+
return !fileEnv || fileEnv === environment;
67+
});
7568
}
7669

77-
function hasIslandFiles(dir: string): boolean {
78-
try {
79-
for (const entry of readdirSync(dir, { withFileTypes: true, recursive: true })) {
80-
if (entry.isFile() && /\.island\./.test(entry.name)) {
81-
return true;
82-
}
70+
/** Whether any page has client code. Without it, pages would load an empty client entry. */
71+
async function hasMdClientCode(pagesDir: string, environment: 'dev' | 'prod'): Promise<boolean> {
72+
for (const file of await listMdFiles(pagesDir, environment)) {
73+
if (extractScript(readFileSync(join(pagesDir, file), 'utf-8')).clientCode) {
74+
return true;
8375
}
84-
} catch {
85-
// ignore
8676
}
8777
return false;
8878
}
@@ -114,12 +104,7 @@ export function voidMarkdown(options?: MarkdownOptions): Array<Plugin> {
114104
return [];
115105
}
116106

117-
const mdFiles = await glob('**/*.md', { cwd: pagesDir });
118-
// Skip pages for the other environment, so their scripts are not added either.
119-
const liveFiles = mdFiles.filter((file) => {
120-
const fileEnv = pageFileEnvironment(file);
121-
return !fileEnv || fileEnv === mdEnvironment;
122-
});
107+
const liveFiles = await listMdFiles(pagesDir, mdEnvironment);
123108
const pages = await Promise.all(
124109
liveFiles.map(async (file) => {
125110
const content = readFileSync(join(pagesDir, file), 'utf-8');
@@ -156,7 +141,7 @@ export function voidMarkdown(options?: MarkdownOptions): Array<Plugin> {
156141
},
157142
},
158143

159-
config(config, env) {
144+
async config(config, env) {
160145
mdEnvironment = env.command === 'serve' && !env.isPreview ? 'dev' : 'prod';
161146
if (env.command === 'serve') {
162147
return;
@@ -166,7 +151,7 @@ export function voidMarkdown(options?: MarkdownOptions): Array<Plugin> {
166151
if (!existsSync(pagesDir)) {
167152
return;
168153
}
169-
if (hasMdFilesWithScript(pagesDir) && hasIslandFiles(pagesDir)) {
154+
if (await hasMdClientCode(pagesDir, mdEnvironment)) {
170155
return {
171156
environments: {
172157
client: {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { readFileSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
import { chromium, type Browser } from 'playwright';
4+
import { preview, type PreviewServer } from 'vite';
5+
import { expect, it } from 'vite-plus/test';
6+
import { waitForIslandsHydrated } from '../../../../test/browser-helpers.ts';
7+
import { buildIslandApp } from '../../../../test/island-css-build.ts';
8+
9+
it('runs client scripts in a production build with no .island. file in pages/', async () => {
10+
const build = buildIslandApp({
11+
packageDir: join(import.meta.dirname, '../../../react'),
12+
pluginName: 'voidReact',
13+
files: {
14+
'tsconfig.json': JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }),
15+
'components/Counter.island.tsx': `import { useState } from 'react';
16+
export default function Counter() {
17+
const [count, setCount] = useState(0);
18+
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
19+
}`,
20+
'pages/script.md': `<script>
21+
document.querySelector('#out').textContent = 'set-by-script';
22+
</script>
23+
24+
# Script
25+
26+
<span id="out">unset</span>
27+
`,
28+
'pages/mixed.md': `<script>
29+
import Counter from '../components/Counter.island.tsx' with { island: 'load' };
30+
document.querySelector('#out').textContent = 'set-by-script';
31+
</script>
32+
33+
# Mixed
34+
35+
<span id="out">unset</span>
36+
37+
<Counter />
38+
`,
39+
},
40+
});
41+
let server: PreviewServer | undefined;
42+
let browser: Browser | undefined;
43+
try {
44+
const manifest = JSON.parse(
45+
readFileSync(join(build.root, 'dist/client/.vite/manifest.json'), 'utf8'),
46+
);
47+
expect(manifest).toHaveProperty(['virtual:void-md-client']);
48+
// A page without client code still ships no JavaScript.
49+
expect(build.page('about.html').html).not.toContain('<script');
50+
51+
server = await preview({
52+
root: build.root,
53+
configFile: false,
54+
logLevel: 'silent',
55+
build: { outDir: 'dist/client' },
56+
preview: { host: '127.0.0.1', port: 0 },
57+
});
58+
const base = server.resolvedUrls?.local[0];
59+
if (!base) {
60+
throw new Error('Preview did not report a local URL.');
61+
}
62+
browser = await chromium.launch();
63+
const page = await browser.newPage();
64+
const errors: Array<string> = [];
65+
page.on('pageerror', (error) => errors.push(error.message));
66+
67+
await page.goto(new URL('script', base).href);
68+
await expect.poll(() => page.textContent('#out'), { timeout: 15_000 }).toBe('set-by-script');
69+
70+
await page.goto(new URL('mixed', base).href);
71+
await expect.poll(() => page.textContent('#out'), { timeout: 15_000 }).toBe('set-by-script');
72+
await waitForIslandsHydrated(page, 1, 15_000);
73+
await page.getByRole('button', { name: 'Count: 0', exact: true }).click();
74+
await expect.poll(() => page.getByRole('button').textContent()).toBe('Count: 1');
75+
76+
expect(errors).toEqual([]);
77+
} finally {
78+
await browser?.close();
79+
await server?.close();
80+
build.cleanup();
81+
}
82+
}, 120_000);

‎packages/md/test/unit/plugin-source-dir.test.ts‎

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ describe('voidMarkdown sourceDir support', () => {
7777
const buildStart = getHookHandler(plugin.buildStart) as () => Promise<void>;
7878
const load = getHookHandler(plugin.load) as (id: string) => string | undefined;
7979

80-
config({ root }, { command });
80+
await config({ root }, { command });
8181
configResolved({
8282
root,
8383
plugins: [{ name: 'void-react:pages' }],
@@ -121,21 +121,32 @@ describe('voidMarkdown sourceDir support', () => {
121121
expect((await scanForCommand(root, 'build')).paths).toEqual(['/posts/stats']);
122122
});
123123

124-
it('adds the markdown client entry for sourceDir pages with scripts and islands', () => {
124+
function buildConfigFor(pages: Record<string, string>): Promise<unknown> {
125125
const root = createTempRoot();
126-
writeFileSync(
127-
join(root, 'src', 'pages', 'posts', 'hello.md'),
128-
'<script>\ndocument.body.dataset.md = "ready";\n</script>\n\n# Hello\n',
129-
);
130-
writeFileSync(join(root, 'src', 'pages', 'posts', 'Counter.island.tsx'), 'export default {};');
131-
132-
const plugin = createPlugin();
133-
const config = getHookHandler(plugin.config) as (
126+
for (const [file, content] of Object.entries(pages)) {
127+
writeFileSync(join(root, 'src', 'pages', file), content);
128+
}
129+
const config = getHookHandler(createPlugin().config) as (
134130
config: { root: string },
135131
env: { command: 'build' },
136-
) => unknown;
132+
) => Promise<unknown>;
133+
return config({ root }, { command: 'build' });
134+
}
137135

138-
expect(config({ root }, { command: 'build' })).toEqual({
136+
const CLIENT_CODE = '<script>\ndocument.body.dataset.md = "ready";\n</script>\n\n# Hello\n';
137+
const ISLAND_IMPORT = 'import Counter from "./Counter.tsx" with { island: "load" };';
138+
139+
it.each([
140+
['a page', { 'posts/hello.md': CLIENT_CODE }],
141+
['a build-only page', { 'stats.prod.md': CLIENT_CODE }],
142+
[
143+
'a page that also imports an island',
144+
{
145+
'mixed.md': `<script>\n${ISLAND_IMPORT}\nconsole.log("ready");\n</script>\n\n<Counter />\n`,
146+
},
147+
],
148+
])('adds the markdown client entry for client code on %s', async (_name, pages) => {
149+
expect(await buildConfigFor(pages)).toEqual({
139150
environments: {
140151
client: {
141152
build: {
@@ -147,4 +158,27 @@ describe('voidMarkdown sourceDir support', () => {
147158
},
148159
});
149160
});
161+
162+
it.each([
163+
['pages without a script', { 'hello.md': '# Hello\n' }],
164+
[
165+
'a script with only island imports',
166+
{ 'hello.md': `<script>\n${ISLAND_IMPORT}\n</script>\n\n<Counter />\n` },
167+
],
168+
[
169+
'a script tag in a code block',
170+
{ 'hello.md': '# Hello\n\n```html\n<script>\nconsole.log("example");\n</script>\n```\n' },
171+
],
172+
['client code on a dev-only page', { 'debug.dev.md': CLIENT_CODE, 'hello.md': '# Hello\n' }],
173+
[
174+
'island files next to pages without client code',
175+
{
176+
'hello.md': `<script>\n${ISLAND_IMPORT}\n</script>\n\n<Counter />\n`,
177+
'Counter.island.tsx': 'export default {};',
178+
'_x.island.txt': '',
179+
},
180+
],
181+
])('does not add the markdown client entry for %s', async (_name, pages) => {
182+
expect(await buildConfigFor(pages)).toBeUndefined();
183+
});
150184
});

0 commit comments

Comments
 (0)