├── .devcontainer └── devcontainer.json ├── .eslintrc.js ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── BUG-REPORT.yml │ ├── ENHANCEMENT.yml │ ├── FEATURE-REQUEST.md │ └── config.yml ├── pull_request_template.md └── workflows │ ├── integration_test.yml │ ├── react.yml │ ├── react_release.yml │ └── ticket_reference_check.yml ├── .gitignore ├── .husky └── pre-commit ├── .nvmrc ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── scripts ├── build.js ├── config.js └── winbuild.js ├── src ├── Context.ts ├── Experiment.spec.tsx ├── Experiment.tsx ├── Feature.spec.tsx ├── Feature.tsx ├── Provider.spec.tsx ├── Provider.tsx ├── Variation.tsx ├── autoUpdate.ts ├── client.spec.ts ├── client.ts ├── hooks.spec.tsx ├── hooks.ts ├── index.cjs.ts ├── index.ts ├── logOnlyEventDispatcher.spec.ts ├── logOnlyEventDispatcher.ts ├── logger.spec.ts ├── logger.tsx ├── notifier.spec.ts ├── notifier.ts ├── utils.spec.tsx ├── utils.tsx ├── withOptimizely.spec.tsx └── withOptimizely.tsx ├── srcclr.yml ├── tsconfig.json └── yarn.lock /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "React SDK", 3 | "image": "mcr.microsoft.com/devcontainers/javascript-node:1-18-bullseye", 4 | "postCreateCommand": "npm i -g yarn@1.22.22 && yarn install", 5 | "customizations": { 6 | "vscode": { 7 | "extensions": [ 8 | "dbaeumer.vscode-eslint", 9 | "eamodio.gitlens", 10 | "esbenp.prettier-vscode", 11 | "Gruntfuggly.todo-tree", 12 | "github.vscode-github-actions", 13 | "Orta.vscode-jest", 14 | "ms-vscode.test-adapter-converter", 15 | "GitHub.copilot-chat", 16 | "vivaxy.vscode-conventional-commits" 17 | ], 18 | "settings": { 19 | "files.eol": "\n" 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | ecmaFeatures: { 4 | jsx: true, 5 | }, 6 | ecmaVersion: 2015, 7 | sourceType: 'module', 8 | }, 9 | env: { 10 | browser: true, 11 | es6: true, 12 | jest: true, 13 | mocha: true, 14 | node: true, 15 | }, 16 | extends: [ 17 | 'plugin:react-hooks/recommended', 18 | 'eslint:recommended', 19 | 'plugin:@typescript-eslint/recommended', 20 | 'plugin:prettier/recommended', 21 | ], 22 | rules: { 23 | '@typescript-eslint/ban-ts-comment': 'warn', 24 | '@typescript-eslint/camelcase': 'off', 25 | '@typescript-eslint/no-empty-function': 'off', 26 | '@typescript-eslint/no-shadow': 'error', 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG-REPORT.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug 2 | description: File a bug/issue 3 | title: "[BUG] " 4 | labels: ["bug", "needs-triage"] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Is there an existing issue for this? 9 | description: Please search to see if an issue already exists for the bug you encountered. 10 | options: 11 | - label: I have searched the existing issues 12 | required: true 13 | - type: textarea 14 | attributes: 15 | label: SDK Version 16 | description: Version of the SDK in use? 17 | validations: 18 | required: true 19 | - type: textarea 20 | attributes: 21 | label: Current Behavior 22 | description: A concise description of what you're experiencing. 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: Expected Behavior 28 | description: A concise description of what you expected to happen. 29 | validations: 30 | required: true 31 | - type: textarea 32 | attributes: 33 | label: Steps To Reproduce 34 | description: Steps to reproduce the behavior. 35 | placeholder: | 36 | 1. In this environment... 37 | 1. With this config... 38 | 1. Run '...' 39 | 1. See error... 40 | validations: 41 | required: true 42 | - type: textarea 43 | attributes: 44 | label: React Framework 45 | description: What reactive framework and version are you using? 46 | validations: 47 | required: false 48 | - type: textarea 49 | attributes: 50 | label: Browsers impacted 51 | description: What browsers are impacted? 52 | validations: 53 | required: false 54 | - type: textarea 55 | attributes: 56 | label: Link 57 | description: Link to code demonstrating the problem. 58 | validations: 59 | required: false 60 | - type: textarea 61 | attributes: 62 | label: Logs 63 | description: Logs/stack traces related to the problem (⚠️do not include sensitive information). 64 | validations: 65 | required: false 66 | - type: dropdown 67 | attributes: 68 | label: Severity 69 | description: What is the severity of the problem? 70 | multiple: true 71 | options: 72 | - Blocking development 73 | - Affecting users 74 | - Minor issue 75 | validations: 76 | required: false 77 | - type: textarea 78 | attributes: 79 | label: Workaround/Solution 80 | description: Do you have any workaround or solution in mind for the problem? 81 | validations: 82 | required: false 83 | - type: textarea 84 | attributes: 85 | label: Recent Change 86 | description: Has this issue started happening after an update or experiment change? 87 | validations: 88 | required: false 89 | - type: textarea 90 | attributes: 91 | label: Conflicts 92 | description: Are there other libraries/dependencies potentially in conflict? 93 | validations: 94 | required: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/ENHANCEMENT.yml: -------------------------------------------------------------------------------- 1 | name: ✨Enhancement 2 | description: Create a new ticket for a Enhancement/Tech-initiative for the benefit of the SDK which would be considered for a minor version update. 3 | title: "[ENHANCEMENT] <title>" 4 | labels: ["enhancement"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Description 10 | description: Briefly describe the enhancement in a few sentences. 11 | placeholder: Short description... 12 | validations: 13 | required: true 14 | - type: textarea 15 | id: benefits 16 | attributes: 17 | label: Benefits 18 | description: How would the enhancement benefit to your product or usage? 19 | placeholder: Benefits... 20 | validations: 21 | required: true 22 | - type: textarea 23 | id: detail 24 | attributes: 25 | label: Detail 26 | description: How would you like the enhancement to work? Please provide as much detail as possible 27 | placeholder: Detailed description... 28 | validations: 29 | required: false 30 | - type: textarea 31 | id: examples 32 | attributes: 33 | label: Examples 34 | description: Are there any examples of this enhancement in other products/services? If so, please provide links or references. 35 | placeholder: Links/References... 36 | validations: 37 | required: false 38 | - type: textarea 39 | id: risks 40 | attributes: 41 | label: Risks/Downsides 42 | description: Do you think this enhancement could have any potential downsides or risks? 43 | placeholder: Risks/Downsides... 44 | validations: 45 | required: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE-REQUEST.md: -------------------------------------------------------------------------------- 1 | <!-- 2 | Thanks for filing in issue! Are you requesting a new feature? If so, please share your feedback with us on the following link. 3 | --> 4 | ## Feedback requesting a new feature can be shared [here.](https://feedback.optimizely.com/) 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 💡Feature Requests 4 | url: https://feedback.optimizely.com/ 5 | about: Feedback requesting a new feature can be shared here. -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | - The "what"; a concise description of each logical change 3 | - Another change 4 | 5 | The "why", or other context. 6 | 7 | ## Test plan 8 | 9 | ## Issues 10 | - "THING-1234" or "Fixes #123" -------------------------------------------------------------------------------- /.github/workflows/integration_test.yml: -------------------------------------------------------------------------------- 1 | name: Run Production Suite 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | CI_USER_TOKEN: 7 | required: true 8 | TRAVIS_COM_TOKEN: 9 | required: true 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout branch 15 | uses: actions/checkout@v3 16 | with: 17 | token: ${{ secrets.CI_USER_TOKEN || secrets.GITHUB_TOKEN }} 18 | repository: 'optimizely/travisci-tools' 19 | path: 'home/runner/travisci-tools' 20 | ref: 'master' 21 | - name: Set SDK branch if PR 22 | env: 23 | HEAD_REF: ${{ github.head_ref }} 24 | if: ${{ github.event_name == 'pull_request' }} 25 | run: | 26 | echo "SDK_BRANCH=$HEAD_REF" >> $GITHUB_ENV 27 | - name: Set SDK branch if not pull request 28 | env: 29 | REF_NAME: ${{ github.ref_name }} 30 | if: ${{ github.event_name != 'pull_request' }} 31 | run: | 32 | echo "SDK_BRANCH=$REF_NAME" >> $GITHUB_ENV 33 | echo "TRAVIS_BRANCH=$REF_NAME" >> $GITHUB_ENV 34 | - name: Trigger build 35 | env: 36 | SDK: react 37 | REPO_SLUG: optimizely/react-sdk-e2e-tests 38 | FULLSTACK_TEST_REPO: ${{ inputs.FULLSTACK_TEST_REPO }} 39 | BUILD_NUMBER: ${{ github.run_id }} 40 | TESTAPP_BRANCH: master 41 | GITHUB_TOKEN: ${{ secrets.CI_USER_TOKEN }} 42 | EVENT_TYPE: ${{ github.event_name }} 43 | GITHUB_CONTEXT: ${{ toJson(github) }} 44 | PULL_REQUEST_SLUG: ${{ github.repository }} 45 | UPSTREAM_REPO: ${{ github.repository }} 46 | PULL_REQUEST_SHA: ${{ github.event.pull_request.head.sha }} 47 | PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} 48 | UPSTREAM_SHA: ${{ github.sha }} 49 | TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} 50 | EVENT_MESSAGE: ${{ github.event.message }} 51 | HOME: 'home/runner' 52 | run: | 53 | home/runner/travisci-tools/trigger-script-with-status-update.sh main 54 | -------------------------------------------------------------------------------- /.github/workflows/react.yml: -------------------------------------------------------------------------------- 1 | name: React SDK CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | unitTests: 11 | name: Run Unit Tests (Node ${{ matrix.node }}) 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node: [ '14', '16', '18', '20' ] 16 | steps: 17 | - name: Checkout branch 18 | uses: actions/checkout@v3 19 | - name: Set up Node ${{ matrix.node }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node }} 23 | - name: Install dependencies 24 | run: yarn install 25 | - name: Run tests 26 | run: yarn test 27 | 28 | coverage: 29 | name: Jest Coverage Report 30 | runs-on: ubuntu-latest 31 | needs: [ unitTests ] 32 | steps: 33 | - uses: actions/checkout@v3 34 | - uses: ArtiomTr/jest-coverage-report-action@v2 35 | with: 36 | custom-title: 'Jest Coverage Report' 37 | package-manager: 'yarn' 38 | 39 | integration_tests: 40 | name: Run integration tests 41 | needs: [ unitTests ] 42 | uses: optimizely/react-sdk/.github/workflows/integration_test.yml@master 43 | secrets: 44 | CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} 45 | TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} 46 | -------------------------------------------------------------------------------- /.github/workflows/react_release.yml: -------------------------------------------------------------------------------- 1 | name: Publish React SDK to NPM 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | 7 | jobs: 8 | publish: 9 | name: Publish to NPM 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout branch 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up Node 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 18 19 | registry-url: "https://registry.npmjs.org/" 20 | always-auth: "true" 21 | env: 22 | NODE_AUTH_TOKEN: ${{ secrets.PUBLISH_REACT_TO_NPM_FROM_GITHUB }} 23 | 24 | - name: Install dependencies 25 | run: yarn install 26 | 27 | - id: npm-tag 28 | name: Determine NPM tag 29 | run: | 30 | version=$(jq -r '.version' package.json) 31 | if [[ "$version" == *"-beta"* ]]; then 32 | echo "npm-tag=beta" >> "$GITHUB_OUTPUT" 33 | elif [[ "$version" == *"-alpha"* ]]; then 34 | echo "npm-tag=alpha" >> "$GITHUB_OUTPUT" 35 | elif [[ "$version" == *"-rc"* ]]; then 36 | echo "npm-tag=rc" >> "$GITHUB_OUTPUT" 37 | else 38 | echo "npm-tag=latest" >> "$GITHUB_OUTPUT" 39 | fi 40 | 41 | - name: Test, build, then publish 42 | env: 43 | NODE_AUTH_TOKEN: ${{ secrets.PUBLISH_REACT_TO_NPM_FROM_GITHUB }} 44 | run: npm publish --tag ${{ steps.npm-tag.outputs['npm-tag'] }} 45 | -------------------------------------------------------------------------------- /.github/workflows/ticket_reference_check.yml: -------------------------------------------------------------------------------- 1 | name: Jira Ticket Reference Check 2 | 3 | on: 4 | pull_request: 5 | types: [opened, edited, reopened, synchronize] 6 | 7 | jobs: 8 | 9 | jira_ticket_reference_check: 10 | name: Check PR description has Jira number 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Call ticket reference checker 14 | uses: optimizely/github-action-ticket-reference-checker-public@master 15 | with: 16 | bodyRegex: 'FSSDK-(?<ticketNumber>\d+)' 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .yarn/ 3 | lerna-debug.log 4 | npm-debug.log 5 | lib 6 | .idea 7 | .npmrc 8 | dist/ 9 | build/ 10 | .rpt2_cache 11 | .env 12 | 13 | # test artifacts 14 | **.tgz 15 | coverage/ 16 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "es5", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "name": "vscode-jest-tests.v2.react-sdk", 7 | "request": "launch", 8 | "args": [ 9 | "--runInBand", 10 | "--watchAll=false", 11 | "--testNamePattern", 12 | "${jest.testNamePattern}", 13 | "--runTestsByPath", 14 | "${jest.testFile}" 15 | ], 16 | "cwd": "${workspaceFolder}", 17 | "console": "integratedTerminal", 18 | "internalConsoleOptions": "neverOpen", 19 | "program": "${workspaceFolder}/node_modules/.bin/jest", 20 | "windows": { 21 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 22 | } 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": true, 3 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], 4 | "prettier.requireConfig": true, 5 | "editor.formatOnSave": false, 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll.eslint": "never" 8 | }, 9 | 10 | "editor.defaultFormatter": "esbenp.prettier-vscode" 11 | } 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [3.2.4] - May 15, 2025 4 | ### Bug fixes 5 | - `client.onReady()` always returns false when ODP is off and user id is null bug fix.([#302](https://github.com/optimizely/react-sdk/pull/285)) 6 | 7 | ## [3.2.3] - Nov 22, 2024 8 | 9 | ### Bug fixes 10 | - `isClientReady` logic adjustment.([#285](https://github.com/optimizely/react-sdk/pull/285)) 11 | - `track` method overrideAttribute bug fix.([#287](https://github.com/optimizely/react-sdk/pull/287)) 12 | - "`OptimizelyProvider` resets previously set user" bug fix.([#292](https://github.com/optimizely/react-sdk/pull/292)) 13 | 14 | 15 | ## [3.2.2] - Aug 21, 2024 16 | 17 | ### Bug fixes 18 | - Multiple instances of the Logger make the log system unconfigurable - bug fix. ([#276](https://github.com/optimizely/react-sdk/pull/276)) 19 | 20 | ## [3.2.1] - Aug 15, 2024 21 | 22 | ### Bug fixes 23 | - `clientReady` is true even though internal client promise returns `success == false` bug fix([#273](https://github.com/optimizely/react-sdk/pull/273)) 24 | - `useDecision` hook set the update listener on overy render bug fix([#273](https://github.com/optimizely/react-sdk/pull/273)) 25 | - `setForcedDecision` does not reflect the changes in optmizely instance and `useDecision` hook bug fix([#274](https://github.com/optimizely/react-sdk/pull/274)) 26 | 27 | ### Changed 28 | - Performance improvements in both hooks and client instance([#273](https://github.com/optimizely/react-sdk/pull/273), [#274](https://github.com/optimizely/react-sdk/pull/274)) 29 | 30 | ## [3.2.0] - July 10, 2024 31 | 32 | ### New Features 33 | - The new `useTrackEvent` hook is now available for tracking events within functional components. This hook offers all the existing track event functionalities provided by the SDK. ([#268](https://github.com/optimizely/react-sdk/pull/268)) 34 | 35 | ## [3.1.2] - July 2, 2024 36 | 37 | ### Changed 38 | - JS SDK bump up for react native polyfill support ([#266](https://github.com/optimizely/react-sdk/pull/266)) 39 | 40 | ## [3.1.1] - May 22, 2024 41 | 42 | ### Bug Fixes 43 | - ODP integration error. ([#262](https://github.com/optimizely/react-sdk/pull/262)) 44 | 45 | ## [3.1.0] - April 9, 2024 46 | 47 | ### Bug Fixes 48 | - Error initializing client. The core client or user promise(s) rejected. 49 | ([#255](https://github.com/optimizely/react-sdk/pull/255)) 50 | - Unable to determine if feature "{your-feature-key}" is enabled because User ID is not set([#255](https://github.com/optimizely/react-sdk/pull/255)) 51 | 52 | ### Changed 53 | - Bumped Optimizely JS SDK version in use ([#255](https://github.com/optimizely/react-sdk/pull/255)) 54 | - Resolve dependabot dependency vulnerabilities ([#245](https://github.com/optimizely/react-sdk/pull/245), [#247](https://github.com/optimizely/react-sdk/pull/247), [#248](https://github.com/optimizely/react-sdk/pull/248), [#251](https://github.com/optimizely/react-sdk/pull/251), [#253](https://github.com/optimizely/react-sdk/pull/253)) 55 | - Add node versions during testing ([#249](https://github.com/optimizely/react-sdk/pull/249)) 56 | 57 | ## [3.0.1] - February 27, 2024 58 | 59 | ### Changed 60 | - Updated `@optimizely/optimizely-sdk` to version `5.0.1` ([#242](https://github.com/optimizely/react-sdk/pull/242)) 61 | - Updated Dependabot alerts ([#239](https://github.com/optimizely/react-sdk/pull/239), [#241](https://github.com/optimizely/react-sdk/pull/241)) 62 | 63 | ## [3.0.0] - January 24, 2024 64 | 65 | ### New Features 66 | 67 | The 3.0.0 release introduces a new primary feature, [Advanced Audience Targeting]( https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting) enabled through integration with [Optimizely Data Platform (ODP)](https://docs.developers.optimizely.com/optimizely-data-platform/docs) ( 68 | [#229](https://github.com/optimizely/react-sdk/pull/229), 69 | [#214](https://github.com/optimizely/react-sdk/pull/214), 70 | [#213](https://github.com/optimizely/react-sdk/pull/213), 71 | [#212](https://github.com/optimizely/react-sdk/pull/212), 72 | [#208](https://github.com/optimizely/react-sdk/pull/208), 73 | [#207](https://github.com/optimizely/react-sdk/pull/207), 74 | [#206](https://github.com/optimizely/react-sdk/pull/206), 75 | [#205](https://github.com/optimizely/react-sdk/pull/205), 76 | [#201](https://github.com/optimizely/react-sdk/pull/201), 77 | [#200](https://github.com/optimizely/react-sdk/pull/200), 78 | [#199](https://github.com/optimizely/react-sdk/pull/199)) 79 | 80 | You can use ODP, a high-performance [Customer Data Platform (CDP)]( https://www.optimizely.com/optimization-glossary/customer-data-platform/), to easily create complex real-time segments (RTS) using first-party and 50+ third-party data sources out of the box. You can create custom schemas that support the user attributes important for your business, and stitch together user behavior done on different devices to better understand and target your customers for personalized user experiences. ODP can be used as a single source of truth for these segments in any Optimizely or 3rd party tool. 81 | 82 | With ODP accounts integrated into Optimizely projects, you can build audiences using segments pre-defined in ODP. The SDK will fetch the segments for given users and make decisions using the segments. For access to ODP audience targeting in your Feature Experimentation account, please contact your Customer Success Manager. 83 | 84 | This release leverages the Optimizely JavaScript SDK 5+ 85 | 86 | This version includes the following changes: 87 | 88 | - New APIs added to `ReactSDKClient`: 89 | 90 | - `fetchQualifiedSegments()`: this API will retrieve user segments from the ODP server. The fetched segments will be used for audience evaluation. The fetched data will be stored in the local cache to avoid repeated network delays. 91 | 92 | - `getUserContext()`: get the current `OptimizelyUserContext` object in use at the React SDK level. 93 | 94 | - `getVuid()`: provides access to the anonymous client-side visitor ID (VUID) generated by the JS SDK. This ID is used to identify unique visitors in Optimizely Results in the absence of a standard user ID. 95 | 96 | - `sendOdpEvent()`: customers can build/send arbitrary ODP events that will bind user identifiers and data to user profiles in ODP. 97 | 98 | For details, refer to our documentation pages: 99 | 100 | - [Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting) 101 | 102 | - [Client SDK Support](https://docs.developers.optimizely.com/feature-experimentation/v1.0/docs/advanced-audience-targeting-for-client-side-sdks) 103 | 104 | - [Initialize React SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/initialize-sdk-react) 105 | 106 | - [Advanced Audience Targeting segment qualification methods](https://docs.developers.optimizely.com/feature-experimentation/docs/advanced-audience-targeting-segment-qualification-methods-react) 107 | 108 | - [Send Optimizely Data Platform data using Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/send-odp-data-using-advanced-audience-targeting-react) 109 | 110 | ### Breaking Changes 111 | - Dropped support for the following browser versions. 112 | - All versions of Microsoft Internet Explorer. 113 | - Chrome versions earlier than `102.0`. 114 | - Microsoft Edge versions earlier than `84.0`. 115 | - Firefox versions earlier than `91.0`. 116 | - Opera versions earlier than `76.0`. 117 | - Safari versions earlier than `13.0`. 118 | - Dropped support for Node JS versions earlier than `16`. 119 | 120 | ### Changed 121 | - Updated `@optimizely/optimizely-sdk` to version `5.0.0` ([#230](https://github.com/optimizely/react-sdk/pull/230)). 122 | - Removed use of deprecated `@optimizely/js-sdk-*` packages. 123 | - Minor version bumps to dependencies. 124 | 125 | ### Bug Fixes 126 | - Updated `OptimizelyProvider` to ([#229](https://github.com/optimizely/react-sdk/pull/229)) 127 | - correctly adhere to optional `userId?` and `user?` interface fields, using the `DefaultUser` to signal to client-side contexts to use the new `vuid` identifier. 128 | - correctly use of the correct React lifecyle methods. 129 | 130 | 131 | ## [3.0.0-beta2] - December 26, 2023 132 | 133 | ### Bug fixes 134 | - Tag release correctly during publishing 135 | - Updated datafile variable in README 136 | - AAT gap fill 137 | - Rendering `default` OptimizelyVariation when not last 138 | - OptimizelyVariation with default and variation props set 139 | 140 | ## [3.0.0-beta] - September 22, 2023 141 | 142 | ### New Features 143 | 144 | The 3.0.0-beta release introduces a new primary feature, [Advanced Audience Targeting]( https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting) enabled through integration with [Optimizely Data Platform (ODP)](https://docs.developers.optimizely.com/optimizely-data-platform/docs) (#214, #213, #212, #208, #207, #206, #205, #201, #200, #199) 145 | 146 | You can use ODP, a high-performance [Customer Data Platform (CDP)]( https://www.optimizely.com/optimization-glossary/customer-data-platform/), to easily create complex real-time segments (RTS) using first-party and 50+ third-party data sources out of the box. You can create custom schemas that support the user attributes important for your business, and stitch together user behavior done on different devices to better understand and target your customers for personalized user experiences. ODP can be used as a single source of truth for these segments in any Optimizely or 3rd party tool. 147 | 148 | With ODP accounts integrated into Optimizely projects, you can build audiences using segments pre-defined in ODP. The SDK will fetch the segments for given users and make decisions using the segments. For access to ODP audience targeting in your Feature Experimentation account, please contact your Customer Success Manager. 149 | 150 | This release leverages the Optimizely JavaScript SDK beta5+ 151 | 152 | This version includes the following changes: 153 | 154 | - New API added to `OptimizelyUserContext`: 155 | 156 | - `fetchQualifiedSegments()`: this API will retrieve user segments from the ODP server. The fetched segments will be used for audience evaluation. The fetched data will be stored in the local cache to avoid repeated network delays. 157 | 158 | - When an `OptimizelyUserContext` is created, the SDK will automatically send an identify request to the ODP server to facilitate observing user activities. 159 | 160 | - New APIs added to `OptimizelyClient`: 161 | 162 | - `sendOdpEvent()`: customers can build/send arbitrary ODP events that will bind user identifiers and data to user profiles in ODP. 163 | 164 | - `createUserContext()` with anonymous user IDs: user-contexts can be created without a userId. The SDK will create and use a persistent `VUID` specific to a device when userId is not provided. 165 | 166 | For details, refer to our documentation pages: 167 | 168 | - [Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting) 169 | 170 | - [Client SDK Support](https://docs.developers.optimizely.com/feature-experimentation/v1.0/docs/advanced-audience-targeting-for-client-side-sdks) 171 | 172 | - [Initialize React SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/initialize-sdk-react) 173 | 174 | - [Advanced Audience Targeting segment qualification methods](https://docs.developers.optimizely.com/feature-experimentation/docs/advanced-audience-targeting-segment-qualification-methods-react) 175 | 176 | - [Send Optimizely Data Platform data using Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/send-odp-data-using-advanced-audience-targeting-react) 177 | 178 | ### Breaking Changes 179 | - Dropped support for the following browser versions. 180 | - All versions of Microsoft Internet Explorer. 181 | - Chrome versions earlier than `102.0`. 182 | - Microsoft Edge versions earlier than `84.0`. 183 | - Firefox versions earlier than `91.0`. 184 | - Opera versions earlier than `76.0`. 185 | - Safari versions earlier than `13.0`. 186 | - Dropped support for Node JS versions earlier than `14`. 187 | 188 | ### Changed 189 | - Updated `createUserContext`'s `userId` parameter to be optional due to the Browser variation's use of the new `vuid` field. 190 | 191 | ## [2.9.2] - March 13, 2023 192 | 193 | ### Enhancements 194 | - We updated our README.md and other non-functional code to reflect that this SDK supports both Optimizely Feature Experimentation and Optimizely Full Stack. ([#190](https://github.com/optimizely/react-sdk/pull/190)). 195 | 196 | ## [2.9.1] - July 20, 2022 197 | 198 | ### Bug fixes 199 | - Fixed Redundant activate calls in useExperiment hook for one scenario. 200 | 201 | ## [2.9.0] - June 15, 2022 202 | 203 | ### Bug fixes 204 | - addresses issues [#152](https://github.com/optimizely/react-sdk/issues/152) and [#134](https://github.com/optimizely/react-sdk/issues/134): Gracefully returns pessimistic default values when hooks fail instead of throwing an error. 205 | - fixed issue [#156](https://github.com/optimizely/react-sdk/issues/156) - Added children prop to make the SDK compatible with React 18([#158](https://github.com/optimizely/react-sdk/pull/158)). 206 | - Updates React SDK to use React 18 and fixed related typescript issues ([#159](https://github.com/optimizely/react-sdk/pull/159)). 207 | - Replaces `enzyme` with `react testing library` to make unit tests work with React 18 ([#159](https://github.com/optimizely/react-sdk/pull/159)). 208 | 209 | ## [2.8.1] - March 7, 2022 210 | 211 | ### Enhancements 212 | - fixed issue [#49](https://github.com/optimizely/react-sdk/issues/49): Return type of `createInstance` was `OptimizelyReactSDKClient` which is the implementation class. Changed it to the `ReactSDKClient` interface instead ([#148](https://github.com/optimizely/react-sdk/pull/148)). 213 | 214 | - fixed issue [#121](https://github.com/optimizely/react-sdk/issues/121):`ActivateListenerPayload` and `TrackListenerPayload` types were exported from `@optimizely/optimizely-sdk` but were missing from `@optimizely/react-sdk` exports. ([#150](https://github.com/optimizely/react-sdk/pull/150)). 215 | 216 | ### Bug fixes 217 | - Fixed issue [#134](https://github.com/optimizely/react-sdk/issues/134) of the React SDK crashing when the internal Optimizely client returns as a null value. [PR #149](https://github.com/optimizely/react-sdk/pull/149) 218 | 219 | ## [2.8.0] - January 26, 2022 220 | 221 | ### New Features 222 | - Add a set of new APIs for overriding and managing user-level flag, experiment and delivery rule decisions. These methods can be used for QA and automated testing purposes. They are an extension of the ReactSDKClient interface ([#133](https://github.com/optimizely/react-sdk/pull/133)): 223 | - setForcedDecision 224 | - getForcedDecision 225 | - removeForcedDecision 226 | - removeAllForcedDecisions 227 | - Updated `useDecision` hook to auto-update and reflect changes when forced decisions are set and removed ([#133](https://github.com/optimizely/react-sdk/pull/133)). 228 | - For details, refer to our documentation pages: [ReactSDKClient](https://docs.developers.optimizely.com/full-stack/v4.0/docs/reactsdkclient), [Forced Decision methods](https://docs.developers.optimizely.com/full-stack/v4.0/docs/forced-decision-methods-react) and [useDecision hook](https://docs.developers.optimizely.com/full-stack/v4.0/docs/usedecision-react). 229 | 230 | ### Bug fixes 231 | - Fixed the SDK to render the correct decision on first render when initialized synchronously using a datafile ([#125](https://github.com/optimizely/react-sdk/pull/125)). 232 | - Fixed the redundant re-rendering when SDK is initialized with both datafile and SDK key ([#125](https://github.com/optimizely/react-sdk/pull/125)). 233 | - Updated `@optimizely/js-sdk-logging` to 0.3.1 ([#140](https://github.com/optimizely/react-sdk/pull/140)). 234 | 235 | ## [2.7.1-alpha] - October 1st, 2021 236 | 237 | ### Bug fixes 238 | - Fixed the SDK to render the correct decision on first render when initialized synchronously using a datafile ([#125](https://github.com/optimizely/react-sdk/pull/125)). 239 | - Fixed the redundant re-rendering when SDK is initialized with both datafile and SDK key ([#125](https://github.com/optimizely/react-sdk/pull/125)). 240 | 241 | ## [2.7.0] - September 16th, 2021 242 | Upgrade `@optimizely/optimizely-sdk` to [4.7.0](https://github.com/optimizely/javascript-sdk/releases/tag/v4.7.0): 243 | - Added new public properties to `OptimizelyConfig` . See [@optimizely/optimizely-sdk Release 4.7.0](https://github.com/optimizely/javascript-sdk/releases/tag/v4.7.0) for details 244 | - Deprecated `OptimizelyFeature.experimentsMap` of `OptimizelyConfig`. See [@optimizely/optimizely-sdk Release 4.7.0](https://github.com/optimizely/javascript-sdk/releases/tag/v4.7.0) for details 245 | - For more information, refer to our documentation page: [https://docs.developers.optimizely.com/full-stack/v4.0/docs/optimizelyconfig-react](https://docs.developers.optimizely.com/full-stack/v4.0/docs/optimizelyconfig-react) 246 | 247 | ## [2.6.3] - July 29th, 2021 248 | 249 | ### Bug fixes 250 | - Update `VariableValuesObject` type to handle JSON type variable and avoid TS compiler error when specifying variable type ([#118](https://github.com/optimizely/react-sdk/pull/118)). 251 | 252 | ## [2.6.2] - July 15th, 2021 253 | 254 | ### Bug fixes 255 | - Upgrade `@optimizely/optimizely-sdk` to [4.6.2](https://github.com/optimizely/javascript-sdk/releases/tag/v4.6.2). Fixed incorrect impression event payload in projects containing multiple flags with duplicate key rules. See [@optimizely/optimizely-sdk Release 4.6.2](https://github.com/optimizely/javascript-sdk/releases/tag/v4.6.2) for more details. 256 | 257 | ## [2.6.1] - July 8th, 2021 258 | 259 | ### Bug fixes 260 | - Upgrade `@optimizely/optimizely-sdk` to [4.6.1](https://github.com/optimizely/javascript-sdk/releases/tag/v4.6.1). Fixed serving incorrect variation issue in projects containing multiple flags with same key rules. See [@optimizely/optimizely-sdk Release 4.6.1](https://github.com/optimizely/javascript-sdk/releases/tag/v4.6.1) for more details. 261 | 262 | ## [2.6.0] - June 8th, 2021 263 | Upgrade `@optimizely/optimizely-sdk` to [4.6.0](https://github.com/optimizely/javascript-sdk/releases/tag/v4.6.0) 264 | - Added support for multiple concurrent prioritized experiments per flag. See [@optimizely/optimizely-sdk Release 4.6.0](https://github.com/optimizely/javascript-sdk/releases/tag/v4.6.0) for more details. 265 | 266 | ## [2.5.0] - March 12th, 2021 267 | - Upgrade `@optimizely/optimizely-sdk` to [4.5.1](https://github.com/optimizely/javascript-sdk/releases/tag/v4.5.1) 268 | - Added support for new set of decide APIs ([#98](https://github.com/optimizely/react-sdk/pull/98)) 269 | - Introducing `useDecision` hook to retrieve the decision result for a flag key, optionally auto updating that decision based on underlying user or datafile changes ([#100](https://github.com/optimizely/react-sdk/pull/100), [#105](https://github.com/optimizely/react-sdk/pull/105)) 270 | - For details, refer to our documentation page: [https://docs.developers.optimizely.com/full-stack/v4.0/docs/javascript-react-sdk](https://docs.developers.optimizely.com/full-stack/v4.0/docs/javascript-react-sdk) 271 | 272 | ## [2.4.3] - March 2nd, 2021 273 | ### Bug fixes 274 | - This version of React SDK depends on [4.4.3](https://github.com/optimizely/javascript-sdk/releases/tag/v4.4.3) of `@optimizely/optimizely-sdk`. The dependency was defined to use the latest available minor version which is no more compatible. Fixed the dependency to use the exact version. 275 | 276 | ## [2.4.2] - December 11th, 2020 277 | ### Bug fixes 278 | - Always recompute decision after resolution of ready promise ([#91](https://github.com/optimizely/react-sdk/pull/91)) 279 | 280 | ## [2.4.1] - November 23rd, 2020 281 | Upgrade `@optimizely/optimizely-sdk` to [4.4.3](https://github.com/optimizely/javascript-sdk/releases/tag/v4.4.3): 282 | - Allowed using `--isolatedModules` flag in TSConfig file by fixing exports in event processor . See [Issue #84](https://github.com/optimizely/react-sdk/issues/84) and [@optimizely/optimizely-sdk Release 4.4.1](https://github.com/optimizely/javascript-sdk/releases/tag/v4.4.1) for more details. 283 | - Added `enabled` field to decision metadata structure to support upcoming application-controlled introduction of tracking for non-experiment Flag decisions. See [@optimizely/optimizely-sdk Release 4.4.2](https://github.com/optimizely/javascript-sdk/releases/tag/v4.4.2) for more details. 284 | - Refactored imports in `optimizely-sdk` TypeScript type definitions to prevent compilation of TS source code. See [@optimizely/optimizely-sdk Release 4.4.2](https://github.com/optimizely/javascript-sdk/releases/tag/v4.4.2) and [@optimizely/optimizely-sdk Release 4.4.3](https://github.com/optimizely/javascript-sdk/releases/tag/v4.4.3) for more details. 285 | 286 | ## [2.4.0] - November 2nd, 2020 287 | Upgrade `@optimizely/optimizely-sdk` to [4.4.0](https://github.com/optimizely/javascript-sdk/releases/tag/v4.4.0): 288 | - Added support for upcoming application-controlled introduction of tracking for non-experiment Flag decisions. See [@optimizely/optimizely-sdk Release 4.4.0](https://github.com/optimizely/javascript-sdk/releases/tag/v4.4.0) for more details. 289 | 290 | ### New features 291 | - Add UMD and System build targets, available at `dist/react-sdk.umd.js` and `dist/react-sdk.system.js`, respectively ([#80](https://github.com/optimizely/react-sdk/pull/80)) 292 | 293 | ### Bug fixes 294 | - Fix `logOnlyEventDispatcher` to conform to `EventDispatcher` type from @optimizely/optimizely-sdk ([#81](https://github.com/optimizely/react-sdk/pull/81)) 295 | - Change the file extension of the ES module bundle from .mjs to .es.js. Resolves issues using React SDK with Gatsby ([#82](https://github.com/optimizely/react-sdk/pull/82)). 296 | 297 | ## [2.3.2] - October 9th, 2020 298 | Upgrade `@optimizely/optimizely-sdk` to [4.3.4](https://github.com/optimizely/javascript-sdk/releases/tag/v4.3.4): 299 | - Exported Optimizely Config Entities types from TypeScript type definitions. See [@optimizely/optimizely-sdk Release 4.3.3](https://github.com/optimizely/javascript-sdk/releases/tag/v4.3.3) for more details. 300 | - Fixed return type of `getAllFeatureVariables` method in TypeScript type definitions. See [@optimizely/optimizely-sdk Release 4.3.2](https://github.com/optimizely/javascript-sdk/releases/tag/v4.3.2) for more details. 301 | 302 | ### Bug fixes 303 | - Fixed return type of `getAllFeatureVariables` method in ReactSDKClient ([#76](https://github.com/optimizely/react-sdk/pull/76)) 304 | 305 | ## [2.3.1] - October 5th, 2020 306 | Upgrade `@optimizely/optimizely-sdk` to [4.3.1](https://github.com/optimizely/javascript-sdk/releases/tag/v4.3.1). Added support for version audience evaluation and datafile accessor. See [@optimizely/optimizely-sdk Release 4.3.0](https://github.com/optimizely/javascript-sdk/releases/tag/v4.3.0) for more details. 307 | 308 | ## [2.3.0] - October 2nd, 2020 309 | Upgrade `@optimizely/optimizely-sdk` to [4.2.1](https://github.com/optimizely/javascript-sdk/releases/tag/v4.2.0) 310 | 311 | ### New Features 312 | - `useExperiment` and `useFeature` hooks re-render when override user ID or attributes change([#64](https://github.com/optimizely/react-sdk/pull/64)) 313 | 314 | ### Bug fixes 315 | - `useExperiment` and `useFeature` hooks return up-to-date decision values on the first call after the client is ready ([#64](https://github.com/optimizely/react-sdk/pull/64)) 316 | 317 | ## [2.3.0-beta] - August 27th, 2020 318 | Upgrade `@optimizely/optimizely-sdk` to [4.2.1](https://github.com/optimizely/javascript-sdk/releases/tag/v4.2.0) 319 | 320 | ### New Features 321 | - `useExperiment` and `useFeature` hooks re-render when override user ID or attributes change([#64](https://github.com/optimizely/react-sdk/pull/64)) 322 | 323 | ### Bug fixes 324 | - `useExperiment` and `useFeature` hooks return up-to-date decision values on the first call after the client is ready ([#64](https://github.com/optimizely/react-sdk/pull/64)) 325 | 326 | ## [2.2.0] - July 31st, 2020 327 | Upgrade `@optimizely/optimizely-sdk` to [4.2.0](https://github.com/optimizely/javascript-sdk/releases/tag/v4.2.0) 328 | 329 | ### New Features 330 | - Better offline support in React Native apps: 331 | - Persist downloaded datafiles in local storage for use in subsequent SDK initializations 332 | - Persist pending impression & conversion events in local storage 333 | 334 | ### Bug fixes 335 | - Fixed log messages for Targeted Rollouts 336 | 337 | ## [2.1.0] - July 8th, 2020 338 | Upgrade `@optimizely/optimizely-sdk` to 4.1.0. See [@optimizely/optimizely-sdk Release 4.1.0](https://github.com/optimizely/javascript-sdk/releases/tag/v4.1.0) for more details. 339 | 340 | ## New Features 341 | - Add support for JSON feature variables ([#53](https://github.com/optimizely/react-sdk/pull/53)) 342 | 343 | ## [2.0.1] - May 22nd, 2020 344 | 345 | ### Bug Fixes 346 | 347 | - Export `useExperiment` hook from this package ([#50](https://github.com/optimizely/react-sdk/pull/50)) 348 | 349 | ## [2.0.0] - April 30th, 2020 350 | 351 | Upgrade `@optimizely/optimizely-sdk` to 4.0.0. See [@optimizely/optimizely-sdk Release 4.0.0](https://github.com/optimizely/javascript-sdk/releases/tag/v4.0.0) for more details. 352 | 353 | ### Breaking Changes 354 | 355 | - Changed supported React version to 16.8+ 356 | 357 | - @optimizely/optimizely-sdk no longer adds `Promise` polyfill in its browser entry point 358 | 359 | - Dropped support for Node.js version <8 360 | 361 | ### New Features 362 | 363 | - Refactored `<OptimizelyFeature>` to a functional component that uses the `useFeature` hook under the hood. See [#32](https://github.com/optimizely/react-sdk/pull/32) for more details. 364 | 365 | - Refactored `<OptimizelyExperiment>` to a functional component that uses the `useExperiment` hook under the hood. See [#36](https://github.com/optimizely/react-sdk/pull/36) for more details. 366 | 367 | - Added `useExperiment` hook 368 | 369 | - Can be used to retrieve the variation for an experiment. See [#36](https://github.com/optimizely/react-sdk/pull/36) for more details. 370 | 371 | - Added `useFeature` hook 372 | - Can be used to retrieve the status of a feature flag and its variables. See [#28](https://github.com/optimizely/react-sdk/pull/28) for more details. 373 | 374 | - Removed lodash dependency 375 | 376 | ### Enhancements 377 | 378 | - Exposed the entire context object used by `<OptimizelyProvider>`. 379 | - Enables support for using APIs which require passing reference to a context object, like `useContext`. [#27](https://github.com/optimizely/react-sdk/pull/27) for more details. 380 | 381 | 382 | ## [2.0.0-rc.2] - April 24th, 2020 383 | 384 | ### Bug Fixes 385 | 386 | - Upgrade `@optimizely/optimizely-sdk` to 4.0.0-rc.2. Allow creating multiple instances from the same datafile object. See [@optimizely/optimizely-sdk Release 4.0.0-rc.2](https://github.com/optimizely/javascript-sdk/releases/tag/v4.0.0-rc.2) for more details. 387 | 388 | 389 | ## [2.0.0-rc.1] - April 20th, 2020 390 | 391 | ### Breaking Changes 392 | 393 | - Upgrade `@optimizely/optimizely-sdk` to 4.0.0-rc.1. Dropped support for Node.js version <8. See [@optimizely/optimizely-sdk Release 4.0.0-rc.1](https://github.com/optimizely/javascript-sdk/releases/tag/v4.0.0-rc.1) for more details. 394 | 395 | ## [2.0.0-alpha.2] - April 3rd, 2020 396 | 397 | ### Breaking Changes 398 | 399 | - Upgrade `@optimizely/optimizely-sdk` to 4.0.0-alpha.1. `Promise` polyfill no longer included. See [@optimizely/optimizely-sdk Release 4.0.0-alpha.1](https://github.com/optimizely/javascript-sdk/releases/tag/v4.0.0-alpha.1) for more details. 400 | 401 | ## [2.0.0-alpha.1] - March 18th, 2020 402 | 403 | ### Breaking Changes 404 | 405 | - Changed supported React version to 16.8+ 406 | 407 | ### New Features 408 | 409 | - Refactored `<OptimizelyFeature>` to a functional component that uses the `useFeature` hook under the hood. See [#32](https://github.com/optimizely/react-sdk/pull/32) for more details. 410 | 411 | - Refactored `<OptimizelyExperiment>` to a functional component that uses the `useExperiment` hook under the hood. See [#36](https://github.com/optimizely/react-sdk/pull/36) for more details. 412 | 413 | - Added `useExperiment` hook 414 | 415 | - Can be used to retrieve the variation for an experiment. See [#36](https://github.com/optimizely/react-sdk/pull/36) for more details. 416 | 417 | - Added `useFeature` hook 418 | - Can be used to retrieve the status of a feature flag and its variables. See [#28](https://github.com/optimizely/react-sdk/pull/28) for more details. 419 | 420 | ### Enhancements 421 | 422 | - Exposed the entire context object used by `<OptimizelyProvider>`. 423 | - Enables support for using APIs which require passing reference to a context object, like `useContext`. [#27](https://github.com/optimizely/react-sdk/pull/27) for more details. 424 | 425 | ## [1.2.0-alpha.1] - March 5th, 2020 426 | 427 | ### New Features 428 | 429 | - Updated minor version of core SDK (`@optimizely/optimizely-sdk`) dependency which, for unrecognized events sent via `.track()`, will impact the log levels in this SDK as well. 430 | 431 | ## [1.1.0] - January 30th, 2020 432 | 433 | ### New Features 434 | 435 | - Added a new API to get project configuration static data. 436 | - Call `getOptimizelyConfig()` to get a snapshot of project configuration static data. 437 | - It returns an `OptimizelyConfig` instance which includes a datafile revision number, all experiments, and feature flags mapped by their key values. 438 | - Added caching for `getOptimizelyConfig` - `OptimizelyConfig` object will be cached and reused for the lifetime of the datafile. 439 | - For details, refer to our documentation page: [https://docs.developers.optimizely.com/full-stack/docs/optimizelyconfig-react](https://docs.developers.optimizely.com/full-stack/docs/optimizelyconfig-react). 440 | 441 | ## [1.0.1] - November 18th, 2019 442 | 443 | ### Fixed 444 | 445 | - Javascript SDK Client version was being sent in dispatched events. Changed it to send React SDK client version. 446 | - Updated `@optimizely/optimizely-sdk to 3.3.2.` Includes a better default logger for React Native and a fix for an error message logged when a user is bucketed to empty space. 447 | - Replaced usage of `react-broadcast` with the native React Context API 448 | 449 | ## [1.0.0] - September 27th, 2019 450 | 451 | Initial release 452 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to React SDK 2 | 3 | We welcome contributions and feedback! All contributors must sign our [Contributor License Agreement (CLA)](https://docs.google.com/a/optimizely.com/forms/d/e/1FAIpQLSf9cbouWptIpMgukAKZZOIAhafvjFCV8hS00XJLWQnWDFtwtA/viewform) to be eligible to contribute. Please read the [README](README.md) to set up your development environment, then read the guidelines below for information on submitting your code. 4 | 5 | ## Development process 6 | 7 | 1. Fork the repository and create your branch from master. 8 | 2. Please follow the [commit message guidelines](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-guidelines) for each commit message. 9 | 3. Make sure to add tests! 10 | 4. `git push` your changes to GitHub. 11 | 5. Open a PR from your fork into the master branch of the original repo 12 | 6. Make sure that all unit tests are passing and that there are no merge conflicts between your branch and `master`. 13 | 7. Open a pull request from `YOUR_NAME/branch_name` to `master`. 14 | 8. A repository maintainer will review your pull request and, if all goes well, squash and merge it! 15 | 16 | ## Pull request acceptance criteria 17 | 18 | * **All code must have test coverage.** Changes in functionality should have accompanying unit tests. Bug fixes should have accompanying regression tests. 19 | 20 | * Please don't change the `package.json` or `VERSION`. We'll take care of bumping the version when we next release. 21 | 22 | ## Style 23 | 24 | To enforce style rules, be sure to run prettier `yarn prettier` in the proper package. 25 | 26 | ## License 27 | 28 | All contributions are under the CLA mentioned above. For this project, Optimizely uses the Apache 2.0 license, and so asks that by contributing your code, you agree to license your contribution under the terms of the [Apache License v2.0](http://www.apache.org/licenses/LICENSE-2.0). Your contributions should also include the following header: 29 | 30 | ``` 31 | /**************************************************************************** 32 | * Copyright YEAR, Optimizely, Inc. and contributors * 33 | * * 34 | * Licensed under the Apache License, Version 2.0 (the "License"); * 35 | * you may not use this file except in compliance with the License. * 36 | * You may obtain a copy of the License at * 37 | * * 38 | * http://www.apache.org/licenses/LICENSE-2.0 * 39 | * * 40 | * Unless required by applicable law or agreed to in writing, software * 41 | * distributed under the License is distributed on an "AS IS" BASIS, * 42 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * 43 | * See the License for the specific language governing permissions and * 44 | * limitations under the License. * 45 | ***************************************************************************/ 46 | ``` 47 | 48 | The YEAR above should be the year of the contribution. If work on the file has been done over multiple years, list each year in the section above. Example: Optimizely writes the file and releases it in 2014. No changes are made in 2015. Change made in 2016. YEAR should be “2014, 2016”. 49 | 50 | ## Contact 51 | 52 | If you have questions, please contact developers@optimizely.com. 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "{}" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | © Optimizely 2018 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Optimizely React SDK 2 | 3 | This repository houses the React SDK for use with Optimizely Feature Experimentation and Optimizely Full Stack (legacy). 4 | 5 | Optimizely Feature Experimentation is an A/B testing and feature management tool for product development teams that enables you to experiment at every step. Using Optimizely Feature Experimentation allows for every feature on your roadmap to be an opportunity to discover hidden insights. Learn more at [Optimizely.com](https://www.optimizely.com/products/experiment/feature-experimentation/), or see the [developer documentation](https://docs.developers.optimizely.com/feature-experimentation/docs/introduction). 6 | 7 | Optimizely Rollouts is [free feature flags](https://www.optimizely.com/free-feature-flagging/) for development teams. You can easily roll out and roll back features in any application without code deploys, mitigating risk for every feature on your roadmap. 8 | 9 | ## Get Started 10 | 11 | Refer to the [React SDK's developer documentation](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/javascript-react-sdk) for detailed instructions on getting started with using the SDK. 12 | 13 | For React Native, review the [React Native developer documentation](https://docs.developers.optimizely.com/feature-experimentation/docs/javascript-react-native-sdk). 14 | 15 | 16 | ### Features 17 | 18 | - Automatic datafile downloading 19 | - User ID + attributes memoization 20 | - Render blocking until datafile is ready via a React API 21 | - Optimizely timeout (only block rendering up to the number of milliseconds you specify) 22 | - Library of React components and hooks to use with [feature flags](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/create-feature-flags) 23 | 24 | ### Compatibility 25 | 26 | The React SDK is compatible with `React 16.8.0 +` 27 | 28 | ### Example 29 | 30 | ```jsx 31 | import { 32 | createInstance, 33 | OptimizelyProvider, 34 | useDecision, 35 | } from '@optimizely/react-sdk'; 36 | 37 | const optimizelyClient = createInstance({ 38 | sdkKey: 'your-optimizely-sdk-key', 39 | }); 40 | 41 | function MyComponent() { 42 | const [decision] = useDecision('sort-algorithm'); 43 | return ( 44 | <React.Fragment> 45 | <SearchComponent algorithm={decision.variables.algorithm} /> 46 | { decision.variationKey === 'relevant_first' && <RelevantFirstList /> } 47 | { decision.variationKey === 'recent_first' && <RecentFirstList /> } 48 | </React.Fragment> 49 | ); 50 | } 51 | 52 | class App extends React.Component { 53 | render() { 54 | return ( 55 | <OptimizelyProvider 56 | optimizely={optimizelyClient} 57 | timeout={500} 58 | user={{ id: window.userId, attributes: { plan_type: 'bronze' } }} 59 | > 60 | <MyComponent /> 61 | </OptimizelyProvider> 62 | ); 63 | } 64 | } 65 | ``` 66 | 67 | ### Install the SDK 68 | 69 | ``` 70 | npm install @optimizely/react-sdk 71 | ``` 72 | 73 | For **React Native**, installation instruction is bit different. Check out the 74 | - [Official Installation guide](https://docs.developers.optimizely.com/feature-experimentation/docs/install-sdk-reactnative) 75 | - [Expo React Native Sample App](https://github.com/optimizely/expo-react-native-sdk-sample) 76 | 77 | ## Use the React SDK 78 | 79 | ### Initialization 80 | 81 | ## `createInstance` 82 | 83 | The `ReactSDKClient` client created via `createInstance` is the programmatic API to evaluating features and experiments and tracking events. The `ReactSDKClient` is what powers the rest of the ReactSDK internally. 84 | 85 | _arguments_ 86 | 87 | - `config : object` Object with SDK configuration parameters. This has the same format as the object passed to the `createInstance` method of the core `@optimizely/javascript-sdk` module. For details on this object, see the following pages from the developer docs: 88 | - [Instantiate](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/initialize-sdk-react) 89 | - [JavaScript: Client-side Datafile Management](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/javascript-client-side-implementation) 90 | 91 | _returns_ 92 | 93 | - A `ReactSDKClient` instance. 94 | 95 | ```jsx 96 | import { OptimizelyProvider, createInstance } from '@optimizely/react-sdk'; 97 | 98 | const optimizely = createInstance({ 99 | datafile: window.optimizelyDatafile, 100 | }); 101 | ``` 102 | 103 | ## `<OptimizelyProvider>` 104 | 105 | Required at the root level. Leverages React’s `Context` API to allow access to the `ReactSDKClient` to the `useDecision` hook. 106 | 107 | _props_ 108 | 109 | - `optimizely : ReactSDKClient` created from `createInstance` 110 | - `user: { id: string; attributes?: { [key: string]: any } } | Promise` User info object - `id` and `attributes` will be passed to the SDK for every feature flag, A/B test, or `track` call, or a `Promise` for the same kind of object 111 | - `timeout : Number` (optional) The amount of time for `useDecision` to return `null` flag Decision while waiting for the SDK instance to become ready, before resolving. 112 | - `isServerSide : Boolean` (optional) must pass `true` here for server side rendering 113 | - `userId : String` (optional) **_Deprecated, prefer using `user` instead_**. Another way to provide user id. The `user` object prop takes precedence when both are provided. 114 | - `userAttributes : Object` : (optional) **_Deprecated, prefer using `user` instead_**. Another way to provide user attributes. The `user` object prop takes precedence when both are provided. 115 | 116 | ### Readiness 117 | 118 | Before rendering real content, both the datafile and the user must be available to the SDK. 119 | 120 | #### Load the datafile synchronously 121 | 122 | Synchronous loading is the preferred method to ensure that Optimizely is always ready and doesn't add any delay or asynchronous complexity to your application. When initializing with both the SDK key and datafile, the SDK will use the given datafile to start, then download the latest version of the datafile in the background. 123 | 124 | ```jsx 125 | import { OptimizelyProvider, createInstance } from '@optimizely/react-sdk'; 126 | 127 | const optimizelyClient = createInstance({ 128 | datafile: window.optimizelyDatafile, 129 | sdkKey: 'your-optimizely-sdk-key', // Optimizely environment key 130 | }); 131 | 132 | class AppWrapper extends React.Component { 133 | render() { 134 | return ( 135 | <OptimizelyProvider optimizely={optimizelyClient} user={{ id: window.userId }}> 136 | <App /> 137 | </OptimizelyProvider> 138 | ); 139 | } 140 | } 141 | ``` 142 | 143 | #### Load the datafile asynchronously 144 | 145 | If you don't have the datafile downloaded, the `ReactSDKClient` can fetch the datafile for you. However, instead of waiting for the datafile to fetch before you render your app, you can immediately render your app and provide a `timeout` option to `<OptimizelyProvider optimizely={optimizely} timeout={200}>`. The `useDecision` hook returns `isClientReady` and `didTimeout`. You can use these to block rendering of component until the datafile loads or the timeout is over. 146 | 147 | ```jsx 148 | import { OptimizelyProvider, createInstance, useDecision } from '@optimizely/react-sdk'; 149 | 150 | const optimizelyClient = createInstance({ 151 | sdkKey: 'your-optimizely-sdk-key', // Optimizely environment key 152 | }); 153 | 154 | function MyComponent() { 155 | const [decision, isClientReady, didTimeout] = useDecision('the-flag'); 156 | return ( 157 | <React.Fragment> 158 | { isClientReady && <div>The Component</div> } 159 | { didTimeout && <div>Default Component</div>} 160 | { /* If client is not ready and time out has not occured yet, do not render anything */ } 161 | </React.Fragment> 162 | ); 163 | } 164 | 165 | class App extends React.Component { 166 | render() { 167 | return ( 168 | <OptimizelyProvider 169 | optimizely={optimizelyClient} 170 | timeout={500} 171 | user={{ id: window.userId, attributes: { plan_type: 'bronze' } }} 172 | > 173 | <MyComponent /> 174 | </OptimizelyProvider> 175 | ); 176 | } 177 | } 178 | ``` 179 | 180 | #### Set user asynchronously 181 | 182 | If user information is synchronously available, it can be provided as the `user` object prop, as in prior examples. But, if user information must be fetched asynchronously, the `user` prop can be a `Promise` for a `user` object with the same properties (`id` and `attributes`): 183 | 184 | ```jsx 185 | import { OptimizelyProvider, createInstance } from '@optimizely/react-sdk'; 186 | import { fetchUser } from './user'; 187 | 188 | const optimizely = createInstance({ 189 | datafile: window.optimizelyDatafile, 190 | }); 191 | 192 | const userPromise = fetchUser(); // fetchUser returns a Promise for an object with { id, attributes } 193 | 194 | class AppWrapper extends React.Component { 195 | render() { 196 | return ( 197 | <OptimizelyProvider optimizely={optimizely} user={userPromise}> 198 | <App /> 199 | </OptimizelyProvider> 200 | ); 201 | } 202 | } 203 | ``` 204 | 205 | ## `useDecision` Hook 206 | 207 | A [React Hook](https://react.dev/learn/state-a-components-memory#meet-your-first-hook) to retrieve the decision result for a flag key, optionally auto updating that decision based on underlying user or datafile changes. 208 | 209 | _arguments_ 210 | 211 | - `flagKey : string` The key of the feature flag. 212 | - `options : Object` 213 | - `autoUpdate : boolean` (optional) If true, this hook will update the flag decision in response to datafile or user changes. Default: `false`. 214 | - `timeout : number` (optional) Client timeout as described in the `OptimizelyProvider` section. Overrides any timeout set on the ancestor `OptimizelyProvider`. 215 | - `decideOption: OptimizelyDecideOption[]` (optional) Array of OptimizelyDecideOption enums. 216 | - `overrides : Object` 217 | - `overrideUserId : string` (optional) Override the userId to be used to obtain the decision result for this hook. 218 | - `overrideAttributes : optimizely.UserAttributes` (optional) Override the user attributes to be used to obtain the decision result for this hook. 219 | 220 | _returns_ 221 | 222 | - `Array` of: 223 | 224 | - `decision : OptimizelyDecision` - Decision result for the flag key. 225 | - `clientReady : boolean` - Whether or not the underlying `ReactSDKClient` instance is ready or not. 226 | - `didTimeout : boolean` - Whether or not the underlying `ReactSDKClient` became ready within the allowed `timeout` range. 227 | 228 | _Note: `clientReady` can be true even if `didTimeout` is also true. This indicates that the client became ready *after* the timeout period._ 229 | 230 | ### Render something if flag is enabled 231 | 232 | ```jsx 233 | import { useEffect } from 'react'; 234 | import { useDecision } from '@optimizely/react-sdk'; 235 | 236 | function LoginComponent() { 237 | const [decision, clientReady] = useDecision( 238 | 'login-flag', 239 | { autoUpdate: true }, 240 | { 241 | /* (Optional) User overrides */ 242 | } 243 | ); 244 | useEffect(() => { 245 | document.title = decision.enabled ? 'login-new' : 'login-default'; 246 | }, [decision.enabled]); 247 | 248 | return ( 249 | <p> 250 | <a href={decision.enabled ? '/login-new' : '/login-default'}>Click to login</a> 251 | </p> 252 | ); 253 | } 254 | ``` 255 | 256 | ## `withOptimizely` 257 | 258 | Any component under the `<OptimizelyProvider>` can access the Optimizely `ReactSDKClient` via the higher-order component (HoC) `withOptimizely`. 259 | 260 | _arguments_ 261 | 262 | - `Component : React.Component` Component which will be enhanced with the following props: 263 | - `optimizely : ReactSDKClient` The client object which was passed to the `OptimizelyProvider` 264 | - `optimizelyReadyTimeout : number | undefined` The timeout which was passed to the `OptimizelyProvider` 265 | - `isServerSide : boolean` Value that was passed to the `OptimizelyProvider` 266 | 267 | _returns_ 268 | 269 | - A wrapped component with additional props as described above 270 | 271 | ### Example 272 | 273 | ```jsx 274 | import { withOptimizely } from '@optimizely/react-sdk'; 275 | 276 | class MyComp extends React.Component { 277 | constructor(props) { 278 | super(props); 279 | const { optimizely } = this.props; 280 | const decision = optimizely.decide('feat1'); 281 | 282 | this.state = { 283 | decision.enabled, 284 | decision.variables, 285 | }; 286 | } 287 | 288 | render() {} 289 | } 290 | 291 | const WrappedMyComponent = withOptimizely(MyComp); 292 | ``` 293 | 294 | **_Note:_** The `optimizely` client object provided via `withOptimizely` is automatically associated with the `user` prop passed to the ancestor `OptimizelyProvider` - the `id` and `attributes` from that `user` object will be automatically forwarded to all appropriate SDK method calls. So, there is no need to pass the `userId` or `attributes` arguments when calling methods of the `optimizely` client object, unless you wish to use _different_ `userId` or `attributes` than those given to `OptimizelyProvider`. 295 | 296 | ## `useContext` 297 | 298 | Any component under the `<OptimizelyProvider>` can access the Optimizely `ReactSDKClient` via the `OptimizelyContext` with `useContext`. 299 | 300 | _arguments_ 301 | - `OptimizelyContext : React.Context<OptimizelyContextInterface>` The Optimizely context initialized in a parent component (or App). 302 | 303 | _returns_ 304 | - Wrapped object: 305 | - `optimizely : ReactSDKClient` The client object which was passed to the `OptimizelyProvider` 306 | - `isServerSide : boolean` Value that was passed to the `OptimizelyProvider` 307 | - `timeout : number | undefined` The timeout which was passed to the `OptimizelyProvider` 308 | 309 | ### Example 310 | 311 | ```jsx 312 | import React, { useContext } from 'react'; 313 | import { OptimizelyContext } from '@optimizely/react-sdk'; 314 | 315 | function MyComponent() { 316 | const { optimizely, isServerSide, timeout } = useContext(OptimizelyContext); 317 | const decision = optimizely.decide('my-feature'); 318 | const onClick = () => { 319 | optimizely.track('signup-clicked'); 320 | // rest of your click handling code 321 | }; 322 | return ( 323 | <> 324 | { decision.enabled && <p>My feature is enabled</p> } 325 | { !decision.enabled && <p>My feature is disabled</p> } 326 | { decision.variationKey === 'control-variation' && <p>Current Variation</p> } 327 | { decision.variationKey === 'experimental-variation' && <p>Better Variation</p> } 328 | <button onClick={onClick}>Sign Up!</button> 329 | </> 330 | ); 331 | } 332 | ``` 333 | 334 | ### Tracking 335 | Use the built-in `useTrackEvent` hook to access the `track` method of optimizely instance 336 | 337 | ```jsx 338 | import { useTrackEvent } from '@optimizely/react-sdk'; 339 | 340 | function SignupButton() { 341 | const [track, clientReady, didTimeout] = useTrackEvent() 342 | 343 | const handleClick = () => { 344 | if(clientReady) { 345 | track('signup-clicked') 346 | } 347 | } 348 | 349 | return ( 350 | <button onClick={handleClick}>Signup</button> 351 | ) 352 | } 353 | ``` 354 | 355 | Or you can use the `withOptimizely` HoC. 356 | 357 | ```jsx 358 | import { withOptimizely } from '@optimizely/react-sdk'; 359 | 360 | class SignupButton extends React.Component { 361 | onClick = () => { 362 | const { optimizely } = this.props; 363 | optimizely.track('signup-clicked'); 364 | // rest of click handler 365 | }; 366 | 367 | render() { 368 | <button onClick={this.onClick}>Signup</button>; 369 | } 370 | } 371 | 372 | const WrappedSignupButton = withOptimizely(SignupButton); 373 | ``` 374 | 375 | **_Note:_** As mentioned above, the `optimizely` client object provided via `withOptimizely` is automatically associated with the `user` prop passed to the ancestor `OptimizelyProvider.` There is no need to pass `userId` or `attributes` arguments when calling `track`, unless you wish to use _different_ `userId` or `attributes` than those given to `OptimizelyProvider`. 376 | 377 | ## `ReactSDKClient` 378 | 379 | The following type definitions are used in the `ReactSDKClient` interface: 380 | 381 | - `UserAttributes : { [name: string]: any }` 382 | - `User : { id: string | null, attributes: userAttributes }` 383 | - `VariableValuesObject : { [key: string]: any }` 384 | - `EventTags : { [key: string]: string | number | boolean; }` 385 | 386 | `ReactSDKClient` instances have the methods/properties listed below. Note that in general, the API largely matches that of the core `@optimizely/optimizely-sdk` client instance, which is documented on the [Optimizely Feature Experimentation developer docs site](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/welcome). The major exception is that, for most methods, user id & attributes are **_optional_** arguments. `ReactSDKClient` has a current user. This user's id & attributes are automatically applied to all method calls, and overrides can be provided as arguments to these method calls if desired. 387 | 388 | - `onReady(opts?: { timeout?: number }): Promise<onReadyResult>` Returns a Promise that fulfills with an `onReadyResult` object representing the initialization process. The instance is ready when it has fetched a datafile and a user is available (via `setUser` being called with an object, or a Promise passed to `setUser` becoming fulfilled). If the `timeout` period happens before the client instance is ready, the `onReadyResult` object will contain an additional key, `dataReadyPromise`, which can be used to determine when, if ever, the instance does become ready. 389 | - `user: User` The current user associated with this client instance 390 | - `setUser(userInfo: User | Promise<User>): void` Call this to update the current user 391 | - `onUserUpdate(handler: (userInfo: User) => void): () => void` Subscribe a callback to be called when this instance's current user changes. Returns a function that will unsubscribe the callback. 392 | - `decide(key: string, options?: optimizely.OptimizelyDecideOption[], overrideUserId?: string, overrideAttributes?: optimizely.UserAttributes): OptimizelyDecision` Returns a decision result for a flag key for a user. The decision result is returned in an OptimizelyDecision object, and contains all data required to deliver the flag rule. 393 | - `decideAll(options?: optimizely.OptimizelyDecideOption[], overrideUserId?: string, overrideAttributes?: optimizely.UserAttributes): { [key: string]: OptimizelyDecision }` Returns decisions for all active (unarchived) flags for a user. 394 | - `decideForKeys(keys: string[], options?: optimizely.OptimizelyDecideOption[], overrideUserId?: string, overrideAttributes?: optimizely.UserAttributes): { [key: string]: OptimizelyDecision }` Returns an object of decision results mapped by flag keys. 395 | - `activate(experimentKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): string | null` Activate an experiment, and return the variation for the given user. 396 | - `getVariation(experimentKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): string | null` Return the variation for the given experiment and user. 397 | - `getFeatureVariables(featureKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): VariableValuesObject`: Decide and return variable values for the given feature and user <br /> <b>Warning:</b> Deprecated since 2.1.0 <br /> `getAllFeatureVariables` is added in JavaScript SDK which is similarly returning all the feature variables, but it sends only single notification of type `all-feature-variables` instead of sending for each variable. As `getFeatureVariables` was added when this functionality wasn't provided by `JavaScript SDK`, so there is no need of it now and it would be removed in next major release 398 | - `getFeatureVariableString(featureKey: string, variableKey: string, overrideUserId?: string, overrideAttributes?: optimizely.UserAttributes): string | null`: Decide and return the variable value for the given feature, variable, and user 399 | - `getFeatureVariableInteger(featureKey: string, variableKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): number | null` Decide and return the variable value for the given feature, variable, and user 400 | - `getFeatureVariableBoolean(featureKey: string, variableKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): boolean | null` Decide and return the variable value for the given feature, variable, and user 401 | - `getFeatureVariableDouble(featureKey: string, variableKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): number | null` Decide and return the variable value for the given feature, variable, and user 402 | - `isFeatureEnabled(featureKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): boolean` Return the enabled status for the given feature and user 403 | - `getEnabledFeatures(overrideUserId?: string, overrideAttributes?: UserAttributes): Array<string>`: Return the keys of all features enabled for the given user 404 | - `track(eventKey: string, overrideUserId?: string | EventTags, overrideAttributes?: UserAttributes, eventTags?: EventTags): void` Track an event to the Optimizely results backend 405 | - `setForcedVariation(experiment: string, overrideUserIdOrVariationKey: string, variationKey?: string | null): boolean` Set a forced variation for the given experiment, variation, and user. **Note**: calling `setForcedVariation` on a given client will trigger a re-render of all `useExperiment` hooks and `OptimizelyExperiment` components that are using that client. 406 | - `getForcedVariation(experiment: string, overrideUserId?: string): string | null` Get the forced variation for the given experiment, variation, and user 407 | 408 | ## Rollout or experiment a feature user-by-user 409 | 410 | To rollout or experiment on a feature by user rather than by random percentage, you will use Attributes and Audiences. To do this, follow the documentation on how to [run a beta](https://docs.developers.optimizely.com/feature-experimentation/docs/run-a-beta) using the React code samples. 411 | 412 | ## Server Side Rendering 413 | 414 | Right now server side rendering is possible with a few caveats. 415 | 416 | **Caveats** 417 | 418 | 1. You must download the datafile manually and pass in via the `datafile` option. Can not use `sdkKey` to automatically download. 419 | 420 | 2. Rendering of components must be completely synchronous (this is true for all server side rendering), thus the Optimizely SDK assumes that the optimizely client has been instantiated and fired it's `onReady` event already. 421 | 422 | ### Setting up `<OptimizelyProvider>` 423 | 424 | Similar to browser side rendering you will need to wrap your app (or portion of the app using Optimizely) in the `<OptimizelyProvider>` component. A new prop 425 | `isServerSide` must be equal to true. 426 | 427 | ```jsx 428 | <OptimizelyProvider optimizely={optimizely} user={{ id: 'user1' }} isServerSide={true}> 429 | <App /> 430 | </OptimizelyProvider> 431 | ``` 432 | 433 | All other Optimizely components, such as `<OptimizelyFeature>` and `<OptimizelyExperiment>` can remain the same. 434 | 435 | ### Full example 436 | 437 | ```jsx 438 | import * as React from 'react'; 439 | import * as ReactDOMServer from 'react-dom/server'; 440 | 441 | import { 442 | createInstance, 443 | OptimizelyProvider, 444 | useDecision, 445 | } from '@optimizely/react-sdk'; 446 | 447 | const fetch = require('node-fetch'); 448 | 449 | function MyComponent() { 450 | const [decision] = useDecision('flag1'); 451 | return ( 452 | <React.Fragment> 453 | { decision.enabled && <p>The feature is enabled</p> } 454 | { !decision.enabled && <p>The feature is not enabled</p> } 455 | { decision.variationKey === 'variation1' && <p>Variation 1</p> } 456 | { decision.variationKey === 'variation2' && <p>Variation 2</p> } 457 | </React.Fragment> 458 | ); 459 | } 460 | 461 | async function main() { 462 | const resp = await fetch('https://cdn.optimizely.com/datafiles/<Your-SDK-Key>.json'); 463 | const datafile = await resp.json(); 464 | const optimizelyClient = createInstance({ 465 | datafile, 466 | }); 467 | 468 | const output = ReactDOMServer.renderToString( 469 | <OptimizelyProvider optimizely={optimizelyClient} user={{ id: 'user1' }} isServerSide={true}> 470 | <MyComponent /> 471 | </OptimizelyProvider> 472 | ); 473 | console.log('output', output); 474 | } 475 | main(); 476 | ``` 477 | 478 | ## Disabled event dispatcher 479 | 480 | To disable sending all events to Optimizely's results backend, use the `logOnlyEventDispatcher` when creating a client: 481 | 482 | ```js 483 | import { createInstance, logOnlyEventDispatcher } from '@optimizely/react-sdk'; 484 | 485 | const optimizely = createInstance({ 486 | datafile: window.optimizelyDatafile, 487 | eventDispatcher: logOnlyEventDispatcher, 488 | }); 489 | ``` 490 | 491 | ### Additional code 492 | 493 | This repository includes the following third party open source code: 494 | 495 | [**hoist-non-react-statics**](https://github.com/mridgway/hoist-non-react-statics) 496 | Copyright © 2015 Yahoo!, Inc. 497 | License: [BSD](https://github.com/mridgway/hoist-non-react-statics/blob/master/LICENSE.md) 498 | 499 | [**js-tokens**](https://github.com/lydell/js-tokens) 500 | Copyright © 2014, 2015, 2016, 2017, 2018, 2019 Simon Lydell 501 | License: [MIT](https://github.com/lydell/js-tokens/blob/master/LICENSE) 502 | 503 | [**json-schema**](https://github.com/kriszyp/json-schema) 504 | Copyright © 2005-2015, The Dojo Foundation 505 | License: [BSD](https://github.com/kriszyp/json-schema/blob/master/LICENSE) 506 | 507 | [**lodash**](https://github.com/lodash/lodash/) 508 | Copyright © JS Foundation and other contributors 509 | License: [MIT](https://github.com/lodash/lodash/blob/master/LICENSE) 510 | 511 | [**loose-envify**](https://github.com/zertosh/loose-envify) 512 | Copyright © 2015 Andres Suarez <zertosh@gmail.com> 513 | License: [MIT](https://github.com/zertosh/loose-envify/blob/master/LICENSE) 514 | 515 | [**node-murmurhash**](https://github.com/perezd/node-murmurhash) 516 | Copyright © 2012 Gary Court, Derek Perez 517 | License: [MIT](https://github.com/perezd/node-murmurhash/blob/master/README.md) 518 | 519 | [**object-assign**](https://github.com/sindresorhus/object-assign) 520 | Copyright © Sindre Sorhus (sindresorhus.com) 521 | License: [MIT](https://github.com/sindresorhus/object-assign/blob/master/license) 522 | 523 | [**promise-polyfill**](https://github.com/taylorhakes/promise-polyfill) 524 | Copyright © 2014 Taylor Hakes 525 | Copyright © 2014 Forbes Lindesay 526 | License: [MIT](https://github.com/taylorhakes/promise-polyfill/blob/master/LICENSE) 527 | 528 | [**react-is**](https://github.com/facebook/react) 529 | Copyright © Facebook, Inc. and its affiliates. 530 | License: [MIT](https://github.com/facebook/react/blob/master/LICENSE) 531 | 532 | [**react**](https://github.com/facebook/react) 533 | Copyright © Facebook, Inc. and its affiliates. 534 | License: [MIT](https://github.com/facebook/react/blob/master/LICENSE) 535 | 536 | [**scheduler**](https://github.com/facebook/react) 537 | Copyright © Facebook, Inc. and its affiliates. 538 | License: [MIT](https://github.com/facebook/react/blob/master/LICENSE) 539 | 540 | [**node-uuid**](https://github.com/kelektiv/node-uuid) 541 | Copyright © 2010-2016 Robert Kieffer and other contributors 542 | License: [MIT](https://github.com/kelektiv/node-uuid/blob/master/LICENSE.md) 543 | 544 | To regenerate the dependencies use by this package, run the following command: 545 | 546 | ```sh 547 | npx license-checker --production --json | jq 'map_values({ licenses, publisher, repository }) | del(.[][] | nulls)' 548 | ``` 549 | 550 | ### Contributing 551 | 552 | Please see [CONTRIBUTING](./CONTRIBUTING.md) for more information. 553 | 554 | ### Credits 555 | 556 | First-party code subject to copyrights held by Optimizely, Inc. and its contributors and licensed to you under the terms of the Apache 2.0 license. 557 | 558 | ### Other Optimizely SDKs 559 | 560 | - Agent - https://github.com/optimizely/agent 561 | 562 | - Android - https://github.com/optimizely/android-sdk 563 | 564 | - C# - https://github.com/optimizely/csharp-sdk 565 | 566 | - Flutter - https://github.com/optimizely/optimizely-flutter-sdk 567 | 568 | - Go - https://github.com/optimizely/go-sdk 569 | 570 | - Java - https://github.com/optimizely/java-sdk 571 | 572 | - JavaScript - https://github.com/optimizely/javascript-sdk 573 | 574 | - PHP - https://github.com/optimizely/php-sdk 575 | 576 | - Python - https://github.com/optimizely/python-sdk 577 | 578 | - Ruby - https://github.com/optimizely/ruby-sdk 579 | 580 | - Swift - https://github.com/optimizely/swift-sdk 581 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019, Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** @type {import('jest').Config} */ 18 | module.exports = { 19 | testEnvironment: 'jsdom', 20 | roots: ['./src'], 21 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 22 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 23 | preset: 'ts-jest', 24 | }; 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@optimizely/react-sdk", 3 | "version": "3.2.4", 4 | "description": "React SDK for Optimizely Feature Experimentation, Optimizely Full Stack (legacy), and Optimizely Rollouts", 5 | "homepage": "https://github.com/optimizely/react-sdk", 6 | "repository": "https://github.com/optimizely/react-sdk", 7 | "license": "Apache-2.0", 8 | "module": "dist/react-sdk.es.js", 9 | "types": "dist/index.d.ts", 10 | "main": "dist/react-sdk.js", 11 | "browser": "dist/react-sdk.js", 12 | "directories": { 13 | "lib": "lib" 14 | }, 15 | "files": [ 16 | "dist", 17 | "LICENSE", 18 | "CHANGELOG", 19 | "README.md" 20 | ], 21 | "engines": { 22 | "node": ">=14.0.0" 23 | }, 24 | "scripts": { 25 | "tsc": "rm -rf lib/ && tsc", 26 | "build": "rm -rf dist/ && node ./scripts/build.js", 27 | "build:win": "(if exist dist rd /s/q dist) && node ./scripts/winbuild.js", 28 | "lint": "tsc --noEmit && eslint 'src/**/*.{js,ts,tsx}' --quiet --fix", 29 | "test": "jest --silent", 30 | "test-coverage": "jest --coverage --coverageReporters=\"text-summary\" --silent", 31 | "prepublishOnly": "npm run test && npm run build", 32 | "prepare": "npm run build && husky install" 33 | }, 34 | "publishConfig": { 35 | "access": "public" 36 | }, 37 | "lint-staged": { 38 | "**/*.{js,ts,tsx}": [ 39 | "yarn run lint", 40 | "yarn run test --findRelatedTests" 41 | ] 42 | }, 43 | "dependencies": { 44 | "@optimizely/optimizely-sdk": "^5.3.4", 45 | "hoist-non-react-statics": "^3.3.2" 46 | }, 47 | "peerDependencies": { 48 | "react": ">=16.8.0" 49 | }, 50 | "devDependencies": { 51 | "@rollup/plugin-commonjs": "^26.0.1", 52 | "@rollup/plugin-node-resolve": "^15.2.3", 53 | "@rollup/plugin-replace": "^5.0.7", 54 | "@rollup/plugin-terser": "0.4.2", 55 | "@testing-library/jest-dom": "^6.4.8", 56 | "@testing-library/react": "^14.3.0", 57 | "@types/hoist-non-react-statics": "^3.3.5", 58 | "@types/jest": "^29.5.12", 59 | "@types/react": "^18.0.15", 60 | "@types/react-dom": "^18.0.6", 61 | "@typescript-eslint/eslint-plugin": "^5.6.2", 62 | "@typescript-eslint/parser": "^5.6.2", 63 | "eslint": "^8.57.0", 64 | "eslint-config-prettier": "^9.1.0", 65 | "eslint-plugin-prettier": "^5.2.1", 66 | "eslint-plugin-react": "^7.35.0", 67 | "eslint-plugin-react-hooks": "^4.6.2", 68 | "husky": "8.0.3", 69 | "jest": "^29.7.0", 70 | "jest-environment-jsdom": "^29.7.0", 71 | "lint-staged": "13.2.3", 72 | "prettier": "^3.3.3", 73 | "react": "^18.2.0", 74 | "react-dom": "^18.2.0", 75 | "rollup": "^3.29.4", 76 | "rollup-plugin-typescript2": "^0.36.0", 77 | "ts-jest": "^29.2.3", 78 | "typescript": "^5.5.4" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019, 2023 Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const path = require('path'); 18 | const execSync = require('child_process').execSync; 19 | 20 | process.chdir(path.resolve(__dirname, '..')); 21 | 22 | function exec(command, extraEnv) { 23 | return execSync(command, { 24 | stdio: 'inherit', 25 | env: Object.assign({}, process.env, extraEnv), 26 | }); 27 | } 28 | 29 | const packageName = 'react-sdk'; 30 | const umdName = 'optimizelyReactSdk'; 31 | 32 | console.log('\nBuilding ES modules...'); 33 | exec(`./node_modules/.bin/rollup -c scripts/config.js -f es -o dist/${packageName}.es.js`); 34 | 35 | console.log('\nBuilding CommonJS modules...'); 36 | exec(`./node_modules/.bin/rollup -c scripts/config.js -f cjs -o dist/${packageName}.js`); 37 | 38 | console.log('\nBuilding UMD modules...'); 39 | exec(`./node_modules/.bin/rollup -c scripts/config.js -f umd -o dist/${packageName}.umd.js --name ${umdName}`, { 40 | EXTERNALS: 'forBrowsers', 41 | BUILD_ENV: 'production', 42 | }); 43 | 44 | console.log('\nBuilding SystemJS modules...'); 45 | exec(`./node_modules/.bin/rollup -c scripts/config.js -f system -o dist/${packageName}.system.js`, { 46 | EXTERNALS: 'forBrowsers', 47 | BUILD_ENV: 'production', 48 | }); 49 | -------------------------------------------------------------------------------- /scripts/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019, 2023 Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const typescript = require('rollup-plugin-typescript2'); 18 | const commonjs = require('@rollup/plugin-commonjs'); 19 | const replace = require('@rollup/plugin-replace'); 20 | const { nodeResolve } = require('@rollup/plugin-node-resolve'); 21 | const terser = require('@rollup/plugin-terser'); 22 | 23 | const packageDeps = require('../package.json').dependencies || {}; 24 | const packagePeers = require('../package.json').peerDependencies || {}; 25 | 26 | function getExternals(externals) { 27 | let externalLibs; 28 | if (externals === 'forBrowsers') { 29 | externalLibs = ['react']; 30 | } else { 31 | externalLibs = 32 | externals === 'peers' ? Object.keys(packagePeers) : Object.keys(packageDeps).concat(Object.keys(packagePeers)); 33 | } 34 | externalLibs.push('crypto'); 35 | return externalLibs; 36 | } 37 | 38 | function getPlugins(env, externals) { 39 | const plugins = [ 40 | nodeResolve({ 41 | browser: externals === 'forBrowsers', 42 | preferBuiltins: externals !== 'forBrowsers', 43 | }), 44 | commonjs({ 45 | include: /node_modules/, 46 | }), 47 | ]; 48 | 49 | if (env) { 50 | plugins.push( 51 | replace({ 52 | 'process.env.NODE_ENV': JSON.stringify(env), 53 | preventAssignment: false, 54 | }) 55 | ); 56 | } 57 | 58 | plugins.push(typescript()); 59 | 60 | if (env === 'production') { 61 | plugins.push(terser()); 62 | } 63 | 64 | return plugins; 65 | } 66 | 67 | const config = { 68 | input: 'src/index.ts', 69 | output: { 70 | globals: { 71 | react: 'React', 72 | crypto: 'crypto', 73 | }, 74 | }, 75 | external: getExternals(process.env.EXTERNALS), 76 | plugins: getPlugins(process.env.BUILD_ENV, process.env.EXTERNALS), 77 | }; 78 | 79 | module.exports = config; 80 | -------------------------------------------------------------------------------- /scripts/winbuild.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023, Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const path = require('path'); 18 | const execSync = require('child_process').execSync; 19 | 20 | process.chdir(path.resolve(__dirname, '..')); 21 | 22 | function exec(command, extraEnv) { 23 | return execSync(command, { 24 | stdio: 'inherit', 25 | env: Object.assign({}, process.env, extraEnv), 26 | }); 27 | } 28 | 29 | const packageName = 'react-sdk'; 30 | const umdName = 'optimizelyReactSdk'; 31 | 32 | console.log('\nBuilding ES modules...'); 33 | exec(`.\\node_modules\\.bin\\rollup -c scripts\\config.js -f es -o dist\\${packageName}.es.js`); 34 | 35 | console.log('\nBuilding CommonJS modules...'); 36 | 37 | exec(`.\\node_modules\\.bin\\rollup -c scripts\\config.js -f cjs -o dist\\${packageName}.js`); 38 | 39 | console.log('\nBuilding UMD modules...'); 40 | 41 | exec(`.\\node_modules\\.bin\\rollup -c scripts\\config.js -f umd -o dist\\${packageName}.umd.js --name ${umdName}`, { 42 | EXTERNALS: 'forBrowsers', 43 | BUILD_ENV: 'production', 44 | }); 45 | 46 | console.log('\nBuilding SystemJS modules...'); 47 | 48 | exec(`.\\node_modules\\.bin\\rollup -c scripts\\config.js -f system -o dist\\${packageName}.system.js`, { 49 | EXTERNALS: 'forBrowsers', 50 | BUILD_ENV: 'production', 51 | }); 52 | -------------------------------------------------------------------------------- /src/Context.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018-2019, Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { createContext } from 'react'; 17 | import { ReactSDKClient } from './client'; 18 | 19 | export interface OptimizelyContextInterface { 20 | optimizely: ReactSDKClient | null; 21 | isServerSide: boolean; 22 | timeout: number | undefined; 23 | } 24 | 25 | export const OptimizelyContext = createContext<OptimizelyContextInterface>({ 26 | optimizely: null, 27 | isServerSide: false, 28 | timeout: 0, 29 | }); 30 | 31 | export const OptimizelyContextConsumer = OptimizelyContext.Consumer; 32 | export const OptimizelyContextProvider = OptimizelyContext.Provider; 33 | -------------------------------------------------------------------------------- /src/Experiment.spec.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018-2019, 2023-2024, Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /// <reference types="jest" /> 18 | import * as React from 'react'; 19 | import { act } from 'react-dom/test-utils'; 20 | import { render, screen, waitFor } from '@testing-library/react'; 21 | import '@testing-library/jest-dom'; 22 | 23 | import { OptimizelyExperiment } from './Experiment'; 24 | import { OptimizelyProvider } from './Provider'; 25 | import { ReactSDKClient } from './client'; 26 | import { OptimizelyVariation } from './Variation'; 27 | 28 | type Resolver = { 29 | resolve: (value: { success: boolean; reason?: string }) => void; 30 | reject: (reason?: string) => void; 31 | }; 32 | 33 | describe('<OptimizelyExperiment>', () => { 34 | const variationKey = 'matchingVariation'; 35 | let resolver: Resolver; 36 | let optimizelyMock: ReactSDKClient; 37 | let isReady: boolean; 38 | 39 | beforeEach(() => { 40 | isReady = false; 41 | const onReadyPromise = new Promise((resolve, reject) => { 42 | resolver = { 43 | reject, 44 | resolve, 45 | }; 46 | }); 47 | 48 | optimizelyMock = { 49 | onReady: jest.fn().mockImplementation(() => onReadyPromise), 50 | activate: jest.fn().mockImplementation(() => variationKey), 51 | onUserUpdate: jest.fn().mockImplementation(() => () => {}), 52 | getVuid: jest.fn().mockImplementation(() => 'vuid_95bf72cebc774dfd8e8e580a5a1'), 53 | notificationCenter: { 54 | addNotificationListener: jest.fn().mockImplementation(() => {}), 55 | removeNotificationListener: jest.fn().mockImplementation(() => {}), 56 | }, 57 | user: { 58 | id: 'testuser', 59 | attributes: {}, 60 | }, 61 | isReady: jest.fn().mockImplementation(() => isReady), 62 | getIsReadyPromiseFulfilled: () => true, 63 | getIsUsingSdkKey: () => true, 64 | onForcedVariationsUpdate: jest.fn().mockReturnValue(() => {}), 65 | setUser: jest.fn(), 66 | } as unknown as ReactSDKClient; 67 | }); 68 | 69 | it('does not throw an error when not rendered in the context of an OptimizelyProvider', () => { 70 | const { container } = render( 71 | <OptimizelyExperiment experiment="experiment1"> 72 | {(variation: string | null) => <span data-testid="variation-key">{variation}</span>} 73 | </OptimizelyExperiment> 74 | ); 75 | 76 | expect(container).toBeDefined(); 77 | }); 78 | 79 | it('isValidElement check works as expected', async () => { 80 | const { container } = render( 81 | <OptimizelyProvider optimizely={optimizelyMock}> 82 | <OptimizelyExperiment experiment="experiment1"> 83 | {(variation: string | null) => ( 84 | <> 85 | <span data-testid="variation-key">{variation}</span> 86 | {null} 87 | {<div />} 88 | </> 89 | )} 90 | </OptimizelyExperiment> 91 | </OptimizelyProvider> 92 | ); 93 | resolver.resolve({ success: true }); 94 | 95 | await waitFor(() => { 96 | const validChildren = container.getElementsByTagName('span'); 97 | expect(validChildren).toHaveLength(1); 98 | }); 99 | }); 100 | 101 | describe('when isServerSide prop is false', () => { 102 | it('should wait client is ready then render result of activate', async () => { 103 | const { container, rerender } = render( 104 | <OptimizelyProvider optimizely={optimizelyMock} timeout={100}> 105 | <OptimizelyExperiment experiment="experiment1"> 106 | {(variation: string | null) => <span data-testid="variation-key">{variation}</span>} 107 | </OptimizelyExperiment> 108 | </OptimizelyProvider> 109 | ); 110 | 111 | expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 100 }); 112 | 113 | // while it's waiting for onReady() 114 | expect(container.innerHTML).toBe(''); 115 | 116 | // Simulate client becoming ready: onReady resolving, firing config update notification 117 | resolver.resolve({ success: true }); 118 | 119 | rerender( 120 | <OptimizelyProvider optimizely={optimizelyMock}> 121 | <OptimizelyExperiment experiment="experiment1"> 122 | {(variation: string | null) => <span data-testid="variation-key">{variation}</span>} 123 | </OptimizelyExperiment> 124 | </OptimizelyProvider> 125 | ); 126 | 127 | await waitFor(() => expect(screen.getByTestId('variation-key')).toHaveTextContent('matchingVariation')); 128 | 129 | expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined); 130 | }); 131 | 132 | it('should allow timeout to be overridden', async () => { 133 | const { container } = render( 134 | <OptimizelyProvider optimizely={optimizelyMock} timeout={100}> 135 | <OptimizelyExperiment experiment="experiment1" timeout={200}> 136 | {(variation: string | null) => <span data-testid="variation-key">{variation}</span>} 137 | </OptimizelyExperiment> 138 | </OptimizelyProvider> 139 | ); 140 | 141 | expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 200 }); 142 | 143 | // while it's waiting for onReady() 144 | expect(container.innerHTML).toBe(''); 145 | 146 | // Simulate client becoming ready; onReady resolving, firing config update notification 147 | resolver.resolve({ success: true }); 148 | 149 | await waitFor(() => expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined)); 150 | }); 151 | 152 | it(`should use the Experiment prop's timeout when there is no timeout passed to <Provider>`, async () => { 153 | const { container } = render( 154 | <OptimizelyProvider optimizely={optimizelyMock}> 155 | <OptimizelyExperiment experiment="experiment1" timeout={200}> 156 | {(variation: string | null) => <span data-testid="variation-key">{variation}</span>} 157 | </OptimizelyExperiment> 158 | </OptimizelyProvider> 159 | ); 160 | 161 | expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 200 }); 162 | 163 | // while it's waiting for onReady() 164 | expect(container.innerHTML).toBe(''); 165 | 166 | // Simulate client becoming ready 167 | resolver.resolve({ success: true }); 168 | 169 | await waitFor(() => expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined)); 170 | }); 171 | 172 | it('should render using <OptimizelyVariation> when the variationKey matches', async () => { 173 | const { container } = render( 174 | <OptimizelyProvider optimizely={optimizelyMock}> 175 | <OptimizelyExperiment experiment="experiment1"> 176 | <OptimizelyVariation variation="otherVariation"> 177 | <span data-testid="variation-key">other variation</span> 178 | </OptimizelyVariation> 179 | <OptimizelyVariation variation="matchingVariation"> 180 | <span data-testid="variation-key">correct variation</span> 181 | </OptimizelyVariation> 182 | <OptimizelyVariation default> 183 | <span data-testid="variation-key">default variation</span> 184 | </OptimizelyVariation> 185 | </OptimizelyExperiment> 186 | </OptimizelyProvider> 187 | ); 188 | 189 | // while it's waiting for onReady() 190 | expect(container.innerHTML).toBe(''); 191 | 192 | // Simulate client becoming ready 193 | resolver.resolve({ success: true }); 194 | 195 | await waitFor(() => expect(screen.getByTestId('variation-key')).toHaveTextContent('correct variation')); 196 | }); 197 | 198 | it('should render using <OptimizelyVariation default> in last position', async () => { 199 | const { container } = render( 200 | <OptimizelyProvider optimizely={optimizelyMock}> 201 | <OptimizelyExperiment experiment="experiment1"> 202 | <OptimizelyVariation variation="otherVariation"> 203 | <span data-testid="variation-key">other variation</span> 204 | </OptimizelyVariation> 205 | 206 | <OptimizelyVariation default> 207 | <span data-testid="variation-key">default variation</span> 208 | </OptimizelyVariation> 209 | </OptimizelyExperiment> 210 | </OptimizelyProvider> 211 | ); 212 | 213 | // while it's waiting for onReady() 214 | expect(container.innerHTML).toBe(''); 215 | 216 | // Simulate client becoming ready 217 | resolver.resolve({ success: true }); 218 | 219 | await waitFor(() => expect(screen.getByTestId('variation-key')).toHaveTextContent('default variation')); 220 | }); 221 | 222 | it('should NOT render using <OptimizelyVariation default> in first position when matching variation', async () => { 223 | const { container } = render( 224 | <OptimizelyProvider optimizely={optimizelyMock}> 225 | <OptimizelyExperiment experiment="experiment1"> 226 | <OptimizelyVariation default> 227 | <span data-testid="variation-key">default variation</span> 228 | </OptimizelyVariation> 229 | <OptimizelyVariation variation="matchingVariation"> 230 | <span data-testid="variation-key">matching variation</span> 231 | </OptimizelyVariation> 232 | </OptimizelyExperiment> 233 | </OptimizelyProvider> 234 | ); 235 | 236 | // while it's waiting for onReady() 237 | expect(container.innerHTML).toBe(''); 238 | 239 | // Simulate client becoming ready 240 | resolver.resolve({ success: true }); 241 | 242 | await waitFor(() => expect(screen.getByTestId('variation-key')).toHaveTextContent('matching variation')); 243 | }); 244 | 245 | it('should render using <OptimizelyVariation default> in first position when NO matching variation', async () => { 246 | const { container } = render( 247 | <OptimizelyProvider optimizely={optimizelyMock}> 248 | <OptimizelyExperiment experiment="experiment1"> 249 | <OptimizelyVariation default> 250 | <span data-testid="variation-key">default variation</span> 251 | </OptimizelyVariation> 252 | <OptimizelyVariation variation="otherVariation"> 253 | <span data-testid="variation-key">other non-matching variation</span> 254 | </OptimizelyVariation> 255 | </OptimizelyExperiment> 256 | </OptimizelyProvider> 257 | ); 258 | 259 | // while it's waiting for onReady() 260 | expect(container.innerHTML).toBe(''); 261 | 262 | // Simulate client becoming ready 263 | resolver.resolve({ success: true }); 264 | 265 | await waitFor(() => expect(screen.getByTestId('variation-key')).toHaveTextContent('default variation')); 266 | }); 267 | 268 | describe('OptimizelyVariation with default & variation props', () => { 269 | it('should render default with NO matching variations ', async () => { 270 | const { container } = render( 271 | <OptimizelyProvider optimizely={optimizelyMock}> 272 | <OptimizelyExperiment experiment="experiment1"> 273 | <OptimizelyVariation default variation="nonMatchingVariation"> 274 | <span data-testid="variation-key">default & non matching variation</span> 275 | </OptimizelyVariation> 276 | <OptimizelyVariation variation="anotherNonMatchingVariation"> 277 | <span data-testid="variation-key">another non-matching variation</span> 278 | </OptimizelyVariation> 279 | </OptimizelyExperiment> 280 | </OptimizelyProvider> 281 | ); 282 | 283 | // while it's waiting for onReady() 284 | expect(container.innerHTML).toBe(''); 285 | 286 | // Simulate client becoming ready 287 | resolver.resolve({ success: true }); 288 | 289 | await waitFor(() => 290 | expect(screen.getByTestId('variation-key')).toHaveTextContent('default & non matching variation') 291 | ); 292 | }); 293 | 294 | it('should render matching variation with a default & non-matching ', async () => { 295 | const { container } = render( 296 | <OptimizelyProvider optimizely={optimizelyMock}> 297 | <OptimizelyExperiment experiment="experiment1"> 298 | <OptimizelyVariation default variation="nonMatchingVariation"> 299 | <span data-testid="variation-key">default & non matching variation</span> 300 | </OptimizelyVariation> 301 | <OptimizelyVariation variation="matchingVariation"> 302 | <span data-testid="variation-key">matching variation</span> 303 | </OptimizelyVariation> 304 | </OptimizelyExperiment> 305 | </OptimizelyProvider> 306 | ); 307 | 308 | // while it's waiting for onReady() 309 | expect(container.innerHTML).toBe(''); 310 | 311 | // Simulate client becoming ready 312 | resolver.resolve({ success: true }); 313 | 314 | await waitFor(() => expect(screen.getByTestId('variation-key')).toHaveTextContent('matching variation')); 315 | }); 316 | }); 317 | 318 | it('should render the last default variation when multiple default props present', async () => { 319 | const { container } = render( 320 | <OptimizelyProvider optimizely={optimizelyMock}> 321 | <OptimizelyExperiment experiment="experiment1"> 322 | <OptimizelyVariation default variation="nonMatchingVariation1"> 323 | <span data-testid="variation-key">non-matching variation 1</span> 324 | </OptimizelyVariation> 325 | <OptimizelyVariation variation="nonMatchingVariation2"> 326 | <span data-testid="variation-key">non-matching variation 2</span> 327 | </OptimizelyVariation> 328 | <OptimizelyVariation default variation="nonMatchingVariation3"> 329 | <span data-testid="variation-key">non-matching variation 3</span> 330 | </OptimizelyVariation> 331 | <OptimizelyVariation variation="nonMatchingVariation4"> 332 | <span data-testid="variation-key">non-matching variation 4</span> 333 | </OptimizelyVariation> 334 | </OptimizelyExperiment> 335 | </OptimizelyProvider> 336 | ); 337 | 338 | // while it's waiting for onReady() 339 | expect(container.innerHTML).toBe(''); 340 | 341 | // Simulate client becoming ready 342 | resolver.resolve({ success: true }); 343 | 344 | await waitFor(() => expect(screen.getByTestId('variation-key')).toHaveTextContent('non-matching variation 3')); 345 | }); 346 | 347 | it('should render an empty string when no default or matching variation is provided', async () => { 348 | const { container } = render( 349 | <OptimizelyProvider optimizely={optimizelyMock}> 350 | <OptimizelyExperiment experiment="experiment1"> 351 | <OptimizelyVariation variation="otherVariation"> 352 | <span data-testid="variation-key">other variation</span> 353 | </OptimizelyVariation> 354 | <OptimizelyVariation variation="otherVariation2"> 355 | <span data-testid="variation-key">other variation2</span> 356 | </OptimizelyVariation> 357 | </OptimizelyExperiment> 358 | </OptimizelyProvider> 359 | ); 360 | 361 | // while it's waiting for onReady() 362 | expect(container.innerHTML).toBe(''); 363 | 364 | // Simulate client becoming ready 365 | resolver.resolve({ success: true }); 366 | 367 | expect(container.innerHTML).toBe(''); 368 | }); 369 | 370 | it('should pass the override props through', async () => { 371 | const { container } = render( 372 | <OptimizelyProvider optimizely={optimizelyMock} timeout={100}> 373 | <OptimizelyExperiment 374 | experiment="experiment1" 375 | overrideUserId="james123" 376 | overrideAttributes={{ betaUser: true }} 377 | > 378 | {(variation: string | null) => <span data-testid="variation-key">{variation}</span>} 379 | </OptimizelyExperiment> 380 | </OptimizelyProvider> 381 | ); 382 | 383 | expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 100 }); 384 | 385 | // while it's waiting for onReady() 386 | expect(container.innerHTML).toBe(''); 387 | 388 | // Simulate client becoming ready 389 | resolver.resolve({ success: true }); 390 | 391 | await waitFor(() => { 392 | expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', 'james123', { betaUser: true }); 393 | expect(screen.getByTestId('variation-key')).toHaveTextContent('matchingVariation'); 394 | }); 395 | }); 396 | 397 | it('should pass the values for clientReady and didTimeout', async () => { 398 | const { container } = render( 399 | <OptimizelyProvider optimizely={optimizelyMock} timeout={100}> 400 | <OptimizelyExperiment experiment="experiment1"> 401 | {(variation: string | null, clientReady?: boolean, didTimeout?: boolean) => ( 402 | <span data-testid="variation-key">{`${variation}|${clientReady}|${didTimeout}`}</span> 403 | )} 404 | </OptimizelyExperiment> 405 | </OptimizelyProvider> 406 | ); 407 | 408 | // while it's waiting for onReady() 409 | expect(container.innerHTML).toBe(''); 410 | 411 | // Simulate client becoming ready 412 | resolver.resolve({ success: true }); 413 | 414 | await waitFor(() => { 415 | expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined); 416 | expect(screen.getByTestId('variation-key')).toHaveTextContent('matchingVariation|true|false'); 417 | }); 418 | }); 419 | 420 | describe('when the onReady() promise return { success: false }', () => { 421 | it('should still render', async () => { 422 | const { container } = render( 423 | <OptimizelyProvider optimizely={optimizelyMock}> 424 | <OptimizelyExperiment experiment="experiment1"> 425 | <OptimizelyVariation variation="otherVariation"> 426 | <span data-testid="variation-key">other variation</span> 427 | </OptimizelyVariation> 428 | <OptimizelyVariation variation="otherVariation2"> 429 | <span data-testid="variation-key">other variation2</span> 430 | </OptimizelyVariation> 431 | </OptimizelyExperiment> 432 | </OptimizelyProvider> 433 | ); 434 | 435 | // while it's waiting for onReady() 436 | expect(container.innerHTML).toBe(''); 437 | 438 | resolver.resolve({ success: false, reason: 'fail' }); 439 | 440 | await waitFor(() => expect(container.innerHTML).toBe('')); 441 | }); 442 | }); 443 | }); 444 | 445 | describe('when autoUpdate prop is true', () => { 446 | it('should re-render when the OPTIMIZELY_CONFIG_UDPATE notification fires', async () => { 447 | const { container } = render( 448 | <OptimizelyProvider optimizely={optimizelyMock} timeout={100}> 449 | <OptimizelyExperiment experiment="experiment1" autoUpdate={true}> 450 | {(variation: string | null) => <span data-testid="variation-key">{variation}</span>} 451 | </OptimizelyExperiment> 452 | </OptimizelyProvider> 453 | ); 454 | expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 100 }); 455 | 456 | // while it's waiting for onReady() 457 | expect(container.innerHTML).toBe(''); 458 | 459 | // Simulate client becoming ready 460 | resolver.resolve({ success: true }); 461 | isReady = true; 462 | 463 | await waitFor(() => { 464 | expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined); 465 | expect(screen.getByTestId('variation-key')).toHaveTextContent('matchingVariation'); 466 | }); 467 | 468 | // capture the OPTIMIZELY_CONFIG_UPDATE function 469 | // change the return value of activate 470 | const mockActivate = optimizelyMock.activate as jest.Mock; 471 | mockActivate.mockImplementationOnce(() => 'newVariation'); 472 | 473 | const updateFn = (optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1]; 474 | updateFn(); 475 | 476 | expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined); 477 | await waitFor(() => expect(screen.getByTestId('variation-key')).toHaveTextContent('newVariation')); 478 | expect(optimizelyMock.activate).toHaveBeenCalledTimes(2); 479 | }); 480 | 481 | it('should re-render when the user changes', async () => { 482 | const { container } = render( 483 | <OptimizelyProvider optimizely={optimizelyMock} timeout={100}> 484 | <OptimizelyExperiment experiment="experiment1" autoUpdate={true}> 485 | {(variation: string | null) => <span data-testid="variation-key">{variation}</span>} 486 | </OptimizelyExperiment> 487 | </OptimizelyProvider> 488 | ); 489 | expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 100 }); 490 | 491 | // while it's waiting for onReady() 492 | expect(container.innerHTML).toBe(''); 493 | // Simulate client becoming ready 494 | resolver.resolve({ success: true }); 495 | isReady = true; 496 | 497 | await waitFor(() => { 498 | expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined); 499 | expect(screen.getByTestId('variation-key')).toHaveTextContent('matchingVariation'); 500 | }); 501 | 502 | // capture the onUserUpdate function 503 | const updateFn = (optimizelyMock.onUserUpdate as jest.Mock).mock.calls[0][0]; 504 | const mockActivate = optimizelyMock.activate as jest.Mock; 505 | mockActivate.mockImplementationOnce(() => 'newVariation'); 506 | updateFn(); 507 | 508 | expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined); 509 | await waitFor(() => expect(screen.getByTestId('variation-key')).toHaveTextContent('newVariation')); 510 | expect(optimizelyMock.activate).toHaveBeenCalledTimes(2); 511 | }); 512 | }); 513 | 514 | describe('when the isServerSide prop is true', () => { 515 | it('should immediately render the result of the experiment without waiting', async () => { 516 | render( 517 | <OptimizelyProvider optimizely={optimizelyMock} timeout={100} isServerSide={true}> 518 | <OptimizelyExperiment experiment="experiment1"> 519 | {(variation: string | null) => <span data-testid="variation-key">{variation}</span>} 520 | </OptimizelyExperiment> 521 | </OptimizelyProvider> 522 | ); 523 | 524 | await waitFor(() => expect(screen.getByTestId('variation-key')).toHaveTextContent(variationKey)); 525 | }); 526 | 527 | it('should render using <OptimizelyVariation> when the variationKey matches', async () => { 528 | render( 529 | <OptimizelyProvider optimizely={optimizelyMock} isServerSide={true}> 530 | <OptimizelyExperiment experiment="experiment1"> 531 | <OptimizelyVariation variation="otherVariation"> 532 | <span data-testid="variation-key">other variation</span> 533 | </OptimizelyVariation> 534 | <OptimizelyVariation variation="matchingVariation"> 535 | <span data-testid="variation-key">correct variation</span> 536 | </OptimizelyVariation> 537 | <OptimizelyVariation default> 538 | <span data-testid="variation-key">default variation</span> 539 | </OptimizelyVariation> 540 | </OptimizelyExperiment> 541 | </OptimizelyProvider> 542 | ); 543 | 544 | await waitFor(() => expect(screen.getByTestId('variation-key')).toHaveTextContent('correct variation')); 545 | }); 546 | }); 547 | }); 548 | -------------------------------------------------------------------------------- /src/Experiment.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018-2019, 2023-2024, Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import * as React from 'react'; 17 | 18 | import { UserAttributes } from '@optimizely/optimizely-sdk'; 19 | 20 | import { useExperiment } from './hooks'; 21 | import { VariationProps } from './Variation'; 22 | 23 | export type ChildrenRenderFunction = ( 24 | variation: string | null, 25 | clientReady?: boolean, 26 | didTimeout?: boolean 27 | ) => React.ReactNode; 28 | 29 | export interface ExperimentProps { 30 | // TODO add support for overrideUserId 31 | experiment: string; 32 | autoUpdate?: boolean; 33 | timeout?: number; 34 | overrideUserId?: string; 35 | overrideAttributes?: UserAttributes; 36 | children: React.ReactNode | ChildrenRenderFunction; 37 | } 38 | 39 | const Experiment: React.FunctionComponent<ExperimentProps> = (props) => { 40 | const { experiment, autoUpdate, timeout, overrideUserId, overrideAttributes, children } = props; 41 | const [variation, clientReady, didTimeout] = useExperiment( 42 | experiment, 43 | { timeout, autoUpdate }, 44 | { overrideUserId, overrideAttributes } 45 | ); 46 | 47 | if (!clientReady && !didTimeout) { 48 | // Only block rendering while were waiting for the client within the allowed timeout. 49 | return null; 50 | } 51 | 52 | if (children != null && typeof children === 'function') { 53 | // Wrap the return value here in a Fragment to please the HOC's expected React.ComponentType 54 | // See https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18051 55 | return <>{(children as ChildrenRenderFunction)(variation, clientReady, didTimeout)}</>; 56 | } 57 | 58 | let defaultMatch: React.ReactElement<VariationProps> | null = null; 59 | let variationMatch: React.ReactElement<VariationProps> | null = null; 60 | 61 | // We use React.Children.forEach instead of React.Children.toArray().find() 62 | // here because toArray adds keys to all child elements and we do not want 63 | // to trigger an unmount/remount 64 | React.Children.forEach(children, (child: React.ReactElement<VariationProps>) => { 65 | if (!React.isValidElement(child)) { 66 | return; 67 | } 68 | 69 | if (child.props.variation) { 70 | if (variation === child.props.variation) { 71 | variationMatch = child; 72 | } 73 | } 74 | // Last child with default prop wins 75 | if (child.props.default) { 76 | defaultMatch = child; 77 | } 78 | }); 79 | 80 | let match: React.ReactElement<VariationProps> | null = null; 81 | if (variationMatch) { 82 | match = variationMatch; 83 | } else if (defaultMatch) { 84 | match = defaultMatch; 85 | } 86 | return match; 87 | }; 88 | 89 | export const OptimizelyExperiment = Experiment; 90 | -------------------------------------------------------------------------------- /src/Feature.spec.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018-2019, 2023-2024 Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /// <reference types="jest" /> 18 | 19 | import * as React from 'react'; 20 | import { act } from 'react-dom/test-utils'; 21 | import { render, screen, waitFor } from '@testing-library/react'; 22 | import '@testing-library/jest-dom'; 23 | 24 | import { OptimizelyProvider } from './Provider'; 25 | import { NotReadyReason, ReactSDKClient, VariableValuesObject } from './client'; 26 | import { OptimizelyFeature } from './Feature'; 27 | 28 | describe('<OptimizelyFeature>', () => { 29 | let resolver: any; 30 | let optimizelyMock: ReactSDKClient; 31 | const isEnabledMock = true; 32 | let isReady: boolean; 33 | const featureVariables = { 34 | foo: 'bar', 35 | }; 36 | 37 | beforeEach(() => { 38 | isReady = false; 39 | const onReadyPromise = new Promise((resolve, reject) => { 40 | resolver = { 41 | reject, 42 | resolve, 43 | }; 44 | }); 45 | 46 | optimizelyMock = { 47 | onReady: jest.fn().mockImplementation(() => onReadyPromise), 48 | getFeatureVariables: jest.fn().mockImplementation(() => featureVariables), 49 | isFeatureEnabled: jest.fn().mockImplementation(() => isEnabledMock), 50 | onUserUpdate: jest.fn().mockImplementation(() => () => {}), 51 | getVuid: jest.fn().mockImplementation(() => 'vuid_95bf72cebc774dfd8e8e580a5a1'), 52 | notificationCenter: { 53 | addNotificationListener: jest.fn().mockImplementation(() => {}), 54 | removeNotificationListener: jest.fn().mockImplementation(() => {}), 55 | }, 56 | user: { 57 | id: 'testuser', 58 | attributes: {}, 59 | }, 60 | isReady: jest.fn().mockImplementation(() => isReady), 61 | getIsReadyPromiseFulfilled: () => true, 62 | getIsUsingSdkKey: () => true, 63 | } as unknown as ReactSDKClient; 64 | }); 65 | 66 | it('does not throw an error when not rendered in the context of an OptimizelyProvider', () => { 67 | expect(() => { 68 | render(<OptimizelyFeature feature="feature1">{(isEnabled, variables) => isEnabled}</OptimizelyFeature>); 69 | }).toBeDefined(); 70 | }); 71 | 72 | describe('when the isServerSide prop is false', () => { 73 | it('should wait until onReady() is resolved then render result of isFeatureEnabled and getFeatureVariables', async () => { 74 | const { container } = render( 75 | <OptimizelyProvider optimizely={optimizelyMock}> 76 | <OptimizelyFeature feature="feature1"> 77 | {(isEnabled: boolean, variables: VariableValuesObject) => ( 78 | <span data-testid="result">{`${isEnabled ? 'true' : 'false'}|${variables.foo}`}</span> 79 | )} 80 | </OptimizelyFeature> 81 | </OptimizelyProvider> 82 | ); 83 | 84 | expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: undefined }); 85 | 86 | // while it's waiting for onReady() 87 | expect(container.innerHTML).toBe(''); 88 | 89 | // Simulate client becoming ready 90 | resolver.resolve({ success: true }); 91 | 92 | await optimizelyMock.onReady(); 93 | 94 | expect(optimizelyMock.isFeatureEnabled).toHaveBeenCalledWith('feature1', undefined, undefined); 95 | expect(optimizelyMock.getFeatureVariables).toHaveBeenCalledWith('feature1', undefined, undefined); 96 | await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('true|bar')); 97 | }); 98 | 99 | it('should respect the timeout provided in <OptimizelyProvider>', async () => { 100 | const { container } = render( 101 | <OptimizelyProvider optimizely={optimizelyMock} timeout={200}> 102 | <OptimizelyFeature feature="feature1"> 103 | {(isEnabled: boolean, variables: VariableValuesObject) => ( 104 | <span data-testid="result">{`${isEnabled ? 'true' : 'false'}|${variables.foo}`}</span> 105 | )} 106 | </OptimizelyFeature> 107 | </OptimizelyProvider> 108 | ); 109 | 110 | expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 200 }); 111 | 112 | // while it's waiting for onReady() 113 | expect(container.innerHTML).toBe(''); 114 | 115 | // Simulate client becoming ready 116 | resolver.resolve({ success: true }); 117 | 118 | await optimizelyMock.onReady(); 119 | 120 | expect(optimizelyMock.isFeatureEnabled).toHaveBeenCalledWith('feature1', undefined, undefined); 121 | expect(optimizelyMock.getFeatureVariables).toHaveBeenCalledWith('feature1', undefined, undefined); 122 | await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('true|bar')); 123 | }); 124 | 125 | it('should pass the values for clientReady and didTimeout', async () => { 126 | const { container } = render( 127 | <OptimizelyProvider optimizely={optimizelyMock} timeout={200}> 128 | <OptimizelyFeature feature="feature1"> 129 | {(isEnabled: boolean, variables: VariableValuesObject, clientReady: boolean, didTimeout: boolean) => ( 130 | <span data-testid="result">{`${isEnabled ? 'true' : 'false'}|${ 131 | variables.foo 132 | }|${clientReady}|${didTimeout}`}</span> 133 | )} 134 | </OptimizelyFeature> 135 | </OptimizelyProvider> 136 | ); 137 | 138 | // while it's waiting for onReady() 139 | expect(container.innerHTML).toBe(''); 140 | 141 | // Simulate client becoming ready 142 | resolver.resolve({ success: true }); 143 | 144 | await optimizelyMock.onReady(); 145 | 146 | expect(optimizelyMock.isFeatureEnabled).toHaveBeenCalledWith('feature1', undefined, undefined); 147 | expect(optimizelyMock.getFeatureVariables).toHaveBeenCalledWith('feature1', undefined, undefined); 148 | await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('true|bar|true|false')); 149 | }); 150 | 151 | it('should respect a locally passed timeout prop', async () => { 152 | const { container } = render( 153 | <OptimizelyProvider optimizely={optimizelyMock} timeout={200}> 154 | <OptimizelyFeature feature="feature1" timeout={100}> 155 | {(isEnabled: boolean, variables: VariableValuesObject) => ( 156 | <span data-testid="result">{`${isEnabled ? 'true' : 'false'}|${variables.foo}`}</span> 157 | )} 158 | </OptimizelyFeature> 159 | </OptimizelyProvider> 160 | ); 161 | 162 | expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 100 }); 163 | 164 | // while it's waiting for onReady() 165 | expect(container.innerHTML).toBe(''); 166 | 167 | // Simulate client becoming ready 168 | resolver.resolve({ success: true }); 169 | 170 | await optimizelyMock.onReady(); 171 | 172 | expect(optimizelyMock.isFeatureEnabled).toHaveBeenCalledWith('feature1', undefined, undefined); 173 | expect(optimizelyMock.getFeatureVariables).toHaveBeenCalledWith('feature1', undefined, undefined); 174 | await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('true|bar')); 175 | }); 176 | 177 | it('should pass the override props through', async () => { 178 | const { container } = render( 179 | <OptimizelyProvider optimizely={optimizelyMock} timeout={100}> 180 | <OptimizelyFeature feature="feature1" overrideUserId="james123" overrideAttributes={{ betaUser: true }}> 181 | {(isEnabled: boolean, variables: VariableValuesObject) => ( 182 | <span data-testid="result">{`${isEnabled ? 'true' : 'false'}|${variables.foo}`}</span> 183 | )} 184 | </OptimizelyFeature> 185 | </OptimizelyProvider> 186 | ); 187 | 188 | // while it's waiting for onReady() 189 | expect(container.innerHTML).toBe(''); 190 | // Simulate client becoming ready 191 | resolver.resolve({ success: true }); 192 | await optimizelyMock.onReady(); 193 | 194 | expect(optimizelyMock.isFeatureEnabled).toHaveBeenCalledWith('feature1', 'james123', { betaUser: true }); 195 | expect(optimizelyMock.getFeatureVariables).toHaveBeenCalledWith('feature1', 'james123', { betaUser: true }); 196 | await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('true|bar')); 197 | }); 198 | 199 | describe(`when the "autoUpdate" prop is true`, () => { 200 | it('should update when the OPTIMIZELY_CONFIG_UPDATE handler is called', async () => { 201 | const { container } = render( 202 | <OptimizelyProvider optimizely={optimizelyMock} timeout={200}> 203 | <OptimizelyFeature feature="feature1" autoUpdate={true}> 204 | {(isEnabled: boolean, variables: VariableValuesObject) => ( 205 | <span data-testid="result">{`${isEnabled ? 'true' : 'false'}|${variables.foo}`}</span> 206 | )} 207 | </OptimizelyFeature> 208 | </OptimizelyProvider> 209 | ); 210 | 211 | expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 200 }); 212 | 213 | // while it's waiting for onReady() 214 | expect(container.innerHTML).toBe(''); 215 | 216 | // Simulate client becoming ready 217 | resolver.resolve({ success: true }); 218 | 219 | isReady = true; 220 | await act(async () => await optimizelyMock.onReady()); 221 | 222 | expect(optimizelyMock.isFeatureEnabled).toHaveBeenCalledWith('feature1', undefined, undefined); 223 | expect(optimizelyMock.getFeatureVariables).toHaveBeenCalledWith('feature1', undefined, undefined); 224 | await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('true|bar')); 225 | 226 | // change the return value of activate 227 | const mockIFE = optimizelyMock.isFeatureEnabled as jest.Mock; 228 | mockIFE.mockImplementationOnce(() => false); 229 | const mockGFV = optimizelyMock.getFeatureVariables as jest.Mock; 230 | mockGFV.mockImplementationOnce(() => ({ 231 | foo: 'baz', 232 | })); 233 | 234 | const updateFn = (optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1]; 235 | act(updateFn); 236 | expect(optimizelyMock.isFeatureEnabled).toHaveBeenCalledTimes(2); 237 | expect(optimizelyMock.getFeatureVariables).toHaveBeenCalledTimes(2); 238 | await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|baz')); 239 | }); 240 | 241 | it('should update when the user changes', async () => { 242 | const { container } = render( 243 | <OptimizelyProvider optimizely={optimizelyMock} timeout={200}> 244 | <OptimizelyFeature feature="feature1" autoUpdate={true}> 245 | {(isEnabled: boolean, variables: VariableValuesObject) => ( 246 | <span data-testid="result">{`${isEnabled ? 'true' : 'false'}|${variables.foo}`}</span> 247 | )} 248 | </OptimizelyFeature> 249 | </OptimizelyProvider> 250 | ); 251 | 252 | expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 200 }); 253 | 254 | // while it's waiting for onReady() 255 | expect(container.innerHTML).toBe(''); 256 | 257 | // Simulate client becoming ready 258 | resolver.resolve({ success: true }); 259 | 260 | isReady = true; 261 | await act(async () => await optimizelyMock.onReady()); 262 | 263 | expect(optimizelyMock.isFeatureEnabled).toHaveBeenCalledWith('feature1', undefined, undefined); 264 | expect(optimizelyMock.getFeatureVariables).toHaveBeenCalledWith('feature1', undefined, undefined); 265 | await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('true|bar')); 266 | 267 | const updateFn = (optimizelyMock.onUserUpdate as jest.Mock).mock.calls[0][0]; 268 | const mockIFE = optimizelyMock.isFeatureEnabled as jest.Mock; 269 | mockIFE.mockImplementationOnce(() => false); 270 | const mockGFV = optimizelyMock.getFeatureVariables as jest.Mock; 271 | mockGFV.mockImplementationOnce(() => ({ 272 | foo: 'baz', 273 | })); 274 | 275 | act(updateFn); 276 | 277 | expect(optimizelyMock.isFeatureEnabled).toHaveBeenCalledTimes(2); 278 | expect(optimizelyMock.getFeatureVariables).toHaveBeenCalledTimes(2); 279 | 280 | await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|baz')); 281 | }); 282 | }); 283 | 284 | describe('when the onReady() promise returns { success: false }', () => { 285 | it('should still render', async () => { 286 | const { container } = render( 287 | <OptimizelyProvider optimizely={optimizelyMock} timeout={200}> 288 | <OptimizelyFeature feature="feature1"> 289 | {(isEnabled: boolean, variables: VariableValuesObject) => ( 290 | <span data-testid="result">{`${isEnabled ? 'true' : 'false'}|${variables.foo}`}</span> 291 | )} 292 | </OptimizelyFeature> 293 | </OptimizelyProvider> 294 | ); 295 | 296 | expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 200 }); 297 | 298 | // while it's waiting for onReady() 299 | expect(container.innerHTML).toBe(''); 300 | resolver.resolve({ success: false, reason: NotReadyReason.TIMEOUT, dataReadyPromise: Promise.resolve() }); 301 | 302 | // Simulate config update notification firing after datafile fetched 303 | await optimizelyMock.onReady().then((res) => res.dataReadyPromise); 304 | 305 | expect(optimizelyMock.isFeatureEnabled).toHaveBeenCalledWith('feature1', undefined, undefined); 306 | expect(optimizelyMock.getFeatureVariables).toHaveBeenCalledWith('feature1', undefined, undefined); 307 | await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('true|bar')); 308 | }); 309 | }); 310 | }); 311 | 312 | describe('when the isServerSide prop is true', () => { 313 | it('should immediately render the result of isFeatureEnabled and getFeatureVariables', async () => { 314 | const { container } = render( 315 | <OptimizelyProvider optimizely={optimizelyMock} isServerSide={true}> 316 | <OptimizelyFeature feature="feature1"> 317 | {(isEnabled: boolean, variables: VariableValuesObject) => ( 318 | <span data-testid="result">{`${isEnabled ? 'true' : 'false'}|${variables.foo}`}</span> 319 | )} 320 | </OptimizelyFeature> 321 | </OptimizelyProvider> 322 | ); 323 | await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('true|bar')); 324 | }); 325 | }); 326 | }); 327 | -------------------------------------------------------------------------------- /src/Feature.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018-2019, Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import * as React from 'react'; 17 | import { UserAttributes } from '@optimizely/optimizely-sdk'; 18 | 19 | import { VariableValuesObject } from './client'; 20 | import { useFeature } from './hooks'; 21 | 22 | export type ChildrenRenderFunction = ( 23 | isEnabled: boolean, 24 | variables: VariableValuesObject, 25 | clientReady: boolean, 26 | didTimeout: boolean 27 | ) => React.ReactNode; 28 | 29 | export interface FeatureProps { 30 | feature: string; 31 | timeout?: number; 32 | autoUpdate?: boolean; 33 | overrideUserId?: string; 34 | overrideAttributes?: UserAttributes; 35 | children: ChildrenRenderFunction; 36 | } 37 | 38 | const FeatureComponent: React.FunctionComponent<FeatureProps> = (props) => { 39 | const { feature, timeout, autoUpdate, children, overrideUserId, overrideAttributes } = props; 40 | const [isEnabled, variables, clientReady, didTimeout] = useFeature( 41 | feature, 42 | { timeout, autoUpdate }, 43 | { overrideUserId, overrideAttributes } 44 | ); 45 | 46 | if (!clientReady && !didTimeout) { 47 | // Only block rendering while were waiting for the client within the allowed timeout. 48 | return null; 49 | } 50 | 51 | // Wrap the return value here in a Fragment to please the HOC's expected React.ComponentType 52 | // See https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18051 53 | return <>{children(isEnabled, variables, clientReady, didTimeout)}</>; 54 | }; 55 | 56 | export const OptimizelyFeature = FeatureComponent; 57 | -------------------------------------------------------------------------------- /src/Provider.spec.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2024 Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /// <reference types="jest" /> 18 | 19 | import React from 'react'; 20 | import { render, waitFor } from '@testing-library/react'; 21 | import { OptimizelyProvider } from './Provider'; 22 | import { DefaultUser, ReactSDKClient } from './client'; 23 | import { getLogger } from '@optimizely/optimizely-sdk'; 24 | 25 | jest.mock('@optimizely/optimizely-sdk', () => { 26 | const originalModule = jest.requireActual('@optimizely/optimizely-sdk'); 27 | return { 28 | ...originalModule, 29 | getLogger: jest.fn().mockReturnValue({ 30 | error: jest.fn(), 31 | warn: jest.fn(), 32 | info: jest.fn(), 33 | debug: jest.fn(), 34 | }), 35 | }; 36 | }); 37 | 38 | const logger = getLogger('<OptimizelyProvider>'); 39 | 40 | describe('OptimizelyProvider', () => { 41 | let mockReactClient: ReactSDKClient; 42 | const user1 = { 43 | id: 'user1', 44 | attributes: { attr1: 'value1' }, 45 | }; 46 | beforeEach(() => { 47 | mockReactClient = { 48 | user: user1, 49 | setUser: jest.fn().mockResolvedValue(undefined), 50 | } as unknown as ReactSDKClient; 51 | }); 52 | 53 | it('should log error if optimizely is not provided', async () => { 54 | // @ts-ignore 55 | render(<OptimizelyProvider optimizely={null} />); 56 | expect(logger.error).toHaveBeenCalled(); 57 | }); 58 | 59 | it('should resolve user promise and set user in optimizely', async () => { 60 | render(<OptimizelyProvider optimizely={mockReactClient} user={Promise.resolve(user1)} />); 61 | await waitFor(() => expect(mockReactClient.setUser).toHaveBeenCalledWith(user1)); 62 | }); 63 | 64 | it('should render successfully with user provided', () => { 65 | render(<OptimizelyProvider optimizely={mockReactClient} user={user1} />); 66 | 67 | expect(mockReactClient.setUser).toHaveBeenCalledWith(user1); 68 | }); 69 | 70 | it('should throw error, if setUser throws error', () => { 71 | mockReactClient.setUser = jest.fn().mockRejectedValue(new Error('error')); 72 | render(<OptimizelyProvider optimizely={mockReactClient} user={user1} />); 73 | expect(logger.error).toHaveBeenCalled(); 74 | }); 75 | 76 | it('should render successfully with userId provided', () => { 77 | render(<OptimizelyProvider optimizely={mockReactClient} userId={user1.id} />); 78 | 79 | expect(mockReactClient.setUser).toHaveBeenCalledWith({ 80 | id: user1.id, 81 | attributes: {}, 82 | }); 83 | }); 84 | 85 | it('should render successfully without user or userId provided', () => { 86 | // @ts-ignore 87 | mockReactClient.user = undefined; 88 | render(<OptimizelyProvider optimizely={mockReactClient} />); 89 | 90 | expect(mockReactClient.setUser).toHaveBeenCalledWith(DefaultUser); 91 | }); 92 | 93 | it('should render successfully with user id & attributes provided', () => { 94 | render(<OptimizelyProvider optimizely={mockReactClient} user={user1} />); 95 | 96 | expect(mockReactClient.setUser).toHaveBeenCalledWith(user1); 97 | }); 98 | 99 | it('should succeed just userAttributes provided', () => { 100 | // @ts-ignore 101 | mockReactClient.user = undefined; 102 | render(<OptimizelyProvider optimizely={mockReactClient} userAttributes={{ attr1: 'value1' }} />); 103 | 104 | expect(mockReactClient.setUser).toHaveBeenCalledWith({ 105 | id: DefaultUser.id, 106 | attributes: { attr1: 'value1' }, 107 | }); 108 | }); 109 | 110 | it('should succeed with the initial user available in client', () => { 111 | render(<OptimizelyProvider optimizely={mockReactClient} />); 112 | 113 | expect(mockReactClient.setUser).toHaveBeenCalledWith(user1); 114 | }); 115 | 116 | it('should succeed with the initial user id and newly passed attributes', () => { 117 | render(<OptimizelyProvider optimizely={mockReactClient} userAttributes={{ attr1: 'value2' }} />); 118 | 119 | expect(mockReactClient.setUser).toHaveBeenCalledWith({ 120 | id: user1.id, 121 | attributes: { attr1: 'value2' }, 122 | }); 123 | }); 124 | 125 | it('should not update when isServerSide is true', () => { 126 | // Initial render 127 | const { rerender } = render(<OptimizelyProvider optimizely={mockReactClient} isServerSide={true} user={user1} />); 128 | 129 | // Reset mock to clear the initial constructor call 130 | (mockReactClient.setUser as jest.Mock).mockClear(); 131 | 132 | // Re-render with same `isServerSide` value 133 | rerender(<OptimizelyProvider optimizely={mockReactClient} isServerSide={true} user={user1} />); 134 | 135 | expect(mockReactClient.setUser).not.toHaveBeenCalled(); 136 | }); 137 | 138 | it('should set user if optimizely.user.id is not set', () => { 139 | mockReactClient.user = { id: '', attributes: {} }; 140 | const { rerender } = render(<OptimizelyProvider optimizely={mockReactClient} />); 141 | 142 | // Change props to trigger componentDidUpdate 143 | rerender(<OptimizelyProvider optimizely={mockReactClient} user={user1} />); 144 | 145 | expect(mockReactClient.setUser).toHaveBeenCalledWith(user1); 146 | }); 147 | 148 | it('should update user if users are not equal', () => { 149 | const user2 = { id: 'user-2', attributes: {} }; 150 | 151 | const { rerender } = render(<OptimizelyProvider optimizely={mockReactClient} user={user1} />); 152 | 153 | // Change props to a different user to trigger componentDidUpdate 154 | rerender(<OptimizelyProvider optimizely={mockReactClient} user={user2} />); 155 | 156 | expect(mockReactClient.setUser).toHaveBeenCalledWith(user2); 157 | }); 158 | 159 | it('should not update user if users are equal', () => { 160 | const { rerender } = render(<OptimizelyProvider optimizely={mockReactClient} user={user1} />); 161 | // Reset mock to clear the initial constructor call 162 | (mockReactClient.setUser as jest.Mock).mockClear(); 163 | // Change props with the same user to trigger componentDidUpdate 164 | rerender(<OptimizelyProvider optimizely={mockReactClient} user={user1} />); 165 | 166 | expect(mockReactClient.setUser).not.toHaveBeenCalled(); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /src/Provider.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022-2024, Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as React from 'react'; 18 | import { UserAttributes } from '@optimizely/optimizely-sdk'; 19 | import { getLogger } from '@optimizely/optimizely-sdk'; 20 | import { OptimizelyContextProvider } from './Context'; 21 | import { ReactSDKClient, DefaultUser } from './client'; 22 | import { areUsersEqual, UserInfo } from './utils'; 23 | 24 | const logger = getLogger('<OptimizelyProvider>'); 25 | 26 | interface OptimizelyProviderProps { 27 | optimizely: ReactSDKClient; 28 | timeout?: number; 29 | isServerSide?: boolean; 30 | user?: Promise<UserInfo> | UserInfo; 31 | userId?: string; 32 | userAttributes?: UserAttributes; 33 | children?: React.ReactNode; 34 | } 35 | 36 | interface OptimizelyProviderState { 37 | userId: string; 38 | attributes: { [key: string]: string } | undefined; 39 | } 40 | 41 | export class OptimizelyProvider extends React.Component<OptimizelyProviderProps, OptimizelyProviderState> { 42 | constructor(props: OptimizelyProviderProps) { 43 | super(props); 44 | 45 | this.setUserInOptimizely(); 46 | } 47 | 48 | async setUserInOptimizely(): Promise<void> { 49 | const { optimizely, userId, userAttributes, user } = this.props; 50 | 51 | if (!optimizely) { 52 | logger.error('OptimizelyProvider must be passed an instance of the Optimizely SDK client'); 53 | return; 54 | } 55 | 56 | let finalUser: UserInfo | null = null; 57 | 58 | if (user) { 59 | if ('then' in user) { 60 | user.then((res: UserInfo) => { 61 | optimizely.setUser(res); 62 | }); 63 | } else { 64 | finalUser = { 65 | id: user.id, 66 | attributes: user.attributes || {}, 67 | }; 68 | } 69 | } else if (userId) { 70 | finalUser = { 71 | id: userId, 72 | attributes: userAttributes || {}, 73 | }; 74 | // deprecation warning 75 | logger.warn('Passing userId and userAttributes as props is deprecated, please switch to using `user` prop'); 76 | } else if (optimizely.user) { 77 | const { id, attributes } = optimizely.user; 78 | finalUser = { 79 | id, 80 | attributes: userAttributes || attributes || {}, 81 | }; 82 | } else { 83 | finalUser = { 84 | id: DefaultUser.id, 85 | attributes: userAttributes || DefaultUser.attributes, 86 | }; 87 | } 88 | 89 | // if user is a promise, setUser occurs in the then block above 90 | if (finalUser) { 91 | try { 92 | await optimizely.setUser(finalUser); 93 | } catch { 94 | logger.error('Error while trying to set user.'); 95 | } 96 | } 97 | } 98 | 99 | componentDidUpdate(prevProps: OptimizelyProviderProps): void { 100 | if (prevProps.isServerSide) { 101 | // dont react to updates on server 102 | return; 103 | } 104 | const { optimizely } = this.props; 105 | if (this.props.user && 'id' in this.props.user) { 106 | if (!optimizely.user.id) { 107 | // no user is set in optimizely, update 108 | optimizely.setUser(this.props.user); 109 | } else if ( 110 | // if the users aren't equal update 111 | !areUsersEqual( 112 | { 113 | id: optimizely.user.id, 114 | attributes: optimizely.user.attributes || {}, 115 | }, 116 | { 117 | id: this.props.user.id, 118 | // TODO see if we can use computeDerivedStateFromProps 119 | attributes: this.props.user.attributes || {}, 120 | } 121 | ) 122 | ) { 123 | optimizely.setUser(this.props.user); 124 | } 125 | } 126 | } 127 | 128 | render(): JSX.Element { 129 | const { optimizely, children, timeout } = this.props; 130 | const isServerSide = !!this.props.isServerSide; 131 | const value = { 132 | optimizely, 133 | isServerSide, 134 | timeout, 135 | }; 136 | 137 | return <OptimizelyContextProvider value={value}>{children}</OptimizelyContextProvider>; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Variation.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018-2019, Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import * as React from 'react'; 17 | 18 | export type VariationProps = { 19 | variation?: string; 20 | default?: any; 21 | children?: React.ReactNode; 22 | }; 23 | 24 | // Wrap the return value here in a Fragment to please the HOC's expected React.ComponentType 25 | // See https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18051 26 | const Variation: React.FunctionComponent<VariationProps> = ({ children }) => <>{children}</>; 27 | 28 | export const OptimizelyVariation = Variation; 29 | -------------------------------------------------------------------------------- /src/autoUpdate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020, 2023 Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { enums } from '@optimizely/optimizely-sdk'; 17 | import { ReactSDKClient } from './client'; 18 | import { LoggerFacade } from '@optimizely/optimizely-sdk/dist/modules/logging'; 19 | 20 | interface AutoUpdate { 21 | ( 22 | optimizely: ReactSDKClient, 23 | type: 'Feature' | 'Experiment', 24 | value: string, 25 | logger: LoggerFacade, 26 | callback: () => void 27 | ): () => void; 28 | } 29 | 30 | /** 31 | * Utility to setup listeners for changes to the datafile or user attributes and invoke the provided callback. 32 | * Returns an unListen function 33 | */ 34 | export const setupAutoUpdateListeners: AutoUpdate = (optimizely, type, value, logger, callback) => { 35 | const loggerSuffix = `re-evaluating ${type}="${value}" for user="${optimizely.user.id}"`; 36 | const optimizelyNotificationId = optimizely.notificationCenter.addNotificationListener( 37 | enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, 38 | () => { 39 | logger.info(`${enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE}, ${loggerSuffix}`); 40 | callback(); 41 | } 42 | ); 43 | const unregisterConfigUpdateListener: () => void = () => 44 | optimizely.notificationCenter.removeNotificationListener(optimizelyNotificationId); 45 | 46 | const unregisterUserListener = optimizely.onUserUpdate(() => { 47 | logger.info(`User update, ${loggerSuffix}`); 48 | callback(); 49 | }); 50 | 51 | return (): void => { 52 | unregisterConfigUpdateListener(); 53 | unregisterUserListener(); 54 | }; 55 | }; 56 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018-2019, 2022-2024, Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { useCallback, useContext, useEffect, useState, useRef, useMemo } from 'react'; 18 | 19 | import { UserAttributes, OptimizelyDecideOption, getLogger } from '@optimizely/optimizely-sdk'; 20 | 21 | import { setupAutoUpdateListeners } from './autoUpdate'; 22 | import { ReactSDKClient, VariableValuesObject, OnReadyResult, NotReadyReason } from './client'; 23 | import { notifier } from './notifier'; 24 | import { OptimizelyContext } from './Context'; 25 | import { areAttributesEqual, OptimizelyDecision, createFailedDecision } from './utils'; 26 | 27 | export const hooksLogger = getLogger('ReactSDK'); 28 | const optimizelyPropError = "The 'optimizely' prop must be supplied via a parent <OptimizelyProvider>"; 29 | 30 | enum HookType { 31 | EXPERIMENT = 'Experiment', 32 | FEATURE = 'Feature', 33 | } 34 | 35 | type HookOptions = { 36 | autoUpdate?: boolean; 37 | timeout?: number; 38 | }; 39 | 40 | type DecideHooksOptions = HookOptions & { decideOptions?: OptimizelyDecideOption[] }; 41 | 42 | type HookOverrides = { 43 | overrideUserId?: string; 44 | overrideAttributes?: UserAttributes; 45 | }; 46 | 47 | type ClientReady = boolean; 48 | 49 | type DidTimeout = boolean; 50 | 51 | interface InitializationState { 52 | clientReady: ClientReady; 53 | didTimeout: DidTimeout; 54 | } 55 | 56 | // TODO - Get these from the core SDK once it's typed 57 | interface ExperimentDecisionValues { 58 | variation: string | null; 59 | } 60 | 61 | // TODO - Get these from the core SDK once it's typed 62 | interface FeatureDecisionValues { 63 | isEnabled: boolean; 64 | variables: VariableValuesObject; 65 | } 66 | 67 | interface UseExperiment { 68 | ( 69 | experimentKey: string, 70 | options?: HookOptions, 71 | overrides?: HookOverrides 72 | ): [ExperimentDecisionValues['variation'], ClientReady, DidTimeout]; 73 | } 74 | 75 | interface UseFeature { 76 | ( 77 | featureKey: string, 78 | options?: HookOptions, 79 | overrides?: HookOverrides 80 | ): [FeatureDecisionValues['isEnabled'], FeatureDecisionValues['variables'], ClientReady, DidTimeout]; 81 | } 82 | 83 | interface UseDecision { 84 | ( 85 | featureKey: string, 86 | options?: DecideHooksOptions, 87 | overrides?: HookOverrides 88 | ): [OptimizelyDecision, ClientReady, DidTimeout]; 89 | } 90 | 91 | interface UseTrackEvent { 92 | (): [(...args: Parameters<ReactSDKClient['track']>) => void, boolean, boolean]; 93 | } 94 | 95 | interface DecisionInputs { 96 | entityKey: string; 97 | overrideUserId?: string; 98 | overrideAttributes?: UserAttributes; 99 | } 100 | 101 | /** 102 | * Equality check applied to decision inputs passed into hooks (experiment/feature keys, override user IDs, and override user attributes). 103 | * Used to determine when we need to recompute a decision because different inputs were passed into a hook. 104 | * @param {DecisionInputs} oldDecisionInputs 105 | * @param {DecisionInput} newDecisionInputs 106 | * @returns boolean 107 | */ 108 | function areDecisionInputsEqual(oldDecisionInputs: DecisionInputs, newDecisionInputs: DecisionInputs): boolean { 109 | return ( 110 | oldDecisionInputs.entityKey === newDecisionInputs.entityKey && 111 | oldDecisionInputs.overrideUserId === newDecisionInputs.overrideUserId && 112 | areAttributesEqual(oldDecisionInputs.overrideAttributes, newDecisionInputs.overrideAttributes) 113 | ); 114 | } 115 | 116 | /** 117 | * Subscribe to changes in initialization state of the argument client. onInitStateChange callback 118 | * is called on the following events: 119 | * - optimizely successfully becomes ready 120 | * - timeout is reached prior to optimizely becoming ready 121 | * - optimizely becomes ready after the timeout has already passed 122 | * @param {ReactSDKClient} optimizely 123 | * @param {number|undefined} timeout 124 | * @param {Function} onInitStateChange 125 | */ 126 | function subscribeToInitialization( 127 | optimizely: ReactSDKClient, 128 | timeout: number | undefined, 129 | onInitStateChange: (initState: InitializationState) => void 130 | ): void { 131 | optimizely 132 | .onReady({ timeout }) 133 | .then((res: OnReadyResult) => { 134 | if (res.success) { 135 | hooksLogger.info('Client immediately ready'); 136 | onInitStateChange({ 137 | clientReady: true, 138 | didTimeout: false, 139 | }); 140 | return; 141 | } 142 | 143 | switch (res.reason) { 144 | // Optimizely client failed to initialize. 145 | case NotReadyReason.NO_CLIENT: 146 | hooksLogger.warn(`Client not ready, reason="${res.message}"`); 147 | onInitStateChange({ 148 | clientReady: false, 149 | didTimeout: false, 150 | }); 151 | res.dataReadyPromise?.then((readyResult?: OnReadyResult) => { 152 | if (!readyResult) { 153 | return; 154 | } 155 | const { success, message } = readyResult; 156 | if (success) { 157 | hooksLogger.info('Client became ready.'); 158 | } else { 159 | hooksLogger.warn(`Client not ready, reason="${message}"`); 160 | } 161 | onInitStateChange({ 162 | clientReady: success, 163 | didTimeout: false, 164 | }); 165 | }); 166 | break; 167 | case NotReadyReason.USER_NOT_READY: 168 | hooksLogger.warn(`User was not ready, reason="${res.message}"`); 169 | onInitStateChange({ 170 | clientReady: false, 171 | didTimeout: false, 172 | }); 173 | res.dataReadyPromise?.then((readyResult?: OnReadyResult) => { 174 | if (!readyResult) { 175 | return; 176 | } 177 | const { success, message } = readyResult; 178 | if (success) { 179 | hooksLogger.info('User became ready later.'); 180 | } else { 181 | hooksLogger.warn(`Client not ready, reason="${message}"`); 182 | } 183 | onInitStateChange({ 184 | clientReady: success, 185 | didTimeout: false, 186 | }); 187 | }); 188 | break; 189 | case NotReadyReason.TIMEOUT: 190 | hooksLogger.info(`Client did not become ready before timeout of ${timeout} ms, reason="${res.message}"`); 191 | onInitStateChange({ 192 | clientReady: false, 193 | didTimeout: true, 194 | }); 195 | res.dataReadyPromise?.then((readyResult?: OnReadyResult) => { 196 | if (!readyResult) { 197 | return; 198 | } 199 | 200 | const { success, message } = readyResult; 201 | 202 | if (success) { 203 | hooksLogger.info('Client became ready after timeout already elapsed'); 204 | } else { 205 | hooksLogger.warn(`Client not ready, reason="${message}"`); 206 | } 207 | 208 | onInitStateChange({ 209 | clientReady: success, 210 | didTimeout: true, 211 | }); 212 | }); 213 | break; 214 | default: 215 | hooksLogger.warn(`Other reason client not ready, reason="${res.message}"`); 216 | onInitStateChange({ 217 | clientReady: false, 218 | didTimeout: false, 219 | }); 220 | res.dataReadyPromise?.then((readyResult?: OnReadyResult) => { 221 | if (!readyResult) { 222 | return; 223 | } 224 | 225 | const { success, message } = readyResult; 226 | 227 | if (success) { 228 | hooksLogger.info('Client became ready later'); 229 | } else { 230 | hooksLogger.warn(`Client not ready, reason="${message}"`); 231 | } 232 | onInitStateChange({ 233 | clientReady: success, 234 | didTimeout: false, 235 | }); 236 | }); 237 | } 238 | }) 239 | .catch(() => { 240 | hooksLogger.error(`Error initializing client. The core client or user promise(s) rejected.`); 241 | }); 242 | } 243 | 244 | function useCompareAttrsMemoize(value: UserAttributes | undefined): UserAttributes | undefined { 245 | const ref = useRef<UserAttributes | undefined>(); 246 | if (!areAttributesEqual(value, ref.current)) { 247 | ref.current = value; 248 | } 249 | return ref.current; 250 | } 251 | 252 | /** 253 | * A React Hook that retrieves the variation for an experiment, optionally 254 | * auto updating that value based on underlying user or datafile changes. 255 | * 256 | * Note: The react client can become ready AFTER the timeout period. 257 | * ClientReady and DidTimeout provide signals to handle this scenario. 258 | */ 259 | export const useExperiment: UseExperiment = (experimentKey, options = {}, overrides = {}) => { 260 | const { optimizely, isServerSide, timeout } = useContext(OptimizelyContext); 261 | 262 | const overrideAttrs = useCompareAttrsMemoize(overrides.overrideAttributes); 263 | 264 | const getCurrentDecision: () => ExperimentDecisionValues = useCallback( 265 | () => ({ 266 | variation: optimizely?.activate(experimentKey, overrides.overrideUserId, overrideAttrs) || null, 267 | }), 268 | [optimizely, experimentKey, overrides.overrideUserId, overrideAttrs] 269 | ); 270 | 271 | const isClientReady = isServerSide || !!optimizely?.isReady(); 272 | const isReadyPromiseFulfilled = !!optimizely?.getIsReadyPromiseFulfilled(); 273 | 274 | const [state, setState] = useState<ExperimentDecisionValues & InitializationState>(() => { 275 | const decisionState = isClientReady ? getCurrentDecision() : { variation: null }; 276 | return { 277 | ...decisionState, 278 | clientReady: isClientReady, 279 | didTimeout: false, 280 | }; 281 | }); 282 | 283 | // Decision state is derived from entityKey and overrides arguments. 284 | // Track the previous value of those arguments, and update state when they change. 285 | // This is an instance of the derived state pattern recommended here: 286 | // https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-getderivedstatefromprops 287 | const currentDecisionInputs: DecisionInputs = { 288 | entityKey: experimentKey, 289 | overrideUserId: overrides.overrideUserId, 290 | overrideAttributes: overrideAttrs, 291 | }; 292 | 293 | const [prevDecisionInputs, setPrevDecisionInputs] = useState<DecisionInputs>(currentDecisionInputs); 294 | if (!areDecisionInputsEqual(prevDecisionInputs, currentDecisionInputs)) { 295 | setPrevDecisionInputs(currentDecisionInputs); 296 | setState((prevState) => ({ 297 | ...prevState, 298 | ...getCurrentDecision(), 299 | })); 300 | } 301 | 302 | const finalReadyTimeout = options.timeout !== undefined ? options.timeout : timeout; 303 | useEffect(() => { 304 | // Subscribe to initialzation promise only 305 | // 1. When client is using Sdk Key, which means the initialization will be asynchronous 306 | // and we need to wait for the promise and update decision. 307 | // 2. When client is using datafile only but client is not ready yet which means user 308 | // was provided as a promise and we need to subscribe and wait for user to become available. 309 | if (optimizely && ((optimizely.getIsUsingSdkKey() && !isReadyPromiseFulfilled) || !isClientReady)) { 310 | subscribeToInitialization(optimizely, finalReadyTimeout, (initState) => { 311 | setState({ 312 | ...getCurrentDecision(), 313 | ...initState, 314 | }); 315 | }); 316 | } 317 | }, [finalReadyTimeout, getCurrentDecision, isClientReady, isReadyPromiseFulfilled, optimizely]); 318 | 319 | useEffect(() => { 320 | // Subscribe to update after first datafile is fetched and readyPromise is resolved to avoid redundant rendering. 321 | if (optimizely && isReadyPromiseFulfilled && options.autoUpdate) { 322 | return setupAutoUpdateListeners(optimizely, HookType.EXPERIMENT, experimentKey, hooksLogger, () => { 323 | setState((prevState) => ({ 324 | ...prevState, 325 | ...getCurrentDecision(), 326 | })); 327 | }); 328 | } 329 | return (): void => {}; 330 | }, [isReadyPromiseFulfilled, options.autoUpdate, optimizely, experimentKey, getCurrentDecision]); 331 | 332 | useEffect( 333 | () => 334 | optimizely?.onForcedVariationsUpdate(() => { 335 | setState((prevState) => ({ 336 | ...prevState, 337 | ...getCurrentDecision(), 338 | })); 339 | }), 340 | [getCurrentDecision, optimizely] 341 | ); 342 | 343 | if (!optimizely) { 344 | hooksLogger.error(`Unable to use experiment ${experimentKey}. ${optimizelyPropError}`); 345 | } 346 | 347 | return [state.variation, state.clientReady, state.didTimeout]; 348 | }; 349 | 350 | /** 351 | * A React Hook that retrieves the status of a feature flag and its variables, optionally 352 | * auto updating those values based on underlying user or datafile changes. 353 | * 354 | * Note: The react client can become ready AFTER the timeout period. 355 | * ClientReady and DidTimeout provide signals to handle this scenario. 356 | */ 357 | export const useFeature: UseFeature = (featureKey, options = {}, overrides = {}) => { 358 | const { optimizely, isServerSide, timeout } = useContext(OptimizelyContext); 359 | const overrideAttrs = useCompareAttrsMemoize(overrides.overrideAttributes); 360 | 361 | const getCurrentDecision: () => FeatureDecisionValues = useCallback( 362 | () => ({ 363 | isEnabled: !!optimizely?.isFeatureEnabled(featureKey, overrides.overrideUserId, overrideAttrs), 364 | variables: optimizely?.getFeatureVariables(featureKey, overrides.overrideUserId, overrideAttrs) || {}, 365 | }), 366 | [optimizely, featureKey, overrides.overrideUserId, overrideAttrs] 367 | ); 368 | 369 | const isClientReady = isServerSide || !!optimizely?.isReady(); 370 | const isReadyPromiseFulfilled = !!optimizely?.getIsReadyPromiseFulfilled(); 371 | 372 | const [state, setState] = useState<FeatureDecisionValues & InitializationState>(() => { 373 | const decisionState = isClientReady ? getCurrentDecision() : { isEnabled: false, variables: {} }; 374 | return { 375 | ...decisionState, 376 | clientReady: isClientReady, 377 | didTimeout: false, 378 | }; 379 | }); 380 | // Decision state is derived from entityKey and overrides arguments. 381 | // Track the previous value of those arguments, and update state when they change. 382 | // This is an instance of the derived state pattern recommended here: 383 | // https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-getderivedstatefromprops 384 | const currentDecisionInputs: DecisionInputs = { 385 | entityKey: featureKey, 386 | overrideUserId: overrides.overrideUserId, 387 | overrideAttributes: overrides.overrideAttributes, 388 | }; 389 | 390 | const [prevDecisionInputs, setPrevDecisionInputs] = useState<DecisionInputs>(currentDecisionInputs); 391 | 392 | if (!areDecisionInputsEqual(prevDecisionInputs, currentDecisionInputs)) { 393 | setPrevDecisionInputs(currentDecisionInputs); 394 | setState((prevState) => ({ 395 | ...prevState, 396 | ...getCurrentDecision(), 397 | })); 398 | } 399 | 400 | const finalReadyTimeout = options.timeout !== undefined ? options.timeout : timeout; 401 | 402 | useEffect(() => { 403 | // Subscribe to initialzation promise only 404 | // 1. When client is using Sdk Key, which means the initialization will be asynchronous 405 | // and we need to wait for the promise and update decision. 406 | // 2. When client is using datafile only but client is not ready yet which means user 407 | // was provided as a promise and we need to subscribe and wait for user to become available. 408 | if (optimizely && (optimizely.getIsUsingSdkKey() || !isClientReady)) { 409 | subscribeToInitialization(optimizely, finalReadyTimeout, (initState) => { 410 | setState({ 411 | ...getCurrentDecision(), 412 | ...initState, 413 | }); 414 | }); 415 | } 416 | // eslint-disable-next-line react-hooks/exhaustive-deps 417 | }, [finalReadyTimeout, getCurrentDecision, optimizely]); 418 | 419 | useEffect(() => { 420 | // Subscribe to update after first datafile is fetched and readyPromise is resolved to avoid redundant rendering. 421 | if (optimizely && isReadyPromiseFulfilled && options.autoUpdate) { 422 | return setupAutoUpdateListeners(optimizely, HookType.FEATURE, featureKey, hooksLogger, () => { 423 | setState((prevState) => ({ 424 | ...prevState, 425 | ...getCurrentDecision(), 426 | })); 427 | }); 428 | } 429 | return (): void => {}; 430 | }, [isReadyPromiseFulfilled, options.autoUpdate, optimizely, featureKey, getCurrentDecision]); 431 | 432 | if (!optimizely) { 433 | hooksLogger.error(`Unable to properly use feature ${featureKey}. ${optimizelyPropError}`); 434 | } 435 | 436 | return [state.isEnabled, state.variables, state.clientReady, state.didTimeout]; 437 | }; 438 | 439 | /** 440 | * A React Hook that retrieves the flag decision, optionally 441 | * auto updating those values based on underlying user or datafile changes. 442 | * 443 | * Note: The react client can become ready AFTER the timeout period. 444 | * ClientReady and DidTimeout provide signals to handle this scenario. 445 | */ 446 | export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) => { 447 | const { optimizely, isServerSide, timeout } = useContext(OptimizelyContext); 448 | 449 | const overrideAttrs = useCompareAttrsMemoize(overrides.overrideAttributes); 450 | 451 | const defaultDecision = useMemo( 452 | () => 453 | createFailedDecision(flagKey, 'Optimizely SDK not configured properly yet.', { 454 | id: overrides.overrideUserId || null, 455 | attributes: overrideAttrs || {}, 456 | }), 457 | [flagKey, overrideAttrs, overrides.overrideUserId] 458 | ); 459 | 460 | const getCurrentDecision: () => { decision: OptimizelyDecision } = useCallback( 461 | () => ({ 462 | decision: 463 | optimizely?.decide(flagKey, options.decideOptions, overrides.overrideUserId, overrideAttrs) || defaultDecision, 464 | }), 465 | [flagKey, defaultDecision, optimizely, options.decideOptions, overrideAttrs, overrides.overrideUserId] 466 | ); 467 | 468 | const isClientReady = isServerSide || !!optimizely?.isReady(); 469 | const isReadyPromiseFulfilled = !!optimizely?.getIsReadyPromiseFulfilled(); 470 | 471 | const [state, setState] = useState<{ decision: OptimizelyDecision } & InitializationState>(() => { 472 | const decisionState = isClientReady 473 | ? getCurrentDecision() 474 | : { 475 | decision: defaultDecision, 476 | }; 477 | return { 478 | ...decisionState, 479 | clientReady: isClientReady, 480 | didTimeout: false, 481 | }; 482 | }); 483 | // Decision state is derived from entityKey and overrides arguments. 484 | // Track the previous value of those arguments, and update state when they change. 485 | // This is an instance of the derived state pattern recommended here: 486 | // https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-getderivedstatefromprops 487 | const currentDecisionInputs: DecisionInputs = { 488 | entityKey: flagKey, 489 | overrideUserId: overrides.overrideUserId, 490 | overrideAttributes: overrides.overrideAttributes, 491 | }; 492 | const [prevDecisionInputs, setPrevDecisionInputs] = useState<DecisionInputs>(currentDecisionInputs); 493 | if (!areDecisionInputsEqual(prevDecisionInputs, currentDecisionInputs)) { 494 | setPrevDecisionInputs(currentDecisionInputs); 495 | setState((prevState) => ({ 496 | ...prevState, 497 | ...getCurrentDecision(), 498 | })); 499 | } 500 | 501 | const finalReadyTimeout = options.timeout !== undefined ? options.timeout : timeout; 502 | 503 | useEffect(() => { 504 | // Subscribe to initialzation promise only 505 | // 1. When client is using Sdk Key, which means the initialization will be asynchronous 506 | // and we need to wait for the promise and update decision. 507 | // 2. When client is using datafile only but client is not ready yet which means user 508 | // was provided as a promise and we need to subscribe and wait for user to become available. 509 | if (optimizely && (optimizely.getIsUsingSdkKey() || !isClientReady)) { 510 | subscribeToInitialization(optimizely, finalReadyTimeout, (initState) => { 511 | setState({ 512 | ...getCurrentDecision(), 513 | ...initState, 514 | }); 515 | }); 516 | } 517 | // eslint-disable-next-line react-hooks/exhaustive-deps 518 | }, [finalReadyTimeout, getCurrentDecision, optimizely]); 519 | 520 | useEffect(() => { 521 | if (overrides.overrideUserId || overrides.overrideAttributes || !options.autoUpdate) { 522 | return; 523 | } 524 | 525 | // Subscribe to Forced Decision changes. 526 | return notifier.subscribe(flagKey, () => { 527 | setState((prevState) => ({ 528 | ...prevState, 529 | ...getCurrentDecision(), 530 | })); 531 | }); 532 | }, [overrides.overrideUserId, overrides.overrideAttributes, options.autoUpdate, flagKey, getCurrentDecision]); 533 | 534 | useEffect(() => { 535 | // Subscribe to update after first datafile is fetched and readyPromise is resolved to avoid redundant rendering. 536 | if (optimizely && isReadyPromiseFulfilled && options.autoUpdate) { 537 | return setupAutoUpdateListeners(optimizely, HookType.FEATURE, flagKey, hooksLogger, () => { 538 | setState((prevState) => ({ 539 | ...prevState, 540 | ...getCurrentDecision(), 541 | })); 542 | }); 543 | } 544 | return (): void => {}; 545 | }, [isReadyPromiseFulfilled, options.autoUpdate, optimizely, flagKey, getCurrentDecision]); 546 | 547 | if (!optimizely) { 548 | hooksLogger.error(`Unable to use decision ${flagKey}. ${optimizelyPropError}`); 549 | } 550 | 551 | return [state.decision, state.clientReady, state.didTimeout]; 552 | }; 553 | 554 | export const useTrackEvent: UseTrackEvent = () => { 555 | const { optimizely, isServerSide, timeout } = useContext(OptimizelyContext); 556 | const isClientReady = isServerSide || !!optimizely?.isReady(); 557 | 558 | const track = useCallback( 559 | (...rest: Parameters<ReactSDKClient['track']>): void => { 560 | if (!optimizely) { 561 | hooksLogger.error(`Unable to track events. ${optimizelyPropError}`); 562 | return; 563 | } 564 | if (!isClientReady) { 565 | hooksLogger.error(`Unable to track events. Optimizely client is not ready yet.`); 566 | return; 567 | } 568 | optimizely.track(...rest); 569 | }, 570 | [optimizely, isClientReady] 571 | ); 572 | 573 | const [state, setState] = useState<{ 574 | clientReady: boolean; 575 | didTimeout: DidTimeout; 576 | }>(() => { 577 | return { 578 | clientReady: isClientReady, 579 | didTimeout: false, 580 | }; 581 | }); 582 | 583 | useEffect(() => { 584 | // Subscribe to initialization promise only 585 | // 1. When client is using Sdk Key, which means the initialization will be asynchronous 586 | // and we need to wait for the promise and update decision. 587 | // 2. When client is using datafile only but client is not ready yet which means user 588 | // was provided as a promise and we need to subscribe and wait for user to become available. 589 | if (optimizely && (optimizely.getIsUsingSdkKey() || !isClientReady)) { 590 | subscribeToInitialization(optimizely, timeout, (initState) => { 591 | setState(initState); 592 | }); 593 | } 594 | // eslint-disable-next-line react-hooks/exhaustive-deps 595 | }, [optimizely, timeout]); 596 | 597 | return [track, state.clientReady, state.didTimeout]; 598 | }; 599 | -------------------------------------------------------------------------------- /src/index.cjs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019, Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as optimizelyReactSDK from './index'; 18 | 19 | module.exports = optimizelyReactSDK; 20 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018-2019, 2023, 2024 Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | export { OptimizelyContext, OptimizelyContextConsumer, OptimizelyContextProvider } from './Context'; 18 | export { OptimizelyProvider } from './Provider'; 19 | export { OptimizelyFeature } from './Feature'; 20 | export { useFeature, useExperiment, useDecision, useTrackEvent } from './hooks'; 21 | export { withOptimizely, WithOptimizelyProps, WithoutOptimizelyProps } from './withOptimizely'; 22 | export { OptimizelyExperiment } from './Experiment'; 23 | export { OptimizelyVariation } from './Variation'; 24 | export { OptimizelyDecision } from './utils'; 25 | 26 | export { 27 | logging, 28 | errorHandler, 29 | setLogger, 30 | setLogLevel, 31 | enums, 32 | eventDispatcher, 33 | OptimizelyDecideOption, 34 | ActivateListenerPayload, 35 | TrackListenerPayload, 36 | ListenerPayload, 37 | OptimizelySegmentOption, 38 | } from '@optimizely/optimizely-sdk'; 39 | 40 | export { createInstance, ReactSDKClient } from './client'; 41 | 42 | export { default as logOnlyEventDispatcher } from './logOnlyEventDispatcher'; 43 | -------------------------------------------------------------------------------- /src/logOnlyEventDispatcher.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023, Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | jest.mock('@optimizely/optimizely-sdk', () => ({ 18 | getLogger: jest.fn().mockReturnValue({ debug: jest.fn() }), 19 | })); 20 | 21 | import logOnlyEventDispatcher from './logOnlyEventDispatcher'; 22 | import { getLogger } from '@optimizely/optimizely-sdk'; 23 | 24 | const logger = getLogger('ReactSDK'); 25 | 26 | describe('logOnlyEventDispatcher', () => { 27 | beforeEach(() => { 28 | jest.clearAllMocks(); 29 | }); 30 | 31 | it('logs a message', () => { 32 | const callback = jest.fn(); 33 | const mockEvent = { url: 'https://localhost:8080', httpVerb: 'POST' as const, params: {} }; 34 | logOnlyEventDispatcher.dispatchEvent(mockEvent, callback); 35 | const secondArgFunction = (logger.debug as jest.Mock).mock.calls[0][1]; 36 | const result = secondArgFunction(); 37 | 38 | expect(callback).toHaveBeenCalled(); 39 | expect(logger.debug).toHaveBeenCalled(); 40 | expect(result).toBe(JSON.stringify(mockEvent)); 41 | }); 42 | 43 | it('debugger log print error stringifying event', () => { 44 | const callback = jest.fn(); 45 | // circular reference to force JSON.stringify to throw an error 46 | const circularReference: any = {}; 47 | circularReference.self = circularReference; 48 | logOnlyEventDispatcher.dispatchEvent(circularReference, callback); 49 | const secondArgFunction = (logger.debug as jest.Mock).mock.calls[0][1]; 50 | const result = secondArgFunction(); 51 | 52 | expect(typeof secondArgFunction).toBe('function'); 53 | expect(result).toBe('error stringifying event'); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/logOnlyEventDispatcher.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019, 2023-2024 Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as optimizely from '@optimizely/optimizely-sdk'; 18 | 19 | const logger = optimizely.getLogger('ReactSDK'); 20 | /** 21 | * logOnlyEventDispatcher only logs a message at the debug level, and does not 22 | * send any requests to the Optimizely results backend. Use this to disable 23 | * all event dispatching. 24 | */ 25 | const logOnlyEventDispatcher: optimizely.EventDispatcher = { 26 | dispatchEvent(event: optimizely.Event, callback: (response: { statusCode: number }) => void): void { 27 | logger.debug('Event not dispatched by disabled event dispatcher: %s', () => { 28 | let eventStr: string; 29 | try { 30 | eventStr = JSON.stringify(event); 31 | } catch (err) { 32 | eventStr = 'error stringifying event'; 33 | } 34 | return eventStr; 35 | }); 36 | callback({ statusCode: 204 }); 37 | }, 38 | }; 39 | 40 | export default logOnlyEventDispatcher; 41 | -------------------------------------------------------------------------------- /src/logger.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2024 Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as optimizely from '@optimizely/optimizely-sdk'; 18 | import { logger } from './logger'; 19 | import { sprintf } from './utils'; 20 | 21 | jest.mock('@optimizely/optimizely-sdk', () => ({ 22 | getLogger: jest.fn().mockReturnValue({ 23 | log: jest.fn(), 24 | }), 25 | enums: { 26 | LOG_LEVEL: { 27 | WARNING: 'WARNING', 28 | INFO: 'INFO', 29 | DEBUG: 'DEBUG', 30 | ERROR: 'ERROR', 31 | }, 32 | }, 33 | })); 34 | 35 | describe('logger module', () => { 36 | const mockLogHandler = optimizely.getLogger('ReactSDK'); 37 | const logSpy = mockLogHandler.log as jest.Mock; 38 | 39 | beforeEach(() => { 40 | jest.clearAllMocks(); 41 | }); 42 | 43 | it('should log a warning message', () => { 44 | const message = 'This is a warning: %s'; 45 | const arg = 'something went wrong'; 46 | 47 | logger.warn(message, arg); 48 | 49 | expect(logSpy).toHaveBeenCalledWith(optimizely.enums.LOG_LEVEL.WARNING, sprintf(message, arg)); 50 | }); 51 | 52 | it('should log an info message', () => { 53 | const message = 'This is an info: %s'; 54 | const arg = 'all good'; 55 | 56 | logger.info(message, arg); 57 | 58 | expect(logSpy).toHaveBeenCalledWith(optimizely.enums.LOG_LEVEL.INFO, sprintf(message, arg)); 59 | }); 60 | 61 | it('should log a debug message', () => { 62 | const message = 'Debugging: %s'; 63 | const arg = 'checking details'; 64 | 65 | logger.debug(message, arg); 66 | 67 | expect(logSpy).toHaveBeenCalledWith(optimizely.enums.LOG_LEVEL.DEBUG, sprintf(message, arg)); 68 | }); 69 | 70 | it('should log an error message', () => { 71 | const message = 'Error occurred: %s'; 72 | const arg = 'critical failure'; 73 | 74 | logger.error(message, arg); 75 | 76 | expect(logSpy).toHaveBeenCalledWith(optimizely.enums.LOG_LEVEL.ERROR, sprintf(message, arg)); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/logger.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022,2024 Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as optimizely from '@optimizely/optimizely-sdk'; 18 | import { sprintf } from './utils'; 19 | 20 | const logHandler = optimizely.getLogger('ReactSDK'); 21 | 22 | export const logger = { 23 | warn: (msg: string, ...splat: any[]) => { 24 | return logHandler.log(optimizely.enums.LOG_LEVEL.WARNING, sprintf(msg, ...splat)); 25 | }, 26 | info: (msg: string, ...splat: any[]) => { 27 | return logHandler.log(optimizely.enums.LOG_LEVEL.INFO, sprintf(msg, ...splat)); 28 | }, 29 | debug: (msg: string, ...splat: any[]) => { 30 | return logHandler.log(optimizely.enums.LOG_LEVEL.DEBUG, sprintf(msg, ...splat)); 31 | }, 32 | error: (msg: string, ...splat: any[]) => { 33 | return logHandler.log(optimizely.enums.LOG_LEVEL.ERROR, sprintf(msg, ...splat)); 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /src/notifier.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019-2022, 2024, Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { notifier } from './notifier'; 17 | 18 | describe('notifier', () => { 19 | it('should have a subscribe method defined', () => { 20 | expect(notifier.subscribe).toBeDefined(); 21 | }); 22 | 23 | it('should have a notify method defined', () => { 24 | expect(notifier.notify).toBeDefined(); 25 | }); 26 | 27 | describe('Subscribing single key', () => { 28 | let callback: jest.MockedFunction<() => void>; 29 | const key = 'key_1'; 30 | 31 | beforeEach(() => { 32 | callback = jest.fn(); 33 | notifier.subscribe(key, callback); 34 | }); 35 | 36 | describe('when notify event envoked with the relevent key', () => { 37 | beforeEach(() => { 38 | notifier.notify(key); 39 | }); 40 | 41 | it('should call the callback', () => { 42 | expect(callback).toHaveBeenCalled(); 43 | }); 44 | 45 | it('should call the callback once only', () => { 46 | expect(callback).toHaveBeenCalledTimes(1); 47 | }); 48 | }); 49 | 50 | describe('when notify event envoked with the irrelevant key', () => { 51 | beforeEach(() => { 52 | notifier.notify('another_key'); 53 | }); 54 | 55 | it('should not call the callback', () => { 56 | expect(callback).not.toHaveBeenCalled(); 57 | }); 58 | }); 59 | }); 60 | 61 | describe('Subscribing multiple key', () => { 62 | let callback1: jest.MockedFunction<() => void>; 63 | const key1 = 'key_1'; 64 | let callback2: jest.MockedFunction<() => void>; 65 | const key2 = 'key_2'; 66 | 67 | beforeEach(() => { 68 | callback1 = jest.fn(); 69 | callback2 = jest.fn(); 70 | notifier.subscribe(key1, callback1); 71 | notifier.subscribe(key2, callback2); 72 | }); 73 | 74 | describe('notifing particular key', () => { 75 | beforeEach(() => { 76 | notifier.notify(key1); 77 | }); 78 | 79 | it('should call the callback of key 1 only', () => { 80 | expect(callback1).toHaveBeenCalledTimes(1); 81 | }); 82 | 83 | it('should not call the callback of key 2', () => { 84 | expect(callback2).not.toHaveBeenCalled(); 85 | }); 86 | }); 87 | }); 88 | 89 | describe('Subscribing similar key with multiple instances', () => { 90 | let callback1: jest.MockedFunction<() => void>; 91 | const sameKey1 = 'key_1'; 92 | let callback2: jest.MockedFunction<() => void>; 93 | const sameKey2 = 'key_1'; 94 | 95 | beforeEach(() => { 96 | callback1 = jest.fn(); 97 | callback2 = jest.fn(); 98 | notifier.subscribe(sameKey1, callback1); 99 | notifier.subscribe(sameKey2, callback2); 100 | }); 101 | describe('when notifing the key', () => { 102 | beforeEach(() => { 103 | notifier.notify(sameKey1); 104 | }); 105 | 106 | it('should call all the callbacks of particular key', () => { 107 | expect(callback1).toHaveBeenCalledTimes(1); 108 | expect(callback2).toHaveBeenCalledTimes(1); 109 | }); 110 | }); 111 | }); 112 | 113 | describe('unsubscribing the key', () => { 114 | let callback: jest.MockedFunction<() => void>; 115 | const key = 'key_1'; 116 | 117 | beforeEach(() => { 118 | callback = jest.fn(); 119 | }); 120 | describe('subscribe should return a function', () => { 121 | it('should call the callback', () => { 122 | const unsubscribe = notifier.subscribe(key, callback); 123 | expect(unsubscribe).toBeInstanceOf(Function); 124 | }); 125 | }); 126 | 127 | describe('should not envoke callback on notify if is unsubscribed', () => { 128 | beforeEach(() => { 129 | const unsubscribe = notifier.subscribe(key, callback); 130 | unsubscribe(); 131 | notifier.notify(key); 132 | }); 133 | 134 | it('should not call the callback', () => { 135 | expect(callback).not.toHaveBeenCalled(); 136 | }); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /src/notifier.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022, 2024, Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | export interface INotifier { 18 | subscribe(key: string, callback: () => void): () => void; 19 | notify(key: string): void; 20 | } 21 | 22 | class Notifier implements INotifier { 23 | private observers: Array<{ subscriptionId: string; key: string; callback: () => void }> = []; 24 | private static instance: INotifier; 25 | 26 | private constructor() {} 27 | 28 | static getInstance(): INotifier { 29 | if (!Notifier.instance) { 30 | Notifier.instance = new Notifier(); 31 | } 32 | return Notifier.instance; 33 | } 34 | 35 | subscribe(key: string, callback: () => void): () => void { 36 | const subscriptionId = `key-${Math.floor(100000 + Math.random() * 999999)}`; 37 | this.observers.push({ subscriptionId, key, callback }); 38 | 39 | return () => { 40 | const observerIndex = this.observers.findIndex((observer) => observer.subscriptionId === subscriptionId); 41 | if (observerIndex >= 0) { 42 | this.observers.splice(observerIndex, 1); 43 | } 44 | }; 45 | } 46 | 47 | notify(key: string) { 48 | this.observers.filter((observer) => observer.key === key).forEach((observer) => observer.callback()); 49 | } 50 | } 51 | 52 | export const notifier: INotifier = Notifier.getInstance(); 53 | -------------------------------------------------------------------------------- /src/utils.spec.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2024 Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import * as utils from './utils'; 17 | import React, { forwardRef } from 'react'; 18 | import { render, screen } from '@testing-library/react'; 19 | import hoistNonReactStatics from 'hoist-non-react-statics'; 20 | import { UserInfo } from './utils'; 21 | 22 | describe('utils', () => { 23 | describe('areUsersEqual', () => { 24 | const user = { id: '1', attributes: { name: 'user1' } }; 25 | 26 | it('returns true if users are equal', () => { 27 | const user2 = JSON.parse(JSON.stringify(user)); 28 | const areUsersEqual = utils.areUsersEqual(user, user2); 29 | 30 | expect(areUsersEqual).toBe(true); 31 | }); 32 | 33 | it('returns false if users are not equal', () => { 34 | const user2 = { id: '2', attributes: { name: 'user2' } }; 35 | const areUsersEqual = utils.areUsersEqual(user, user2); 36 | 37 | expect(areUsersEqual).toBe(false); 38 | }); 39 | 40 | it('returns false if key lengths are not equal', () => { 41 | const user2 = { id: '1', attributes: { name: 'user1', age: 30 } }; 42 | const areUsersEqual = utils.areUsersEqual(user, user2); 43 | 44 | expect(areUsersEqual).toBe(false); 45 | }); 46 | 47 | it('returns false if one of the key value pairs are not equal', () => { 48 | const user2 = { id: '1', attributes: { name: 'user2' } }; 49 | const areUsersEqual = utils.areUsersEqual(user, user2); 50 | 51 | expect(areUsersEqual).toBe(false); 52 | }); 53 | }); 54 | 55 | describe('hoistStaticsAndForwardRefs', () => { 56 | class TestComponent extends React.Component<{ forwardedRef?: React.Ref<HTMLDivElement> }> { 57 | static testStaticMethod = () => 'static method result'; 58 | 59 | render() { 60 | return ( 61 | <div ref={this.props.forwardedRef} data-testid="test-div"> 62 | Hello 63 | </div> 64 | ); 65 | } 66 | } 67 | 68 | // source component with statics, that should be available in the wrapped component 69 | class SourceComponent extends React.Component { 70 | static sourceStaticMethod = () => 'source static method result'; 71 | 72 | render() { 73 | return <div />; 74 | } 75 | } 76 | 77 | const WrappedComponent = utils.hoistStaticsAndForwardRefs(TestComponent, SourceComponent, 'WrappedComponent'); 78 | 79 | it('should forward refs and hoist static methods', () => { 80 | const ref = React.createRef<HTMLDivElement>(); 81 | 82 | render(<WrappedComponent ref={ref} />); 83 | 84 | expect(ref.current).toBeDefined(); 85 | expect(ref.current?.nodeName).toBe('DIV'); 86 | expect(screen.getByTestId('test-div')).toBe(ref.current); 87 | 88 | // @ts-ignore 89 | expect(WrappedComponent.sourceStaticMethod()).toBe('source static method result'); 90 | }); 91 | }); 92 | 93 | describe('areAttributesEqual', () => { 94 | it('should return true for equal attributes', () => { 95 | const attrs1 = { a: 1, b: 2 }; 96 | const attrs2 = { a: 1, b: 2 }; 97 | const areAttributesEqual = utils.areAttributesEqual(attrs1, attrs2); 98 | 99 | expect(areAttributesEqual).toBe(true); 100 | }); 101 | 102 | it('should return false for different attribute keys', () => { 103 | const attrs1 = { a: 1, b: 2 }; 104 | const attrs2 = { a: 1, c: 2 }; 105 | const areAttributesEqual = utils.areAttributesEqual(attrs1, attrs2); 106 | 107 | expect(areAttributesEqual).toBe(false); 108 | }); 109 | 110 | it('should return false for different attribute values', () => { 111 | const attrs1 = { a: 1, b: 2 }; 112 | const attrs2 = { a: 1, b: 3 }; 113 | const areAttributesEqual = utils.areAttributesEqual(attrs1, attrs2); 114 | 115 | expect(areAttributesEqual).toBe(false); 116 | }); 117 | 118 | it('should return false if the number of attributes differs', () => { 119 | const attrs1 = { a: 1, b: 2 }; 120 | const attrs2 = { a: 1 }; 121 | const areAttributesEqual = utils.areAttributesEqual(attrs1, attrs2); 122 | 123 | expect(areAttributesEqual).toBe(false); 124 | }); 125 | 126 | it('should handle undefined or null attributes as empty objects', () => { 127 | const attrs1 = null; 128 | const attrs2 = undefined; 129 | const areAttributesEqual = utils.areAttributesEqual(attrs1, attrs2); 130 | 131 | expect(areAttributesEqual).toBe(true); 132 | }); 133 | 134 | it('should return false when one attribute is an object and another is not', () => { 135 | const attrs1 = { a: 1 }; 136 | const attrs2 = 'not an object'; 137 | const areAttributesEqual = utils.areAttributesEqual(attrs1, attrs2); 138 | 139 | expect(areAttributesEqual).toBe(false); 140 | }); 141 | 142 | it('should handle different types of attribute values correctly', () => { 143 | const attrs1 = { a: '1', b: true }; 144 | const attrs2 = { a: '1', b: true }; 145 | const areAttributesEqual = utils.areAttributesEqual(attrs1, attrs2); 146 | 147 | expect(areAttributesEqual).toBe(true); 148 | }); 149 | }); 150 | 151 | describe('createFailedDecision', () => { 152 | it('should return a correctly formatted OptimizelyDecision object', () => { 153 | const flagKey = 'testFlag'; 154 | const message = 'Decision failed due to some reason'; 155 | const user: UserInfo = { 156 | id: 'user123', 157 | attributes: { age: 25, location: 'NY' }, 158 | }; 159 | 160 | const expectedDecision: utils.OptimizelyDecision = { 161 | enabled: false, 162 | flagKey: 'testFlag', 163 | ruleKey: null, 164 | variationKey: null, 165 | variables: {}, 166 | reasons: ['Decision failed due to some reason'], 167 | userContext: { 168 | id: 'user123', 169 | attributes: { age: 25, location: 'NY' }, 170 | }, 171 | }; 172 | 173 | const result = utils.createFailedDecision(flagKey, message, user); 174 | 175 | expect(result).toEqual(expectedDecision); 176 | }); 177 | }); 178 | 179 | describe('sprintf', () => { 180 | it('should replace %s with a string argument', () => { 181 | expect(utils.sprintf('Hello, %s!', 'world')).toBe('Hello, world!'); 182 | }); 183 | 184 | it('should replace %s with a function result', () => { 185 | const dynamicString = () => 'dynamic'; 186 | expect(utils.sprintf('This is a %s string.', dynamicString)).toBe('This is a dynamic string.'); 187 | }); 188 | 189 | it('should replace %s with various types of arguments', () => { 190 | expect(utils.sprintf('Boolean: %s, Number: %s, Object: %s', true, 42, { key: 'value' })).toBe( 191 | 'Boolean: true, Number: 42, Object: [object Object]' 192 | ); 193 | }); 194 | 195 | it('should handle a mix of strings, functions, and other types', () => { 196 | const dynamicPart = () => 'computed'; 197 | expect(utils.sprintf('String: %s, Function: %s, Number: %s', 'example', dynamicPart, 123)).toBe( 198 | 'String: example, Function: computed, Number: 123' 199 | ); 200 | }); 201 | 202 | it('should handle missing arguments as undefined', () => { 203 | expect(utils.sprintf('Two placeholders: %s and %s', 'first')).toBe('Two placeholders: first and undefined'); 204 | }); 205 | }); 206 | }); 207 | -------------------------------------------------------------------------------- /src/utils.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019, Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import hoistNonReactStatics from 'hoist-non-react-statics'; 18 | import * as optimizely from '@optimizely/optimizely-sdk'; 19 | import * as React from 'react'; 20 | 21 | export type UserInfo = { 22 | id: string | null; 23 | attributes?: optimizely.UserAttributes; 24 | }; 25 | 26 | export interface OptimizelyDecision extends Omit<optimizely.OptimizelyDecision, 'userContext'> { 27 | userContext: UserInfo; 28 | } 29 | 30 | export function areUsersEqual(user1: UserInfo, user2: UserInfo): boolean { 31 | if (user1.id !== user2.id) { 32 | return false; 33 | } 34 | 35 | const user1Attributes = user1.attributes || {}; 36 | const user2Attributes = user2.attributes || {}; 37 | 38 | const user1Keys = Object.keys(user1Attributes); 39 | const user2Keys = Object.keys(user2Attributes); 40 | 41 | if (user1Keys.length !== user2Keys.length) { 42 | return false; 43 | } 44 | 45 | for (const key of user1Keys) { 46 | if (user1Attributes[key] !== user2Attributes[key]) { 47 | return false; 48 | } 49 | } 50 | 51 | return true; 52 | } 53 | 54 | export interface AcceptsForwardedRef<R> { 55 | forwardedRef?: React.Ref<R>; 56 | } 57 | 58 | export function hoistStaticsAndForwardRefs<R, P extends AcceptsForwardedRef<R>>( 59 | Target: React.ComponentType<P>, 60 | Source: React.ComponentType<any>, 61 | displayName: string 62 | ): React.ForwardRefExoticComponent<React.PropsWithoutRef<P> & React.RefAttributes<R>> { 63 | // Make sure to hoist statics and forward any refs through from Source to Target 64 | // From the React docs: 65 | // https://reactjs.org/docs/higher-order-components.html#static-methods-must-be-copied-over 66 | // https://reactjs.org/docs/forwarding-refs.html#forwarding-refs-in-higher-order-components 67 | const forwardRef: React.ForwardRefRenderFunction<R, P> = (props, ref) => <Target {...props} forwardedRef={ref} />; 68 | forwardRef.displayName = `${displayName}(${Source.displayName || Source.name})`; 69 | return hoistNonReactStatics< 70 | React.ForwardRefExoticComponent<React.PropsWithoutRef<P> & React.RefAttributes<R>>, 71 | React.ComponentType<any> 72 | >(React.forwardRef(forwardRef), Source); 73 | } 74 | 75 | function coerceUnknownAttrsValueForComparison(maybeAttrs: unknown): optimizely.UserAttributes { 76 | if (typeof maybeAttrs === 'object' && maybeAttrs !== null) { 77 | return maybeAttrs as optimizely.UserAttributes; 78 | } 79 | return {} as optimizely.UserAttributes; 80 | } 81 | 82 | /** 83 | * Equality check applied to override user attributes passed into hooks. Used to determine when we need to recompute 84 | * a decision because a new set of override attributes was passed into a hook. 85 | * @param {UserAttributes|undefined} oldAttrs 86 | * @param {UserAttributes|undefined} newAttrs 87 | * @returns boolean 88 | */ 89 | export function areAttributesEqual(maybeOldAttrs: unknown, maybeNewAttrs: unknown): boolean { 90 | const oldAttrs = coerceUnknownAttrsValueForComparison(maybeOldAttrs); 91 | const newAttrs = coerceUnknownAttrsValueForComparison(maybeNewAttrs); 92 | const oldAttrsKeys = Object.keys(oldAttrs); 93 | const newAttrsKeys = Object.keys(newAttrs); 94 | if (oldAttrsKeys.length !== newAttrsKeys.length) { 95 | // Different attr count - must update 96 | return false; 97 | } 98 | return oldAttrsKeys.every((oldAttrKey: string) => { 99 | return oldAttrKey in newAttrs && oldAttrs[oldAttrKey] === newAttrs[oldAttrKey]; 100 | }); 101 | } 102 | 103 | export function createFailedDecision(flagKey: string, message: string, user: UserInfo): OptimizelyDecision { 104 | return { 105 | enabled: false, 106 | flagKey: flagKey, 107 | ruleKey: null, 108 | variationKey: null, 109 | variables: {}, 110 | reasons: [message], 111 | userContext: { 112 | id: user.id, 113 | attributes: user.attributes, 114 | }, 115 | }; 116 | } 117 | 118 | export function sprintf(format: string, ...args: any[]): string { 119 | let i = 0; 120 | return format.replace(/%s/g, () => { 121 | const arg = args[i++]; 122 | const type = typeof arg; 123 | if (type === 'function') { 124 | return arg(); 125 | } else if (type === 'string') { 126 | return arg; 127 | } else { 128 | return String(arg); 129 | } 130 | }); 131 | } 132 | -------------------------------------------------------------------------------- /src/withOptimizely.spec.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018-2019, 2023-2024, Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /// <reference types="jest" /> 18 | 19 | import * as React from 'react'; 20 | import { render, screen, waitFor } from '@testing-library/react'; 21 | import '@testing-library/jest-dom'; 22 | 23 | import { OptimizelyProvider } from './Provider'; 24 | import { withOptimizely } from './withOptimizely'; 25 | import { ReactSDKClient } from './client'; 26 | 27 | type TestProps = { 28 | optimizely: ReactSDKClient; 29 | optimizelyReadyTimeout: number | undefined; 30 | isServerSide: boolean; 31 | }; 32 | 33 | class InnerComponent extends React.Component<TestProps, any> { 34 | constructor(props: TestProps) { 35 | super(props); 36 | } 37 | 38 | render(): JSX.Element { 39 | return ( 40 | <div> 41 | <span data-testid="props-of-component">{JSON.stringify({ ...this.props })}</span> 42 | test 43 | </div> 44 | ); 45 | } 46 | } 47 | 48 | const WrapperComponent = withOptimizely(InnerComponent); 49 | 50 | describe('withOptimizely', () => { 51 | let optimizelyClient: ReactSDKClient; 52 | beforeEach(() => { 53 | optimizelyClient = { 54 | setUser: jest.fn(), 55 | getVuid: jest.fn(), 56 | onReady: jest.fn(), 57 | } as unknown as ReactSDKClient; 58 | }); 59 | 60 | describe('when userId / userAttributes props are provided', () => { 61 | it('should call setUser with the correct user id / attributes', async () => { 62 | const attributes = { 63 | foo: 'bar', 64 | }; 65 | const userId = 'jordan'; 66 | render( 67 | <OptimizelyProvider optimizely={optimizelyClient} timeout={200} userId={userId} userAttributes={attributes}> 68 | <WrapperComponent /> 69 | </OptimizelyProvider> 70 | ); 71 | 72 | await waitFor(() => expect(optimizelyClient.setUser).toHaveBeenCalledTimes(1)); 73 | expect(optimizelyClient.setUser).toHaveBeenCalledWith({ id: userId, attributes }); 74 | }); 75 | }); 76 | 77 | describe('when only userId prop is provided', () => { 78 | it('should call setUser with the correct user id / attributes', async () => { 79 | const userId = 'jordan'; 80 | render( 81 | <OptimizelyProvider optimizely={optimizelyClient} timeout={200} userId={userId}> 82 | <WrapperComponent /> 83 | </OptimizelyProvider> 84 | ); 85 | 86 | await waitFor(() => expect(optimizelyClient.setUser).toHaveBeenCalledTimes(1)); 87 | expect(optimizelyClient.setUser).toHaveBeenCalledWith({ 88 | id: userId, 89 | attributes: {}, 90 | }); 91 | }); 92 | }); 93 | 94 | describe(`when the user prop is passed only with "id"`, () => { 95 | it('should call setUser with the correct user id / attributes', async () => { 96 | const userId = 'jordan'; 97 | render( 98 | <OptimizelyProvider optimizely={optimizelyClient} timeout={200} user={{ id: userId }}> 99 | <WrapperComponent /> 100 | </OptimizelyProvider> 101 | ); 102 | 103 | await waitFor(() => expect(optimizelyClient.setUser).toHaveBeenCalledTimes(1)); 104 | expect(optimizelyClient.setUser).toHaveBeenCalledWith({ 105 | id: userId, 106 | attributes: {}, 107 | }); 108 | }); 109 | }); 110 | 111 | describe(`when the user prop is passed with "id" and "attributes"`, () => { 112 | it('should call setUser with the correct user id / attributes', async () => { 113 | const userId = 'jordan'; 114 | const attributes = { foo: 'bar' }; 115 | render( 116 | <OptimizelyProvider optimizely={optimizelyClient} timeout={200} user={{ id: userId, attributes }}> 117 | <WrapperComponent /> 118 | </OptimizelyProvider> 119 | ); 120 | 121 | await waitFor(() => expect(optimizelyClient.setUser).toHaveBeenCalledTimes(1)); 122 | expect(optimizelyClient.setUser).toHaveBeenCalledWith({ 123 | id: userId, 124 | attributes, 125 | }); 126 | }); 127 | }); 128 | 129 | describe('when both the user prop and userId / userAttributes props are passed', () => { 130 | it('should respect the user object prop', async () => { 131 | const userId = 'jordan'; 132 | const attributes = { foo: 'bar' }; 133 | render( 134 | <OptimizelyProvider 135 | optimizely={optimizelyClient} 136 | timeout={200} 137 | user={{ id: userId, attributes }} 138 | userId="otherUserId" 139 | userAttributes={{ other: 'yo' }} 140 | > 141 | <WrapperComponent /> 142 | </OptimizelyProvider> 143 | ); 144 | 145 | await waitFor(() => expect(optimizelyClient.setUser).toHaveBeenCalledTimes(1)); 146 | expect(optimizelyClient.setUser).toHaveBeenCalledWith({ 147 | id: userId, 148 | attributes, 149 | }); 150 | }); 151 | }); 152 | 153 | it('should inject optimizely and optimizelyReadyTimeout from <OptimizelyProvider>', async () => { 154 | render( 155 | <OptimizelyProvider optimizely={optimizelyClient} timeout={200}> 156 | <WrapperComponent /> 157 | </OptimizelyProvider> 158 | ); 159 | 160 | await waitFor(() => 161 | expect(screen.getByTestId('props-of-component')).toHaveTextContent( 162 | '{"optimizelyReadyTimeout":200,"optimizely":{},"isServerSide":false}' 163 | ) 164 | ); 165 | 166 | expect(optimizelyClient.setUser).toHaveBeenCalled(); 167 | }); 168 | 169 | it('should inject the isServerSide prop', async () => { 170 | render( 171 | <OptimizelyProvider optimizely={optimizelyClient} timeout={200} isServerSide={true}> 172 | <WrapperComponent /> 173 | </OptimizelyProvider> 174 | ); 175 | await waitFor(() => 176 | expect(screen.getByTestId('props-of-component')).toHaveTextContent( 177 | '{"optimizelyReadyTimeout":200,"optimizely":{},"isServerSide":true}' 178 | ) 179 | ); 180 | }); 181 | 182 | it('should forward refs', () => { 183 | interface FancyInputProps extends TestProps { 184 | defaultValue: string; 185 | } 186 | const FancyInput: React.ForwardRefRenderFunction<HTMLInputElement, FancyInputProps> = (props, ref) => ( 187 | <input data-testid="input-element" ref={ref} className="fancyInput" defaultValue={props.defaultValue} /> 188 | ); 189 | const ForwardingFancyInput = React.forwardRef(FancyInput); 190 | const OptimizelyInput = withOptimizely(ForwardingFancyInput); 191 | const inputRef: React.RefObject<HTMLInputElement> = React.createRef(); 192 | 193 | render( 194 | <OptimizelyProvider 195 | optimizely={optimizelyClient} 196 | timeout={200} 197 | user={{ id: 'jordan' }} 198 | userAttributes={{ plan_type: 'bronze' }} 199 | isServerSide={true} 200 | > 201 | <OptimizelyInput ref={inputRef} defaultValue="hi" /> 202 | </OptimizelyProvider> 203 | ); 204 | expect(inputRef).toBeDefined(); 205 | expect(inputRef.current).toBeInstanceOf(HTMLInputElement); 206 | expect(typeof inputRef.current?.focus).toBe('function'); 207 | const inputNode: HTMLInputElement = screen.getByTestId('input-element'); 208 | expect(inputRef.current).toBe(inputNode); 209 | }); 210 | 211 | it('should hoist non-React statics', () => { 212 | class MyComponentWithAStatic extends React.Component<TestProps> { 213 | static foo(): string { 214 | return 'foo'; 215 | } 216 | 217 | render() { 218 | return <div>I have a static method</div>; 219 | } 220 | } 221 | const OptlyComponent = withOptimizely(MyComponentWithAStatic); 222 | expect(typeof (OptlyComponent as any).foo).toBe('function'); 223 | expect((OptlyComponent as any).foo()).toBe('foo'); 224 | }); 225 | }); 226 | -------------------------------------------------------------------------------- /src/withOptimizely.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018-2019, Optimizely 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import * as React from 'react'; 17 | 18 | import { OptimizelyContextConsumer, OptimizelyContextInterface } from './Context'; 19 | import { ReactSDKClient } from './client'; 20 | import { hoistStaticsAndForwardRefs } from './utils'; 21 | 22 | export interface WithOptimizelyProps { 23 | optimizely: ReactSDKClient | null; 24 | optimizelyReadyTimeout: number | undefined; 25 | isServerSide: boolean; 26 | } 27 | 28 | export type WithoutOptimizelyProps<P extends WithOptimizelyProps> = Omit<P, keyof WithOptimizelyProps>; 29 | 30 | export function withOptimizely<P extends WithOptimizelyProps, R>( 31 | Component: React.ComponentType<P> 32 | ): React.ForwardRefExoticComponent<React.PropsWithoutRef<WithoutOptimizelyProps<P>> & React.RefAttributes<R>> { 33 | type WrapperProps = WithoutOptimizelyProps<P> & { forwardedRef?: React.Ref<R> }; 34 | 35 | class WithOptimizely extends React.Component<WrapperProps> { 36 | render() { 37 | const { forwardedRef, ...rest } = this.props; 38 | // Note: Casting props to P is necessary because of this TypeScript issue: 39 | // https://github.com/microsoft/TypeScript/issues/28884 40 | return ( 41 | <OptimizelyContextConsumer> 42 | {(value: OptimizelyContextInterface) => ( 43 | <Component 44 | {...(rest as P)} 45 | optimizelyReadyTimeout={value.timeout} 46 | optimizely={value.optimizely} 47 | isServerSide={value.isServerSide} 48 | ref={forwardedRef} 49 | /> 50 | )} 51 | </OptimizelyContextConsumer> 52 | ); 53 | } 54 | } 55 | 56 | const withRefsForwarded = hoistStaticsAndForwardRefs<R, WithoutOptimizelyProps<P>>( 57 | WithOptimizely, 58 | Component, 59 | 'withOptimizely' 60 | ); 61 | 62 | return withRefsForwarded; 63 | } 64 | -------------------------------------------------------------------------------- /srcclr.yml: -------------------------------------------------------------------------------- 1 | scope: production 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "target": "es5", 5 | "lib": ["es2015", "dom"], 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitReturns": true, 9 | "noImplicitThis": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "noLib": false, 13 | "emitDecoratorMetadata": true, 14 | "experimentalDecorators": true, 15 | "sourceMap": true, 16 | "declarationDir": "./dist", 17 | "outDir": "./lib", 18 | "esModuleInterop": true 19 | }, 20 | "include": ["./src"], 21 | "exclude": ["./node_modules", "./src/**/*.spec.tsx", "./src/**/*.spec.ts"] 22 | } 23 | --------------------------------------------------------------------------------