diff --git a/.storybook/main.ts b/.storybook/main.ts index 557a3855..02ef60f8 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -29,7 +29,7 @@ const config: StorybookConfig = { configFile: configFileMap[CHROMATIC_BASE_URL || '"https://www.chromatic.com"'], }, }, - "@storybook/addon-mdx-gfm" + "@storybook/addon-mdx-gfm", ], docs: { autodocs: "tag", @@ -43,8 +43,14 @@ const config: StorybookConfig = { stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], refs: { "@storybook/components": { - title: "@storybook/components", - url: "https://next--635781f3500dd2c49e189caf.chromatic.com", + title: "Storybook Components", + url: "https://next--635781f3500dd2c49e189caf.chromatic.com/", + expanded: false, + }, + "@storybook/icons": { + title: "Storybook Icons", + url: "https://main--64b56e737c0aeefed9d5e675.chromatic.com/", + expanded: false, }, }, async viteFinal(config, { configType }) { diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 56fb8332..60e35a69 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -13,7 +13,7 @@ import { } from "@storybook/theming"; import { HttpResponse, graphql } from "msw"; import { initialize, mswLoader } from "msw-storybook-addon"; -import React, { useState } from "react"; +import React from "react"; import { AuthProvider } from "../src/AuthContext"; import { baseModes } from "../src/modes"; @@ -21,6 +21,7 @@ import { UninstallProvider } from "../src/screens/Uninstalled/UninstallContext"; import { RunBuildProvider } from "../src/screens/VisualTests/RunBuildContext"; import { GraphQLClientProvider } from "../src/utils/graphQLClient"; import { storyWrapper } from "../src/utils/storyWrapper"; +import { useSessionState } from "../src/utils/useSessionState"; // Initialize MSW initialize({ @@ -137,9 +138,12 @@ const withManagerApi = storyWrapper(ManagerContext.Provider, ({ argsByTarget }) })); const withUninstall: Decorator = (Story) => { - const [addonInstalled, setAddonInstalled] = useState(false); + const [addonUninstalled, setAddonUninstalled] = useSessionState("addonUninstalled", false); return ( - + ); diff --git a/src/Panel.tsx b/src/Panel.tsx index e94bed7e..1bfc82af 100644 --- a/src/Panel.tsx +++ b/src/Panel.tsx @@ -1,6 +1,6 @@ import type { API } from "@storybook/manager-api"; import { useChannel, useStorybookState } from "@storybook/manager-api"; -import React, { useCallback, useState } from "react"; +import React, { useCallback } from "react"; import { AuthProvider } from "./AuthContext"; import { Spinner } from "./components/design-system"; @@ -15,7 +15,6 @@ import { START_BUILD, STOP_BUILD, } from "./constants"; -import { Project } from "./gql/graphql"; import { Authentication } from "./screens/Authentication/Authentication"; import { GitNotFound } from "./screens/Errors/GitNotFound"; import { LinkedProject } from "./screens/LinkProject/LinkedProject"; @@ -30,6 +29,7 @@ import { VisualTests } from "./screens/VisualTests/VisualTests"; import { GitInfoPayload, LocalBuildProgress, UpdateStatusFunction } from "./types"; import { client, Provider, useAccessToken } from "./utils/graphQLClient"; import { useProjectId } from "./utils/useProjectId"; +import { clearSessionState, useSessionState } from "./utils/useSessionState"; import { useSharedState } from "./utils/useSharedState"; interface PanelProps { @@ -38,7 +38,14 @@ interface PanelProps { } export const Panel = ({ active, api }: PanelProps) => { - const [accessToken, setAccessToken] = useAccessToken(); + const [accessToken, updateAccessToken] = useAccessToken(); + const setAccessToken = useCallback( + (token: string | null) => { + updateAccessToken(token); + if (!token) clearSessionState("authenticationScreen", "exchangeParameters"); + }, + [updateAccessToken] + ); const { storyId } = useStorybookState(); const [gitInfo] = useSharedState(GIT_INFO); @@ -64,7 +71,7 @@ export const Panel = ({ active, api }: PanelProps) => { } = useProjectId(); // If the user creates a project in a dialog (either during login or later, it get set here) - const [createdProjectId, setCreatedProjectId] = useState(); + const [createdProjectId, setCreatedProjectId] = useSessionState("createdProjectId"); const [addonUninstalled, setAddonUninstalled] = useSharedState(REMOVE_ADDON); const startBuild = () => emit(START_BUILD, { accessToken }); diff --git a/src/index.ts b/src/index.ts index 06b528dc..dd67a8fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -162,6 +162,7 @@ async function serverChannel(channel: Channel, options: Options & { configFile?: projectInfoState.value = { ...projectInfoState.value, written: true, + dismissed: false, configFile: targetConfigFile, }; } catch (err) { @@ -170,6 +171,7 @@ async function serverChannel(channel: Channel, options: Options & { configFile?: projectInfoState.value = { ...projectInfoState.value, written: false, + dismissed: false, configFile: writtenConfigFile, }; } diff --git a/src/screens/Authentication/Authentication.stories.tsx b/src/screens/Authentication/Authentication.stories.tsx index f85eab99..7658328d 100644 --- a/src/screens/Authentication/Authentication.stories.tsx +++ b/src/screens/Authentication/Authentication.stories.tsx @@ -8,12 +8,14 @@ import { panelModes } from "../../modes"; import { GraphQLClientProvider } from "../../utils/graphQLClient"; import { playAll } from "../../utils/playAll"; import { storyWrapper } from "../../utils/storyWrapper"; +import { clearSessionState } from "../../utils/useSessionState"; import { withFigmaDesign } from "../../utils/withFigmaDesign"; +import { withSetup } from "../../utils/withSetup"; import { Authentication } from "./Authentication"; const meta = { component: Authentication, - decorators: [storyWrapper(GraphQLClientProvider)], + decorators: [withSetup(clearSessionState), storyWrapper(GraphQLClientProvider)], args: { setAccessToken: action("setAccessToken"), hasProjectId: false, diff --git a/src/screens/Authentication/Authentication.tsx b/src/screens/Authentication/Authentication.tsx index 581b65f9..9b8ea7fb 100644 --- a/src/screens/Authentication/Authentication.tsx +++ b/src/screens/Authentication/Authentication.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useState } from "react"; import { Project } from "../../gql/graphql"; import { initiateSignin, TokenExchangeParameters } from "../../utils/requestAccessToken"; import { useErrorNotification } from "../../utils/useErrorNotification"; +import { useSessionState } from "../../utils/useSessionState"; import { useUninstallAddon } from "../Uninstalled/UninstallContext"; import { SetSubdomain } from "./SetSubdomain"; import { SignIn } from "./SignIn"; @@ -22,8 +23,12 @@ export const Authentication = ({ setCreatedProjectId, hasProjectId, }: AuthenticationProps) => { - const [screen, setScreen] = useState(hasProjectId ? "signin" : "welcome"); - const [exchangeParameters, setExchangeParameters] = useState(); + const [screen, setScreen] = useSessionState( + "authenticationScreen", + hasProjectId ? "signin" : "welcome" + ); + const [exchangeParameters, setExchangeParameters] = + useSessionState("exchangeParameters"); const onError = useErrorNotification(); const { uninstallAddon } = useUninstallAddon(); @@ -36,7 +41,7 @@ export const Authentication = ({ onError("Sign in Error", err); } }, - [onError] + [onError, setExchangeParameters, setScreen] ); if (screen === "welcome" && !hasProjectId) { diff --git a/src/screens/GuidedTour/GuidedTour.tsx b/src/screens/GuidedTour/GuidedTour.tsx index 36752bd3..959d038d 100644 --- a/src/screens/GuidedTour/GuidedTour.tsx +++ b/src/screens/GuidedTour/GuidedTour.tsx @@ -5,6 +5,7 @@ import Joyride from "react-joyride"; import { PANEL_ID } from "../../constants"; import { ENABLE_FILTER } from "../../SidebarBottom"; +import { useSessionState } from "../../utils/useSessionState"; import { useSelectedStoryState } from "../VisualTests/BuildContext"; import { Confetti } from "./Confetti"; import { Tooltip, TooltipProps } from "./Tooltip"; @@ -56,11 +57,9 @@ export const GuidedTour = ({ managerApi.setSelectedPanel(PANEL_ID); }, [managerApi]); - const [showConfetti, setShowConfetti] = React.useState(false); - const [stepIndex, setStepIndex] = React.useState(0); - const nextStep = () => { - setStepIndex((prev) => prev + 1); - }; + const [showConfetti, setShowConfetti] = useSessionState("showConfetti", false); + const [stepIndex, setStepIndex] = useSessionState("stepIndex", 0); + const nextStep = () => setStepIndex((prev = 0) => prev + 1); useEffect(() => { // Listen for internal event to indicate a filter was set before moving to next step. @@ -71,7 +70,7 @@ export const GuidedTour = ({ window.dispatchEvent(new Event("resize")); }, 100); }); - }, [managerApi]); + }, [managerApi, setStepIndex]); useEffect(() => { // Listen for the test status to change to ACCEPTED and move to the completed step. @@ -79,7 +78,7 @@ export const GuidedTour = ({ setShowConfetti(true); setStepIndex(6); } - }, [selectedStory?.selectedTest?.status, showConfetti, setShowConfetti, stepIndex]); + }, [selectedStory?.selectedTest?.status, showConfetti, setShowConfetti, stepIndex, setStepIndex]); const steps: Partial[] = [ { diff --git a/src/screens/LinkProject/LinkProject.tsx b/src/screens/LinkProject/LinkProject.tsx index 3c1c8ff8..795048b9 100644 --- a/src/screens/LinkProject/LinkProject.tsx +++ b/src/screens/LinkProject/LinkProject.tsx @@ -1,6 +1,6 @@ import { AddIcon } from "@storybook/icons"; import { styled } from "@storybook/theming"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect } from "react"; import { useQuery } from "urql"; import { Container } from "../../components/Container"; @@ -10,8 +10,9 @@ import { Screen } from "../../components/Screen"; import { Stack } from "../../components/Stack"; import { Text } from "../../components/Text"; import { graphql } from "../../gql"; -import type { Account, Project, SelectProjectsQueryQuery } from "../../gql/graphql"; +import type { Project, SelectProjectsQueryQuery } from "../../gql/graphql"; import { DialogHandler, useChromaticDialog } from "../../utils/useChromaticDialog"; +import { useSessionState } from "../../utils/useSessionState"; const SelectProjectsQuery = graphql(/* GraphQL */ ` query SelectProjectsQuery { @@ -130,13 +131,12 @@ function SelectProject({ return () => clearInterval(interval); }, [rerunProjectsQuery]); - const [selectedAccountId, setSelectedAccountId] = useState(); + const [selectedAccountId, setSelectedAccountId] = useSessionState("selectedAccountId"); const selectedAccount = data?.viewer?.accounts.find((a) => a.id === selectedAccountId); const onSelectAccount = React.useCallback( - (account: NonNullable["accounts"][number]) => { - setSelectedAccountId(account.id); - }, + (account: NonNullable["accounts"][number]) => + setSelectedAccountId(account.id), [setSelectedAccountId] ); @@ -146,7 +146,7 @@ function SelectProject({ } }, [data, selectedAccountId, onSelectAccount]); - const [isSelectingProject, setSelectingProject] = useState(false); + const [isSelectingProject, setSelectingProject] = useSessionState("isSelectingProject", false); const handleSelectProject = React.useCallback( ( diff --git a/src/screens/Onboarding/Onboarding.stories.tsx b/src/screens/Onboarding/Onboarding.stories.tsx index b1eff3a5..2ee79244 100644 --- a/src/screens/Onboarding/Onboarding.stories.tsx +++ b/src/screens/Onboarding/Onboarding.stories.tsx @@ -10,7 +10,9 @@ import { LocalBuildProgress } from "../../types"; import { GraphQLClientProvider } from "../../utils/graphQLClient"; import { playAll } from "../../utils/playAll"; import { storyWrapper } from "../../utils/storyWrapper"; +import { clearSessionState } from "../../utils/useSessionState"; import { withFigmaDesign } from "../../utils/withFigmaDesign"; +import { withSetup } from "../../utils/withSetup"; import { BuildProvider } from "../VisualTests/BuildContext"; import { acceptedBuild, acceptedTests, buildInfo, withTests } from "../VisualTests/mocks"; import { RunBuildProvider } from "../VisualTests/RunBuildContext"; @@ -43,6 +45,7 @@ const RunBuildWrapper = ({ const meta = { component: Onboarding, decorators: [ + withSetup(clearSessionState), storyWrapper(BuildProvider, (ctx) => ({ watchState: buildInfo(ctx.parameters.selectedBuild) })), storyWrapper(GraphQLClientProvider), ], diff --git a/src/screens/Onboarding/Onboarding.tsx b/src/screens/Onboarding/Onboarding.tsx index f88883c1..c65a7353 100644 --- a/src/screens/Onboarding/Onboarding.tsx +++ b/src/screens/Onboarding/Onboarding.tsx @@ -1,7 +1,7 @@ import { PlayIcon } from "@storybook/icons"; import { styled } from "@storybook/theming"; import { lighten } from "polished"; -import React, { useEffect, useState } from "react"; +import React, { useEffect } from "react"; import { BuildProgressInline } from "../../components/BuildProgressBarInline"; import { Button } from "../../components/Button"; @@ -16,6 +16,7 @@ import { Stack } from "../../components/Stack"; import { Text } from "../../components/Text"; import { AccountSuspensionReason, SelectedBuildFieldsFragment } from "../../gql/graphql"; import { GitInfoPayload, LocalBuildProgress } from "../../types"; +import { useSessionState } from "../../utils/useSessionState"; import { AccountSuspended } from "../Errors/AccountSuspended"; import { BuildError } from "../Errors/BuildError"; import { useBuildState, useSelectedStoryState } from "../VisualTests/BuildContext"; @@ -78,23 +79,30 @@ export const Onboarding = ({ // The initial build screen is only necessary if this is a brand new project with no builds at all. Instead, !selectedBuild would appear on any new branch, even if there are other builds on the project. // TODO: Removed this entirely to solve for the most common case of an existing user with some builds to use as a baseline. // Removing instead of fixing to avoid additional work as this project is past due. We need to revisit this later. - const [showInitialBuild, setShowInitialBuild] = useState(showInitialBuildScreen); + const [showInitialBuild, setShowInitialBuild] = useSessionState( + "showInitialBuild", + showInitialBuildScreen + ); useEffect(() => { // Watch the value of showInitialBuildScreen, and if it becomes true, set the state to true. This is necessary because Onboarding may render before there is data to determine if there are any builds. - if (showInitialBuildScreen) { - setShowInitialBuild(true); - } - }, [showInitialBuildScreen]); + if (showInitialBuildScreen) setShowInitialBuild(true); + }, [showInitialBuildScreen, setShowInitialBuild]); - const [showCatchAChange, setShowCatchAChange] = useState(() => !showInitialBuild); - const [initialGitHash, setInitialGitHash] = React.useState(gitInfo.uncommittedHash); + const [showCatchAChange, setShowCatchAChange] = useSessionState( + "showCatchAChange", + !showInitialBuild + ); + const [initialGitHash, setInitialGitHash] = useSessionState( + "initialGitHash", + gitInfo.uncommittedHash + ); const onCatchAChange = () => { setInitialGitHash(gitInfo.uncommittedHash); setShowCatchAChange(true); }; - const [runningSecondBuild, setRunningSecondBuild] = React.useState(false); + const [runningSecondBuild, setRunningSecondBuild] = useSessionState("runningSecondBuild", false); // TODO: This design for an error in the Onboarding is incomplete if (localBuildProgress && localBuildProgress.currentStep === "error") { diff --git a/src/screens/VisualTests/VisualTests.tsx b/src/screens/VisualTests/VisualTests.tsx index 4552edcc..b663da53 100644 --- a/src/screens/VisualTests/VisualTests.tsx +++ b/src/screens/VisualTests/VisualTests.tsx @@ -1,6 +1,6 @@ import { useStorybookApi, useStorybookState } from "@storybook/manager-api"; import type { API_StatusState } from "@storybook/types"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect } from "react"; import { useMutation } from "urql"; import { PANEL_ID } from "../../constants"; @@ -15,6 +15,7 @@ import { import { GitInfoPayload, LocalBuildProgress, UpdateStatusFunction } from "../../types"; import { testsToStatusUpdate } from "../../utils/testsToStatusUpdate"; import { SelectedBuildInfo, updateSelectedBuildInfo } from "../../utils/updateSelectedBuildInfo"; +import { useSessionState } from "../../utils/useSessionState"; import { AccountSuspended } from "../Errors/AccountSuspended"; import { GuidedTour } from "../GuidedTour/GuidedTour"; import { Onboarding } from "../Onboarding/Onboarding"; @@ -31,7 +32,7 @@ const createEmptyStoryStatusUpdate = (state: API_StatusState) => { interface VisualTestsProps { isOutdated: boolean; selectedBuildInfo?: SelectedBuildInfo; - setSelectedBuildInfo: ReturnType>[1]; + setSelectedBuildInfo: ReturnType>[1]; dismissBuildError: () => void; localBuildProgress?: LocalBuildProgress; setOutdated: (isOutdated: boolean) => void; @@ -394,7 +395,9 @@ export const VisualTestsWithoutSelectedBuildId = ({ export const VisualTests = ( props: Omit ) => { - const [selectedBuildInfo, setSelectedBuildInfo] = useState(); + const [selectedBuildInfo, setSelectedBuildInfo] = useSessionState( + "selectedBuildInfo" + ); return ( diff --git a/src/types.ts b/src/types.ts index 956f922e..eb5b8f54 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,6 +36,7 @@ export type GitInfoPayload = Omit; export type ProjectInfoPayload = { projectId?: string; written?: boolean; + dismissed?: boolean; configFile?: string; }; diff --git a/src/utils/useProjectId.ts b/src/utils/useProjectId.ts index d66e927c..d8213c63 100644 --- a/src/utils/useProjectId.ts +++ b/src/utils/useProjectId.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import { PROJECT_INFO } from "../constants"; import { ProjectInfoPayload } from "../types"; @@ -6,26 +6,21 @@ import { useSharedState } from "./useSharedState"; export const useProjectId = () => { const [projectInfo, setProjectInfo] = useSharedState(PROJECT_INFO); + const { projectId, written, dismissed, configFile } = projectInfo || {}; - // Once we've seen the state of the update, we can "clear" it to move on - const [clearUpdated, setClearUpdated] = useState(false); - - const updateProject = useCallback( - (newProjectId: string) => { - setClearUpdated(false); - setProjectInfo({ projectId: newProjectId }); - }, - [setProjectInfo] - ); - - const { projectId, written, configFile } = projectInfo || {}; return { loading: !projectInfo, projectId, configFile, - updateProject, - projectUpdatingFailed: !clearUpdated && written === false, - projectIdUpdated: !clearUpdated && written === true, - clearProjectIdUpdated: () => setClearUpdated(true), + updateProject: useCallback( + (id: string) => setProjectInfo({ ...projectInfo, projectId: id, dismissed: false }), + [projectInfo, setProjectInfo] + ), + projectUpdatingFailed: !dismissed && written === false, + projectIdUpdated: !dismissed && written === true, + clearProjectIdUpdated: useCallback( + () => setProjectInfo({ ...projectInfo, dismissed: true }), + [projectInfo, setProjectInfo] + ), }; }; diff --git a/src/utils/useSessionState.stories.tsx b/src/utils/useSessionState.stories.tsx new file mode 100644 index 00000000..3d62a0ad --- /dev/null +++ b/src/utils/useSessionState.stories.tsx @@ -0,0 +1,131 @@ +import { expect, userEvent, waitFor } from "@storybook/test"; +import { within } from "@storybook/testing-library"; +import React, { useEffect } from "react"; + +import { ADDON_ID } from "../constants"; +import { playAll } from "./playAll"; +import { useSessionState } from "./useSessionState"; + +type Props = { id: string; initialState: any }; + +const UseSessionState = ({ id, initialState, update }: Props & { update: string }) => { + const [state, setState] = useSessionState(id, initialState); + const json = JSON.stringify(state, null, 2); + return ( + <> +
{json}
+ + + ); +}; + +const Component = (props: Props) => { + const [initialized, setInitialized] = React.useState(false); + + useEffect(() => { + if (initialized) return; + sessionStorage.setItem(`${ADDON_ID}/state/PredefinedState`, JSON.stringify({ foo: "bar" })); + sessionStorage.setItem(`${ADDON_ID}/state`, "PredefinedState"); + setInitialized(true); + }, [initialized]); + + return ( + initialized && ( + <> + + + + + ) + ); +}; + +const expectState = async (context: any, expected: any) => { + const elements = context.canvasElement.getElementsByTagName("pre"); + await Promise.all( + Array.from(elements).map(async (element: any) => { + const data = await waitFor(() => JSON.parse(element.textContent as any)); + await expect(data).toEqual(expected); + }) + ); +}; + +export default { + component: Component, + args: { + initialState: { initial: true }, + }, + parameters: { + chromatic: { + modes: { + Light: { theme: "light", viewport: "default" }, + }, + }, + }, +}; + +export const InitialState = { + args: { + id: Math.random().toString(16), + initialState: { initial: "initial" }, + }, + play: playAll((context) => expectState(context, { initial: "initial" })), +}; + +export const LazyInitialState = { + args: { + id: Math.random().toString(16), + initialState: () => ({ initial: "lazy" }), + }, + play: playAll((context) => expectState(context, { initial: "lazy" })), +}; + +export const PredefinedState = { + args: { + id: "PredefinedState", + }, + play: playAll((context) => expectState(context, { foo: "bar" })), +}; + +export const SynchronizedState = { + args: { + id: Math.random().toString(16), + }, + play: playAll(async (context) => { + const canvas = within(context.canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: /one/ })); + await expectState(context, { update: "one" }); + }), +}; + +export const EventuallyConsistent = { + args: { + id: Math.random().toString(16), + }, + play: playAll(async (context) => { + const canvas = within(context.canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: /one/ })); + await expectState(context, { update: "one" }); + + await userEvent.click(await canvas.findByRole("button", { name: /two/ })); + await userEvent.click(await canvas.findByRole("button", { name: /one/ })); + await userEvent.click(await canvas.findByRole("button", { name: /three/ })); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + await expectState(context, { update: "three" }); + }), +}; + +export const EventListener = { + args: { + id: Math.random().toString(16), + }, + play: playAll(async (context) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + sessionStorage.setItem(`${ADDON_ID}/state/${context.args.id}`, JSON.stringify({ bar: "baz" })); + window.dispatchEvent(new StorageEvent("session-storage", { key: context.args.id })); + await expectState(context, { bar: "baz" }); + }), +}; diff --git a/src/utils/useSessionState.ts b/src/utils/useSessionState.ts new file mode 100644 index 00000000..f789fdee --- /dev/null +++ b/src/utils/useSessionState.ts @@ -0,0 +1,109 @@ +import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { ADDON_ID } from "../constants"; + +declare global { + interface WindowEventMap { + "session-storage": CustomEvent; + } +} + +const timeoutIds = new Map(); +const debounce = (key: string, callback: (...args: any[]) => unknown, wait: number) => { + const cancel = () => { + window.clearTimeout(timeoutIds.get(key)); + timeoutIds.delete(key); + }; + const debounced = (...args: any[]) => { + if (timeoutIds.has(key)) cancel(); + else callback(...args); // Leading edge call + + timeoutIds.set( + key, + window.setTimeout(() => timeoutIds.delete(key) && callback(...args), wait) // Trailing edge call + ); + }; + debounced.cancel = cancel; + return debounced; +}; + +export function useSessionState( + key: string, + initialState?: S | (() => S) +): readonly [S, Dispatch>] { + const readValue = useCallback(() => { + try { + const value = sessionStorage.getItem(`${ADDON_ID}/state/${key}`) as string; + if (value !== undefined && value !== null) return JSON.parse(value) as S; + } catch (e) { + // Fall back to initial state + } + return typeof initialState === "function" ? (initialState as () => S)() : (initialState as S); + }, [key, initialState]); + + const [state, setState] = useState(readValue); + + const persist = useMemo( + () => + debounce( + key, + (value: unknown) => { + const stateKeys = new Set(sessionStorage.getItem(`${ADDON_ID}/state`)?.split(";")); + if (value === undefined || value === null) { + sessionStorage.removeItem(`${ADDON_ID}/state/${key}`); + stateKeys.delete(key); + } else { + sessionStorage.setItem(`${ADDON_ID}/state/${key}`, JSON.stringify(value)); + stateKeys.add(key); + } + sessionStorage.setItem(`${ADDON_ID}/state`, Array.from(stateKeys).join(";")); + window.dispatchEvent(new StorageEvent("session-storage", { key })); + }, + 1000 + ), + [key] + ); + + useEffect(() => persist.cancel, [persist]); + + const handleStorageChange = useCallback( + (event: StorageEvent | CustomEvent) => { + const storageEvent = event as StorageEvent; + if (!storageEvent.key || storageEvent.key === key) setState(readValue()); + }, + [key, readValue] + ); + + useEffect(() => { + window.addEventListener("storage", handleStorageChange); + window.addEventListener("session-storage", handleStorageChange); + return () => { + window.removeEventListener("storage", handleStorageChange); + window.removeEventListener("session-storage", handleStorageChange); + }; + }, [handleStorageChange]); + + return [ + state, + useCallback( + (update: S | ((currentValue: S) => S)) => + setState((currentValue: S | undefined) => { + const newValue = typeof update === "function" ? (update as any)(currentValue) : update; + persist(newValue); + return newValue; + }), + [persist] + ), + ] as const; +} + +export function clearSessionState(...keys: string[]) { + const items = sessionStorage.getItem(`${ADDON_ID}/state`)?.split(";") || []; + if (keys.length) { + keys.forEach((key) => sessionStorage.removeItem(`${ADDON_ID}/state/${key}`)); + sessionStorage.setItem(`${ADDON_ID}/state`, items.filter((i) => !keys.includes(i)).join(";")); + } else { + items.forEach((item) => sessionStorage.removeItem(`${ADDON_ID}/state/${item}`)); + sessionStorage.removeItem(`${ADDON_ID}/state`); + } +} diff --git a/src/utils/useTests.stories.tsx b/src/utils/useTests.stories.tsx index 100167a3..06abc2fd 100644 --- a/src/utils/useTests.stories.tsx +++ b/src/utils/useTests.stories.tsx @@ -2,7 +2,7 @@ import { expect, waitFor } from "@storybook/test"; import React, { useEffect } from "react"; import { SELECTED_BROWSER_ID, SELECTED_MODE_NAME } from "../constants"; -import { ComparisonResult, StoryTestFieldsFragment, TestStatus } from "../gql/graphql"; +import { ComparisonResult, StoryTestFieldsFragment } from "../gql/graphql"; import { playAll } from "./playAll"; import { useSharedState } from "./useSharedState"; import { useTests } from "./useTests";