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/quiet-trees-observe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solid-primitives/resize-observer": patch
---

Preserve hydration identifiers when `createElementSize` receives a target accessor during server rendering.
3 changes: 3 additions & 0 deletions packages/resize-observer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ export function createElementSize(
target: Accessor<Element | false | undefined | null> | Element,
): Readonly<NullableSize> {
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;
}

Expand Down
71 changes: 71 additions & 0 deletions packages/resize-observer/test/hydration.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Element>();
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<HTMLDivElement>();
const size = createElementSize(${path === "accessor" ? "target" : 'isServer ? undefined as any : document.getElementById("measured")!'});
return <><div id="measured" ref={setTarget}>Width: </div><span>{size.width ?? "waiting"}</span><p>after size</p></>;
}
`,
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();
},
);
});
31 changes: 31 additions & 0 deletions packages/resize-observer/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Element>();
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<Element | null>(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);
Expand Down
33 changes: 33 additions & 0 deletions packages/resize-observer/test/server.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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();
}
});
});
12 changes: 10 additions & 2 deletions scripts/test-utils/hydration-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -147,9 +149,11 @@ async function runHydrationRoundTrip(appSource: string, cwd: string): Promise<Hy

const container = document.createElement("div");
container.innerHTML = html;
const serverNodes = Array.from(container.childNodes);
document.body.appendChild(container);

const messages: string[] = [];
let dispose: (() => void) | undefined;
const originalError = console.error;
const originalWarn = console.warn;
console.error = (...args: unknown[]) => {
Expand All @@ -166,7 +170,7 @@ async function runHydrationRoundTrip(appSource: string, cwd: string): Promise<Hy
// HTML — jsdom doesn't run scripts assigned via .innerHTML, so this sets up the same global
// hydrate() otherwise expects to already be there.
(globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} };
hydrate(() => 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.
Expand All @@ -180,8 +184,12 @@ async function runHydrationRoundTrip(appSource: string, cwd: string): Promise<Hy
return {
html,
container,
serverNodes,
consoleMessages: messages,
cleanup: () => container.remove(),
cleanup: () => {
dispose?.();
container.remove();
},
};
}

Expand Down
Loading