Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion app/web/features/auth/useAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export default function useAuthStore() {
false,
);
const [jailed, setJailed] = usePersistedState("auth.jailed", false);
const [userId, setUserId] = usePersistedState<number | null>(
const [userId, setUserId, , isHydrated] = usePersistedState<number | null>(
"auth.userId",
null,
);
Expand Down Expand Up @@ -221,6 +221,7 @@ export default function useAuthStore() {
loading,
userId,
flowState,
isHydrated,
},
};
}
Expand Down
2 changes: 0 additions & 2 deletions app/web/features/profile/edit/EditProfilePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,6 @@ describe("Edit profile", () => {

const user = userEvent.setup();

await screen.findByText(t("profile:heading.about_me"));

const aboutMeInput = await screen.findByTestId("aboutMe-input");

await user.clear(aboutMeInput);
Expand Down
3 changes: 2 additions & 1 deletion app/web/features/userQueries/useCurrentUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ export default function useCurrentUser() {
const authState = useAuthContext().authState;
const userQuery = useUser(authState.userId ?? undefined);
const router = useRouter();
if (!authState.userId) {
// Only redirect if auth has been hydrated from storage and there's still no userId
if (authState.isHydrated && !authState.userId) {
console.error("No user id available to get current user.");
if (typeof window !== "undefined") router.push(loginRoute);
}
Expand Down
141 changes: 141 additions & 0 deletions app/web/platform/usePersistedState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { act, renderHook } from "@testing-library/react";

import { usePersistedState } from "./usePersistedState";

describe("usePersistedState", () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});

describe("localStorage (default)", () => {
it("returns default value when localStorage is empty", () => {
const { result } = renderHook(() =>
usePersistedState("test-key", "default"),
);

const [value, , , isHydrated] = result.current;
expect(value).toBe("default");
// After effect runs, isHydrated is true
expect(isHydrated).toBe(true);
});

it("loads value from localStorage after hydration", () => {
localStorage.setItem("test-key", JSON.stringify("stored-value"));

const { result } = renderHook(() =>
usePersistedState("test-key", "default"),
);

// After hydration, value should come from localStorage
expect(result.current[0]).toBe("stored-value");
expect(result.current[3]).toBe(true);
});

it("persists value to localStorage when setState is called", () => {
const { result } = renderHook(() =>
usePersistedState("test-key", "default"),
);

act(() => {
result.current[1]("new-value");
});

expect(result.current[0]).toBe("new-value");
expect(localStorage.getItem("test-key")).toBe(
JSON.stringify("new-value"),
);
});

it("clears value from localStorage when clearState is called", () => {
localStorage.setItem("test-key", JSON.stringify("stored-value"));

const { result } = renderHook(() =>
usePersistedState("test-key", "default"),
);

act(() => {
result.current[2]();
});

expect(result.current[0]).toBeUndefined();
expect(localStorage.getItem("test-key")).toBeNull();
});
});

describe("sessionStorage", () => {
it("reads from sessionStorage immediately and isHydrated=true", () => {
sessionStorage.setItem("test-key", JSON.stringify("session-value"));

const { result } = renderHook(() =>
usePersistedState("test-key", "default", "sessionStorage"),
);

const [value, , , isHydrated] = result.current;
// sessionStorage is read synchronously, so value and isHydrated are ready immediately
expect(value).toBe("session-value");
expect(isHydrated).toBe(true);
});

it("returns default when sessionStorage is empty", () => {
const { result } = renderHook(() =>
usePersistedState("test-key", "default", "sessionStorage"),
);

expect(result.current[0]).toBe("default");
expect(result.current[3]).toBe(true);
});
});

describe("hydration behavior", () => {
/**
* This test documents the key architectural difference:
*
* - localStorage: The initial useState value is always the default, NOT the
* stored value. The stored value is loaded via useEffect after hydration.
* This prevents React hydration mismatches where SSR renders with default
* but client would render with stored value.
*
* - sessionStorage: Reads immediately since it's per-tab data that doesn't
* exist during SSR anyway.
*
* Note: In React Testing Library, effects run synchronously after render,
* so we can't observe the intermediate isHydrated=false state directly.
* The real SSR scenario is: server renders with default -> client hydrates
* with default (matching server) -> effect runs and loads actual value.
*/
it("localStorage defers reading to useEffect for SSR compatibility", () => {
localStorage.setItem("local-key", JSON.stringify("local-value"));
sessionStorage.setItem("session-key", JSON.stringify("session-value"));

const localResult = renderHook(() =>
usePersistedState("local-key", "default", "localStorage"),
);

const sessionResult = renderHook(() =>
usePersistedState("session-key", "default", "sessionStorage"),
);

// After effects run, both have their stored values and are hydrated
expect(localResult.result.current[0]).toBe("local-value");
expect(localResult.result.current[3]).toBe(true);

expect(sessionResult.result.current[0]).toBe("session-value");
expect(sessionResult.result.current[3]).toBe(true);
});

it("exposes isHydrated flag for consumers to gate logic", () => {
// The isHydrated flag allows consumers (like useCurrentUser) to wait
// for localStorage to be loaded before making decisions like redirects
const { result } = renderHook(() =>
usePersistedState<number | null>("userId", null),
);

// After hydration, isHydrated is true and value is loaded
expect(result.current[3]).toBe(true);

// Consumer can now safely check if userId is null (meaning not logged in)
// vs null because we haven't loaded from localStorage yet
});
});
});
37 changes: 28 additions & 9 deletions app/web/platform/usePersistedState.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import {
clearState as nativeLinkClearState,
sendState,
Expand All @@ -10,13 +10,32 @@ export function usePersistedState<T>(
key: string,
defaultValue: T,
storage: StorageType = "localStorage",
): [T | undefined, (value: T) => void, () => void] {
// in ssr, window doesn't exist, just use default
const saved =
typeof window !== "undefined" ? window[storage].getItem(key) : null;
const [_state, _setState] = useState<T | undefined>(
saved !== null ? JSON.parse(saved) : defaultValue,
);
): [T | undefined, (value: T) => void, () => void, boolean] {
// For sessionStorage: read synchronously (no SSR concern, data is per-tab)
// For localStorage: defer to useEffect to avoid hydration mismatch
const getInitialValue = (): T | undefined => {
if (typeof window === "undefined") return defaultValue;
if (storage === "sessionStorage") {
const saved = window.sessionStorage.getItem(key);
return saved !== null ? JSON.parse(saved) : defaultValue;
}
return defaultValue;
};

const [_state, _setState] = useState<T | undefined>(getInitialValue);
const [isHydrated, setIsHydrated] = useState(storage === "sessionStorage");

useEffect(() => {
// For localStorage: sync state from storage after hydration
if (storage === "localStorage") {
const saved = window.localStorage.getItem(key);
if (saved !== null) {
_setState(JSON.parse(saved));
}
setIsHydrated(true);
}
}, [key, storage]);

const setState = useCallback(
(value: T) => {
if (value === undefined) {
Expand All @@ -34,7 +53,7 @@ export function usePersistedState<T>(
nativeLinkClearState(key);
_setState(undefined);
}, [key, storage]);
return [_state, setState, clearState];
return [_state, setState, clearState, isHydrated];
}

export function clearStorage() {
Expand Down
Loading