diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5af9618..cded89c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,10 +15,11 @@ on: jobs: from-template: - uses: Unleash/.github/.github/workflows/npm-release.yml@v1.1.0 + uses: Unleash/.github/.github/workflows/npm-release.yml@v2.0.0 with: version: ${{ github.event.inputs.version }} tag: ${{ github.event.inputs.tag }} secrets: - GH_ACCESS_TOKEN: ${{ secrets.GH_TOKEN}} NPM_ACCESS_TOKEN: ${{ secrets.NPM_TOKEN }} + UNLEASH_BOT_APP_ID: ${{ secrets.UNLEASH_BOT_APP_ID }} + UNLEASH_BOT_PRIVATE_KEY: ${{ secrets.UNLEASH_BOT_PRIVATE_KEY }} diff --git a/README.md b/README.md index ce2078f..be53ef4 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,12 @@ yarn add @unleash/proxy-client-react unleash-proxy-client # How to use +This library uses the core [unleash-proxy-client](https://github.com/Unleash/unleash-proxy-client-js) JS client as a base. + ## Initialize the client +*NOTE*: [unleash-proxy](https://github.com/Unleash/unleash-proxy) is in maintenance mode. It is recommend to use the [Frontend API](https://docs.getunleash.io/reference/front-end-api) or [unleash-edge](https://github.com/Unleash/unleash-edge) instead. + Prepare [Unleash Proxy](https://docs.getunleash.io/reference/unleash-proxy) secret or [Frontend API Access](https://docs.getunleash.io/reference/front-end-api) token. @@ -42,6 +46,8 @@ root.render( To connect this SDK to your Unleash instance's [front-end API](https://docs.getunleash.io/reference/front-end-api), use the URL to your Unleash instance's front-end API (`/api/frontend`) as the `url` parameter. For the `clientKey` parameter, use a `FRONTEND` token generated from your Unleash instance. Refer to the [_how to create API tokens_](https://docs.getunleash.io/how-to/how-to-create-api-tokens) guide for the necessary steps. +To connect this SDK to unleash-edge, follow the documentation provided in the [unleash-edge repository](https://github.com/unleash/unleash-edge). + To connect this SDK to the [Unleash proxy](https://docs.getunleash.io/reference/unleash-proxy), use the proxy's URL and a [proxy client key](https://docs.getunleash.io/reference/api-tokens-and-client-keys#proxy-client-keys). The [_configuration_ section of the Unleash proxy docs](https://docs.getunleash.io/reference/unleash-proxy#configuration) contains more info on how to configure client keys for your proxy. ## Check feature toggle status @@ -134,6 +140,36 @@ const MyComponent = ({ userId }) => { }; ``` +## Listening to events + +The core JavaScript client emits various types of events depending on internal activities. You can listen to these events by using a hook to access the client and then directly attaching event listeners. Alternatively, if you're using the FlagProvider with a client, you can directly use this client to listen to the events. [See the full list of events here.](https://github.com/Unleash/unleash-proxy-client-js?tab=readme-ov-file#available-events) + +NOTE: `FlagProvider` uses these internal events to provide information through `useFlagsStatus`. + +```jsx +import { useUnleashClient, useFlag } from '@unleash/proxy-client-react'; + +const MyComponent = ({ userId }) => { + const client = useUnleashClient(); + + useEffect(() => { + if (client) { + const handleError = () => { + // Handle error + } + + client.on('error', handleError) + } + + return () => { + client.off('error', handleError) + } + }, [client]) + + // ...rest of component +}; +``` + # Advanced use cases ## Deferring client start @@ -265,6 +301,8 @@ Wrap your components like so: ## React Native +### localStorage + IMPORTANT: This no longer comes included in the unleash-proxy-client-js library. You will need to install the storage adapter for your preferred storage solution. Because React Native doesn't run in a web browser, it doesn't have access to the `localStorage` API. Instead, you need to tell Unleash to use your specific storage provider. The most common storage provider for React Native is [AsyncStorage](https://github.com/react-native-async-storage/async-storage). @@ -282,6 +320,13 @@ const config = { }; ``` +### startTransition + +If your version of React Native doesn't support `startTransition`, you can provide fallback implementation: +```jsx + fn()} > +``` + # Migration guide ## Upgrade path from v1 -> v2 @@ -303,5 +348,9 @@ Upgrading should be as easy as running yarn again with the new version, but we m #### Note on v4.0.0: The major release is driven by Node14 end of life and represents no other changes. From this version onwards we do not guarantee that this library will work server side with Node 14. +## Upgrade path from v4 -> v5 + +[FlagContext public interface changed](https://github.com/Unleash/proxy-client-react/commit/b783ef4016dbb881ac3d878cffaf5241b047cc35#diff-825c82ad66c3934257e0ee3e0511d9223db22e7ddf5de9cbdf6485206e3e02cfL20-R20). If you used FlagContext directly you may have to adjust your code slightly to accomodate the new type changes. + ## Design philosophy This feature flag SDK is designed according to our design philosophy. You can [read more about that here](https://docs.getunleash.io/topics/feature-flags/feature-flag-best-practices). diff --git a/package.json b/package.json index e351914..21e1796 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@unleash/proxy-client-react", - "version": "4.3.0", + "version": "5.0.0", "description": "React interface for working with unleash", "type": "module", "main": "./dist/unleash-react.umd.cjs", @@ -50,12 +50,12 @@ "react-dom": "^18.2.0", "react-test-renderer": "^18.2.0", "typescript": "^5.3.2", - "unleash-proxy-client": "^3.5.1", + "unleash-proxy-client": "^3.7.3", "vite": "^4.5.0", "vite-plugin-dts": "^3.6.3", "vitest": "^0.34.6" }, "peerDependencies": { - "unleash-proxy-client": "^3.5.1" + "unleash-proxy-client": "^3.7.3" } } diff --git a/src/FlagContext.ts b/src/FlagContext.ts index 537720f..dd0cfc5 100644 --- a/src/FlagContext.ts +++ b/src/FlagContext.ts @@ -17,6 +17,6 @@ export interface IFlagContextValue >; } -const FlagContext = React.createContext(null as never); +const FlagContext = React.createContext(null); export default FlagContext; diff --git a/src/FlagProvider.test.tsx b/src/FlagProvider.test.tsx index 4061eed..8c8d679 100644 --- a/src/FlagProvider.test.tsx +++ b/src/FlagProvider.test.tsx @@ -1,9 +1,9 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { render, screen } from '@testing-library/react'; import { type Mock } from 'vitest'; import { UnleashClient, type IVariant, EVENTS } from 'unleash-proxy-client'; import FlagProvider from './FlagProvider'; -import FlagContext from './FlagContext'; +import { useFlagContext } from './useFlagContext'; import '@testing-library/jest-dom'; const getVariantMock = vi.fn().mockReturnValue('A'); @@ -44,8 +44,7 @@ vi.mock('unleash-proxy-client', async (importOriginal) => { const noop = () => {}; const FlagConsumerAfterClientInit = () => { - const { updateContext, isEnabled, getVariant, client, on } = - useContext(FlagContext); + const { updateContext, isEnabled, getVariant, client, on } = useFlagContext(); const [enabled, setIsEnabled] = useState(false); const [variant, setVariant] = useState(null); const [context, setContext] = useState('nothing'); @@ -71,8 +70,7 @@ const FlagConsumerAfterClientInit = () => { }; const FlagConsumerBeforeClientInit = () => { - const { updateContext, isEnabled, getVariant, client, on } = - useContext(FlagContext); + const { updateContext, isEnabled, getVariant, client, on } = useFlagContext(); const [enabled, setIsEnabled] = useState(false); const [variant, setVariant] = useState(null); const [context, setContext] = useState('nothing'); @@ -162,8 +160,7 @@ test('A memoized consumer should not rerender when the context provider values a const renderCounter = vi.fn(); const MemoizedConsumer = React.memo(() => { - const { updateContext, isEnabled, getVariant, client, on } = - useContext(FlagContext); + const { updateContext, isEnabled, getVariant, client, on } = useFlagContext(); renderCounter(); diff --git a/src/FlagProvider.tsx b/src/FlagProvider.tsx index 3e5a742..50331f4 100644 --- a/src/FlagProvider.tsx +++ b/src/FlagProvider.tsx @@ -1,7 +1,7 @@ /** @format */ import React, { type FC, type PropsWithChildren, useEffect, useMemo, useState } from 'react'; -import { type IConfig, UnleashClient } from 'unleash-proxy-client'; +import { type IConfig, IMutableContext, UnleashClient } from 'unleash-proxy-client'; import FlagContext, { type IFlagContextValue } from './FlagContext'; export interface IFlagProvider { @@ -9,6 +9,7 @@ export interface IFlagProvider { unleashClient?: UnleashClient; startClient?: boolean; stopClient?: boolean; + startTransition?: (fn: () => void) => void; } const offlineConfig: IConfig = { @@ -23,7 +24,8 @@ const offlineConfig: IConfig = { // save startTransition as var to avoid webpack analysis (https://github.com/webpack/webpack/issues/14814) const _startTransition = 'startTransition'; // fallback for React <18 which doesn't support startTransition -const startTransition = React[_startTransition] || (fn => fn()); +// Fallback for React <18 and exclude startTransition if in React Native +const defaultStartTransition = React[_startTransition] || (fn => fn()); const FlagProvider: FC> = ({ config: customConfig, @@ -31,6 +33,7 @@ const FlagProvider: FC> = ({ unleashClient, startClient = true, stopClient = true, + startTransition = defaultStartTransition }) => { const config = customConfig || offlineConfig; const client = React.useRef( @@ -105,11 +108,12 @@ const FlagProvider: FC> = ({ const context = useMemo( () => ({ - on: ((event, callback, ctx) => client.current.on(event, callback, ctx)) as IFlagContextValue['on'], - off: ((event, callback) => client.current.off(event, callback)) as IFlagContextValue['off'], - updateContext: async (context) => await client.current.updateContext(context), - isEnabled: (toggleName) => client.current.isEnabled(toggleName), - getVariant: (toggleName) => client.current.getVariant(toggleName), + on: (...args) => client.current.on(...args), + off: (...args) => client.current.off(...args), + isEnabled: (...args) => client.current.isEnabled(...args), + updateContext: async (...args) => + await client.current.updateContext(...args), + getVariant: (...args) => client.current.getVariant(...args), client: client.current, flagsReady, flagsError, diff --git a/src/integration.test.tsx b/src/integration.test.tsx index 7205e6e..6ef6652 100644 --- a/src/integration.test.tsx +++ b/src/integration.test.tsx @@ -1,12 +1,12 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import { EVENTS, UnleashClient } from 'unleash-proxy-client'; import FlagProvider from './FlagProvider'; import useFlagsStatus from './useFlagsStatus'; import { act } from 'react-dom/test-utils'; import useFlag from './useFlag'; +import { useFlagContext } from './useFlagContext'; import useVariant from './useVariant'; -import FlagContext from './FlagContext'; const fetchMock = vi.fn(async () => { return Promise.resolve({ @@ -89,7 +89,7 @@ test('should render toggles', async () => { test('should be ready from the start if bootstrapped', () => { const Component = React.memo(() => { - const { flagsReady } = useContext(FlagContext); + const { flagsReady } = useFlagContext(); return <>{flagsReady ? 'ready' : ''}; }); @@ -183,7 +183,7 @@ test('should render limited times when bootstrapped', async () => { const Component = () => { const enabled = useFlag('test-flag'); - const { flagsReady } = useContext(FlagContext); + const { flagsReady } = useFlagContext(); renders += 1; @@ -229,7 +229,7 @@ test('should resolve values before setting flagsReady', async () => { const Component = () => { const enabled = useFlag('test-flag'); - const { flagsReady } = useContext(FlagContext); + const { flagsReady } = useFlagContext(); renders += 1; diff --git a/src/useFlag.ts b/src/useFlag.ts index 8e13ea8..e853b95 100644 --- a/src/useFlag.ts +++ b/src/useFlag.ts @@ -1,8 +1,8 @@ -import { useContext, useEffect, useState, useRef } from 'react'; -import FlagContext from './FlagContext'; +import { useEffect, useState, useRef } from 'react'; +import { useFlagContext } from './useFlagContext'; const useFlag = (featureName: string) => { - const { isEnabled, client } = useContext(FlagContext); + const { isEnabled, client } = useFlagContext(); const [flag, setFlag] = useState(!!isEnabled(featureName)); const flagRef = useRef(); flagRef.current = flag; diff --git a/src/useFlagContext.test.ts b/src/useFlagContext.test.ts new file mode 100644 index 0000000..c87a9ad --- /dev/null +++ b/src/useFlagContext.test.ts @@ -0,0 +1,24 @@ +import { renderHook } from '@testing-library/react'; +import { vi, test, expect } from 'vitest'; +import FlagProvider from "./FlagProvider"; +import { useFlagContext } from "./useFlagContext"; + +test("logs an error if used outside of a FlagProvider", () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + renderHook(() => useFlagContext()); + expect(consoleSpy).toHaveBeenCalledWith("useFlagContext() must be used within a FlagProvider"); + + consoleSpy.mockRestore(); +}); + +test("does not log an error if used inside of a FlagProvider", () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useFlagContext(), { wrapper: FlagProvider }); + + expect(consoleSpy).not.toHaveBeenCalled(); + expect(result.current).not.toBeNull(); + + consoleSpy.mockRestore(); +}); diff --git a/src/useFlagContext.ts b/src/useFlagContext.ts new file mode 100644 index 0000000..673e7cb --- /dev/null +++ b/src/useFlagContext.ts @@ -0,0 +1,61 @@ +import { useContext } from "react"; +import FlagContext, { type IFlagContextValue } from "./FlagContext"; +import type { UnleashClient } from "unleash-proxy-client"; + +const methods = { + on: (event: string, callback: Function, ctx?: any): UnleashClient => { + console.error("on() must be used within a FlagProvider"); + return mockUnleashClient; + }, + off: (event: string, callback?: Function): UnleashClient => { + console.error("off() must be used within a FlagProvider"); + return mockUnleashClient; + }, + updateContext: async () => { + console.error("updateContext() must be used within a FlagProvider"); + return undefined; + }, + isEnabled: () => { + console.error("isEnabled() must be used within a FlagProvider"); + return false; + }, + getVariant: () => { + console.error("getVariant() must be used within a FlagProvider"); + return { name: "disabled", enabled: false }; + } +}; + +const mockUnleashClient = { + ...methods, + toggles: [], + impressionDataAll: {}, + context: {}, + storage: {}, + start: () => {}, + stop: () => {}, + isReady: () => false, + getError: () => null, + getAllToggles: () => [] +} as unknown as UnleashClient; + +const defaultContextValue: IFlagContextValue = { + ...methods, + client: mockUnleashClient, + flagsReady: false, + setFlagsReady: () => { + console.error("setFlagsReady() must be used within a FlagProvider"); + }, + flagsError: null, + setFlagsError: () => { + console.error("setFlagsError() must be used within a FlagProvider"); + } +}; + +export function useFlagContext() { + const context = useContext(FlagContext); + if (!context) { + console.error("useFlagContext() must be used within a FlagProvider"); + return defaultContextValue; + } + return context; +} diff --git a/src/useFlags.ts b/src/useFlags.ts index 1630144..7a10748 100644 --- a/src/useFlags.ts +++ b/src/useFlags.ts @@ -1,8 +1,8 @@ -import { useContext, useEffect, useState } from 'react'; -import FlagContext from './FlagContext'; +import { useEffect, useState } from 'react'; +import { useFlagContext } from './useFlagContext'; const useFlags = () => { - const { client } = useContext(FlagContext); + const { client } = useFlagContext(); const [flags, setFlags] = useState(client.getAllToggles()); useEffect(() => { diff --git a/src/useFlagsStatus.ts b/src/useFlagsStatus.ts index 5e1cda6..1e0c697 100644 --- a/src/useFlagsStatus.ts +++ b/src/useFlagsStatus.ts @@ -1,10 +1,8 @@ /** @format */ - -import { useContext } from 'react'; -import FlagContext from './FlagContext'; +import { useFlagContext } from './useFlagContext'; const useFlagsStatus = () => { - const { flagsReady, flagsError } = useContext(FlagContext); + const { flagsReady, flagsError } = useFlagContext(); return { flagsReady, flagsError }; }; diff --git a/src/useUnleashClient.ts b/src/useUnleashClient.ts index fae34d5..edcd46a 100644 --- a/src/useUnleashClient.ts +++ b/src/useUnleashClient.ts @@ -1,8 +1,7 @@ -import { useContext } from 'react'; -import FlagContext from './FlagContext'; +import { useFlagContext } from './useFlagContext'; const useUnleashClient = () => { - const { client } = useContext(FlagContext); + const { client } = useFlagContext(); return client; }; diff --git a/src/useUnleashContext.ts b/src/useUnleashContext.ts index 477cce7..c1da10f 100644 --- a/src/useUnleashContext.ts +++ b/src/useUnleashContext.ts @@ -1,8 +1,7 @@ -import { useContext } from 'react'; -import FlagContext from './FlagContext'; +import { useFlagContext } from './useFlagContext'; const useUnleashContext = () => { - const { updateContext } = useContext(FlagContext); + const { updateContext } = useFlagContext(); return updateContext; }; diff --git a/src/useVariant.ts b/src/useVariant.ts index f99e18e..90980d1 100644 --- a/src/useVariant.ts +++ b/src/useVariant.ts @@ -1,6 +1,6 @@ -import { useContext, useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { IVariant } from 'unleash-proxy-client'; -import FlagContext from './FlagContext'; +import { useFlagContext } from './useFlagContext'; export const variantHasChanged = ( oldVariant: IVariant, @@ -17,7 +17,7 @@ export const variantHasChanged = ( }; const useVariant = (featureName: string): Partial => { - const { getVariant, client } = useContext(FlagContext); + const { getVariant, client } = useFlagContext(); const [variant, setVariant] = useState(getVariant(featureName)); const variantRef = useRef({ diff --git a/yarn.lock b/yarn.lock index f44da1a..d27d8dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1545,9 +1545,9 @@ muggle-string@^0.3.1: integrity sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg== nanoid@^3.3.6: - version "3.3.6" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" - integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== + version "3.3.8" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" + integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== node-releases@^2.0.12: version "2.0.12" @@ -1972,10 +1972,10 @@ universalify@^0.2.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== -unleash-proxy-client@^3.5.1: - version "3.5.1" - resolved "https://registry.yarnpkg.com/unleash-proxy-client/-/unleash-proxy-client-3.5.1.tgz#603af23b4c30e3509b8123e7a9ae99a9c38f335d" - integrity sha512-vfWAozp5O16ZedPPH7wFobsZaj8TQQEp/pfj+4jpWZTnOXyFpH6fAgrztRHO26bQ6iC95vVtfeVRQvgw9lo5zA== +unleash-proxy-client@^3.7.3: + version "3.7.3" + resolved "https://registry.yarnpkg.com/unleash-proxy-client/-/unleash-proxy-client-3.7.3.tgz#2f69b4d4a3ea673f6f1c647b50e5a4433b955045" + integrity sha512-Cd7ovrhAIpwveNFdtpzs7HO0WeArC2fbjIVGL2Cjza8bHD+jJ1JbSuy3tFuKvvUkbVKq/EGV0RgosEa/3UVLgg== dependencies: tiny-emitter "^2.1.0" uuid "^9.0.1"