Skip to content

Commit 2eb5df9

Browse files
tyler-daneclaude
andcommitted
refactor(web): remove rxjs in favor of existing tools
rxjs was used in packages/web for only three small, self-contained jobs, but pulled in a full reactive-streams framework (BehaviorSubject, pipe, switchMap/iif/distinctUntilChanged/share, ...) that made these one-off use cases harder to read for contributors who never otherwise touch rxjs. Replace each usage with the most appropriate tool already in the codebase: - Module state observed by React -> a tiny createExternalStore helper read via React's built-in useSyncExternalStore. distinctUntilChanged (Object.is guard), skip(1) (no replay on subscribe), and share (snapshot-based re-render) all fall out of the store contract for free. - Fire-and-forget event buses (domMovement, Session events) -> eventemitter2, already used elsewhere via @compass/core. - Internal, non-React state (pointerdown/selectionStart, gridOrganization) -> plain module state with accessors. - useMovementEvent's pause pipeline (switchMap/iif/NEVER) -> window listeners plus a useRef gate; pausing only ever needed to gate the live listener. - lastValueFrom(timer(10)) -> a setTimeout promise. Removes the rxjs dependency from packages/web/package.json. Behavior is unchanged; all 1051 web tests, type-check, and lint pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9800591 commit 2eb5df9

25 files changed

Lines changed: 310 additions & 326 deletions

bun.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/web/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
"react-toastify": "^9.1.3",
3434
"redux": "^4.1.1",
3535
"redux-saga": "^1.1.3",
36-
"rxjs": "^7.8.0",
3736
"supertokens-web-js": "^0.16.0"
3837
},
3938
"devDependencies": {

packages/web/src/auth/compass/session/SessionProvider.test.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { act, renderHook, waitFor } from "@testing-library/react";
22
import { useContext } from "react";
3-
import { Subject } from "rxjs";
43
import { authSlice } from "@web/ducks/auth/slices/auth.slice";
54
import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice";
65
import { beforeEach, describe, expect, it, mock } from "bun:test";
@@ -22,7 +21,7 @@ const shouldShowAnonymousCalendarChangeSignUpPrompt = mock(() => false);
2221
const subscribeToAuthState = mock();
2322
const updateAuthState = mock();
2423
const doesSessionExist = mock();
25-
const events$ = new Subject<{ action: string }>();
24+
const eventListeners = new Set<(event: { action: string }) => void>();
2625
const mockRecipeInit = mock(() => ({}));
2726
const mockSuperTokensInit = mock(() => ({}));
2827

@@ -83,9 +82,15 @@ mock.module("@web/auth/compass/state/auth.state.util", () => ({
8382
mock.module("@web/common/classes/Session", () => ({
8483
session: {
8584
doesSessionExist,
86-
events: events$,
85+
onAnyEvent: (listener: (event: { action: string }) => void) => {
86+
eventListeners.add(listener);
87+
88+
return () => eventListeners.delete(listener);
89+
},
8790
emit: (_action: string, payload: unknown) =>
88-
events$.next(payload as { action: string }),
91+
eventListeners.forEach((listener) =>
92+
listener(payload as { action: string }),
93+
),
8994
on: mock(),
9095
off: mock(),
9196
signOut: mock().mockResolvedValue(undefined),
@@ -96,7 +101,7 @@ mock.module("@web/common/classes/Session", () => ({
96101
const { session } = require("@web/common/classes/Session") as {
97102
session: {
98103
doesSessionExist: ReturnType<typeof mock>;
99-
events: Subject<{ action: string }>;
104+
onAnyEvent: (listener: (event: { action: string }) => void) => () => void;
100105
emit: (action: string, payload: unknown) => void;
101106
on: ReturnType<typeof mock>;
102107
off: ReturnType<typeof mock>;

packages/web/src/auth/compass/session/SessionProvider.tsx

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
import { type PropsWithChildren, useEffect, useState } from "react";
2-
import { BehaviorSubject } from "rxjs";
3-
import {
4-
distinctUntilChanged,
5-
distinctUntilKeyChanged,
6-
skip,
7-
} from "rxjs/operators";
1+
import { type PropsWithChildren, useEffect, useSyncExternalStore } from "react";
82
import SuperTokens from "supertokens-web-js";
93
import EmailPassword from "supertokens-web-js/recipe/emailpassword";
104
import EmailVerification from "supertokens-web-js/recipe/emailverification";
@@ -18,6 +12,7 @@ import {
1812
import { session } from "@web/common/classes/Session";
1913
import { ENV_WEB } from "@web/common/constants/env.constants";
2014
import { ROOT_ROUTES } from "@web/common/constants/routes";
15+
import { createExternalStore } from "@web/common/utils/external-store.util";
2116
import { authSlice } from "@web/ducks/auth/slices/auth.slice";
2217
import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice";
2318
import * as sse from "@web/sse/provider/SSEProvider";
@@ -47,15 +42,13 @@ SuperTokens.init({
4742
],
4843
});
4944

50-
const authenticated$ = new BehaviorSubject(false);
45+
const authStore = createExternalStore(false);
5146
let isCheckingSession = false;
5247
let isSessionInitialized = false;
5348
let sessionEventVersion = 0;
5449

55-
const $authenticated = authenticated$.pipe(skip(1), distinctUntilChanged());
56-
5750
const handleAuthenticatedSession = () => {
58-
authenticated$.next(true);
51+
authStore.set(true);
5952
markUserAsAuthenticated(getLastKnownEmail());
6053
void refreshUserMetadata();
6154
};
@@ -68,7 +61,7 @@ const handleSessionExists = () => {
6861
};
6962

7063
const handleSessionMissing = () => {
71-
authenticated$.next(false);
64+
authStore.set(false);
7265
store.dispatch(authSlice.actions.resetAuth());
7366
store.dispatch(userMetadataSlice.actions.clear(undefined));
7467
clearGoogleSyncIndicatorOverride();
@@ -81,7 +74,7 @@ async function checkIfSessionExists(): Promise<boolean> {
8174
return false;
8275
}
8376

84-
if (isCheckingSession) return authenticated$.value;
77+
if (isCheckingSession) return authStore.get();
8578

8679
isCheckingSession = true;
8780
const eventVersionAtCheckStart = sessionEventVersion;
@@ -90,7 +83,7 @@ async function checkIfSessionExists(): Promise<boolean> {
9083
const exists = await session.doesSessionExist();
9184

9285
if (sessionEventVersion !== eventVersionAtCheckStart) {
93-
return authenticated$.value;
86+
return authStore.get();
9487
}
9588

9689
if (exists) {
@@ -102,7 +95,7 @@ async function checkIfSessionExists(): Promise<boolean> {
10295
return exists;
10396
} catch (error) {
10497
console.error("Error checking auth status:", error);
105-
authenticated$.next(false);
98+
authStore.set(false);
10699
return false;
107100
} finally {
108101
isCheckingSession = false;
@@ -117,8 +110,15 @@ export function sessionInit() {
117110
isSessionInitialized = true;
118111
void checkIfSessionExists();
119112

113+
// Dedupe consecutive events that share the same action (was
114+
// `distinctUntilKeyChanged("action")`).
115+
let lastAction: string | undefined;
116+
120117
// No need to unsubscribe as this runs for the lifetime of the app
121-
session.events.pipe(distinctUntilKeyChanged("action")).subscribe((e) => {
118+
session.onAnyEvent((e) => {
119+
if (e.action === lastAction) return;
120+
lastAction = e.action;
121+
122122
switch (e.action) {
123123
case "REFRESH_SESSION":
124124
case "SESSION_CREATED":
@@ -141,21 +141,16 @@ export function sessionInit() {
141141
}
142142

143143
export function SessionProvider({ children }: PropsWithChildren<object>) {
144-
const [authenticated, setAuthenticated] = useState(authenticated$.value);
145-
146-
useEffect(() => {
147-
const authSub = $authenticated.subscribe(setAuthenticated);
148-
149-
return () => {
150-
authSub.unsubscribe();
151-
};
152-
}, []);
144+
const authenticated = useSyncExternalStore(
145+
authStore.subscribe,
146+
authStore.get,
147+
);
153148

154149
// Expose test hooks for e2e testing
155150
useEffect(() => {
156151
if (typeof window !== "undefined" && window.__COMPASS_E2E_TEST__) {
157152
window.__COMPASS_E2E_HOOKS__ = {
158-
setAuthenticated: (value: boolean) => authenticated$.next(value),
153+
setAuthenticated: (value: boolean) => authStore.set(value),
159154
};
160155
}
161156
}, []);
@@ -164,7 +159,7 @@ export function SessionProvider({ children }: PropsWithChildren<object>) {
164159
<SessionContext.Provider
165160
value={{
166161
authenticated,
167-
setAuthenticated: (value: boolean) => authenticated$.next(value),
162+
setAuthenticated: (value: boolean) => authStore.set(value),
168163
}}
169164
>
170165
{children}

packages/web/src/common/classes/Session.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { EventEmitter2, type ListenerFn } from "eventemitter2";
2-
import { fromEventPattern } from "rxjs";
32
import SuperTokensSession from "supertokens-web-js/recipe/session";
43
import { type Event } from "supertokens-website/lib/build/types";
54

@@ -29,19 +28,16 @@ class Session {
2928
getAccessTokenPayloadSecurely =
3029
SuperTokensSession.getAccessTokenPayloadSecurely;
3130

32-
#handleAllEvents(handler: ListenerFn) {
33-
this.#emitter.addListener("*", handler);
34-
}
31+
/**
32+
* Subscribe to every emitted session event (wildcard listener).
33+
* Returns an unsubscribe function.
34+
*/
35+
onAnyEvent(listener: (event: Event) => void): () => void {
36+
this.#emitter.addListener("*", listener as ListenerFn);
3537

36-
#removeAllEventsHandler(handler: ListenerFn) {
37-
this.#emitter.removeListener("*", handler);
38+
return () => this.#emitter.removeListener("*", listener as ListenerFn);
3839
}
3940

40-
events = fromEventPattern<Event>(
41-
this.#handleAllEvents.bind(this),
42-
this.#removeAllEventsHandler.bind(this),
43-
);
44-
4541
emit(event: Event["action"], payload: Event) {
4642
this.#emitter.emit(event, payload);
4743
}

packages/web/src/common/context/pointer-position.test.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
22
import { type ReactNode } from "react";
33
import { ID_GRID_MAIN, ID_ROOT } from "@web/common/constants/web.constants";
44
import {
5-
cursor$,
5+
cursorStore,
66
PointerPositionProvider,
7-
pointerState$,
7+
pointerStateStore,
88
} from "@web/common/context/pointer-position";
99
import { useSetupMovementEvents } from "@web/common/hooks/useMovementEvent";
1010
import {
11-
pointerdown$,
12-
selectionStart$,
11+
setPointerDown,
12+
setSelectionStart,
1313
} from "@web/common/utils/dom/event-emitter.util";
1414
import { beforeEach, describe, expect, it, mock } from "bun:test";
1515

@@ -25,10 +25,10 @@ function renderWithPointerProvider(children: ReactNode) {
2525

2626
describe("PointerPositionProvider", () => {
2727
beforeEach(() => {
28-
pointerdown$.next(false);
29-
selectionStart$.next(null);
30-
cursor$.next({ x: 0, y: 0 });
31-
pointerState$.next({
28+
setPointerDown(false);
29+
setSelectionStart(null);
30+
cursorStore.set({ x: 0, y: 0 });
31+
pointerStateStore.set({
3232
event: new PointerEvent("none", { button: 1 }) as never,
3333
pointerdown: false,
3434
selectionStart: null,
@@ -65,8 +65,8 @@ describe("PointerPositionProvider", () => {
6565
});
6666

6767
await waitFor(() => {
68-
expect(cursor$.getValue()).toEqual({ x: 100, y: 130 });
69-
expect(pointerState$.getValue()).toEqual(
68+
expect(cursorStore.get()).toEqual({ x: 100, y: 130 });
69+
expect(pointerStateStore.get()).toEqual(
7070
expect.objectContaining({
7171
event: expect.objectContaining({ type: "pointermove" }),
7272
isOverGrid: true,

packages/web/src/common/context/pointer-position.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
type PropsWithChildren,
55
useCallback,
66
} from "react";
7-
import { BehaviorSubject } from "rxjs";
87
import {
98
COLUMN_MONTH,
109
COLUMN_WEEK,
@@ -18,6 +17,7 @@ import {
1817
type DomMovement,
1918
getElementAtPoint,
2019
} from "@web/common/utils/dom/event-emitter.util";
20+
import { createExternalStore } from "@web/common/utils/external-store.util";
2121

2222
export interface PointerState {
2323
event: PointerEvent;
@@ -35,12 +35,12 @@ interface PointerPosition {
3535
togglePointerMovementTracking: (pauseTracking?: boolean) => void;
3636
}
3737

38-
export const cursor$ = new BehaviorSubject<Pick<DomMovement, "x" | "y">>({
38+
export const cursorStore = createExternalStore<Pick<DomMovement, "x" | "y">>({
3939
x: 0,
4040
y: 0,
4141
});
4242

43-
export const pointerState$ = new BehaviorSubject<PointerState>({
43+
export const pointerStateStore = createExternalStore<PointerState>({
4444
event: new MouseEvent("none", { button: 1 }) as unknown as PointerEvent,
4545
pointerdown: false,
4646
selectionStart: null,
@@ -82,7 +82,7 @@ export function getPointerPosition(): Pick<
8282
PointerEvent,
8383
"clientX" | "clientY"
8484
> {
85-
const { x: clientX, y: clientY } = cursor$.getValue();
85+
const { x: clientX, y: clientY } = cursorStore.get();
8686

8787
return { clientX, clientY };
8888
}
@@ -132,15 +132,15 @@ export function isOverCalendarGrid(element = getElementAtPointer()) {
132132
export function PointerPositionProvider({ children }: PropsWithChildren) {
133133
const handler = useCallback(
134134
({ x, y, element, pointerdown, selectionStart, event }: DomMovement) => {
135-
cursor$.next({ x, y });
135+
cursorStore.set({ x, y });
136136
const overSidebar = isOverSidebar(element);
137137
const overSomedayWeek = isOverSomedayWeek(element);
138138
const overSomedayMonth = isOverSomedayMonth(element);
139139
const overAllDayRow = isOverAllDayRow(element);
140140
const overMainGrid = isOverMainGrid(element);
141141
const overCalendarGrid = isOverCalendarGrid(element);
142142

143-
pointerState$.next({
143+
pointerStateStore.set({
144144
event,
145145
pointerdown,
146146
selectionStart,

packages/web/src/common/hooks/useCursorCoordinates.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { renderHook } from "@testing-library/react";
22
import { act } from "react";
3-
import { cursor$ } from "@web/common/context/pointer-position";
3+
import { cursorStore } from "@web/common/context/pointer-position";
44
import { useCursorCoordinates } from "./useCursorCoordinates";
55

66
describe("useCursorCoordinates", () => {
77
beforeEach(() => {
88
// Reset to a known state
99
act(() => {
10-
cursor$.next({ x: 0, y: 0 });
10+
cursorStore.set({ x: 0, y: 0 });
1111
});
1212
});
1313

@@ -16,17 +16,17 @@ describe("useCursorCoordinates", () => {
1616
expect(result.current).toEqual({ x: 0, y: 0 });
1717
});
1818

19-
it("should update coordinates when cursor$ emits new values", () => {
19+
it("should update coordinates when the cursor store emits new values", () => {
2020
const { result } = renderHook(() => useCursorCoordinates());
2121

2222
act(() => {
23-
cursor$.next({ x: 100, y: 200 });
23+
cursorStore.set({ x: 100, y: 200 });
2424
});
2525

2626
expect(result.current).toEqual({ x: 100, y: 200 });
2727

2828
act(() => {
29-
cursor$.next({ x: 50, y: 50 });
29+
cursorStore.set({ x: 50, y: 50 });
3030
});
3131

3232
expect(result.current).toEqual({ x: 50, y: 50 });
Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,6 @@
1-
import { useEffect, useState } from "react";
2-
import { cursor$ } from "@web/common/context/pointer-position";
1+
import { useSyncExternalStore } from "react";
2+
import { cursorStore } from "@web/common/context/pointer-position";
33

44
export function useCursorCoordinates() {
5-
const cursor = cursor$.getValue();
6-
const [x, setX] = useState<number>(cursor.x);
7-
const [y, setY] = useState<number>(cursor.y);
8-
9-
useEffect(() => {
10-
const subscription = cursor$.subscribe((value) => {
11-
setX(value.x);
12-
setY(value.y);
13-
});
14-
15-
return () => subscription.unsubscribe();
16-
}, []);
17-
18-
return { x, y };
5+
return useSyncExternalStore(cursorStore.subscribe, cursorStore.get);
196
}

0 commit comments

Comments
 (0)