diff --git a/packages/pages-components/src/components/hours/hoursStatus.hydration.test.tsx b/packages/pages-components/src/components/hours/hoursStatus.hydration.test.tsx new file mode 100644 index 00000000..53d2c965 --- /dev/null +++ b/packages/pages-components/src/components/hours/hoursStatus.hydration.test.tsx @@ -0,0 +1,62 @@ +import { hydrateRoot } from "react-dom/client"; +import { renderToString } from "react-dom/server"; +import { act } from "react-dom/test-utils"; +import { afterEach, describe, expect, it } from "vitest"; +import { DateTime, Settings } from "luxon"; +import { HoursStatus } from "./hoursStatus.js"; +import { HoursData } from "./hoursSampleData.js"; + +const originalNow = Settings.now; +const originalDefaultZone = Settings.defaultZone; +const originalActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT; + +describe("HoursStatus hydration", () => { + afterEach(async () => { + Settings.now = originalNow; + Settings.defaultZone = originalDefaultZone; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + originalActEnvironment; + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("hydrates from the placeholder to the current status without warnings", async () => { + const mockedNow = DateTime.fromObject( + { year: 2025, month: 1, day: 7, hour: 10 }, + { zone: "America/New_York" } + ); + + Settings.now = () => mockedNow.toMillis(); + Settings.defaultZone = "America/New_York"; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + vi.spyOn(globalThis, "setTimeout").mockImplementation((() => 0) as typeof setTimeout); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const container = document.createElement("div"); + container.innerHTML = renderToString( +
+ ); + document.body.appendChild(container); + + expect(container.textContent).toBe(""); + + let root: ReturnType | undefined; + + await act(async () => { + root = hydrateRoot(container, ); + await Promise.resolve(); + }); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Open Now"); + expect(container.textContent).toContain("Closes at"); + expect(container.textContent).toContain("6:02 PM"); + expect(container.textContent).toContain("Tuesday"); + + await act(async () => { + root?.unmount(); + }); + }); +}); diff --git a/packages/pages-components/src/components/hours/hoursStatus.ssr.test.tsx b/packages/pages-components/src/components/hours/hoursStatus.ssr.test.tsx new file mode 100644 index 00000000..8cca68d8 --- /dev/null +++ b/packages/pages-components/src/components/hours/hoursStatus.ssr.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment node + +import { afterEach, describe, expect, it } from "vitest"; +import { DateTime, Settings } from "luxon"; +import { renderToString } from "react-dom/server"; +import { HoursStatus } from "./hoursStatus.js"; +import { HoursData } from "./hoursSampleData.js"; + +const originalNow = Settings.now; +const originalDefaultZone = Settings.defaultZone; + +describe("HoursStatus SSR", () => { + afterEach(() => { + Settings.now = originalNow; + Settings.defaultZone = originalDefaultZone; + vi.restoreAllMocks(); + }); + + it("renders the placeholder without SSR warnings", () => { + const mockedNow = DateTime.fromObject( + { year: 2025, month: 1, day: 7, hour: 10 }, + { zone: "America/New_York" } + ); + + Settings.now = () => mockedNow.toMillis(); + Settings.defaultZone = "America/New_York"; + + vi.spyOn(globalThis, "setTimeout").mockImplementation((() => 0) as typeof setTimeout); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const html = renderToString(); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(html).toContain('class="HoursStatus"'); + expect(html).toContain("min-height:1.5em"); + expect(html).not.toContain("HoursStatus-current"); + expect(html).not.toContain("Open Now"); + expect(html).not.toContain("Closed"); + expect(html).not.toContain("Closes at"); + expect(html).not.toContain("Opens at"); + }); +}); diff --git a/packages/pages-components/src/components/hours/hoursStatus.tsx b/packages/pages-components/src/components/hours/hoursStatus.tsx index 1c34d41a..8fca83fc 100644 --- a/packages/pages-components/src/components/hours/hoursStatus.tsx +++ b/packages/pages-components/src/components/hours/hoursStatus.tsx @@ -1,9 +1,11 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useLayoutEffect, useState } from "react"; import c from "classnames"; import { Hours } from "./hours.js"; import { DateTime } from "luxon"; import { HoursStatusProps, StatusParams, StatusTemplateParams } from "./types.js"; +const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + function isOpen24h(params: StatusParams): boolean { return params?.currentInterval?.is24h?.() || false; } @@ -115,7 +117,7 @@ const HoursStatus: React.FC = (props) => { // https://reactjs.org/docs/react-dom.html#hydrate const [isClient, setIsClient] = useState(false); - useEffect(() => { + useIsomorphicLayoutEffect(() => { setIsClient(true); }, []); diff --git a/packages/pages-components/src/components/hours/hoursTable.hydration.test.tsx b/packages/pages-components/src/components/hours/hoursTable.hydration.test.tsx new file mode 100644 index 00000000..2f68f87a --- /dev/null +++ b/packages/pages-components/src/components/hours/hoursTable.hydration.test.tsx @@ -0,0 +1,78 @@ +import { hydrateRoot } from "react-dom/client"; +import { renderToString } from "react-dom/server"; +import { act } from "react-dom/test-utils"; +import { afterEach, describe, expect, it } from "vitest"; +import { DateTime, Settings } from "luxon"; +import { HoursTable, ServerSideHoursTable } from "./hoursTable.js"; +import { HoursData } from "./hoursSampleData.js"; + +const originalNow = Settings.now; +const originalDefaultZone = Settings.defaultZone; +const originalResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions; +const originalActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT; + +describe("HoursTable hydration", () => { + afterEach(() => { + Settings.now = originalNow; + Settings.defaultZone = originalDefaultZone; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + originalActEnvironment; + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("hydrates from the server-safe table to the client-aware table without warnings", async () => { + const mockedNow = DateTime.fromObject( + { year: 2025, month: 1, day: 9, hour: 12 }, + { zone: "America/New_York" } + ); + + Settings.now = () => mockedNow.toMillis(); + Settings.defaultZone = "America/New_York"; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockImplementation( + function (this: Intl.DateTimeFormat) { + return { + ...originalResolvedOptions.call(this), + timeZone: "America/New_York", + }; + } + ); + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const serverHtml = renderToString( + + ); + const container = document.createElement("div"); + container.innerHTML = serverHtml; + document.body.appendChild(container); + + const getDayLabels = () => + Array.from(container.querySelectorAll(".HoursTable-day"), (element) => element.textContent); + + expect(getDayLabels()[0]).toBe("Sunday"); + expect(container.querySelector(".HoursTable-row.is-today")).toBeNull(); + + let root: ReturnType | undefined; + + await act(async () => { + root = hydrateRoot(container, ); + await Promise.resolve(); + }); + + const todayRow = container.querySelector(".HoursTable-row.is-today"); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(getDayLabels()[0]).toBe("Thursday"); + expect(todayRow?.querySelector(".HoursTable-day")?.textContent).toBe("Thursday"); + expect(todayRow?.querySelector(".HoursTable-intervals")?.textContent).toContain( + "9:04 AM - 6:04 PM" + ); + + await act(async () => { + root?.unmount(); + }); + }); +}); diff --git a/packages/pages-components/src/components/hours/hoursTable.ssr.test.tsx b/packages/pages-components/src/components/hours/hoursTable.ssr.test.tsx new file mode 100644 index 00000000..3e62bbde --- /dev/null +++ b/packages/pages-components/src/components/hours/hoursTable.ssr.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment node + +import { afterEach, describe, expect, it } from "vitest"; +import { DateTime, Settings } from "luxon"; +import { renderToString } from "react-dom/server"; +import { HoursTable } from "./hoursTable.js"; +import { HoursData } from "./hoursSampleData.js"; + +const originalNow = Settings.now; +const originalDefaultZone = Settings.defaultZone; + +describe("HoursTable SSR", () => { + afterEach(() => { + Settings.now = originalNow; + Settings.defaultZone = originalDefaultZone; + vi.restoreAllMocks(); + }); + + it("renders the server-safe table without SSR warnings", () => { + const mockedNow = DateTime.fromObject( + { year: 2025, month: 1, day: 9, hour: 12 }, + { zone: "America/New_York" } + ); + + Settings.now = () => mockedNow.toMillis(); + Settings.defaultZone = "America/New_York"; + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const html = renderToString(); + const dayLabels = [...html.matchAll(/([^<]+)<\/span>/g)].map( + (match) => match[1] + ); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(html).toContain("HoursTable-row"); + expect(html).not.toContain("is-today"); + expect(dayLabels[0]).toBe("Sunday"); + }); +}); diff --git a/packages/pages-components/src/components/hours/hoursTable.tsx b/packages/pages-components/src/components/hours/hoursTable.tsx index a7e18dcc..a0438504 100644 --- a/packages/pages-components/src/components/hours/hoursTable.tsx +++ b/packages/pages-components/src/components/hours/hoursTable.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useLayoutEffect, useState } from "react"; import c from "classnames"; import "./hoursTable.css"; import { @@ -20,6 +20,8 @@ import { } from "./hours.js"; import { DateTime, WeekdayNumbers } from "luxon"; +const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + /** * * @param hoursDays - HoursTableDayData[] @@ -140,7 +142,7 @@ const HoursTable: React.FC = (props) => { // On the second pass (After the page has been loaded), render the content // https://reactjs.org/docs/react-dom.html#hydrate const [isClient, setIsClient] = useState(false); - useEffect(() => { + useIsomorphicLayoutEffect(() => { setIsClient(true); }, []);