mirror of
https://github.com/coder/coder.git
synced 2026-11-02 13:33:30 +08:00
Since React 19 supports head tags natively, we no longer need to use `react-helmet`. --------- Co-authored-by: ケイラ <mckayla@hey.com> Co-authored-by: Andrew Aquino <dawneraq@gmail.com>
246 lines
7.4 KiB
TypeScript
246 lines
7.4 KiB
TypeScript
import type {
|
|
AppearanceConfig,
|
|
BuildInfoResponse,
|
|
Entitlements,
|
|
Experiment,
|
|
Region,
|
|
User,
|
|
UserAppearanceSettings,
|
|
} from "api/typesGenerated";
|
|
import { useMemo, useSyncExternalStore } from "react";
|
|
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: Experiment[];
|
|
appearance: AppearanceConfig;
|
|
userAppearance: UserAppearanceSettings;
|
|
entitlements: Entitlements;
|
|
regions: readonly Region[];
|
|
"build-info": BuildInfoResponse;
|
|
"tasks-tab-visible": boolean;
|
|
}>;
|
|
|
|
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"),
|
|
userAppearance:
|
|
this.registerValue<UserAppearanceSettings>("userAppearance"),
|
|
entitlements: this.registerValue<Entitlements>("entitlements"),
|
|
experiments: this.registerValue<Experiment[]>("experiments"),
|
|
"build-info": this.registerValue<BuildInfoResponse>("build-info"),
|
|
regions: this.registerRegionValue(),
|
|
"tasks-tab-visible": this.registerValue<boolean>("tasks-tab-visible"),
|
|
};
|
|
}
|
|
|
|
private notifySubscriptionsOfStateChange(): void {
|
|
const metadataBinding = this.metadata;
|
|
for (const cb of this.subscriptions) {
|
|
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,
|
|
};
|
|
}, [manager, metadata]);
|
|
|
|
return stableMetadataResult;
|
|
};
|
|
}
|
|
|
|
export const defaultMetadataManager = new MetadataManager();
|
|
export const useEmbeddedMetadata = makeUseEmbeddedMetadata(
|
|
defaultMetadataManager,
|
|
);
|