diff --git a/.changeset/quiet-trees-observe.md b/.changeset/quiet-trees-observe.md new file mode 100644 index 000000000..6be9bacbe --- /dev/null +++ b/.changeset/quiet-trees-observe.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/resize-observer": patch +--- + +Preserve hydration identifiers when `createElementSize` receives a target accessor during server rendering. diff --git a/packages/resize-observer/src/index.ts b/packages/resize-observer/src/index.ts index cddc6be22..7557c2c36 100644 --- a/packages/resize-observer/src/index.ts +++ b/packages/resize-observer/src/index.ts @@ -188,6 +188,9 @@ export function createElementSize( target: Accessor | Element, ): Readonly { if (isServer) { + // Reserve the same hydration child id as the client-side target effect, + // without evaluating the accessor or touching browser APIs on the server. + if (typeof target === "function") createEffect(noop, noop); return ELEMENT_SIZE_FALLBACK; } diff --git a/packages/resize-observer/test/hydration.test.tsx b/packages/resize-observer/test/hydration.test.tsx new file mode 100644 index 000000000..a8aaf3c42 --- /dev/null +++ b/packages/resize-observer/test/hydration.test.tsx @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + renderHydrationRoundTrip, + type HydrationRoundTripResult, +} from "../../../scripts/test-utils/hydration-harness.ts"; + +describe("createElementSize hydration", () => { + let result: HydrationRoundTripResult | undefined; + const targets = new Set(); + const disconnect = vi.fn(() => targets.clear()); + + beforeEach(() => { + targets.clear(); + disconnect.mockClear(); + vi.stubGlobal( + "ResizeObserver", + class { + observe(target: Element) { + targets.add(target); + } + unobserve(target: Element) { + targets.delete(target); + } + disconnect = disconnect; + }, + ); + }); + + afterEach(() => { + result?.cleanup(); + result = undefined; + vi.unstubAllGlobals(); + }); + + it.each(["accessor", "element"])( + "hydrates the %s target without replacing server nodes", + async path => { + result = await renderHydrationRoundTrip( + ` +import { createSignal } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { createElementSize } from "@solid-primitives/resize-observer"; + +export default function App() { + const [target, setTarget] = createSignal(); + const size = createElementSize(${path === "accessor" ? "target" : 'isServer ? undefined as any : document.getElementById("measured")!'}); + return <>
Width:
{size.width ?? "waiting"}

after size

; +} +`, + import.meta.dirname, + ); + expect(result.html).toContain("waiting"); + expect(result.consoleMessages).toEqual([]); + expect(result.container.childNodes).toHaveLength(result.serverNodes.length); + result.serverNodes.forEach((node, index) => { + expect(result!.container.childNodes[index]).toBe(node); + expect(node.parentNode).toBe(result!.container); + }); + expect(result.container.querySelectorAll("#measured")).toHaveLength(1); + expect(targets.size).toBe(1); + expect(targets.values().next().value).toBe(result.container.querySelector("#measured")); + expect(result.container.querySelector("p")?.textContent).toBe("after size"); + if (path === "accessor") + expect(result.container.querySelector("span")?.textContent).toBe("0"); + result.cleanup(); + result = undefined; + expect(targets.size).toBe(0); + expect(disconnect).toHaveBeenCalledOnce(); + }, + ); +}); diff --git a/packages/resize-observer/test/index.test.ts b/packages/resize-observer/test/index.test.ts index 7242d467c..ec52655b9 100644 --- a/packages/resize-observer/test/index.test.ts +++ b/packages/resize-observer/test/index.test.ts @@ -252,6 +252,37 @@ describe("createElementSize", () => { const div = document.createElement("div"); div.__mock_size = { width: 100, height: 200, clientWidth: 90, clientHeight: 190 }; + test("tracks replacement and missing targets and disconnects on disposal", () => { + _targets = new Set(); + const next = document.createElement("div"); + next.__mock_size = { width: 300, height: 400, clientWidth: 290, clientHeight: 390 }; + const before = disconnect_count; + const { size, setTarget, dispose } = createRoot(dispose => { + const [target, setTarget] = createSignal(div); + return { size: createElementSize(target), setTarget, dispose }; + }); + flush(); + expect(size).toEqual(div.__mock_size); + expect(_targets.size).toBe(1); + expect(_targets.values().next().value).toBe(div); + setTarget(next); + flush(); + expect(size).toEqual(next.__mock_size); + expect(_targets.size).toBe(1); + expect(_targets.values().next().value).toBe(next); + setTarget(null); + flush(); + expect(size).toEqual({ width: null, height: null, clientWidth: null, clientHeight: null }); + expect(_targets.size).toBe(0); + setTarget(div); + flush(); + expect(_targets.size).toBe(1); + expect(_targets.values().next().value).toBe(div); + dispose(); + expect(_targets.size).toBe(0); + expect(disconnect_count).toBe(before + 1); + }); + test("will return element size immediately", () => { createRoot(dispose => { const { width, height, clientWidth, clientHeight } = createElementSize(div); diff --git a/packages/resize-observer/test/server.test.ts b/packages/resize-observer/test/server.test.ts index ebf6e11c3..1b8fc176c 100644 --- a/packages/resize-observer/test/server.test.ts +++ b/packages/resize-observer/test/server.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect, vi } from "vitest"; import { createResizeObserver, createElementSize, createWindowSize } from "../src/index.js"; +import { renderToString } from "@solidjs/web"; describe("server", () => { test("createResizeObserver", () => { @@ -25,4 +26,36 @@ describe("server", () => { expect(size.width).toBe(0); expect(size.height).toBe(0); }); + + test("createElementSize does not read targets or construct observers during SSR", () => { + const observer = vi.fn(() => { + throw new Error("ResizeObserver must not be constructed on the server"); + }); + vi.stubGlobal("ResizeObserver", observer); + const accessor = vi.fn((): Element => { + throw new Error("The target accessor must not run on the server"); + }); + const element = { + getBoundingClientRect() { + throw new Error("The element must not be measured on the server"); + }, + } as unknown as Element; + try { + renderToString(() => { + for (const size of [createElementSize(accessor), createElementSize(element)]) { + expect(size).toEqual({ + width: null, + height: null, + clientWidth: null, + clientHeight: null, + }); + } + return "server-safe"; + }); + expect(accessor).not.toHaveBeenCalled(); + expect(observer).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); }); diff --git a/scripts/test-utils/hydration-harness.ts b/scripts/test-utils/hydration-harness.ts index 065bd530a..756edb377 100644 --- a/scripts/test-utils/hydration-harness.ts +++ b/scripts/test-utils/hydration-harness.ts @@ -130,6 +130,8 @@ export type HydrationRoundTripResult = { html: string; /** `container` element the server HTML was hydrated into — still attached to `document.body`. */ container: HTMLElement; + /** Server-rendered top-level nodes, captured before hydration. */ + serverNodes: Node[]; /** Every `console.error`/`console.warn` message emitted while `hydrate()` ran. */ consoleMessages: string[]; /** Removes `container` from the document. Call in test cleanup. */ @@ -147,9 +149,11 @@ async function runHydrationRoundTrip(appSource: string, cwd: string): Promise void) | undefined; const originalError = console.error; const originalWarn = console.warn; console.error = (...args: unknown[]) => { @@ -166,7 +170,7 @@ async function runHydrationRoundTrip(appSource: string, cwd: string): Promise App({}), container); + dispose = hydrate(() => App({}), container); // Solid finalizes hydration (drainHydrationCallbacks) via a `setTimeout`, after any // microtask-scheduled reactive updates have settled — wait for both ticks so a // late-firing hydration-mismatch warning isn't missed by the console restore below. @@ -180,8 +184,12 @@ async function runHydrationRoundTrip(appSource: string, cwd: string): Promise container.remove(), + cleanup: () => { + dispose?.(); + container.remove(); + }, }; }