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:
Michael Smith 2024-05-03 10:40:06 -04:00 committed by GitHub
parent ed0ca76b0b
commit 7873c961e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 665 additions and 142 deletions

View File

@ -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>
);
};

View File

@ -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(),
});

View File

@ -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(),
});

View File

@ -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(),
});

View File

@ -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(),
});

View File

@ -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();
},
};

View File

@ -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
>;
}

View File

@ -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;
},
}),
);

View File

@ -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.");

View File

@ -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;
};

View File

@ -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();
});
});

View File

@ -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,
);

View File

@ -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;

View File

@ -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}

View File

@ -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) => {

View File

@ -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),
);

View File

@ -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

View File

@ -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 (
<>

View File

@ -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");

View File

@ -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;
};