diff --git a/.changeset/server-owner-symmetry.md b/.changeset/server-owner-symmetry.md new file mode 100644 index 000000000..ff5b1c4ab --- /dev/null +++ b/.changeset/server-owner-symmetry.md @@ -0,0 +1,11 @@ +--- +"@solid-primitives/event-listener": patch +"@solid-primitives/interaction": patch +"@solid-primitives/keyboard": patch +"@solid-primitives/scroll": patch +"@solid-primitives/media": patch +"@solid-primitives/utils": patch +"@solid-primitives/static-store": patch +--- + +Make client-only effects transparent to Solid 2 hydration and call hydratable helpers’ onSettled on both server and client so later nodes bind correctly. diff --git a/packages/event-listener/src/eventListener.ts b/packages/event-listener/src/eventListener.ts index ef218a0b9..cd471d90a 100644 --- a/packages/event-listener/src/eventListener.ts +++ b/packages/event-listener/src/eventListener.ts @@ -251,6 +251,7 @@ export function eventListener( el.addEventListener(type, handler, options); return () => el.removeEventListener(type, handler, options); }, + { transparent: true }, ); return (el: EventTarget) => { setTarget(el); diff --git a/packages/event-listener/src/eventListenerMap.ts b/packages/event-listener/src/eventListenerMap.ts index fc26fc079..bee61d2cc 100644 --- a/packages/event-listener/src/eventListenerMap.ts +++ b/packages/event-listener/src/eventListenerMap.ts @@ -1,7 +1,6 @@ import { type AnyFunction, entries, type Many, type MaybeAccessor } from "@solid-primitives/utils"; import { createEventListener } from "./eventListener.ts"; import type { EventMapOf, TargetWithEventMap, EventListenerOptions } from "./types.ts"; -import { isServer } from "@solidjs/web"; export type EventHandlersMap = { [EventName in keyof EventMap]: (event: EventMap[EventName]) => void; @@ -48,9 +47,6 @@ export function createEventListenerMap( handlersMap: Record, options?: EventListenerOptions, ): void { - if (isServer) { - return; - } for (const [eventName, handler] of entries(handlersMap)) { if (handler) createEventListener(targets, eventName, handler, options); } diff --git a/packages/event-listener/test/event-listener.test.ts b/packages/event-listener/test/event-listener.test.ts index 6f942c99a..9b0774445 100644 --- a/packages/event-listener/test/event-listener.test.ts +++ b/packages/event-listener/test/event-listener.test.ts @@ -1,3 +1,4 @@ +import { renderHydrationRoundTrip } from "../../../scripts/test-utils/hydration-harness.ts"; import { dispatchFakeEvent, event_target } from "./setup.js"; import { describe, test, expect } from "vitest"; import { createRoot, createSignal, flush } from "solid-js"; @@ -245,3 +246,23 @@ describe("eventListener directive", () => { dispose(); }); }); + +test("hydrates primitive output and its following sibling", async () => { + const result = await renderHydrationRoundTrip( + ` + import { eventListener, createEventListenerMap } from "@solid-primitives/event-listener"; + function Child() { eventListener(["click", () => {}]); createEventListenerMap(() => document, { click: () => {}, keydown: () => {}, focus: undefined }); 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.container.querySelector("span")).toBe(result.serverNodes[1]); + expect((result.serverNodes[0] as HTMLElement).dataset.bound).toBe("child"); + expect((result.serverNodes[1] as HTMLElement).dataset.bound).toBe("sibling"); + } finally { + result.cleanup(); + } +}); diff --git a/packages/interaction/src/index.ts b/packages/interaction/src/index.ts index baa26fbdf..8e7af800a 100644 --- a/packages/interaction/src/index.ts +++ b/packages/interaction/src/index.ts @@ -196,10 +196,10 @@ export function ariaHideOutside( walk(root); - const observer = new MutationObserver((changes) => { + const observer = new MutationObserver(changes => { for (const change of changes) { if (change.type !== "childList" || change.addedNodes.length === 0) continue; - if (![...visibleNodes, ...hiddenNodes].some((node) => node.contains(change.target))) { + if (![...visibleNodes, ...hiddenNodes].some(node => node.contains(change.target))) { Array.from(change.removedNodes).forEach(node => { if (node instanceof Element) { visibleNodes.delete(node); @@ -541,5 +541,6 @@ export function createInteractOutside( if (disabled || !el) return; return makeInteractOutside(el, options); }, + { transparent: true }, ); } diff --git a/packages/interaction/test/interact-outside.test.ts b/packages/interaction/test/interact-outside.test.ts index 7cf638f7f..b1ee95627 100644 --- a/packages/interaction/test/interact-outside.test.ts +++ b/packages/interaction/test/interact-outside.test.ts @@ -1,3 +1,4 @@ +import { renderHydrationRoundTrip } from "../../../scripts/test-utils/hydration-harness.ts"; import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest"; import { createRoot, createSignal, flush } from "solid-js"; import { @@ -308,3 +309,23 @@ describe("createInteractOutside", () => { }); }); }); + +it("hydrates primitive output and its following sibling", async () => { + const result = await renderHydrationRoundTrip( + ` + import { createInteractOutside } from "@solid-primitives/interaction"; + function Child() { createInteractOutside({ disabled: true }, () => undefined); 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.container.querySelector("span")).toBe(result.serverNodes[1]); + expect((result.serverNodes[0] as HTMLElement).dataset.bound).toBe("child"); + expect((result.serverNodes[1] as HTMLElement).dataset.bound).toBe("sibling"); + } finally { + result.cleanup(); + } +}); diff --git a/packages/keyboard/src/index.ts b/packages/keyboard/src/index.ts index 71bfe13d4..a7eda758f 100644 --- a/packages/keyboard/src/index.ts +++ b/packages/keyboard/src/index.ts @@ -502,5 +502,6 @@ export function createKeyDown( document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); }, + { transparent: true }, ); } diff --git a/packages/keyboard/test/index.test.ts b/packages/keyboard/test/index.test.ts index 375925a98..c2608a7bd 100644 --- a/packages/keyboard/test/index.test.ts +++ b/packages/keyboard/test/index.test.ts @@ -1,3 +1,4 @@ +import { renderHydrationRoundTrip } from "../../../scripts/test-utils/hydration-harness.ts"; import { createRoot, createSignal, flush } from "solid-js"; import { describe, test, expect } from "vitest"; import { @@ -532,3 +533,23 @@ describe("createShortcut", () => { })); }); }); + +test("hydrates primitive output and its following sibling", async () => { + const result = await renderHydrationRoundTrip( + ` + import { createKeyDown } from "@solid-primitives/keyboard"; + function Child() { createKeyDown("Escape", () => {}); 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.container.querySelector("span")).toBe(result.serverNodes[1]); + expect((result.serverNodes[0] as HTMLElement).dataset.bound).toBe("child"); + expect((result.serverNodes[1] as HTMLElement).dataset.bound).toBe("sibling"); + } finally { + result.cleanup(); + } +}); diff --git a/packages/media/src/index.ts b/packages/media/src/index.ts index f5825d7e6..44e7f7dda 100644 --- a/packages/media/src/index.ts +++ b/packages/media/src/index.ts @@ -42,13 +42,9 @@ export function makeMediaQueryListener( * ``` */ export function createMediaQuery(query: string, serverFallback = false): Accessor { - if (isServer) { - return () => serverFallback; - } - const mql = window.matchMedia(query); - const [state, setState] = createHydratableSignal(serverFallback, () => mql.matches); - const update = () => setState(mql.matches); - makeEventListener(mql, "change", update); + const mql = isServer ? undefined : window.matchMedia(query); + const [state, setState] = createHydratableSignal(serverFallback, () => mql!.matches); + if (mql) makeEventListener(mql, "change", () => setState(mql.matches)); return state; } @@ -138,12 +134,11 @@ export function createBreakpoints( { enumerable: false, get: () => Object.keys(breakpoints).pop() }, ); - // oxlint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (isServer || !window.matchMedia) return fallback; - const { mediaFeature = "min-width", watchChange = true } = options; const [matches, setMatches] = createHydratableStaticStore>(fallback, () => { + // 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]) => { @@ -158,6 +153,9 @@ export function createBreakpoints( return matches as Matches; }); + // oxlint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (isServer || !window.matchMedia) return fallback; + return Object.defineProperty(matches, "key", { enumerable: false, get: () => Object.keys(matches).findLast(token => matches[token]) as keyof T, diff --git a/packages/media/test/index.test.ts b/packages/media/test/index.test.ts index 7306a433a..fb7259107 100644 --- a/packages/media/test/index.test.ts +++ b/packages/media/test/index.test.ts @@ -1,3 +1,4 @@ +import { renderHydrationRoundTrip } from "../../../scripts/test-utils/hydration-harness.ts"; import { describe, test, expect, vi, beforeEach, beforeAll, afterAll } from "vitest"; import { createRoot } from "solid-js"; import { createBreakpoints, sortBreakpoints } from "../src/index.js"; @@ -232,6 +233,26 @@ describe("createBreakpoints", () => { }); expect(removeListenerMock).not.toBeCalled(); }); + test("hydrates primitive output and its following sibling", async () => { + const result = await renderHydrationRoundTrip( + ` + import { createMediaQuery, createBreakpoints } from "@solid-primitives/media"; + import { isServer } from "@solidjs/web"; + function Child() { createMediaQuery("(min-width: 640px)"); if (!isServer) window.matchMedia = undefined; createBreakpoints({ sm: "640px" }, { watchChange: false }); 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.container.querySelector("span")).toBe(result.serverNodes[1]); + expect((result.serverNodes[0] as HTMLElement).dataset.bound).toBe("child"); + expect((result.serverNodes[1] as HTMLElement).dataset.bound).toBe("sibling"); + } finally { + result.cleanup(); + } + }); }); describe("sortBreakpoints", () => { diff --git a/packages/scroll/src/preventScroll.ts b/packages/scroll/src/preventScroll.ts index 95444e91e..99eb255d0 100644 --- a/packages/scroll/src/preventScroll.ts +++ b/packages/scroll/src/preventScroll.ts @@ -206,6 +206,7 @@ export const createPreventScroll = (props: CreatePreventScrollProps = {}): void setPreventScrollStack(stack => [...stack, id]); return () => setPreventScrollStack(stack => stack.filter(s => s !== id)); }, + { transparent: true }, ); // 2. Apply body overflow styles. @@ -252,6 +253,7 @@ export const createPreventScroll = (props: CreatePreventScrollProps = {}): void } }; }, + { transparent: true }, ); // 3. Install wheel/touch event listeners (only when this is the topmost instance). @@ -331,5 +333,6 @@ export const createPreventScroll = (props: CreatePreventScrollProps = {}): void document.removeEventListener("touchmove", maybePreventTouch); }; }, + { transparent: true }, ); }; diff --git a/packages/scroll/test/index.test.ts b/packages/scroll/test/index.test.ts index 05bb84e49..876801435 100644 --- a/packages/scroll/test/index.test.ts +++ b/packages/scroll/test/index.test.ts @@ -1,3 +1,4 @@ +import { renderHydrationRoundTrip } from "../../../scripts/test-utils/hydration-harness.ts"; import { createRoot, createSignal, flush } from "solid-js"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; @@ -267,3 +268,23 @@ describe("createPreventScroll", () => { expect(document.documentElement.style.overflow).toBe(""); })); }); + +it("hydrates primitive output and its following sibling", async () => { + const result = await renderHydrationRoundTrip( + ` + import { createPreventScroll } from "@solid-primitives/scroll"; + function Child() { createPreventScroll({ enabled: false }); 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.container.querySelector("span")).toBe(result.serverNodes[1]); + expect((result.serverNodes[0] as HTMLElement).dataset.bound).toBe("child"); + expect((result.serverNodes[1] as HTMLElement).dataset.bound).toBe("sibling"); + } finally { + result.cleanup(); + } +}); diff --git a/packages/static-store/src/index.ts b/packages/static-store/src/index.ts index 972338cf7..c8be0e077 100644 --- a/packages/static-store/src/index.ts +++ b/packages/static-store/src/index.ts @@ -100,9 +100,7 @@ export function createHydratableStaticStore T, ): ReturnType> { - if (isServer) return createStaticStore(serverValue); - - if (sharedConfig.hydrating) { + if (isServer || sharedConfig.hydrating) { const [state, setState] = createStaticStore(serverValue); onSettled(() => { setState(update()); }); return [state, setState]; diff --git a/packages/static-store/test/index.test.ts b/packages/static-store/test/index.test.ts index f725da71b..aa9ed8ba0 100644 --- a/packages/static-store/test/index.test.ts +++ b/packages/static-store/test/index.test.ts @@ -1,3 +1,4 @@ +import { renderHydrationRoundTrip } from "../../../scripts/test-utils/hydration-harness.ts"; import { createRoot, createSignal, createTrackedEffect, flush } from "solid-js"; import { describe, expect, test } from "vitest"; import { @@ -110,3 +111,23 @@ describe("createDerivedStaticStore", () => { dispose(); }); }); + +test("hydrates primitive output and its following sibling", async () => { + const result = await renderHydrationRoundTrip( + ` + import { createHydratableStaticStore } from "@solid-primitives/static-store"; + function Child() { createHydratableStaticStore({ value: false }, () => ({ value: true })); 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.container.querySelector("span")).toBe(result.serverNodes[1]); + expect((result.serverNodes[0] as HTMLElement).dataset.bound).toBe("child"); + expect((result.serverNodes[1] as HTMLElement).dataset.bound).toBe("sibling"); + } finally { + result.cleanup(); + } +}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e32862b08..c0adc2f09 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -356,10 +356,7 @@ export function createHydratableSignal( update: () => T, options?: SignalOptions, ): ReturnType> { - if (isServer) { - return createSignal(serverValue as Exclude, options); - } - if (sharedConfig.hydrating) { + if (isServer || sharedConfig.hydrating) { const [state, setState] = createSignal(serverValue as Exclude, options); onSettled(() => { setState(() => update()); diff --git a/packages/utils/test/index.test.ts b/packages/utils/test/index.test.ts index 0630b2c15..c5b324c9d 100644 --- a/packages/utils/test/index.test.ts +++ b/packages/utils/test/index.test.ts @@ -1,3 +1,4 @@ +import { renderHydrationRoundTrip } from "../../../scripts/test-utils/hydration-harness.ts"; import { describe, test, expect, assert, vi } from "vitest"; import { createEffect, createRoot, createSignal, createStore, flush } from "solid-js"; import { @@ -192,3 +193,23 @@ describe("createServerSafeSignal", () => { dispose(); }); }); + +test("hydrates primitive output and its following sibling", async () => { + const result = await renderHydrationRoundTrip( + ` + import { createHydratableSignal } from "@solid-primitives/utils"; + function Child() { createHydratableSignal(false, () => true); 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.container.querySelector("span")).toBe(result.serverNodes[1]); + expect((result.serverNodes[0] as HTMLElement).dataset.bound).toBe("child"); + expect((result.serverNodes[1] as HTMLElement).dataset.bound).toBe("sibling"); + } finally { + result.cleanup(); + } +});