-
Notifications
You must be signed in to change notification settings - Fork 22
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
base: main
Are you sure you want to change the base?
Changes from all commits
0ee3c37
2bcc02e
5d60f92
d3db8e9
7b1e90e
319fdb6
afa7f80
58f65a8
3924d4d
ebba63f
4cba229
1370d6a
cb2355f
bcc470c
29b5180
2fabe25
0b71176
9c46ed1
ce8a6ac
4ea0d9b
b584c2a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -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", | ||||||||||||||||||||
|
@@ -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, | ||||||||||||||||||||
|
@@ -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(); | ||||||||||||||||||||
}); | ||||||||||||||||||||
|
||||||||||||||||||||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(readManagedStorage).mockResolvedValue({ | ||||||||||||||||||||
showSidebarLogo: policyValue, | ||||||||||||||||||||
}); | ||||||||||||||||||||
|
||||||||||||||||||||
const { result, waitForNextUpdate } = renderHook(() => useTheme()); | ||||||||||||||||||||
|
||||||||||||||||||||
await waitForNextUpdate(); | ||||||||||||||||||||
Check failure on line 126 in src/hooks/useTheme.test.ts GitHub Actions / testuseTheme › handles showSidebarLogo policy (policy: true
Check failure on line 126 in src/hooks/useTheme.test.ts GitHub Actions / testuseTheme › handles showSidebarLogo policy (policy: true
Check failure on line 126 in src/hooks/useTheme.test.ts GitHub Actions / testuseTheme › handles showSidebarLogo policy (policy: false
Check failure on line 126 in src/hooks/useTheme.test.ts GitHub Actions / testuseTheme › handles showSidebarLogo policy (policy: false
Check failure on line 126 in src/hooks/useTheme.test.ts GitHub Actions / testuseTheme › handles showSidebarLogo policy (policy: undefined
Check failure on line 126 in src/hooks/useTheme.test.ts GitHub Actions / testuseTheme › handles showSidebarLogo policy (policy: undefined
|
||||||||||||||||||||
|
||||||||||||||||||||
expect(result.current.activeTheme.showSidebarLogo).toBe(expectedValue); | ||||||||||||||||||||
expect(result.current.activeTheme).toMatchObject({ | ||||||||||||||||||||
...customTheme, | ||||||||||||||||||||
lastFetched: expect.any(Number), | ||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||
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 GitHub Actions / testuseTheme › uses activeTheme when an error occurs in managed storage (showSidebarLogo: false)
|
||||||||||||||||||||
}, | ||||||||||||||||||||
); | ||||||||||||||||||||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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(); | ||
|
@@ -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(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's use this as an opportunity to properly refactor There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
I think we then get error handling for free with those There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was wondering the same thing, re why not use readManagedStorage + useAsyncExternalStore There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 |
||
|
||
const showSidebarLogoOverride = managedStorageState?.showSidebarLogo; | ||
|
||
const isLoading = isManagedStorageLoading || isCachedThemeLoading; | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -25,11 +25,17 @@ | |
}); | ||
|
||
describe("useManagedStorageState", () => { | ||
it("waits on uninitialized state", async () => { | ||
Check failure on line 28 in src/store/enterprise/useManagedStorageState.test.ts GitHub Actions / testuseManagedStorageState › waits on uninitialized state
|
||
const { result, waitFor } = renderHook(() => useManagedStorageState()); | ||
expect(result.current).toStrictEqual({ | ||
currentData: undefined, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
@@ -52,13 +58,22 @@ | |
useManagedStorageState(), | ||
); | ||
|
||
await waitForNextUpdate(); | ||
Check failure on line 61 in src/store/enterprise/useManagedStorageState.test.ts GitHub Actions / testuseManagedStorageState › handles already initialized state
|
||
|
||
expect(result.current).toStrictEqual({ | ||
currentData: { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prefer valueToAsyncState here and override the |
||
partnerId: "taco-bell", | ||
}, | ||
data: { | ||
partnerId: "taco-bell", | ||
}, | ||
error: undefined, | ||
isError: false, | ||
isFetching: false, | ||
isLoading: false, | ||
isSuccess: true, | ||
isUninitialized: false, | ||
refetch: expect.any(Function), | ||
}); | ||
}); | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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/
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NIT: use
valueToAsyncState
fromasyncStateUtils