diff --git a/packages/kerosene-ui/package.json b/packages/kerosene-ui/package.json
index e817dbf..23aed63 100644
--- a/packages/kerosene-ui/package.json
+++ b/packages/kerosene-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@kablamo/kerosene-ui",
- "version": "0.0.33",
+ "version": "0.0.34",
"repository": "https://github.com/KablamoOSS/kerosene/tree/master/packages/kerosene-ui",
"bugs": {
"url": "https://github.com/KablamoOSS/kerosene/issues"
diff --git a/packages/kerosene-ui/readme.md b/packages/kerosene-ui/readme.md
index d6681d3..2887031 100644
--- a/packages/kerosene-ui/readme.md
+++ b/packages/kerosene-ui/readme.md
@@ -14,6 +14,10 @@ Context Provider for the CurrentTimeEmitter used internally by the `useCurrentTi
### ``
+### ``
+
+Context Provider for the `useTimeZone` hook. May be used to override the default `"Etc/UTC"` `timeZone` value during SSR and hydration.
+
## Utility Types
### `UnwrapComponent`
@@ -96,6 +100,12 @@ Custom hook which allows reading/writing of `sessionStorage` in a manner similar
Custom hook which provides a stable identity between renders for `value` which is equal to the previous value according to the `isEqual` function.
+### `useTimeZone()`
+
+Custom hook which returns the current `timeZone`.
+
+Defaults to `"Etc/UTC"` during SSR and hydration, but this may be overriden with a provider ``. Ensure that the value used during SSR and hydration is the same.
+
### `useUpdatingRef(value)`
Custom hook which creates a ref to the `value` which is kept up-to-date after each render in `useLayoutEffect`.
diff --git a/packages/kerosene-ui/src/hooks/useTimeZone.spec.tsx b/packages/kerosene-ui/src/hooks/useTimeZone.spec.tsx
new file mode 100644
index 0000000..8f460ac
--- /dev/null
+++ b/packages/kerosene-ui/src/hooks/useTimeZone.spec.tsx
@@ -0,0 +1,75 @@
+import { act, renderHook } from "@testing-library/react";
+import { identity } from "lodash";
+import * as React from "react";
+import useTimeZone, { TimeZoneProvider } from "./useTimeZone";
+
+describe("useTimeZone", () => {
+ let onRender: jest.Mock;
+ let resolvedOptions: jest.SpiedFunction<
+ Intl.DateTimeFormat["resolvedOptions"]
+ >;
+ beforeEach(() => {
+ window.ontimezonechange = null;
+ onRender = jest.fn().mockImplementation(identity);
+ resolvedOptions = jest.spyOn(
+ Intl.DateTimeFormat.prototype,
+ "resolvedOptions",
+ );
+ resolvedOptions.mockReturnValue({
+ timeZone: "Australia/Sydney",
+ } as Intl.ResolvedDateTimeFormatOptions);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it("should return Australia/Sydney", () => {
+ const { result } = renderHook(() => useTimeZone());
+ expect(result.current).toBe("Australia/Sydney");
+ });
+
+ it("should return Etc/UTC during hydration", () => {
+ renderHook(() => onRender(useTimeZone()), { hydrate: true });
+ expect(onRender).toHaveBeenNthCalledWith(1, "Etc/UTC");
+ expect(onRender).toHaveBeenLastCalledWith("Australia/Sydney");
+ });
+
+ it("should return ssrTimeZone from context during hydration", () => {
+ renderHook(() => onRender(useTimeZone()), {
+ hydrate: true,
+ wrapper({ children }) {
+ return (
+
+ {children}
+
+ );
+ },
+ });
+ expect(onRender).toHaveBeenNthCalledWith(1, "Australia/Brisbane");
+ expect(onRender).toHaveBeenLastCalledWith("Australia/Sydney");
+ });
+
+ it("should response to timezonechange events", () => {
+ const { result } = renderHook(() => useTimeZone());
+ resolvedOptions.mockReturnValue({
+ timeZone: "Australia/Brisbane",
+ } as Intl.ResolvedDateTimeFormatOptions);
+ act(() => {
+ window.dispatchEvent(new Event("timezonechange"));
+ });
+ expect(result.current).toBe("Australia/Brisbane");
+ });
+
+ it("should response to visibilitychange events when ontimezonechange is not supported", () => {
+ delete window.ontimezonechange;
+ const { result } = renderHook(() => useTimeZone());
+ resolvedOptions.mockReturnValue({
+ timeZone: "Australia/Brisbane",
+ } as Intl.ResolvedDateTimeFormatOptions);
+ act(() => {
+ document.dispatchEvent(new Event("visibilitychange"));
+ });
+ expect(result.current).toBe("Australia/Brisbane");
+ });
+});
diff --git a/packages/kerosene-ui/src/hooks/useTimeZone.tsx b/packages/kerosene-ui/src/hooks/useTimeZone.tsx
new file mode 100644
index 0000000..ac4f1d7
--- /dev/null
+++ b/packages/kerosene-ui/src/hooks/useTimeZone.tsx
@@ -0,0 +1,69 @@
+import * as React from "react";
+import { useSyncExternalStore } from "use-sync-external-store/shim";
+
+declare global {
+ interface WindowEventHandlersEventMap {
+ timezonechange: Event;
+ }
+
+ interface WindowEventHandlers {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ontimezonechange?: ((this: Window, ev: Event) => any) | null;
+ }
+}
+
+function subscribe(callback: () => void): () => void {
+ // @see https://github.com/whatwg/html/pull/3047
+ if ("ontimezonechange" in window) {
+ window.addEventListener("timezonechange", callback);
+ return () => {
+ window.removeEventListener("timezonechange", callback);
+ };
+ }
+
+ // If the "timezonechange" event is not supported, use "visibilitychange" as a proxy
+ document.addEventListener("visibilitychange", callback);
+ return () => document.removeEventListener("visibilitychange", callback);
+}
+
+function getSnapshot() {
+ return new Intl.DateTimeFormat().resolvedOptions().timeZone;
+}
+
+const SSRTimeZoneContext = React.createContext("Etc/UTC");
+
+/**
+ * Custom hook which returns the current `timeZone`.
+ *
+ * Defaults to `"Etc/UTC"` during SSR and hydration, but this may be overriden with a provider
+ * ``. Ensure that the value used during SSR and hydration is the same.
+ * @returns IANA tz database identifier
+ * @see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
+ */
+export default function useTimeZone() {
+ const ssrTimeZone = React.useContext(SSRTimeZoneContext);
+ const getServerSnapshot = React.useCallback(() => ssrTimeZone, [ssrTimeZone]);
+
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
+
+export interface TimeZoneProviderProps {
+ children?: React.ReactNode;
+ /** IANA tz database identifier */
+ ssrTimeZone: string;
+}
+
+/**
+ * Context Provider for the `useTimeZone` hook. May be used to override the default `"Etc/UTC"` `timeZone` value during
+ * SSR and hydration.
+ * @param props.children
+ * @param props.ssrTimeZone IANA tz database identifier
+ */
+export const TimeZoneProvider = ({
+ children,
+ ssrTimeZone,
+}: TimeZoneProviderProps) => (
+
+ {children}
+
+);