Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/media-breakpoints-watchchange-cleanup.md
Original file line number Diff line number Diff line change
@@ -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`.
31 changes: 18 additions & 13 deletions packages/media/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,25 +136,30 @@ export function createBreakpoints<T extends Breakpoints>(

const { mediaFeature = "min-width", watchChange = true } = options;

const [matches, setMatches] = createHydratableStaticStore<Matches<T>>(fallback, () => {
const mqls =
// oxlint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!window.matchMedia) return fallback;
const matches = {} as Record<keyof T, boolean>;

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<Matches<T>>(fallback, () => {
if (!mqls) return fallback;
const matches = {} as Record<keyof T, boolean>;
for (const [token, mql] of mqls) matches[token] = mql.matches;
return matches as Matches<T>;
});

// 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,
Expand Down
17 changes: 17 additions & 0 deletions packages/media/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <button ref={el => el.dataset.bound = "child"}>{String(m.sm)}</button>; }
export default function App() { return <><Child /><span ref={el => el.dataset.bound = "sibling"}>sibling</span></>; }
`,
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", () => {
Expand Down
Loading