mirror of https://github.com/coder/coder.git
fix: ensure signing out cannot cause any runtime render errors (#13137)
* fix: remove some of the jank around our core App component * refactor: scope navigation logic more aggressively * refactor: add explicit return type to useAuthenticated * refactor: clean up ProxyContext code * wip: add code for consolidating the HTML metadata * refactor: clean up hook logic * refactor: rename useHtmlMetadata to useEmbeddedMetadata * fix: correct names that weren't updated * fix: update type-safety of useEmbeddedMetadata further * wip: switch codebase to use metadata hook * refactor: simplify design of metadata hook * fix: update stray type mismatches * fix: more type fixing * fix: resolve illegal invocation error * fix: get metadata issue resolved * fix: update comments * chore: add unit tests for MetadataManager * fix: beef up tests * fix: update typo in tests
This commit is contained in:
parent
ed0ca76b0b
commit
7873c961e3
|
@ -38,9 +38,23 @@ export const AppProviders: FC<AppProvidersProps> = ({
|
|||
}) => {
|
||||
// https://tanstack.com/query/v4/docs/react/devtools
|
||||
const [showDevtools, setShowDevtools] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.toggleDevtools = () => setShowDevtools((old) => !old);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- no dependencies needed here
|
||||
// Storing key in variable to avoid accidental typos; we're working with the
|
||||
// window object, so there's basically zero type-checking available
|
||||
const toggleKey = "toggleDevtools";
|
||||
|
||||
// Don't want to throw away the previous devtools value if some other
|
||||
// extension added something already
|
||||
const devtoolsBeforeSync = window[toggleKey];
|
||||
window[toggleKey] = () => {
|
||||
devtoolsBeforeSync?.();
|
||||
setShowDevtools((current) => !current);
|
||||
};
|
||||
|
||||
return () => {
|
||||
window[toggleKey] = devtoolsBeforeSync;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
@ -60,10 +74,10 @@ export const AppProviders: FC<AppProvidersProps> = ({
|
|||
|
||||
export const App: FC = () => {
|
||||
return (
|
||||
<AppProviders>
|
||||
<ErrorBoundary>
|
||||
<ErrorBoundary>
|
||||
<AppProviders>
|
||||
<RouterProvider router={router} />
|
||||
</ErrorBoundary>
|
||||
</AppProviders>
|
||||
</AppProviders>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,16 +1,14 @@
|
|||
import type { QueryClient, UseQueryOptions } from "react-query";
|
||||
import type { QueryClient } from "react-query";
|
||||
import * as API from "api/api";
|
||||
import type { AppearanceConfig } from "api/typesGenerated";
|
||||
import { getMetadataAsJSON } from "utils/metadata";
|
||||
import type { MetadataState } from "hooks/useEmbeddedMetadata";
|
||||
import { cachedQuery } from "./util";
|
||||
|
||||
const initialAppearanceData = getMetadataAsJSON<AppearanceConfig>("appearance");
|
||||
const appearanceConfigKey = ["appearance"] as const;
|
||||
|
||||
export const appearance = (): UseQueryOptions<AppearanceConfig> => {
|
||||
// We either have our initial data or should immediately fetch and never again!
|
||||
export const appearance = (metadata: MetadataState<AppearanceConfig>) => {
|
||||
return cachedQuery({
|
||||
initialData: initialAppearanceData,
|
||||
metadata,
|
||||
queryKey: ["appearance"],
|
||||
queryFn: () => API.getAppearance(),
|
||||
});
|
||||
|
|
|
@ -1,16 +1,14 @@
|
|||
import type { UseQueryOptions } from "react-query";
|
||||
import * as API from "api/api";
|
||||
import type { BuildInfoResponse } from "api/typesGenerated";
|
||||
import { getMetadataAsJSON } from "utils/metadata";
|
||||
import type { MetadataState } from "hooks/useEmbeddedMetadata";
|
||||
import { cachedQuery } from "./util";
|
||||
|
||||
const initialBuildInfoData = getMetadataAsJSON<BuildInfoResponse>("build-info");
|
||||
const buildInfoKey = ["buildInfo"] as const;
|
||||
|
||||
export const buildInfo = (): UseQueryOptions<BuildInfoResponse> => {
|
||||
export const buildInfo = (metadata: MetadataState<BuildInfoResponse>) => {
|
||||
// The version of the app can't change without reloading the page.
|
||||
return cachedQuery({
|
||||
initialData: initialBuildInfoData,
|
||||
metadata,
|
||||
queryKey: buildInfoKey,
|
||||
queryFn: () => API.getBuildInfo(),
|
||||
});
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
import type { QueryClient, UseQueryOptions } from "react-query";
|
||||
import type { QueryClient } from "react-query";
|
||||
import * as API from "api/api";
|
||||
import type { Entitlements } from "api/typesGenerated";
|
||||
import { getMetadataAsJSON } from "utils/metadata";
|
||||
import type { MetadataState } from "hooks/useEmbeddedMetadata";
|
||||
import { cachedQuery } from "./util";
|
||||
|
||||
const initialEntitlementsData = getMetadataAsJSON<Entitlements>("entitlements");
|
||||
const entitlementsQueryKey = ["entitlements"] as const;
|
||||
|
||||
export const entitlements = (): UseQueryOptions<Entitlements> => {
|
||||
export const entitlements = (metadata: MetadataState<Entitlements>) => {
|
||||
return cachedQuery({
|
||||
initialData: initialEntitlementsData,
|
||||
metadata,
|
||||
queryKey: entitlementsQueryKey,
|
||||
queryFn: () => API.getEntitlements(),
|
||||
});
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
import type { UseQueryOptions } from "react-query";
|
||||
import * as API from "api/api";
|
||||
import type { Experiments } from "api/typesGenerated";
|
||||
import { getMetadataAsJSON } from "utils/metadata";
|
||||
import type { MetadataState } from "hooks/useEmbeddedMetadata";
|
||||
import { cachedQuery } from "./util";
|
||||
|
||||
const initialExperimentsData = getMetadataAsJSON<Experiments>("experiments");
|
||||
const experimentsKey = ["experiments"] as const;
|
||||
|
||||
export const experiments = (): UseQueryOptions<Experiments> => {
|
||||
export const experiments = (metadata: MetadataState<Experiments>) => {
|
||||
return cachedQuery({
|
||||
initialData: initialExperimentsData,
|
||||
metadata,
|
||||
queryKey: experimentsKey,
|
||||
queryFn: () => API.getExperiments(),
|
||||
});
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import type {
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseQueryOptions,
|
||||
} from "react-query";
|
||||
|
@ -15,9 +14,12 @@ import type {
|
|||
User,
|
||||
GenerateAPIKeyResponse,
|
||||
} from "api/typesGenerated";
|
||||
import {
|
||||
defaultMetadataManager,
|
||||
type MetadataState,
|
||||
} from "hooks/useEmbeddedMetadata";
|
||||
import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
|
||||
import { prepareQuery } from "utils/filters";
|
||||
import { getMetadataAsJSON } from "utils/metadata";
|
||||
import { getAuthorizationKey } from "./authCheck";
|
||||
import { cachedQuery } from "./util";
|
||||
|
||||
|
@ -113,8 +115,6 @@ export const updateRoles = (queryClient: QueryClient) => {
|
|||
};
|
||||
};
|
||||
|
||||
const initialUserData = getMetadataAsJSON<User>("user");
|
||||
|
||||
export const authMethods = () => {
|
||||
return {
|
||||
// Even the endpoint being /users/authmethods we don't want to revalidate it
|
||||
|
@ -126,11 +126,9 @@ export const authMethods = () => {
|
|||
|
||||
const meKey = ["me"];
|
||||
|
||||
export const me = (): UseQueryOptions<User> & {
|
||||
queryKey: QueryKey;
|
||||
} => {
|
||||
export const me = (metadata: MetadataState<User>) => {
|
||||
return cachedQuery({
|
||||
initialData: initialUserData,
|
||||
metadata,
|
||||
queryKey: meKey,
|
||||
queryFn: API.getAuthenticatedUser,
|
||||
});
|
||||
|
@ -143,10 +141,9 @@ export function apiKey(): UseQueryOptions<GenerateAPIKeyResponse> {
|
|||
};
|
||||
}
|
||||
|
||||
export const hasFirstUser = (): UseQueryOptions<boolean> => {
|
||||
export const hasFirstUser = (userMetadata: MetadataState<User>) => {
|
||||
return cachedQuery({
|
||||
// This cannot be false otherwise it will not fetch!
|
||||
initialData: Boolean(initialUserData) || undefined,
|
||||
metadata: userMetadata,
|
||||
queryKey: ["hasFirstUser"],
|
||||
queryFn: API.hasFirstUser,
|
||||
});
|
||||
|
@ -193,6 +190,22 @@ export const logout = (queryClient: QueryClient) => {
|
|||
return {
|
||||
mutationFn: API.logout,
|
||||
onSuccess: () => {
|
||||
/**
|
||||
* 2024-05-02 - If we persist any form of user data after the user logs
|
||||
* out, that will continue to seed the React Query cache, creating
|
||||
* "impossible" states where we'll have data we're not supposed to have.
|
||||
*
|
||||
* This has caused issues where logging out will instantly throw a
|
||||
* completely uncaught runtime rendering error. Worse yet, the error only
|
||||
* exists when serving the site from the Go backend (the JS environment
|
||||
* has zero issues because it doesn't have access to the metadata). These
|
||||
* errors can only be caught with E2E tests.
|
||||
*
|
||||
* Deleting the user data will mean that all future requests have to take
|
||||
* a full roundtrip, but this still felt like the best way to ensure that
|
||||
* manually logging out doesn't blow the entire app up.
|
||||
*/
|
||||
defaultMetadataManager.clearMetadataByKey("user");
|
||||
queryClient.removeQueries();
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,4 +1,38 @@
|
|||
import type { UseQueryOptions } from "react-query";
|
||||
import type { UseQueryOptions, QueryKey } from "react-query";
|
||||
import type { MetadataState, MetadataValue } from "hooks/useEmbeddedMetadata";
|
||||
|
||||
const disabledFetchOptions = {
|
||||
cacheTime: Infinity,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
} as const satisfies UseQueryOptions;
|
||||
|
||||
type UseQueryOptionsWithMetadata<
|
||||
TMetadata extends MetadataValue = MetadataValue,
|
||||
TQueryFnData = unknown,
|
||||
TError = unknown,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> = Omit<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
"initialData"
|
||||
> & {
|
||||
metadata: MetadataState<TMetadata>;
|
||||
};
|
||||
|
||||
type FormattedQueryOptionsResult<
|
||||
TQueryFnData = unknown,
|
||||
TError = unknown,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> = Omit<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
"initialData"
|
||||
> & {
|
||||
queryKey: NonNullable<TQueryKey>;
|
||||
};
|
||||
|
||||
/**
|
||||
* cachedQuery allows the caller to only make a request a single time, and use
|
||||
|
@ -6,22 +40,35 @@ import type { UseQueryOptions } from "react-query";
|
|||
* values injected via metadata. We do this for the initial user fetch,
|
||||
* buildinfo, and a few others to reduce page load time.
|
||||
*/
|
||||
export const cachedQuery = <
|
||||
TQueryOptions extends UseQueryOptions<TData>,
|
||||
TData,
|
||||
export function cachedQuery<
|
||||
TMetadata extends MetadataValue = MetadataValue,
|
||||
TQueryFnData = unknown,
|
||||
TError = unknown,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: TQueryOptions,
|
||||
): TQueryOptions =>
|
||||
// Only do this if there is initial data, otherwise it can conflict with tests.
|
||||
({
|
||||
...(options.initialData
|
||||
? {
|
||||
cacheTime: Infinity,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
: {}),
|
||||
...options,
|
||||
});
|
||||
options: UseQueryOptionsWithMetadata<
|
||||
TMetadata,
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey
|
||||
>,
|
||||
): FormattedQueryOptionsResult<TQueryFnData, TError, TData, TQueryKey> {
|
||||
const { metadata, ...delegatedOptions } = options;
|
||||
const newOptions = {
|
||||
...delegatedOptions,
|
||||
initialData: metadata.available ? metadata.value : undefined,
|
||||
|
||||
// Make sure the disabled options are always serialized last, so that no
|
||||
// one using this function can accidentally override the values
|
||||
...(metadata.available ? disabledFetchOptions : {}),
|
||||
};
|
||||
|
||||
return newOptions as FormattedQueryOptionsResult<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey
|
||||
>;
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import { getWorkspaceProxies, getWorkspaceProxyRegions } from "api/api";
|
|||
import { cachedQuery } from "api/queries/util";
|
||||
import type { Region, WorkspaceProxy } from "api/typesGenerated";
|
||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
|
||||
import { type ProxyLatencyReport, useProxyLatency } from "./useProxyLatency";
|
||||
|
||||
export interface ProxyContextValue {
|
||||
|
@ -94,37 +95,8 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
|
|||
computeUsableURLS(userSavedProxy),
|
||||
);
|
||||
|
||||
const queryKey = ["get-proxies"];
|
||||
// This doesn't seem like an idiomatic way to get react-query to use the
|
||||
// initial data without performing an API request on mount, but it works.
|
||||
//
|
||||
// If anyone would like to clean this up in the future, it should seed data
|
||||
// from the `meta` tag if it exists, and not fetch the regions route.
|
||||
const [initialData] = useState(() => {
|
||||
// Build info is injected by the Coder server into the HTML document.
|
||||
const regions = document.querySelector("meta[property=regions]");
|
||||
if (regions) {
|
||||
const rawContent = regions.getAttribute("content");
|
||||
try {
|
||||
const obj = JSON.parse(rawContent as string);
|
||||
if ("regions" in obj) {
|
||||
return obj.regions as Region[];
|
||||
}
|
||||
return obj as Region[];
|
||||
} catch (ex) {
|
||||
// Ignore this and fetch as normal!
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const { permissions } = useAuthenticated();
|
||||
const query = async (): Promise<readonly Region[]> => {
|
||||
const endpoint = permissions.editWorkspaceProxies
|
||||
? getWorkspaceProxies
|
||||
: getWorkspaceProxyRegions;
|
||||
const resp = await endpoint();
|
||||
return resp.regions;
|
||||
};
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
|
||||
const {
|
||||
data: proxiesResp,
|
||||
|
@ -133,9 +105,15 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
|
|||
isFetched: proxiesFetched,
|
||||
} = useQuery(
|
||||
cachedQuery({
|
||||
initialData,
|
||||
queryKey,
|
||||
queryFn: query,
|
||||
metadata: metadata.regions,
|
||||
queryKey: ["get-proxies"],
|
||||
queryFn: async (): Promise<readonly Region[]> => {
|
||||
const endpoint = permissions.editWorkspaceProxies
|
||||
? getWorkspaceProxies
|
||||
: getWorkspaceProxyRegions;
|
||||
const resp = await endpoint();
|
||||
return resp.regions;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
createContext,
|
||||
type FC,
|
||||
type PropsWithChildren,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
} from "react";
|
||||
|
@ -17,6 +17,7 @@ import {
|
|||
} from "api/queries/users";
|
||||
import type { UpdateUserProfileRequest, User } from "api/typesGenerated";
|
||||
import { displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
|
||||
import { permissionsToCheck, type Permissions } from "./permissions";
|
||||
|
||||
export type AuthContextValue = {
|
||||
|
@ -42,22 +43,26 @@ export const AuthContext = createContext<AuthContextValue | undefined>(
|
|||
);
|
||||
|
||||
export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const meOptions = me();
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const userMetadataState = metadata.user;
|
||||
|
||||
const meOptions = me(userMetadataState);
|
||||
const userQuery = useQuery(meOptions);
|
||||
const hasFirstUserQuery = useQuery(hasFirstUser());
|
||||
const hasFirstUserQuery = useQuery(hasFirstUser(userMetadataState));
|
||||
|
||||
const permissionsQuery = useQuery({
|
||||
...checkAuthorization({ checks: permissionsToCheck }),
|
||||
enabled: userQuery.data !== undefined,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const loginMutation = useMutation(
|
||||
login({ checks: permissionsToCheck }, queryClient),
|
||||
);
|
||||
|
||||
const logoutMutation = useMutation(logout(queryClient));
|
||||
const updateProfileMutation = useMutation({
|
||||
...updateProfileOptions("me"),
|
||||
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(meOptions.queryKey, user);
|
||||
displaySuccess("Updated settings.");
|
||||
|
|
|
@ -9,13 +9,9 @@ import { embedRedirect } from "utils/redirect";
|
|||
import { type AuthContextValue, useAuthContext } from "./AuthProvider";
|
||||
|
||||
export const RequireAuth: FC = () => {
|
||||
const location = useLocation();
|
||||
const { signOut, isSigningOut, isSignedOut, isSignedIn, isLoading } =
|
||||
useAuthContext();
|
||||
const location = useLocation();
|
||||
const isHomePage = location.pathname === "/";
|
||||
const navigateTo = isHomePage
|
||||
? "/login"
|
||||
: embedRedirect(`${location.pathname}${location.search}`);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isSigningOut || !isSignedIn) {
|
||||
|
@ -48,6 +44,11 @@ export const RequireAuth: FC = () => {
|
|||
}
|
||||
|
||||
if (isSignedOut) {
|
||||
const isHomePage = location.pathname === "/";
|
||||
const navigateTo = isHomePage
|
||||
? "/login"
|
||||
: embedRedirect(`${location.pathname}${location.search}`);
|
||||
|
||||
return (
|
||||
<Navigate to={navigateTo} state={{ isRedirect: !isHomePage }} replace />
|
||||
);
|
||||
|
@ -64,7 +65,15 @@ export const RequireAuth: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export const useAuthenticated = () => {
|
||||
// We can do some TS magic here but I would rather to be explicit on what
|
||||
// values are not undefined when authenticated
|
||||
type NonNullableAuth = AuthContextValue & {
|
||||
user: Exclude<AuthContextValue["user"], undefined>;
|
||||
permissions: Exclude<AuthContextValue["permissions"], undefined>;
|
||||
organizationId: Exclude<AuthContextValue["organizationId"], undefined>;
|
||||
};
|
||||
|
||||
export const useAuthenticated = (): NonNullableAuth => {
|
||||
const auth = useAuthContext();
|
||||
|
||||
if (!auth.user) {
|
||||
|
@ -75,11 +84,5 @@ export const useAuthenticated = () => {
|
|||
throw new Error("Permissions are not available.");
|
||||
}
|
||||
|
||||
// We can do some TS magic here but I would rather to be explicit on what
|
||||
// values are not undefined when authenticated
|
||||
return auth as AuthContextValue & {
|
||||
user: Exclude<AuthContextValue["user"], undefined>;
|
||||
permissions: Exclude<AuthContextValue["permissions"], undefined>;
|
||||
organizationId: Exclude<AuthContextValue["organizationId"], undefined>;
|
||||
};
|
||||
return auth as NonNullableAuth;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,232 @@
|
|||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { Region, User } from "api/typesGenerated";
|
||||
import {
|
||||
MockAppearanceConfig,
|
||||
MockBuildInfo,
|
||||
MockEntitlements,
|
||||
MockExperiments,
|
||||
MockUser,
|
||||
} from "testHelpers/entities";
|
||||
import {
|
||||
type MetadataKey,
|
||||
type MetadataValue,
|
||||
type RuntimeHtmlMetadata,
|
||||
DEFAULT_METADATA_KEY,
|
||||
makeUseEmbeddedMetadata,
|
||||
MetadataManager,
|
||||
useEmbeddedMetadata,
|
||||
} from "./useEmbeddedMetadata";
|
||||
|
||||
// Make sure that no matter what happens in the tests, all metadata is
|
||||
// eventually deleted
|
||||
const allAppendedNodes = new Set<Set<Element>>();
|
||||
afterAll(() => {
|
||||
allAppendedNodes.forEach((tracker) => {
|
||||
tracker.forEach((node) => node.remove());
|
||||
});
|
||||
});
|
||||
|
||||
// Using empty array for now, because we don't have a separate mock regions
|
||||
// value, but it's still good enough for the tests because it's truthy
|
||||
const MockRegions: readonly Region[] = [];
|
||||
|
||||
const mockDataForTags = {
|
||||
appearance: MockAppearanceConfig,
|
||||
"build-info": MockBuildInfo,
|
||||
entitlements: MockEntitlements,
|
||||
experiments: MockExperiments,
|
||||
user: MockUser,
|
||||
regions: MockRegions,
|
||||
} as const satisfies Record<MetadataKey, MetadataValue>;
|
||||
|
||||
const emptyMetadata: RuntimeHtmlMetadata = {
|
||||
appearance: {
|
||||
available: false,
|
||||
value: undefined,
|
||||
},
|
||||
"build-info": {
|
||||
available: false,
|
||||
value: undefined,
|
||||
},
|
||||
entitlements: {
|
||||
available: false,
|
||||
value: undefined,
|
||||
},
|
||||
experiments: {
|
||||
available: false,
|
||||
value: undefined,
|
||||
},
|
||||
regions: {
|
||||
available: false,
|
||||
value: undefined,
|
||||
},
|
||||
user: {
|
||||
available: false,
|
||||
value: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const populatedMetadata: RuntimeHtmlMetadata = {
|
||||
appearance: {
|
||||
available: true,
|
||||
value: MockAppearanceConfig,
|
||||
},
|
||||
"build-info": {
|
||||
available: true,
|
||||
value: MockBuildInfo,
|
||||
},
|
||||
entitlements: {
|
||||
available: true,
|
||||
value: MockEntitlements,
|
||||
},
|
||||
experiments: {
|
||||
available: true,
|
||||
value: MockExperiments,
|
||||
},
|
||||
regions: {
|
||||
available: true,
|
||||
value: MockRegions,
|
||||
},
|
||||
user: {
|
||||
available: true,
|
||||
value: MockUser,
|
||||
},
|
||||
};
|
||||
|
||||
function seedInitialMetadata(metadataKey: string): () => void {
|
||||
// Enforcing this to make sure that even if we start to adopt more concurrent
|
||||
// tests through Vitest (or similar), there's no risk of global state causing
|
||||
// weird, hard-to-test false positives/negatives with other tests
|
||||
if (metadataKey === DEFAULT_METADATA_KEY) {
|
||||
throw new Error(
|
||||
"Please ensure that the key you provide does not match the key used throughout the majority of the application",
|
||||
);
|
||||
}
|
||||
|
||||
const trackedNodes = new Set<Element>();
|
||||
allAppendedNodes.add(trackedNodes);
|
||||
|
||||
for (const metadataName in mockDataForTags) {
|
||||
// Serializing first because that's the part that can fail; want to fail
|
||||
// early if possible
|
||||
const value = mockDataForTags[metadataName as keyof typeof mockDataForTags];
|
||||
const serialized = JSON.stringify(value);
|
||||
|
||||
const newNode = document.createElement("meta");
|
||||
newNode.setAttribute(metadataKey, metadataName);
|
||||
newNode.setAttribute("content", serialized);
|
||||
document.head.append(newNode);
|
||||
|
||||
trackedNodes.add(newNode);
|
||||
}
|
||||
|
||||
return () => {
|
||||
trackedNodes.forEach((node) => node.remove());
|
||||
};
|
||||
}
|
||||
|
||||
function renderMetadataHook(metadataKey: string) {
|
||||
const manager = new MetadataManager(metadataKey);
|
||||
const hook = makeUseEmbeddedMetadata(manager);
|
||||
|
||||
return {
|
||||
...renderHook(hook),
|
||||
manager,
|
||||
};
|
||||
}
|
||||
|
||||
// Just to be on the safe side, probably want to make sure that each test case
|
||||
// is set up with a unique key
|
||||
describe(useEmbeddedMetadata.name, () => {
|
||||
it("Correctly detects when metadata is missing in the HTML page", () => {
|
||||
const key = "cat";
|
||||
|
||||
// Deliberately avoid seeding any metadata
|
||||
const { result } = renderMetadataHook(key);
|
||||
expect(result.current.metadata).toEqual<RuntimeHtmlMetadata>(emptyMetadata);
|
||||
});
|
||||
|
||||
it("Can detect when metadata exists in the HTML", () => {
|
||||
const key = "dog";
|
||||
|
||||
const cleanupTags = seedInitialMetadata(key);
|
||||
const { result } = renderMetadataHook(key);
|
||||
expect(result.current.metadata).toEqual<RuntimeHtmlMetadata>(
|
||||
populatedMetadata,
|
||||
);
|
||||
|
||||
cleanupTags();
|
||||
});
|
||||
|
||||
it("Lets external systems (including React) subscribe to when metadata values are deleted", () => {
|
||||
const key = "bird";
|
||||
const tag1: MetadataKey = "user";
|
||||
const tag2: MetadataKey = "appearance";
|
||||
|
||||
const cleanupTags = seedInitialMetadata(key);
|
||||
const { result: reactResult, manager } = renderMetadataHook(key);
|
||||
|
||||
const nonReactSubscriber = jest.fn();
|
||||
manager.subscribe(nonReactSubscriber);
|
||||
|
||||
const expectedUpdate1: RuntimeHtmlMetadata = {
|
||||
...populatedMetadata,
|
||||
[tag1]: {
|
||||
available: false,
|
||||
value: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// Test that updates work when run directly through the metadata manager
|
||||
// itself
|
||||
act(() => manager.clearMetadataByKey(tag1));
|
||||
expect(reactResult.current.metadata).toEqual(expectedUpdate1);
|
||||
expect(nonReactSubscriber).toBeCalledWith(expectedUpdate1);
|
||||
|
||||
nonReactSubscriber.mockClear();
|
||||
const expectedUpdate2: RuntimeHtmlMetadata = {
|
||||
...expectedUpdate1,
|
||||
[tag2]: {
|
||||
available: false,
|
||||
value: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// Test that updates work when calling the convenience function exposed
|
||||
// through the React hooks
|
||||
act(() => reactResult.current.clearMetadataByKey(tag2));
|
||||
expect(reactResult.current.metadata).toEqual(expectedUpdate2);
|
||||
expect(nonReactSubscriber).toBeCalledWith(expectedUpdate2);
|
||||
|
||||
cleanupTags();
|
||||
});
|
||||
|
||||
// Need to guarantee this, or else we could have a good number of bugs in the
|
||||
// React UI
|
||||
it("Always treats metadata as immutable values during all deletions", () => {
|
||||
const key = "hamster";
|
||||
const tagToDelete: MetadataKey = "user";
|
||||
|
||||
const cleanupTags = seedInitialMetadata(key);
|
||||
const { result } = renderMetadataHook(key);
|
||||
|
||||
const initialResult = result.current.metadata;
|
||||
act(() => result.current.clearMetadataByKey(tagToDelete));
|
||||
const newResult = result.current.metadata;
|
||||
expect(initialResult).not.toBe(newResult);
|
||||
|
||||
// Mutate the initial result, and make sure the change doesn't propagate to
|
||||
// the updated result
|
||||
const mutableUser = initialResult.user as {
|
||||
available: boolean;
|
||||
value: User | undefined;
|
||||
};
|
||||
|
||||
mutableUser.available = false;
|
||||
mutableUser.value = undefined;
|
||||
expect(mutableUser).toEqual(newResult.user);
|
||||
expect(mutableUser).not.toBe(newResult.user);
|
||||
|
||||
cleanupTags();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,238 @@
|
|||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import type {
|
||||
AppearanceConfig,
|
||||
BuildInfoResponse,
|
||||
Entitlements,
|
||||
Experiments,
|
||||
Region,
|
||||
User,
|
||||
} from "api/typesGenerated";
|
||||
|
||||
export const DEFAULT_METADATA_KEY = "property";
|
||||
|
||||
/**
|
||||
* This is the set of values that are currently being exposed to the React
|
||||
* application during production. These values are embedded via the Go server,
|
||||
* so they will never exist when using a JavaScript runtime for the backend
|
||||
*
|
||||
* If you want to add new metadata in a type-safe way, add it to this type.
|
||||
* Each key should be the name of the "property" attribute that will be used on
|
||||
* the HTML meta elements themselves (e.g., meta[property=${key}]), and the
|
||||
* values should be the data you get back from parsing those element's content
|
||||
* attributes
|
||||
*/
|
||||
type AvailableMetadata = Readonly<{
|
||||
user: User;
|
||||
experiments: Experiments;
|
||||
appearance: AppearanceConfig;
|
||||
entitlements: Entitlements;
|
||||
regions: readonly Region[];
|
||||
"build-info": BuildInfoResponse;
|
||||
}>;
|
||||
|
||||
export type MetadataKey = keyof AvailableMetadata;
|
||||
export type MetadataValue = AvailableMetadata[MetadataKey];
|
||||
|
||||
export type MetadataState<T extends MetadataValue> = Readonly<{
|
||||
// undefined chosen to signify missing value because unlike null, it isn't a
|
||||
// valid JSON-serializable value. It's impossible to be returned by the API
|
||||
value: T | undefined;
|
||||
available: boolean;
|
||||
}>;
|
||||
|
||||
const unavailableState = {
|
||||
value: undefined,
|
||||
available: false,
|
||||
} as const satisfies MetadataState<MetadataValue>;
|
||||
|
||||
export type RuntimeHtmlMetadata = Readonly<{
|
||||
[Key in MetadataKey]: MetadataState<AvailableMetadata[Key]>;
|
||||
}>;
|
||||
|
||||
type SubscriptionCallback = (metadata: RuntimeHtmlMetadata) => void;
|
||||
|
||||
type ParseJsonResult<T = unknown> = Readonly<
|
||||
| {
|
||||
value: T;
|
||||
node: Element;
|
||||
}
|
||||
| {
|
||||
value: undefined;
|
||||
node: null;
|
||||
}
|
||||
>;
|
||||
|
||||
interface MetadataManagerApi {
|
||||
subscribe: (callback: SubscriptionCallback) => () => void;
|
||||
getMetadata: () => RuntimeHtmlMetadata;
|
||||
clearMetadataByKey: (key: MetadataKey) => void;
|
||||
}
|
||||
|
||||
export class MetadataManager implements MetadataManagerApi {
|
||||
private readonly metadataKey: string;
|
||||
private readonly subscriptions: Set<SubscriptionCallback>;
|
||||
private readonly trackedMetadataNodes: Map<string, Element | null>;
|
||||
|
||||
private metadata: RuntimeHtmlMetadata;
|
||||
|
||||
constructor(metadataKey?: string) {
|
||||
this.metadataKey = metadataKey ?? DEFAULT_METADATA_KEY;
|
||||
this.subscriptions = new Set();
|
||||
this.trackedMetadataNodes = new Map();
|
||||
|
||||
this.metadata = {
|
||||
user: this.registerValue<User>("user"),
|
||||
appearance: this.registerValue<AppearanceConfig>("appearance"),
|
||||
entitlements: this.registerValue<Entitlements>("entitlements"),
|
||||
experiments: this.registerValue<Experiments>("experiments"),
|
||||
"build-info": this.registerValue<BuildInfoResponse>("build-info"),
|
||||
regions: this.registerRegionValue(),
|
||||
};
|
||||
}
|
||||
|
||||
private notifySubscriptionsOfStateChange(): void {
|
||||
const metadataBinding = this.metadata;
|
||||
this.subscriptions.forEach((cb) => cb(metadataBinding));
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a band-aid solution for code that was specific to the Region
|
||||
* type.
|
||||
*
|
||||
* Ideally the code should be updated on the backend to ensure that the
|
||||
* response is one consistent type, and then this method should be removed
|
||||
* entirely.
|
||||
*
|
||||
* Removing this method would also ensure that the other types in this file
|
||||
* can be tightened up even further (e.g., adding a type constraint to
|
||||
* parseJson)
|
||||
*/
|
||||
private registerRegionValue(): MetadataState<readonly Region[]> {
|
||||
type RegionResponse =
|
||||
| readonly Region[]
|
||||
| Readonly<{
|
||||
regions: readonly Region[];
|
||||
}>;
|
||||
|
||||
const { value, node } = this.parseJson<RegionResponse>("regions");
|
||||
|
||||
let newEntry: MetadataState<readonly Region[]>;
|
||||
if (!node || value === undefined) {
|
||||
newEntry = unavailableState;
|
||||
} else if ("regions" in value) {
|
||||
newEntry = { value: value.regions, available: true };
|
||||
} else {
|
||||
newEntry = { value, available: true };
|
||||
}
|
||||
|
||||
const key = "regions" satisfies MetadataKey;
|
||||
this.trackedMetadataNodes.set(key, node);
|
||||
return newEntry;
|
||||
}
|
||||
|
||||
private registerValue<T extends MetadataValue>(
|
||||
key: MetadataKey,
|
||||
): MetadataState<T> {
|
||||
const { value, node } = this.parseJson<T>(key);
|
||||
|
||||
let newEntry: MetadataState<T>;
|
||||
if (!node || value === undefined) {
|
||||
newEntry = unavailableState;
|
||||
} else {
|
||||
newEntry = { value, available: true };
|
||||
}
|
||||
|
||||
this.trackedMetadataNodes.set(key, node);
|
||||
return newEntry;
|
||||
}
|
||||
|
||||
private parseJson<T = unknown>(key: string): ParseJsonResult<T> {
|
||||
const node = document.querySelector(`meta[${this.metadataKey}=${key}]`);
|
||||
if (!node) {
|
||||
return { value: undefined, node: null };
|
||||
}
|
||||
|
||||
const rawContent = node.getAttribute("content");
|
||||
if (rawContent) {
|
||||
try {
|
||||
const value = JSON.parse(rawContent) as T;
|
||||
return { value, node };
|
||||
} catch (err) {
|
||||
// In development, the metadata is always going to be empty; error is
|
||||
// only a concern for production
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
console.warn(`Failed to parse ${key} metadata. Error message:`);
|
||||
console.warn(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { value: undefined, node: null };
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// All public functions should be defined as arrow functions to ensure that
|
||||
// they cannot lose their `this` context when passed around the React UI
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
subscribe = (callback: SubscriptionCallback): (() => void) => {
|
||||
this.subscriptions.add(callback);
|
||||
return () => this.subscriptions.delete(callback);
|
||||
};
|
||||
|
||||
getMetadata = (): RuntimeHtmlMetadata => {
|
||||
return this.metadata;
|
||||
};
|
||||
|
||||
clearMetadataByKey = (key: MetadataKey): void => {
|
||||
const metadataValue = this.metadata[key];
|
||||
if (!metadataValue.available) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadataNode = this.trackedMetadataNodes.get(key);
|
||||
this.trackedMetadataNodes.delete(key);
|
||||
|
||||
// Delete the node entirely so that no other code can accidentally access
|
||||
// the value after it's supposed to have been made unavailable
|
||||
metadataNode?.remove();
|
||||
|
||||
this.metadata = { ...this.metadata, [key]: unavailableState };
|
||||
this.notifySubscriptionsOfStateChange();
|
||||
};
|
||||
}
|
||||
|
||||
type UseEmbeddedMetadataResult = Readonly<{
|
||||
metadata: RuntimeHtmlMetadata;
|
||||
clearMetadataByKey: MetadataManager["clearMetadataByKey"];
|
||||
}>;
|
||||
|
||||
export function makeUseEmbeddedMetadata(
|
||||
manager: MetadataManager,
|
||||
): () => UseEmbeddedMetadataResult {
|
||||
return function useEmbeddedMetadata(): UseEmbeddedMetadataResult {
|
||||
// Hook binds re-renders to the memory reference of the entire exposed
|
||||
// metadata object, meaning that even if you only care about one metadata
|
||||
// property, the hook will cause a component to re-render if the object
|
||||
// changes at all. If this becomes a performance issue down the line, we can
|
||||
// look into selector functions to minimize re-renders, but let's wait
|
||||
const metadata = useSyncExternalStore(
|
||||
manager.subscribe,
|
||||
manager.getMetadata,
|
||||
);
|
||||
|
||||
const stableMetadataResult = useMemo<UseEmbeddedMetadataResult>(() => {
|
||||
return {
|
||||
metadata,
|
||||
clearMetadataByKey: manager.clearMetadataByKey,
|
||||
};
|
||||
}, [metadata]);
|
||||
|
||||
return stableMetadataResult;
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultMetadataManager = new MetadataManager();
|
||||
export const useEmbeddedMetadata = makeUseEmbeddedMetadata(
|
||||
defaultMetadataManager,
|
||||
);
|
|
@ -16,6 +16,7 @@ import type {
|
|||
} from "api/typesGenerated";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
|
||||
import { hslToHex, isHexColor, isHslColor } from "utils/colors";
|
||||
|
||||
interface Appearance {
|
||||
|
@ -35,9 +36,10 @@ export const DashboardContext = createContext<DashboardValue | undefined>(
|
|||
);
|
||||
|
||||
export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const entitlementsQuery = useQuery(entitlements());
|
||||
const experimentsQuery = useQuery(experiments());
|
||||
const appearanceQuery = useQuery(appearance());
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const entitlementsQuery = useQuery(entitlements(metadata.entitlements));
|
||||
const experimentsQuery = useQuery(experiments(metadata.experiments));
|
||||
const appearanceQuery = useQuery(appearance(metadata.appearance));
|
||||
|
||||
const isLoading =
|
||||
!entitlementsQuery.data || !appearanceQuery.data || !experimentsQuery.data;
|
||||
|
|
|
@ -3,13 +3,16 @@ import { useQuery } from "react-query";
|
|||
import { buildInfo } from "api/queries/buildInfo";
|
||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||
import { useProxy } from "contexts/ProxyContext";
|
||||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import { useFeatureVisibility } from "../useFeatureVisibility";
|
||||
import { NavbarView } from "./NavbarView";
|
||||
|
||||
export const Navbar: FC = () => {
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
|
||||
|
||||
const { appearance } = useDashboard();
|
||||
const buildInfoQuery = useQuery(buildInfo());
|
||||
const { user: me, permissions, signOut } = useAuthenticated();
|
||||
const featureVisibility = useFeatureVisibility();
|
||||
const canViewAuditLog =
|
||||
|
@ -18,6 +21,7 @@ export const Navbar: FC = () => {
|
|||
const canViewAllUsers = Boolean(permissions.readAllUsers);
|
||||
const proxyContextValue = useProxy();
|
||||
const canViewHealth = canViewDeployment;
|
||||
|
||||
return (
|
||||
<NavbarView
|
||||
user={me}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { useQuery } from "react-query";
|
|||
import { deploymentDAUs } from "api/queries/deployment";
|
||||
import { entitlements } from "api/queries/entitlements";
|
||||
import { availableExperiments, experiments } from "api/queries/experiments";
|
||||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { useDeploySettings } from "../DeploySettingsLayout";
|
||||
import { GeneralSettingsPageView } from "./GeneralSettingsPageView";
|
||||
|
@ -11,10 +12,12 @@ import { GeneralSettingsPageView } from "./GeneralSettingsPageView";
|
|||
const GeneralSettingsPage: FC = () => {
|
||||
const { deploymentValues } = useDeploySettings();
|
||||
const deploymentDAUsQuery = useQuery(deploymentDAUs());
|
||||
const entitlementsQuery = useQuery(entitlements());
|
||||
const enabledExperimentsQuery = useQuery(experiments());
|
||||
const safeExperimentsQuery = useQuery(availableExperiments());
|
||||
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const entitlementsQuery = useQuery(entitlements(metadata.entitlements));
|
||||
const enabledExperimentsQuery = useQuery(experiments(metadata.experiments));
|
||||
|
||||
const safeExperiments = safeExperimentsQuery.data?.safe ?? [];
|
||||
const invalidExperiments =
|
||||
enabledExperimentsQuery.data?.filter((exp) => {
|
||||
|
|
|
@ -7,6 +7,7 @@ import { getLicenses, removeLicense } from "api/api";
|
|||
import { getErrorMessage } from "api/errors";
|
||||
import { entitlements, refreshEntitlements } from "api/queries/entitlements";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
|
||||
import { pageTitle } from "utils/page";
|
||||
import LicensesSettingsPageView from "./LicensesSettingsPageView";
|
||||
|
||||
|
@ -15,7 +16,10 @@ const LicensesSettingsPage: FC = () => {
|
|||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const success = searchParams.get("success");
|
||||
const [confettiOn, toggleConfettiOn] = useToggle(false);
|
||||
const entitlementsQuery = useQuery(entitlements());
|
||||
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const entitlementsQuery = useQuery(entitlements(metadata.entitlements));
|
||||
|
||||
const refreshEntitlementsMutation = useMutation(
|
||||
refreshEntitlements(queryClient),
|
||||
);
|
||||
|
|
|
@ -5,6 +5,7 @@ import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
|||
import { buildInfo } from "api/queries/buildInfo";
|
||||
import { authMethods } from "api/queries/users";
|
||||
import { useAuthContext } from "contexts/auth/AuthProvider";
|
||||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
|
||||
import { getApplicationName } from "utils/appearance";
|
||||
import { retrieveRedirect } from "utils/redirect";
|
||||
import { LoginPageView } from "./LoginPageView";
|
||||
|
@ -23,7 +24,9 @@ export const LoginPage: FC = () => {
|
|||
const redirectTo = retrieveRedirect(location.search);
|
||||
const applicationName = getApplicationName();
|
||||
const navigate = useNavigate();
|
||||
const buildInfoQuery = useQuery(buildInfo());
|
||||
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
|
||||
|
||||
if (isSignedIn) {
|
||||
// If the redirect is going to a workspace application, and we
|
||||
|
|
|
@ -53,6 +53,7 @@ import {
|
|||
} from "components/HelpTooltip/HelpTooltip";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { UserAvatar } from "components/UserAvatar/UserAvatar";
|
||||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
|
||||
import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout";
|
||||
import { getLatencyColor } from "utils/latency";
|
||||
import { getTemplatePageTitle } from "../utils";
|
||||
|
@ -91,7 +92,11 @@ export default function TemplateInsightsPage() {
|
|||
const { data: templateInsights } = useQuery(insightsTemplate(insightsFilter));
|
||||
const { data: userLatency } = useQuery(insightsUserLatency(commonFilters));
|
||||
const { data: userActivity } = useQuery(insightsUserActivity(commonFilters));
|
||||
const { data: entitlementsQuery } = useQuery(entitlements());
|
||||
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const { data: entitlementsQuery } = useQuery(
|
||||
entitlements(metadata.entitlements),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -27,6 +27,7 @@ import { displayError } from "components/GlobalSnackbar/utils";
|
|||
import { MemoizedInlineMarkdown } from "components/Markdown/Markdown";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
|
||||
import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { pageTitle } from "utils/page";
|
||||
|
@ -48,9 +49,11 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
|
|||
template,
|
||||
permissions,
|
||||
}) => {
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const buildInfoQuery = useQuery(buildInfo());
|
||||
|
||||
const featureVisibility = useFeatureVisibility();
|
||||
if (workspace === undefined) {
|
||||
throw Error("Workspace is undefined");
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
export const getMetadataAsJSON = <T extends NonNullable<unknown>>(
|
||||
property: string,
|
||||
): T | undefined => {
|
||||
const metadata = document.querySelector(`meta[property=${property}]`);
|
||||
|
||||
if (metadata) {
|
||||
const rawContent = metadata.getAttribute("content");
|
||||
|
||||
if (rawContent) {
|
||||
try {
|
||||
return JSON.parse(rawContent);
|
||||
} catch (err) {
|
||||
// In development, the metadata is always going to be empty; error is
|
||||
// only a concern for production
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
console.warn(`Failed to parse ${property} metadata. Error message:`);
|
||||
console.warn(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
Loading…
Reference in New Issue