diff --git a/site/src/App.tsx b/site/src/App.tsx index 9b0a554b107af..f2dd6988ec273 100644 --- a/site/src/App.tsx +++ b/site/src/App.tsx @@ -38,9 +38,23 @@ export const AppProviders: FC = ({ }) => { // 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 = ({ export const App: FC = () => { return ( - - + + - - + + ); }; diff --git a/site/src/api/queries/appearance.ts b/site/src/api/queries/appearance.ts index 8178ed602abb7..d9337bc39e79d 100644 --- a/site/src/api/queries/appearance.ts +++ b/site/src/api/queries/appearance.ts @@ -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("appearance"); const appearanceConfigKey = ["appearance"] as const; -export const appearance = (): UseQueryOptions => { - // We either have our initial data or should immediately fetch and never again! +export const appearance = (metadata: MetadataState) => { return cachedQuery({ - initialData: initialAppearanceData, + metadata, queryKey: ["appearance"], queryFn: () => API.getAppearance(), }); diff --git a/site/src/api/queries/buildInfo.ts b/site/src/api/queries/buildInfo.ts index 504b59bd1d341..0f0eecafa9f49 100644 --- a/site/src/api/queries/buildInfo.ts +++ b/site/src/api/queries/buildInfo.ts @@ -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("build-info"); const buildInfoKey = ["buildInfo"] as const; -export const buildInfo = (): UseQueryOptions => { +export const buildInfo = (metadata: MetadataState) => { // The version of the app can't change without reloading the page. return cachedQuery({ - initialData: initialBuildInfoData, + metadata, queryKey: buildInfoKey, queryFn: () => API.getBuildInfo(), }); diff --git a/site/src/api/queries/entitlements.ts b/site/src/api/queries/entitlements.ts index 46b41133bd476..48f43630ea29a 100644 --- a/site/src/api/queries/entitlements.ts +++ b/site/src/api/queries/entitlements.ts @@ -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"); const entitlementsQueryKey = ["entitlements"] as const; -export const entitlements = (): UseQueryOptions => { +export const entitlements = (metadata: MetadataState) => { return cachedQuery({ - initialData: initialEntitlementsData, + metadata, queryKey: entitlementsQueryKey, queryFn: () => API.getEntitlements(), }); diff --git a/site/src/api/queries/experiments.ts b/site/src/api/queries/experiments.ts index 934e44b863437..e0a2749d75829 100644 --- a/site/src/api/queries/experiments.ts +++ b/site/src/api/queries/experiments.ts @@ -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"); const experimentsKey = ["experiments"] as const; -export const experiments = (): UseQueryOptions => { +export const experiments = (metadata: MetadataState) => { return cachedQuery({ - initialData: initialExperimentsData, + metadata, queryKey: experimentsKey, queryFn: () => API.getExperiments(), }); diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 3a1806276146b..ded7c7a5f29c8 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -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"); - 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 & { - queryKey: QueryKey; -} => { +export const me = (metadata: MetadataState) => { return cachedQuery({ - initialData: initialUserData, + metadata, queryKey: meKey, queryFn: API.getAuthenticatedUser, }); @@ -143,10 +141,9 @@ export function apiKey(): UseQueryOptions { }; } -export const hasFirstUser = (): UseQueryOptions => { +export const hasFirstUser = (userMetadata: MetadataState) => { 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(); }, }; diff --git a/site/src/api/queries/util.ts b/site/src/api/queries/util.ts index 6dc02ac897482..6043b984fab93 100644 --- a/site/src/api/queries/util.ts +++ b/site/src/api/queries/util.ts @@ -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, + "initialData" +> & { + metadata: MetadataState; +}; + +type FormattedQueryOptionsResult< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = Omit< + UseQueryOptions, + "initialData" +> & { + queryKey: NonNullable; +}; /** * 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, +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 { + 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 + >; +} diff --git a/site/src/contexts/ProxyContext.tsx b/site/src/contexts/ProxyContext.tsx index 07e1486dd12e6..0f5af37634092 100644 --- a/site/src/contexts/ProxyContext.tsx +++ b/site/src/contexts/ProxyContext.tsx @@ -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 = ({ 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 => { - 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 = ({ children }) => { isFetched: proxiesFetched, } = useQuery( cachedQuery({ - initialData, - queryKey, - queryFn: query, + metadata: metadata.regions, + queryKey: ["get-proxies"], + queryFn: async (): Promise => { + const endpoint = permissions.editWorkspaceProxies + ? getWorkspaceProxies + : getWorkspaceProxyRegions; + const resp = await endpoint(); + return resp.regions; + }, }), ); diff --git a/site/src/contexts/auth/AuthProvider.tsx b/site/src/contexts/auth/AuthProvider.tsx index f581971b1175a..767606e8d605f 100644 --- a/site/src/contexts/auth/AuthProvider.tsx +++ b/site/src/contexts/auth/AuthProvider.tsx @@ -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( ); export const AuthProvider: FC = ({ 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."); diff --git a/site/src/contexts/auth/RequireAuth.tsx b/site/src/contexts/auth/RequireAuth.tsx index d8db9071cc940..7f23da60c3dbb 100644 --- a/site/src/contexts/auth/RequireAuth.tsx +++ b/site/src/contexts/auth/RequireAuth.tsx @@ -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 ( ); @@ -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; + permissions: Exclude; + organizationId: Exclude; +}; + +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; - permissions: Exclude; - organizationId: Exclude; - }; + return auth as NonNullableAuth; }; diff --git a/site/src/hooks/useEmbeddedMetadata.test.ts b/site/src/hooks/useEmbeddedMetadata.test.ts new file mode 100644 index 0000000000000..697e924122ed9 --- /dev/null +++ b/site/src/hooks/useEmbeddedMetadata.test.ts @@ -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>(); +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; + +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(); + 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(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( + 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(); + }); +}); diff --git a/site/src/hooks/useEmbeddedMetadata.ts b/site/src/hooks/useEmbeddedMetadata.ts new file mode 100644 index 0000000000000..61529fe70fa52 --- /dev/null +++ b/site/src/hooks/useEmbeddedMetadata.ts @@ -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 = 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; + +export type RuntimeHtmlMetadata = Readonly<{ + [Key in MetadataKey]: MetadataState; +}>; + +type SubscriptionCallback = (metadata: RuntimeHtmlMetadata) => void; + +type ParseJsonResult = 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; + private readonly trackedMetadataNodes: Map; + + 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"), + appearance: this.registerValue("appearance"), + entitlements: this.registerValue("entitlements"), + experiments: this.registerValue("experiments"), + "build-info": this.registerValue("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 { + type RegionResponse = + | readonly Region[] + | Readonly<{ + regions: readonly Region[]; + }>; + + const { value, node } = this.parseJson("regions"); + + let newEntry: MetadataState; + 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( + key: MetadataKey, + ): MetadataState { + const { value, node } = this.parseJson(key); + + let newEntry: MetadataState; + if (!node || value === undefined) { + newEntry = unavailableState; + } else { + newEntry = { value, available: true }; + } + + this.trackedMetadataNodes.set(key, node); + return newEntry; + } + + private parseJson(key: string): ParseJsonResult { + 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(() => { + return { + metadata, + clearMetadataByKey: manager.clearMetadataByKey, + }; + }, [metadata]); + + return stableMetadataResult; + }; +} + +export const defaultMetadataManager = new MetadataManager(); +export const useEmbeddedMetadata = makeUseEmbeddedMetadata( + defaultMetadataManager, +); diff --git a/site/src/modules/dashboard/DashboardProvider.tsx b/site/src/modules/dashboard/DashboardProvider.tsx index 7c1871946cf4c..3cb85c2461c8d 100644 --- a/site/src/modules/dashboard/DashboardProvider.tsx +++ b/site/src/modules/dashboard/DashboardProvider.tsx @@ -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( ); export const DashboardProvider: FC = ({ 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; diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index d39cfc8417b23..388622fdf7636 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -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 ( { 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) => { diff --git a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx index 8f172cf1155a1..dcd219c99e8c9 100644 --- a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx @@ -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), ); diff --git a/site/src/pages/LoginPage/LoginPage.tsx b/site/src/pages/LoginPage/LoginPage.tsx index 418a734efbe5d..f05c0b40d981f 100644 --- a/site/src/pages/LoginPage/LoginPage.tsx +++ b/site/src/pages/LoginPage/LoginPage.tsx @@ -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 diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx index e067425a639c7..5839fb044941e 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx @@ -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 ( <> diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 5df3e18bc6fe6..e460c7163c1e6 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -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 = ({ 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"); diff --git a/site/src/utils/metadata.ts b/site/src/utils/metadata.ts deleted file mode 100644 index 723b2e508395e..0000000000000 --- a/site/src/utils/metadata.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const getMetadataAsJSON = >( - 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; -};