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);
}, []);