diff --git a/.changeset/rich-items-repeat.md b/.changeset/rich-items-repeat.md new file mode 100644 index 00000000000..1a204253ae2 --- /dev/null +++ b/.changeset/rich-items-repeat.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Add support for an experimental FeatureFlags component for working with feature flags in Primer diff --git a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts new file mode 100644 index 00000000000..a561ed33c2b --- /dev/null +++ b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts @@ -0,0 +1,3 @@ +import {FeatureFlagScope} from './FeatureFlagScope' + +export const DefaultFeatureFlags = FeatureFlagScope.create() diff --git a/packages/react/src/FeatureFlags/FeatureFlagContext.ts b/packages/react/src/FeatureFlags/FeatureFlagContext.ts new file mode 100644 index 00000000000..74d86355609 --- /dev/null +++ b/packages/react/src/FeatureFlags/FeatureFlagContext.ts @@ -0,0 +1,5 @@ +import {createContext} from 'react' +import type {FeatureFlagScope} from './FeatureFlagScope' +import {DefaultFeatureFlags} from './DefaultFeatureFlags' + +export const FeatureFlagContext = createContext(DefaultFeatureFlags) diff --git a/packages/react/src/FeatureFlags/FeatureFlagScope.ts b/packages/react/src/FeatureFlags/FeatureFlagScope.ts new file mode 100644 index 00000000000..4aee95cf715 --- /dev/null +++ b/packages/react/src/FeatureFlags/FeatureFlagScope.ts @@ -0,0 +1,50 @@ +export type FeatureFlags = { + [key: string]: boolean +} + +export class FeatureFlagScope { + static create(flags?: FeatureFlags): FeatureFlagScope { + return new FeatureFlagScope(flags) + } + + static merge(a: FeatureFlagScope, b: FeatureFlagScope): FeatureFlagScope { + const merged = new FeatureFlagScope() + + for (const [key, value] of a.flags) { + merged.flags.set(key, value) + } + + for (const [key, value] of b.flags) { + merged.flags.set(key, value) + } + + return merged + } + + flags: Map + + constructor(flags: FeatureFlags = {}) { + this.flags = new Map(Object.entries(flags)) + } + + /** + * Enable a feature flag + */ + public enable(name: string): void { + this.flags.set(name, true) + } + + /** + * Disable a feature flag + */ + public disable(name: string): void { + this.flags.set(name, false) + } + + /** + * Check if a feature flag is enabled + */ + public enabled(name: string): boolean { + return this.flags.get(name) ?? false + } +} diff --git a/packages/react/src/FeatureFlags/FeatureFlags.tsx b/packages/react/src/FeatureFlags/FeatureFlags.tsx new file mode 100644 index 00000000000..ea581ef147b --- /dev/null +++ b/packages/react/src/FeatureFlags/FeatureFlags.tsx @@ -0,0 +1,16 @@ +import React, {useMemo} from 'react' +import {FeatureFlagContext} from './FeatureFlagContext' +import {FeatureFlagScope, type FeatureFlags} from './FeatureFlagScope' +import {DefaultFeatureFlags} from './DefaultFeatureFlags' + +export type FeatureFlagsProps = React.PropsWithChildren<{ + flags: FeatureFlags +}> + +export function FeatureFlags({children, flags}: FeatureFlagsProps) { + const value = useMemo(() => { + const scope = FeatureFlagScope.merge(DefaultFeatureFlags, FeatureFlagScope.create(flags)) + return scope + }, [flags]) + return {children} +} diff --git a/packages/react/src/FeatureFlags/README.md b/packages/react/src/FeatureFlags/README.md new file mode 100644 index 00000000000..988c6134094 --- /dev/null +++ b/packages/react/src/FeatureFlags/README.md @@ -0,0 +1,102 @@ +# Feature flags + +This area is made up of several areas: + +- A `FeatureFlags` context provider that determines what flags are enabled within a React tree +- A `useFeatureFlag` hook that allows a component to check if a flag is enabled +- A `FeatureFlagScope` class that acts as the value being passed around in + context. It allows us to combine and merge multiple groups of feature flags + together in a React tree +- A `GlobalFeatureFlags` value that is used as the default context value. It + holds all flags that are enabled by default + +## FeatureFlags + +This component acts as the context provider for feature flags in a React +application or sub-tree. It accepts a `flags` prop that specifies which the +state of feature flags. + +```tsx +const defaultFeatureFlags = { + enable_new_feature: true, +} + +function App() { + return ( + // Note: the value of `flags` should be memoized or initialized outside of + // render + + + + ) +} +``` + +This component is primarily used at the root of an application. However, it may +also be used for specific sub-trees, as well, to provide different feature flags +for specific routes or areas of an application. + +## useFeatureFlag + +The `useFeatureFlag` hook allows a component to determine if a given feature +flag is enabled. The component may use this hook to conditionally alter the +behavior of a component or render a different component altogether. + +### Change the behavior of a handler based on a feature flag + +```tsx +function ExampleComponent(props) { + const enabled = useFeatureFlag('enable_new_feature') + + function onClick() { + if (enabled) { + // ... + } else { + // ... + } + } + + // ... +} +``` + +### Change the behavior of a component based on a feature flag + +```tsx +function ExampleComponent(props) { + const enabled = useFeatureFlag('enable_new_feature') + if (enabled) { + return + } + return +} +``` + +> [!NOTE] +> In scenarios where you are branching between two different components, it may +> be helpful to use [function overloads](https://www.typescriptlang.org/docs/handbook/2/functions.html#function-overloads) in order for the types to be inferred correctly + +```tsx +function ExampleComponent(props: ClassicProps): React.ReactNode +function ExampleComponent(props: NextProps): React.ReactNode +function ExampleComponent(props: ClassicProps | NextProps): React.ReactNode { + // +} +``` + +By default, using `ClassicProps | NextProps` as the type signature would allow +both props to be applied to a component. Using the function overload, TypeScript +will error if you mix between the two. + +## Testing + +Use the `FeatureFlags` component to set the value of specific feature flags +during tests. + +```tsx +render( + + + , +) +``` diff --git a/packages/react/src/FeatureFlags/__tests__/FeatureFlags.test.tsx b/packages/react/src/FeatureFlags/__tests__/FeatureFlags.test.tsx new file mode 100644 index 00000000000..91469623b34 --- /dev/null +++ b/packages/react/src/FeatureFlags/__tests__/FeatureFlags.test.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import {render} from '@testing-library/react' +import {FeatureFlags, useFeatureFlag} from '../../FeatureFlags' + +describe('FeatureFlags', () => { + it('should allow a component to check if a feature flag is enabled', () => { + const calls: Array = [] + + render( + + + , + ) + + function TestFeatureFlag() { + calls.push(useFeatureFlag('enabledFlag')) + calls.push(useFeatureFlag('disabledFlag')) + return null + } + + expect(calls).toEqual([true, false]) + }) + + it('should set flags that are not defined to `false`', () => { + const calls: Array = [] + + render( + + + , + ) + + function TestFeatureFlag() { + calls.push(useFeatureFlag('unknownFlag')) + return null + } + + expect(calls).toEqual([false]) + }) +}) diff --git a/packages/react/src/FeatureFlags/index.ts b/packages/react/src/FeatureFlags/index.ts new file mode 100644 index 00000000000..6e364de8102 --- /dev/null +++ b/packages/react/src/FeatureFlags/index.ts @@ -0,0 +1,3 @@ +export {FeatureFlags} from './FeatureFlags' +export type {FeatureFlagsProps} from './FeatureFlags' +export {useFeatureFlag} from './useFeatureFlag' diff --git a/packages/react/src/FeatureFlags/useFeatureFlag.ts b/packages/react/src/FeatureFlags/useFeatureFlag.ts new file mode 100644 index 00000000000..97e9b6c9b62 --- /dev/null +++ b/packages/react/src/FeatureFlags/useFeatureFlag.ts @@ -0,0 +1,10 @@ +import {useContext} from 'react' +import {FeatureFlagContext} from './FeatureFlagContext' + +/** + * Check if the given feature flag is enabled + */ +export function useFeatureFlag(flag: string): boolean { + const context = useContext(FeatureFlagContext) + return context.enabled(flag) +} diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index 77f328160d7..d42911d9f54 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -350,6 +350,8 @@ exports[`@primer/react/experimental should not update exports without a semver c "type DialogProps", "type DialogWidth", "type Emoji", + "FeatureFlags", + "type FeatureFlagsProps", "type FileType", "type FileUploadResult", "Hidden", diff --git a/packages/react/src/experimental/index.ts b/packages/react/src/experimental/index.ts index 9680692b574..e4cd5fbb900 100644 --- a/packages/react/src/experimental/index.ts +++ b/packages/react/src/experimental/index.ts @@ -1,3 +1,5 @@ 'use client' export * from '../drafts' +export {FeatureFlags} from '../FeatureFlags' +export type {FeatureFlagsProps} from '../FeatureFlags'