Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 .changeset/rich-items-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Add support for an experimental FeatureFlags component for working with feature flags in Primer
3 changes: 3 additions & 0 deletions packages/react/src/FeatureFlags/DefaultFeatureFlags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {FeatureFlagScope} from './FeatureFlagScope'

export const DefaultFeatureFlags = FeatureFlagScope.create()
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 {DefaultFeatureFlags} from './DefaultFeatureFlags'

export const FeatureFlagContext = createContext<FeatureFlagScope>(DefaultFeatureFlags)
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, {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 <FeatureFlagContext.Provider value={value}>{children}</FeatureFlagContext.Provider>
}
102 changes: 102 additions & 0 deletions packages/react/src/FeatureFlags/README.md
Original file line number Diff line number Diff line change
@@ -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
<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>,
)
```
45 changes: 45 additions & 0 deletions packages/react/src/FeatureFlags/__tests__/FeatureFlags.test.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean> = []

render(
<FeatureFlags
flags={{
enabledFlag: true,
disabledFlag: false,
}}
>
<TestFeatureFlag />
</FeatureFlags>,
)

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<boolean> = []

render(
<FeatureFlags flags={{}}>
<TestFeatureFlag />
</FeatureFlags>,
)

function TestFeatureFlag() {
calls.push(useFeatureFlag('unknownFlag'))
return null
}

expect(calls).toEqual([false])
})
})
3 changes: 3 additions & 0 deletions packages/react/src/FeatureFlags/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export {FeatureFlags} from './FeatureFlags'
export type {FeatureFlagsProps} from './FeatureFlags'
export {useFeatureFlag} from './useFeatureFlag'
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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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'