Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#9227 Refactor useTheme to use useManagedStorageState #9228

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0ee3c37
add hideSidebarLogo to manatgedStorageSchema
mnholtz Oct 2, 2024
2bcc02e
override showSidebarLogo in useTheme
mnholtz Oct 2, 2024
5d60f92
add unit tests to useTheme
mnholtz Oct 2, 2024
d3db8e9
Merge branch 'main' into feature/9222_hide_sidebar_logo_policy
mnholtz Oct 2, 2024
7b1e90e
fix header button alignment when missing sidebar logo
mnholtz Oct 2, 2024
319fdb6
Merge branch 'feature/9222_hide_sidebar_logo_policy' of https://githu…
mnholtz Oct 2, 2024
afa7f80
refactor rename hideSidebarLogo -> showSidebarLogo
mnholtz Oct 3, 2024
58f65a8
refactor combine test cases
mnholtz Oct 3, 2024
3924d4d
add add custom theme to test cases
mnholtz Oct 3, 2024
ebba63f
deck version number in code comment
mnholtz Oct 3, 2024
4cba229
Merge branch 'main' into feature/9222_hide_sidebar_logo_policy
mnholtz Oct 3, 2024
1370d6a
refactor use useManagedStorageState instead of readManagedStorageByKey
mnholtz Oct 3, 2024
cb2355f
make test cases complete
mnholtz Oct 3, 2024
bcc470c
refactor use useManagedStorageState instead of readManagedStorageByKey
mnholtz Oct 3, 2024
29b5180
rebase fixes
mnholtz Oct 3, 2024
2fabe25
resolve merge conflicts
mnholtz Oct 3, 2024
0b71176
resolve merge issues
mnholtz Oct 3, 2024
9c46ed1
replace useManagedStorageState implementation with useAsyncExternalStore
mnholtz Oct 4, 2024
ce8a6ac
make some test accomodations for hook changes
mnholtz Oct 4, 2024
4ea0d9b
Merge branch 'refs/heads/main' into feature/9227_refactor_use_theme
mnholtz Oct 7, 2024
b584c2a
fix one useManagedStorageState unit test
mnholtz Oct 7, 2024
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
6 changes: 4 additions & 2 deletions src/auth/useRequiredPartnerAuth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import usePartnerAuthData from "@/auth/usePartnerAuthData";
import { Milestones } from "@/data/model/UserMilestone";
import { getDeploymentKey } from "@/auth/deploymentKey";
import { getExtensionToken } from "@/auth/authStorage";
import type { AsyncState } from "@/types/sliceTypes";
import { type ManagedStorageState } from "@/store/enterprise/managedStorageTypes";

jest.mock("@/store/enterprise/useManagedStorageState");
jest.mock("@/auth/usePartnerAuthData");
Expand All @@ -57,7 +59,7 @@ beforeEach(() => {
useManagedStorageStateMock.mockReturnValue({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: use valueToAsyncState from asyncStateUtils

data: {},
isLoading: false,
});
} as AsyncState<ManagedStorageState>);

usePartnerAuthDataMock.mockReturnValue(valueToAsyncState(undefined));
});
Expand Down Expand Up @@ -166,7 +168,7 @@ describe("useRequiredPartnerAuth", () => {
useManagedStorageStateMock.mockReturnValue({
data: { partnerId: "automation-anywhere" },
isLoading: false,
});
} as AsyncState<ManagedStorageState>);

mockAnonymousMeApiResponse();

Expand Down
115 changes: 65 additions & 50 deletions src/hooks/useTheme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,32 @@

import useTheme from "@/hooks/useTheme";
import { renderHook } from "@/pageEditor/testHelpers";
import useAsyncExternalStore from "@/hooks/useAsyncExternalStore";
import { initialTheme } from "@/themes/themeStore";
import { type AsyncState } from "@/types/sliceTypes";
import { themeStorage } from "@/themes/themeUtils";
import { type ThemeAssets, themeStorage } from "@/themes/themeUtils";
import { activateTheme } from "@/background/messenger/api";
import { readManagedStorageByKey } from "@/store/enterprise/managedStorage";
import { readManagedStorage } from "@/store/enterprise/managedStorage";

jest.mock("@/themes/themeUtils", () => ({
...jest.requireActual("@/themes/themeUtils"),
themeStorage: {
get: jest.fn(),
onChanged: jest.fn(),
},
}));

jest.mock("@/store/enterprise/managedStorage", () => ({
...jest.requireActual("@/store/enterprise/managedStorage"),
readManagedStorage: jest.fn(),
}));

afterEach(() => {
jest.clearAllMocks();
});

jest.mock("@/hooks/useAsyncExternalStore");
jest.mock("@/background/messenger/api");
jest.mock("@/store/enterprise/managedStorage");

const customTheme = {
themeName: "custom",
const customTheme: ThemeAssets = {
themeName: "default",
showSidebarLogo: true,
customSidebarLogo: "https://example.com/custom-logo.png",
toolbarIcon: "https://example.com/custom-icon.svg",
Expand All @@ -46,31 +55,31 @@

describe("useTheme", () => {
beforeEach(async () => {
jest
.mocked(useAsyncExternalStore)
.mockReturnValue({ data: initialTheme, isLoading: false } as AsyncState);
// eslint-disable-next-line no-restricted-syntax -- this func requires a parameter
jest.mocked(readManagedStorageByKey).mockResolvedValue(undefined);
jest.mocked(themeStorage.get).mockResolvedValue({
...initialTheme,
lastFetched: Date.now(),
});
jest.mocked(readManagedStorage).mockResolvedValue({});
});

test("calls useAsyncExternalStore and gets current theme state", async () => {
afterEach(() => {
jest.useRealTimers();
});

test("calls themeStorage to get the current theme state", async () => {
const { result: themeResult, waitForNextUpdate } = renderHook(() =>
useTheme(),
);

await waitForNextUpdate();

expect(useAsyncExternalStore).toHaveBeenNthCalledWith(
1,
expect.any(Function),
themeStorage.get,
);
expect(themeStorage.get).toHaveBeenCalledOnce();

expect(themeResult.current).toStrictEqual({
expect(themeResult.current).toMatchObject({
activeTheme: {
themeName: "default",
customSidebarLogo: null,
lastFetched: null,
lastFetched: expect.any(Number),
logo: { regular: "test-file-stub", small: "test-file-stub" },
showSidebarLogo: true,
toolbarIcon: null,
Expand All @@ -81,20 +90,14 @@

it("calls activateTheme after loading is done and it hasn't been called recently", async () => {
jest.useFakeTimers();
renderHook(() => useTheme());

jest.mocked(useAsyncExternalStore).mockReturnValue({
data: { ...initialTheme, lastFetched: Date.now() },
isLoading: false,
} as AsyncState);

let result = renderHook(() => useTheme());
await result.waitForNextUpdate();
expect(activateTheme).not.toHaveBeenCalled();

jest.advanceTimersByTime(125_000);

result = renderHook(() => useTheme());
await result.waitForNextUpdate();
renderHook(() => useTheme());

expect(activateTheme).toHaveBeenCalledOnce();
});

Expand All @@ -108,36 +111,48 @@
])(
"handles showSidebarLogo policy (policy: $policyValue, theme: $themeValue, expected: $expectedValue)",
async ({ policyValue, themeValue, expectedValue }) => {
jest.mocked(useAsyncExternalStore).mockReturnValue({
data: { ...customTheme, showSidebarLogo: themeValue },
isLoading: false,
} as AsyncState);
jest.mocked(readManagedStorageByKey).mockResolvedValue(policyValue);
jest.mocked(themeStorage.get).mockResolvedValue({
...customTheme,
showSidebarLogo: themeValue,
lastFetched: Date.now(),
Comment on lines +114 to +117
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: save the time and use it to validate against:

Suggested change
jest.mocked(themeStorage.get).mockResolvedValue({
...customTheme,
showSidebarLogo: themeValue,
lastFetched: Date.now(),
const lastFetched = Date.now();
jest.mocked(themeStorage.get).mockResolvedValue({
...customTheme,
showSidebarLogo: themeValue,
lastFetched,

});

jest.mocked(readManagedStorage).mockResolvedValue({
showSidebarLogo: policyValue,
});

const { result, waitForNextUpdate } = renderHook(() => useTheme());

await waitForNextUpdate();

Check failure on line 126 in src/hooks/useTheme.test.ts

View workflow job for this annotation

GitHub Actions / test

useTheme › handles showSidebarLogo policy (policy: true

Timed out in waitForNextUpdate after 1000ms. at waitForNextUpdate (node_modules/@testing-library/react-hooks/lib/core/asyncUtils.js:96:13) at src/hooks/useTheme.test.ts:126:7

Check failure on line 126 in src/hooks/useTheme.test.ts

View workflow job for this annotation

GitHub Actions / test

useTheme › handles showSidebarLogo policy (policy: true

Timed out in waitForNextUpdate after 1000ms. at waitForNextUpdate (node_modules/@testing-library/react-hooks/lib/core/asyncUtils.js:96:13) at src/hooks/useTheme.test.ts:126:7

Check failure on line 126 in src/hooks/useTheme.test.ts

View workflow job for this annotation

GitHub Actions / test

useTheme › handles showSidebarLogo policy (policy: false

Timed out in waitForNextUpdate after 1000ms. at waitForNextUpdate (node_modules/@testing-library/react-hooks/lib/core/asyncUtils.js:96:13) at src/hooks/useTheme.test.ts:126:7

Check failure on line 126 in src/hooks/useTheme.test.ts

View workflow job for this annotation

GitHub Actions / test

useTheme › handles showSidebarLogo policy (policy: false

Timed out in waitForNextUpdate after 1000ms. at waitForNextUpdate (node_modules/@testing-library/react-hooks/lib/core/asyncUtils.js:96:13) at src/hooks/useTheme.test.ts:126:7

Check failure on line 126 in src/hooks/useTheme.test.ts

View workflow job for this annotation

GitHub Actions / test

useTheme › handles showSidebarLogo policy (policy: undefined

Timed out in waitForNextUpdate after 1000ms. at waitForNextUpdate (node_modules/@testing-library/react-hooks/lib/core/asyncUtils.js:96:13) at src/hooks/useTheme.test.ts:126:7

Check failure on line 126 in src/hooks/useTheme.test.ts

View workflow job for this annotation

GitHub Actions / test

useTheme › handles showSidebarLogo policy (policy: undefined

Timed out in waitForNextUpdate after 1000ms. at waitForNextUpdate (node_modules/@testing-library/react-hooks/lib/core/asyncUtils.js:96:13) at src/hooks/useTheme.test.ts:126:7

expect(result.current.activeTheme.showSidebarLogo).toBe(expectedValue);
expect(result.current.activeTheme).toMatchObject({
...customTheme,
lastFetched: expect.any(Number),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

showSidebarLogo: expectedValue,
});
},
);

it("uses activeTheme when an error occurs in managed storage", async () => {
jest.mocked(useAsyncExternalStore).mockReturnValue({
data: customTheme,
isLoading: false,
} as AsyncState);
it.each([{ showSidebarLogo: true }, { showSidebarLogo: false }])(
"uses activeTheme when an error occurs in managed storage (showSidebarLogo: $showSidebarLogo)",
async ({ showSidebarLogo }) => {
const customThemeWithSidebarLogo = {
...customTheme,
showSidebarLogo,
lastFetched: Date.now(),
};

jest
.mocked(readManagedStorageByKey)
.mockRejectedValue(new Error("Managed storage error"));
jest
.mocked(themeStorage.get)
.mockResolvedValue(customThemeWithSidebarLogo);

const { result, waitForNextUpdate } = renderHook(() => useTheme());
jest
.mocked(readManagedStorage)
.mockRejectedValue(new Error("Managed storage error"));

await waitForNextUpdate();
const { result } = renderHook(() => useTheme());

expect(result.current.activeTheme.showSidebarLogo).toBe(
customTheme.showSidebarLogo,
);
});
expect(result.current.activeTheme.showSidebarLogo).toBe(showSidebarLogo);

Check failure on line 155 in src/hooks/useTheme.test.ts

View workflow job for this annotation

GitHub Actions / test

useTheme › uses activeTheme when an error occurs in managed storage (showSidebarLogo: false)

expect(received).toBe(expected) // Object.is equality Expected: false Received: true at toBe (src/hooks/useTheme.test.ts:155:58)
},
);
});
9 changes: 5 additions & 4 deletions src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ import {
import { initialTheme } from "@/themes/themeStore";
import useAsyncExternalStore from "@/hooks/useAsyncExternalStore";
import { activateTheme } from "@/background/messenger/api";
import useAsyncState from "@/hooks/useAsyncState";
import { readManagedStorageByKey } from "@/store/enterprise/managedStorage";
import useManagedStorageState from "@/store/enterprise/useManagedStorageState";

const themeStorageSubscribe = (callback: () => void) => {
const abortController = new AbortController();
Expand All @@ -47,8 +46,10 @@ function useTheme(): { activeTheme: ThemeAssets; isLoading: boolean } {
const { data: cachedTheme, isLoading: isCachedThemeLoading } =
useAsyncExternalStore(themeStorageSubscribe, themeStorage.get);

const { data: showSidebarLogoOverride, isLoading: isManagedStorageLoading } =
useAsyncState(async () => readManagedStorageByKey("showSidebarLogo"), []);
const { data: managedStorageState, isLoading: isManagedStorageLoading } =
useManagedStorageState();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use this as an opportunity to properly refactor useManagedStorageState to return AsyncState

Copy link
Contributor

@twschiller twschiller Oct 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably use the following? I don't remember if there's a reason why we didn't use that other than that maybe the useAsyncExternalStore method didn't exist yet (I haven't checked) / or it just wasn't very valuable given that managed storage values should never change in practice:

  • useAsyncExternalStore
  • readManagedStorage

I think we then get error handling for free with those

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wondering the same thing, re why not use readManagedStorage + useAsyncExternalStore

Copy link
Contributor

@twschiller twschiller Oct 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wondering the same thing, re why not use readManagedStorage + useAsyncExternalStore

I don't see a reason not to. The main difference will be it will be technically possible for the hook to return an error state. Whereas the current one could potentially stay isLoading indefinitely. But I don't think the difference matters in practice


const showSidebarLogoOverride = managedStorageState?.showSidebarLogo;

const isLoading = isManagedStorageLoading || isCachedThemeLoading;

Expand Down
15 changes: 15 additions & 0 deletions src/store/enterprise/useManagedStorageState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,17 @@
});

describe("useManagedStorageState", () => {
it("waits on uninitialized state", async () => {

Check failure on line 28 in src/store/enterprise/useManagedStorageState.test.ts

View workflow job for this annotation

GitHub Actions / test

useManagedStorageState › waits on uninitialized state

thrown: "Exceeded timeout of 5000 ms for a test. Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout." at it (src/store/enterprise/useManagedStorageState.test.ts:28:3) at Object.describe (src/store/enterprise/useManagedStorageState.test.ts:27:1)
const { result, waitFor } = renderHook(() => useManagedStorageState());
expect(result.current).toStrictEqual({
currentData: undefined,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer loadingAsyncStateFactory here

data: undefined,
error: undefined,
isError: false,
isFetching: true,
isLoading: true,
isSuccess: false,
isUninitialized: false,
});

await waitFor(
Expand All @@ -52,13 +58,22 @@
useManagedStorageState(),
);

await waitForNextUpdate();

Check failure on line 61 in src/store/enterprise/useManagedStorageState.test.ts

View workflow job for this annotation

GitHub Actions / test

useManagedStorageState › handles already initialized state

Timed out in waitForNextUpdate after 1000ms. at waitForNextUpdate (node_modules/@testing-library/react-hooks/lib/core/asyncUtils.js:96:13) at Object.<anonymous> (src/store/enterprise/useManagedStorageState.test.ts:61:5)

expect(result.current).toStrictEqual({
currentData: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer valueToAsyncState here and override the refetch prop matcher

partnerId: "taco-bell",
},
data: {
partnerId: "taco-bell",
},
error: undefined,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: true,
isUninitialized: false,
refetch: expect.any(Function),
});
});

Expand Down
27 changes: 4 additions & 23 deletions src/store/enterprise/useManagedStorageState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,12 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { useSyncExternalStore } from "use-sync-external-store/shim";
import {
getSnapshot,
initManagedStorage,
managedStorageStateChange,
readManagedStorage,
} from "@/store/enterprise/managedStorage";
import { useEffect } from "react";
import type { ManagedStorageState } from "@/store/enterprise/managedStorageTypes";
import type { Nullishable } from "@/utils/nullishUtils";
import { expectContext } from "@/utils/expectContext";

type HookState = {
data: Nullishable<ManagedStorageState>;
isLoading: boolean;
};
import useAsyncExternalStore from "@/hooks/useAsyncExternalStore";

// NOTE: can't share subscribe methods across generators currently for useAsyncExternalStore because it maintains
// a map of subscriptions to state controllers. See https://github.com/pixiebrix/pixiebrix-extension/issues/7789
Expand All @@ -46,18 +37,8 @@ function subscribe(callback: () => void): () => void {
/**
* React hook to get the current state of managed storage.
*/
function useManagedStorageState(): HookState {
useEffect(() => {
// `initManagedStorage` is wrapped in once, so safe to call from multiple locations in the tree.
void initManagedStorage();
}, []);

const data = useSyncExternalStore(subscribe, getSnapshot);

return {
data,
isLoading: data == null,
};
function useManagedStorageState() {
Copy link
Contributor

@twschiller twschiller Oct 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: I'd encourage us to declare the return type on exported functions

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Typescript infers the return type very well:

image

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was from hovering over useManagedStorageState in useTheme. I would actually prefer to not require return types in cases like this

Copy link
Contributor

@twschiller twschiller Oct 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your call since it's a philosophical distinction. Explicitly declaring the type better decouples the protocol/interface from the implementation. (And is required to hide certain properties from the caller)

See rule for more context: https://typescript-eslint.io/rules/explicit-module-boundary-types/

</bike-shedding>

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a better rule than requiring return types on all functions (which can be a nightmare). I could accept this, but I don't feel strongly that we need it

return useAsyncExternalStore(subscribe, readManagedStorage);
}

export default useManagedStorageState;
Loading