Skip to content
Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
edb3d1e
fix: migrate over time changes
Parkreiner Apr 28, 2025
39d8641
Merge branch 'main' into mes/use-time-2
Parkreiner Apr 28, 2025
93f85c4
docs: add one more todo
Parkreiner Apr 28, 2025
ba92d78
fix: resolve memoization wonkiness
Parkreiner Apr 29, 2025
e642293
docs: rewrite comment for clarity
Parkreiner Apr 29, 2025
1bc9463
refactor: slight cleanup
Parkreiner Apr 29, 2025
ee76345
fix: revamp the API design to remove risks of infinite renders
Parkreiner Apr 30, 2025
0ca593b
fix: beef up subscription update method
Parkreiner Apr 30, 2025
fc0694c
fix: add back logic that was removed during merge
Parkreiner Apr 30, 2025
ebe8821
chore: add provider to base Storybook meta
Parkreiner Apr 30, 2025
90f2146
fix: add decorator to right place
Parkreiner Apr 30, 2025
532ec85
fix: remove ghost import
Parkreiner Apr 30, 2025
8c5c49d
fix: get rid of UMD import warnings
Parkreiner Apr 30, 2025
30283f0
Merge branch 'main' into mes/use-time-2
Parkreiner May 7, 2025
9f258a2
Merge branch 'mes/use-time-2' of https://github.com/coder/coder into …
Parkreiner May 7, 2025
62e5f8e
refactor: split up TimeSync into vanilla file and React file
Parkreiner May 7, 2025
60b2220
fix: move resync option back to base class
Parkreiner May 7, 2025
62b6f6e
chore: flesh out more of React class
Parkreiner May 7, 2025
fe8b3ef
fix: get initial solution in place for fixing stale closure issues
Parkreiner May 7, 2025
9d8671e
refactor: make class less confusing
Parkreiner May 7, 2025
997c3da
chore: format
Parkreiner May 7, 2025
d0a57de
docs: add more info about properties
Parkreiner May 7, 2025
da5803d
refactor: split useTimeSync into two hooks
Parkreiner May 7, 2025
08fda7b
fix: add back type-checking
Parkreiner May 7, 2025
5233fb8
chore: finish initial implementation of onSubscriptionAdd
Parkreiner May 7, 2025
93cf58a
fix: make error case more clear
Parkreiner May 9, 2025
5ba068a
docs: add notes about potential other approach for hook
Parkreiner May 9, 2025
f352e3b
fix: remove dependencies from hook API
Parkreiner May 9, 2025
b5a6870
refactor: clean up code
Parkreiner May 9, 2025
10fbdfc
refactor: make hook less confusing
Parkreiner May 9, 2025
d7c70e2
fix: resolve bug when syncing new interval
Parkreiner May 9, 2025
0ffe2af
fix: remove race condition in caching logic
Parkreiner May 9, 2025
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
27 changes: 25 additions & 2 deletions site/.storybook/preview.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@
* Storybook decorator function used to inject baseline data dependencies into
* our React components during testing.
*/
import React from "react";
import "../src/index.css";
import { ThemeProvider as EmotionThemeProvider } from "@emotion/react";
import CssBaseline from "@mui/material/CssBaseline";
import {
ThemeProvider as MuiThemeProvider,
StyledEngineProvider,
// biome-ignore lint/nursery/noRestrictedImports: we extend the MUI theme
} from "@mui/material/styles";
import { DecoratorHelpers } from "@storybook/addon-themes";
import isChromatic from "chromatic/isChromatic";
Expand All @@ -31,12 +31,19 @@ import { HelmetProvider } from "react-helmet-async";
import { QueryClient, QueryClientProvider, parseQueryArgs } from "react-query";
import { withRouter } from "storybook-addon-remix-react-router";
import "theme/globalFonts";
import { TimeSyncProvider } from "../src/hooks/useTimeSync";
import themes from "../src/theme";

DecoratorHelpers.initializeThemeState(Object.keys(themes), "dark");

/** @type {readonly Decorator[]} */
export const decorators = [withRouter, withQuery, withHelmet, withTheme];
export const decorators = [
withRouter,
withQuery,
withHelmet,
withTheme,
withTimeSyncProvider,
];

/** @type {Preview["parameters"]} */
export const parameters = {
Expand Down Expand Up @@ -101,6 +108,22 @@ function withHelmet(Story) {
);
}

const storyDate = new Date("March 15, 2022");

/** @type {Decorator} */
function withTimeSyncProvider(Story) {
return (
<TimeSyncProvider
options={{
initialDatetime: storyDate,
resyncOnNewSubscription: false,
}}
>
<Story />
</TimeSyncProvider>
);
}

/** @type {Decorator} */
function withQuery(Story, { parameters }) {
const queryClient = new QueryClient({
Expand Down
2 changes: 1 addition & 1 deletion site/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import "./theme/globalFonts";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { TimeSyncProvider } from "hooks/useTimeSync";
import {
type FC,
type ReactNode,
Expand All @@ -14,7 +15,6 @@ import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar";
import { ThemeProvider } from "./contexts/ThemeProvider";
import { AuthProvider } from "./contexts/auth/AuthProvider";
import { router } from "./router";
import { TimeSyncProvider } from "hooks/useTimeSync";

const defaultQueryClient = new QueryClient({
defaultOptions: {
Expand Down
2 changes: 1 addition & 1 deletion site/src/components/SignInLayout/SignInLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { FC, PropsWithChildren } from "react";

export const SignInLayout: FC<PropsWithChildren> = ({ children }) => {
const year = useTimeSync({
maxRefreshIntervalMs: Number.POSITIVE_INFINITY,
idealRefreshIntervalMs: Number.POSITIVE_INFINITY,
select: (date) => date.getFullYear(),
});

Expand Down
155 changes: 96 additions & 59 deletions site/src/hooks/useTimeSync.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,36 @@
/**
* @todo Things that still need to be done before this can be called done:
* 1. Finish up the interval reconciliation method
*
* 1. Revamp the entire class definition, and fill out all missing methods
* 2. Update the class to respect the resyncOnNewSubscription option
* 3. Add tests
* 4. See if there's a way to make sure that if you provide a type parameter to
* the hook, you must also provide a select function
*/
import {
type FC,
type PropsWithChildren,
createContext,
useCallback,
useContext,
useId,
useState,
useSyncExternalStore,
type FC,
type PropsWithChildren,
} from "react";
import { useEffectEvent } from "./hookPolyfills";

export const MAX_REFRESH_ONE_SECOND = 1_000;
export const MAX_REFRESH_ONE_MINUTE = 60 * 1_000;
export const MAX_REFRESH_ONE_HOUR = 60 * 60 * 1_000;
export const MAX_REFRESH_ONE_DAY = 24 * 60 * 60 * 1_000;
export const IDEAL_REFRESH_ONE_SECOND = 1_000;
export const IDEAL_REFRESH_ONE_MINUTE = 60 * 1_000;
export const IDEAL_REFRESH_ONE_HOUR = 60 * 60 * 1_000;
export const IDEAL_REFRESH_ONE_DAY = 24 * 60 * 60 * 1_000;

type SetInterval = (fn: () => void, intervalMs: number) => number;
type ClearInterval = (id: number | undefined) => void;

type TimeSyncInitOptions = Readonly<{
/**
* Configures whether adding a new subscription will immediately create a new
* time snapshot and use it to update all other subscriptions.
* Configures whether adding a new subscription will immediately create a
* new time snapshot and use it to update all other subscriptions.
*/
resyncOnNewSubscription: boolean;

Expand All @@ -51,7 +53,7 @@ type TimeSyncInitOptions = Readonly<{
/**
* The function to use when clearing intervals.
*
* (i.e., Clearing a previous interval because the TimeSync needs to make a
* (e.g., Clearing a previous interval because the TimeSync needs to make a
* new interval to increase/decrease its update speed.)
*/
clearInterval: ClearInterval;
Expand All @@ -67,14 +69,16 @@ const defaultOptions: TimeSyncInitOptions = {

type SubscriptionEntry = Readonly<{
id: string;
maxRefreshIntervalMs: number;
idealRefreshIntervalMs: number;
onUpdate: (newDatetime: Date) => void;
select?: (newSnapshot: Date) => unknown;
}>;

interface TimeSyncApi {
getLatestDatetimeSnapshot: () => Date;
subscribe: (entry: SubscriptionEntry) => () => void;
unsubscribe: (id: string) => void;
getTimeSnapshot: () => Date;
getSelectionSnapshot: <T = unknown>(id: string) => T;
}

/**
Expand Down Expand Up @@ -107,8 +111,9 @@ export class TimeSync implements TimeSyncApi {
readonly #createNewDatetime: (prev: Date) => Date;
readonly #setInterval: SetInterval;
readonly #clearInterval: ClearInterval;
readonly #selectionCache: Map<string, unknown>;

#latestSnapshot: Date;
#latestDateSnapshot: Date;
#subscriptions: SubscriptionEntry[];
#latestIntervalId: number | undefined;

Expand All @@ -126,8 +131,9 @@ export class TimeSync implements TimeSyncApi {
this.#createNewDatetime = createNewDatetime;
this.#resyncOnNewSubscription = resyncOnNewSubscription;

this.#latestSnapshot = initialDatetime;
this.#latestDateSnapshot = initialDatetime;
this.#subscriptions = [];
this.#selectionCache = new Map();
this.#latestIntervalId = undefined;
}

Expand All @@ -138,15 +144,17 @@ export class TimeSync implements TimeSyncApi {
}

const prevFastestInterval =
this.#subscriptions[0]?.maxRefreshIntervalMs ?? Number.POSITIVE_INFINITY;
this.#subscriptions[0]?.idealRefreshIntervalMs ??
Number.POSITIVE_INFINITY;
if (this.#subscriptions.length > 1) {
this.#subscriptions.sort(
(e1, e2) => e1.maxRefreshIntervalMs - e2.maxRefreshIntervalMs,
(e1, e2) => e1.idealRefreshIntervalMs - e2.idealRefreshIntervalMs,
);
}

const newFastestInterval =
this.#subscriptions[0]?.maxRefreshIntervalMs ?? Number.POSITIVE_INFINITY;
this.#subscriptions[0]?.idealRefreshIntervalMs ??
Number.POSITIVE_INFINITY;
if (prevFastestInterval === newFastestInterval) {
return;
}
Expand All @@ -160,22 +168,40 @@ export class TimeSync implements TimeSyncApi {
* when/how it should be updated
*/
this.#latestIntervalId = this.#setInterval(() => {
this.#latestSnapshot = this.#createNewDatetime(this.#latestSnapshot);
this.#notifySubscriptions();
this.#latestDateSnapshot = this.#createNewDatetime(
this.#latestDateSnapshot,
);
this.#flushUpdateToSubscriptions();
}, newFastestInterval);
}

#notifySubscriptions(): void {
#flushUpdateToSubscriptions(): void {
for (const subEntry of this.#subscriptions) {
subEntry.onUpdate(this.#latestSnapshot);
if (subEntry.select === undefined) {
subEntry.onUpdate(this.#latestDateSnapshot);
continue;
}

// Keeping things simple by only comparing values React-style with ===.
// If that becomes a problem down the line, we can beef the class up
const prevSelection = this.#selectionCache.get(subEntry.id);
const newSelection = subEntry.select(this.#latestDateSnapshot);
if (prevSelection !== newSelection) {
this.#selectionCache.set(subEntry.id, newSelection);
subEntry.onUpdate(this.#latestDateSnapshot);
}
}
}

// All functions that are part of the public interface must be defined as
// arrow functions, so that they work properly with React

getLatestDatetimeSnapshot = (): Date => {
return this.#latestSnapshot;
getTimeSnapshot = (): Date => {
return this.#latestDateSnapshot;
};

getSelectionSnapshot = <T,>(id: string): T => {
return this.#selectionCache.get(id) as T;
};

unsubscribe = (id: string): void => {
Expand All @@ -189,9 +215,9 @@ export class TimeSync implements TimeSyncApi {
};

subscribe = (entry: SubscriptionEntry): (() => void) => {
if (entry.maxRefreshIntervalMs <= 0) {
if (entry.idealRefreshIntervalMs <= 0) {
throw new Error(
`Refresh interval ${entry.maxRefreshIntervalMs} must be a positive integer (or Infinity)`,
`Refresh interval ${entry.idealRefreshIntervalMs} must be a positive integer (or Infinity)`,
);
}

Expand All @@ -209,7 +235,7 @@ export class TimeSync implements TimeSyncApi {
}

this.#subscriptions[subIndex] = entry;
if (prev.maxRefreshIntervalMs !== entry.maxRefreshIntervalMs) {
if (prev.idealRefreshIntervalMs !== entry.idealRefreshIntervalMs) {
this.#reconcileRefreshIntervals();
}
return unsub;
Expand All @@ -218,15 +244,6 @@ export class TimeSync implements TimeSyncApi {

const timeSyncContext = createContext<TimeSync | null>(null);

function useTimeSyncContext(): TimeSync {
const timeSync = useContext(timeSyncContext);
if (timeSync === null) {
throw new Error("Cannot call useTimeSync outside of a TimeSyncProvider");
}

return timeSync;
}

type TimeSyncProviderProps = Readonly<
PropsWithChildren<{
options?: Partial<TimeSyncInitOptions>;
Expand All @@ -248,7 +265,7 @@ export const TimeSyncProvider: FC<TimeSyncProviderProps> = ({
// Making the TimeSync instance be initialized via React State, so that it's
// easy to mock it out for component and story tests. TimeSync itself should
// be treated like a pseudo-ref value, where its values can only be used in
// very specific, React-approved ways (e.g., not directly in a render path)
// very specific, React-approved ways
const [readonlySync] = useState(
() => new TimeSync(options ?? defaultOptions),
);
Expand All @@ -261,23 +278,20 @@ export const TimeSyncProvider: FC<TimeSyncProviderProps> = ({
};

type UseTimeSyncOptions<T = Date> = Readonly<{
maxRefreshIntervalMs: number;
idealRefreshIntervalMs: number;

/**
* Allows you to transform any date values received from the TimeSync class.
*
* Note that select functions are not memoized and will run on every render
* (similar to the ones in React Query and Redux Toolkit's default selectors).
* Select functions should be kept cheap to recalculate.
* Select functions work similarly to the selects from React Query and Redux
* Toolkit – you don't need to memoize them, and they will only run when the
* underlying TimeSync state has changed.
*
* Select functions must not be async. The hook will error out at the type
* level if you provide one by mistake.
*/
select?: (latestDatetime: Date) => T extends Promise<unknown> ? never : T;
}>;

type ReactSubscriptionCallback = (notifyReact: () => void) => () => void;

/**
* useTimeSync provides React bindings for the TimeSync class, letting a React
* component bind its update lifecycles to interval updates from TimeSync. This
Expand All @@ -294,35 +308,58 @@ type ReactSubscriptionCallback = (notifyReact: () => void) => () => void;
* interval.
*/
export function useTimeSync<T = Date>(options: UseTimeSyncOptions<T>): T {
const { select, maxRefreshIntervalMs } = options;
const { select, idealRefreshIntervalMs } = options;
const timeSync = useContext(timeSyncContext);
if (timeSync === null) {
throw new Error("Cannot call useTimeSync outside of a TimeSyncProvider");
}

// Abusing useId a little bit here. It's mainly meant to be used for
// accessibility, but it also gives us a globally unique ID associated with
// whichever component instance is consuming this hook
const hookId = useId();
const timeSync = useTimeSyncContext();

// We need to define this callback using useCallback instead of useEffectEvent
// because we want the memoization to be invaliated when the refresh interval
// changes. (When the subscription callback changes by reference, that causes
// useSyncExternalStore to redo the subscription with the new callback). All
// other values need to be included in the dependency array for correctness,
// but their memory references should always be stable

// This is the one place where we're borderline breaking the React rules.
// useEffectEvent is meant to be used only in useEffect calls, and normally
// shouldn't be called inside a render. But its behavior lines up with
// useSyncExternalStore, letting us cheat a little. useSyncExternalStore's
// state getter callback is called in two scenarios:
// (1) Mid-render on mount
// (2) Whenever React is notified of a state change (outside of React).
//
// Case 2 is basically an effect with extra steps (and single-threaded JS
// gives us assurance about correctness). And for (1), useEffectEvent will be
// initialized with whatever callback you give it on mount. So for the
// mounting render alone, it's safe to call a useEffectEvent callback from
// inside a render.
const stableSelect = useEffectEvent((date: Date): T => {
const recast = date as Date & T;
return select?.(recast) ?? recast;
});

// We need to define this callback using useCallback instead of
// useEffectEvent because we want the memoization to be invaliated when the
// refresh interval changes. (When the subscription callback changes by
// reference, that causes useSyncExternalStore to redo the subscription with
// the new callback). All other values need to be included in the dependency
// array for correctness, but they should always maintain stable memory
// addresses
type ReactSubscriptionCallback = (notifyReact: () => void) => () => void;
const subscribe = useCallback<ReactSubscriptionCallback>(
(notifyReact) => {
return timeSync.subscribe({
maxRefreshIntervalMs,
onUpdate: notifyReact,
idealRefreshIntervalMs,
id: hookId,
onUpdate: notifyReact,
select: stableSelect,
});
},
[timeSync, hookId, maxRefreshIntervalMs],
[timeSync, hookId, stableSelect, idealRefreshIntervalMs],
);

const currentTime = useSyncExternalStore(subscribe, () =>
timeSync.getLatestDatetimeSnapshot(),
const snapshot = useSyncExternalStore<T>(subscribe, () =>
timeSync.getSelectionSnapshot(hookId),
);

const recast = currentTime as T & Date;
return select?.(recast) ?? recast;
return snapshot;
}
Loading
Loading