Skip to content

[2.0 rc.9] Async dynamic() inside Loading hydrates an absent fallback and replaces SSR DOM #3666

Closed

Description

@brenelz

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: false

vite.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

  1. Install these dependencies without any local runtime patches and start the Vite dev server.
  2. Delay the generated client-entry request until the server-rendered #content element is visible.
  3. Save a reference to that DOM element.
  4. Release the client entry and let hydration finish.
  5. Click the counter, then compare the current #content element 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 had s: 1.
  • The client stack passed through createCollectionBoundary, the Loading fallback getter, and getNextElement.

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-js and @solidjs/web: preview URLs above (2.0.0-rc.9, 1d3ec5d)
  • Preview @solidjs/signals dependency: 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

  1. ryansolid commented on Sep 26, 2026

    @ryansolid
    Member

    Confirmed, and fixed in #3671 (merged to next as 3af4696) — thanks for the two reproductions; they turned out to share one root cause.

    dynamic() opted its memo out of hydration serialization (serialize: false), on the assumption that the client would simply re-run the source during hydration. That only works when the client-side source answers synchronously. A direct server-reference call does (the frames intercept answers from the document), but anything async in front of it — the router's query() (its handleResponse is an async function), an async arrow, or your toy async () => "article" — leaves the instance pending under the hydrating <Loading>, which selects a fallback the server never rendered, misses its nodes, and re-renders from scratch. Same mechanism in the app and the toy.

    The fix is in dynamic, not in query() — using query() there is idiomatic. An async dynamic() instance is now an ordinary async memo: the server serializes what the source landed and the client adopts that record during hydration — no re-run, no pending beat, no fallback. A server component crosses as a reference (so a wrapped query() call and a direct call hydrate identically; source called once on the client, zero requests), and a tag name crosses as a string, so the toy works as written.

    One shape becomes a dev error: an async source that resolves to a client component function (dynamic(async () => (await load()).ok ? Editor : Viewer)). A function can't cross the wire, and the old behaviour for it was exactly this bug, so the server now rejects it as DYNAMIC_ASYNC_COMPONENT with the two correct spellings: move the async upstream (createAsync/createMemo that the source reads synchronously — dynamic(() => page().ok ? Editor : Viewer)), or lazy() when what you're awaiting is the component's code. Sync sources are untouched.

    — Claude via Cursor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions