Skip to content
Open
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/tidy-carts-hydrate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/hydrogen': patch
---

Prevent cart hydration mismatches when asynchronous initial cart data resolves before a `useCart` consumer hydrates.
49 changes: 48 additions & 1 deletion packages/hydrogen/src/react/cart-ssr.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// @vitest-environment node
import { PassThrough } from "node:stream";

import { createElement, Suspense } from "react";
import { renderToString } from "react-dom/server";
import { renderToPipeableStream, renderToString } from "react-dom/server";
import { describe, it, expect } from "vitest";

import type { CartData } from "../core/cart/state";
Expand Down Expand Up @@ -154,4 +156,49 @@ describe("CartProvider SSR", () => {
expect(html).toContain("Loading cart");
expect(html).not.toContain(">0<");
});

it("streams resolved cart content from useSuspenseCart", async () => {
let resolveInitialData: ((value: { cart: CartData }) => void) | undefined;
const initialData = new Promise<{ cart: CartData }>((resolve) => {
resolveInitialData = resolve;
});
const tree = createElement(
typedCart.CartProvider,
{ initialData },
createElement(
Suspense,
{ fallback: createElement("span", null, "Loading cart") },
createElement(SuspenseCartTotalQuantity),
),
);

const html = await new Promise<string>((resolve, reject) => {
const destination = new PassThrough();
let output = "";
destination.setEncoding("utf8");
destination.on("data", (chunk: string) => {
output += chunk;
});
destination.on("end", () => resolve(output));
destination.on("error", reject);

const stream = renderToPipeableStream(tree, {
onShellReady() {
if (!resolveInitialData) {
reject(new Error("Expected initialData resolver to be assigned"));
return;
}
resolveInitialData({ cart: MOCK_CART });
},
onAllReady() {
stream.pipe(destination);
},
onShellError: reject,
onError: reject,
});
});

expect(html).toContain(">3<");
expect(html).not.toContain("Loading cart");
});
});
181 changes: 181 additions & 0 deletions packages/hydrogen/src/react/cart.hydration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// @vitest-environment happy-dom
import { act } from "@testing-library/react";
import { createElement, Suspense, type ReactNode } from "react";
import { hydrateRoot, type Root } from "react-dom/client";
import { renderToString } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";

import type { CartData } from "../core/cart/state";
import { assert } from "../core/test-utils";
import { CartProvider, createCartComponents, useCart } from "./cart";

const CART: CartData = {
id: "gid://shopify/Cart/123",
checkoutUrl: "https://example.com/checkout",
totalQuantity: 3,
cost: {
subtotalAmount: { amount: "30.00", currencyCode: "USD" },
totalAmount: { amount: "30.00", currencyCode: "USD" },
checkoutChargeAmount: { amount: "30.00", currencyCode: "USD" },
},
note: "",
attributes: [],
lines: { nodes: [] },
discountCodes: [],
};

interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}

function createDeferred<T>(): Deferred<T> {
let resolvePromise: ((value: T) => void) | undefined;
const promise = new Promise<T>((resolve) => {
resolvePromise = resolve;
});

return {
promise,
resolve(value) {
assert(resolvePromise, "Expected deferred resolve to be assigned");
resolvePromise(value);
},
};
}

function CartSummary() {
const loading = useCart((state) => state.loading);
const quantity = useCart((state) => state.data.totalQuantity);
return createElement("span", { "data-testid": "cart-summary" }, loading ? "Loading" : quantity);
}

const typedCart = createCartComponents<{
get: () => Promise<{ data: { cart: CartData } }>;
}>();

function SuspenseCartSummary() {
const quantity = typedCart.useSuspenseCart((state) => state.data.totalQuantity);
return createElement("span", { "data-testid": "cart-summary" }, quantity);
}

afterEach(() => {
document.body.replaceChildren();
vi.restoreAllMocks();
});

describe("CartProvider hydration", () => {
it("hydrates useCart from its initial snapshot when cart data resolves first", async () => {
const cartData = createDeferred<{ cart: CartData }>();
const hydrationGate = createDeferred<void>();
let blockHydration = false;

function HydrationGate({ children }: { children: ReactNode }) {
if (blockHydration) throw hydrationGate.promise;
return children;
}

function App() {
return createElement(
CartProvider,
{ initialData: cartData.promise },
createElement(
Suspense,
{ fallback: createElement("span", null, "Waiting") },
createElement(HydrationGate, null, createElement(CartSummary)),
),
);
}

const container = document.createElement("div");
container.innerHTML = renderToString(createElement(App));
document.body.append(container);
expect(container.textContent).toBe("Loading");

const onRecoverableError = vi.fn();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
let root: Root | undefined;
blockHydration = true;

await act(async () => {
root = hydrateRoot(container, createElement(App), { onRecoverableError });
});

cartData.resolve({ cart: CART });
await act(async () => {
await cartData.promise;
});

blockHydration = false;
hydrationGate.resolve();
await act(async () => {
await hydrationGate.promise;
});

expect(container.textContent).toBe("3");
expect(onRecoverableError).not.toHaveBeenCalled();
expect(consoleError).not.toHaveBeenCalled();

await act(async () => root?.unmount());
});

it("hydrates useSuspenseCart content from live cart data", async () => {
const cartData = createDeferred<{ cart: CartData }>();
const hydrationGate = createDeferred<void>();
let blockHydration = false;

function HydrationGate({ children }: { children: ReactNode }) {
if (blockHydration) throw hydrationGate.promise;
return children;
}

function App({
initialData,
}: {
initialData: { cart: CartData } | Promise<{ cart: CartData }>;
}) {
return createElement(
typedCart.CartProvider,
{ initialData },
createElement(
Suspense,
{ fallback: createElement("span", null, "Loading") },
createElement(HydrationGate, null, createElement(SuspenseCartSummary)),
),
);
}

const container = document.createElement("div");
container.innerHTML = renderToString(createElement(App, { initialData: { cart: CART } }));
document.body.append(container);
expect(container.textContent).toBe("3");

const onRecoverableError = vi.fn();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
let root: Root | undefined;
blockHydration = true;

await act(async () => {
root = hydrateRoot(container, createElement(App, { initialData: cartData.promise }), {
onRecoverableError,
});
});

cartData.resolve({ cart: CART });
await act(async () => {
await cartData.promise;
});

blockHydration = false;
hydrationGate.resolve();
await act(async () => {
await hydrationGate.promise;
});

expect(container.textContent).toBe("3");
expect(onRecoverableError).not.toHaveBeenCalled();
expect(consoleError).not.toHaveBeenCalled();

await act(async () => root?.unmount());
});
});
37 changes: 37 additions & 0 deletions packages/hydrogen/src/react/cart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,43 @@ describe("useSuspenseCart", () => {
expect(screen.getByTestId("fallback").textContent).toBe("Loading");
});

it("renders live cart data after the initial readyPromise settles", async () => {
const deferred = createDeferred<void>();
const typedCart = createCartComponents<{
get: () => Promise<{ data: { cart: CartData } }>;
}>();
const mockStore = createMockStore();
mockStore.setReadyPromise(deferred.promise);
vi.mocked(createCartStore).mockImplementation(() => mockStore);

function Consumer() {
const qty = typedCart.useSuspenseCart((s) => s.data.totalQuantity);
return createElement("span", { "data-testid": "qty" }, qty);
}

render(
createElement(
CartProvider,
null,
createElement(
Suspense,
{ fallback: createElement("span", { "data-testid": "fallback" }, "Loading") },
createElement(Consumer),
),
),
);

expect(screen.getByTestId("fallback").textContent).toBe("Loading");

await act(async () => {
mockStore.setState(makeCartState({ totalQuantity: 8 }));
deferred.resolve();
await deferred.promise;
});

expect(screen.getByTestId("qty").textContent).toBe("8");
});

it("returns selected cart state when no readyPromise is visible", () => {
const typedCart = createCartComponents<{
get: () => Promise<{ data: { cart: CartData } }>;
Expand Down
Loading
Loading