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
56 changes: 35 additions & 21 deletions packages/solid/src/plugin-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div data-island="${meta.islandId}" data-props={JSON.stringify(props)} data-hydrate="${meta.hydrate}"><_Cmp {...props} /></div>;
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 <div data-island="${meta.islandId}" data-props={JSON.stringify(props)} data-hydrate="${meta.hydrate}" data-solid-render-id={renderId}>{content}</div>;
}
`;
}
Expand Down Expand Up @@ -395,30 +412,27 @@ 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. 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 { 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") },
Comment thread
Brooooooklyn marked this conversation as resolved.
);
el.setAttribute("data-hydrated", "true");
}
Expand Down
277 changes: 277 additions & 0 deletions packages/solid/test/integration/island-hydration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { chromium, type Browser } from 'playwright';
import { preview, type PreviewServer } from 'vite';
import solid from 'vite-plugin-solid';
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';
import { islandsPlugin } from '../../src/plugin-islands.ts';

type SnapshotWindow = Window & {
__ssrCounters?: Record<string, { button: HTMLButtonElement; input: HTMLInputElement }>;
};

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 <section data-counter={props.name} data-frontmatter={useContext(FrontmatterContext).title}>
<label for={id}>{props.name}</label><input id={id} />
<button onClick={() => setCount(count() + 1)}>{count()}</button>
</section>;
}`;

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 <Suspense fallback={<p>Loading</p>}>
<Counter name={name()} initial={50} />
</Suspense>;
}`,
'pages/layout.island.tsx': `
import Counter from '../components/Counter' with { island: 'load' };
export default function Layout(props) {
return <div><Counter name="layout" initial={30} />{props.children}</div>;
}`,
'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 <main>
<Counter name="first" initial={0} /><Counter name="second" initial={10} />
<AsyncCounter />
<div style={{ height: '1600px' }} />
<VisibleCounter name="visible-first" initial={20} />
<div style={{ height: '1600px' }} />
<VisibleCounter name="visible-second" initial={40} />
<script innerHTML={${JSON.stringify(capture)}} />
</main>;
}`,
'pages/plain.island.tsx': `
import Counter from '../components/Counter' with { island: 'load' };
export default function Plain() {
return <main>
<Counter name="plain" initial={60} />
<script innerHTML={${JSON.stringify(capture)}} />
</main>;
}`,
};

async function visitFixture(): Promise<{ visit: IslandPageVisit; close(): Promise<void> }> {
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(
browser: Browser,
base: string,
): Promise<{
visit: IslandPageVisit;
close(): Promise<void>;
}> {
const context = await browser.newContext();
const close = () => context.close();
try {
const page = await context.newPage();
page.setDefaultTimeout(10_000);
const diagnostics = collectBrowserDiagnostics(page);
const response = await page.goto(base);
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,
frontmatter: string | null = 'Island metadata',
): Promise<void> {
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,
});
}

describe.each(['dev', 'production'])('%s island hydration', (mode) => {
let productionBuild: ReturnType<typeof buildIslandApp> | 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(path = '') {
return mode === 'production'
? visitProductionFixture(productionBrowser!, productionServer!.resolvedUrls!.local[0] + path)
: visitFixture();
}

it('reuses SSR DOM and preserves input in repeated islands and an island layout', async () => {
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();
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();
}
});

it('hydrates visible islands in reverse order after another island handles an event', async () => {
const fixture = await newFixture();
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();
await expect
.poll(() => page.locator(`[data-counter="${name}"] button`).textContent())
.toBe(String(initial + 1));
}
expect(fixture.visit.diagnostics()).not.toContain('pageerror');
} finally {
await fixture.close();
}
});

// 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 {
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();
await expect.poll(() => button.textContent()).toBe('51');
expect(fixture.visit.diagnostics()).not.toContain('pageerror');
} finally {
await fixture.close();
}
});
});
Loading