Repository navigation
Description
Describe the bug
An async dynamic() source inside <Loading> can produce a hydration-key warning for the loading fallback even when the resolved server-rendered content is already present. The client also replaces the server-rendered content element instead of adopting it.
This was first observed with server-component routes, but the reproduction below uses no router and no server components: only dynamic(), <Loading>, and a signal.
The tested packages are the unpatched preview builds listed below, which identify themselves as 2.0.0-rc.9. I have not verified this against the latest solidjs/solid next commit or the stock npm 2.0.0-rc.9 packages.
Reproduction
Self-contained source is included here; there is no hosted reproduction URL.
package.json:
{
"private": true,
"type": "module",
"scripts": {
"dev": "vite"
},
"dependencies": {
"solid-js": "https://pkg.pr.new/mizulu/solid2/solid-js@1d3ec5d",
"@solidjs/web": "https://pkg.pr.new/mizulu/solid2/@solidjs/web@1d3ec5d"
},
"devDependencies": {
"@solidjs/vite-plugin": "3.0.0-next.44",
"vite": "8.3.1",
"playwright-core": "1.63.0"
}
}For pnpm, allow the preview packages' URL subdependencies in pnpm-workspace.yaml:
blockExoticSubdeps: falsevite.config.ts:
import { defineConfig } from 'vite';
import solid from '@solidjs/vite-plugin';
export default defineConfig({
plugins: [solid({ start: true, ssr: true })],
server: { port: 3102, strictPort: true },
});src/App.tsx:
import { createSignal, Loading } from 'solid-js';
import { dynamic } from '@solidjs/web';
export default function App() {
const [count, setCount] = createSignal(0, { name: 'repro.count' });
const Content = dynamic(async () => 'article' as const);
return (
<Loading fallback={<main id="fallback">Loading…</main>}>
<Content id="content">
<button id="counter" onClick={() => setCount(count() + 1)}>
Count: {count()}
</button>
</Content>
</Loading>
);
}The Vite plugin generates the document and server/client entries.
Steps to reproduce
- Install these dependencies without any local runtime patches and start the Vite dev server.
- Delay the generated client-entry request until the server-rendered
#contentelement is visible. - Save a reference to that DOM element.
- Release the client entry and let hydration finish.
- Click the counter, then compare the current
#contentelement with the saved reference.
The following Playwright script makes that ordering explicit. It delays only the client entry, allowing the document and inline SSR scripts to arrive normally.
// reproduce.mjs
import { chromium } from 'playwright-core';
const browser = await chromium.launch({
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined,
});
try {
const page = await browser.newPage();
page.on('console', message => {
if (message.type() === 'warning') console.log(message.text());
});
let release;
const gate = new Promise(resolve => { release = resolve; });
await page.route('**/*solid-ssr-entry-client*', async route => {
await gate;
await route.continue();
});
await page.goto('http://localhost:3102/', { waitUntil: 'domcontentloaded' });
await page.locator('#content').waitFor();
await page.evaluate(() => {
window.initialContent = document.querySelector('#content');
});
release();
await page.waitForLoadState('networkidle');
await page.locator('#counter').click();
await page.getByRole('button', { name: 'Count: 1', exact: true }).waitFor();
console.log('same DOM element:', await page.evaluate(
() => window.initialContent === document.querySelector('#content'),
));
} finally {
await browser.close();
}Run pnpm exec playwright-core install chromium once, then node reproduce.mjs while the dev server is running. PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH can point to an existing Chrome executable.
Actual behavior
The reduced reproduction logs:
Hydration key miss for "1110": no server-rendered element carries this key
(template: <main id=fallback>Loading…). A detached element was created instead;
its subtree will not appear in the document or become interactive.
same DOM element: false
The counter eventually responds to clicks, so this is not a permanently blank page. However, the original server-rendered article is replaced during hydration.
In the original server-route application, the warning requested key 170210 for <main>Loading…</main>. Capturing the response and browser state showed:
- The server response's loading fallback had key
17020. - At the warning, the live DOM already contained the resolved users content rather than that fallback.
- The corresponding
_$HY.r["1702_fr"]record hads: 1. - The client stack passed through
createCollectionBoundary, theLoadingfallback getter, andgetNextElement.
The exact key depends on the surrounding component tree.
Expected behavior
Hydration should adopt the resolved server-rendered content, preserve DOM element identity, and attach working event handlers without attempting to hydrate an absent fallback or logging a hydration-key mismatch.
Platform
- macOS 26.5.2
- Google Chrome 153.0.8010.53, including headless runs via Playwright Core 1.63.0
- Node.js 25.1.0
solid-jsand@solidjs/web: preview URLs above (2.0.0-rc.9,1d3ec5d)- Preview
@solidjs/signalsdependency:1d3ec5d81087dac10ac621698394373b7c1f412c @solidjs/vite-plugin:3.0.0-next.44- Vite:
8.3.1
Related reports
The warning resembles #3574 and #3610. This reproduction uses neither a hybrid store nor an entry that runs before the document is ready: the client entry is deliberately held until the resolved server content is visible. I am including the distinction in case these share an underlying hydration path.
Activity
fix(web): async dynamic() serializes its landing and the client adopt…
ryansolid commentedon Sep 26, 2026