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
+ + +
+ +
+ +