├── .nvmrc ├── .husky ├── .gitignore ├── pre-commit └── commit-msg ├── tooling ├── test-utils │ ├── CHANGELOG.md │ ├── tsconfig.json │ ├── index.js │ └── package.json └── tsconfig │ ├── package.json │ ├── base.json │ └── CHANGELOG.md ├── .gitattributes ├── mise.toml ├── vitrst.workspace.ts ├── demo.gif ├── logo.png ├── pnpm-workspace.yaml ├── packages ├── react-redux │ ├── src │ │ ├── constants.ts │ │ ├── reconfigure.ts │ │ ├── use-adapter-reconfiguration.ts │ │ ├── use-flag-variation.ts │ │ ├── ducks │ │ │ ├── types.ts │ │ │ ├── index.ts │ │ │ ├── status.ts │ │ │ └── flags.ts │ │ ├── types.ts │ │ ├── use-adapter-status.ts │ │ ├── use-update-status.ts │ │ ├── use-all-feature-toggles.ts │ │ ├── use-flag-variations.ts │ │ ├── use-update-flags.ts │ │ ├── use-feature-toggle.ts │ │ ├── toggle-feature.tsx │ │ ├── use-feature-toggles.ts │ │ ├── branch-on-feature-toggle.tsx │ │ ├── inject-feature-toggle.tsx │ │ ├── index.ts │ │ ├── inject-feature-toggles.tsx │ │ ├── configure.tsx │ │ └── enhancer.ts │ ├── tsconfig.json │ ├── vitest.config.ts │ ├── tsup.config.js │ ├── test │ │ ├── test-utils.js │ │ ├── use-flag-variation.spec.jsx │ │ ├── use-adapter-status.spec.jsx │ │ ├── use-feature-toggles.spec.jsx │ │ ├── use-feature-toggle.spec.jsx │ │ ├── use-flag-variations.spec.jsx │ │ ├── configure.spec.jsx │ │ └── toggle-feature.spec.jsx │ ├── readme.md │ └── package.json ├── react-broadcast │ ├── src │ │ ├── reconfigure.ts │ │ ├── use-adapter-reconfiguration.ts │ │ ├── use-flags-context.ts │ │ ├── use-flag-variation.ts │ │ ├── use-adapter-status.ts │ │ ├── flags-context.ts │ │ ├── store.ts │ │ ├── use-all-feature-toggles.ts │ │ ├── use-flag-variations.ts │ │ ├── use-feature-toggle.ts │ │ ├── use-feature-toggles.ts │ │ ├── toggle-feature.tsx │ │ ├── index.ts │ │ ├── branch-on-feature-toggle.tsx │ │ ├── inject-feature-toggle.tsx │ │ ├── inject-feature-toggles.tsx │ │ └── test-provider.tsx │ ├── tsconfig.json │ ├── vitest.config.ts │ ├── tsup.config.js │ ├── readme.md │ ├── test │ │ ├── use-flag-variation.spec.jsx │ │ ├── use-adapter-status.spec.jsx │ │ ├── use-feature-toggles.spec.jsx │ │ ├── use-feature-toggle.spec.jsx │ │ ├── use-flag-variations.spec.jsx │ │ ├── toggle-feature.spec.jsx │ │ ├── test-provider.spec.jsx │ │ ├── inject-feature-toggle.spec.jsx │ │ ├── configure.spec.jsx │ │ └── inject-feature-toggles.spec.jsx │ └── package.json ├── react │ ├── src │ │ ├── is-nil.ts │ │ ├── configure-adapter │ │ │ ├── index.ts │ │ │ └── helpers.ts │ │ ├── constants.ts │ │ ├── use-adapter-context.ts │ │ ├── get-normalized-flag-name.ts │ │ ├── set-display-name.ts │ │ ├── wrap-display-name.ts │ │ ├── use-adapter-reconfiguration.ts │ │ ├── get-is-feature-enabled.ts │ │ ├── reconfigure-adapter.ts │ │ ├── index.ts │ │ ├── get-flag-variation.ts │ │ ├── use-adapter-subscription.ts │ │ ├── toggle-feature.ts │ │ └── adapter-context.ts │ ├── tsconfig.json │ ├── vitest.config.ts │ ├── tsup.config.js │ ├── test │ │ ├── use-adapter-reconfiguration.spec.js │ │ ├── wrap-display-name.spec.js │ │ ├── is-nil.spec.js │ │ ├── set-display-name.spec.js │ │ ├── get-normalized-flag-name.spec.js │ │ ├── configure-adapter │ │ │ └── helpers.spec.js │ │ ├── get-is-feature-enabled.spec.js │ │ └── use-adapter-subscription.spec.jsx │ ├── readme.md │ └── package.json ├── cache │ ├── tsconfig.json │ ├── src │ │ └── index.ts │ ├── vitest.config.ts │ ├── tsup.config.js │ └── package.json ├── types │ ├── tsconfig.json │ ├── tsup.config.js │ ├── readme.md │ └── package.json ├── graphql-adapter │ ├── tsconfig.json │ ├── src │ │ └── index.ts │ ├── vitest.config.ts │ ├── tsup.config.js │ ├── readme.md │ └── package.json ├── http-adapter │ ├── tsconfig.json │ ├── src │ │ └── index.ts │ ├── vitest.config.ts │ ├── tsup.config.js │ ├── readme.md │ └── package.json ├── memory-adapter │ ├── tsconfig.json │ ├── src │ │ └── index.ts │ ├── vitest.config.ts │ ├── tsup.config.js │ ├── readme.md │ └── package.json ├── splitio-adapter │ ├── tsconfig.json │ ├── src │ │ └── index.ts │ ├── vitest.config.ts │ ├── tsup.config.js │ ├── readme.md │ └── package.json ├── adapter-utilities │ ├── tsconfig.json │ ├── src │ │ ├── index.ts │ │ ├── denormalize-flag-name.ts │ │ ├── expose-globally.ts │ │ ├── normalize-flag.ts │ │ └── normalize-flags.ts │ ├── vitest.config.ts │ ├── test │ │ ├── denormalize-flag-name.spec.js │ │ ├── expose-globally.spec.js │ │ ├── normalize-flag.spec.js │ │ └── normalize-flags.spec.js │ ├── tsup.config.js │ ├── readme.md │ └── package.json ├── combine-adapters │ ├── tsconfig.json │ ├── src │ │ └── index.ts │ ├── vitest.config.ts │ ├── tsup.config.js │ ├── readme.md │ └── package.json ├── launchdarkly-adapter │ ├── tsconfig.json │ ├── src │ │ └── index.ts │ ├── @types │ │ └── tiny-invariant │ │ │ └── index.d.ts │ ├── vitest.config.ts │ ├── tsup.config.js │ ├── readme.md │ └── package.json ├── localstorage-adapter │ ├── tsconfig.json │ ├── src │ │ └── index.ts │ ├── vitest.config.ts │ ├── tsup.config.js │ ├── readme.md │ └── package.json ├── localstorage-cache │ ├── tsconfig.json │ ├── src │ │ ├── index.ts │ │ └── cache.ts │ ├── vitest.config.ts │ ├── tsup.config.js │ ├── package.json │ └── test │ │ └── cache.spec.js ├── sessionstorage-cache │ ├── tsconfig.json │ ├── src │ │ ├── index.ts │ │ └── cache.ts │ ├── vitest.config.ts │ ├── tsup.config.js │ ├── package.json │ └── test │ │ └── cache.spec.js └── cypress-plugin │ ├── src │ ├── index.ts │ └── plugin.ts │ ├── tsconfig.json │ ├── tsup.config.js │ ├── readme.md │ └── package.json ├── setup-tests.ts ├── resources ├── logo-2x3.pdf ├── logo-1,5x2.pdf └── logo-circle.pdf ├── .browserslistrc ├── .github ├── FUNDING.yml ├── PULL_REQUEST_TEMPLATE.md ├── actions │ └── ci │ │ └── action.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── settings.yml ├── renovate.json └── workflows │ ├── bundles.yml │ ├── quality.yml │ └── release.yml ├── lint-staged.config.mjs ├── .editorconfig ├── turbo.json ├── codecov.yml ├── .changeset ├── config.json └── README.md ├── commitlint.config.mjs ├── vitest.shared.ts ├── babel-plugin-package-version.js ├── scripts └── echo-release-version.sh ├── AGENTS.md ├── CLAUDE.md ├── .gitignore ├── LICENSE ├── .claude └── commands │ └── openspec │ ├── archive.md │ ├── apply.md │ └── proposal.md ├── patches └── cypress@13.6.4.patch ├── throwing-console-patch.js ├── babel.config.js └── biome.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 24.12 2 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /tooling/test-utils/CHANGELOG.md : -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | node = "24.12.0" 3 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | pnpm lint-staged 4 | -------------------------------------------------------------------------------- /vitrst.workspace.ts: -------------------------------------------------------------------------------- 1 | export default ['./packages/*']; 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | pnpm commitlint --edit $1 4 | 5 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdeekens/flopflip/HEAD/demo.gif -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdeekens/flopflip/HEAD/logo.png -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* 3 | - tooling/* 4 | -------------------------------------------------------------------------------- /packages/react-redux/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const STATE_SLICE = '@flopflip'; 2 | -------------------------------------------------------------------------------- /setup-tests.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import 'vitest-localstorage-mock'; 3 | -------------------------------------------------------------------------------- /packages/react-redux/src/reconfigure.ts: -------------------------------------------------------------------------------- 1 | export { ReconfigureAdapter } from '@flopflip/react'; 2 | -------------------------------------------------------------------------------- /resources/logo-2x3.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdeekens/flopflip/HEAD/resources/logo-2x3.pdf -------------------------------------------------------------------------------- /packages/react-broadcast/src/reconfigure.ts: -------------------------------------------------------------------------------- 1 | export { ReconfigureAdapter } from '@flopflip/react'; 2 | -------------------------------------------------------------------------------- /resources/logo-1,5x2.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdeekens/flopflip/HEAD/resources/logo-1,5x2.pdf -------------------------------------------------------------------------------- /resources/logo-circle.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdeekens/flopflip/HEAD/resources/logo-circle.pdf -------------------------------------------------------------------------------- /packages/react/src/is-nil.ts: -------------------------------------------------------------------------------- 1 | const isNil = (value: any) => value == null; 2 | 3 | export { isNil }; 4 | -------------------------------------------------------------------------------- /packages/react-redux/src/use-adapter-reconfiguration.ts: -------------------------------------------------------------------------------- 1 | export { useAdapterReconfiguration } from '@flopflip/react'; 2 | -------------------------------------------------------------------------------- /packages/react/src/configure-adapter/index.ts: -------------------------------------------------------------------------------- 1 | export { AdapterStates, ConfigureAdapter } from './configure-adapter'; 2 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | [production] 2 | supports es6-module and >0.25% 3 | not ie 11 4 | not op_mini all 5 | 6 | [ssr] 7 | node 12 8 | -------------------------------------------------------------------------------- /packages/react-broadcast/src/use-adapter-reconfiguration.ts: -------------------------------------------------------------------------------- 1 | export { useAdapterReconfiguration } from '@flopflip/react'; 2 | -------------------------------------------------------------------------------- /packages/cache/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@flopflip/tsconfig/base.json", 3 | "exclude": ["**/dist/*", "**/vitest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@flopflip/tsconfig/base.json", 3 | "exclude": ["**/dist/*", "**/vitest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@flopflip/tsconfig/base.json", 3 | "exclude": ["**/dist/*", "**/vitest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tooling/test-utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@flopflip/tsconfig/base.json", 3 | "exclude": ["**/dist/*", "**/vitest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/graphql-adapter/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@flopflip/tsconfig/base.json", 3 | "exclude": ["**/dist/*", "**/vitest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/http-adapter/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@flopflip/tsconfig/base.json", 3 | "exclude": ["**/dist/*", "**/vitest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/memory-adapter/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@flopflip/tsconfig/base.json", 3 | "exclude": ["**/dist/*", "**/vitest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/react-broadcast/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@flopflip/tsconfig/base.json", 3 | "exclude": ["**/dist/*", "**/vitest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/react-redux/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@flopflip/tsconfig/base.json", 3 | "exclude": ["**/dist/*", "**/vitest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/splitio-adapter/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@flopflip/tsconfig/base.json", 3 | "exclude": ["**/dist/*", "**/vitest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: tdeekens 4 | ko_fi: tdeekens 5 | custom: https://paypal.me/tdeekens 6 | -------------------------------------------------------------------------------- /packages/adapter-utilities/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@flopflip/tsconfig/base.json", 3 | "exclude": ["**/dist/*", "**/vitest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/combine-adapters/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@flopflip/tsconfig/base.json", 3 | "exclude": ["**/dist/*", "**/vitest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/launchdarkly-adapter/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@flopflip/tsconfig/base.json", 3 | "exclude": ["**/dist/*", "**/vitest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/localstorage-adapter/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@flopflip/tsconfig/base.json", 3 | "exclude": ["**/dist/*", "**/vitest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/localstorage-cache/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@flopflip/tsconfig/base.json", 3 | "exclude": ["**/dist/*", "**/vitest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/sessionstorage-cache/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@flopflip/tsconfig/base.json", 3 | "exclude": ["**/dist/*", "**/vitest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/graphql-adapter/src/index.ts: -------------------------------------------------------------------------------- 1 | const version = '__@FLOPFLIP/VERSION_OF_RELEASE__'; 2 | 3 | export { adapter } from './adapter'; 4 | export { version }; 5 | -------------------------------------------------------------------------------- /packages/http-adapter/src/index.ts: -------------------------------------------------------------------------------- 1 | const version = '__@FLOPFLIP/VERSION_OF_RELEASE__'; 2 | 3 | export { adapter } from './adapter'; 4 | export { version }; 5 | -------------------------------------------------------------------------------- /packages/memory-adapter/src/index.ts: -------------------------------------------------------------------------------- 1 | const version = '__@FLOPFLIP/VERSION_OF_RELEASE__'; 2 | 3 | export { adapter } from './adapter'; 4 | export { version }; 5 | -------------------------------------------------------------------------------- /packages/splitio-adapter/src/index.ts: -------------------------------------------------------------------------------- 1 | const version = '__@FLOPFLIP/VERSION_OF_RELEASE__'; 2 | 3 | export { version }; 4 | export { adapter } from './adapter'; 5 | -------------------------------------------------------------------------------- /packages/combine-adapters/src/index.ts: -------------------------------------------------------------------------------- 1 | const version = '__@FLOPFLIP/VERSION_OF_RELEASE__'; 2 | 3 | export { adapter } from './adapter'; 4 | export { version }; 5 | -------------------------------------------------------------------------------- /packages/launchdarkly-adapter/src/index.ts: -------------------------------------------------------------------------------- 1 | const version = '__@FLOPFLIP/VERSION_OF_RELEASE__'; 2 | 3 | export { version }; 4 | export { adapter } from './adapter'; 5 | -------------------------------------------------------------------------------- /packages/localstorage-cache/src/index.ts: -------------------------------------------------------------------------------- 1 | const version = '__@FLOPFLIP/VERSION_OF_RELEASE__'; 2 | 3 | export { createCache } from './cache'; 4 | export { version }; 5 | -------------------------------------------------------------------------------- /packages/sessionstorage-cache/src/index.ts: -------------------------------------------------------------------------------- 1 | const version = '__@FLOPFLIP/VERSION_OF_RELEASE__'; 2 | 3 | export { createCache } from './cache'; 4 | export { version }; 5 | -------------------------------------------------------------------------------- /packages/cypress-plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | const version = '__@FLOPFLIP/VERSION_OF_RELEASE__'; 2 | 3 | export { addCommands, install } from './plugin'; 4 | 5 | export { version }; 6 | -------------------------------------------------------------------------------- /packages/localstorage-adapter/src/index.ts: -------------------------------------------------------------------------------- 1 | const version = '__@FLOPFLIP/VERSION_OF_RELEASE__'; 2 | 3 | export { adapter, STORAGE_SLICE } from './adapter'; 4 | export { version }; 5 | -------------------------------------------------------------------------------- /lint-staged.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | '*': [ 3 | 'biome check --write --no-errors-on-unmatched', // Format, sort imports, lint, and apply safe fixes 4 | 'git add -u', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /packages/react/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_FLAG_PROP_KEY = 'isFeatureEnabled'; 2 | export const DEFAULT_FLAGS_PROP_KEY = 'featureToggles'; 3 | export const ALL_FLAGS_PROP_KEY = '@flopflip/flags'; 4 | -------------------------------------------------------------------------------- /tooling/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flopflip/tsconfig", 3 | "version": "15.1.6", 4 | "private": true, 5 | "license": "MIT", 6 | "devDependencies": { 7 | "typescript": "5.9.3" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/cache/src/index.ts: -------------------------------------------------------------------------------- 1 | const version = '__@FLOPFLIP/VERSION_OF_RELEASE__'; 2 | 3 | export { 4 | encodeCacheContext, 5 | getAllCachedFlags, 6 | getCache, 7 | getCachedFlags, 8 | } from './cache'; 9 | export { version }; 10 | -------------------------------------------------------------------------------- /packages/react-broadcast/src/use-flags-context.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { FlagsContext } from './flags-context'; 4 | 5 | const useFlagsContext = () => useContext(FlagsContext); 6 | 7 | export { useFlagsContext }; 8 | -------------------------------------------------------------------------------- /packages/react/src/use-adapter-context.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { AdapterContext } from './adapter-context'; 4 | 5 | const useAdapterContext = () => useContext(AdapterContext); 6 | 7 | export { useAdapterContext }; 8 | -------------------------------------------------------------------------------- /packages/launchdarkly-adapter/@types/tiny-invariant/index.d.ts: -------------------------------------------------------------------------------- 1 | declare function invariant( 2 | testValue: any, 3 | format?: string, 4 | ...extra: readonly any[] 5 | ): void; 6 | 7 | declare module 'tiny-invariant' { 8 | export = invariant; 9 | } 10 | -------------------------------------------------------------------------------- /packages/adapter-utilities/src/index.ts: -------------------------------------------------------------------------------- 1 | export { denormalizeFlagName } from './denormalize-flag-name'; 2 | export { exposeGlobally } from './expose-globally'; 3 | export { normalizeFlag } from './normalize-flag'; 4 | export { normalizeFlags } from './normalize-flags'; 5 | -------------------------------------------------------------------------------- /packages/cypress-plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@flopflip/tsconfig/base.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "types": ["cypress"] 6 | }, 7 | "exclude": [""], 8 | "include": ["../node_modules/cypress", "./**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/adapter-utilities/src/denormalize-flag-name.ts: -------------------------------------------------------------------------------- 1 | import type { TFlagName } from '@flopflip/types'; 2 | import kebabCase from 'lodash/kebabCase.js'; 3 | 4 | const denormalizeFlagName = (flagName: TFlagName) => kebabCase(flagName); 5 | 6 | export { denormalizeFlagName }; 7 | -------------------------------------------------------------------------------- /packages/react/src/get-normalized-flag-name.ts: -------------------------------------------------------------------------------- 1 | import type { TFlagName } from '@flopflip/types'; 2 | import camelCase from 'lodash/camelCase.js'; 3 | 4 | const getNormalizedFlagName = (flagName: TFlagName): TFlagName => 5 | camelCase(flagName); 6 | 7 | export { getNormalizedFlagName }; 8 | -------------------------------------------------------------------------------- /packages/cache/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject, mergeConfig } from 'vitest/config'; 2 | import configShared from '../../vitest.shared.ts'; 3 | 4 | export default mergeConfig( 5 | configShared, 6 | defineProject({ 7 | test: { 8 | environment: 'jsdom', 9 | }, 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /packages/react/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject, mergeConfig } from 'vitest/config'; 2 | import configShared from '../../vitest.shared.ts'; 3 | 4 | export default mergeConfig( 5 | configShared, 6 | defineProject({ 7 | test: { 8 | environment: 'jsdom', 9 | }, 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /packages/http-adapter/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject, mergeConfig } from 'vitest/config'; 2 | import configShared from '../../vitest.shared.ts'; 3 | 4 | export default mergeConfig( 5 | configShared, 6 | defineProject({ 7 | test: { 8 | environment: 'jsdom', 9 | }, 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /packages/react-redux/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject, mergeConfig } from 'vitest/config'; 2 | import configShared from '../../vitest.shared.ts'; 3 | 4 | export default mergeConfig( 5 | configShared, 6 | defineProject({ 7 | test: { 8 | environment: 'jsdom', 9 | }, 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /packages/adapter-utilities/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject, mergeConfig } from 'vitest/config'; 2 | import configShared from '../../vitest.shared.ts'; 3 | 4 | export default mergeConfig( 5 | configShared, 6 | defineProject({ 7 | test: { 8 | environment: 'jsdom', 9 | }, 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /packages/combine-adapters/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject, mergeConfig } from 'vitest/config'; 2 | import configShared from '../../vitest.shared.ts'; 3 | 4 | export default mergeConfig( 5 | configShared, 6 | defineProject({ 7 | test: { 8 | environment: 'jsdom', 9 | }, 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /packages/graphql-adapter/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject, mergeConfig } from 'vitest/config'; 2 | import configShared from '../../vitest.shared.ts'; 3 | 4 | export default mergeConfig( 5 | configShared, 6 | defineProject({ 7 | test: { 8 | environment: 'jsdom', 9 | }, 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /packages/memory-adapter/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject, mergeConfig } from 'vitest/config'; 2 | import configShared from '../../vitest.shared.ts'; 3 | 4 | export default mergeConfig( 5 | configShared, 6 | defineProject({ 7 | test: { 8 | environment: 'jsdom', 9 | }, 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /packages/react-broadcast/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject, mergeConfig } from 'vitest/config'; 2 | import configShared from '../../vitest.shared.ts'; 3 | 4 | export default mergeConfig( 5 | configShared, 6 | defineProject({ 7 | test: { 8 | environment: 'jsdom', 9 | }, 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /packages/splitio-adapter/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject, mergeConfig } from 'vitest/config'; 2 | import configShared from '../../vitest.shared.ts'; 3 | 4 | export default mergeConfig( 5 | configShared, 6 | defineProject({ 7 | test: { 8 | environment: 'jsdom', 9 | }, 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /packages/launchdarkly-adapter/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject, mergeConfig } from 'vitest/config'; 2 | import configShared from '../../vitest.shared.ts'; 3 | 4 | export default mergeConfig( 5 | configShared, 6 | defineProject({ 7 | test: { 8 | environment: 'jsdom', 9 | }, 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /packages/localstorage-adapter/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject, mergeConfig } from 'vitest/config'; 2 | import configShared from '../../vitest.shared.ts'; 3 | 4 | export default mergeConfig( 5 | configShared, 6 | defineProject({ 7 | test: { 8 | environment: 'jsdom', 9 | }, 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /packages/localstorage-cache/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject, mergeConfig } from 'vitest/config'; 2 | import configShared from '../../vitest.shared.ts'; 3 | 4 | export default mergeConfig( 5 | configShared, 6 | defineProject({ 7 | test: { 8 | environment: 'jsdom', 9 | }, 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /packages/sessionstorage-cache/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject, mergeConfig } from 'vitest/config'; 2 | import configShared from '../../vitest.shared.ts'; 3 | 4 | export default mergeConfig( 5 | configShared, 6 | defineProject({ 7 | test: { 8 | environment: 'jsdom', 9 | }, 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["dist/**"] 7 | }, 8 | "check-types": { 9 | "dependsOn": ["^build"] 10 | }, 11 | "test": { 12 | "dependsOn": ["^build"] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: 2 | layout: 'reach, diff, flags, files' 3 | behavior: default 4 | require_changes: false 5 | require_base: no 6 | require_head: yes 7 | branches: null 8 | coverage: 9 | status: 10 | project: 11 | default: 12 | target: 70% 13 | patch: 14 | default: 15 | target: 70% 16 | -------------------------------------------------------------------------------- /packages/react/src/set-display-name.ts: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | 3 | const setDisplayName = 4 | >(nextDisplayName: string) => 5 | (BaseComponent: T): T => { 6 | BaseComponent.displayName = nextDisplayName; 7 | 8 | return BaseComponent; 9 | }; 10 | 11 | export { setDisplayName }; 12 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.6.1/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { 6 | "repo": "tdeekens/flopflip" 7 | } 8 | ], 9 | "commit": false, 10 | "fixed": [["@flopflip/*"]], 11 | "access": "restricted", 12 | "baseBranch": "main" 13 | } 14 | -------------------------------------------------------------------------------- /packages/cache/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm', 'cjs'], 6 | splitting: true, 7 | sourcemap: true, 8 | minify: false, 9 | clean: false, 10 | skipNodeModulesBundle: true, 11 | dts: true, 12 | external: ['node_modules'], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/react/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm', 'cjs'], 6 | splitting: true, 7 | sourcemap: true, 8 | minify: false, 9 | clean: false, 10 | skipNodeModulesBundle: true, 11 | dts: true, 12 | external: ['node_modules'], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/types/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm', 'cjs'], 6 | splitting: true, 7 | sourcemap: true, 8 | minify: false, 9 | clean: false, 10 | skipNodeModulesBundle: true, 11 | dts: true, 12 | external: ['node_modules'], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/adapter-utilities/test/denormalize-flag-name.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { denormalizeFlagName } from '../src/denormalize-flag-name'; 3 | 4 | describe('with camel case', () => { 5 | it('should camel case to uppercase flag names', () => { 6 | expect(denormalizeFlagName('aFlag')).toEqual('a-flag'); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /packages/http-adapter/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm', 'cjs'], 6 | splitting: true, 7 | sourcemap: true, 8 | minify: false, 9 | clean: false, 10 | skipNodeModulesBundle: true, 11 | dts: true, 12 | external: ['node_modules'], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/react-redux/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm', 'cjs'], 6 | splitting: true, 7 | sourcemap: true, 8 | minify: false, 9 | clean: false, 10 | skipNodeModulesBundle: true, 11 | dts: true, 12 | external: ['node_modules'], 13 | }); 14 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### Summary 2 | 3 | 4 | 5 | #### Description 6 | 7 | 8 | 9 | #### Technical debt & future 10 | 11 | 16 | -------------------------------------------------------------------------------- /packages/adapter-utilities/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm', 'cjs'], 6 | splitting: true, 7 | sourcemap: true, 8 | minify: false, 9 | clean: false, 10 | skipNodeModulesBundle: true, 11 | dts: true, 12 | external: ['node_modules'], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/combine-adapters/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm', 'cjs'], 6 | splitting: true, 7 | sourcemap: true, 8 | minify: false, 9 | clean: false, 10 | skipNodeModulesBundle: true, 11 | dts: true, 12 | external: ['node_modules'], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/cypress-plugin/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm', 'cjs'], 6 | splitting: true, 7 | sourcemap: true, 8 | minify: false, 9 | clean: false, 10 | skipNodeModulesBundle: true, 11 | dts: true, 12 | external: ['node_modules'], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/graphql-adapter/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm', 'cjs'], 6 | splitting: true, 7 | sourcemap: true, 8 | minify: false, 9 | clean: false, 10 | skipNodeModulesBundle: true, 11 | dts: true, 12 | external: ['node_modules'], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/localstorage-cache/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm', 'cjs'], 6 | splitting: true, 7 | sourcemap: true, 8 | minify: false, 9 | clean: false, 10 | skipNodeModulesBundle: true, 11 | dts: true, 12 | external: ['node_modules'], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/memory-adapter/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm', 'cjs'], 6 | splitting: true, 7 | sourcemap: true, 8 | minify: false, 9 | clean: false, 10 | skipNodeModulesBundle: true, 11 | dts: true, 12 | external: ['node_modules'], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/react-broadcast/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm', 'cjs'], 6 | splitting: true, 7 | sourcemap: true, 8 | minify: false, 9 | clean: false, 10 | skipNodeModulesBundle: true, 11 | dts: true, 12 | external: ['node_modules'], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/splitio-adapter/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm', 'cjs'], 6 | splitting: true, 7 | sourcemap: true, 8 | minify: false, 9 | clean: false, 10 | skipNodeModulesBundle: true, 11 | dts: true, 12 | external: ['node_modules'], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/launchdarkly-adapter/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm', 'cjs'], 6 | splitting: true, 7 | sourcemap: true, 8 | minify: false, 9 | clean: false, 10 | skipNodeModulesBundle: true, 11 | dts: true, 12 | external: ['node_modules'], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/localstorage-adapter/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm', 'cjs'], 6 | splitting: true, 7 | sourcemap: true, 8 | minify: false, 9 | clean: false, 10 | skipNodeModulesBundle: true, 11 | dts: true, 12 | external: ['node_modules'], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/sessionstorage-cache/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm', 'cjs'], 6 | splitting: true, 7 | sourcemap: true, 8 | minify: false, 9 | clean: false, 10 | skipNodeModulesBundle: true, 11 | dts: true, 12 | external: ['node_modules'], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/adapter-utilities/src/expose-globally.ts: -------------------------------------------------------------------------------- 1 | import type { TAdapter } from '@flopflip/types'; 2 | import getGlobalThis from 'globalthis'; 3 | 4 | const exposeGlobally = (adapter: TAdapter) => { 5 | const global = getGlobalThis(); 6 | 7 | global.__flopflip__ ||= {}; 8 | 9 | global.__flopflip__[adapter.id] = adapter; 10 | }; 11 | 12 | export { exposeGlobally }; 13 | -------------------------------------------------------------------------------- /packages/react/src/wrap-display-name.ts: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | 3 | function wrapDisplayName( 4 | BaseComponent: React.ComponentType, 5 | hocName: string 6 | ) { 7 | const previousDisplayName = BaseComponent.displayName ?? BaseComponent.name; 8 | 9 | return `${hocName}(${previousDisplayName ?? 'Component'})`; 10 | } 11 | 12 | export { wrapDisplayName }; 13 | -------------------------------------------------------------------------------- /packages/react-broadcast/src/use-flag-variation.ts: -------------------------------------------------------------------------------- 1 | import type { TFlagName, TFlagVariation } from '@flopflip/types'; 2 | 3 | import { useFlagVariations } from './use-flag-variations'; 4 | 5 | function useFlagVariation(flagName?: TFlagName): TFlagVariation { 6 | const [flagVariation] = useFlagVariations([flagName]); 7 | 8 | return flagVariation; 9 | } 10 | 11 | export { useFlagVariation }; 12 | -------------------------------------------------------------------------------- /packages/react-redux/src/use-flag-variation.ts: -------------------------------------------------------------------------------- 1 | import type { TFlagName, TFlagVariation } from '@flopflip/types'; 2 | 3 | import { useFlagVariations } from './use-flag-variations'; 4 | 5 | function useFlagVariation(flagName?: TFlagName): TFlagVariation { 6 | const [flagVariation] = useFlagVariations([flagName]); 7 | 8 | return flagVariation; 9 | } 10 | 11 | export { useFlagVariation }; 12 | -------------------------------------------------------------------------------- /commitlint.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ['@commitlint/config-conventional'], 3 | parserPreset: { 4 | parserOpts: { 5 | // Allow to write a "scope" with slashes 6 | // E.g. `refactor(app/my-component): something` 7 | headerPattern: /^(\w*)(?:\(([\w$./\-* ]*)\))?: (.*)$/, 8 | }, 9 | }, 10 | rules: { 11 | 'header-max-length': [0, 'always', 100], 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /tooling/test-utils/index.js: -------------------------------------------------------------------------------- 1 | // This file exists because we want vitest to use our non-compiled code to run tests 2 | // if this file is missing, and you have a `module` or `main` that points to a non-existing file 3 | // (ie, a bundle that hasn't been built yet) then vitest will fail if the bundle is not yet built. 4 | // all apps should export all their named exports from their root index.js 5 | export * from './src'; 6 | -------------------------------------------------------------------------------- /vitest.shared.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import react from '@vitejs/plugin-react'; 3 | import { defineConfig } from 'vitest/config'; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | react({ 8 | jsxRuntime: 'automatic', 9 | }), 10 | ], 11 | test: { 12 | globals: true, 13 | environment: 'jsdom', 14 | setupFiles: [path.resolve(__dirname, 'setup-tests.ts')], 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /packages/react/src/use-adapter-reconfiguration.ts: -------------------------------------------------------------------------------- 1 | import type { TAdapterContext } from '@flopflip/types'; 2 | import { useContext } from 'react'; 3 | 4 | import { AdapterContext } from './adapter-context'; 5 | 6 | function useAdapterReconfiguration() { 7 | const adapterContext: TAdapterContext = useContext(AdapterContext); 8 | 9 | return adapterContext.reconfigure; 10 | } 11 | 12 | export { useAdapterReconfiguration }; 13 | -------------------------------------------------------------------------------- /packages/adapter-utilities/src/normalize-flag.ts: -------------------------------------------------------------------------------- 1 | import type { TFlag, TFlagName, TFlagVariation } from '@flopflip/types'; 2 | import camelCase from 'lodash/camelCase.js'; 3 | 4 | const normalizeFlag = ( 5 | flagName: TFlagName, 6 | flagValue?: TFlagVariation 7 | ): TFlag => [ 8 | camelCase(flagName), 9 | // Multi variate flags contain a string or `null` - `false` seems more natural. 10 | flagValue == null ? false : flagValue, 11 | ]; 12 | 13 | export { normalizeFlag }; 14 | -------------------------------------------------------------------------------- /packages/react-redux/src/ducks/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TAdapterIdentifiers, 3 | TAdapterStatusChange, 4 | TFlagsChange, 5 | } from '@flopflip/types'; 6 | 7 | export type TUpdateStatusAction = { 8 | type: string; 9 | payload: TAdapterStatusChange & { 10 | adapterIdentifiers: TAdapterIdentifiers[]; 11 | }; 12 | }; 13 | 14 | export type TUpdateFlagsAction = { 15 | type: string; 16 | payload: TFlagsChange & { 17 | adapterIdentifiers: TAdapterIdentifiers[]; 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /packages/react-redux/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { TAdaptersStatus, TFlagsContext } from '@flopflip/types'; 2 | 3 | import type { STATE_SLICE } from './constants'; 4 | import type { TUpdateFlagsAction, TUpdateStatusAction } from './ducks/types'; 5 | 6 | export type TState = { 7 | [STATE_SLICE]: { 8 | flags?: TFlagsContext; 9 | status?: TAdaptersStatus; 10 | }; 11 | }; 12 | 13 | export type UpdateFlagsAction = TUpdateFlagsAction; 14 | export type UpdateStatusAction = TUpdateStatusAction; 15 | -------------------------------------------------------------------------------- /packages/adapter-utilities/test/expose-globally.spec.js: -------------------------------------------------------------------------------- 1 | import getGlobalThis from 'globalthis'; 2 | import { expect, it } from 'vitest'; 3 | import { exposeGlobally } from '../src/expose-globally'; 4 | 5 | const testAdapter = { 6 | id: 'test', 7 | configure: () => null, 8 | }; 9 | 10 | it('should expose `adapter` globally', () => { 11 | exposeGlobally(testAdapter); 12 | 13 | const global = getGlobalThis(); 14 | 15 | expect(global).toHaveProperty('__flopflip__.test', testAdapter); 16 | }); 17 | -------------------------------------------------------------------------------- /.github/actions/ci/action.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | description: Shared action to install dependencies 4 | 5 | runs: 6 | using: composite 7 | 8 | steps: 9 | - name: Install pnpm 10 | uses: pnpm/action-setup@v4.2.0 11 | with: 12 | run_install: false 13 | 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v6 16 | with: 17 | node-version-file: ".nvmrc" 18 | cache: "pnpm" 19 | 20 | - name: Install 21 | run: pnpm install 22 | shell: bash 23 | -------------------------------------------------------------------------------- /packages/react-redux/test/test-utils.js: -------------------------------------------------------------------------------- 1 | import { combineReducers, configureStore } from '@reduxjs/toolkit'; 2 | 3 | import { createFlopflipReducer, FLOPFLIP_STATE_SLICE } from '../src/'; 4 | 5 | const defaultInitialState = {}; 6 | 7 | const reducer = combineReducers({ 8 | [FLOPFLIP_STATE_SLICE]: createFlopflipReducer(), 9 | }); 10 | const createStore = (initialState = defaultInitialState) => 11 | configureStore({ 12 | reducer, 13 | preloadedState: initialState, 14 | }); 15 | 16 | export { createStore }; 17 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/master/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /packages/react/test/use-adapter-reconfiguration.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, it, vi } from 'vitest'; 2 | 3 | import { useAdapterReconfiguration } from '../src/use-adapter-reconfiguration'; 4 | 5 | const reconfigure = vi.fn(); 6 | 7 | vi.mock(import('react'), async (importOriginal) => { 8 | const actual = await importOriginal(); 9 | return { 10 | ...actual, 11 | useContext: vi.fn(() => ({ reconfigure })), 12 | }; 13 | }); 14 | 15 | it('should return a function', () => { 16 | expect(useAdapterReconfiguration()).toBe(reconfigure); 17 | }); 18 | -------------------------------------------------------------------------------- /packages/adapter-utilities/test/normalize-flag.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { normalizeFlag } from '../src/normalize-flag'; 3 | 4 | describe('with dashes', () => { 5 | it('should camel case to uppercase flag names', () => { 6 | expect(normalizeFlag('a-flag', 'false')).toEqual(['aFlag', 'false']); 7 | }); 8 | }); 9 | 10 | describe('with whitespace', () => { 11 | it('should camel case to uppercase flag names', () => { 12 | expect(normalizeFlag('a flag', 'false')).toEqual(['aFlag', 'false']); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/react/test/wrap-display-name.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { wrapDisplayName } from '../src/wrap-display-name'; 3 | 4 | function BaseComponent() { 5 | return 'BaseComponent'; 6 | } 7 | 8 | BaseComponent.displayName = 'BaseComponent'; 9 | 10 | describe('rendering', () => { 11 | const hocName = 'testHoc'; 12 | 13 | it('should include `hocName` in wrapped display name', () => { 14 | const wrappedDisplayName = wrapDisplayName(BaseComponent, hocName); 15 | 16 | expect(wrappedDisplayName).toContain(hocName); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | --- 5 | 6 | **Describe the bug** 7 | A clear and concise description of what the bug is. 8 | 9 | **To Reproduce** 10 | Steps to reproduce the behavior: 11 | 12 | 1. Given '...' 13 | 2. Then '....' 14 | 3. That '....' 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /babel-plugin-package-version.js: -------------------------------------------------------------------------------- 1 | const { sync: findUpSync } = require('find-up'); 2 | 3 | const pluginReplacePackageVersion = (babel) => ({ 4 | visitor: { 5 | StringLiteral(path, state) { 6 | if (path.node.value === '__@FLOPFLIP/VERSION_OF_RELEASE__') { 7 | const packageJsonPath = findUpSync('package.json', { 8 | cwd: state.file.opts.filename, 9 | }); 10 | const { version } = require(packageJsonPath); 11 | path.replaceWith(babel.types.valueToNode(version)); 12 | } 13 | }, 14 | }, 15 | }); 16 | 17 | module.exports = pluginReplacePackageVersion; 18 | -------------------------------------------------------------------------------- /packages/react/test/is-nil.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { isNil } from '../src/is-nil'; 3 | 4 | describe('when null', () => { 5 | it('should indicate that the value is nil', () => { 6 | expect(isNil(null)).toBe(true); 7 | }); 8 | }); 9 | 10 | describe('when undefined', () => { 11 | it('should indicate that the value is nil', () => { 12 | expect(isNil(undefined)).toBe(true); 13 | }); 14 | }); 15 | 16 | describe('when anything else', () => { 17 | it('should indicate that the value is not nil', () => { 18 | expect(isNil('foo')).toBe(false); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /packages/react/test/set-display-name.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { setDisplayName } from '../src/set-display-name'; 3 | 4 | function BaseComponent() { 5 | return 'BaseComponent'; 6 | } 7 | 8 | BaseComponent.displayName = 'BaseComponent'; 9 | 10 | describe('rendering', () => { 11 | const nextDisplayName = 'RenamedBaseComponent'; 12 | 13 | it('should overwrite the previous display name', () => { 14 | const EnhancedComponent = setDisplayName(nextDisplayName)(BaseComponent); 15 | 16 | expect(EnhancedComponent.displayName).toEqual(nextDisplayName); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/react-redux/src/use-adapter-status.ts: -------------------------------------------------------------------------------- 1 | import type { TAdapterIdentifiers } from '@flopflip/types'; 2 | import { useDebugValue } from 'react'; 3 | import { useSelector } from 'react-redux'; 4 | 5 | import { selectStatus } from './ducks/status'; 6 | 7 | type TUseAdapterStatusArgs = { adapterIdentifiers?: TAdapterIdentifiers[] }; 8 | function useAdapterStatus({ adapterIdentifiers }: TUseAdapterStatusArgs = {}) { 9 | const adapterStatus = useSelector(selectStatus({ adapterIdentifiers })); 10 | 11 | useDebugValue({ adapterStatus }); 12 | 13 | return adapterStatus; 14 | } 15 | 16 | export { useAdapterStatus }; 17 | -------------------------------------------------------------------------------- /packages/react/src/get-is-feature-enabled.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TAdapterIdentifiers, 3 | TFlagName, 4 | TFlagsContext, 5 | TFlagVariation, 6 | } from '@flopflip/types'; 7 | 8 | import { DEFAULT_FLAG_PROP_KEY } from './constants'; 9 | import { getFlagVariation } from './get-flag-variation'; 10 | 11 | const getIsFeatureEnabled = ( 12 | allFlags: TFlagsContext, 13 | adapterIdentifiers: TAdapterIdentifiers[], 14 | flagName: TFlagName = DEFAULT_FLAG_PROP_KEY, 15 | flagVariation: TFlagVariation = true 16 | ) => getFlagVariation(allFlags, adapterIdentifiers, flagName) === flagVariation; 17 | 18 | export { getIsFeatureEnabled }; 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | --- 5 | 6 | **Is your feature request related to a problem? Please describe.** 7 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 8 | 9 | **Describe the solution you'd like** 10 | A clear and concise description of what you want to happen. 11 | 12 | **Describe alternatives you've considered** 13 | A clear and concise description of any alternative solutions or features you've considered. 14 | 15 | **Additional context** 16 | Add any other context or screenshots about the feature request here. 17 | -------------------------------------------------------------------------------- /packages/react/test/get-normalized-flag-name.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { getNormalizedFlagName } from '../src/get-normalized-flag-name'; 3 | 4 | describe('when not camel caased', () => { 5 | it('should normalized the flag name', () => { 6 | expect(getNormalizedFlagName('foo-flag')).toEqual('fooFlag'); 7 | expect(getNormalizedFlagName('foo_flag')).toEqual('fooFlag'); 8 | expect(getNormalizedFlagName('foo flag')).toEqual('fooFlag'); 9 | }); 10 | }); 11 | 12 | describe('when camel caased', () => { 13 | it('should normalized the flag name', () => { 14 | expect(getNormalizedFlagName('fooFlag')).toEqual('fooFlag'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /packages/react-redux/src/use-update-status.ts: -------------------------------------------------------------------------------- 1 | import { 2 | adapterIdentifiers as allAdapterIdentifiers, 3 | type TAdapterEventHandlers, 4 | type TAdapterStatusChange, 5 | } from '@flopflip/types'; 6 | import { useCallback } from 'react'; 7 | import { useDispatch } from 'react-redux'; 8 | 9 | import { updateStatus } from './ducks/status'; 10 | 11 | const useUpdateStatus = (): TAdapterEventHandlers['onStatusStateChange'] => { 12 | const dispatch = useDispatch(); 13 | 14 | return useCallback( 15 | (statusChange: TAdapterStatusChange) => 16 | dispatch(updateStatus(statusChange, Object.keys(allAdapterIdentifiers))), 17 | [dispatch] 18 | ); 19 | }; 20 | 21 | export { useUpdateStatus }; 22 | -------------------------------------------------------------------------------- /scripts/echo-release-version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | echo "Running 'changeset version' to know the new release version" 6 | 7 | pnpm changeset version 8 | 9 | echo "Running 'git status' to see the worktree changes" 10 | 11 | git status 12 | 13 | echo "Determining the version from the package.json of a package" 14 | release_version=$(node -e "console.log(require('./packages/react/package.json').version)") 15 | 16 | echo "Version for release is $release_version" 17 | 18 | echo "Running 'git reset' and exporting to GITHUB_OUTPUT" 19 | 20 | git reset --hard 21 | 22 | echo "VERSION=$release_version" >> $GITHUB_OUTPUT 23 | 24 | echo "GITHUB_OUTPUT is:" 25 | echo $GITHUB_OUTPUT 26 | 27 | -------------------------------------------------------------------------------- /packages/react-broadcast/src/use-adapter-status.ts: -------------------------------------------------------------------------------- 1 | import { 2 | selectAdapterConfigurationStatus, 3 | useAdapterContext, 4 | } from '@flopflip/react'; 5 | import type { TAdapterIdentifiers } from '@flopflip/types'; 6 | import { useDebugValue } from 'react'; 7 | 8 | type TUseAdapterStatusArgs = { adapterIdentifiers?: TAdapterIdentifiers[] }; 9 | function useAdapterStatus({ adapterIdentifiers }: TUseAdapterStatusArgs = {}) { 10 | const { status } = useAdapterContext(); 11 | 12 | const adapterStatus = selectAdapterConfigurationStatus( 13 | status, 14 | adapterIdentifiers 15 | ); 16 | 17 | useDebugValue({ adapterStatus }); 18 | 19 | return adapterStatus; 20 | } 21 | 22 | export { useAdapterStatus }; 23 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | 2 | # OpenSpec Instructions 3 | 4 | These instructions are for AI assistants working in this project. 5 | 6 | Always open `@/openspec/AGENTS.md` when the request: 7 | - Mentions planning or proposals (words like proposal, spec, change, plan) 8 | - Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work 9 | - Sounds ambiguous and you need the authoritative spec before coding 10 | 11 | Use `@/openspec/AGENTS.md` to learn: 12 | - How to create and apply change proposals 13 | - Spec format and conventions 14 | - Project structure and guidelines 15 | 16 | Keep this managed block so 'openspec update' can refresh the instructions. 17 | 18 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | 2 | # OpenSpec Instructions 3 | 4 | These instructions are for AI assistants working in this project. 5 | 6 | Always open `@/openspec/AGENTS.md` when the request: 7 | - Mentions planning or proposals (words like proposal, spec, change, plan) 8 | - Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work 9 | - Sounds ambiguous and you need the authoritative spec before coding 10 | 11 | Use `@/openspec/AGENTS.md` to learn: 12 | - How to create and apply change proposals 13 | - Spec format and conventions 14 | - Project structure and guidelines 15 | 16 | Keep this managed block so 'openspec update' can refresh the instructions. 17 | 18 | -------------------------------------------------------------------------------- /packages/react-broadcast/src/flags-context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | adapterIdentifiers as allAdapterIdentifiers, 3 | type TFlags, 4 | type TFlagsContext, 5 | } from '@flopflip/types'; 6 | import { createContext } from 'react'; 7 | 8 | const createIntialFlagsContext = ( 9 | adapterIdentifiers: Record, 10 | initialFlags: TFlags 11 | ) => 12 | Object.fromEntries( 13 | Object.values(adapterIdentifiers).map((adapterInterfaceIdentifier) => [ 14 | adapterInterfaceIdentifier, 15 | initialFlags, 16 | ]) 17 | ); 18 | 19 | const FlagsContext = createContext( 20 | createIntialFlagsContext(allAdapterIdentifiers, {}) 21 | ); 22 | 23 | export { FlagsContext, createIntialFlagsContext }; 24 | -------------------------------------------------------------------------------- /packages/react-broadcast/src/store.ts: -------------------------------------------------------------------------------- 1 | type TListener = () => void; 2 | 3 | function createStore>( 4 | initialState: TState 5 | ) { 6 | let state = initialState; 7 | const getSnapshot = () => state; 8 | const listeners = new Set(); 9 | 10 | function setState(fn: (prevState: TState) => TState) { 11 | state = fn(state); 12 | 13 | for (const listener of listeners) { 14 | listener(); 15 | } 16 | } 17 | 18 | function subscribe(listener: TListener) { 19 | listeners.add(listener); 20 | 21 | return () => { 22 | listeners.delete(listener); 23 | }; 24 | } 25 | 26 | return { getSnapshot, setState, subscribe }; 27 | } 28 | 29 | export { createStore }; 30 | -------------------------------------------------------------------------------- /packages/types/readme.md: -------------------------------------------------------------------------------- 1 |

2 | 🎛 flopflip - Feature Toggling 🎚
3 | flip or flop a feature via adapters (e.g. memory or LaunchDarkly) with real-time updates through a Redux store or React's context. 4 |

5 | 6 |

7 | Logo

8 | Toggle (flip or flop) features being stored in Redux or in a broadcasting system (through the context) via a set of React components or HoCs. 9 |

10 | 11 | ### `@flopflip/types` 12 | 13 | This repository is part of the `flopflip` mono repository. Please head [here](https://github.com/tdeekens/flopflip) for more information. 14 | -------------------------------------------------------------------------------- /packages/adapter-utilities/readme.md: -------------------------------------------------------------------------------- 1 |

2 | 🎛 flopflip - Feature Toggling 🎚
3 | flip or flop a feature via adapters (e.g. memory or LaunchDarkly) with real-time updates through a Redux store or React's context. 4 |

5 | 6 |

7 | Logo

8 | Toggle (flip or flop) features being stored in Redux or in a broadcasting system (through the context) via a set of React components or HoCs. 9 |

10 | 11 | ### `@flopflip/types` 12 | 13 | This repository is part of the `flopflip` mono repository. Please head [here](https://github.com/tdeekens/flopflip) for more information. 14 | -------------------------------------------------------------------------------- /packages/react-broadcast/src/use-all-feature-toggles.ts: -------------------------------------------------------------------------------- 1 | import { useAdapterContext } from '@flopflip/react'; 2 | import type { TFlags } from '@flopflip/types'; 3 | 4 | import { useFlagsContext } from './use-flags-context'; 5 | 6 | function useAllFeatureToggles(): TFlags { 7 | const adapterContext = useAdapterContext(); 8 | const flagsContext = useFlagsContext(); 9 | const reversedAdapterEffectIdentifiers = [ 10 | ...adapterContext.adapterEffectIdentifiers, 11 | ].reverse(); 12 | 13 | return reversedAdapterEffectIdentifiers.reduce( 14 | (_allFlags, adapterIdentifier) => ({ 15 | ..._allFlags, 16 | ...flagsContext[adapterIdentifier], 17 | }), 18 | {} 19 | ); 20 | } 21 | 22 | export { useAllFeatureToggles }; 23 | -------------------------------------------------------------------------------- /packages/react-redux/readme.md: -------------------------------------------------------------------------------- 1 |

2 | 🎛 flopflip - Feature Toggling 🎚
3 | flip or flop a feature via adapters (e.g. memory or LaunchDarkly) with real-time updates through a Redux store or React's context. 4 |

5 | 6 |

7 | Logo

8 | Toggle (flip or flop) features being stored in Redux or in a broadcasting system (through the context) via a set of React components or HoCs. 9 |

10 | 11 | ### `@flopflip/react-redux` 12 | 13 | This repository is part of the `flopflip` mono repository. Please head [here](https://github.com/tdeekens/flopflip) for more information. 14 | -------------------------------------------------------------------------------- /packages/react/readme.md: -------------------------------------------------------------------------------- 1 |

2 | 🎛 flopflip - Feature Toggling 🎚
3 | flip or flop a feature via adapters (e.g. memory or LaunchDarkly) with real-time updates through a Redux store or React's context. 4 |

5 | 6 |

7 | Logo

8 | Toggle (flip or flop) features being stored in Redux or in a broadcasting system (through the context) via a set of React components or HoCs. 9 |

10 | 11 | ### `@flopflip/launchdarkly-react` 12 | 13 | This repository is part of the `flopflip` mono repository. Please head [here](https://github.com/tdeekens/flopflip) for more information. 14 | -------------------------------------------------------------------------------- /packages/cypress-plugin/readme.md: -------------------------------------------------------------------------------- 1 |

2 | 🎛 flopflip - Feature Toggling 🎚
3 | flip or flop a feature via adapters (e.g. memory or LaunchDarkly) with real-time updates through a Redux store or React's context. 4 |

5 | 6 |

7 | Logo

8 | Toggle (flip or flop) features being stored in Redux or in a broadcasting system (through the context) via a set of React components or HoCs. 9 |

10 | 11 | ### `@flopflip/cypress-plugin` 12 | 13 | This repository is part of the `flopflip` mono repository. Please head [here](https://github.com/tdeekens/flopflip) for more information. 14 | -------------------------------------------------------------------------------- /packages/http-adapter/readme.md: -------------------------------------------------------------------------------- 1 |

2 | 🎛 flopflip - Feature Toggling 🎚
3 | flip or flop a feature via adapters (e.g. memory or LaunchDarkly) with real-time updates through a Redux store or React's context. 4 |

5 | 6 |

7 | Logo

8 | Toggle (flip or flop) features being stored in Redux or in a broadcasting system (through the context) via a set of React components or HoCs. 9 |

10 | 11 | ### `@flopflip/memory-adapter` 12 | 13 | This repository is part of the `flopflip` mono repository. Please head [here](https://github.com/tdeekens/flopflip) for more information. 14 | -------------------------------------------------------------------------------- /packages/memory-adapter/readme.md: -------------------------------------------------------------------------------- 1 |

2 | 🎛 flopflip - Feature Toggling 🎚
3 | flip or flop a feature via adapters (e.g. memory or LaunchDarkly) with real-time updates through a Redux store or React's context. 4 |

5 | 6 |

7 | Logo

8 | Toggle (flip or flop) features being stored in Redux or in a broadcasting system (through the context) via a set of React components or HoCs. 9 |

10 | 11 | ### `@flopflip/memory-adapter` 12 | 13 | This repository is part of the `flopflip` mono repository. Please head [here](https://github.com/tdeekens/flopflip) for more information. 14 | -------------------------------------------------------------------------------- /packages/combine-adapters/readme.md: -------------------------------------------------------------------------------- 1 |

2 | 🎛 flopflip - Feature Toggling 🎚
3 | flip or flop a feature via adapters (e.g. memory or LaunchDarkly) with real-time updates through a Redux store or React's context. 4 |

5 | 6 |

7 | Logo

8 | Toggle (flip or flop) features being stored in Redux or in a broadcasting system (through the context) via a set of React components or HoCs. 9 |

10 | 11 | ### `@flopflip/memory-adapter` 12 | 13 | This repository is part of the `flopflip` mono repository. Please head [here](https://github.com/tdeekens/flopflip) for more information. 14 | -------------------------------------------------------------------------------- /packages/graphql-adapter/readme.md: -------------------------------------------------------------------------------- 1 |

2 | 🎛 flopflip - Feature Toggling 🎚
3 | flip or flop a feature via adapters (e.g. memory or LaunchDarkly) with real-time updates through a Redux store or React's context. 4 |

5 | 6 |

7 | Logo

8 | Toggle (flip or flop) features being stored in Redux or in a broadcasting system (through the context) via a set of React components or HoCs. 9 |

10 | 11 | ### `@flopflip/memory-adapter` 12 | 13 | This repository is part of the `flopflip` mono repository. Please head [here](https://github.com/tdeekens/flopflip) for more information. 14 | -------------------------------------------------------------------------------- /packages/react-broadcast/readme.md: -------------------------------------------------------------------------------- 1 |

2 | 🎛 flopflip - Feature Toggling 🎚
3 | flip or flop a feature via adapters (e.g. memory or LaunchDarkly) with real-time updates through a Redux store or React's context. 4 |

5 | 6 |

7 | Logo

8 | Toggle (flip or flop) features being stored in Redux or in a broadcasting system (through the context) via a set of React components or HoCs. 9 |

10 | 11 | ### `@flopflip/react-broadcast` 12 | 13 | This repository is part of the `flopflip` mono repository. Please head [here](https://github.com/tdeekens/flopflip) for more information. 14 | -------------------------------------------------------------------------------- /packages/splitio-adapter/readme.md: -------------------------------------------------------------------------------- 1 |

2 | 🎛 flopflip - Feature Toggling 🎚
3 | flip or flop a feature via adapters (e.g. memory or LaunchDarkly) with real-time updates through a Redux store or React's context. 4 |

5 | 6 |

7 | Logo

8 | Toggle (flip or flop) features being stored in Redux or in a broadcasting system (through the context) via a set of React components or HoCs. 9 |

10 | 11 | ### `@flopflip/splitio-adapter` 12 | 13 | This repository is part of the `flopflip` mono repository. Please head [here](https://github.com/tdeekens/flopflip) for more information. 14 | -------------------------------------------------------------------------------- /packages/launchdarkly-adapter/readme.md: -------------------------------------------------------------------------------- 1 |

2 | 🎛 flopflip - Feature Toggling 🎚
3 | flip or flop a feature via adapters (e.g. memory or LaunchDarkly) with real-time updates through a Redux store or React's context. 4 |

5 | 6 |

7 | Logo

8 | Toggle (flip or flop) features being stored in Redux or in a broadcasting system (through the context) via a set of React components or HoCs. 9 |

10 | 11 | ### `@flopflip/launchdarkly-adapter` 12 | 13 | This repository is part of the `flopflip` mono repository. Please head [here](https://github.com/tdeekens/flopflip) for more information. 14 | -------------------------------------------------------------------------------- /packages/localstorage-adapter/readme.md: -------------------------------------------------------------------------------- 1 |

2 | 🎛 flopflip - Feature Toggling 🎚
3 | flip or flop a feature via adapters (e.g. memory or LaunchDarkly) with real-time updates through a Redux store or React's context. 4 |

5 | 6 |

7 | Logo

8 | Toggle (flip or flop) features being stored in Redux or in a broadcasting system (through the context) via a set of React components or HoCs. 9 |

10 | 11 | ### `@flopflip/localstorage-adapter` 12 | 13 | This repository is part of the `flopflip` mono repository. Please head [here](https://github.com/tdeekens/flopflip) for more information. 14 | -------------------------------------------------------------------------------- /packages/react-broadcast/src/use-flag-variations.ts: -------------------------------------------------------------------------------- 1 | import { getFlagVariation, useAdapterContext } from '@flopflip/react'; 2 | import type { TFlagName, TFlagVariation } from '@flopflip/types'; 3 | 4 | import { useFlagsContext } from './use-flags-context'; 5 | 6 | function useFlagVariations( 7 | flagNames: Array 8 | ): TFlagVariation[] { 9 | const adapterContext = useAdapterContext(); 10 | const flagsContext = useFlagsContext(); 11 | 12 | const flagVariations: TFlagVariation[] = flagNames.map((requestedVariation) => 13 | getFlagVariation( 14 | flagsContext, 15 | adapterContext.adapterEffectIdentifiers, 16 | requestedVariation 17 | ) 18 | ); 19 | 20 | return flagVariations; 21 | } 22 | 23 | export { useFlagVariations }; 24 | -------------------------------------------------------------------------------- /packages/react-redux/src/ducks/index.ts: -------------------------------------------------------------------------------- 1 | import type { TFlagsContext } from '@flopflip/types'; 2 | import { combineReducers } from '@reduxjs/toolkit'; 3 | 4 | import { 5 | createReducer as createFlagsReducer, 6 | reducer as flagsReducer, 7 | } from './flags'; 8 | import { reducer as statusReducer } from './status'; 9 | 10 | export { selectFlag, selectFlags, updateFlags } from './flags'; 11 | export { updateStatus } from './status'; 12 | 13 | export const flopflipReducer = combineReducers({ 14 | flags: flagsReducer, 15 | status: statusReducer, 16 | }); 17 | export const createFlopflipReducer = ( 18 | preloadedState: TFlagsContext = { memory: {} } 19 | ) => 20 | combineReducers({ 21 | flags: createFlagsReducer(preloadedState), 22 | status: statusReducer, 23 | }); 24 | -------------------------------------------------------------------------------- /packages/react-redux/src/use-all-feature-toggles.ts: -------------------------------------------------------------------------------- 1 | import { useAdapterContext } from '@flopflip/react'; 2 | import type { TFlags } from '@flopflip/types'; 3 | import { useSelector } from 'react-redux'; 4 | 5 | import { selectFlags } from './ducks/flags'; 6 | 7 | function useAllFeatureToggles(): TFlags { 8 | const adapterContext = useAdapterContext(); 9 | const allFlags = useSelector(selectFlags()); 10 | const reversedAdapterEffectIdentifiers = [ 11 | ...adapterContext.adapterEffectIdentifiers, 12 | ].reverse(); 13 | 14 | return reversedAdapterEffectIdentifiers.reduce( 15 | (_allFlags, adapterIdentifier) => ({ 16 | ..._allFlags, 17 | ...allFlags[adapterIdentifier], 18 | }), 19 | {} 20 | ); 21 | } 22 | 23 | export { useAllFeatureToggles }; 24 | -------------------------------------------------------------------------------- /packages/react-redux/src/use-flag-variations.ts: -------------------------------------------------------------------------------- 1 | import { getFlagVariation, useAdapterContext } from '@flopflip/react'; 2 | import type { TFlagName, TFlagVariation } from '@flopflip/types'; 3 | import { useSelector } from 'react-redux'; 4 | 5 | import { selectFlags } from './ducks/flags'; 6 | 7 | function useFlagVariations( 8 | flagNames: Array 9 | ): TFlagVariation[] { 10 | const adapterContext = useAdapterContext(); 11 | const allFlags = useSelector(selectFlags()); 12 | const flagVariations: TFlagVariation[] = flagNames.map((requestedVariation) => 13 | getFlagVariation( 14 | allFlags, 15 | adapterContext.adapterEffectIdentifiers, 16 | requestedVariation 17 | ) 18 | ); 19 | 20 | return flagVariations; 21 | } 22 | 23 | export { useFlagVariations }; 24 | -------------------------------------------------------------------------------- /packages/react-redux/src/use-update-flags.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TAdapterEventHandlers, 3 | TAdapterIdentifiers, 4 | TFlagsChange, 5 | } from '@flopflip/types'; 6 | import { useCallback } from 'react'; 7 | import { useDispatch } from 'react-redux'; 8 | 9 | import { updateFlags } from './ducks/flags'; 10 | 11 | type TUseUpdateFlagsOptions = { 12 | adapterIdentifiers: TAdapterIdentifiers[]; 13 | }; 14 | const useUpdateFlags = ({ 15 | adapterIdentifiers, 16 | }: TUseUpdateFlagsOptions): TAdapterEventHandlers['onFlagsStateChange'] => { 17 | const dispatch = useDispatch(); 18 | 19 | return useCallback( 20 | (flagsChange: TFlagsChange) => 21 | dispatch(updateFlags(flagsChange, adapterIdentifiers)), 22 | [dispatch, adapterIdentifiers] 23 | ); 24 | }; 25 | 26 | export { useUpdateFlags }; 27 | -------------------------------------------------------------------------------- /tooling/test-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "@flopflip/test-utils", 4 | "version": "15.1.6", 5 | "description": "Test utils for flipflop", 6 | "main": "index.js", 7 | "author": "Tobias Deekens ", 8 | "bugs": { 9 | "url": "https://github.com/tdeekens/flopflip/issues" 10 | }, 11 | "homepage": "https://github.com/tdeekens/flopflip#readme", 12 | "keywords": [ 13 | "feature-flags", 14 | "feature-toggles", 15 | "types" 16 | ], 17 | "dependencies": { 18 | "@babel/runtime": "7.28.4", 19 | "@flopflip/memory-adapter": "workspace:*", 20 | "@flopflip/tsconfig": "workspace:*", 21 | "@testing-library/jest-dom": "6.9.1", 22 | "@testing-library/react": "16.3.0", 23 | "react": "19.2.3", 24 | "react-dom": "19.2.3" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | .npmrc 36 | 37 | # Optional REPL history 38 | .node_repl_history 39 | dist 40 | 41 | coverage 42 | 43 | .vscode 44 | 45 | junit.xml 46 | 47 | .turbo 48 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | repository: 2 | name: flopflip 3 | description: 🎚Flip or flop features in your React application in real-time backed by flag provider of your choice 🚦 4 | homepage: https://techblog.commercetools.com/embracing-real-time-feature-toggling-in-your-react-application-a5e6052716a9 5 | private: false 6 | has_issues: true 7 | has_wiki: false 8 | has_downloads: true 9 | default_branch: main 10 | allow_squash_merge: true 11 | allow_merge_commit: true 12 | allow_rebase_merge: true 13 | labels: 14 | - name: Automerge 15 | color: cfdb62 16 | - name: Bug 17 | color: ee0701 18 | - name: Documentation 19 | color: d4c5f9 20 | - name: ✨ Feature 21 | color: 84b6eb 22 | - name: ❓ Question 23 | color: cc317c 24 | - name: ❤️ Refactoring 25 | color: fbca04 26 | - name: Wontfix 27 | color: ffffff 28 | -------------------------------------------------------------------------------- /packages/adapter-utilities/src/normalize-flags.ts: -------------------------------------------------------------------------------- 1 | import type { TFlag, TFlags, TFlagVariation } from '@flopflip/types'; 2 | 3 | import { normalizeFlag as defaultNormalizeFlag } from './normalize-flag'; 4 | 5 | const normalizeFlags = ( 6 | rawFlags: TFlags, 7 | normalizer: typeof defaultNormalizeFlag = defaultNormalizeFlag 8 | ): Record => 9 | Object.entries(rawFlags || {}).reduce( 10 | (normalizedFlags: TFlags, [flagName, flagValue]) => { 11 | const [normalizedFlagName, normalizedFlagValue]: TFlag = normalizer( 12 | flagName, 13 | flagValue 14 | ); 15 | // Can't return expression as it is the assigned value 16 | normalizedFlags[normalizedFlagName] = normalizedFlagValue; 17 | 18 | return normalizedFlags; 19 | }, 20 | {} 21 | ); 22 | 23 | export { normalizeFlags }; 24 | -------------------------------------------------------------------------------- /packages/react-broadcast/test/use-flag-variation.spec.jsx: -------------------------------------------------------------------------------- 1 | import { renderWithAdapter, screen } from '@flopflip/test-utils'; 2 | import { expect, it } from 'vitest'; 3 | 4 | import { Configure } from '../src/configure'; 5 | import { useFlagVariation } from '../src/use-flag-variation'; 6 | 7 | const render = (TestComponent) => 8 | renderWithAdapter(TestComponent, { 9 | components: { ConfigureFlopFlip: Configure }, 10 | }); 11 | 12 | function TestComponent() { 13 | const variation = useFlagVariation('variation'); 14 | 15 | return ( 16 |
    17 |
  • Variation: {variation}
  • 18 |
19 | ); 20 | } 21 | 22 | it('should indicate a flag variation', async () => { 23 | const { waitUntilConfigured } = render(); 24 | 25 | await waitUntilConfigured(); 26 | 27 | expect(screen.getByText('Variation: A')).toBeInTheDocument(); 28 | }); 29 | -------------------------------------------------------------------------------- /packages/sessionstorage-cache/src/cache.ts: -------------------------------------------------------------------------------- 1 | import type { TCache, TCacheOptions } from '@flopflip/types'; 2 | 3 | const createCache = (options: TCacheOptions) => { 4 | const cache: TCache = { 5 | get(key) { 6 | const sessionStorageValue = sessionStorage.getItem( 7 | [options.prefix, key].join('/') 8 | ); 9 | 10 | return sessionStorageValue ? JSON.parse(sessionStorageValue) : null; 11 | }, 12 | set(key, value) { 13 | try { 14 | sessionStorage.setItem( 15 | [options.prefix, key].join('/'), 16 | JSON.stringify(value) 17 | ); 18 | return true; 19 | } catch (_error) { 20 | return false; 21 | } 22 | }, 23 | unset(key) { 24 | sessionStorage.removeItem([options.prefix, key].join('/')); 25 | }, 26 | }; 27 | 28 | return cache; 29 | }; 30 | 31 | export { createCache }; 32 | -------------------------------------------------------------------------------- /packages/react-broadcast/src/use-feature-toggle.ts: -------------------------------------------------------------------------------- 1 | import { getIsFeatureEnabled, useAdapterContext } from '@flopflip/react'; 2 | import type { TFlagName, TFlagVariation } from '@flopflip/types'; 3 | import { useDebugValue } from 'react'; 4 | 5 | import { useFlagsContext } from './use-flags-context'; 6 | 7 | function useFeatureToggle( 8 | flagName: TFlagName, 9 | flagVariation: TFlagVariation = true 10 | ) { 11 | const adapterContext = useAdapterContext(); 12 | const flagsContext = useFlagsContext(); 13 | const isFeatureEnabled: boolean = getIsFeatureEnabled( 14 | flagsContext, 15 | adapterContext.adapterEffectIdentifiers, 16 | flagName, 17 | flagVariation 18 | ); 19 | 20 | useDebugValue({ 21 | flagName, 22 | flagVariation, 23 | isEnabled: isFeatureEnabled, 24 | }); 25 | 26 | return isFeatureEnabled; 27 | } 28 | 29 | export { useFeatureToggle }; 30 | -------------------------------------------------------------------------------- /packages/react-redux/src/use-feature-toggle.ts: -------------------------------------------------------------------------------- 1 | import { getIsFeatureEnabled, useAdapterContext } from '@flopflip/react'; 2 | import type { TFlagName, TFlagVariation } from '@flopflip/types'; 3 | import { useDebugValue } from 'react'; 4 | import { useSelector } from 'react-redux'; 5 | 6 | import { selectFlags } from './ducks/flags'; 7 | 8 | function useFeatureToggle( 9 | flagName: TFlagName, 10 | flagVariation: TFlagVariation = true 11 | ) { 12 | const adapterContext = useAdapterContext(); 13 | const allFlags = useSelector(selectFlags()); 14 | 15 | const isFeatureEnabled: boolean = getIsFeatureEnabled( 16 | allFlags, 17 | adapterContext.adapterEffectIdentifiers, 18 | flagName, 19 | flagVariation 20 | ); 21 | 22 | useDebugValue({ 23 | flagName, 24 | flagVariation, 25 | isEnabled: isFeatureEnabled, 26 | }); 27 | 28 | return isFeatureEnabled; 29 | } 30 | 31 | export { useFeatureToggle }; 32 | -------------------------------------------------------------------------------- /packages/localstorage-cache/src/cache.ts: -------------------------------------------------------------------------------- 1 | import type { TCache, TCacheOptions } from '@flopflip/types'; 2 | 3 | const createCache = (options: TCacheOptions) => { 4 | const cache: TCache = { 5 | get(key) { 6 | const cacheKey = [options.prefix, key].join('/'); 7 | 8 | const localStorageValue = localStorage.getItem(cacheKey); 9 | 10 | return localStorageValue ? JSON.parse(localStorageValue) : null; 11 | }, 12 | set(key, value) { 13 | try { 14 | const cacheKey = [options.prefix, key].join('/'); 15 | 16 | localStorage.setItem(cacheKey, JSON.stringify(value)); 17 | 18 | return true; 19 | } catch (_error) { 20 | return false; 21 | } 22 | }, 23 | unset(key) { 24 | const cacheKey = [options.prefix, key].join('/'); 25 | 26 | localStorage.removeItem(cacheKey); 27 | }, 28 | }; 29 | 30 | return cache; 31 | }; 32 | 33 | export { createCache }; 34 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base", "group:monorepos", "schedule:weekly"], 3 | "separateMajorMinor": true, 4 | "lockFileMaintenance": { 5 | "enabled": true 6 | }, 7 | "packageRules": [ 8 | { 9 | "packagePatterns": ["*"], 10 | "updateTypes": ["minor", "patch"], 11 | "groupName": "all dependencies", 12 | "groupSlug": "all" 13 | }, 14 | { 15 | "extends": "monorepo:babel", 16 | "groupName": "babel monorepo", 17 | "matchUpdateTypes": ["digest", "patch", "minor", "major"] 18 | }, 19 | { 20 | "matchPackageNames": ["cypress"], 21 | "allowedVersions": "<=13.6.4" 22 | }, 23 | { 24 | "matchPackageNames": ["debounce-fn"], 25 | "allowedVersions": "<=4.0.0" 26 | } 27 | ], 28 | "circleci": { "enabled": false }, 29 | "automerge": true, 30 | "major": { 31 | "automerge": false 32 | }, 33 | "ignoreDeps": [], 34 | "labels": ["🤖 Dependencies"] 35 | } 36 | -------------------------------------------------------------------------------- /packages/react/src/configure-adapter/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TAdapterArgs, 3 | TAdapterReconfiguration, 4 | TConfigureAdapterChildren, 5 | TConfigureAdapterChildrenAsFunction, 6 | } from '@flopflip/types'; 7 | import { Children } from 'react'; 8 | import { merge } from 'ts-deepmerge'; 9 | 10 | const isFunctionChildren = ( 11 | children: TConfigureAdapterChildren 12 | ): children is TConfigureAdapterChildrenAsFunction => 13 | typeof children === 'function'; 14 | 15 | const isEmptyChildren = (children: TConfigureAdapterChildren) => 16 | !isFunctionChildren(children) && Children.count(children) === 0; 17 | 18 | const mergeAdapterArgs = ( 19 | previousAdapterArgs: TAdapterArgs, 20 | { adapterArgs: nextAdapterArgs, options = {} }: TAdapterReconfiguration 21 | ): TAdapterArgs => 22 | options.shouldOverwrite 23 | ? nextAdapterArgs 24 | : merge(previousAdapterArgs, nextAdapterArgs); 25 | 26 | export { isEmptyChildren, isFunctionChildren, mergeAdapterArgs }; 27 | -------------------------------------------------------------------------------- /packages/react-broadcast/src/use-feature-toggles.ts: -------------------------------------------------------------------------------- 1 | import { getIsFeatureEnabled, useAdapterContext } from '@flopflip/react'; 2 | import type { TFlagName, TFlags, TFlagVariation } from '@flopflip/types'; 3 | 4 | import { useFlagsContext } from './use-flags-context'; 5 | 6 | function useFeatureToggles(flags: TFlags) { 7 | const adapterContext = useAdapterContext(); 8 | const flagsContext = useFlagsContext(); 9 | const requestedFlags: boolean[] = Object.entries(flags).reduce( 10 | (previousFlags, [flagName, flagVariation]: [TFlagName, TFlagVariation]) => { 11 | const isFeatureEnabled: boolean = getIsFeatureEnabled( 12 | flagsContext, 13 | adapterContext.adapterEffectIdentifiers, 14 | flagName, 15 | flagVariation 16 | ); 17 | 18 | previousFlags.push(isFeatureEnabled); 19 | 20 | return previousFlags; 21 | }, 22 | [] 23 | ); 24 | 25 | return requestedFlags; 26 | } 27 | 28 | export { useFeatureToggles }; 29 | -------------------------------------------------------------------------------- /tooling/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": true, 5 | "emitDecoratorMetadata": false, 6 | "experimentalDecorators": false, 7 | "esModuleInterop": true, 8 | "importHelpers": true, 9 | "jsx": "react", 10 | "lib": ["esnext", "dom"], 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "noFallthroughCasesInSwitch": true, 14 | "noImplicitAny": false, 15 | "noImplicitReturns": true, 16 | "resolveJsonModule": true, 17 | "noImplicitThis": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "pretty": true, 21 | "removeComments": true, 22 | "sourceMap": true, 23 | "strict": true, 24 | "strictNullChecks": true, 25 | "stripInternal": true, 26 | "target": "ES2015", 27 | "allowJs": false 28 | }, 29 | "exclude": ["packages/cypress-plugin"], 30 | "typeRoots": ["@types", "node_modules/@types"] 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/bundles.yml: -------------------------------------------------------------------------------- 1 | name: 'Bundles' 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | bundlewatch: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: GitHub context 14 | run: echo "$GITHUB_CONTEXT" 15 | env: 16 | GITHUB_CONTEXT: ${{ toJson(github) }} 17 | 18 | - name: Checkout 19 | uses: actions/checkout@v6 20 | 21 | - name: Install pnpm 22 | uses: pnpm/action-setup@v4.2.0 23 | with: 24 | run_install: false 25 | 26 | - name: Setup Node (uses version in .nvmrc) 27 | uses: actions/setup-node@v6 28 | with: 29 | node-version-file: '.nvmrc' 30 | cache: 'pnpm' 31 | 32 | - name: Install dependencies 33 | run: pnpm install 34 | 35 | - uses: jackyef/bundlewatch-gh-action@master 36 | with: 37 | build-script: pnpm build 38 | bundlewatch-github-token: ${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }} 39 | -------------------------------------------------------------------------------- /packages/react-redux/src/toggle-feature.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ToggleFeature as SharedToggleFeature, 3 | type TToggleFeatureProps, 4 | } from '@flopflip/react'; 5 | import type { TFlagName, TFlagVariation } from '@flopflip/types'; 6 | // biome-ignore lint/correctness/noUnusedImports: false positive 7 | import React from 'react'; 8 | import { useFeatureToggle } from './use-feature-toggle'; 9 | 10 | type TProps = { 11 | flag: TFlagName; 12 | variation?: TFlagVariation; 13 | } & Omit; 14 | 15 | function ToggleFeature({ 16 | flag, 17 | variation, 18 | ...remainingProps 19 | }: OwnProps) { 20 | const isFeatureEnabled = useFeatureToggle(flag, variation); 21 | 22 | return ( 23 | 29 | ); 30 | } 31 | 32 | ToggleFeature.displayName = 'ToggleFeature'; 33 | 34 | export { ToggleFeature }; 35 | -------------------------------------------------------------------------------- /packages/react-redux/src/use-feature-toggles.ts: -------------------------------------------------------------------------------- 1 | import { getIsFeatureEnabled, useAdapterContext } from '@flopflip/react'; 2 | import type { TFlagName, TFlags, TFlagVariation } from '@flopflip/types'; 3 | import { useSelector } from 'react-redux'; 4 | 5 | import { selectFlags } from './ducks/flags'; 6 | 7 | function useFeatureToggles(flags: TFlags) { 8 | const allFlags = useSelector(selectFlags()); 9 | const adapterContext = useAdapterContext(); 10 | 11 | const requestedFlags: boolean[] = Object.entries(flags).reduce( 12 | (previousFlags, [flagName, flagVariation]: [TFlagName, TFlagVariation]) => { 13 | const isFeatureEnabled: boolean = getIsFeatureEnabled( 14 | allFlags, 15 | adapterContext.adapterEffectIdentifiers, 16 | flagName, 17 | flagVariation 18 | ); 19 | 20 | previousFlags.push(isFeatureEnabled); 21 | 22 | return previousFlags; 23 | }, 24 | [] 25 | ); 26 | 27 | return requestedFlags; 28 | } 29 | 30 | export { useFeatureToggles }; 31 | -------------------------------------------------------------------------------- /packages/react-broadcast/src/toggle-feature.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ToggleFeature as SharedToggleFeature, 3 | type TToggleFeatureProps, 4 | } from '@flopflip/react'; 5 | import type { TFlagName, TFlagVariation } from '@flopflip/types'; 6 | // biome-ignore lint/correctness/noUnusedImports: false positive 7 | import React from 'react'; 8 | 9 | import { useFeatureToggle } from './use-feature-toggle'; 10 | 11 | type TProps = { 12 | flag: TFlagName; 13 | variation?: TFlagVariation; 14 | } & Omit; 15 | 16 | function ToggleFeature({ 17 | flag, 18 | variation, 19 | ...remainingProps 20 | }: OwnProps) { 21 | const isFeatureEnabled = useFeatureToggle(flag, variation); 22 | 23 | return ( 24 | 30 | ); 31 | } 32 | 33 | ToggleFeature.displayName = 'ToggleFeature'; 34 | 35 | export { ToggleFeature }; 36 | -------------------------------------------------------------------------------- /packages/react-broadcast/src/index.ts: -------------------------------------------------------------------------------- 1 | const version = '__@FLOPFLIP/VERSION_OF_RELEASE__'; 2 | 3 | export { branchOnFeatureToggle } from './branch-on-feature-toggle'; 4 | export { Configure as ConfigureFlopFlip } from './configure'; 5 | export { injectFeatureToggle } from './inject-feature-toggle'; 6 | export { injectFeatureToggles } from './inject-feature-toggles'; 7 | export { ReconfigureAdapter as ReconfigureFlopFlip } from './reconfigure'; 8 | export { TestProvider as TestProviderFlopFlip } from './test-provider'; 9 | export { ToggleFeature } from './toggle-feature'; 10 | export { useAdapterReconfiguration } from './use-adapter-reconfiguration'; 11 | export { useAdapterStatus } from './use-adapter-status'; 12 | export { useAllFeatureToggles } from './use-all-feature-toggles'; 13 | export { useFeatureToggle } from './use-feature-toggle'; 14 | export { useFeatureToggles } from './use-feature-toggles'; 15 | export { useFlagVariation } from './use-flag-variation'; 16 | export { useFlagVariations } from './use-flag-variations'; 17 | 18 | export { version }; 19 | -------------------------------------------------------------------------------- /packages/react/src/reconfigure-adapter.ts: -------------------------------------------------------------------------------- 1 | import type { TUser } from '@flopflip/types'; 2 | // biome-ignore lint/style/useImportType: false positive 3 | import React, { Children, useEffect } from 'react'; 4 | 5 | import { useAdapterContext } from './use-adapter-context'; 6 | 7 | type TProps = { 8 | readonly shouldOverwrite?: boolean; 9 | readonly user: TUser; 10 | readonly children?: React.ReactNode; 11 | }; 12 | 13 | function ReconfigureAdapter({ 14 | shouldOverwrite = false, 15 | user, 16 | children = null, 17 | }: TProps) { 18 | const adapterContext = useAdapterContext(); 19 | 20 | useEffect(() => { 21 | adapterContext.reconfigure( 22 | { 23 | user, 24 | }, 25 | { 26 | shouldOverwrite, 27 | } 28 | ); 29 | }, [user, shouldOverwrite, adapterContext]); 30 | 31 | return children ? Children.only(children) : null; 32 | } 33 | 34 | ReconfigureAdapter.displayName = 'ReconfigureAdapter'; 35 | 36 | export { ReconfigureAdapter }; 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 suǝʞǝǝpʇ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/react-redux/src/branch-on-feature-toggle.tsx: -------------------------------------------------------------------------------- 1 | import type { TFlagName, TFlagVariation } from '@flopflip/types'; 2 | // biome-ignore lint/style/useImportType: false positive 3 | import React from 'react'; 4 | 5 | import { useFeatureToggle } from './use-feature-toggle'; 6 | 7 | type TBranchOnFeatureToggleOptions = { 8 | flag: TFlagName; 9 | variation?: TFlagVariation; 10 | }; 11 | function branchOnFeatureToggle>( 12 | { flag: flagName, variation: flagVariation }: TBranchOnFeatureToggleOptions, 13 | UntoggledComponent?: React.ComponentType 14 | ) { 15 | return (ToggledComponent: React.ComponentType) => { 16 | function WrappedToggledComponent(ownProps: OwnProps) { 17 | const isFeatureEnabled = useFeatureToggle(flagName, flagVariation); 18 | 19 | if (isFeatureEnabled) { 20 | return ; 21 | } 22 | if (UntoggledComponent) { 23 | return ; 24 | } 25 | return null; 26 | } 27 | 28 | return WrappedToggledComponent; 29 | }; 30 | } 31 | 32 | export { branchOnFeatureToggle }; 33 | -------------------------------------------------------------------------------- /packages/react-broadcast/src/branch-on-feature-toggle.tsx: -------------------------------------------------------------------------------- 1 | import type { TFlagName, TFlagVariation } from '@flopflip/types'; 2 | // biome-ignore lint/style/useImportType: TS is just weird 3 | import React from 'react'; 4 | 5 | import { useFeatureToggle } from './use-feature-toggle'; 6 | 7 | type TBranchOnFeatureToggleOptions = { 8 | flag: TFlagName; 9 | variation?: TFlagVariation; 10 | }; 11 | function branchOnFeatureToggle>( 12 | { flag: flagName, variation: flagVariation }: TBranchOnFeatureToggleOptions, 13 | UntoggledComponent?: React.ComponentType 14 | ) { 15 | return (ToggledComponent: React.ComponentType) => { 16 | function WrappedToggledComponent(ownProps: OwnProps) { 17 | const isFeatureEnabled = useFeatureToggle(flagName, flagVariation); 18 | 19 | if (isFeatureEnabled) { 20 | return ; 21 | } 22 | if (UntoggledComponent) { 23 | return ; 24 | } 25 | return null; 26 | } 27 | 28 | return WrappedToggledComponent; 29 | }; 30 | } 31 | 32 | export { branchOnFeatureToggle }; 33 | -------------------------------------------------------------------------------- /packages/react/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { TProps as ToggleFeatureProps } from './toggle-feature'; 2 | 3 | export type TToggleFeatureProps = ToggleFeatureProps; 4 | 5 | const version = '__@FLOPFLIP/VERSION_OF_RELEASE__'; 6 | 7 | export { 8 | AdapterContext, 9 | createAdapterContext, 10 | selectAdapterConfigurationStatus, 11 | } from './adapter-context'; 12 | export { ConfigureAdapter } from './configure-adapter'; 13 | export { 14 | ALL_FLAGS_PROP_KEY, 15 | DEFAULT_FLAG_PROP_KEY, 16 | DEFAULT_FLAGS_PROP_KEY, 17 | } from './constants'; 18 | export { getFlagVariation } from './/get-flag-variation'; 19 | export { getIsFeatureEnabled } from './get-is-feature-enabled'; 20 | export { isNil } from './is-nil'; 21 | export { ReconfigureAdapter } from './reconfigure-adapter'; 22 | export { setDisplayName } from './set-display-name'; 23 | export { ToggleFeature } from './toggle-feature'; 24 | export { useAdapterContext } from './use-adapter-context'; 25 | export { useAdapterReconfiguration } from './use-adapter-reconfiguration'; 26 | export { useAdapterSubscription } from './use-adapter-subscription'; 27 | export { wrapDisplayName } from './wrap-display-name'; 28 | 29 | export { version }; 30 | -------------------------------------------------------------------------------- /packages/react-redux/src/inject-feature-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_FLAG_PROP_KEY, 3 | setDisplayName, 4 | wrapDisplayName, 5 | } from '@flopflip/react'; 6 | import type { TFlagName, TFlagVariation } from '@flopflip/types'; 7 | // biome-ignore lint/style/useImportType: false positive 8 | import React from 'react'; 9 | 10 | import { useFlagVariations } from './use-flag-variations'; 11 | 12 | type InjectedProps = Record; 13 | 14 | const injectFeatureToggle = 15 | >( 16 | flagName: TFlagName, 17 | propKey: string = DEFAULT_FLAG_PROP_KEY 18 | ) => 19 | ( 20 | Component: React.ComponentType 21 | ): React.ComponentType => { 22 | function WrappedComponent(ownProps: OwnProps) { 23 | const [flagVariation] = useFlagVariations([flagName]); 24 | const props = { 25 | ...ownProps, 26 | [propKey]: flagVariation, 27 | }; 28 | 29 | return ; 30 | } 31 | 32 | setDisplayName(wrapDisplayName(WrappedComponent, 'injectFeatureToggle')); 33 | 34 | return WrappedComponent; 35 | }; 36 | 37 | export { injectFeatureToggle }; 38 | -------------------------------------------------------------------------------- /packages/react-broadcast/src/inject-feature-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_FLAG_PROP_KEY, 3 | setDisplayName, 4 | wrapDisplayName, 5 | } from '@flopflip/react'; 6 | import type { TFlagName, TFlagVariation } from '@flopflip/types'; 7 | // biome-ignore lint/style/useImportType: false positive 8 | import React from 'react'; 9 | 10 | import { useFlagVariations } from './use-flag-variations'; 11 | 12 | type InjectedProps = Record; 13 | 14 | function injectFeatureToggle>( 15 | flagName: TFlagName, 16 | propKey: string = DEFAULT_FLAG_PROP_KEY 17 | ) { 18 | return ( 19 | Component: React.ComponentType 20 | ): React.ComponentType => { 21 | function WrappedComponent(ownProps: OwnProps) { 22 | const [flagVariation] = useFlagVariations([flagName]); 23 | const props = { 24 | ...ownProps, 25 | [propKey]: flagVariation, 26 | }; 27 | 28 | return ; 29 | } 30 | 31 | setDisplayName(wrapDisplayName(WrappedComponent, 'injectFeatureToggle')); 32 | 33 | return WrappedComponent; 34 | }; 35 | } 36 | 37 | export { injectFeatureToggle }; 38 | -------------------------------------------------------------------------------- /.claude/commands/openspec/archive.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: OpenSpec: Archive 3 | description: Archive a deployed OpenSpec change and update specs. 4 | category: OpenSpec 5 | tags: [openspec, archive] 6 | --- 7 | 8 | **Guardrails** 9 | - Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. 10 | - Keep changes tightly scoped to the requested outcome. 11 | - Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. 12 | 13 | **Steps** 14 | 1. Identify the requested change ID (via the prompt or `openspec list`). 15 | 2. Run `openspec archive --yes` to let the CLI move the change and apply spec updates without prompts (use `--skip-specs` only for tooling-only work). 16 | 3. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`. 17 | 4. Validate with `openspec validate --strict` and inspect with `openspec show ` if anything looks off. 18 | 19 | **Reference** 20 | - Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off. 21 | 22 | -------------------------------------------------------------------------------- /packages/react-redux/src/index.ts: -------------------------------------------------------------------------------- 1 | const version = '__@FLOPFLIP/VERSION_OF_RELEASE__'; 2 | 3 | export { branchOnFeatureToggle } from './branch-on-feature-toggle'; 4 | export { Configure as ConfigureFlopFlip } from './configure'; 5 | export { STATE_SLICE as FLOPFLIP_STATE_SLICE } from './constants'; 6 | export { 7 | createFlopflipReducer, 8 | flopflipReducer, 9 | selectFlag as selectFeatureFlag, 10 | selectFlags as selectFeatureFlags, 11 | } from './ducks'; 12 | export { createFlopFlipEnhancer } from './enhancer'; 13 | export { injectFeatureToggle } from './inject-feature-toggle'; 14 | export { injectFeatureToggles } from './inject-feature-toggles'; 15 | export { ReconfigureAdapter as ReconfigureFlopFlip } from './reconfigure'; 16 | export { ToggleFeature } from './toggle-feature'; 17 | export { useAdapterReconfiguration } from './use-adapter-reconfiguration'; 18 | export { useAdapterStatus } from './use-adapter-status'; 19 | export { useAllFeatureToggles } from './use-all-feature-toggles'; 20 | export { useFeatureToggle } from './use-feature-toggle'; 21 | export { useFeatureToggles } from './use-feature-toggles'; 22 | export { useFlagVariation } from './use-flag-variation'; 23 | export { useFlagVariations } from './use-flag-variations'; 24 | 25 | export { version }; 26 | -------------------------------------------------------------------------------- /packages/react/src/get-flag-variation.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TAdapterIdentifiers, 3 | TFlagName, 4 | TFlagsContext, 5 | TFlagVariation, 6 | } from '@flopflip/types'; 7 | import warning from 'tiny-warning'; 8 | 9 | import { DEFAULT_FLAG_PROP_KEY } from './constants'; 10 | import { getNormalizedFlagName } from './get-normalized-flag-name'; 11 | import { isNil } from './is-nil'; 12 | 13 | const getFlagVariation = ( 14 | allFlags: TFlagsContext, 15 | adapterIdentifiers: TAdapterIdentifiers[], 16 | flagName: TFlagName = DEFAULT_FLAG_PROP_KEY 17 | ): TFlagVariation => { 18 | const normalizedFlagName = getNormalizedFlagName(flagName); 19 | 20 | warning( 21 | normalizedFlagName === flagName, 22 | '@flopflip/react: passed flag name does not seem to be normalized which may result in unexpected toggling. Please refer to our readme for more information: https://github.com/tdeekens/flopflip#flag-normalization' 23 | ); 24 | 25 | for (const adapterInterfaceIdentifier of adapterIdentifiers) { 26 | const flagVariation = 27 | allFlags[adapterInterfaceIdentifier]?.[normalizedFlagName]; 28 | 29 | if (!isNil(flagVariation)) { 30 | return flagVariation; 31 | } 32 | } 33 | 34 | return false; 35 | }; 36 | 37 | export { getFlagVariation }; 38 | -------------------------------------------------------------------------------- /packages/react-redux/test/use-flag-variation.spec.jsx: -------------------------------------------------------------------------------- 1 | import { renderWithAdapter, screen } from '@flopflip/test-utils'; 2 | import { Provider } from 'react-redux'; 3 | import { expect, it } from 'vitest'; 4 | 5 | import { Configure } from '../src/configure'; 6 | import { STATE_SLICE } from '../src/constants'; 7 | import { useFlagVariation } from '../src/use-flag-variation'; 8 | import { createStore } from './test-utils'; 9 | 10 | const render = (store, TestComponent) => 11 | renderWithAdapter(TestComponent, { 12 | components: { 13 | ConfigureFlopFlip: Configure, 14 | Wrapper: , 15 | }, 16 | }); 17 | 18 | function TestComponent() { 19 | const variation = useFlagVariation('variation'); 20 | 21 | return ( 22 |
    23 |
  • Variation: {variation}
  • 24 |
25 | ); 26 | } 27 | 28 | it('should indicate a flag variation', async () => { 29 | const store = createStore({ 30 | [STATE_SLICE]: { 31 | flags: { 32 | memory: { 33 | variation: 'A', 34 | }, 35 | }, 36 | }, 37 | }); 38 | const { waitUntilConfigured } = render(store, ); 39 | 40 | await waitUntilConfigured(); 41 | 42 | expect(screen.getByText('Variation: A')).toBeInTheDocument(); 43 | }); 44 | -------------------------------------------------------------------------------- /packages/adapter-utilities/test/normalize-flags.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { normalizeFlag } from '../src/normalize-flag'; 3 | import { normalizeFlags } from '../src/normalize-flags'; 4 | 5 | const rawFlags = { 6 | 'a-flag': true, 7 | 'flag-b-c': false, 8 | }; 9 | 10 | describe('with default normalization', () => { 11 | describe('with dashes', () => { 12 | it('should camel case to uppercased flag names', () => { 13 | expect(normalizeFlags(rawFlags)).toEqual({ aFlag: true, flagBC: false }); 14 | }); 15 | }); 16 | 17 | describe('with spaces', () => { 18 | const rawFlags = { 19 | 'a flag': true, 20 | 'flag b-c': false, 21 | }; 22 | 23 | it('should camel case to uppercased flag names', () => { 24 | expect(normalizeFlags(rawFlags)).toEqual({ aFlag: true, flagBC: false }); 25 | }); 26 | }); 27 | }); 28 | 29 | describe('with custom normalization', () => { 30 | it('should use the custom normalization function', () => { 31 | const customNormalizeFlag = vi.fn((...args) => normalizeFlag(...args)); 32 | 33 | expect(normalizeFlags(rawFlags, customNormalizeFlag)).toEqual({ 34 | aFlag: true, 35 | flagBC: false, 36 | }); 37 | expect(customNormalizeFlag).toHaveBeenCalled(); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /packages/react-broadcast/test/use-adapter-status.spec.jsx: -------------------------------------------------------------------------------- 1 | import { renderWithAdapter, screen } from '@flopflip/test-utils'; 2 | import { expect, it } from 'vitest'; 3 | 4 | import { Configure } from '../src/configure'; 5 | import { useAdapterStatus } from '../src/use-adapter-status'; 6 | 7 | const render = (TestComponent) => 8 | renderWithAdapter(TestComponent, { 9 | components: { ConfigureFlopFlip: Configure }, 10 | }); 11 | 12 | function TestComponent() { 13 | const { isConfiguring, isConfigured } = useAdapterStatus(); 14 | 15 | return ( 16 |
    17 |
  • Is configuring: {isConfiguring ? 'Yes' : 'No'}
  • 18 |
  • Is configured: {isConfigured ? 'Yes' : 'No'}
  • 19 |
20 | ); 21 | } 22 | 23 | it('should indicate the adapter not configured yet', async () => { 24 | const { waitUntilConfigured } = render(); 25 | 26 | expect(screen.getByText(/Is configuring: Yes/i)).toBeInTheDocument(); 27 | expect(screen.getByText(/Is configured: No/i)).toBeInTheDocument(); 28 | 29 | await waitUntilConfigured(); 30 | }); 31 | 32 | it('should indicate the adapter is configured', async () => { 33 | const { waitUntilConfigured } = render(); 34 | 35 | await waitUntilConfigured(); 36 | 37 | await screen.findByText(/Is configuring: No/i); 38 | await screen.findByText(/Is configured: Yes/i); 39 | }); 40 | -------------------------------------------------------------------------------- /patches/cypress@13.6.4.patch: -------------------------------------------------------------------------------- 1 | diff --git a/types/lodash/index.d.ts b/types/lodash/index.d.ts 2 | index 1fa09484625c1062a9f23708b52dcbbbdc808ee2..510aca729e088309ed632deef629693fe00b7053 100644 3 | --- a/types/lodash/index.d.ts 4 | +++ b/types/lodash/index.d.ts 5 | @@ -29,18 +29,17 @@ export as namespace _; 6 | 7 | declare const _: _.LoDashStatic; 8 | declare namespace _ { 9 | - // tslint:disable-next-line no-empty-interface (This will be augmented) 10 | - interface LoDashStatic {} 11 | + // tslint:disable-next-line no-empty-interface (This will be augmented) 12 | + interface LoDashStatic {} 13 | } 14 | 15 | // Backward compatibility with --target es5 16 | declare global { 17 | - // tslint:disable-next-line:no-empty-interface 18 | - interface Set { } 19 | - // tslint:disable-next-line:no-empty-interface 20 | - interface Map { } 21 | - // tslint:disable-next-line:no-empty-interface 22 | - interface WeakSet { } 23 | - // tslint:disable-next-line:no-empty-interface 24 | - interface WeakMap { } 25 | + // tslint:disable-next-line:no-empty-interface 26 | + interface Set {} 27 | + // tslint:disable-next-line:no-empty-interface 28 | + interface Map {} 29 | + // tslint:disable-next-line:no-empty-interface 30 | + interface WeakSet {} 31 | + // tslint:disable-next-line:no-empty-interface 32 | } 33 | -------------------------------------------------------------------------------- /packages/react-broadcast/test/use-feature-toggles.spec.jsx: -------------------------------------------------------------------------------- 1 | import { renderWithAdapter, screen } from '@flopflip/test-utils'; 2 | import { expect, it } from 'vitest'; 3 | 4 | import { Configure } from '../src/configure'; 5 | import { useFeatureToggles } from '../src/use-feature-toggles'; 6 | 7 | const render = (TestComponent) => 8 | renderWithAdapter(TestComponent, { 9 | components: { ConfigureFlopFlip: Configure }, 10 | }); 11 | 12 | function TestComponent() { 13 | const [isEnabledFeatureEnabled, isDisabledFeatureDisabled] = 14 | useFeatureToggles({ 15 | enabledFeature: true, 16 | disabledFeature: true, 17 | }); 18 | 19 | return ( 20 |
    21 |
  • Is enabled: {isEnabledFeatureEnabled ? 'Yes' : 'No'}
  • 22 |
  • Is disabled: {isDisabledFeatureDisabled ? 'No' : 'Yes'}
  • 23 |
24 | ); 25 | } 26 | 27 | it('should indicate a feature being disabled', async () => { 28 | const { waitUntilConfigured } = render(); 29 | 30 | await waitUntilConfigured(); 31 | 32 | expect(screen.getByText('Is disabled: Yes')).toBeInTheDocument(); 33 | }); 34 | 35 | it('should indicate a feature being enabled', async () => { 36 | const { waitUntilConfigured } = render(); 37 | 38 | await waitUntilConfigured(); 39 | 40 | expect(screen.getByText('Is enabled: Yes')).toBeInTheDocument(); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/react-redux/src/inject-feature-toggles.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_FLAGS_PROP_KEY, 3 | setDisplayName, 4 | wrapDisplayName, 5 | } from '@flopflip/react'; 6 | import type { TFlagName, TFlags } from '@flopflip/types'; 7 | // biome-ignore lint/style/useImportType: false positive 8 | import React from 'react'; 9 | 10 | import { useFlagVariations } from './use-flag-variations'; 11 | 12 | type InjectedProps = Record; 13 | 14 | const injectFeatureToggles = 15 | >( 16 | flagNames: TFlagName[], 17 | propKey: string = DEFAULT_FLAGS_PROP_KEY 18 | ) => 19 | ( 20 | Component: React.ComponentType 21 | ): React.ComponentType => { 22 | function WrappedComponent(ownProps: OwnProps) { 23 | const flagVariations = useFlagVariations(flagNames); 24 | const flags = Object.fromEntries( 25 | flagNames.map((flagName, indexOfFlagName) => [ 26 | flagName, 27 | flagVariations[indexOfFlagName], 28 | ]) 29 | ); 30 | const props = { 31 | ...ownProps, 32 | [propKey]: flags, 33 | }; 34 | 35 | return ; 36 | } 37 | 38 | setDisplayName(wrapDisplayName(WrappedComponent, 'injectFeatureToggles')); 39 | 40 | return WrappedComponent; 41 | }; 42 | 43 | export { injectFeatureToggles }; 44 | -------------------------------------------------------------------------------- /packages/react-broadcast/src/inject-feature-toggles.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_FLAGS_PROP_KEY, 3 | setDisplayName, 4 | wrapDisplayName, 5 | } from '@flopflip/react'; 6 | import type { TFlagName, TFlags } from '@flopflip/types'; 7 | // biome-ignore lint/style/useImportType: false positive 8 | import React from 'react'; 9 | 10 | import { useFlagVariations } from './use-flag-variations'; 11 | 12 | type InjectedProps = Record; 13 | 14 | function injectFeatureToggles>( 15 | flagNames: TFlagName[], 16 | propKey: string = DEFAULT_FLAGS_PROP_KEY 17 | ) { 18 | return ( 19 | Component: React.ComponentType 20 | ): React.ComponentType => { 21 | function WrappedComponent(ownProps: OwnProps) { 22 | const flagVariations = useFlagVariations(flagNames); 23 | const flags = Object.fromEntries( 24 | flagNames.map((flagName, indexOfFlagName) => [ 25 | flagName, 26 | flagVariations[indexOfFlagName], 27 | ]) 28 | ); 29 | const props = { 30 | ...ownProps, 31 | [propKey]: flags, 32 | }; 33 | 34 | return ; 35 | } 36 | 37 | setDisplayName(wrapDisplayName(WrappedComponent, 'injectFeatureToggles')); 38 | 39 | return WrappedComponent; 40 | }; 41 | } 42 | 43 | export { injectFeatureToggles }; 44 | -------------------------------------------------------------------------------- /.claude/commands/openspec/apply.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: OpenSpec: Apply 3 | description: Implement an approved OpenSpec change and keep tasks in sync. 4 | category: OpenSpec 5 | tags: [openspec, apply] 6 | --- 7 | 8 | **Guardrails** 9 | - Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. 10 | - Keep changes tightly scoped to the requested outcome. 11 | - Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. 12 | 13 | **Steps** 14 | Track these steps as TODOs and complete them one by one. 15 | 1. Read `changes//proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria. 16 | 2. Work through tasks sequentially, keeping edits minimal and focused on the requested change. 17 | 3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished. 18 | 4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality. 19 | 5. Reference `openspec list` or `openspec show ` when additional context is required. 20 | 21 | **Reference** 22 | - Use `openspec show --json --deltas-only` if you need additional context from the proposal while implementing. 23 | 24 | -------------------------------------------------------------------------------- /packages/react-broadcast/test/use-feature-toggle.spec.jsx: -------------------------------------------------------------------------------- 1 | import { renderWithAdapter, screen } from '@flopflip/test-utils'; 2 | import { expect, it } from 'vitest'; 3 | 4 | import { Configure } from '../src/configure'; 5 | import { useFeatureToggle } from '../src/use-feature-toggle'; 6 | 7 | const render = (TestComponent) => 8 | renderWithAdapter(TestComponent, { 9 | components: { ConfigureFlopFlip: Configure }, 10 | }); 11 | 12 | function TestComponent() { 13 | const isEnabledFeatureEnabled = useFeatureToggle('enabledFeature'); 14 | const isDisabledFeatureDisabled = useFeatureToggle('disabledFeature'); 15 | const flagVariation = useFeatureToggle('variation', null); 16 | 17 | return ( 18 |
    19 |
  • Is enabled: {isEnabledFeatureEnabled ? 'Yes' : 'No'}
  • 20 |
  • Is disabled: {isDisabledFeatureDisabled ? 'No' : 'Yes'}
  • 21 |
  • Variation: {flagVariation}
  • 22 |
23 | ); 24 | } 25 | 26 | it('should indicate a feature being disabled', async () => { 27 | const { waitUntilConfigured } = render(); 28 | 29 | await waitUntilConfigured(); 30 | 31 | expect(screen.getByText('Is disabled: Yes')).toBeInTheDocument(); 32 | }); 33 | 34 | it('should indicate a feature being enabled', async () => { 35 | const { waitUntilConfigured } = render(); 36 | 37 | await waitUntilConfigured(); 38 | 39 | expect(screen.getByText('Is enabled: Yes')).toBeInTheDocument(); 40 | }); 41 | -------------------------------------------------------------------------------- /throwing-console-patch.js: -------------------------------------------------------------------------------- 1 | const colors = require('colors/safe'); 2 | 3 | const shouldSilenceWarnings = (...messages) => 4 | [].some((msgRegex) => messages.some((msg) => msgRegex.test(msg))); 5 | 6 | const shouldNotThrowWarnings = (...messages) => 7 | [].some((msgRegex) => messages.some((msg) => msgRegex.test(msg))); 8 | 9 | const logOrThrow = (log, method, messages) => { 10 | const warning = `console.${method} calls not allowed in tests`; 11 | 12 | if (!process.env.CI) { 13 | log(colors.bgYellow.black(' WARN '), warning, '\n', ...messages); 14 | return; 15 | } 16 | 17 | if (shouldSilenceWarnings(messages)) { 18 | return; 19 | } 20 | 21 | log(warning, '\n', ...messages); 22 | 23 | // NOTE: That some warnings should be logged allowing us to refactor graceully 24 | // without having to introduce a breaking change. 25 | if (shouldNotThrowWarnings(messages)) { 26 | return; 27 | } 28 | 29 | throw new Error(...messages); 30 | }; 31 | 32 | const logMessage = console.log; 33 | global.console.log = (...messages) => { 34 | logOrThrow(logMessage, 'log', messages); 35 | }; 36 | 37 | const logInfo = console.info; 38 | global.console.info = (...messages) => { 39 | logOrThrow(logInfo, 'info', messages); 40 | }; 41 | 42 | const logWarning = console.warn; 43 | global.console.warn = (...messages) => { 44 | logOrThrow(logWarning, 'warn', messages); 45 | }; 46 | 47 | const logError = console.error; 48 | global.console.error = (...messages) => { 49 | logOrThrow(logError, 'error', messages); 50 | }; 51 | -------------------------------------------------------------------------------- /packages/localstorage-cache/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flopflip/localstorage-cache", 3 | "version": "15.1.6", 4 | "description": "Localstorage cache for flipflop adapters", 5 | "sideEffects": false, 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.js", 10 | "require": "./dist/index.cjs" 11 | } 12 | }, 13 | "main": "./dist/index.js", 14 | "typesVersions": { 15 | "*": { 16 | ".": [ 17 | "dist/*.d.ts", 18 | "dist/*.d.cts" 19 | ] 20 | } 21 | }, 22 | "scripts": { 23 | "build": "rimraf dist && tsup", 24 | "check-types": "tsc --noEmit", 25 | "test": "vitest --run", 26 | "test:watch": "vitest", 27 | "dev": "tsup --watch --clean=false" 28 | }, 29 | "files": [ 30 | "readme.md", 31 | "dist/**" 32 | ], 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/tdeekens/flopflip.git", 39 | "directory": "packages/localstorage-cache" 40 | }, 41 | "author": "Tobias Deekens ", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/tdeekens/flopflip/issues" 45 | }, 46 | "homepage": "https://github.com/tdeekens/flopflip#readme", 47 | "keywords": [ 48 | "feature-flags", 49 | "feature-toggles", 50 | "localstorage", 51 | "cache", 52 | "client" 53 | ], 54 | "dependencies": { 55 | "@flopflip/types": "workspace:*" 56 | }, 57 | "devDependencies": { 58 | "@flopflip/tsconfig": "workspace:*", 59 | "tsup": "8.5.1" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/sessionstorage-cache/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flopflip/sessionstorage-cache", 3 | "version": "15.1.6", 4 | "description": "Sessionstorage cache for flipflop adapters", 5 | "sideEffects": false, 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.js", 10 | "require": "./dist/index.cjs" 11 | } 12 | }, 13 | "main": "./dist/index.js", 14 | "typesVersions": { 15 | "*": { 16 | ".": [ 17 | "dist/*.d.ts", 18 | "dist/*.d.cts" 19 | ] 20 | } 21 | }, 22 | "scripts": { 23 | "build": "rimraf dist && tsup", 24 | "check-types": "tsc --noEmit", 25 | "test": "vitest --run", 26 | "test:watch": "vitest", 27 | "dev": "tsup --watch --clean=false" 28 | }, 29 | "files": [ 30 | "readme.md", 31 | "dist/**" 32 | ], 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/tdeekens/flopflip.git", 39 | "directory": "packages/sessionstorage-cache" 40 | }, 41 | "author": "Tobias Deekens ", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/tdeekens/flopflip/issues" 45 | }, 46 | "homepage": "https://github.com/tdeekens/flopflip#readme", 47 | "keywords": [ 48 | "feature-flags", 49 | "feature-toggles", 50 | "sessionstorage", 51 | "cache", 52 | "client" 53 | ], 54 | "dependencies": { 55 | "@flopflip/types": "workspace:*" 56 | }, 57 | "devDependencies": { 58 | "@flopflip/tsconfig": "workspace:*", 59 | "tsup": "8.5.1" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/react/src/use-adapter-subscription.ts: -------------------------------------------------------------------------------- 1 | import { AdapterSubscriptionStatus, type TAdapter } from '@flopflip/types'; 2 | import { useCallback, useEffect, useRef } from 'react'; 3 | 4 | function useAdapterSubscription(adapter: TAdapter) { 5 | /** 6 | * NOTE: 7 | * This state needs to be duplicated in a React.ref 8 | * as under test multiple instances of flopflip might 9 | * be rendered. This yields in them competing in adapter 10 | * subscription state (e.g. A unsubscribing and B subscribing 11 | * which yields A and B being subscribed as the adapter 12 | * is a singleton). 13 | */ 14 | const useAdapterSubscriptionStatusRef = useRef( 15 | AdapterSubscriptionStatus.Subscribed 16 | ); 17 | 18 | const { subscribe, unsubscribe } = adapter; 19 | 20 | useEffect(() => { 21 | if (subscribe) { 22 | subscribe(); 23 | } 24 | 25 | useAdapterSubscriptionStatusRef.current = 26 | AdapterSubscriptionStatus.Subscribed; 27 | 28 | return () => { 29 | if (unsubscribe) { 30 | unsubscribe(); 31 | } 32 | 33 | useAdapterSubscriptionStatusRef.current = 34 | AdapterSubscriptionStatus.Unsubscribed; 35 | }; 36 | }, [subscribe, unsubscribe]); 37 | 38 | // biome-ignore lint/correctness/useExhaustiveDependencies: false positive 39 | return useCallback( 40 | (demandedAdapterSubscriptionStatus: AdapterSubscriptionStatus) => 41 | useAdapterSubscriptionStatusRef.current === 42 | demandedAdapterSubscriptionStatus, 43 | [useAdapterSubscriptionStatusRef] 44 | ); 45 | } 46 | 47 | export { useAdapterSubscription }; 48 | -------------------------------------------------------------------------------- /packages/cypress-plugin/src/plugin.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import type { TAdapterIdentifiers, TFlags } from '@flopflip/types'; 4 | 5 | type TCypressPluginAddCommandOptions = { 6 | adapterId: TAdapterIdentifiers; 7 | }; 8 | 9 | declare namespace Cypress { 10 | interface Chainable { 11 | updateFeatureFlags: (flags: TFlags) => Chainable; 12 | } 13 | } 14 | 15 | const FLOPFLIP_GLOBAL = '__flopflip__'; 16 | 17 | const addCommands = (options: TCypressPluginAddCommandOptions) => { 18 | Cypress.Commands.add( 19 | // @ts-expect-error Cypress doesn't seem to allow a non any chainable 20 | 'updateFeatureFlags', 21 | (flags: TFlags) => 22 | cy 23 | .window() 24 | .its(FLOPFLIP_GLOBAL) 25 | .then((flopFlipGlobal) => { 26 | const flopflipAdapterGlobal = flopFlipGlobal[options.adapterId]; 27 | 28 | if (!flopflipAdapterGlobal) { 29 | throw new Error( 30 | '@flopflip/cypress: namespace or adapter of the passed id does not exist. Make sure you use one and the specified adapter.' 31 | ); 32 | } 33 | 34 | Cypress.log({ 35 | name: 'updateFeatureFlags', 36 | message: 'Updating @flopflip feature flags.', 37 | consoleProps: () => ({ 38 | flags, 39 | }), 40 | }); 41 | 42 | flopflipAdapterGlobal?.updateFlags(flags, { 43 | unsubscribeFlags: true, 44 | }); 45 | }) 46 | ); 47 | }; 48 | 49 | const install = (_on) => { 50 | // Add event listeners if needed 51 | }; 52 | 53 | export { addCommands, install }; 54 | -------------------------------------------------------------------------------- /packages/react-broadcast/test/use-flag-variations.spec.jsx: -------------------------------------------------------------------------------- 1 | import { renderWithAdapter, screen } from '@flopflip/test-utils'; 2 | import { expect, it } from 'vitest'; 3 | 4 | import { Configure } from '../src/configure'; 5 | import { useFlagVariations } from '../src/use-flag-variations'; 6 | 7 | const render = (TestComponent) => 8 | renderWithAdapter(TestComponent, { 9 | components: { ConfigureFlopFlip: Configure }, 10 | }); 11 | 12 | function TestComponent() { 13 | const [isEnabledFeatureEnabled, isDisabledFeatureDisabled, variation] = 14 | useFlagVariations(['enabledFeature', 'disabledFeature', 'variation']); 15 | 16 | return ( 17 |
    18 |
  • Is enabled: {isEnabledFeatureEnabled ? 'Yes' : 'No'}
  • 19 |
  • Is disabled: {isDisabledFeatureDisabled ? 'No' : 'Yes'}
  • 20 |
  • Variation: {variation}
  • 21 |
22 | ); 23 | } 24 | 25 | it('should indicate a feature being disabled', async () => { 26 | const { waitUntilConfigured } = render(); 27 | 28 | await waitUntilConfigured(); 29 | 30 | expect(screen.getByText('Is disabled: Yes')).toBeInTheDocument(); 31 | }); 32 | 33 | it('should indicate a feature being enabled', async () => { 34 | const { waitUntilConfigured } = render(); 35 | 36 | await waitUntilConfigured(); 37 | 38 | expect(screen.getByText('Is enabled: Yes')).toBeInTheDocument(); 39 | }); 40 | 41 | it('should indicate a flag variation', async () => { 42 | const { waitUntilConfigured } = render(); 43 | 44 | await waitUntilConfigured(); 45 | 46 | expect(screen.getByText('Variation: A')).toBeInTheDocument(); 47 | }); 48 | -------------------------------------------------------------------------------- /packages/cache/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flopflip/cache", 3 | "version": "15.1.6", 4 | "description": "Caching for flipflop adapters", 5 | "sideEffects": false, 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.js", 10 | "require": "./dist/index.cjs" 11 | } 12 | }, 13 | "main": "./dist/index.js", 14 | "typesVersions": { 15 | "*": { 16 | ".": [ 17 | "dist/*.d.ts", 18 | "dist/*.d.cts" 19 | ] 20 | } 21 | }, 22 | "scripts": { 23 | "build": "rimraf dist && tsup", 24 | "check-types": "tsc --noEmit", 25 | "test": "vitest --run", 26 | "test:watch": "vitest", 27 | "dev": "tsup --watch --clean=false" 28 | }, 29 | "files": [ 30 | "readme.md", 31 | "dist/**" 32 | ], 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/tdeekens/flopflip.git", 39 | "directory": "packages/cache" 40 | }, 41 | "author": "Tobias Deekens ", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/tdeekens/flopflip/issues" 45 | }, 46 | "homepage": "https://github.com/tdeekens/flopflip#readme", 47 | "keywords": [ 48 | "feature-flags", 49 | "feature-toggles", 50 | "cache", 51 | "client" 52 | ], 53 | "dependencies": { 54 | "@flopflip/localstorage-cache": "workspace:*", 55 | "@flopflip/sessionstorage-cache": "workspace:*", 56 | "@flopflip/types": "workspace:*" 57 | }, 58 | "devDependencies": { 59 | "@flopflip/tsconfig": "workspace:*", 60 | "tsup": "8.5.1" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flopflip/types", 3 | "version": "15.1.6", 4 | "description": "Type definitions for flipflop", 5 | "sideEffects": false, 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.js", 10 | "require": "./dist/index.cjs" 11 | } 12 | }, 13 | "main": "./dist/index.js", 14 | "typesVersions": { 15 | "*": { 16 | ".": [ 17 | "dist/*.d.ts", 18 | "dist/*.d.cts" 19 | ] 20 | } 21 | }, 22 | "scripts": { 23 | "build": "rimraf dist && tsup", 24 | "check-types": "tsc --noEmit", 25 | "test": "exit 0", 26 | "test:watch": "exit 0", 27 | "dev": "tsup --watch --clean=false" 28 | }, 29 | "files": [ 30 | "readme.md", 31 | "dist/**" 32 | ], 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/tdeekens/flopflip.git", 39 | "directory": "packages/types" 40 | }, 41 | "author": "Tobias Deekens ", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/tdeekens/flopflip/issues" 45 | }, 46 | "homepage": "https://github.com/tdeekens/flopflip#readme", 47 | "keywords": [ 48 | "feature-flags", 49 | "feature-toggles", 50 | "types" 51 | ], 52 | "dependencies": { 53 | "launchdarkly-js-client-sdk": "3.9.0" 54 | }, 55 | "devDependencies": { 56 | "@flopflip/tsconfig": "workspace:*", 57 | "@types/react": "19.2.7", 58 | "tsup": "8.5.1", 59 | "typescript": "5.9.3" 60 | }, 61 | "peerDependencies": { 62 | "typescript": "4.x || 5.x" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/quality.yml: -------------------------------------------------------------------------------- 1 | name: Quality 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | lint: 11 | name: Linting 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v6 17 | 18 | - name: Setup 19 | uses: ./.github/actions/ci 20 | 21 | - name: Lint 22 | run: pnpm lint:ci 23 | 24 | check-types: 25 | name: Type checking 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v6 31 | 32 | - name: Setup 33 | uses: ./.github/actions/ci 34 | 35 | - name: TypeScript 36 | run: pnpm check-types 37 | 38 | testing: 39 | name: Testing 40 | runs-on: ubuntu-latest 41 | 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v6 45 | 46 | - name: Setup 47 | uses: ./.github/actions/ci 48 | 49 | - name: Build 50 | run: pnpm build 51 | 52 | - name: Test (with coverage) 53 | run: pnpm test:coverage --config vitest.shared.ts 54 | 55 | - name: Test (with report) 56 | run: pnpm test:report --config vitest.shared.ts 57 | 58 | - name: Upload coverage 59 | uses: codecov/codecov-action@v5 60 | with: 61 | fail_ci_if_error: true 62 | verbose: true 63 | token: ${{ secrets.CODECOV_TOKEN }} # required 64 | 65 | - name: Upload test results to Codecov 66 | if: ${{ !cancelled() }} 67 | uses: codecov/test-results-action@v1 68 | with: 69 | token: ${{ secrets.CODECOV_TOKEN }} 70 | -------------------------------------------------------------------------------- /packages/react-redux/src/configure.tsx: -------------------------------------------------------------------------------- 1 | import { ConfigureAdapter, useAdapterSubscription } from '@flopflip/react'; 2 | import type { 3 | TAdapter, 4 | TConfigureAdapterChildren, 5 | TConfigureAdapterProps, 6 | TFlags, 7 | } from '@flopflip/types'; 8 | // biome-ignore lint/correctness/noUnusedImports: false positive 9 | import React from 'react'; 10 | import { useUpdateFlags } from './use-update-flags'; 11 | import { useUpdateStatus } from './use-update-status'; 12 | 13 | type TBaseProps = { 14 | readonly children?: TConfigureAdapterChildren; 15 | readonly shouldDeferAdapterConfiguration?: boolean; 16 | readonly defaultFlags?: TFlags; 17 | }; 18 | type TProps = TBaseProps & 19 | TConfigureAdapterProps; 20 | 21 | function Configure({ 22 | adapter, 23 | adapterArgs, 24 | children, 25 | defaultFlags = {}, 26 | shouldDeferAdapterConfiguration = false, 27 | }: TProps) { 28 | const adapterIdentifiers = [adapter.id]; 29 | const handleUpdateFlags = useUpdateFlags({ adapterIdentifiers }); 30 | const handleUpdateStatus = useUpdateStatus(); 31 | 32 | useAdapterSubscription(adapter); 33 | 34 | return ( 35 | 43 | {children} 44 | 45 | ); 46 | } 47 | 48 | Configure.displayName = 'ConfigureFlopflip'; 49 | 50 | export { Configure }; 51 | -------------------------------------------------------------------------------- /packages/cypress-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flopflip/cypress-plugin", 3 | "version": "15.1.6", 4 | "description": "A plugin for Cypress change feature toggles in Cypress runs", 5 | "sideEffects": false, 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.js", 10 | "require": "./dist/index.cjs" 11 | } 12 | }, 13 | "main": "./dist/index.js", 14 | "browser": "./dist/index.js", 15 | "typesVersions": { 16 | "*": { 17 | ".": [ 18 | "dist/*.d.ts", 19 | "dist/*.d.cts" 20 | ] 21 | } 22 | }, 23 | "scripts": { 24 | "build": "rimraf dist && tsup", 25 | "check-types": "tsc --noEmit", 26 | "test": "exit 0", 27 | "test:watch": "exit 0", 28 | "dev": "tsup --watch --clean=false" 29 | }, 30 | "files": [ 31 | "readme.md", 32 | "dist/**" 33 | ], 34 | "publishConfig": { 35 | "access": "public" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "https://github.com/tdeekens/flopflip.git", 40 | "directory": "packages/cypress-plugin" 41 | }, 42 | "author": "Tobias Deekens ", 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/tdeekens/flopflip/issues" 46 | }, 47 | "homepage": "https://github.com/tdeekens/flopflip#readme", 48 | "devDependencies": { 49 | "@flopflip/tsconfig": "workspace:*", 50 | "cypress": "13.6.4", 51 | "tsup": "8.5.1" 52 | }, 53 | "peerDependencies": { 54 | "cypress": "13.x || 14.x" 55 | }, 56 | "dependencies": { 57 | "@flopflip/types": "workspace:*" 58 | }, 59 | "keywords": [ 60 | "react", 61 | "feature-flags", 62 | "feature-toggles", 63 | "LaunchDarkly", 64 | "client" 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /packages/memory-adapter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flopflip/memory-adapter", 3 | "version": "15.1.6", 4 | "description": "An in memory adapter for flipflop", 5 | "sideEffects": false, 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.js", 10 | "require": "./dist/index.cjs" 11 | } 12 | }, 13 | "main": "./dist/index.js", 14 | "typesVersions": { 15 | "*": { 16 | ".": [ 17 | "dist/*.d.ts", 18 | "dist/*.d.cts" 19 | ] 20 | } 21 | }, 22 | "scripts": { 23 | "build": "rimraf dist && tsup", 24 | "check-types": "tsc --noEmit", 25 | "test": "vitest --run", 26 | "test:watch": "vitest", 27 | "dev": "tsup --watch --clean=false" 28 | }, 29 | "files": [ 30 | "readme.md", 31 | "dist/**" 32 | ], 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/tdeekens/flopflip.git", 39 | "directory": "packages/memory-adapter" 40 | }, 41 | "author": "Tobias Deekens ", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/tdeekens/flopflip/issues" 45 | }, 46 | "homepage": "https://github.com/tdeekens/flopflip#readme", 47 | "keywords": [ 48 | "feature-flags", 49 | "feature-toggles", 50 | "memory", 51 | "client" 52 | ], 53 | "dependencies": { 54 | "@babel/runtime": "7.28.4", 55 | "@flopflip/adapter-utilities": "workspace:*", 56 | "@flopflip/types": "workspace:*", 57 | "mitt": "3.0.1", 58 | "tiny-warning": "1.0.3" 59 | }, 60 | "devDependencies": { 61 | "@flopflip/tsconfig": "workspace:*", 62 | "globalthis": "1.0.4", 63 | "tsup": "8.5.1" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/adapter-utilities/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flopflip/adapter-utilities", 3 | "version": "15.1.6", 4 | "description": "Adapter utilities for flipflop", 5 | "sideEffects": false, 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.js", 10 | "require": "./dist/index.cjs" 11 | } 12 | }, 13 | "main": "./dist/index.js", 14 | "typesVersions": { 15 | "*": { 16 | ".": [ 17 | "dist/*.d.ts", 18 | "dist/*.d.cts" 19 | ] 20 | } 21 | }, 22 | "scripts": { 23 | "build": "rimraf dist && tsup", 24 | "check-types": "tsc --noEmit", 25 | "test": "vitest --run", 26 | "test:watch": "vitest", 27 | "dev": "tsup --watch --clean=false" 28 | }, 29 | "files": [ 30 | "readme.md", 31 | "dist/**" 32 | ], 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/tdeekens/flopflip.git", 39 | "directory": "packages/adapter-utilities" 40 | }, 41 | "author": "Tobias Deekens ", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/tdeekens/flopflip/issues" 45 | }, 46 | "homepage": "https://github.com/tdeekens/flopflip#readme", 47 | "keywords": [ 48 | "feature-flags", 49 | "feature-toggles", 50 | "types" 51 | ], 52 | "dependencies": { 53 | "@babel/runtime": "7.28.4", 54 | "@flopflip/types": "workspace:*", 55 | "globalthis": "1.0.4", 56 | "lodash": "4.17.21" 57 | }, 58 | "devDependencies": { 59 | "@flopflip/tsconfig": "workspace:*", 60 | "tsup": "8.5.1", 61 | "typescript": "5.9.3" 62 | }, 63 | "peerDependencies": { 64 | "typescript": "4.x || 5.x" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/react-redux/src/enhancer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | adapterIdentifiers as allAdapterIdentifiers, 3 | type TAdapter, 4 | type TAdapterArgs, 5 | type TAdapterInterface, 6 | type TAdapterStatusChange, 7 | type TFlagsChange, 8 | } from '@flopflip/types'; 9 | import { createAction } from '@reduxjs/toolkit'; 10 | import type { Reducer, Store, StoreEnhancerStoreCreator } from 'redux'; 11 | 12 | import { updateFlags, updateStatus } from './ducks'; 13 | import type { TState } from './types'; 14 | 15 | // Create typed actions 16 | const configureAdapter = createAction<{ 17 | adapter: TAdapter; 18 | adapterArgs: TAdapterArgs; 19 | }>('flopflip/configureAdapter'); 20 | 21 | function createFlopFlipEnhancer( 22 | adapter: TAdapter, 23 | adapterArgs: TAdapterArgs 24 | ): ( 25 | next: StoreEnhancerStoreCreator 26 | ) => (reducer: Reducer, preloadedState?: StoreState) => Store { 27 | return (next) => 28 | (...args) => { 29 | const store: Store = next(...args); 30 | 31 | // Dispatch configuration action 32 | store.dispatch(configureAdapter({ adapter, adapterArgs })); 33 | 34 | // Configure adapter with bound action creators 35 | (adapter as TAdapterInterface).configure(adapterArgs, { 36 | onFlagsStateChange: (flagsChange: TFlagsChange) => { 37 | store.dispatch(updateFlags(flagsChange, [adapter.id])); 38 | }, 39 | onStatusStateChange: (statusChange: TAdapterStatusChange) => { 40 | store.dispatch( 41 | updateStatus(statusChange, Object.keys(allAdapterIdentifiers)) 42 | ); 43 | }, 44 | }); 45 | 46 | return store; 47 | }; 48 | } 49 | 50 | export { createFlopFlipEnhancer }; 51 | -------------------------------------------------------------------------------- /packages/splitio-adapter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flopflip/splitio-adapter", 3 | "version": "15.1.6", 4 | "description": "A adapter around the split.io client for flipflop", 5 | "sideEffects": false, 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.js", 10 | "require": "./dist/index.cjs" 11 | } 12 | }, 13 | "main": "./dist/index.js", 14 | "typesVersions": { 15 | "*": { 16 | ".": [ 17 | "dist/*.d.ts", 18 | "dist/*.d.cts" 19 | ] 20 | } 21 | }, 22 | "scripts": { 23 | "build": "rimraf dist && tsup", 24 | "check-types": "tsc --noEmit", 25 | "test": "vitest --run", 26 | "test:watch": "vitest", 27 | "dev": "tsup --watch --clean=false" 28 | }, 29 | "files": [ 30 | "readme.md", 31 | "dist/**" 32 | ], 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/tdeekens/flopflip.git", 39 | "directory": "packages/splitio-adapter" 40 | }, 41 | "author": "Tobias Deekens ", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/tdeekens/flopflip/issues" 45 | }, 46 | "homepage": "https://github.com/tdeekens/flopflip#readme", 47 | "devDependencies": { 48 | "@flopflip/tsconfig": "workspace:*", 49 | "globalthis": "1.0.4", 50 | "tsup": "8.5.1" 51 | }, 52 | "dependencies": { 53 | "@babel/runtime": "7.28.4", 54 | "@flopflip/adapter-utilities": "workspace:*", 55 | "@flopflip/types": "workspace:*", 56 | "@splitsoftware/splitio": "10.28.1", 57 | "lodash": "4.17.21", 58 | "ts-deepmerge": "7.0.3" 59 | }, 60 | "keywords": [ 61 | "feature-flags", 62 | "feature-toggles", 63 | "split.io", 64 | "client" 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /packages/localstorage-adapter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flopflip/localstorage-adapter", 3 | "version": "15.1.6", 4 | "description": "An localstorage adapter for flipflop", 5 | "sideEffects": false, 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.js", 10 | "require": "./dist/index.cjs" 11 | } 12 | }, 13 | "main": "./dist/index.js", 14 | "typesVersions": { 15 | "*": { 16 | ".": [ 17 | "dist/*.d.ts", 18 | "dist/*.d.cts" 19 | ] 20 | } 21 | }, 22 | "scripts": { 23 | "build": "rimraf dist && tsup", 24 | "check-types": "tsc --noEmit", 25 | "test": "vitest --run", 26 | "test:watch": "vitest", 27 | "dev": "tsup --watch --clean=false" 28 | }, 29 | "files": [ 30 | "readme.md", 31 | "dist/**" 32 | ], 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/tdeekens/flopflip.git", 39 | "directory": "packages/localstorage-adapter" 40 | }, 41 | "author": "Tobias Deekens ", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/tdeekens/flopflip/issues" 45 | }, 46 | "homepage": "https://github.com/tdeekens/flopflip#readme", 47 | "keywords": [ 48 | "feature-flags", 49 | "feature-toggles", 50 | "localstorage", 51 | "client" 52 | ], 53 | "dependencies": { 54 | "@babel/runtime": "7.28.4", 55 | "@flopflip/adapter-utilities": "workspace:*", 56 | "@flopflip/localstorage-cache": "workspace:*", 57 | "@flopflip/types": "workspace:*", 58 | "lodash": "4.17.21", 59 | "mitt": "3.0.1", 60 | "tiny-warning": "1.0.3" 61 | }, 62 | "devDependencies": { 63 | "@flopflip/tsconfig": "workspace:*", 64 | "globalthis": "1.0.4", 65 | "tsup": "8.5.1" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/combine-adapters/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flopflip/combine-adapters", 3 | "version": "15.1.6", 4 | "description": "An adapter which combines other adapters for flipflop", 5 | "sideEffects": false, 6 | "type": "module", 7 | "main": "./dist/index.js", 8 | "exports": { 9 | ".": { 10 | "import": "./dist/index.js", 11 | "require": "./dist/index.cjs" 12 | } 13 | }, 14 | "typesVersions": { 15 | "*": { 16 | "*": [ 17 | "dist/*.d.ts", 18 | "dist/*.d.cts" 19 | ] 20 | } 21 | }, 22 | "files": [ 23 | "readme.md", 24 | "dist/**" 25 | ], 26 | "scripts": { 27 | "build": "rimraf dist && tsup", 28 | "check-types": "tsc --noEmit", 29 | "test": "vitest --run", 30 | "test:watch": "vitest", 31 | "dev": "tsup --watch --clean=false" 32 | }, 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/tdeekens/flopflip.git", 39 | "directory": "packages/combine-adapters" 40 | }, 41 | "author": "Tobias Deekens ", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/tdeekens/flopflip/issues" 45 | }, 46 | "homepage": "https://github.com/tdeekens/flopflip#readme", 47 | "keywords": [ 48 | "feature-flags", 49 | "feature-toggles", 50 | "memory", 51 | "client" 52 | ], 53 | "dependencies": { 54 | "@babel/runtime": "7.28.4", 55 | "@flopflip/adapter-utilities": "workspace:*", 56 | "@flopflip/types": "workspace:*", 57 | "mitt": "3.0.1", 58 | "tiny-warning": "1.0.3" 59 | }, 60 | "devDependencies": { 61 | "@flopflip/localstorage-adapter": "workspace:*", 62 | "@flopflip/memory-adapter": "workspace:*", 63 | "@flopflip/tsconfig": "workspace:*", 64 | "globalthis": "1.0.4", 65 | "tsup": "8.5.1" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/react-redux/test/use-adapter-status.spec.jsx: -------------------------------------------------------------------------------- 1 | import { renderWithAdapter, screen } from '@flopflip/test-utils'; 2 | import { Provider } from 'react-redux'; 3 | import { expect, it } from 'vitest'; 4 | 5 | import { Configure } from '../src/configure'; 6 | import { STATE_SLICE } from '../src/constants'; 7 | import { useAdapterStatus } from '../src/use-adapter-status'; 8 | import { createStore } from './test-utils'; 9 | 10 | const render = (store, TestComponent) => 11 | renderWithAdapter(TestComponent, { 12 | components: { 13 | ConfigureFlopFlip: Configure, 14 | Wrapper: , 15 | }, 16 | }); 17 | 18 | function TestComponent() { 19 | const { isConfiguring, isConfigured } = useAdapterStatus(); 20 | 21 | return ( 22 |
    23 |
  • Is configuring: {isConfiguring ? 'Yes' : 'No'}
  • 24 |
  • Is configured: {isConfigured ? 'Yes' : 'No'}
  • 25 |
26 | ); 27 | } 28 | 29 | it('should indicate the adapter not configured yet', async () => { 30 | const store = createStore({ 31 | [STATE_SLICE]: { flags: { disabledFeature: false } }, 32 | }); 33 | 34 | const { waitUntilConfigured } = render(store, ); 35 | 36 | expect(screen.getByText(/Is configured: No/i)).toBeInTheDocument(); 37 | expect(screen.getByText(/Is configuring: Yes/i)).toBeInTheDocument(); 38 | 39 | await waitUntilConfigured(); 40 | }); 41 | 42 | it('should indicate the adapter is configured and not configuring any longer', async () => { 43 | const store = createStore({ 44 | [STATE_SLICE]: { flags: { disabledFeature: false } }, 45 | }); 46 | 47 | const { waitUntilConfigured } = render(store, ); 48 | 49 | await waitUntilConfigured(); 50 | 51 | expect(screen.getByText(/Is configured: Yes/i)).toBeInTheDocument(); 52 | expect(screen.getByText(/Is configuring: No/i)).toBeInTheDocument(); 53 | }); 54 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const isEnv = (env) => process.env.NODE_ENV === env; 2 | 3 | /** 4 | * @type {import('@babel/core').TransformOptions} 5 | */ 6 | const preset = { 7 | presets: [ 8 | [ 9 | '@babel/env', 10 | { 11 | useBuiltIns: 'entry', 12 | corejs: 3, 13 | }, 14 | ], 15 | [ 16 | '@babel/preset-react', 17 | { 18 | development: isEnv('test'), 19 | useBuiltIns: true, 20 | }, 21 | ], 22 | '@babel/preset-typescript', 23 | ], 24 | plugins: [ 25 | '@babel/plugin-external-helpers', 26 | [ 27 | '@babel/plugin-transform-class-properties', 28 | { 29 | loose: true, 30 | }, 31 | ], 32 | [ 33 | '@babel/plugin-transform-private-methods', 34 | { 35 | loose: true, 36 | }, 37 | ], 38 | [ 39 | '@babel/plugin-transform-private-property-in-object', 40 | { 41 | loose: true, 42 | }, 43 | ], 44 | '@babel/plugin-proposal-export-default-from', 45 | '@babel/plugin-transform-export-namespace-from', 46 | [ 47 | '@babel/plugin-transform-object-rest-spread', 48 | { 49 | useBuiltIns: true, 50 | }, 51 | ], 52 | '@babel/plugin-syntax-dynamic-import', 53 | '@babel/plugin-transform-destructuring', 54 | '@babel/plugin-transform-react-constant-elements', 55 | '@babel/plugin-transform-runtime', 56 | '@babel/plugin-transform-optional-chaining', 57 | '@babel/plugin-transform-nullish-coalescing-operator', 58 | isEnv('test') && [ 59 | '@babel/plugin-transform-regenerator', 60 | { 61 | async: false, 62 | }, 63 | ], 64 | isEnv('test') && 'babel-plugin-transform-dynamic-import', 65 | isEnv('test') && '@babel/plugin-transform-modules-commonjs', 66 | './babel-plugin-package-version', 67 | ].filter(Boolean), 68 | }; 69 | 70 | module.exports = preset; 71 | -------------------------------------------------------------------------------- /packages/http-adapter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flopflip/http-adapter", 3 | "version": "15.1.6", 4 | "description": "An HTTP adapter for flipflop", 5 | "sideEffects": false, 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.js", 10 | "require": "./dist/index.cjs" 11 | } 12 | }, 13 | "main": "./dist/index.js", 14 | "typesVersions": { 15 | "*": { 16 | ".": [ 17 | "dist/*.d.ts", 18 | "dist/*.d.cts" 19 | ] 20 | } 21 | }, 22 | "scripts": { 23 | "build": "rimraf dist && tsup", 24 | "check-types": "tsc --noEmit", 25 | "test": "vitest --run", 26 | "test:watch": "vitest", 27 | "dev": "tsup --watch --clean=false" 28 | }, 29 | "files": [ 30 | "readme.md", 31 | "dist/**" 32 | ], 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/tdeekens/flopflip.git", 39 | "directory": "packages/http-adapter" 40 | }, 41 | "author": "Tobias Deekens ", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/tdeekens/flopflip/issues" 45 | }, 46 | "homepage": "https://github.com/tdeekens/flopflip#readme", 47 | "keywords": [ 48 | "feature-flags", 49 | "feature-toggles", 50 | "graphql", 51 | "client" 52 | ], 53 | "dependencies": { 54 | "@babel/runtime": "7.28.4", 55 | "@flopflip/adapter-utilities": "workspace:*", 56 | "@flopflip/cache": "workspace:*", 57 | "@flopflip/localstorage-cache": "workspace:*", 58 | "@flopflip/sessionstorage-cache": "workspace:*", 59 | "@flopflip/types": "workspace:*", 60 | "lodash": "4.17.21", 61 | "mitt": "3.0.1", 62 | "tiny-warning": "1.0.3" 63 | }, 64 | "devDependencies": { 65 | "@flopflip/tsconfig": "workspace:*", 66 | "globalthis": "1.0.4", 67 | "tsup": "8.5.1" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/graphql-adapter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flopflip/graphql-adapter", 3 | "version": "15.1.6", 4 | "description": "An GraphQL adapter for flipflop", 5 | "sideEffects": false, 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.js", 10 | "require": "./dist/index.cjs" 11 | } 12 | }, 13 | "main": "./dist/index.js", 14 | "typesVersions": { 15 | "*": { 16 | ".": [ 17 | "dist/*.d.ts", 18 | "dist/*.d.cts" 19 | ] 20 | } 21 | }, 22 | "scripts": { 23 | "build": "rimraf dist && tsup", 24 | "check-types": "tsc --noEmit", 25 | "test": "vitest --run", 26 | "test:watch": "vitest", 27 | "dev": "tsup --watch --clean=false" 28 | }, 29 | "files": [ 30 | "readme.md", 31 | "dist/**" 32 | ], 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/tdeekens/flopflip.git", 39 | "directory": "packages/graphql-adapter" 40 | }, 41 | "author": "Tobias Deekens ", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/tdeekens/flopflip/issues" 45 | }, 46 | "homepage": "https://github.com/tdeekens/flopflip#readme", 47 | "keywords": [ 48 | "feature-flags", 49 | "feature-toggles", 50 | "graphql", 51 | "client" 52 | ], 53 | "dependencies": { 54 | "@babel/runtime": "7.28.4", 55 | "@flopflip/adapter-utilities": "workspace:*", 56 | "@flopflip/cache": "workspace:*", 57 | "@flopflip/localstorage-cache": "workspace:*", 58 | "@flopflip/sessionstorage-cache": "workspace:*", 59 | "@flopflip/types": "workspace:*", 60 | "lodash": "4.17.21", 61 | "mitt": "3.0.1", 62 | "tiny-warning": "1.0.3" 63 | }, 64 | "devDependencies": { 65 | "@flopflip/tsconfig": "workspace:*", 66 | "globalthis": "1.0.4", 67 | "tsup": "8.5.1" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/react-redux/test/use-feature-toggles.spec.jsx: -------------------------------------------------------------------------------- 1 | import { renderWithAdapter, screen } from '@flopflip/test-utils'; 2 | import { Provider } from 'react-redux'; 3 | import { describe, expect, it } from 'vitest'; 4 | 5 | import { Configure } from '../src/configure'; 6 | import { STATE_SLICE } from '../src/constants'; 7 | import { useFeatureToggles } from '../src/use-feature-toggles'; 8 | import { createStore } from './test-utils'; 9 | 10 | const render = (store, TestComponent) => 11 | renderWithAdapter(TestComponent, { 12 | components: { 13 | ConfigureFlopFlip: Configure, 14 | Wrapper: , 15 | }, 16 | }); 17 | 18 | function TestComponent() { 19 | const [isEnabledFeatureEnabled, isDisabledFeatureDisabled] = 20 | useFeatureToggles({ 21 | enabledFeature: true, 22 | disabledFeature: true, 23 | }); 24 | 25 | return ( 26 |
    27 |
  • Is enabled: {isEnabledFeatureEnabled ? 'Yes' : 'No'}
  • 28 |
  • Is disabled: {isDisabledFeatureDisabled ? 'No' : 'Yes'}
  • 29 |
30 | ); 31 | } 32 | 33 | describe('when adapter is configured', () => { 34 | it('should indicate a feature being disabled', async () => { 35 | const store = createStore({ 36 | [STATE_SLICE]: { flags: { memory: { disabledFeature: false } } }, 37 | }); 38 | 39 | const { waitUntilConfigured } = render(store, ); 40 | 41 | await waitUntilConfigured(); 42 | 43 | expect(screen.getByText('Is disabled: Yes')).toBeInTheDocument(); 44 | }); 45 | 46 | it('should indicate a feature being enabled', async () => { 47 | const store = createStore({ 48 | [STATE_SLICE]: { flags: { memory: { disabledFeature: false } } }, 49 | }); 50 | 51 | const { waitUntilConfigured } = render(store, ); 52 | 53 | await waitUntilConfigured(); 54 | 55 | expect(screen.getByText('Is enabled: Yes')).toBeInTheDocument(); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /packages/react-redux/test/use-feature-toggle.spec.jsx: -------------------------------------------------------------------------------- 1 | import { renderWithAdapter, screen } from '@flopflip/test-utils'; 2 | import { Provider } from 'react-redux'; 3 | import { describe, expect, it, vi } from 'vitest'; 4 | 5 | import { Configure } from '../src/configure'; 6 | import { STATE_SLICE } from '../src/constants'; 7 | import { useFeatureToggle } from '../src/use-feature-toggle'; 8 | import { createStore } from './test-utils'; 9 | 10 | vi.mock('tiny-warning'); 11 | 12 | const render = (store, TestComponent) => 13 | renderWithAdapter(TestComponent, { 14 | components: { 15 | ConfigureFlopFlip: Configure, 16 | Wrapper: , 17 | }, 18 | }); 19 | 20 | function TestComponent() { 21 | const isEnabledFeatureEnabled = useFeatureToggle('enabledFeature'); 22 | const isDisabledFeatureDisabled = useFeatureToggle('disabledFeature'); 23 | 24 | return ( 25 |
    26 |
  • Is enabled: {isEnabledFeatureEnabled ? 'Yes' : 'No'}
  • 27 |
  • Is disabled: {isDisabledFeatureDisabled ? 'No' : 'Yes'}
  • 28 |
29 | ); 30 | } 31 | 32 | describe('when adapter is configured', () => { 33 | it('should indicate a feature being disabled', async () => { 34 | const store = createStore({ 35 | [STATE_SLICE]: { flags: { memory: { disabledFeature: false } } }, 36 | }); 37 | const { waitUntilConfigured } = render(store, ); 38 | 39 | await waitUntilConfigured(); 40 | 41 | expect(screen.getByText('Is disabled: Yes')).toBeInTheDocument(); 42 | }); 43 | 44 | it('should indicate a feature being enabled', async () => { 45 | const store = createStore({ 46 | [STATE_SLICE]: { flags: { memory: { disabledFeature: false } } }, 47 | }); 48 | 49 | const { waitUntilConfigured } = render(store, ); 50 | 51 | await waitUntilConfigured(); 52 | 53 | expect(screen.getByText('Is enabled: Yes')).toBeInTheDocument(); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /packages/react/test/configure-adapter/helpers.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { mergeAdapterArgs } from '../../src/configure-adapter/helpers'; 3 | 4 | describe('mergeAdapterArgs', () => { 5 | describe('when not `shouldOverwrite`', () => { 6 | const previousAdapterArgs = { 7 | 'some-prop': 'was-present', 8 | }; 9 | const nextAdapterArgs = { 10 | 'another-prop': 'is-added', 11 | }; 12 | 13 | it('should merge the next properties', () => { 14 | expect( 15 | mergeAdapterArgs(previousAdapterArgs, { 16 | adapterArgs: nextAdapterArgs, 17 | options: { shouldOverwrite: false }, 18 | }) 19 | ).toEqual(expect.objectContaining(nextAdapterArgs)); 20 | }); 21 | 22 | it('should keep the previous properties', () => { 23 | expect( 24 | mergeAdapterArgs(previousAdapterArgs, { 25 | adapterArgs: nextAdapterArgs, 26 | options: { shouldOverwrite: false }, 27 | }) 28 | ).toEqual(expect.objectContaining(previousAdapterArgs)); 29 | }); 30 | }); 31 | 32 | describe('when `shouldOverwrite`', () => { 33 | const previousAdapterArgs = { 34 | 'some-prop': 'was-present', 35 | }; 36 | const nextAdapterArgs = { 37 | 'another-prop': 'is-added', 38 | }; 39 | 40 | it('should merge the next properties', () => { 41 | expect( 42 | mergeAdapterArgs(previousAdapterArgs, { 43 | adapterArgs: nextAdapterArgs, 44 | options: { shouldOverwrite: true }, 45 | }) 46 | ).toEqual(expect.objectContaining(nextAdapterArgs)); 47 | }); 48 | 49 | it('should not keep the previous properties', () => { 50 | expect( 51 | mergeAdapterArgs(previousAdapterArgs, { 52 | adapterArgs: nextAdapterArgs, 53 | options: { shouldOverwrite: true }, 54 | }) 55 | ).not.toEqual(expect.objectContaining(previousAdapterArgs)); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /packages/react/test/get-is-feature-enabled.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { getIsFeatureEnabled } from '../src/get-is-feature-enabled'; 3 | 4 | vi.mock('tiny-warning'); 5 | 6 | describe('with existing flag', () => { 7 | describe('with flag variation', () => { 8 | const allFlags = { memory: { fooFlag: 'foo-variation' } }; 9 | const adapterIdentifiers = ['memory']; 10 | 11 | it('should indicate feature being enabled', () => { 12 | expect( 13 | getIsFeatureEnabled( 14 | allFlags, 15 | adapterIdentifiers, 16 | 'fooFlag', 17 | 'foo-variation' 18 | ) 19 | ).toBe(true); 20 | }); 21 | 22 | it('should indicate feature being disabled', () => { 23 | expect( 24 | getIsFeatureEnabled( 25 | allFlags, 26 | adapterIdentifiers, 27 | 'fooFlag', 28 | 'foo-variation-1' 29 | ) 30 | ).toBe(false); 31 | }); 32 | }); 33 | 34 | describe('without flag variation', () => { 35 | it('should indicate feature being enabled', () => { 36 | const allFlags = { memory: { fooFlag: true } }; 37 | const adapterIdentifiers = ['memory']; 38 | 39 | expect(getIsFeatureEnabled(allFlags, adapterIdentifiers, 'fooFlag')).toBe( 40 | true 41 | ); 42 | }); 43 | 44 | it('should indicate feature being disabled', () => { 45 | const allFlags = { memory: { fooFlag: false } }; 46 | const adapterIdentifiers = ['memory']; 47 | 48 | expect(getIsFeatureEnabled(allFlags, adapterIdentifiers, 'fooFlag')).toBe( 49 | false 50 | ); 51 | }); 52 | }); 53 | }); 54 | 55 | describe('with non existing flag', () => { 56 | it('should indicate feature being disabled', () => { 57 | const allFlags = { memory: { fooFlag: true } }; 58 | const adapterIdentifiers = ['memory']; 59 | 60 | expect(getIsFeatureEnabled(allFlags, adapterIdentifiers, 'fooFlag2')).toBe( 61 | false 62 | ); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /packages/react/src/toggle-feature.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { isValidElementType } from 'react-is'; 3 | import warning from 'tiny-warning'; 4 | 5 | type RenderFnArgs = { 6 | isFeatureEnabled: boolean; 7 | }; 8 | type TRenderFn = (args: RenderFnArgs) => React.ReactNode; 9 | export type TProps = { 10 | readonly untoggledComponent?: React.ComponentType; 11 | readonly toggledComponent?: React.ComponentType; 12 | readonly render?: () => React.ReactNode; 13 | readonly children?: TRenderFn | React.ReactNode; 14 | readonly isFeatureEnabled: boolean; 15 | }; 16 | 17 | function ToggleFeature({ 18 | untoggledComponent, 19 | toggledComponent, 20 | render, 21 | children, 22 | isFeatureEnabled, 23 | }: TProps) { 24 | if (untoggledComponent) { 25 | warning( 26 | isValidElementType(untoggledComponent), 27 | `Invalid prop 'untoggledComponent' supplied to 'ToggleFeature': the prop is not a valid React component` 28 | ); 29 | } 30 | 31 | if (toggledComponent) { 32 | warning( 33 | isValidElementType(toggledComponent), 34 | `Invalid prop 'toggledComponent' supplied to 'ToggleFeature': the prop is not a valid React component` 35 | ); 36 | } 37 | 38 | if (isFeatureEnabled) { 39 | if (toggledComponent) { 40 | return React.createElement(toggledComponent); 41 | } 42 | 43 | if (children) { 44 | if (typeof children === 'function') { 45 | return children({ 46 | isFeatureEnabled, 47 | }); 48 | } 49 | return React.Children.only(children); 50 | } 51 | 52 | if (typeof render === 'function') { 53 | return render(); 54 | } 55 | } 56 | 57 | if (typeof children === 'function') { 58 | return children({ 59 | isFeatureEnabled, 60 | }); 61 | } 62 | 63 | if (untoggledComponent) { 64 | return React.createElement(untoggledComponent); 65 | } 66 | 67 | return null; 68 | } 69 | 70 | ToggleFeature.displayName = 'ToggleFeature'; 71 | 72 | export { ToggleFeature }; 73 | -------------------------------------------------------------------------------- /packages/react-redux/src/ducks/status.ts: -------------------------------------------------------------------------------- 1 | import { selectAdapterConfigurationStatus } from '@flopflip/react'; 2 | import type { 3 | TAdapterIdentifiers, 4 | TAdapterStatusChange, 5 | TAdaptersStatus, 6 | } from '@flopflip/types'; 7 | import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; 8 | 9 | import { STATE_SLICE } from '../constants'; 10 | import type { TState } from '../types'; 11 | 12 | const initialState: TAdaptersStatus = {}; 13 | 14 | const statusSlice = createSlice({ 15 | name: 'status', 16 | initialState, 17 | reducers: { 18 | updateStatus: { 19 | reducer( 20 | state, 21 | action: PayloadAction< 22 | TAdapterStatusChange & { adapterIdentifiers: TAdapterIdentifiers[] } 23 | > 24 | ) { 25 | if (action.payload.id) { 26 | state[action.payload.id] = { 27 | ...state[action.payload.id], 28 | ...action.payload.status, 29 | }; 30 | return; 31 | } 32 | 33 | for (const adapterInterfaceIdentifier of action.payload 34 | .adapterIdentifiers) { 35 | state[adapterInterfaceIdentifier] = { 36 | ...state[adapterInterfaceIdentifier], 37 | ...action.payload.status, 38 | }; 39 | } 40 | }, 41 | prepare( 42 | statusChange: TAdapterStatusChange, 43 | adapterIdentifiers: TAdapterIdentifiers[] 44 | ) { 45 | return { 46 | payload: { ...statusChange, adapterIdentifiers }, 47 | }; 48 | }, 49 | }, 50 | }, 51 | }); 52 | 53 | export const { updateStatus } = statusSlice.actions; 54 | export const reducer = statusSlice.reducer; 55 | 56 | // Selectors 57 | type TSelectStatusArgs = { 58 | adapterIdentifiers?: TAdapterIdentifiers[]; 59 | }; 60 | 61 | export const selectStatus = 62 | ({ adapterIdentifiers }: TSelectStatusArgs = {}) => 63 | (state: TState) => { 64 | const { status } = state[STATE_SLICE]; 65 | return selectAdapterConfigurationStatus(status, adapterIdentifiers); 66 | }; 67 | -------------------------------------------------------------------------------- /packages/launchdarkly-adapter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flopflip/launchdarkly-adapter", 3 | "version": "15.1.6", 4 | "description": "A adapter around the LaunchDarkly client for flipflop", 5 | "sideEffects": false, 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.js", 10 | "require": "./dist/index.cjs" 11 | } 12 | }, 13 | "main": "./dist/index.js", 14 | "typesVersions": { 15 | "*": { 16 | ".": [ 17 | "dist/*.d.ts", 18 | "dist/*.d.cts" 19 | ] 20 | } 21 | }, 22 | "scripts": { 23 | "build": "rimraf dist && tsup", 24 | "check-types": "tsc --noEmit", 25 | "test": "vitest --run", 26 | "test:watch": "vitest", 27 | "dev": "tsup --watch --clean=false" 28 | }, 29 | "files": [ 30 | "readme.md", 31 | "dist/**" 32 | ], 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/tdeekens/flopflip.git", 39 | "directory": "packages/launchdarkly-adapter" 40 | }, 41 | "author": "Tobias Deekens ", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/tdeekens/flopflip/issues" 45 | }, 46 | "homepage": "https://github.com/tdeekens/flopflip#readme", 47 | "devDependencies": { 48 | "@flopflip/tsconfig": "workspace:*", 49 | "globalthis": "1.0.4", 50 | "tsup": "8.5.1" 51 | }, 52 | "dependencies": { 53 | "@babel/runtime": "7.28.4", 54 | "@flopflip/adapter-utilities": "workspace:*", 55 | "@flopflip/cache": "workspace:*", 56 | "@flopflip/localstorage-cache": "workspace:*", 57 | "@flopflip/sessionstorage-cache": "workspace:*", 58 | "@flopflip/types": "workspace:*", 59 | "debounce-fn": "4.0.0", 60 | "launchdarkly-js-client-sdk": "3.9.0", 61 | "lodash": "4.17.21", 62 | "mitt": "3.0.1", 63 | "tiny-warning": "1.0.3", 64 | "ts-deepmerge": "7.0.3" 65 | }, 66 | "keywords": [ 67 | "feature-flags", 68 | "feature-toggles", 69 | "LaunchDarkly", 70 | "client" 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /packages/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flopflip/react", 3 | "version": "15.1.6", 4 | "description": "A feature toggle wrapper to use LaunchDarkly with React", 5 | "sideEffects": false, 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.js", 10 | "require": "./dist/index.cjs" 11 | } 12 | }, 13 | "main": "./dist/index.js", 14 | "browser": "./dist/index.js", 15 | "typesVersions": { 16 | "*": { 17 | ".": [ 18 | "dist/*.d.ts", 19 | "dist/*.d.cts" 20 | ] 21 | } 22 | }, 23 | "scripts": { 24 | "build": "rimraf dist && tsup", 25 | "check-types": "tsc --noEmit", 26 | "test": "vitest --run", 27 | "test:watch": "vitest", 28 | "dev": "tsup --watch --clean=false" 29 | }, 30 | "files": [ 31 | "readme.md", 32 | "dist/**" 33 | ], 34 | "publishConfig": { 35 | "access": "public" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "https://github.com/tdeekens/flopflip.git", 40 | "directory": "packages/react" 41 | }, 42 | "author": "Tobias Deekens ", 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/tdeekens/flopflip/issues" 46 | }, 47 | "homepage": "https://github.com/tdeekens/flopflip#readme", 48 | "devDependencies": { 49 | "@flopflip/test-utils": "workspace:*", 50 | "@flopflip/tsconfig": "workspace:*", 51 | "@types/react": "19.2.7", 52 | "@types/react-dom": "19.2.3", 53 | "react": "19.2.3", 54 | "react-dom": "19.2.3", 55 | "tsup": "8.5.1" 56 | }, 57 | "peerDependencies": { 58 | "react": "18.x || 19.x", 59 | "react-dom": "18.x || 19.x" 60 | }, 61 | "dependencies": { 62 | "@babel/runtime": "7.28.4", 63 | "@flopflip/cache": "workspace:*", 64 | "@flopflip/types": "workspace:*", 65 | "@types/react-is": "19.2.0", 66 | "lodash": "4.17.21", 67 | "react-is": "19.2.3", 68 | "tiny-warning": "1.0.3", 69 | "ts-deepmerge": "7.0.3" 70 | }, 71 | "keywords": [ 72 | "react", 73 | "feature-flags", 74 | "feature-toggles", 75 | "LaunchDarkly", 76 | "client" 77 | ] 78 | } 79 | -------------------------------------------------------------------------------- /packages/react-broadcast/test/toggle-feature.spec.jsx: -------------------------------------------------------------------------------- 1 | import { components, renderWithAdapter } from '@flopflip/test-utils'; 2 | import { describe, expect, it } from 'vitest'; 3 | 4 | import { Configure } from '../src/configure'; 5 | import { ToggleFeature } from '../src/toggle-feature'; 6 | 7 | const render = (TestComponent) => 8 | renderWithAdapter(TestComponent, { 9 | components: { ConfigureFlopFlip: Configure }, 10 | }); 11 | function TestEnabledComponent() { 12 | return ( 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | function TestDisabledComponent() { 20 | return ( 21 | 22 | 23 | 24 | ); 25 | } 26 | 27 | describe('when feature is disabled', () => { 28 | it('should not render the component representing a enabled feature', async () => { 29 | const { waitUntilConfigured, queryByFlagName } = render( 30 | 31 | ); 32 | 33 | expect(queryByFlagName('disabledFeature')).not.toBeInTheDocument(); 34 | 35 | await waitUntilConfigured(); 36 | }); 37 | 38 | describe('when enabling feature', () => { 39 | it('should render the component representing a enabled feature', async () => { 40 | const { waitUntilConfigured, getByFlagName, changeFlagVariation } = 41 | render(); 42 | 43 | await waitUntilConfigured(); 44 | 45 | changeFlagVariation('disabledFeature', true); 46 | 47 | expect(getByFlagName('disabledFeature')).toBeInTheDocument(); 48 | }); 49 | }); 50 | }); 51 | 52 | describe('when feature is enabled', () => { 53 | it('should render the component representing a enabled feature', async () => { 54 | const { waitUntilConfigured, queryByFlagName } = render( 55 | 56 | ); 57 | 58 | await waitUntilConfigured(); 59 | 60 | expect(queryByFlagName('enabledFeature')).toHaveAttribute( 61 | 'data-flag-status', 62 | 'enabled' 63 | ); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /packages/react-broadcast/src/test-provider.tsx: -------------------------------------------------------------------------------- 1 | import { AdapterContext, createAdapterContext } from '@flopflip/react'; 2 | import { 3 | AdapterConfigurationStatus, 4 | AdapterSubscriptionStatus, 5 | adapterIdentifiers, 6 | type TAdapterIdentifiers, 7 | type TAdaptersStatus, 8 | type TFlags, 9 | type TReconfigureAdapter, 10 | } from '@flopflip/types'; 11 | // biome-ignore lint/style/useImportType: false positive 12 | import React from 'react'; 13 | 14 | import { createIntialFlagsContext, FlagsContext } from './flags-context'; 15 | 16 | type TProps = { 17 | readonly children: React.ReactNode; 18 | readonly flags: TFlags; 19 | readonly adapterIdentifiers?: TAdapterIdentifiers[]; 20 | readonly reconfigure?: TReconfigureAdapter; 21 | readonly status?: TAdaptersStatus; 22 | }; 23 | 24 | const defaultProps: Pick< 25 | TProps, 26 | 'adapterIdentifiers' | 'reconfigure' | 'status' 27 | > = { 28 | adapterIdentifiers: ['test'], 29 | status: { 30 | ...Object.fromEntries( 31 | Object.values(adapterIdentifiers).map((adapterInterfaceIdentifier) => [ 32 | adapterInterfaceIdentifier, 33 | { 34 | subscriptionStatus: AdapterSubscriptionStatus.Subscribed, 35 | configurationStatus: AdapterConfigurationStatus.Configured, 36 | }, 37 | ]) 38 | ), 39 | }, 40 | }; 41 | 42 | function TestProvider({ 43 | adapterIdentifiers = defaultProps.adapterIdentifiers, 44 | reconfigure, 45 | flags, 46 | children, 47 | status = defaultProps.status, 48 | }: TProps) { 49 | const adapterContextValue = createAdapterContext( 50 | adapterIdentifiers, 51 | reconfigure, 52 | status 53 | ); 54 | const flagsContextValue = createIntialFlagsContext( 55 | // @ts-expect-error Can not remember. Sorry to myself. 56 | adapterIdentifiers, 57 | flags 58 | ); 59 | 60 | return ( 61 | 62 | 63 | {children} 64 | 65 | 66 | ); 67 | } 68 | 69 | TestProvider.displayName = 'TestProviderFlopFlip'; 70 | 71 | export { TestProvider }; 72 | -------------------------------------------------------------------------------- /packages/react-broadcast/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flopflip/react-broadcast", 3 | "version": "15.1.6", 4 | "description": "A feature toggle wrapper to use LaunchDarkly with React", 5 | "sideEffects": false, 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.js", 10 | "require": "./dist/index.cjs" 11 | } 12 | }, 13 | "main": "./dist/index.js", 14 | "browser": "./dist/index.js", 15 | "typesVersions": { 16 | "*": { 17 | ".": [ 18 | "dist/*.d.ts", 19 | "dist/*.d.cts" 20 | ] 21 | } 22 | }, 23 | "scripts": { 24 | "build": "rimraf dist && tsup", 25 | "check-types": "tsc --noEmit", 26 | "test": "vitest --run", 27 | "test:watch": "vitest", 28 | "dev": "tsup --watch --clean=false" 29 | }, 30 | "files": [ 31 | "readme.md", 32 | "dist/**" 33 | ], 34 | "publishConfig": { 35 | "access": "public" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "https://github.com/tdeekens/flopflip.git", 40 | "directory": "packages/react-broadcast" 41 | }, 42 | "author": "Tobias Deekens ", 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/tdeekens/flopflip/issues" 46 | }, 47 | "homepage": "https://github.com/tdeekens/flopflip#readme", 48 | "devDependencies": { 49 | "@flopflip/combine-adapters": "workspace:*", 50 | "@flopflip/localstorage-adapter": "workspace:*", 51 | "@flopflip/memory-adapter": "workspace:*", 52 | "@flopflip/test-utils": "workspace:*", 53 | "@flopflip/tsconfig": "workspace:*", 54 | "@types/react": "19.2.7", 55 | "@types/react-dom": "19.2.3", 56 | "react": "19.2.3", 57 | "react-dom": "19.2.3", 58 | "tsup": "8.5.1" 59 | }, 60 | "peerDependencies": { 61 | "react": "18.x || 19.x", 62 | "react-dom": "18.x || 19.x" 63 | }, 64 | "dependencies": { 65 | "@babel/runtime": "7.28.4", 66 | "@flopflip/react": "workspace:*", 67 | "@flopflip/types": "workspace:*", 68 | "use-sync-external-store": "1.6.0" 69 | }, 70 | "keywords": [ 71 | "react", 72 | "feature-flags", 73 | "feature-toggles", 74 | "LaunchDarkly", 75 | "client" 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /packages/localstorage-cache/test/cache.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { createCache } from '../src/cache'; 3 | 4 | const cachePrefix = 'test'; 5 | 6 | describe('setting a value', () => { 7 | describe('when the value does not exist', () => { 8 | it('should set the value in the cache', () => { 9 | const cache = createCache({ prefix: cachePrefix }); 10 | 11 | cache.set('foo', 'bar'); 12 | 13 | expect( 14 | JSON.parse(localStorage.getItem(`${cachePrefix}/foo`)) 15 | ).toStrictEqual('bar'); 16 | }); 17 | }); 18 | 19 | describe('when the value exists', () => { 20 | it('should set and overwrite the value in the cache', () => { 21 | const cache = createCache({ prefix: cachePrefix }); 22 | 23 | cache.set('foo', 'bar'); 24 | cache.set('foo', 'baz'); 25 | 26 | expect( 27 | JSON.parse(localStorage.getItem(`${cachePrefix}/foo`)) 28 | ).toStrictEqual('baz'); 29 | }); 30 | }); 31 | }); 32 | 33 | describe('getting a value', () => { 34 | describe('when the value exists', () => { 35 | it('should set and overwrite the value in the cache', () => { 36 | const cache = createCache({ prefix: cachePrefix }); 37 | 38 | cache.set('foo', 'bar'); 39 | 40 | expect( 41 | JSON.parse(localStorage.getItem(`${cachePrefix}/foo`)) 42 | ).toStrictEqual('bar'); 43 | }); 44 | }); 45 | 46 | describe('when the value is valid JSON', () => { 47 | it('should set and overwrite the value in the cache', () => { 48 | const cache = createCache({ prefix: cachePrefix }); 49 | const json = { foo: 'foo' }; 50 | 51 | cache.set('foo', json); 52 | 53 | expect( 54 | JSON.parse(localStorage.getItem(`${cachePrefix}/foo`)) 55 | ).toStrictEqual(json); 56 | }); 57 | }); 58 | }); 59 | 60 | describe('unsetting a value', () => { 61 | it('should unset the value in the cache', () => { 62 | const cache = createCache({ prefix: cachePrefix }); 63 | 64 | cache.set('foo', 'bar'); 65 | expect( 66 | JSON.parse(localStorage.getItem(`${cachePrefix}/foo`)) 67 | ).toStrictEqual('bar'); 68 | 69 | cache.unset('foo', 'bar'); 70 | 71 | expect(JSON.parse(localStorage.getItem(`${cachePrefix}/foo`))).toBeNull(); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /packages/react-redux/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flopflip/react-redux", 3 | "version": "15.1.6", 4 | "description": "A feature toggle wrapper to use LaunchDarkly with React Redux", 5 | "sideEffects": false, 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.js", 10 | "require": "./dist/index.cjs" 11 | } 12 | }, 13 | "main": "./dist/index.js", 14 | "browser": "./dist/index.js", 15 | "typesVersions": { 16 | "*": { 17 | ".": [ 18 | "dist/*.d.ts", 19 | "dist/*.d.cts" 20 | ] 21 | } 22 | }, 23 | "scripts": { 24 | "build": "rimraf dist && tsup", 25 | "check-types": "tsc --noEmit", 26 | "test": "vitest --run", 27 | "test:watch": "vitest", 28 | "dev": "tsup --watch --clean=false" 29 | }, 30 | "files": [ 31 | "readme.md", 32 | "dist/**" 33 | ], 34 | "publishConfig": { 35 | "access": "public" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "https://github.com/tdeekens/flopflip.git", 40 | "directory": "packages/react-redux" 41 | }, 42 | "author": "Tobias Deekens ", 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/tdeekens/flopflip/issues" 46 | }, 47 | "homepage": "https://github.com/tdeekens/flopflip#readme", 48 | "devDependencies": { 49 | "@flopflip/combine-adapters": "workspace:*", 50 | "@flopflip/localstorage-adapter": "workspace:*", 51 | "@flopflip/memory-adapter": "workspace:*", 52 | "@flopflip/test-utils": "workspace:*", 53 | "@flopflip/tsconfig": "workspace:*", 54 | "react": "19.2.3", 55 | "react-dom": "19.2.3", 56 | "react-redux": "9.2.0", 57 | "redux": "5.0.1", 58 | "tsup": "8.5.1" 59 | }, 60 | "dependencies": { 61 | "@babel/runtime": "7.28.4", 62 | "@flopflip/react": "workspace:*", 63 | "@flopflip/types": "workspace:*", 64 | "@reduxjs/toolkit": "2.11.2", 65 | "@types/react": "19.2.7", 66 | "@types/react-redux": "7.1.34" 67 | }, 68 | "peerDependencies": { 69 | "react": "18.x || 19.x", 70 | "react-dom": "18.x || 19.x", 71 | "react-redux": "9.x", 72 | "redux": "5.x" 73 | }, 74 | "keywords": [ 75 | "feature-flags", 76 | "feature-toggles", 77 | "LaunchDarkly", 78 | "client" 79 | ] 80 | } 81 | -------------------------------------------------------------------------------- /packages/sessionstorage-cache/test/cache.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { createCache } from '../src/cache'; 3 | 4 | const cachePrefix = 'test'; 5 | 6 | describe('setting a value', () => { 7 | describe('when the value does not exist', () => { 8 | it('should set the value in the cache', () => { 9 | const cache = createCache({ prefix: cachePrefix }); 10 | 11 | cache.set('foo', 'bar'); 12 | 13 | expect( 14 | JSON.parse(sessionStorage.getItem(`${cachePrefix}/foo`)) 15 | ).toStrictEqual('bar'); 16 | }); 17 | }); 18 | 19 | describe('when the value exists', () => { 20 | it('should set and overwrite the value in the cache', () => { 21 | const cache = createCache({ prefix: cachePrefix }); 22 | 23 | cache.set('foo', 'bar'); 24 | cache.set('foo', 'baz'); 25 | 26 | expect( 27 | JSON.parse(sessionStorage.getItem(`${cachePrefix}/foo`)) 28 | ).toStrictEqual('baz'); 29 | }); 30 | }); 31 | }); 32 | 33 | describe('getting a value', () => { 34 | describe('when the value exists', () => { 35 | it('should set and overwrite the value in the cache', () => { 36 | const cache = createCache({ prefix: cachePrefix }); 37 | 38 | cache.set('foo', 'bar'); 39 | 40 | expect( 41 | JSON.parse(sessionStorage.getItem(`${cachePrefix}/foo`)) 42 | ).toStrictEqual('bar'); 43 | }); 44 | }); 45 | 46 | describe('when the value is valid JSON', () => { 47 | it('should set and overwrite the value in the cache', () => { 48 | const cache = createCache({ prefix: cachePrefix }); 49 | const json = { foo: 'foo' }; 50 | 51 | cache.set('foo', json); 52 | 53 | expect( 54 | JSON.parse(sessionStorage.getItem(`${cachePrefix}/foo`)) 55 | ).toStrictEqual(json); 56 | }); 57 | }); 58 | }); 59 | 60 | describe('unsetting a value', () => { 61 | it('should unset the value in the cache', () => { 62 | const cache = createCache({ prefix: cachePrefix }); 63 | 64 | cache.set('foo', 'bar'); 65 | expect( 66 | JSON.parse(sessionStorage.getItem(`${cachePrefix}/foo`)) 67 | ).toStrictEqual('bar'); 68 | 69 | cache.unset('foo', 'bar'); 70 | 71 | expect(JSON.parse(sessionStorage.getItem(`${cachePrefix}/foo`))).toBeNull(); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /tooling/tsconfig/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @flopflip/tsconfig 2 | 3 | ## 15.1.6 4 | 5 | ## 15.1.5 6 | 7 | ### Patch Changes 8 | 9 | - [#2077](https://github.com/tdeekens/flopflip/pull/2077) [`1bf6ce7`](https://github.com/tdeekens/flopflip/commit/1bf6ce73efe5d5f5c0530ed8abff86272d260d27) Thanks [@tdeekens](https://github.com/tdeekens)! - Migrate to Trusted Publishing and update all dependencies. 10 | 11 | ## 15.1.4 12 | 13 | ### Patch Changes 14 | 15 | - [#2074](https://github.com/tdeekens/flopflip/pull/2074) [`3f6ad4f`](https://github.com/tdeekens/flopflip/commit/3f6ad4f5b99fa267cf3adac5f8b52824ec835d77) Thanks [@renovate](https://github.com/apps/renovate)! - Setup Trusted Publishing and update all dependencies. 16 | 17 | ## 15.1.3 18 | 19 | ### Patch Changes 20 | 21 | - [#2044](https://github.com/tdeekens/flopflip/pull/2044) [`7e4b35d`](https://github.com/tdeekens/flopflip/commit/7e4b35d13a0f0976ccbf4686fd53bac4ca3827bc) Thanks [@renovate](https://github.com/apps/renovate)! - Migrate to biome v2 22 | 23 | ## 15.1.2 24 | 25 | ### Patch Changes 26 | 27 | - [`469e9fe`](https://github.com/tdeekens/flopflip/commit/469e9fed68cfd6f791e2ca208c905981160834af) Thanks [@tdeekens](https://github.com/tdeekens)! - Fix lodash imports to be fully qualified 28 | 29 | ## 15.1.1 30 | 31 | ### Patch Changes 32 | 33 | - [`ac606fa`](https://github.com/tdeekens/flopflip/commit/ac606fa23ec2e9325cdb575b57e1b342453c00ae) Thanks [@tdeekens](https://github.com/tdeekens)! - Fix downgrade debounce-fn to not be ESM 34 | 35 | ## 15.1.0 36 | 37 | ### Minor Changes 38 | 39 | - [`5e47dbb`](https://github.com/tdeekens/flopflip/commit/5e47dbbe428d003b4162fd194c7b8c5404555f45) Thanks [@tdeekens](https://github.com/tdeekens)! - Combined release 40 | 41 | ## 15.0.1 42 | 43 | ## 15.0.0 44 | 45 | ### Major Changes 46 | 47 | - [#2001](https://github.com/tdeekens/flopflip/pull/2001) [`fb263d3`](https://github.com/tdeekens/flopflip/commit/fb263d3762d1cff0220a094d445561141a31f01c) Thanks [@tdeekens](https://github.com/tdeekens)! - Requires react 18.x, react-redux 9.x and redux 5.x. 48 | 49 | ## 15.0.0 50 | 51 | ### Major Changes 52 | 53 | - [#2001](https://github.com/tdeekens/flopflip/pull/2001) [`fb263d3`](https://github.com/tdeekens/flopflip/commit/fb263d3762d1cff0220a094d445561141a31f01c) Thanks [@tdeekens](https://github.com/tdeekens)! - Requires react 18.x, react-redux 9.x and redux 5.x. 54 | -------------------------------------------------------------------------------- /packages/react-broadcast/test/test-provider.spec.jsx: -------------------------------------------------------------------------------- 1 | import { render as rtlRender, screen } from '@flopflip/test-utils'; 2 | import { 3 | AdapterConfigurationStatus, 4 | AdapterSubscriptionStatus, 5 | } from '@flopflip/types'; 6 | import { describe, expect, it } from 'vitest'; 7 | 8 | import { TestProvider } from '../src/test-provider'; 9 | import { useAdapterStatus } from '../src/use-adapter-status'; 10 | import { useFeatureToggle } from '../src/use-feature-toggle'; 11 | 12 | const testFlagName = 'testFlag1'; 13 | function TestComponent() { 14 | const { isUnconfigured, isConfiguring, isConfigured } = useAdapterStatus(); 15 | 16 | const isFeatureEnabled = useFeatureToggle(testFlagName); 17 | 18 | return ( 19 |
    20 |
  • Is unconfigured: {isUnconfigured ? 'Yes' : 'No'}
  • 21 |
  • Is configuring: {isConfiguring ? 'Yes' : 'No'}
  • 22 |
  • Is configured: {isConfigured ? 'Yes' : 'No'}
  • 23 |
  • Feature enabled: {isFeatureEnabled ? 'Yes' : 'No'}
  • 24 |
25 | ); 26 | } 27 | 28 | const render = ({ flags, status } = {}) => { 29 | rtlRender( 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | describe('when configured', () => { 37 | it('should expose the default adapter status', async () => { 38 | render({ 39 | flags: { 40 | [testFlagName]: true, 41 | }, 42 | }); 43 | 44 | await screen.findByText(/is configured: yes/i); 45 | 46 | expect(screen.queryByText(/is configuring: yes/)).not.toBeInTheDocument(); 47 | }); 48 | 49 | it('should expose a passed adapter status', async () => { 50 | render({ 51 | status: { 52 | memory: { 53 | subscriptionStatus: AdapterSubscriptionStatus.Unsubscribed, 54 | configurationStatus: AdapterConfigurationStatus.Unconfigured, 55 | }, 56 | }, 57 | }); 58 | 59 | await screen.findByText(/is unconfigured: yes/i); 60 | 61 | expect(screen.queryByText(/is configured: yes/)).not.toBeInTheDocument(); 62 | }); 63 | 64 | it('should expose features', async () => { 65 | render({ 66 | flags: { 67 | [testFlagName]: true, 68 | }, 69 | }); 70 | 71 | await screen.findByText(/is configured: yes/i); 72 | 73 | expect(screen.getByText(/feature enabled: yes/i)).toBeInTheDocument(); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /.claude/commands/openspec/proposal.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: OpenSpec: Proposal 3 | description: Scaffold a new OpenSpec change and validate strictly. 4 | category: OpenSpec 5 | tags: [openspec, change] 6 | --- 7 | 8 | **Guardrails** 9 | - Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. 10 | - Keep changes tightly scoped to the requested outcome. 11 | - Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. 12 | - Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files. 13 | 14 | **Steps** 15 | 1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification. 16 | 2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes//`. 17 | 3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing. 18 | 4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs. 19 | 5. Draft spec deltas in `changes//specs//spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant. 20 | 6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work. 21 | 7. Validate with `openspec validate --strict` and resolve every issue before sharing the proposal. 22 | 23 | **Reference** 24 | - Use `openspec show --json --deltas-only` or `openspec show --type spec` to inspect details when validation fails. 25 | - Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones. 26 | - Explore the codebase with `rg `, `ls`, or direct file reads so proposals align with current implementation realities. 27 | 28 | -------------------------------------------------------------------------------- /packages/react-redux/src/ducks/flags.ts: -------------------------------------------------------------------------------- 1 | import { isNil } from '@flopflip/react'; 2 | import type { 3 | TAdapterIdentifiers, 4 | TFlagName, 5 | TFlagsChange, 6 | TFlagsContext, 7 | TFlagVariation, 8 | } from '@flopflip/types'; 9 | import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; 10 | 11 | import { STATE_SLICE } from '../constants'; 12 | import type { TState } from '../types'; 13 | 14 | const initialState: TFlagsContext = { memory: {} }; 15 | 16 | const flagsSlice = createSlice({ 17 | name: 'flags', 18 | initialState, 19 | reducers: { 20 | updateFlags: { 21 | reducer( 22 | state, 23 | action: PayloadAction< 24 | TFlagsChange & { adapterIdentifiers: TAdapterIdentifiers[] } 25 | > 26 | ) { 27 | if (action.payload.id) { 28 | state[action.payload.id] = { 29 | ...state[action.payload.id], 30 | ...action.payload.flags, 31 | }; 32 | return; 33 | } 34 | 35 | for (const adapterId of action.payload.adapterIdentifiers) { 36 | state[adapterId] = { 37 | ...state[adapterId], 38 | ...action.payload.flags, 39 | }; 40 | } 41 | }, 42 | prepare( 43 | flagsChange: TFlagsChange, 44 | adapterIdentifiers: TAdapterIdentifiers[] 45 | ) { 46 | return { 47 | payload: { ...flagsChange, adapterIdentifiers }, 48 | }; 49 | }, 50 | }, 51 | }, 52 | }); 53 | 54 | export const { updateFlags } = flagsSlice.actions; 55 | export const reducer = flagsSlice.reducer; 56 | 57 | export const createReducer = (preloadedState: TFlagsContext = initialState) => { 58 | // biome-ignore lint/style/useDefaultParameterLast: false positive 59 | return (state = preloadedState, action: ReturnType) => 60 | reducer(state, action); 61 | }; 62 | 63 | export const selectFlags = () => (state: TState) => 64 | state[STATE_SLICE].flags ?? {}; 65 | 66 | export const selectFlag = 67 | (flagName: TFlagName, adapterIdentifiers: TAdapterIdentifiers[]) => 68 | (state: TState): TFlagVariation => { 69 | const allFlags = selectFlags()(state); 70 | let foundFlagVariation: TFlagVariation = false; 71 | 72 | for (const adapterId of adapterIdentifiers) { 73 | const flagValue = allFlags[adapterId]?.[flagName]; 74 | if (!isNil(flagValue)) { 75 | foundFlagVariation = flagValue; 76 | } 77 | } 78 | 79 | return foundFlagVariation; 80 | }; 81 | -------------------------------------------------------------------------------- /packages/react/src/adapter-context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AdapterConfigurationStatus, 3 | type TAdapterContext, 4 | type TAdapterIdentifiers, 5 | type TAdaptersStatus, 6 | type TReconfigureAdapter, 7 | } from '@flopflip/types'; 8 | import { createContext } from 'react'; 9 | 10 | const initialReconfigureAdapter: TReconfigureAdapter = () => undefined; 11 | 12 | const createAdapterContext = ( 13 | adapterIdentifiers?: TAdapterIdentifiers[], 14 | reconfigure?: TReconfigureAdapter, 15 | status?: TAdaptersStatus 16 | ): TAdapterContext => ({ 17 | adapterEffectIdentifiers: adapterIdentifiers ?? [], 18 | reconfigure: reconfigure ?? initialReconfigureAdapter, 19 | status, 20 | }); 21 | 22 | const initialAdapterContext = createAdapterContext(); 23 | const AdapterContext = createContext(initialAdapterContext); 24 | 25 | function hasEveryAdapterStatus( 26 | adapterConfigurationStatus: AdapterConfigurationStatus, 27 | adaptersStatus?: TAdaptersStatus, 28 | adapterIdentifiers?: TAdapterIdentifiers[] 29 | ) { 30 | if (Object.keys(adaptersStatus ?? {}).length === 0) { 31 | return false; 32 | } 33 | 34 | if (Array.isArray(adapterIdentifiers)) { 35 | return adapterIdentifiers.every( 36 | (adapterIdentifier) => 37 | adaptersStatus?.[adapterIdentifier]?.configurationStatus === 38 | adapterConfigurationStatus 39 | ); 40 | } 41 | 42 | return Object.values(adaptersStatus ?? {}).every( 43 | (adapterStatus) => 44 | adapterStatus.configurationStatus === adapterConfigurationStatus 45 | ); 46 | } 47 | 48 | const selectAdapterConfigurationStatus = ( 49 | adaptersStatus?: TAdaptersStatus, 50 | adapterIdentifiers?: TAdapterIdentifiers[] 51 | ) => { 52 | const isReady = hasEveryAdapterStatus( 53 | AdapterConfigurationStatus.Configured, 54 | adaptersStatus, 55 | adapterIdentifiers 56 | ); 57 | const isUnconfigured = hasEveryAdapterStatus( 58 | AdapterConfigurationStatus.Unconfigured, 59 | adaptersStatus, 60 | adapterIdentifiers 61 | ); 62 | const isConfiguring = hasEveryAdapterStatus( 63 | AdapterConfigurationStatus.Configuring, 64 | adaptersStatus, 65 | adapterIdentifiers 66 | ); 67 | const isConfigured = hasEveryAdapterStatus( 68 | AdapterConfigurationStatus.Configured, 69 | adaptersStatus, 70 | adapterIdentifiers 71 | ); 72 | 73 | const status = { isReady, isUnconfigured, isConfiguring, isConfigured }; 74 | 75 | return status; 76 | }; 77 | 78 | export { 79 | createAdapterContext, 80 | selectAdapterConfigurationStatus, 81 | AdapterContext, 82 | }; 83 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | id-token: write 10 | contents: write 11 | pull-requests: write 12 | 13 | jobs: 14 | release: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: GitHub context 19 | run: echo "$GITHUB_CONTEXT" 20 | env: 21 | GITHUB_CONTEXT: ${{ toJson(github) }} 22 | 23 | - name: Checkout 24 | if: github.ref == 'refs/heads/main' 25 | uses: actions/checkout@v6 26 | 27 | - name: Install pnpm 28 | uses: pnpm/action-setup@v4.2.0 29 | with: 30 | run_install: false 31 | 32 | - name: Fetch all tags (for releases) 33 | run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* 34 | 35 | - name: Setup Node (uses version in .nvmrc) 36 | uses: actions/setup-node@v6 37 | with: 38 | node-version-file: '.nvmrc' 39 | cache: 'pnpm' 40 | registry-url: 'https://registry.npmjs.org' 41 | 42 | - name: Install dependencies 43 | run: pnpm install 44 | 45 | - name: Storing release version for changeset 46 | id: release_version 47 | run: ./scripts/echo-release-version.sh 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 50 | 51 | - name: Ouptut version 52 | run: echo "The version is ${{ steps.release_version.outputs.VERSION }}" 53 | 54 | 55 | 56 | - name: Building packages 57 | run: pnpm build 58 | 59 | - name: Create Release Pull Request or Publish to npm 60 | id: changesets 61 | # uses: changesets/action@v1 62 | uses: dotansimha/changesets-action@v1.5.2 63 | with: 64 | publish: pnpm changeset publish 65 | version: pnpm changeset:version-and-format 66 | title: 'ci(changesets): version packages' 67 | commit: 'ci(changesets): version packages' 68 | createGithubReleases: aggregate 69 | githubReleaseName: v${{ steps.release_version.outputs.VERSION }} 70 | githubTagName: v${{ steps.release_version.outputs.VERSION }} 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 73 | 74 | - name: Publishing canary releases to npm registry 75 | if: steps.changesets.outputs.published != 'true' && github.ref == 'refs/heads/main' 76 | run: | 77 | git checkout main 78 | pnpm changeset version --snapshot canary 79 | pnpm changeset publish --tag canary 80 | env: 81 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 82 | -------------------------------------------------------------------------------- /packages/react-broadcast/test/inject-feature-toggle.spec.jsx: -------------------------------------------------------------------------------- 1 | import { components, renderWithAdapter } from '@flopflip/test-utils'; 2 | import { describe, expect, it } from 'vitest'; 3 | 4 | import { Configure } from '../src/configure'; 5 | import { injectFeatureToggle } from '../src/inject-feature-toggle'; 6 | 7 | const render = (TestComponent) => 8 | renderWithAdapter(TestComponent, { 9 | components: { ConfigureFlopFlip: Configure }, 10 | }); 11 | 12 | describe('without `propKey`', () => { 13 | describe('when feature is disabled', () => { 14 | it('should render receive the flag value as `false`', async () => { 15 | const TestComponent = injectFeatureToggle('disabledFeature')( 16 | components.FlagsToComponent 17 | ); 18 | 19 | const { waitUntilConfigured, queryByFlagName } = render( 20 | 21 | ); 22 | 23 | expect(queryByFlagName('isFeatureEnabled')).toHaveTextContent('false'); 24 | 25 | await waitUntilConfigured(); 26 | }); 27 | 28 | describe('when enabling feature', () => { 29 | it('should render the component representing a enabled feature', async () => { 30 | const TestComponent = injectFeatureToggle('disabledFeature')( 31 | components.FlagsToComponent 32 | ); 33 | 34 | const { waitUntilConfigured, queryByFlagName, changeFlagVariation } = 35 | render(); 36 | 37 | await waitUntilConfigured(); 38 | 39 | changeFlagVariation('disabledFeature', true); 40 | 41 | expect(queryByFlagName('isFeatureEnabled')).toHaveTextContent('true'); 42 | }); 43 | }); 44 | }); 45 | 46 | describe('when feature is enabled', () => { 47 | it('should render receive the flag value as `true`', async () => { 48 | const TestComponent = injectFeatureToggle('enabledFeature')( 49 | components.FlagsToComponent 50 | ); 51 | 52 | const { waitUntilConfigured, queryByFlagName } = render( 53 | 54 | ); 55 | 56 | await waitUntilConfigured(); 57 | 58 | expect(queryByFlagName('isFeatureEnabled')).toHaveTextContent('true'); 59 | }); 60 | }); 61 | }); 62 | 63 | describe('with `propKey`', () => { 64 | describe('when feature is disabled', () => { 65 | it('should render receive the flag value as `false`', async () => { 66 | const TestComponent = injectFeatureToggle( 67 | 'disabledFeature', 68 | 'customPropKey' 69 | )(components.FlagsToComponent); 70 | 71 | const { waitUntilConfigured, queryByFlagName } = render( 72 | 73 | ); 74 | 75 | await waitUntilConfigured(); 76 | 77 | expect(queryByFlagName('customPropKey')).toHaveTextContent('false'); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /packages/react-redux/test/use-flag-variations.spec.jsx: -------------------------------------------------------------------------------- 1 | import { renderWithAdapter, screen } from '@flopflip/test-utils'; 2 | import { Provider } from 'react-redux'; 3 | import { describe, expect, it } from 'vitest'; 4 | 5 | import { Configure } from '../src/configure'; 6 | import { STATE_SLICE } from '../src/constants'; 7 | import { useFlagVariations } from '../src/use-flag-variations'; 8 | import { createStore } from './test-utils'; 9 | 10 | const render = (store, TestComponent) => 11 | renderWithAdapter(TestComponent, { 12 | components: { 13 | ConfigureFlopFlip: Configure, 14 | Wrapper: , 15 | }, 16 | }); 17 | 18 | function TestComponent() { 19 | const [isEnabledFeatureEnabled, isDisabledFeatureDisabled, variation] = 20 | useFlagVariations(['enabledFeature', 'disabledFeature', 'variation']); 21 | 22 | return ( 23 |
    24 |
  • Is enabled: {isEnabledFeatureEnabled ? 'Yes' : 'No'}
  • 25 |
  • Is disabled: {isDisabledFeatureDisabled ? 'No' : 'Yes'}
  • 26 |
  • Variation: {variation}
  • 27 |
28 | ); 29 | } 30 | 31 | describe('when adaopter is configured', () => { 32 | it('should indicate a feature being disabled', async () => { 33 | const store = createStore({ 34 | [STATE_SLICE]: { 35 | flags: { 36 | memory: { 37 | enabledFeature: true, 38 | disabledFeature: false, 39 | variation: 'A', 40 | }, 41 | }, 42 | }, 43 | }); 44 | 45 | const { waitUntilConfigured } = render(store, ); 46 | 47 | await waitUntilConfigured(); 48 | 49 | expect(screen.getByText('Is disabled: Yes')).toBeInTheDocument(); 50 | }); 51 | 52 | it('should indicate a feature being enabled', async () => { 53 | const store = createStore({ 54 | [STATE_SLICE]: { 55 | flags: { 56 | memory: { 57 | enabledFeature: true, 58 | disabledFeature: false, 59 | variation: 'A', 60 | }, 61 | }, 62 | }, 63 | }); 64 | 65 | const { waitUntilConfigured } = render(store, ); 66 | 67 | await waitUntilConfigured(); 68 | 69 | expect(screen.getByText('Is enabled: Yes')).toBeInTheDocument(); 70 | }); 71 | 72 | it('should indicate a flag variation', async () => { 73 | const store = createStore({ 74 | [STATE_SLICE]: { 75 | flags: { 76 | memory: { 77 | enabledFeature: true, 78 | disabledFeature: false, 79 | variation: 'A', 80 | }, 81 | }, 82 | }, 83 | }); 84 | 85 | const { waitUntilConfigured } = render(store, ); 86 | 87 | await waitUntilConfigured(); 88 | 89 | expect(screen.getByText('Variation: A')).toBeInTheDocument(); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /packages/react-redux/test/configure.spec.jsx: -------------------------------------------------------------------------------- 1 | import { adapter } from '@flopflip/memory-adapter'; 2 | import { act, render as rtlRender, screen } from '@flopflip/test-utils'; 3 | import { Provider } from 'react-redux'; 4 | import { describe, expect, it } from 'vitest'; 5 | 6 | import { Configure } from '../src/configure'; 7 | import { STATE_SLICE } from '../src/constants'; 8 | import { useAdapterStatus } from '../src/use-adapter-status'; 9 | import { useFeatureToggle } from '../src/use-feature-toggle'; 10 | import { createStore } from './test-utils'; 11 | 12 | const testFlagName = 'firstFlag'; 13 | function TestComponent() { 14 | const { isUnconfigured, isConfiguring, isConfigured } = useAdapterStatus(); 15 | const isFeatureEnabled = useFeatureToggle(testFlagName); 16 | 17 | return ( 18 |
    19 |
  • Is unconfigured: {isUnconfigured ? 'Yes' : 'No'}
  • 20 |
  • Is configuring: {isConfiguring ? 'Yes' : 'No'}
  • 21 |
  • Is configured: {isConfigured ? 'Yes' : 'No'}
  • 22 |
  • Feature enabled: {isFeatureEnabled ? 'Yes' : 'No'}
  • 23 |
24 | ); 25 | } 26 | 27 | const render = () => { 28 | const props = createTestProps(); 29 | const store = createStore({ 30 | [STATE_SLICE]: { flags: { disabledFeature: false } }, 31 | }); 32 | 33 | rtlRender( 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | 41 | const waitUntilConfigured = () => screen.findByText(/Is configured: Yes/i); 42 | 43 | return { waitUntilConfigured }; 44 | }; 45 | 46 | const createTestProps = (custom) => ({ 47 | adapter, 48 | adapterArgs: { 49 | fooId: 'foo-id', 50 | }, 51 | 52 | ...custom, 53 | }); 54 | 55 | describe('when feature is disabled', () => { 56 | it('should indicate the feature being disabled', async () => { 57 | const { waitUntilConfigured } = render(); 58 | 59 | await waitUntilConfigured(); 60 | 61 | expect(screen.getByText(/Feature enabled: No/i)).toBeInTheDocument(); 62 | }); 63 | }); 64 | 65 | describe('when enabling feature is', () => { 66 | it('should indicate the feature being enabled', async () => { 67 | const { waitUntilConfigured } = render(); 68 | 69 | await waitUntilConfigured(); 70 | 71 | act(() => { 72 | adapter.updateFlags({ 73 | [testFlagName]: true, 74 | }); 75 | }); 76 | 77 | expect(screen.getByText(/Feature enabled: Yes/i)).toBeInTheDocument(); 78 | }); 79 | }); 80 | 81 | describe('when configured', () => { 82 | it('should indicate through the adapter state', async () => { 83 | const { waitUntilConfigured } = render(); 84 | 85 | await waitUntilConfigured(); 86 | await adapter.waitUntilConfigured(); 87 | 88 | expect(screen.getByText(/Is configuring: No/i)).toBeInTheDocument(); 89 | expect(screen.getByText(/Is configured: Yes/i)).toBeInTheDocument(); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /packages/react/test/use-adapter-subscription.spec.jsx: -------------------------------------------------------------------------------- 1 | import { render as rtlRender, screen } from '@flopflip/test-utils'; 2 | import { 3 | AdapterConfigurationStatus, 4 | AdapterSubscriptionStatus, 5 | } from '@flopflip/types'; 6 | import { describe, expect, it, vi } from 'vitest'; 7 | 8 | import { useAdapterSubscription } from '../src/use-adapter-subscription'; 9 | 10 | const createAdapter = () => ({ 11 | getIsConfigurationStatus: vi.fn( 12 | () => AdapterConfigurationStatus.Unconfigured 13 | ), 14 | configure: vi.fn(() => Promise.resolve()), 15 | reconfigure: vi.fn(() => Promise.resolve()), 16 | subscribe: vi.fn(), 17 | unsubscribe: vi.fn(), 18 | }); 19 | 20 | function TestComponent({ adapter }) { 21 | const getHasAdapterSubscriptionStatus = useAdapterSubscription(adapter); 22 | 23 | const isConfigured = adapter.getIsConfigurationStatus( 24 | AdapterConfigurationStatus.Configured 25 | ); 26 | 27 | return ( 28 | <> 29 |

Test Component

30 |
    31 |
  • Is configured: {isConfigured ? 'Yes' : 'No'}
  • 32 |
  • 33 | Is subscribed:{' '} 34 | {getHasAdapterSubscriptionStatus(AdapterSubscriptionStatus.Subscribed) 35 | ? 'Yes' 36 | : 'No'} 37 |
  • 38 |
  • 39 | Is unsubscribed:{' '} 40 | {getHasAdapterSubscriptionStatus( 41 | AdapterSubscriptionStatus.Unsubscribed 42 | ) 43 | ? 'Yes' 44 | : 'No'} 45 |
  • 46 |
47 | 48 | ); 49 | } 50 | 51 | const render = ({ adapter }) => { 52 | const props = { adapter }; 53 | const { unmount } = rtlRender(); 54 | const waitUntilConfigured = () => Promise.resolve(); 55 | 56 | return { waitUntilConfigured, unmount, renderProps: props }; 57 | }; 58 | 59 | describe('rendering', () => { 60 | it('should unsubscribe the adapter when mounting', async () => { 61 | const adapter = createAdapter(); 62 | 63 | const { waitUntilConfigured, renderProps } = render({ adapter }); 64 | 65 | await waitUntilConfigured(); 66 | 67 | expect(renderProps.adapter.subscribe).toHaveBeenCalled(); 68 | }); 69 | 70 | it('should return adapter subscribtion status indicating being subscribed', async () => { 71 | const adapter = createAdapter(); 72 | 73 | const { waitUntilConfigured } = render({ adapter }); 74 | 75 | await waitUntilConfigured(); 76 | 77 | expect(screen.getByText(/Is subscribed: Yes/i)).toBeInTheDocument(); 78 | expect(screen.getByText(/Is unsubscribed: No/i)).toBeInTheDocument(); 79 | }); 80 | 81 | it('should unsubscribe the adapter when unmounting', async () => { 82 | const adapter = createAdapter(); 83 | 84 | const { unmount, waitUntilConfigured, renderProps } = render({ adapter }); 85 | 86 | await waitUntilConfigured(); 87 | 88 | unmount(); 89 | 90 | expect(renderProps.adapter.unsubscribe).toHaveBeenCalled(); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /packages/react-broadcast/test/configure.spec.jsx: -------------------------------------------------------------------------------- 1 | import { adapter } from '@flopflip/memory-adapter'; 2 | import { act, render as rtlRender, screen } from '@flopflip/test-utils'; 3 | import { describe, expect, it, vi } from 'vitest'; 4 | 5 | import { Configure } from '../src/configure'; 6 | import { useAdapterStatus } from '../src/use-adapter-status'; 7 | import { useFeatureToggle } from '../src/use-feature-toggle'; 8 | 9 | const testFlagName = 'firstFlag'; 10 | function TestComponent() { 11 | const { isUnconfigured, isConfiguring, isConfigured } = useAdapterStatus(); 12 | 13 | const isFeatureEnabled = useFeatureToggle(testFlagName); 14 | 15 | return ( 16 |
    17 |
  • Is unconfigured: {isUnconfigured ? 'Yes' : 'No'}
  • 18 |
  • Is configuring: {isConfiguring ? 'Yes' : 'No'}
  • 19 |
  • Is configured: {isConfigured ? 'Yes' : 'No'}
  • 20 |
  • Feature enabled: {isFeatureEnabled ? 'Yes' : 'No'}
  • 21 |
22 | ); 23 | } 24 | 25 | const createTestProps = (custom) => ({ 26 | adapter, 27 | adapterArgs: { 28 | fooId: 'foo-id', 29 | }, 30 | 31 | ...custom, 32 | }); 33 | 34 | const render = () => { 35 | const props = createTestProps(); 36 | rtlRender( 37 | 38 | 39 | 40 | ); 41 | const waitUntilConfigured = () => screen.findByText(/Is configured: Yes/i); 42 | 43 | return { waitUntilConfigured }; 44 | }; 45 | 46 | describe('when feature is disabled', () => { 47 | it('should indicate the feature being disabled', async () => { 48 | const { waitUntilConfigured } = render(); 49 | 50 | await waitUntilConfigured(); 51 | 52 | expect(screen.getByText(/Feature enabled: No/i)).toBeInTheDocument(); 53 | }); 54 | }); 55 | 56 | describe('when enabling feature', () => { 57 | it('should indicate the feature being enabled', async () => { 58 | const { waitUntilConfigured } = render(); 59 | 60 | await waitUntilConfigured(); 61 | 62 | act(() => { 63 | adapter.updateFlags({ 64 | [testFlagName]: true, 65 | }); 66 | }); 67 | 68 | expect(screen.getByText(/Feature enabled: Yes/i)).toBeInTheDocument(); 69 | }); 70 | 71 | it('should not reconfigure the adapter multiple times', async () => { 72 | const { waitUntilConfigured } = render(); 73 | const spy = vi.spyOn(adapter, 'reconfigure'); 74 | 75 | await waitUntilConfigured(); 76 | 77 | act(() => { 78 | adapter.updateFlags({ 79 | [testFlagName]: true, 80 | }); 81 | }); 82 | 83 | expect(spy).toHaveBeenCalledTimes(1); 84 | spy.mockRestore(); 85 | }); 86 | }); 87 | 88 | describe('when configured', () => { 89 | it('should indicate through the adapter state', async () => { 90 | const { waitUntilConfigured } = render(); 91 | 92 | await waitUntilConfigured(); 93 | 94 | expect(screen.getByText(/Is configuring: No/i)).toBeInTheDocument(); 95 | expect(screen.getByText(/Is configured: Yes/i)).toBeInTheDocument(); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /packages/react-redux/test/toggle-feature.spec.jsx: -------------------------------------------------------------------------------- 1 | import { components, renderWithAdapter } from '@flopflip/test-utils'; 2 | import { Provider } from 'react-redux'; 3 | import { describe, expect, it } from 'vitest'; 4 | 5 | import { Configure } from '../src/configure'; 6 | import { STATE_SLICE } from '../src/constants'; 7 | import { ToggleFeature } from '../src/toggle-feature'; 8 | import { createStore } from './test-utils'; 9 | 10 | const render = (store, TestComponent) => 11 | renderWithAdapter(TestComponent, { 12 | components: { 13 | ConfigureFlopFlip: Configure, 14 | Wrapper: , 15 | }, 16 | }); 17 | 18 | describe('', () => { 19 | describe('when feature is disabled', () => { 20 | it('should not render the component representing a enabled feature', async () => { 21 | function TestComponent() { 22 | return ( 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | const store = createStore({ 30 | [STATE_SLICE]: { flags: { memory: { disabledFeature: false } } }, 31 | }); 32 | 33 | const { waitUntilConfigured, queryByFlagName } = render( 34 | store, 35 | 36 | ); 37 | 38 | await waitUntilConfigured(); 39 | 40 | expect(queryByFlagName('disabledFeature')).not.toBeInTheDocument(); 41 | }); 42 | 43 | describe('when enabling feature', () => { 44 | it('should render the component representing a enabled feature', async () => { 45 | function TestComponent() { 46 | return ( 47 | 48 | 49 | 50 | ); 51 | } 52 | 53 | const store = createStore({ 54 | [STATE_SLICE]: { flags: { memory: { disabledFeature: false } } }, 55 | }); 56 | 57 | const { waitUntilConfigured, getByFlagName, changeFlagVariation } = 58 | render(store, ); 59 | 60 | await waitUntilConfigured(); 61 | 62 | changeFlagVariation('disabledFeature', true); 63 | 64 | expect(getByFlagName('disabledFeature')).toBeInTheDocument(); 65 | }); 66 | }); 67 | }); 68 | 69 | describe('when feature is enabled', () => { 70 | it('should render the component representing a enabled feature', async () => { 71 | const store = createStore({ 72 | [STATE_SLICE]: { flags: { memory: { enabledFeature: true } } }, 73 | }); 74 | function TestComponent() { 75 | return ( 76 | 77 | 78 | 79 | ); 80 | } 81 | 82 | const { waitUntilConfigured, queryByFlagName } = render( 83 | store, 84 | 85 | ); 86 | 87 | await waitUntilConfigured(); 88 | 89 | expect(queryByFlagName('enabledFeature')).toHaveAttribute( 90 | 'data-flag-status', 91 | 'enabled' 92 | ); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /packages/react-broadcast/test/inject-feature-toggles.spec.jsx: -------------------------------------------------------------------------------- 1 | import { components, renderWithAdapter } from '@flopflip/test-utils'; 2 | import { describe, expect, it } from 'vitest'; 3 | 4 | import { Configure } from '../src/configure'; 5 | import { injectFeatureToggles } from '../src/inject-feature-toggles'; 6 | 7 | const render = (TestComponent) => 8 | renderWithAdapter(TestComponent, { 9 | components: { ConfigureFlopFlip: Configure }, 10 | }); 11 | 12 | function FlagsToComponent(props) { 13 | return ; 14 | } 15 | 16 | function FlagsToComponentWithPropKey(props) { 17 | return ; 18 | } 19 | 20 | describe('without `propKey`', () => { 21 | it('should have feature enabling prop for `enabledFeature`', async () => { 22 | const TestComponent = injectFeatureToggles([ 23 | 'disabledFeature', 24 | 'enabledFeature', 25 | ])(FlagsToComponent); 26 | 27 | const { waitUntilConfigured, queryByFlagName } = render(); 28 | 29 | await waitUntilConfigured(); 30 | 31 | expect(queryByFlagName('enabledFeature')).toHaveTextContent('true'); 32 | }); 33 | 34 | it('should have feature disabling prop for `disabledFeature`', async () => { 35 | const TestComponent = injectFeatureToggles([ 36 | 'disabledFeature', 37 | 'enabledFeature', 38 | ])(FlagsToComponent); 39 | 40 | const { waitUntilConfigured, queryByFlagName } = render(); 41 | 42 | await waitUntilConfigured(); 43 | 44 | expect(queryByFlagName('disabledFeature')).toHaveTextContent('false'); 45 | }); 46 | 47 | describe('when enabling feature', () => { 48 | it('should render the component representing a enabled feature', async () => { 49 | const TestComponent = injectFeatureToggles([ 50 | 'disabledFeature', 51 | 'enabledFeature', 52 | ])(FlagsToComponent); 53 | 54 | const { waitUntilConfigured, changeFlagVariation, queryByFlagName } = 55 | render(); 56 | 57 | await waitUntilConfigured(); 58 | 59 | changeFlagVariation('disabledFeature', true); 60 | 61 | expect(queryByFlagName('disabledFeature')).toHaveTextContent('true'); 62 | }); 63 | }); 64 | }); 65 | 66 | describe('with `propKey`', () => { 67 | it('should have feature enabling prop for `enabledFeature`', async () => { 68 | const TestComponent = injectFeatureToggles( 69 | ['disabledFeature', 'enabledFeature'], 70 | 'onOffs' 71 | )(FlagsToComponentWithPropKey); 72 | 73 | const { waitUntilConfigured, queryByFlagName } = render(); 74 | 75 | await waitUntilConfigured(); 76 | 77 | expect(queryByFlagName('enabledFeature')).toHaveTextContent('true'); 78 | }); 79 | 80 | it('should have feature disabling prop for `disabledFeature`', async () => { 81 | const TestComponent = injectFeatureToggles( 82 | ['disabledFeature', 'enabledFeature'], 83 | 'onOffs' 84 | )(FlagsToComponentWithPropKey); 85 | 86 | const { waitUntilConfigured, queryByFlagName } = render(); 87 | 88 | await waitUntilConfigured(); 89 | 90 | expect(queryByFlagName('disabledFeature')).toHaveTextContent('false'); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", 3 | "vcs": { 4 | "clientKind": "git", 5 | "enabled": true, 6 | "useIgnoreFile": true 7 | }, 8 | "files": { 9 | "ignoreUnknown": true 10 | }, 11 | "formatter": { 12 | "enabled": true, 13 | "formatWithErrors": false, 14 | "indentStyle": "space", 15 | "indentWidth": 2, 16 | "lineEnding": "lf", 17 | "lineWidth": 80, 18 | "includes": [ 19 | "**", 20 | "!**/package.json", 21 | "!**/dist/**", 22 | "!**/.changeset", 23 | "!**/CHANGELOG.md", 24 | "!**/pnpm-lock.yaml", 25 | "!**/pnpm-workspace.yaml" 26 | ] 27 | }, 28 | "assist": { "actions": { "source": { "organizeImports": "on" } } }, 29 | "linter": { 30 | "enabled": true, 31 | "rules": { 32 | "recommended": true, 33 | "performance": { 34 | "noAccumulatingSpread": "off" 35 | }, 36 | "complexity": { 37 | "noVoid": "error", 38 | "noUselessFragments": "off" 39 | }, 40 | "correctness": { 41 | "noUndeclaredVariables": "error", 42 | "noUnreachableSuper": "error", 43 | "noUnusedVariables": "error", 44 | "useExhaustiveDependencies": "warn", 45 | "useHookAtTopLevel": "error" 46 | }, 47 | "style": { 48 | "noNegationElse": "error", 49 | "noRestrictedGlobals": { 50 | "level": "error", 51 | "options": { 52 | "deniedGlobals": { 53 | "event": "TODO: Add a custom message here.", 54 | "atob": "TODO: Add a custom message here.", 55 | "btoa": "TODO: Add a custom message here." 56 | } 57 | } 58 | }, 59 | "useBlockStatements": "error", 60 | "useCollapsedElseIf": "error", 61 | "useConsistentArrayType": { 62 | "level": "error", 63 | "options": { "syntax": "shorthand" } 64 | }, 65 | "useForOf": "error", 66 | "useFragmentSyntax": "error", 67 | "useShorthandAssign": "error", 68 | "noParameterAssign": "error", 69 | "useAsConstAssertion": "error", 70 | "useDefaultParameterLast": "error", 71 | "useEnumInitializers": "error", 72 | "useSelfClosingElements": "error", 73 | "useSingleVarDeclarator": "error", 74 | "noUnusedTemplateLiteral": "error", 75 | "useNumberNamespace": "error", 76 | "noInferrableTypes": "error", 77 | "noUselessElse": "error", 78 | "useArrayLiterals": "error" 79 | }, 80 | "suspicious": { 81 | "noEmptyBlockStatements": "error", 82 | "noSkippedTests": "warn", 83 | "noExplicitAny": "off" 84 | } 85 | }, 86 | "includes": ["**", "!**/node_modules/", "!**/coverage/", "!**/dist/"] 87 | }, 88 | "javascript": { 89 | "formatter": { 90 | "jsxQuoteStyle": "double", 91 | "quoteProperties": "asNeeded", 92 | "trailingCommas": "es5", 93 | "semicolons": "always", 94 | "arrowParentheses": "always", 95 | "bracketSpacing": true, 96 | "quoteStyle": "single" 97 | }, 98 | "globals": ["cy", "VERSION", "Cypress", "SplitIO"] 99 | } 100 | } 101 | --------------------------------------------------------------------------------