From 92fd7726414ffb73549054edd4595c2f2bc50805 Mon Sep 17 00:00:00 2001 From: Dev Agrawal Date: Wed, 21 Oct 2026 08:05:59 +0530 Subject: [PATCH] fix(media): register createBreakpoints listeners outside hydration onSettled --- .../media-breakpoints-watchchange-cleanup.md | 5 +++ packages/media/src/index.ts | 31 +++++++++++-------- packages/media/test/index.test.ts | 17 ++++++++++ 3 files changed, 40 insertions(+), 13 deletions(-) create mode 100644 .changeset/media-breakpoints-watchchange-cleanup.md diff --git a/.changeset/media-breakpoints-watchchange-cleanup.md b/.changeset/media-breakpoints-watchchange-cleanup.md new file mode 100644 index 000000000..2cc0ed6df --- /dev/null +++ b/.changeset/media-breakpoints-watchchange-cleanup.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/media": patch +--- + +Fix `createBreakpoints` throwing `CLEANUP_IN_FORBIDDEN_SCOPE` during hydration when `watchChange` is enabled: media query change listeners are now registered in the owner scope instead of inside `onSettled`. diff --git a/packages/media/src/index.ts b/packages/media/src/index.ts index 44e7f7dda..194cf7b1b 100644 --- a/packages/media/src/index.ts +++ b/packages/media/src/index.ts @@ -136,25 +136,30 @@ export function createBreakpoints( const { mediaFeature = "min-width", watchChange = true } = options; - const [matches, setMatches] = createHydratableStaticStore>(fallback, () => { + const mqls = // oxlint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!window.matchMedia) return fallback; - const matches = {} as Record; - - entries(breakpoints).forEach(([token, width]: [t: keyof T, w: string]) => { - const mql = window.matchMedia(`(${mediaFeature}: ${width})`); - matches[token] = mql.matches; - if (watchChange) - makeEventListener(mql, "change", (e: MediaQueryListEvent) => - setMatches(token, e.matches as any), + isServer || !window.matchMedia + ? undefined + : entries(breakpoints).map( + ([token, width]: [t: keyof T, w: string]) => + [token, window.matchMedia(`(${mediaFeature}: ${width})`)] as const, ); - }); + const [matches, setMatches] = createHydratableStaticStore>(fallback, () => { + if (!mqls) return fallback; + const matches = {} as Record; + for (const [token, mql] of mqls) matches[token] = mql.matches; return matches as Matches; }); - // oxlint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (isServer || !window.matchMedia) return fallback; + if (!mqls) return fallback; + + // registered here rather than in the hydration onSettled, where cleanup is forbidden + if (watchChange) + for (const [token, mql] of mqls) + makeEventListener(mql, "change", (e: MediaQueryListEvent) => + setMatches(token, e.matches as any), + ); return Object.defineProperty(matches, "key", { enumerable: false, diff --git a/packages/media/test/index.test.ts b/packages/media/test/index.test.ts index fb7259107..b61749fe3 100644 --- a/packages/media/test/index.test.ts +++ b/packages/media/test/index.test.ts @@ -253,6 +253,23 @@ describe("createBreakpoints", () => { result.cleanup(); } }); + test("hydrates createBreakpoints with watchChange listeners", async () => { + const result = await renderHydrationRoundTrip( + ` + import { createBreakpoints } from "@solid-primitives/media"; + function Child() { const m = createBreakpoints({ sm: "640px" }); return ; } + export default function App() { return <> el.dataset.bound = "sibling"}>sibling; } + `, + import.meta.dirname, + ); + try { + expect(result.consoleMessages).toEqual([]); + expect(result.container.querySelector("button")).toBe(result.serverNodes[0]); + expect((result.serverNodes[1] as HTMLElement).dataset.bound).toBe("sibling"); + } finally { + result.cleanup(); + } + }); }); describe("sortBreakpoints", () => {