From 9ec695fd184c3ed6d4a5594d435c0c6ec00b87cb Mon Sep 17 00:00:00 2001
From: mizchi
Date: Mon, 19 Oct 2026 12:55:07 +0900
Subject: [PATCH 1/3] fix(solid): preserve SSR DOM when hydrating islands
---
packages/solid/src/plugin-islands.ts | 54 +++--
.../test/integration/island-hydration.test.ts | 227 ++++++++++++++++++
2 files changed, 260 insertions(+), 21 deletions(-)
create mode 100644 packages/solid/test/integration/island-hydration.test.ts
diff --git a/packages/solid/src/plugin-islands.ts b/packages/solid/src/plugin-islands.ts
index 69093ccdd..8be930253 100644
--- a/packages/solid/src/plugin-islands.ts
+++ b/packages/solid/src/plugin-islands.ts
@@ -251,8 +251,25 @@ export function islandsPlugin(
function generateWrapperComponent(meta: WrapperMeta): string {
return `import _Cmp from ${JSON.stringify(meta.src)};
+import { createComponent, createUniqueId, sharedConfig, useContext } from "solid-js";
+import { FrontmatterContext } from "@void/solid";
export default function _IslandWrap(props) {
- return <_Cmp {...props} />
;
+ const context = sharedConfig.context;
+ const renderId = "void-" + createUniqueId() + "-";
+ const frontmatter = useContext(FrontmatterContext);
+ let content;
+ // Keep the page's SSR serializer, suspense state and owner, but allocate
+ // this island's keys from the same root used by the client hydrator.
+ sharedConfig.context = { ...context, id: renderId, count: 0, noHydrate: false };
+ try {
+ content = createComponent(FrontmatterContext.Provider, {
+ value: frontmatter,
+ get children() { return createComponent(_Cmp, props); }
+ });
+ } finally {
+ sharedConfig.context = context;
+ }
+ return {content}
;
}
`;
}
@@ -395,30 +412,25 @@ function generateIslandClientEntry(
}
lines.push('};');
- // Use render (not hydrate) for island activation. Solid's hydrate() uses
- // tree-position-dependent hydration keys that won't match when hydrating a
- // sub-component that was SSR'd deeper in the component tree (inside context
- // providers and layouts). render() replaces the server HTML with a fresh
- // client render — the brief re-render is acceptable for interactive islands.
+ // Each wrapper scopes its SSR keys independently of its page and layout.
+ // Recreate the same provider tree before hydrating that island's root.
lines.push(`
async function mountIsland(el, mod, props, frontmatter) {
- const { render } = await import("solid-js/web");
- const { createComponent } = await import("solid-js");
+ const { hydrate } = await import("solid-js/web");
+ const { createComponent, sharedConfig } = await import("solid-js");
const Component = mod.default || mod;
- const { FrontmatterContext } =
- Object.keys(frontmatter).length > 0 ? await import("@void/solid") : {};
- el.textContent = "";
- render(
- () =>
- FrontmatterContext
- ? createComponent(FrontmatterContext.Provider, {
- value: frontmatter,
- get children() {
- return createComponent(Component, props);
- },
- })
- : createComponent(Component, props),
+ const { FrontmatterContext } = await import("@void/solid");
+ // Solid 1.x marks the document done on its first delegated event. A later
+ // island is still an SSR root, even after another island was interacted with.
+ globalThis._$HY.done = false;
+ sharedConfig.done = false;
+ hydrate(
+ () => createComponent(FrontmatterContext.Provider, {
+ value: frontmatter,
+ get children() { return createComponent(Component, props); }
+ }),
el,
+ { renderId: el.getAttribute("data-solid-render-id") },
);
el.setAttribute("data-hydrated", "true");
}
diff --git a/packages/solid/test/integration/island-hydration.test.ts b/packages/solid/test/integration/island-hydration.test.ts
new file mode 100644
index 000000000..4d1c1b455
--- /dev/null
+++ b/packages/solid/test/integration/island-hydration.test.ts
@@ -0,0 +1,227 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { chromium } from 'playwright';
+import { preview } from 'vite';
+import solid from 'vite-plugin-solid';
+import { describe, expect, it } from 'vite-plus/test';
+import { collectBrowserDiagnostics } from '../../../../test/browser-helpers.ts';
+import { buildIslandApp } from '../../../../test/island-css-build.ts';
+import { openIslandPage, type IslandPageVisit } from '../../../../test/island-page-browser.ts';
+import { islandsPlugin } from '../../src/plugin-islands.ts';
+
+type SnapshotWindow = Window & {
+ __ssrCounters?: Record;
+};
+
+const counter = `import { createSignal, createUniqueId, useContext } from 'solid-js';
+import { FrontmatterContext } from '@void/solid';
+export default function Counter(props) {
+ const [count, setCount] = createSignal(props.initial);
+ const id = createUniqueId();
+ return ;
+}`;
+
+const capture = `window.__ssrCounters = Object.fromEntries(
+ Array.from(document.querySelectorAll('[data-counter]')).map(host => {
+ const input = host.querySelector('input');
+ input.value = 'draft-' + host.dataset.counter;
+ return [host.dataset.counter, { button: host.querySelector('button'), input }];
+ })
+);`;
+
+const files = {
+ 'components/Counter.tsx': counter,
+ 'components/AsyncCounter.tsx': `
+import { createResource, Suspense } from 'solid-js';
+import Counter from './Counter';
+export default function AsyncCounter() {
+ const [name] = createResource(async () => {
+ await new Promise(resolve => setTimeout(resolve, 10));
+ return 'async';
+ });
+ return Loading
}>
+
+ ;
+}`,
+ 'pages/layout.island.tsx': `
+import Counter from '../components/Counter' with { island: 'load' };
+export default function Layout(props) {
+ return {props.children}
;
+}`,
+ 'pages/index.island.tsx': `
+import Counter from '../components/Counter' with { island: 'load' };
+import VisibleCounter from '../components/Counter' with { island: 'visible' };
+import AsyncCounter from '../components/AsyncCounter' with { island: 'load' };
+export const frontmatter = { title: 'Island metadata' };
+export default function Page() {
+ return
+
+
+
+
+
+
+
+ ;
+}`,
+};
+
+async function visitFixture(
+ production: boolean,
+): Promise<{ visit: IslandPageVisit; close(): Promise }> {
+ if (production) {
+ return visitProductionFixture();
+ }
+ const root = mkdtempSync(join(import.meta.dirname, '__tmp_island_hydration_'));
+ for (const [file, content] of Object.entries(files)) {
+ mkdirSync(dirname(join(root, file)), { recursive: true });
+ writeFileSync(join(root, file), content);
+ }
+ try {
+ const visit = await openIslandPage(root, {
+ alias: { '@void/solid': join(import.meta.dirname, '../../src/runtime/index.ts') },
+ plugins: [solid({ ssr: true }), ...islandsPlugin(join(root, 'pages'))],
+ });
+ return {
+ visit,
+ async close() {
+ await visit.close();
+ rmSync(root, { recursive: true, force: true });
+ },
+ };
+ } catch (error) {
+ rmSync(root, { recursive: true, force: true });
+ throw error;
+ }
+}
+
+async function visitProductionFixture(): Promise<{
+ visit: IslandPageVisit;
+ close(): Promise;
+}> {
+ const build = buildIslandApp({
+ packageDir: join(import.meta.dirname, '../..'),
+ pluginName: 'voidSolid',
+ files,
+ });
+ const server = await preview({
+ root: build.root,
+ configFile: false,
+ logLevel: 'silent',
+ build: { outDir: 'dist/client' },
+ preview: { host: '127.0.0.1', port: 0 },
+ });
+ const browser = await chromium.launch();
+ const close = async () => {
+ await browser.close();
+ await server.close();
+ build.cleanup();
+ };
+ try {
+ const page = await browser.newPage();
+ const diagnostics = collectBrowserDiagnostics(page);
+ const response = await page.goto(server.resolvedUrls!.local[0]);
+ const visit = { page, diagnostics, html: await response!.text(), close };
+ return { visit, close };
+ } catch (error) {
+ await close();
+ throw error;
+ }
+}
+
+async function expectReused(visit: IslandPageVisit, name: string): Promise {
+ const { page } = visit;
+ await page.waitForFunction(
+ (name) =>
+ document
+ .querySelector(`[data-counter="${name}"]`)
+ ?.closest('[data-island]')
+ ?.getAttribute('data-hydrated') === 'true',
+ name,
+ );
+ expect(
+ await page.evaluate((name) => {
+ const nodes = (window as SnapshotWindow).__ssrCounters![name];
+ const host = document.querySelector(`[data-counter="${name}"]`)!;
+ return {
+ button: nodes.button === host.querySelector('button'),
+ input: nodes.input === host.querySelector('input'),
+ value: host.querySelector('input')!.value,
+ labelled: host.querySelector('label')!.htmlFor === host.querySelector('input')!.id,
+ frontmatter: host.getAttribute('data-frontmatter'),
+ };
+ }, name),
+ ).toEqual({
+ button: true,
+ input: true,
+ value: `draft-${name}`,
+ labelled: true,
+ frontmatter: 'Island metadata',
+ });
+}
+
+describe.each(['dev', 'production'])('%s island hydration', (mode) => {
+ it('reuses SSR DOM and preserves input in repeated islands and an island layout', async () => {
+ const fixture = await visitFixture(mode === 'production');
+ try {
+ for (const name of ['layout', 'first', 'second']) {
+ await expectReused(fixture.visit, name);
+ }
+ const { page } = fixture.visit;
+ await page.locator('[data-counter="first"] button').click();
+ expect(await page.locator('[data-counter="first"] button').textContent()).toBe('1');
+ expect(await page.locator('[data-counter="second"] button').textContent()).toBe('10');
+ expect(fixture.visit.diagnostics()).not.toContain('pageerror');
+ } finally {
+ await fixture.close();
+ }
+ });
+
+ it('hydrates visible islands in reverse order after another island handles an event', async () => {
+ const fixture = await visitFixture(mode === 'production');
+ try {
+ const { page } = fixture.visit;
+ await page.waitForSelector('[data-counter="first"]');
+ await page.waitForFunction(() =>
+ document.querySelector('[data-island][data-hydrated="true"]'),
+ );
+ await page.locator('[data-counter="first"] button').click();
+ for (const [name, initial] of [
+ ['visible-second', 40],
+ ['visible-first', 20],
+ ] as const) {
+ expect(
+ await page
+ .locator(`[data-counter="${name}"]`)
+ .evaluate((host) => host.closest('[data-island]')!.hasAttribute('data-hydrated')),
+ ).toBe(false);
+ await page.locator(`[data-counter="${name}"]`).scrollIntoViewIfNeeded();
+ await expectReused(fixture.visit, name);
+ await page.locator(`[data-counter="${name}"] button`).click();
+ expect(await page.locator(`[data-counter="${name}"] button`).textContent()).toBe(
+ String(initial + 1),
+ );
+ }
+ expect(fixture.visit.diagnostics()).not.toContain('pageerror');
+ } finally {
+ await fixture.close();
+ }
+ });
+
+ it('hydrates an async resource inside Suspense without replacing the resolved SSR DOM', async () => {
+ const fixture = await visitFixture(mode === 'production');
+ try {
+ expect(fixture.visit.html).toContain('data-counter="async"');
+ await expectReused(fixture.visit, 'async');
+ const button = fixture.visit.page.locator('[data-counter="async"] button');
+ await button.click();
+ expect(await button.textContent()).toBe('51');
+ expect(fixture.visit.diagnostics()).not.toContain('pageerror');
+ } finally {
+ await fixture.close();
+ }
+ });
+});
From ace42b690d2fa56d184aff8887c9fdeda732bf85 Mon Sep 17 00:00:00 2001
From: mizchi
Date: Mon, 19 Oct 2026 15:49:09 +0900
Subject: [PATCH 2/3] test(solid): share production island hydration fixtures
---
.../test/integration/island-hydration.test.ts | 99 +++++++++++--------
1 file changed, 60 insertions(+), 39 deletions(-)
diff --git a/packages/solid/test/integration/island-hydration.test.ts b/packages/solid/test/integration/island-hydration.test.ts
index 4d1c1b455..130b2f12f 100644
--- a/packages/solid/test/integration/island-hydration.test.ts
+++ b/packages/solid/test/integration/island-hydration.test.ts
@@ -1,9 +1,9 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
-import { chromium } from 'playwright';
-import { preview } from 'vite';
+import { chromium, type Browser } from 'playwright';
+import { preview, type PreviewServer } from 'vite';
import solid from 'vite-plugin-solid';
-import { describe, expect, it } from 'vite-plus/test';
+import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test';
import { collectBrowserDiagnostics } from '../../../../test/browser-helpers.ts';
import { buildIslandApp } from '../../../../test/island-css-build.ts';
import { openIslandPage, type IslandPageVisit } from '../../../../test/island-page-browser.ts';
@@ -69,12 +69,7 @@ export default function Page() {
}`,
};
-async function visitFixture(
- production: boolean,
-): Promise<{ visit: IslandPageVisit; close(): Promise }> {
- if (production) {
- return visitProductionFixture();
- }
+async function visitFixture(): Promise<{ visit: IslandPageVisit; close(): Promise }> {
const root = mkdtempSync(join(import.meta.dirname, '__tmp_island_hydration_'));
for (const [file, content] of Object.entries(files)) {
mkdirSync(dirname(join(root, file)), { recursive: true });
@@ -98,32 +93,20 @@ async function visitFixture(
}
}
-async function visitProductionFixture(): Promise<{
+async function visitProductionFixture(
+ browser: Browser,
+ base: string,
+): Promise<{
visit: IslandPageVisit;
close(): Promise;
}> {
- const build = buildIslandApp({
- packageDir: join(import.meta.dirname, '../..'),
- pluginName: 'voidSolid',
- files,
- });
- const server = await preview({
- root: build.root,
- configFile: false,
- logLevel: 'silent',
- build: { outDir: 'dist/client' },
- preview: { host: '127.0.0.1', port: 0 },
- });
- const browser = await chromium.launch();
- const close = async () => {
- await browser.close();
- await server.close();
- build.cleanup();
- };
+ const context = await browser.newContext();
+ const close = () => context.close();
try {
- const page = await browser.newPage();
+ const page = await context.newPage();
+ page.setDefaultTimeout(10_000);
const diagnostics = collectBrowserDiagnostics(page);
- const response = await page.goto(server.resolvedUrls!.local[0]);
+ const response = await page.goto(base);
const visit = { page, diagnostics, html: await response!.text(), close };
return { visit, close };
} catch (error) {
@@ -164,16 +147,54 @@ async function expectReused(visit: IslandPageVisit, name: string): Promise
}
describe.each(['dev', 'production'])('%s island hydration', (mode) => {
+ let productionBuild: ReturnType | undefined;
+ let productionServer: PreviewServer | undefined;
+ let productionBrowser: Browser | undefined;
+
+ beforeAll(async () => {
+ if (mode === 'production') {
+ productionBuild = buildIslandApp({
+ packageDir: join(import.meta.dirname, '../..'),
+ pluginName: 'voidSolid',
+ files,
+ });
+ productionServer = await preview({
+ root: productionBuild.root,
+ configFile: false,
+ logLevel: 'silent',
+ build: { outDir: 'dist/client' },
+ preview: { host: '127.0.0.1', port: 0 },
+ });
+ productionBrowser = await chromium.launch();
+ }
+ });
+
+ afterAll(async () => {
+ await productionBrowser?.close();
+ await productionServer?.close();
+ productionBuild?.cleanup();
+ });
+
+ function newFixture() {
+ return mode === 'production'
+ ? visitProductionFixture(productionBrowser!, productionServer!.resolvedUrls!.local[0])
+ : visitFixture();
+ }
+
it('reuses SSR DOM and preserves input in repeated islands and an island layout', async () => {
- const fixture = await visitFixture(mode === 'production');
+ const fixture = await newFixture();
try {
for (const name of ['layout', 'first', 'second']) {
await expectReused(fixture.visit, name);
}
const { page } = fixture.visit;
await page.locator('[data-counter="first"] button').click();
- expect(await page.locator('[data-counter="first"] button').textContent()).toBe('1');
- expect(await page.locator('[data-counter="second"] button').textContent()).toBe('10');
+ await expect
+ .poll(() => page.locator('[data-counter="first"] button').textContent())
+ .toBe('1');
+ await expect
+ .poll(() => page.locator('[data-counter="second"] button').textContent())
+ .toBe('10');
expect(fixture.visit.diagnostics()).not.toContain('pageerror');
} finally {
await fixture.close();
@@ -181,7 +202,7 @@ describe.each(['dev', 'production'])('%s island hydration', (mode) => {
});
it('hydrates visible islands in reverse order after another island handles an event', async () => {
- const fixture = await visitFixture(mode === 'production');
+ const fixture = await newFixture();
try {
const { page } = fixture.visit;
await page.waitForSelector('[data-counter="first"]');
@@ -201,9 +222,9 @@ describe.each(['dev', 'production'])('%s island hydration', (mode) => {
await page.locator(`[data-counter="${name}"]`).scrollIntoViewIfNeeded();
await expectReused(fixture.visit, name);
await page.locator(`[data-counter="${name}"] button`).click();
- expect(await page.locator(`[data-counter="${name}"] button`).textContent()).toBe(
- String(initial + 1),
- );
+ await expect
+ .poll(() => page.locator(`[data-counter="${name}"] button`).textContent())
+ .toBe(String(initial + 1));
}
expect(fixture.visit.diagnostics()).not.toContain('pageerror');
} finally {
@@ -212,13 +233,13 @@ describe.each(['dev', 'production'])('%s island hydration', (mode) => {
});
it('hydrates an async resource inside Suspense without replacing the resolved SSR DOM', async () => {
- const fixture = await visitFixture(mode === 'production');
+ const fixture = await newFixture();
try {
expect(fixture.visit.html).toContain('data-counter="async"');
await expectReused(fixture.visit, 'async');
const button = fixture.visit.page.locator('[data-counter="async"] button');
await button.click();
- expect(await button.textContent()).toBe('51');
+ await expect.poll(() => button.textContent()).toBe('51');
expect(fixture.visit.diagnostics()).not.toContain('pageerror');
} finally {
await fixture.close();
From 7219ea39cc91c2e9435495a75dcfb40d675664c0 Mon Sep 17 00:00:00 2001
From: LongYinan
Date: Mon, 19 Oct 2026 15:06:59 +0800
Subject: [PATCH 3/3] test(solid): cover island hydration on a page without
frontmatter
The client must render the frontmatter provider even when the page has
no frontmatter: every createComponent call opens a hydration context, so
skipping the provider shifts the island's hydration keys and it never
hydrates. Add a production case for that page and say why in the client
entry.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
packages/solid/src/plugin-islands.ts | 4 +-
.../test/integration/island-hydration.test.ts | 37 +++++++++++++++++--
2 files changed, 36 insertions(+), 5 deletions(-)
diff --git a/packages/solid/src/plugin-islands.ts b/packages/solid/src/plugin-islands.ts
index 8be930253..fd2f0d29b 100644
--- a/packages/solid/src/plugin-islands.ts
+++ b/packages/solid/src/plugin-islands.ts
@@ -413,7 +413,9 @@ function generateIslandClientEntry(
lines.push('};');
// Each wrapper scopes its SSR keys independently of its page and layout.
- // Recreate the same provider tree before hydrating that island's root.
+ // Recreate the same provider tree before hydrating that island's root. Every
+ // createComponent call opens a hydration context, so the provider stays even
+ // when the page has no frontmatter.
lines.push(`
async function mountIsland(el, mod, props, frontmatter) {
const { hydrate } = await import("solid-js/web");
diff --git a/packages/solid/test/integration/island-hydration.test.ts b/packages/solid/test/integration/island-hydration.test.ts
index 130b2f12f..fc263c4a1 100644
--- a/packages/solid/test/integration/island-hydration.test.ts
+++ b/packages/solid/test/integration/island-hydration.test.ts
@@ -66,6 +66,14 @@ export default function Page() {
;
+}`,
+ 'pages/plain.island.tsx': `
+import Counter from '../components/Counter' with { island: 'load' };
+export default function Plain() {
+ return
+
+
+ ;
}`,
};
@@ -115,7 +123,11 @@ async function visitProductionFixture(
}
}
-async function expectReused(visit: IslandPageVisit, name: string): Promise {
+async function expectReused(
+ visit: IslandPageVisit,
+ name: string,
+ frontmatter: string | null = 'Island metadata',
+): Promise {
const { page } = visit;
await page.waitForFunction(
(name) =>
@@ -142,7 +154,7 @@ async function expectReused(visit: IslandPageVisit, name: string): Promise
input: true,
value: `draft-${name}`,
labelled: true,
- frontmatter: 'Island metadata',
+ frontmatter,
});
}
@@ -175,9 +187,9 @@ describe.each(['dev', 'production'])('%s island hydration', (mode) => {
productionBuild?.cleanup();
});
- function newFixture() {
+ function newFixture(path = '') {
return mode === 'production'
- ? visitProductionFixture(productionBrowser!, productionServer!.resolvedUrls!.local[0])
+ ? visitProductionFixture(productionBrowser!, productionServer!.resolvedUrls!.local[0] + path)
: visitFixture();
}
@@ -232,6 +244,23 @@ describe.each(['dev', 'production'])('%s island hydration', (mode) => {
}
});
+ // The dev helper serves only the index page.
+ it.runIf(mode === 'production')(
+ 'reuses SSR DOM on an island page without frontmatter',
+ async () => {
+ const fixture = await newFixture('plain');
+ try {
+ await expectReused(fixture.visit, 'plain', null);
+ const button = fixture.visit.page.locator('[data-counter="plain"] button');
+ await button.click();
+ await expect.poll(() => button.textContent()).toBe('61');
+ expect(fixture.visit.diagnostics()).not.toContain('pageerror');
+ } finally {
+ await fixture.close();
+ }
+ },
+ );
+
it('hydrates an async resource inside Suspense without replacing the resolved SSR DOM', async () => {
const fixture = await newFixture();
try {