Skip to content

default-theme: module-scope mobileLayout watcher leaks a computation and breaks hydration on viewports ≤ 1100px #152

Description

@daveycodez

Summary

src/default-theme/globals.ts creates its mobileLayout media-query watcher at module scope and applies the initial value on a setTimeout(0). This causes three user-visible problems, the worst being broken hydration (blank/errored page) on viewports ≤ 1100px for any page whose content hydrates a Dynamic-based component (e.g. anything built on @kobalte/core).

// src/default-theme/globals.ts
const [_mobileLayout, setMobileLayout] = createSignal(false);

onMount(() => {            // ← module scope: no owner
	const query = createMediaQuery("(max-width: 1100px)");
	createRoot(() => {
		createEffect(on(query, (q) => setMobileLayout(q), { defer: true }));
	});
	setTimeout(() => setMobileLayout(query()));   // ← can fire mid-hydration
});

Symptom 1 — dev console warning on every site

onMount at module scope registers a computation without an owner:

computations created outside a `createRoot` or `render` will never be disposed
  createComputation → createEffect → onMount → globals.js:4

Symptom 2 — hydration poisoned on viewports ≤ 1100px

Sequence on a full page load with a window narrower than 1100px:

  1. SSR renders with mobileLayout() === false → desktop <aside> sidenav branch of the Show in Layout.tsx.
  2. On the client, globals.ts is imported before hydration finishes; the leaked onMount effect runs and queues setTimeout(() => setMobileLayout(query())).
  3. With streaming SSR the timeout can fire while hydration is still in progress. mobileLayout flips to true, the Show in Layout.tsx switches to the mobile Dialog branch, which no longer matches the server-rendered DOM.
  4. Solid throws Hydration Mismatch. Unable to find DOM nodes for hydration key … (pointing at Layout.tsx) and sets sharedConfig.done = true.
  5. Every element that hydrates after that point sees isHydrating() === false. For plain elements this happens to fall through, but Dynamic in its string case calls getNextElement() with no template argument (solid-js/web createDynamic), so it crashes:
TypeError: template is not a function
    at getNextElement (solid-js/web)
    at createDynamic ...

Any docs page whose MDX/content renders a Kobalte-based component (all Kobalte parts render through Polymorphic/Dynamic) dies here — the route content client-renders into an error boundary or a blank page. Pages of plain markdown mostly survive, which makes the bug easy to miss.

(As a side effect, the solid-start dev overlay that catches the error then fails itself with The requested module 'source-map-js' does not provide an export named 'SourceMapConsumer', which adds to the confusion.)

Symptom 3 — no responsive layout updates until the timeout wins the race

Cosmetic by comparison, but the initial layout state is applied at an uncontrolled time.

Reproduction

  • solid-start 2.0.0-beta.0, vite 8, @kobalte/solidbase 0.6.9 (also reproduces with the current pkg.pr.new build — globals.ts is unchanged there), default theme.
  • Any MDX page that renders a @kobalte/core-based component in its content (e.g. a Kobalte Button wrapper).
  • Set the browser window to < 1100px wide and hard-reload the page.
  • Console shows the Hydration Mismatch warning from Layout.tsx, then TypeError: template is not a function; the page content is blank or error-boundaried.

At > 1100px the setTimeout writes false into a signal already holding false, nothing re-renders, and everything works — the crash only affects narrower windows.

Suggested fix

Create the reactivity inside a root and defer the initial flip until hydration has settled (e.g. the window load event). This removes the leaked-computation warning and makes the mid-hydration flip impossible in practice:

import { createMediaQuery } from "@solid-primitives/media";
import { createEffect, createRoot, createSignal, on } from "solid-js";
import { isServer } from "solid-js/web";

const [_mobileLayout, setMobileLayout] = createSignal(false);

if (!isServer) {
	createRoot(() => {
		const query = createMediaQuery("(max-width: 1100px)");
		createEffect(on(query, (q) => setMobileLayout(q), { defer: true }));

		const apply = () => setTimeout(() => setMobileLayout(query()));
		if (document.readyState === "complete") apply();
		else window.addEventListener("load", apply, { once: true });
	});
}

export const mobileLayout = _mobileLayout;

I've verified this patch locally (via bun patch): the warning disappears and pages with Kobalte-based content hydrate correctly at all widths. A deeper fix might be to render the same tree on both sides until after hydration by design, but the above stays within the current architecture.

Happy to open a PR if you'd take this shape of fix.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions