diff --git a/src/FlagToggleOverlay.tsx b/src/FlagToggleOverlay.tsx new file mode 100644 index 0000000..d2500b2 --- /dev/null +++ b/src/FlagToggleOverlay.tsx @@ -0,0 +1,231 @@ +import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { ConfigValue } from "@prefab-cloud/prefab-cloud-js"; +import { PrefabContext, ProvidedContext } from "./types"; + +function FlagToggleOverlay({ children }: { children: React.ReactNode | undefined }) { + const parentContext: ProvidedContext = useContext(PrefabContext); + const [showOverlay, setShowOverlay] = useState(false); + const [flags, setFlags] = useState<{ key: string; value: ConfigValue }[]>([]); + const [overrides, setOverrides] = useState>(new Map()); + + // we can't keep track of usage in a state variable, + // because evaluation calls from child component render methods would trigger an infinite render loop + const usageRef = useRef(new Map()); + + const addOverride = (key: string, value: ConfigValue) => { + setOverrides((prevOverrides) => { + const newOverrides = new Map(prevOverrides); + newOverrides.set(key, value); + return newOverrides; + }); + }; + + const clearFlags = () => { + usageRef.current.clear(); + }; + + const contextValue = useMemo( + () => ({ + ...parentContext, + get: (key: string) => { + if (overrides.has(key)) { + const result = overrides.get(key); + usageRef.current.set(key, result); + return result; + } + const result = parentContext.get(key); + usageRef.current.set(key, result); + return result; + }, + isEnabled: (key: string) => { + if (overrides.has(key)) { + const result = overrides.get(key) as boolean; + usageRef.current.set(key, result); + return result; + } + const result = parentContext.isEnabled(key); + usageRef.current.set(key, result); + return result; + }, + }), + [parentContext, overrides] + ); + + // changes to the data in usageRef don't trigger a render, + // so we need to poll for changes + useEffect(() => { + if (showOverlay) { + const interval = setInterval(() => { + setFlags(Array.from(usageRef.current.entries()).map(([key, value]) => ({ key, value }))); + }, 1000); + + return () => clearInterval(interval); + } + + return () => {}; + }, [showOverlay]); + + // setup keyboard shortcut listener to toggle overlay + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.ctrlKey && event.key === "f") { + setShowOverlay((prev) => !prev); + } + }; + window.addEventListener("keydown", handleKeyDown); + + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + // setup listeners for page changes + useEffect(() => { + const handlePopState = () => { + clearFlags(); + }; + + const patchHistoryMethods = () => { + const originalPushState = window.history.pushState; + const originalReplaceState = window.history.replaceState; + + window.history.pushState = function ( + state: any, + unused: string, + url?: string | URL | null | undefined + ) { + const result = originalPushState.apply(window.history, [state, unused, url]); + window.dispatchEvent(new Event("pushState")); + return result; + }; + + window.history.replaceState = function ( + state: any, + unused: string, + url?: string | URL | null | undefined + ) { + const result = originalReplaceState.apply(window.history, [state, unused, url]); + window.dispatchEvent(new Event("replaceState")); + return result; + }; + + window.addEventListener("pushState", handlePopState); + window.addEventListener("replaceState", handlePopState); + + return () => { + window.removeEventListener("pushState", handlePopState); + window.removeEventListener("replaceState", handlePopState); + window.history.pushState = originalPushState; + window.history.replaceState = originalReplaceState; + }; + }; + + const unpatchHistoryMethods = patchHistoryMethods(); + + window.addEventListener("popstate", handlePopState); + + return () => { + window.removeEventListener("popstate", handlePopState); + unpatchHistoryMethods(); + }; + }, []); + + if (!showOverlay) + return {children}; + + return ( + + {children} +
+
+ + + + + + + + + + + + + + + +
+
+
Feature Flags
+
    + {flags.map(({ key, value }) => { + const actualValue = overrides.has(key) ? overrides.get(key) : value; + + return ( +
  • + {typeof actualValue === "boolean" ? ( + + ) : ( + <> + {key} + {actualValue?.toString()} + + )} +
  • + ); + })} +
+
+
+
+ ); +} + +export default FlagToggleOverlay; diff --git a/src/index.tsx b/src/index.tsx index c77ee38..7d01388 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,29 +1,9 @@ import React, { PropsWithChildren } from "react"; import { prefab, ConfigValue, Context } from "@prefab-cloud/prefab-cloud-js"; -import version from "./version"; - -type ContextValue = number | string | boolean; -type ContextAttributes = { [key: string]: Record }; - -type ProvidedContext = { - get: (key: string) => any; - contextAttributes: ContextAttributes; - isEnabled: (key: string) => boolean; - loading: boolean; - prefab: typeof prefab; - keys: string[]; -}; -const defaultContext: ProvidedContext = { - get: (_: string) => undefined, - isEnabled: (_: string) => false, - keys: [], - loading: true, - contextAttributes: {}, - prefab, -}; - -const PrefabContext = React.createContext(defaultContext); +import FlagToggleOverlay from "./FlagToggleOverlay"; +import { ContextAttributes, PrefabContext, ProvidedContext } from "./types"; +import version from "./version"; const usePrefab = () => React.useContext(PrefabContext); @@ -168,6 +148,7 @@ function PrefabTestProvider({ config, children }: PropsWithChildren) export { PrefabProvider, PrefabTestProvider, + FlagToggleOverlay, usePrefab, TestProps, Props, diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..3e2eb86 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,26 @@ +import { createContext } from "react"; +import { prefab } from "@prefab-cloud/prefab-cloud-js"; + +type ContextValue = number | string | boolean; + +export type ContextAttributes = { [key: string]: Record }; + +export type ProvidedContext = { + get: (key: string) => any; + contextAttributes: ContextAttributes; + isEnabled: (key: string) => boolean; + loading: boolean; + prefab: typeof prefab; + keys: string[]; +}; + +const defaultContext: ProvidedContext = { + get: (_: string) => undefined, + isEnabled: (_: string) => false, + keys: [], + loading: true, + contextAttributes: {}, + prefab, +}; + +export const PrefabContext = createContext(defaultContext);