Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/react/src/FeatureFlags/FeatureFlagContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {createContext} from 'react'
import type {FeatureFlagScope} from './FeatureFlagScope'
import {GlobalFeatureFlags} from './GlobalFeatureFlags'

export const FeatureFlagContext = createContext<FeatureFlagScope>(GlobalFeatureFlags)
50 changes: 50 additions & 0 deletions packages/react/src/FeatureFlags/FeatureFlagScope.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean>

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
}
}
16 changes: 16 additions & 0 deletions packages/react/src/FeatureFlags/FeatureFlags.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React, {useContext, useMemo} from 'react'
import {FeatureFlagContext} from './FeatureFlagContext'
import {FeatureFlagScope, type FeatureFlags} from './FeatureFlagScope'

export type FeatureFlagsProps = React.PropsWithChildren<{
flags: FeatureFlags
}>

export function FeatureFlags({children, flags}: FeatureFlagsProps) {
const parentScope = useContext(FeatureFlagContext)
const value = useMemo(() => {
const scope = FeatureFlagScope.create(flags)
return FeatureFlagScope.merge(parentScope, scope)
}, [parentScope, flags])
return <FeatureFlagContext.Provider value={value}>{children}</FeatureFlagContext.Provider>
}
3 changes: 3 additions & 0 deletions packages/react/src/FeatureFlags/GlobalFeatureFlags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {FeatureFlagScope} from './FeatureFlagScope'

export const GlobalFeatureFlags = FeatureFlagScope.create()
104 changes: 104 additions & 0 deletions packages/react/src/FeatureFlags/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# 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
<FeatureFlags flags={defaultFeatureFlags}>
<Content />
</FeatureFlags>
)
}
```

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 <ExampleComponentNext {...props} />
}
return <ExampleComponentClassic {...props} />
}
```

> [!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(
<FeatureFlags flags={{enableNewFeature: true}}>
<ExampleComponent />
</FeatureFlags>,
)
```

## Storybook
4 changes: 4 additions & 0 deletions packages/react/src/FeatureFlags/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {FeatureFlags} from './FeatureFlags'
export type {FeatureFlagsProps} from './FeatureFlags'
export {useFeatureFlag} from './useFeatureFlag'
export {useFeatureFlags} from './useFeatureFlags'
10 changes: 10 additions & 0 deletions packages/react/src/FeatureFlags/useFeatureFlag.ts
Original file line number Diff line number Diff line change
@@ -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)
}
18 changes: 18 additions & 0 deletions packages/react/src/FeatureFlags/useFeatureFlags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {useContext} from 'react'
import {FeatureFlagContext} from './FeatureFlagContext'

type FlagValue<Input extends ReadonlyArray<string>> = {
[K in Input extends ReadonlyArray<infer U> ? U : never]: boolean
}

/**
* Check if the given feature flag is enabled
*/
export function useFeatureFlags<T extends ReadonlyArray<string>>(flags: T): FlagValue<T> {
const context = useContext(FeatureFlagContext)
return Object.fromEntries(
flags.map(flag => {
return [flag, context.enabled(flag)]
}),
) as FlagValue<T>
}
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,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",
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/experimental/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client'

export * from '../drafts'
export {FeatureFlags} from '../FeatureFlags'
export type {FeatureFlagsProps} from '../FeatureFlags'