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} + +);