Compare commits

...

17 Commits

Author SHA1 Message Date
OpenFeature Bot ee236396cd
chore(main): release react-sdk 1.0.1 (#1235)
🤖 I have created a release *beep* *boop*
---


##
[1.0.1](https://github.com/open-feature/js-sdk/compare/react-sdk-v1.0.0...react-sdk-v1.0.1)
(2025-08-18)


### 🐛 Bug Fixes

* **react:** re-evaluate flags on re-render to detect silent provider …
([#1226](https://github.com/open-feature/js-sdk/issues/1226))
([3105595](3105595926))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>
2025-08-18 16:26:25 +00:00
Michael Beemer 9aab3d053b
chore: remove hardcoded react v1
Signed-off-by: Michael Beemer <beeme1mr@users.noreply.github.com>
2025-08-18 11:56:09 -04:00
Michael Beemer 3105595926
fix(react): re-evaluate flags on re-render to detect silent provider … (#1226)
## This PR

- Added `useEffect` that runs on re-render to re-evaluate the flag value
- Only updates state if the resolved value actually changed (using
`isEqual` comparison)
- Used lazy initialization for `useState` to avoid redundant initial
evaluation
  - Added `useCallback` memoization for event handlers
  - Fixed `AbortController` scope issue

### Notes

This resolves a subtle issue where the provider state may update without
emitting a change event, leading to confusing results. The `useFlag`
hook sets the initial evaluated value in a `useState`. Since this wasn't
in a closure, this evaluation happened any time the component using the
hook rerendered but the result was essentially ignored. Adding a logging
hook shows that the current results but since this evaluation was made
in an existing `useState`, the result had no effect.

This resolves a subtle issue where the provider state may update without
emitting a change event, leading to stale flag values being displayed.

The `useFlag` hook was evaluating the flag on every re-render (as part
of the `useState` initialization), but because `useState` only uses its
initial value on the first render, these subsequent evaluations were
being discarded. This meant that even though the hook was fetching the
correct updated value from the provider on each re-render, it was
throwing that value away and continuing to display the stale cached
value.

Adding a logging hook would show the correct evaluation happening
(proving the provider had the updated value), but the UI would remain
stuck with the old value because the `useState` was ignoring the
re-evaluated result.

The fix ensures that these re-evaluations on re-render actually update
the component's state when the resolved value changes.

The key insight is that the evaluation WAS happening on every re-render
(due to how useState works), but React was discarding the result. Your
fix makes those evaluations actually matter by checking if the value
changed and updating state accordingly.

Original thread:
https://cloud-native.slack.com/archives/C06E4DE6S07/p1754508917397519

### How to test

I created a test that reproduced the issue, and it failed. I then
implemented the changes and verified that the test passed.

---------

Signed-off-by: Developer <developer@example.com>
Signed-off-by: Michael Beemer <beeme1mr@users.noreply.github.com>
Co-authored-by: Developer <developer@example.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Lukas Reining <lukas.reining@codecentric.de>
Co-authored-by: Todd Baert <todd.baert@dynatrace.com>
2025-08-18 15:08:51 +00:00
renovate[bot] 1dbbd5161b
chore(deps): update dependency rxjs to v7.8.2 (#1230)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [rxjs](https://rxjs.dev)
([source](https://redirect.github.com/reactivex/rxjs)) | [`7.8.1` ->
`7.8.2`](https://renovatebot.com/diffs/npm/rxjs/7.8.1/7.8.2) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/rxjs/7.8.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/rxjs/7.8.1/7.8.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>reactivex/rxjs (rxjs)</summary>

###
[`v7.8.2`](https://redirect.github.com/reactivex/rxjs/compare/7.8.1...7.8.2)

[Compare
Source](https://redirect.github.com/reactivex/rxjs/compare/7.8.1...7.8.2)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled because a matching PR was automerged
previously.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/js-sdk).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS43MS4xIiwidXBkYXRlZEluVmVyIjoiNDEuNzEuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-15 09:06:37 -04:00
renovate[bot] 2287083de7
chore(deps): update dependency shx to ^0.4.0 (#1213)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [shx](https://redirect.github.com/shelljs/shx) | [`^0.3.4` ->
`^0.4.0`](https://renovatebot.com/diffs/npm/shx/0.3.4/0.4.0) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/shx/0.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/shx/0.3.4/0.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>shelljs/shx (shx)</summary>

###
[`v0.4.0`](https://redirect.github.com/shelljs/shx/releases/tag/v0.4.0)

[Compare
Source](https://redirect.github.com/shelljs/shx/compare/v0.3.4...v0.4.0)

####  Highlighted changes

- This is based on ShellJS v0.9! This means we bumped the minimum node
version to >= v18.
- Small bash compatibility change to `shx sed`. Now if you invoke `shx
sed -i`, this will not print any output to stdout (this is for
consistency with unix `sed`). Using `shx sed` without the `-i` flag will
still print to stdout as before.

#### What's Changed

- chore: remove codecov devDependency by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/198](https://redirect.github.com/shelljs/shx/pull/198)
- chore(ci): run tests up to node v16 by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/197](https://redirect.github.com/shelljs/shx/pull/197)
- chore: rename master -> main by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/204](https://redirect.github.com/shelljs/shx/pull/204)
- chore: update deps by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/205](https://redirect.github.com/shelljs/shx/pull/205)
- chore: update CI to include v18 by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/206](https://redirect.github.com/shelljs/shx/pull/206)
- fix(lint): fixes import order lint warnings by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/215](https://redirect.github.com/shelljs/shx/pull/215)
- doc: highlight globs and emphasize double quotes by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/214](https://redirect.github.com/shelljs/shx/pull/214)
- chore: update CI to test against node v20 by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/212](https://redirect.github.com/shelljs/shx/pull/212)
- docs: change GitHub Actions README badge by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/216](https://redirect.github.com/shelljs/shx/pull/216)
- chore: keep node < 16 around longer by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/219](https://redirect.github.com/shelljs/shx/pull/219)
- Bump GitHub workflow action to latest version by
[@&#8203;deining](https://redirect.github.com/deining) in
[https://github.com/shelljs/shx/pull/220](https://redirect.github.com/shelljs/shx/pull/220)
- Update minimist for CVE-2021-44906 by
[@&#8203;tomhaines432](https://redirect.github.com/tomhaines432) in
[https://github.com/shelljs/shx/pull/218](https://redirect.github.com/shelljs/shx/pull/218)
- chore: add codecov token by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/222](https://redirect.github.com/shelljs/shx/pull/222)
- chore: remove unsupported node configs from CI by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/221](https://redirect.github.com/shelljs/shx/pull/221)
- chore: switch to codecov v4 by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/223](https://redirect.github.com/shelljs/shx/pull/223)
- chore(dependencies): update js-yaml by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/224](https://redirect.github.com/shelljs/shx/pull/224)
- doc: Fix typo in README by
[@&#8203;mpaw](https://redirect.github.com/mpaw) in
[https://github.com/shelljs/shx/pull/227](https://redirect.github.com/shelljs/shx/pull/227)
- chore: update shelljs and drop old node support by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/228](https://redirect.github.com/shelljs/shx/pull/228)
- chore: drop non-LTS node versions by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/229](https://redirect.github.com/shelljs/shx/pull/229)
- chore: drop some dependencies and simplify by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/230](https://redirect.github.com/shelljs/shx/pull/230)
- chore: update dependencies by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/231](https://redirect.github.com/shelljs/shx/pull/231)
- fix: add back ShellJS version in --version by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/232](https://redirect.github.com/shelljs/shx/pull/232)
- Adding a global --negate flag by
[@&#8203;SoTrx](https://redirect.github.com/SoTrx) in
[https://github.com/shelljs/shx/pull/189](https://redirect.github.com/shelljs/shx/pull/189)
- refactor: code cleanup for the --negate flag by
[@&#8203;nfischer](https://redirect.github.com/nfischer) in
[https://github.com/shelljs/shx/pull/233](https://redirect.github.com/shelljs/shx/pull/233)

#### New Contributors

- [@&#8203;deining](https://redirect.github.com/deining) made their
first contribution in
[https://github.com/shelljs/shx/pull/220](https://redirect.github.com/shelljs/shx/pull/220)
- [@&#8203;tomhaines432](https://redirect.github.com/tomhaines432) made
their first contribution in
[https://github.com/shelljs/shx/pull/218](https://redirect.github.com/shelljs/shx/pull/218)
- [@&#8203;mpaw](https://redirect.github.com/mpaw) made their first
contribution in
[https://github.com/shelljs/shx/pull/227](https://redirect.github.com/shelljs/shx/pull/227)
- [@&#8203;SoTrx](https://redirect.github.com/SoTrx) made their first
contribution in
[https://github.com/shelljs/shx/pull/189](https://redirect.github.com/shelljs/shx/pull/189)

**Full Changelog**:
https://github.com/shelljs/shx/compare/v0.3.4...v0.4.0

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/js-sdk).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC40MC4zIiwidXBkYXRlZEluVmVyIjoiNDEuNzEuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-15 09:06:26 -04:00
OpenFeature Bot 04139affcb
chore(main): release server-sdk 1.19.0 (#1181)
🤖 I have created a release *beep* *boop*
---


##
[1.19.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.18.0...server-sdk-v1.19.0)
(2025-08-14)


###  New Features

* add evaluation-scoped hook data
([#1216](https://github.com/open-feature/js-sdk/issues/1216))
([07af3a9](07af3a9eda))


### 🐛 Bug Fixes

* update core dep
([#1228](https://github.com/open-feature/js-sdk/issues/1228))
([845d24c](845d24c5fe))


### 🧹 Chore

* update node to v20+
([#1203](https://github.com/open-feature/js-sdk/issues/1203))
([1f33453](1f33453c23))


### 📚 Documentation

* Clarify the behavior of setProviderAndWait
([#1180](https://github.com/open-feature/js-sdk/issues/1180))
([4fe8d87](4fe8d87a2e))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>
Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
Co-authored-by: Todd Baert <todd.baert@dynatrace.com>
2025-08-14 15:57:34 -04:00
OpenFeature Bot 1e330a2e13
chore(main): release web-sdk 1.6.1 (#1229)
🤖 I have created a release *beep* *boop*
---


##
[1.6.1](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.6.0...web-sdk-v1.6.1)
(2025-08-14)


### 🐛 Bug Fixes

* update core dep
([#1228](https://github.com/open-feature/js-sdk/issues/1228))
([845d24c](845d24c5fe))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>
2025-08-14 13:37:46 -04:00
Todd Baert 7df8a8eedc
chore: engine version in root
Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
2025-08-14 13:28:59 -04:00
Todd Baert 3b3515b601 chore: fix lockfile again
Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
2025-08-14 13:18:01 -04:00
Todd Baert 63a1feb213 chore: fix lockfile
Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
2025-08-14 13:10:38 -04:00
Todd Baert 845d24c5fe
fix: update core dep (#1228)
As astutely pointed out by @JasperJuergensen in
https://github.com/open-feature/js-sdk/issues/1227, we added API surface
in core which we use in the latest web, but did't accordingly update the
min version of core in web.

(I also updated the min core version in server, just because).

Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
2025-08-14 13:03:20 -04:00
OpenFeature Bot dc1970e717
chore(main): release web-sdk 1.6.0 (#1182)
🤖 I have created a release *beep* *boop*
---


##
[1.6.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.5.0...web-sdk-v1.6.0)
(2025-08-12)


###  New Features

* add evaluation-scoped hook data
([#1216](https://github.com/open-feature/js-sdk/issues/1216))
([07af3a9](07af3a9eda))
* **web-global-build:** publish web packages to unpkg and jsdelivr
([#1225](https://github.com/open-feature/js-sdk/issues/1225))
([40a512e](40a512e212))


### 📚 Documentation

* Clarify the behavior of setProviderAndWait
([#1180](https://github.com/open-feature/js-sdk/issues/1180))
([4fe8d87](4fe8d87a2e))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>
2025-08-12 23:27:08 +02:00
OpenFeature Bot e6e5ff3edf
chore(main): release core 1.9.0 (#1219)
🤖 I have created a release *beep* *boop*
---


##
[1.9.0](https://github.com/open-feature/js-sdk/compare/core-v1.8.1...core-v1.9.0)
(2025-08-10)


###  New Features

* add evaluation-scoped hook data
([#1216](https://github.com/open-feature/js-sdk/issues/1216))
([07af3a9](07af3a9eda))
* support Angular 20
([#1220](https://github.com/open-feature/js-sdk/issues/1220))
([aa232a9](aa232a9d6a))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>
2025-08-12 21:08:51 +00:00
Patryk Zdunowski 40a512e212
feat(web-global-build): impl (#1225)
<!-- Please use this template for your pull request. -->
<!-- Please use the sections that you need and delete other sections -->

## This PR

- Implements a global (IIFE) build for the web.
- Adds support for global distribution via CDN by including `unpkg` and
`jsdelivr` fields in `package.json`.

### Notes

- The global build outputs to `dist/global/index.js`.
- The global bundle is exposed under the global name `OpenFeature`.

### Follow-up Tasks

- Consider adding automated tests or validation for the global bundle.
- If this bundle should be included in a release checklist or CI, link
the appropriate issue here.

### How to test

1. Run `npm run build` and ensure `dist/global/index.js` is generated.
2. Serve the file locally or upload to a CDN and verify that
`window.OpenFeature` is available in the browser console.

### Motivation
Trying to load OpenFeature as a first dependency before browser will
resolve any modules.

---------

Signed-off-by: Patryk Zdunowski <zdunekhere@gmail.com>
2025-08-10 14:24:01 +00:00
OpenFeature Bot 28850b7f6d
chore(main): release angular-sdk 0.0.16 (#1221) 2025-07-25 21:18:43 +02:00
Lukas Reining aa232a9d6a
feat: support Angular 20 (#1220)
Adds Angular 20 support.

This also remove the custom jest setup as it was not compatible with
Angular 20 and uses builtin support for vitest in Angular 20.
For this, Angular has been removed from the jest setup and the pipeline
runs it separately now.

Fixes #1206

---------

Signed-off-by: Lukas Reining <lukas.reining@codecentric.de>
2025-07-25 18:48:38 +02:00
Michael Beemer 07af3a9eda
feat: add evaluation-scoped hook data (#1216)
Signed-off-by: Michael Beemer <beeme1mr@users.noreply.github.com>
2025-07-18 15:08:01 -04:00
47 changed files with 7511 additions and 8648 deletions

View File

@ -37,8 +37,11 @@ jobs:
- name: Lint
run: npm run lint
- name: Test
run: npm run test
- name: Test Jest Projects
run: npm run test:jest
- name: Test Angular SDK
run: npm run test:angular
codecov-and-docs:
runs-on: ubuntu-latest

View File

@ -1,8 +1,8 @@
{
"packages/nest": "0.2.5",
"packages/react": "1.0.0",
"packages/web": "1.5.0",
"packages/server": "1.18.0",
"packages/shared": "1.8.1",
"packages/angular/projects/angular-sdk": "0.0.15"
"packages/react": "1.0.1",
"packages/web": "1.6.1",
"packages/server": "1.19.0",
"packages/shared": "1.9.0",
"packages/angular/projects/angular-sdk": "0.0.16"
}

View File

@ -190,26 +190,6 @@ export default {
],
},
},
{
displayName: 'angular',
testEnvironment: 'jsdom',
preset: 'jest-preset-angular',
testMatch: ['<rootDir>/packages/angular/projects/angular-sdk/src/**/*.spec.{ts,tsx}'],
setupFilesAfterEnv: ['<rootDir>/packages/angular/setup-jest.ts'],
moduleNameMapper: {
'@openfeature/core': '<rootDir>/packages/shared/src',
'@openfeature/web-sdk': '<rootDir>/packages/web/src',
},
transform: {
'^.+\\.(ts|js|html|svg)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/packages/angular/tsconfig.json',
isolatedModules: true,
},
],
},
}
],
// Use this configuration option to add custom reporters to Jest

14226
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,15 @@
{
"name": "@openfeature/js",
"engines": {
"npm": "^10.0.0"
},
"version": "0.0.0",
"private": true,
"description": "OpenFeature SDK for JavaScript",
"scripts": {
"test": "jest --selectProjects=shared --selectProjects=server --selectProjects=web --selectProjects=react --selectProjects=angular --selectProjects=nest --silent",
"test": "npm run test:jest && npm run test:angular",
"test:jest": "jest --selectProjects=shared --selectProjects=server --selectProjects=web --selectProjects=react --selectProjects=nest --silent",
"test:angular": "npm run test:coverage --workspace=packages/angular",
"e2e-server": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/server/e2e/features && jest --selectProjects=server-e2e --verbose",
"e2e-web": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/web/e2e/features && jest --selectProjects=web-e2e --verbose",
"e2e": "npm run e2e-server && npm run e2e-web",
@ -54,14 +59,12 @@
"jest-environment-jsdom": "^29.7.0",
"jest-environment-node": "^29.7.0",
"jest-junit": "^16.0.0",
"jest-preset-angular": "^14.2.4",
"ng-packagr": "^18.2.1",
"prettier": "^3.2.5",
"react": "^18.2.0",
"rollup": "^4.0.0",
"rollup-plugin-dts": "^6.1.1",
"rxjs": "~7.8.0",
"shx": "^0.3.4",
"shx": "^0.4.0",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"tslib": "^2.3.0",
@ -69,9 +72,6 @@
"typescript": "^4.7.4",
"uuid": "^11.0.0"
},
"overrides": {
"typescript": "^4.7.4"
},
"workspaces": [
"packages/shared",
"packages/server",

View File

@ -10,7 +10,7 @@
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"builder": "@angular/build:ng-packagr",
"options": {
"project": "projects/angular-sdk/ng-package.json"
},
@ -32,6 +32,15 @@
"projects/angular-sdk/**/*.html"
]
}
},
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "projects/angular-sdk/tsconfig.spec.json",
"providersFile": "projects/angular-sdk/src/test-provider.ts",
"runner": "vitest",
"buildTarget": "::development"
}
}
}
}

View File

@ -7,37 +7,42 @@
"lint": "ng lint",
"lint:fix": "ng lint --fix",
"watch": "ng build --watch --configuration development",
"test": "jest",
"test": "ng test",
"test:coverage": "ng test --no-watch --code-coverage",
"build": "ng build && npm run postbuild",
"postbuild": "shx cp ./../../LICENSE ./dist/angular/LICENSE",
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm --prefix dist/angular run current-published-version -s)\" = \"$(npm --prefix dist/angular run current-version -s)\" ]; then echo 'already published, skipping'; else cd dist/angular && npm publish --access public; fi"
},
"private": true,
"devDependencies": {
"@angular-devkit/build-angular": "^19.0.0",
"@angular-eslint/builder": "19.3.0",
"@angular-eslint/eslint-plugin": "19.3.0",
"@angular-eslint/eslint-plugin-template": "19.3.0",
"@angular-eslint/schematics": "19.3.0",
"@angular-eslint/template-parser": "19.3.0",
"@angular/animations": "^19.0.0",
"@angular/cli": "^19.0.0",
"@angular/common": "^19.0.0",
"@angular/compiler": "^19.0.0",
"@angular/compiler-cli": "^19.0.0",
"@angular/core": "^19.0.0",
"@angular/forms": "^19.0.0",
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@angular-eslint/builder": "^20.1.1",
"@angular-eslint/eslint-plugin": "^20.1.1",
"@angular-eslint/eslint-plugin-template": "^20.1.1",
"@angular-eslint/schematics": "^20.1.1",
"@angular-eslint/template-parser": "^20.1.1",
"@angular/animations": "^20.1.1",
"@angular/build": "^20.1.1",
"@angular/cli": "^20.1.1",
"@angular/common": "^20.1.1",
"@angular/compiler": "^20.1.1",
"@angular/compiler-cli": "^20.1.1",
"@angular/core": "^20.1.1",
"@angular/forms": "^20.1.1",
"@angular/platform-browser": "^20.1.1",
"@angular/platform-browser-dynamic": "^20.1.1",
"@angular/router": "^20.1.1",
"@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^8.57.0",
"jest-preset-angular": "^14.2.4",
"ng-packagr": "^19.0.0",
"jsdom": "^26.1.0",
"ng-packagr": "^20.1.0",
"playwright": "^1.53.2",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"typescript": "^5.5.4",
"typescript": "^5.8.3",
"vitest": "^3.2.4",
"zone.js": "~0.15.0"
}
}

View File

@ -0,0 +1,18 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# For Angular's browser support policy, please see:
# https://angular.dev/reference/versions#browser-support
# You can see what browsers were selected by your queries by running:
# npx browserslist
Chrome >= 107
ChromeAndroid >= 107
Edge >= 107
Firefox >= 104
FirefoxAndroid >= 104
Safari >= 16
iOS >= 16

View File

@ -1,6 +1,14 @@
# Changelog
## [0.0.16](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.15...angular-sdk-v0.0.16) (2025-07-25)
### ✨ New Features
* support Angular 20 ([#1220](https://github.com/open-feature/js-sdk/issues/1220)) ([aa232a9](https://github.com/open-feature/js-sdk/commit/aa232a9d6a8dfa416380ccdecd71843d3e361048))
## [0.0.15](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.14...angular-sdk-v0.0.15) (2025-05-27)

View File

@ -16,8 +16,8 @@
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
</a>
<!-- x-release-please-start-version -->
<a href="https://github.com/open-feature/js-sdk/releases/tag/angular-sdk-v0.0.15">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.0.15&color=blue&style=for-the-badge" />
<a href="https://github.com/open-feature/js-sdk/releases/tag/angular-sdk-v0.0.16">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.0.16&color=blue&style=for-the-badge" />
</a>
<!-- x-release-please-end -->
<br/>

View File

@ -1,6 +1,6 @@
{
"name": "@openfeature/angular-sdk",
"version": "0.0.15",
"version": "0.0.16",
"description": "OpenFeature Angular SDK",
"repository": {
"type": "git",
@ -17,18 +17,18 @@
"prepack": "shx cp ./../../../../LICENSE ./LICENSE"
},
"peerDependencies": {
"@angular/common": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0",
"@angular/core": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0",
"@angular/common": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0 || ^20.0.0",
"@angular/core": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0 || ^20.0.0",
"@openfeature/web-sdk": "^1.4.1"
},
"dependencies": {
"tslib": "^2.3.0"
},
"devDependencies": {
"@openfeature/core": "*",
"@openfeature/web-sdk": "*",
"@angular/common": "^19.0.0",
"@angular/core": "^19.0.0"
"@openfeature/core": "^1.8.1",
"@openfeature/web-sdk": "^1.5.0",
"@angular/common": "^20.1.2",
"@angular/core": "^20.1.2"
},
"sideEffects": false,
"keywords": [

View File

@ -253,6 +253,7 @@ describe('FeatureFlagDirective', () => {
await expectRenderedText(fixture, 'case-2', 'Flag On');
await updateFlagValue(provider, false);
fixture.detectChanges(); // Ensure change detection after flag update
await expectRenderedText(fixture, 'case-2', 'Flag Off');
});
});
@ -393,7 +394,9 @@ describe('FeatureFlagDirective', () => {
await waitForClientReady(client);
await expectRenderedText(fixture, 'case-6', 'Flag On');
fixture.componentInstance.specialFlagKey = 'new-test-flag';
fixture.componentRef.setInput('specialFlagKey', 'new-test-flag');
await fixture.whenStable();
await expectRenderedText(fixture, 'case-6', 'Flag Off');
});
@ -445,7 +448,9 @@ describe('FeatureFlagDirective', () => {
);
await OpenFeature.setProviderAndWait(newDomain, newProvider);
fixture.componentInstance.domain = newDomain;
fixture.componentRef.setInput('domain', newDomain);
await fixture.whenStable();
await expectRenderedText(fixture, 'case-6', 'Flag Off');
});
});
@ -560,8 +565,7 @@ async function createTestingModule(config?: {
],
}).createComponent(TestComponent);
fixture.componentInstance.domain = domain;
fixture.detectChanges();
fixture.componentRef.setInput('domain', domain);
await fixture.whenStable();
const client = OpenFeature.getClient(domain);

View File

@ -8,6 +8,7 @@ import {
OnInit,
TemplateRef,
ViewContainerRef,
inject,
} from '@angular/core';
import {
Client,
@ -35,6 +36,9 @@ class FeatureFlagDirectiveContext<T extends FlagValue> {
selector: '[featureFlag]',
})
export abstract class FeatureFlagDirective<T extends FlagValue> implements OnInit, OnDestroy, OnChanges {
protected _changeDetectorRef: ChangeDetectorRef;
protected _viewContainerRef: ViewContainerRef;
protected _featureFlagDefault: T;
protected _featureFlagDomain: string | undefined;
@ -64,13 +68,7 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
protected _reconcilingTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
protected _reconcilingViewRef: EmbeddedViewRef<unknown> | null;
protected constructor(
protected _changeDetectorRef: ChangeDetectorRef,
protected _viewContainerRef: ViewContainerRef,
templateRef: TemplateRef<FeatureFlagDirectiveContext<T>>,
) {
this._thenTemplateRef = templateRef;
}
protected constructor() {}
set featureFlagDomain(domain: string | undefined) {
/**
@ -232,6 +230,10 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
selector: '[booleanFeatureFlag]',
})
export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> implements OnChanges {
override _changeDetectorRef = inject(ChangeDetectorRef);
override _viewContainerRef = inject(ViewContainerRef);
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<boolean>>>(TemplateRef);
/**
* The key of the boolean feature flag.
*/
@ -242,12 +244,8 @@ export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> i
*/
@Input({ required: true }) booleanFeatureFlagDefault: boolean;
constructor(
_changeDetectorRef: ChangeDetectorRef,
_viewContainerRef: ViewContainerRef,
templateRef: TemplateRef<FeatureFlagDirectiveContext<boolean>>,
) {
super(_changeDetectorRef, _viewContainerRef, templateRef);
constructor() {
super();
}
override ngOnChanges() {
@ -347,6 +345,10 @@ export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> i
selector: '[numberFeatureFlag]',
})
export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> implements OnChanges {
override _changeDetectorRef = inject(ChangeDetectorRef);
override _viewContainerRef = inject(ViewContainerRef);
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<number>>>(TemplateRef);
/**
* The key of the number feature flag.
*/
@ -362,12 +364,8 @@ export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> imp
*/
@Input({ required: false }) numberFeatureFlagValue?: number;
constructor(
_changeDetectorRef: ChangeDetectorRef,
_viewContainerRef: ViewContainerRef,
templateRef: TemplateRef<FeatureFlagDirectiveContext<number>>,
) {
super(_changeDetectorRef, _viewContainerRef, templateRef);
constructor() {
super();
}
override ngOnChanges() {
@ -467,6 +465,10 @@ export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> imp
selector: '[stringFeatureFlag]',
})
export class StringFeatureFlagDirective extends FeatureFlagDirective<string> implements OnChanges {
override _changeDetectorRef = inject(ChangeDetectorRef);
override _viewContainerRef = inject(ViewContainerRef);
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<string>>>(TemplateRef);
/**
* The key of the string feature flag.
*/
@ -482,12 +484,8 @@ export class StringFeatureFlagDirective extends FeatureFlagDirective<string> imp
*/
@Input({ required: false }) stringFeatureFlagValue?: string;
constructor(
_changeDetectorRef: ChangeDetectorRef,
_viewContainerRef: ViewContainerRef,
templateRef: TemplateRef<FeatureFlagDirectiveContext<string>>,
) {
super(_changeDetectorRef, _viewContainerRef, templateRef);
constructor() {
super();
}
override ngOnChanges() {
@ -587,6 +585,10 @@ export class StringFeatureFlagDirective extends FeatureFlagDirective<string> imp
selector: '[objectFeatureFlag]',
})
export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlagDirective<T> implements OnChanges {
override _changeDetectorRef = inject(ChangeDetectorRef);
override _viewContainerRef = inject(ViewContainerRef);
override _thenTemplateRef = inject<TemplateRef<FeatureFlagDirectiveContext<T>>>(TemplateRef);
/**
* The key of the object feature flag.
*/
@ -602,12 +604,8 @@ export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlag
*/
@Input({ required: false }) objectFeatureFlagValue?: T;
constructor(
_changeDetectorRef: ChangeDetectorRef,
_viewContainerRef: ViewContainerRef,
templateRef: TemplateRef<FeatureFlagDirectiveContext<T>>,
) {
super(_changeDetectorRef, _viewContainerRef, templateRef);
constructor() {
super();
}
override ngOnChanges() {

View File

@ -0,0 +1,3 @@
import { provideZonelessChangeDetection } from '@angular/core';
export default [provideZonelessChangeDetection()];

View File

@ -3,15 +3,20 @@
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jest",
"vitest/globals",
"node"
],
"paths": {
"angular": [
"./dist/angular"
]
},
"esModuleInterop": true,
"emitDecoratorMetadata": true
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts",
"setup-jest.ts"
"src/test-provider.ts"
]
}

View File

@ -1,5 +0,0 @@
import 'jest-preset-angular/setup-jest';
import { TestBed } from '@angular/core/testing';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
TestBed.initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting());

View File

@ -23,8 +23,8 @@
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "ES2015",
"module": "ES2015",
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"strictNullChecks": false,
"lib": [

View File

@ -3,13 +3,14 @@
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jest"
"vitest/globals",
"node"
],
"esModuleInterop": true,
"emitDecoratorMetadata": true
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
"projects/angular-sdk/src/**/*.spec.ts",
"projects/angular-sdk/src/**/*.d.ts"
]
}

View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
},
});

View File

@ -1,5 +1,12 @@
# Changelog
## [1.0.1](https://github.com/open-feature/js-sdk/compare/react-sdk-v1.0.0...react-sdk-v1.0.1) (2025-08-18)
### 🐛 Bug Fixes
* **react:** re-evaluate flags on re-render to detect silent provider … ([#1226](https://github.com/open-feature/js-sdk/issues/1226)) ([3105595](https://github.com/open-feature/js-sdk/commit/31055959265a53f52102590f54fa3168811ec678))
## [1.0.0](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.11...react-sdk-v1.0.0) (2025-04-14)

View File

@ -16,8 +16,8 @@
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
</a>
<!-- x-release-please-start-version -->
<a href="https://github.com/open-feature/js-sdk/releases/tag/react-sdk-v1.0.0">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.0.0&color=blue&style=for-the-badge" />
<a href="https://github.com/open-feature/js-sdk/releases/tag/react-sdk-v1.0.1">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.0.1&color=blue&style=for-the-badge" />
</a>
<!-- x-release-please-end -->
<br/>

View File

@ -1,6 +1,6 @@
{
"name": "@openfeature/react-sdk",
"version": "1.0.0",
"version": "1.0.1",
"description": "OpenFeature React SDK",
"main": "./dist/cjs/index.js",
"files": [

View File

@ -8,7 +8,7 @@ import type {
JsonValue,
} from '@openfeature/web-sdk';
import { ProviderEvents, ProviderStatus } from '@openfeature/web-sdk';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
DEFAULT_OPTIONS,
isEqual,
@ -287,8 +287,7 @@ function attachHandlersAndResolve<T extends FlagValue>(
const client = useOpenFeatureClient();
const status = useOpenFeatureClientStatus();
const provider = useOpenFeatureProvider();
const controller = new AbortController();
const isFirstRender = useRef(true);
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
suspendUntilInitialized(provider, client);
@ -298,9 +297,22 @@ function attachHandlersAndResolve<T extends FlagValue>(
suspendUntilReconciled(client);
}
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(() =>
resolver(client).call(client, flagKey, defaultValue, options),
);
// Re-evaluate when dependencies change (handles prop changes like flagKey), or if during a re-render, we have detected a change in the evaluated value
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
const newDetails = resolver(client).call(client, flagKey, defaultValue, options);
if (!isEqual(newDetails.value, evaluationDetails.value)) {
setEvaluationDetails(newDetails);
}
}, [client, flagKey, defaultValue, options, resolver, evaluationDetails]);
// Maintain a mutable reference to the evaluation details to have a up-to-date reference in the handlers.
const evaluationDetailsRef = useRef<EvaluationDetails<T>>(evaluationDetails);
@ -308,7 +320,7 @@ function attachHandlersAndResolve<T extends FlagValue>(
evaluationDetailsRef.current = evaluationDetails;
}, [evaluationDetails]);
const updateEvaluationDetailsCallback = () => {
const updateEvaluationDetailsCallback = useCallback(() => {
const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options);
/**
@ -319,15 +331,19 @@ function attachHandlersAndResolve<T extends FlagValue>(
if (!isEqual(updatedEvaluationDetails.value, evaluationDetailsRef.current.value)) {
setEvaluationDetails(updatedEvaluationDetails);
}
};
}, [client, flagKey, defaultValue, options, resolver]);
const configurationChangeCallback: EventHandler<ClientProviderEvents.ConfigurationChanged> = (eventDetails) => {
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
updateEvaluationDetailsCallback();
}
};
const configurationChangeCallback = useCallback<EventHandler<ClientProviderEvents.ConfigurationChanged>>(
(eventDetails) => {
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
updateEvaluationDetailsCallback();
}
},
[flagKey, updateEvaluationDetailsCallback],
);
useEffect(() => {
const controller = new AbortController();
if (status === ProviderStatus.NOT_READY) {
// update when the provider is ready
client.addHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback, { signal: controller.signal });
@ -348,7 +364,14 @@ function attachHandlersAndResolve<T extends FlagValue>(
// cleanup the handlers
controller.abort();
};
}, []);
}, [
client,
status,
defaultedOptions.updateOnContextChanged,
defaultedOptions.updateOnConfigurationChanged,
updateEvaluationDetailsCallback,
configurationChangeCallback,
]);
return evaluationDetails;
}

View File

@ -924,17 +924,124 @@ describe('evaluation', () => {
OpenFeature.setContext(SUSPEND_OFF, { user: TARGETED_USER });
});
// expect to see static value until we reconcile
await waitFor(() => expect(screen.queryByText(STATIC_FLAG_VALUE_A)).toBeInTheDocument(), {
timeout: DELAY / 2,
});
// make sure we updated after reconciling
// With the fix for useState initialization, the hook now immediately
// reflects provider state changes. This is intentional to handle cases
// where providers don't emit proper events.
// The value updates immediately to the targeted value.
await waitFor(() => expect(screen.queryByText(TARGETED_FLAG_VALUE)).toBeInTheDocument(), {
timeout: DELAY * 2,
});
});
});
describe('re-render behavior when flag values change without provider events', ()=> {
it('should reflect provider state changes on re-render even without provider events', async () => {
let providerValue = 'initial-value';
class SilentUpdateProvider extends InMemoryProvider {
resolveBooleanEvaluation() {
return {
value: true,
variant: 'on',
reason: StandardResolutionReasons.STATIC,
};
}
resolveStringEvaluation() {
return {
value: providerValue,
variant: providerValue,
reason: StandardResolutionReasons.STATIC,
};
}
}
const provider = new SilentUpdateProvider({});
await OpenFeature.setProviderAndWait('test', provider);
// The triggerRender prop forces a re-render
const TestComponent = ({ triggerRender }: { triggerRender: number }) => {
const { value } = useFlag('test-flag', 'default');
return <div data-testid="flag-value" data-render-count={triggerRender}>{value}</div>;
};
const WrapperComponent = () => {
const [renderCount, setRenderCount] = useState(0);
return (
<>
<button onClick={() => setRenderCount(c => c + 1)}>Force Re-render</button>
<TestComponent triggerRender={renderCount} />
</>
);
};
const { getByText } = render(
<OpenFeatureProvider client={OpenFeature.getClient('test')}>
<WrapperComponent />
</OpenFeatureProvider>
);
// Initial value should be rendered
await waitFor(() => {
expect(screen.getByTestId('flag-value')).toHaveTextContent('initial-value');
});
// Change the provider's internal state (without emitting events)
providerValue = 'updated-value';
// Force a re-render of the component
act(() => {
getByText('Force Re-render').click();
});
await waitFor(() => {
expect(screen.getByTestId('flag-value')).toHaveTextContent('updated-value');
});
});
it('should update flag value when flag key prop changes without provider events', async () => {
const provider = new InMemoryProvider({
'flag-a': {
disabled: false,
variants: { on: 'value-a' },
defaultVariant: 'on',
},
'flag-b': {
disabled: false,
variants: { on: 'value-b' },
defaultVariant: 'on',
},
});
await OpenFeature.setProviderAndWait(EVALUATION, provider);
const TestComponent = ({ flagKey }: { flagKey: string }) => {
const { value } = useFlag(flagKey, 'default');
return <div data-testid="flag-value">{value}</div>;
};
const { rerender } = render(
<OpenFeatureProvider client={OpenFeature.getClient(EVALUATION)}>
<TestComponent flagKey="flag-a" />
</OpenFeatureProvider>
);
await waitFor(() => {
expect(screen.getByTestId('flag-value')).toHaveTextContent('value-a');
});
// Change to flag-b (without any provider events)
rerender(
<OpenFeatureProvider client={OpenFeature.getClient(EVALUATION)}>
<TestComponent flagKey="flag-b" />
</OpenFeatureProvider>
);
await waitFor(() => {
expect(screen.getByTestId('flag-value')).toHaveTextContent('value-b');
});
});
});
});
describe('context, hooks and options', () => {

View File

@ -288,7 +288,7 @@ describe('OpenFeatureProvider', () => {
{ timeout: DELAY * 4 },
);
expect(screen.getByText('Will says hi')).toBeInTheDocument();
expect(screen.getByText('Will says aloha')).toBeInTheDocument();
});
});
});

View File

@ -1,5 +1,27 @@
# Changelog
## [1.19.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.18.0...server-sdk-v1.19.0) (2025-08-14)
### ✨ New Features
* add evaluation-scoped hook data ([#1216](https://github.com/open-feature/js-sdk/issues/1216)) ([07af3a9](https://github.com/open-feature/js-sdk/commit/07af3a9eda895e9edb24c7ee1e3c1c4f16e17431))
### 🐛 Bug Fixes
* update core dep ([#1228](https://github.com/open-feature/js-sdk/issues/1228)) ([845d24c](https://github.com/open-feature/js-sdk/commit/845d24c5fecc80de3080e49fde839f08ecac6b33))
### 🧹 Chore
* update node to v20+ ([#1203](https://github.com/open-feature/js-sdk/issues/1203)) ([1f33453](https://github.com/open-feature/js-sdk/commit/1f33453c23df0763cbf0d0b44db8d91216377009))
### 📚 Documentation
* Clarify the behavior of setProviderAndWait ([#1180](https://github.com/open-feature/js-sdk/issues/1180)) ([4fe8d87](https://github.com/open-feature/js-sdk/commit/4fe8d87a2e5df2cbd4086cc4f4a380e8857ed8ba))
## [1.18.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.17.1...server-sdk-v1.18.0) (2025-04-11)

View File

@ -16,8 +16,8 @@
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
</a>
<!-- x-release-please-start-version -->
<a href="https://github.com/open-feature/js-sdk/releases/tag/server-sdk-v1.18.0">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.18.0&color=blue&style=for-the-badge" />
<a href="https://github.com/open-feature/js-sdk/releases/tag/server-sdk-v1.19.0">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.19.0&color=blue&style=for-the-badge" />
</a>
<!-- x-release-please-end -->
<br/>

View File

@ -1,6 +1,6 @@
{
"name": "@openfeature/server-sdk",
"version": "1.18.0",
"version": "1.19.0",
"description": "OpenFeature SDK for JavaScript",
"main": "./dist/cjs/index.js",
"files": [
@ -48,9 +48,9 @@
"node": ">=20"
},
"peerDependencies": {
"@openfeature/core": "^1.7.0"
"@openfeature/core": "^1.9.0"
},
"devDependencies": {
"@openfeature/core": "^1.7.0"
"@openfeature/core": "^1.9.0"
}
}

View File

@ -22,6 +22,7 @@ import {
StandardResolutionReasons,
instantiateErrorByErrorCode,
statusMatchesEvent,
MapHookData,
} from '@openfeature/core';
import type { FlagEvaluationOptions } from '../../evaluation';
import type { ProviderEvents } from '../../events';
@ -276,22 +277,26 @@ export class OpenFeatureClient implements Client {
const mergedContext = this.mergeContexts(invocationContext);
// this reference cannot change during the course of evaluation
// it may be used as a key in WeakMaps
const hookContext: Readonly<HookContext> = {
flagKey,
defaultValue,
flagValueType: flagType,
clientMetadata: this.metadata,
providerMetadata: this._provider.metadata,
context: mergedContext,
logger: this._logger,
};
// Create hook context instances for each hook (stable object references for the entire evaluation)
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index.
const hookContexts = allHooksReversed.map<HookContext>(() =>
Object.freeze({
flagKey,
defaultValue,
flagValueType: flagType,
clientMetadata: this.metadata,
providerMetadata: this._provider.metadata,
context: mergedContext,
logger: this._logger,
hookData: new MapHookData(),
}),
);
let evaluationDetails: EvaluationDetails<T>;
try {
const frozenContext = await this.beforeHooks(allHooks, hookContext, options);
const frozenContext = await this.beforeHooks(allHooks, hookContexts, mergedContext, options);
this.shortCircuitIfNotReady();
@ -306,53 +311,71 @@ export class OpenFeatureClient implements Client {
if (resolutionDetails.errorCode) {
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
await this.errorHooks(allHooksReversed, hookContext, err, options);
await this.errorHooks(allHooksReversed, hookContexts, err, options);
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
} else {
await this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
await this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
evaluationDetails = resolutionDetails;
}
} catch (err: unknown) {
await this.errorHooks(allHooksReversed, hookContext, err, options);
await this.errorHooks(allHooksReversed, hookContexts, err, options);
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
}
await this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
await this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
return evaluationDetails;
}
private async beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
for (const hook of hooks) {
// freeze the hookContext
Object.freeze(hookContext);
private async beforeHooks(
hooks: Hook[],
hookContexts: HookContext[],
mergedContext: EvaluationContext,
options: FlagEvaluationOptions,
) {
let accumulatedContext = mergedContext;
// use Object.assign to avoid modification of frozen hookContext
Object.assign(hookContext.context, {
...hookContext.context,
...(await hook?.before?.(hookContext, Object.freeze(options.hookHints))),
});
for (const [index, hook] of hooks.entries()) {
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks
const hookContext = hookContexts[hookContextIndex];
// Update the context on the stable hook context object
Object.assign(hookContext.context, accumulatedContext);
const hookResult = await hook?.before?.(hookContext, Object.freeze(options.hookHints));
if (hookResult) {
accumulatedContext = {
...accumulatedContext,
...hookResult,
};
for (let i = 0; i < hooks.length; i++) {
Object.assign(hookContexts[hookContextIndex].context, accumulatedContext);
}
}
}
// after before hooks, freeze the EvaluationContext.
return Object.freeze(hookContext.context);
return Object.freeze(accumulatedContext);
}
private async afterHooks(
hooks: Hook[],
hookContext: HookContext,
hookContexts: HookContext[],
evaluationDetails: EvaluationDetails<FlagValue>,
options: FlagEvaluationOptions,
) {
// run "after" hooks sequentially
for (const hook of hooks) {
for (const [index, hook] of hooks.entries()) {
const hookContext = hookContexts[index];
await hook?.after?.(hookContext, evaluationDetails, options.hookHints);
}
}
private async errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
private async errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) {
// run "error" hooks sequentially
for (const hook of hooks) {
for (const [index, hook] of hooks.entries()) {
try {
const hookContext = hookContexts[index];
await hook?.error?.(hookContext, err, options.hookHints);
} catch (err) {
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
@ -366,13 +389,14 @@ export class OpenFeatureClient implements Client {
private async finallyHooks(
hooks: Hook[],
hookContext: HookContext,
hookContexts: HookContext[],
evaluationDetails: EvaluationDetails<FlagValue>,
options: FlagEvaluationOptions,
) {
// run "finally" hooks sequentially
for (const hook of hooks) {
for (const [index, hook] of hooks.entries()) {
try {
const hookContext = hookContexts[index];
await hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
} catch (err) {
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);

View File

@ -1,7 +1,8 @@
import type { BaseHook, EvaluationContext, FlagValue } from '@openfeature/core';
export type Hook = BaseHook<
export type Hook<TData = Record<string, unknown>> = BaseHook<
FlagValue,
TData,
Promise<EvaluationContext | void> | EvaluationContext | void,
Promise<void> | void
>;

View File

@ -0,0 +1,508 @@
import { OpenFeature } from '../src';
import type { Client } from '../src/client';
import type {
JsonValue,
ResolutionDetails,
HookContext,
BeforeHookContext,
HookData} from '@openfeature/core';
import {
StandardResolutionReasons
} from '@openfeature/core';
import type { Provider } from '../src/provider';
import type { Hook } from '../src/hooks';
const BOOLEAN_VALUE = true;
const STRING_VALUE = 'val';
const NUMBER_VALUE = 1;
const OBJECT_VALUE = { key: 'value' };
// A test hook that stores data in the before stage and retrieves it in after/error/finally
class TestHookWithData implements Hook {
beforeData: unknown;
afterData: unknown;
errorData: unknown;
finallyData: unknown;
async before(hookContext: BeforeHookContext) {
// Store some data
hookContext.hookData.set('testKey', 'testValue');
hookContext.hookData.set('timestamp', Date.now());
hookContext.hookData.set('object', { nested: 'value' });
this.beforeData = hookContext.hookData.get('testKey');
}
async after(hookContext: HookContext) {
// Retrieve data stored in before
this.afterData = hookContext.hookData.get('testKey');
}
async error(hookContext: HookContext) {
// Retrieve data stored in before
this.errorData = hookContext.hookData.get('testKey');
}
async finally(hookContext: HookContext) {
// Retrieve data stored in before
this.finallyData = hookContext.hookData.get('testKey');
}
}
// Typed hook example demonstrating improved type safety
interface OpenTelemetryData {
spanId: string;
traceId: string;
startTime: number;
attributes: Record<string, string | number | boolean>;
}
class TypedOpenTelemetryHook implements Hook {
spanId?: string;
duration?: number;
async before(hookContext: BeforeHookContext) {
const spanId = `span-${Math.random().toString(36).substring(2, 11)}`;
const traceId = `trace-${Math.random().toString(36).substring(2, 11)}`;
// Demonstrate that we can cast for type safety while maintaining compatibility
const typedHookData = hookContext.hookData as unknown as HookData<OpenTelemetryData>;
// Type-safe setting with proper intellisense
typedHookData.set('spanId', spanId);
typedHookData.set('traceId', traceId);
typedHookData.set('startTime', Date.now());
typedHookData.set('attributes', {
flagKey: hookContext.flagKey,
clientName: hookContext.clientMetadata.name || 'unknown',
providerName: hookContext.providerMetadata.name,
});
this.spanId = spanId;
}
async after(hookContext: HookContext) {
// Type-safe getting with proper return types
const typedHookData = hookContext.hookData as unknown as HookData<OpenTelemetryData>;
const startTime: number | undefined = typedHookData.get('startTime');
const spanId: string | undefined = typedHookData.get('spanId');
if (startTime && spanId) {
this.duration = Date.now() - startTime;
// Simulate span completion
}
}
async error(hookContext: HookContext) {
const typedHookData = hookContext.hookData as unknown as HookData<OpenTelemetryData>;
const spanId: string | undefined = typedHookData.get('spanId');
if (spanId) {
// Mark span as error
}
}
}
// A timing hook that measures evaluation duration
class TimingHook implements Hook {
duration?: number;
async before(hookContext: BeforeHookContext) {
hookContext.hookData.set('startTime', Date.now());
}
async after(hookContext: HookContext) {
const startTime = hookContext.hookData.get('startTime') as number;
if (startTime) {
this.duration = Date.now() - startTime;
}
}
async error(hookContext: HookContext) {
const startTime = hookContext.hookData.get('startTime') as number;
if (startTime) {
this.duration = Date.now() - startTime;
}
}
}
// Hook that tests hook data isolation
class IsolationTestHook implements Hook {
hookId: string;
constructor(id: string) {
this.hookId = id;
}
before(hookContext: BeforeHookContext) {
const storedId = hookContext.hookData.get('hookId');
if (storedId) {
throw new Error('Hook data isolation violated! Data is set in before hook.');
}
// Each hook instance should have its own data
hookContext.hookData.set('hookId', this.hookId);
hookContext.hookData.set(`data_${this.hookId}`, `value_${this.hookId}`);
}
after(hookContext: HookContext) {
// Verify we can only see our own data
const storedId = hookContext.hookData.get('hookId');
if (storedId !== this.hookId) {
throw new Error(`Hook data isolation violated! Expected ${this.hookId}, got ${storedId}`);
}
}
}
// Mock provider for testing
const MOCK_PROVIDER: Provider = {
metadata: { name: 'mock-provider' },
async resolveBooleanEvaluation(): Promise<ResolutionDetails<boolean>> {
return {
value: BOOLEAN_VALUE,
variant: 'default',
reason: StandardResolutionReasons.DEFAULT,
};
},
async resolveStringEvaluation(): Promise<ResolutionDetails<string>> {
return {
value: STRING_VALUE,
variant: 'default',
reason: StandardResolutionReasons.DEFAULT,
};
},
async resolveNumberEvaluation(): Promise<ResolutionDetails<number>> {
return {
value: NUMBER_VALUE,
variant: 'default',
reason: StandardResolutionReasons.DEFAULT,
};
},
async resolveObjectEvaluation<T extends JsonValue>(): Promise<ResolutionDetails<T>> {
return {
value: OBJECT_VALUE as unknown as T,
variant: 'default',
reason: StandardResolutionReasons.DEFAULT,
};
},
};
// Mock provider that throws an error
const ERROR_PROVIDER: Provider = {
metadata: { name: 'error-provider' },
async resolveBooleanEvaluation(): Promise<ResolutionDetails<boolean>> {
throw new Error('Provider error');
},
async resolveStringEvaluation(): Promise<ResolutionDetails<string>> {
throw new Error('Provider error');
},
async resolveNumberEvaluation(): Promise<ResolutionDetails<number>> {
throw new Error('Provider error');
},
async resolveObjectEvaluation<T extends JsonValue>(): Promise<ResolutionDetails<T>> {
throw new Error('Provider error');
},
};
describe('Hook Data', () => {
let client: Client;
beforeEach(async () => {
OpenFeature.clearHooks();
await OpenFeature.setProviderAndWait(MOCK_PROVIDER);
client = OpenFeature.getClient();
});
afterEach(async () => {
await OpenFeature.clearProviders();
});
describe('Basic Hook Data Functionality', () => {
it('should allow hooks to store and retrieve data across stages', async () => {
const hook = new TestHookWithData();
client.addHooks(hook);
await client.getBooleanValue('test-flag', false);
// Verify data was stored in before and retrieved in all other stages
expect(hook.beforeData).toBe('testValue');
expect(hook.afterData).toBe('testValue');
expect(hook.finallyData).toBe('testValue');
});
it('should support storing different data types', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const storedValues: any = {};
const hook: Hook = {
async before(hookContext: BeforeHookContext) {
// Store various types
hookContext.hookData.set('string', 'test');
hookContext.hookData.set('number', 42);
hookContext.hookData.set('boolean', true);
hookContext.hookData.set('object', { key: 'value' });
hookContext.hookData.set('array', [1, 2, 3]);
hookContext.hookData.set('null', null);
hookContext.hookData.set('undefined', undefined);
},
async after(hookContext: HookContext) {
storedValues.string = hookContext.hookData.get('string');
storedValues.number = hookContext.hookData.get('number');
storedValues.boolean = hookContext.hookData.get('boolean');
storedValues.object = hookContext.hookData.get('object');
storedValues.array = hookContext.hookData.get('array');
storedValues.null = hookContext.hookData.get('null');
storedValues.undefined = hookContext.hookData.get('undefined');
},
};
client.addHooks(hook);
await client.getBooleanValue('test-flag', false);
expect(storedValues.string).toBe('test');
expect(storedValues.number).toBe(42);
expect(storedValues.boolean).toBe(true);
expect(storedValues.object).toEqual({ key: 'value' });
expect(storedValues.array).toEqual([1, 2, 3]);
expect(storedValues.null).toBeNull();
expect(storedValues.undefined).toBeUndefined();
});
it('should handle hook data in error scenarios', async () => {
await OpenFeature.setProviderAndWait(ERROR_PROVIDER);
const hook = new TestHookWithData();
client.addHooks(hook);
await client.getBooleanValue('test-flag', false);
// Verify data was accessible in error and finally stages
expect(hook.beforeData).toBe('testValue');
expect(hook.errorData).toBe('testValue');
expect(hook.finallyData).toBe('testValue');
expect(hook.afterData).toBeUndefined(); // after should not run on error
});
});
describe('Hook Data API', () => {
it('should support has() method', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hasResults: any = {};
const hook: Hook = {
async before(hookContext: BeforeHookContext) {
hookContext.hookData.set('exists', 'value');
hasResults.beforeExists = hookContext.hookData.has('exists');
hasResults.beforeNotExists = hookContext.hookData.has('notExists');
},
async after(hookContext: HookContext) {
hasResults.afterExists = hookContext.hookData.has('exists');
hasResults.afterNotExists = hookContext.hookData.has('notExists');
},
};
client.addHooks(hook);
await client.getBooleanValue('test-flag', false);
expect(hasResults.beforeExists).toBe(true);
expect(hasResults.beforeNotExists).toBe(false);
expect(hasResults.afterExists).toBe(true);
expect(hasResults.afterNotExists).toBe(false);
});
it('should support delete() method', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deleteResults: any = {};
const hook: Hook = {
async before(hookContext: BeforeHookContext) {
hookContext.hookData.set('toDelete', 'value');
deleteResults.hasBeforeDelete = hookContext.hookData.has('toDelete');
deleteResults.deleteResult = hookContext.hookData.delete('toDelete');
deleteResults.hasAfterDelete = hookContext.hookData.has('toDelete');
deleteResults.deleteAgainResult = hookContext.hookData.delete('toDelete');
},
};
client.addHooks(hook);
await client.getBooleanValue('test-flag', false);
expect(deleteResults.hasBeforeDelete).toBe(true);
expect(deleteResults.deleteResult).toBe(true);
expect(deleteResults.hasAfterDelete).toBe(false);
expect(deleteResults.deleteAgainResult).toBe(false);
});
it('should support clear() method', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const clearResults: any = {};
const hook: Hook = {
async before(hookContext: BeforeHookContext) {
hookContext.hookData.set('key1', 'value1');
hookContext.hookData.set('key2', 'value2');
hookContext.hookData.set('key3', 'value3');
clearResults.hasBeforeClear = hookContext.hookData.has('key1');
hookContext.hookData.clear();
clearResults.hasAfterClear = hookContext.hookData.has('key1');
},
async after(hookContext: HookContext) {
// Verify all data was cleared
clearResults.afterHasKey1 = hookContext.hookData.has('key1');
clearResults.afterHasKey2 = hookContext.hookData.has('key2');
clearResults.afterHasKey3 = hookContext.hookData.has('key3');
},
};
client.addHooks(hook);
await client.getBooleanValue('test-flag', false);
expect(clearResults.hasBeforeClear).toBe(true);
expect(clearResults.hasAfterClear).toBe(false);
expect(clearResults.afterHasKey1).toBe(false);
expect(clearResults.afterHasKey2).toBe(false);
expect(clearResults.afterHasKey3).toBe(false);
});
});
describe('Hook Data Isolation', () => {
it('should isolate data between different hook instances', async () => {
const hook1 = new IsolationTestHook('hook1');
const hook2 = new IsolationTestHook('hook2');
const hook3 = new IsolationTestHook('hook3');
client.addHooks(hook1, hook2, hook3);
expect(await client.getBooleanValue('test-flag', false)).toBe(true);
});
it('should isolate data between the same hook instance', async () => {
const hook = new IsolationTestHook('hook');
client.addHooks(hook, hook);
expect(await client.getBooleanValue('test-flag', false)).toBe(true);
});
it('should not share data between different evaluations', async () => {
let firstEvalData: unknown;
let secondEvalData: unknown;
const hook: Hook = {
async before(hookContext: BeforeHookContext) {
// Check if data exists from previous evaluation
const existingData = hookContext.hookData.get('evalData');
if (existingData) {
throw new Error('Hook data leaked between evaluations!');
}
hookContext.hookData.set('evalData', 'evaluation-specific');
},
async after(hookContext: HookContext) {
if (!firstEvalData) {
firstEvalData = hookContext.hookData.get('evalData');
} else {
secondEvalData = hookContext.hookData.get('evalData');
}
},
};
client.addHooks(hook);
// First evaluation
await client.getBooleanValue('test-flag', false);
// Second evaluation
await client.getBooleanValue('test-flag', false);
expect(firstEvalData).toBe('evaluation-specific');
expect(secondEvalData).toBe('evaluation-specific');
});
it('should isolate data between global, client, and invocation hooks', async () => {
const globalHook = new IsolationTestHook('global');
const clientHook = new IsolationTestHook('client');
const invocationHook = new IsolationTestHook('invocation');
OpenFeature.addHooks(globalHook);
client.addHooks(clientHook);
expect(await client.getBooleanValue('test-flag', false, {}, { hooks: [invocationHook] })).toBe(true);
});
});
describe('Use Cases', () => {
it('should support timing measurements', async () => {
const timingHook = new TimingHook();
client.addHooks(timingHook);
await client.getBooleanValue('test-flag', false);
expect(timingHook.duration).toBeDefined();
expect(timingHook.duration).toBeGreaterThanOrEqual(0);
});
it('should support multi-stage validation accumulation', async () => {
let finalErrors: string[] = [];
const validationHook: Hook = {
async before(hookContext: BeforeHookContext) {
hookContext.hookData.set('errors', []);
// Simulate validation
const errors = hookContext.hookData.get('errors') as string[];
if (!hookContext.context.userId) {
errors.push('Missing userId');
}
if (!hookContext.context.region) {
errors.push('Missing region');
}
},
async finally(hookContext: HookContext) {
finalErrors = (hookContext.hookData.get('errors') as string[]) || [];
},
};
client.addHooks(validationHook);
await client.getBooleanValue('test-flag', false, {});
expect(finalErrors).toContain('Missing userId');
expect(finalErrors).toContain('Missing region');
});
it('should support request correlation', async () => {
let correlationId: string | undefined;
const correlationHook: Hook = {
async before(hookContext: BeforeHookContext) {
const id = `req-${Date.now()}-${Math.random()}`;
hookContext.hookData.set('correlationId', id);
},
async after(hookContext: HookContext) {
correlationId = hookContext.hookData.get('correlationId') as string;
},
};
client.addHooks(correlationHook);
await client.getBooleanValue('test-flag', false);
expect(correlationId).toBeDefined();
expect(correlationId).toMatch(/^req-\d+-[\d.]+$/);
});
it('should support typed hook data for better type safety', async () => {
const typedHook = new TypedOpenTelemetryHook();
client.addHooks(typedHook);
await client.getBooleanValue('test-flag', false);
// Verify the typed hook worked correctly
expect(typedHook.spanId).toBeDefined();
expect(typedHook.spanId).toMatch(/^span-[a-z0-9]+$/);
expect(typedHook.duration).toBeDefined();
expect(typeof typedHook.duration).toBe('number');
expect(typedHook.duration).toBeGreaterThanOrEqual(0);
});
});
});

View File

@ -1,5 +1,13 @@
# Changelog
## [1.9.0](https://github.com/open-feature/js-sdk/compare/core-v1.8.1...core-v1.9.0) (2025-08-10)
### ✨ New Features
* add evaluation-scoped hook data ([#1216](https://github.com/open-feature/js-sdk/issues/1216)) ([07af3a9](https://github.com/open-feature/js-sdk/commit/07af3a9eda895e9edb24c7ee1e3c1c4f16e17431))
* support Angular 20 ([#1220](https://github.com/open-feature/js-sdk/issues/1220)) ([aa232a9](https://github.com/open-feature/js-sdk/commit/aa232a9d6a8dfa416380ccdecd71843d3e361048))
## [1.8.1](https://github.com/open-feature/js-sdk/compare/core-v1.8.0...core-v1.8.1) (2025-06-04)

View File

@ -1,6 +1,6 @@
{
"name": "@openfeature/core",
"version": "1.8.1",
"version": "1.9.0",
"description": "Shared OpenFeature JS components (server and web)",
"main": "./dist/cjs/index.js",
"files": [

View File

@ -18,3 +18,4 @@ export abstract class OpenFeatureError extends Error {
this.cause = options?.cause;
}
}

View File

@ -0,0 +1,72 @@
/**
* A mutable data structure for hooks to maintain state across their lifecycle.
* Each hook instance gets its own isolated data store that persists for the
* duration of a single flag evaluation.
* @template TData - A record type that defines the shape of the stored data
*/
export interface HookData<TData = Record<string, unknown>> {
/**
* Sets a value in the hook data store.
* @param key The key to store the value under
* @param value The value to store
*/
set<K extends keyof TData>(key: K, value: TData[K]): void;
set(key: string, value: unknown): void;
/**
* Gets a value from the hook data store.
* @param key The key to retrieve the value for
* @returns The stored value, or undefined if not found
*/
get<K extends keyof TData>(key: K): TData[K] | undefined;
get(key: string): unknown;
/**
* Checks if a key exists in the hook data store.
* @param key The key to check
* @returns True if the key exists, false otherwise
*/
has<K extends keyof TData>(key: K): boolean;
has(key: string): boolean;
/**
* Deletes a value from the hook data store.
* @param key The key to delete
* @returns True if the key was deleted, false if it didn't exist
*/
delete<K extends keyof TData>(key: K): boolean;
delete(key: string): boolean;
/**
* Clears all values from the hook data store.
*/
clear(): void;
}
/**
* Default implementation of HookData using a Map.
* @template TData - A record type that defines the shape of the stored data
*/
export class MapHookData<TData = Record<string, unknown>> implements HookData<TData> {
private readonly data = new Map<keyof TData, TData[keyof TData]>();
set<K extends keyof TData>(key: K, value: TData[K]): void {
this.data.set(key, value);
}
get<K extends keyof TData>(key: K): TData[K] | undefined {
return this.data.get(key) as TData[K] | undefined;
}
has<K extends keyof TData>(key: K): boolean {
return this.data.has(key);
}
delete<K extends keyof TData>(key: K): boolean {
return this.data.delete(key);
}
clear(): void {
this.data.clear();
}
}

View File

@ -1,14 +1,19 @@
import type { BeforeHookContext, HookContext, HookHints } from './hooks';
import type { EvaluationDetails, FlagValue } from '../evaluation';
export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = unknown, HooksReturn = unknown> {
export interface BaseHook<
T extends FlagValue = FlagValue,
TData = Record<string, unknown>,
BeforeHookReturn = unknown,
HooksReturn = unknown
> {
/**
* Runs before flag values are resolved from the provider.
* If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext.
* @param hookContext
* @param hookHints
*/
before?(hookContext: BeforeHookContext, hookHints?: HookHints): BeforeHookReturn;
before?(hookContext: BeforeHookContext<T, TData>, hookHints?: HookHints): BeforeHookReturn;
/**
* Runs after flag values are successfully resolved from the provider.
@ -17,7 +22,7 @@ export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = un
* @param hookHints
*/
after?(
hookContext: Readonly<HookContext<T>>,
hookContext: Readonly<HookContext<T, TData>>,
evaluationDetails: EvaluationDetails<T>,
hookHints?: HookHints,
): HooksReturn;
@ -28,7 +33,7 @@ export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = un
* @param error
* @param hookHints
*/
error?(hookContext: Readonly<HookContext<T>>, error: unknown, hookHints?: HookHints): HooksReturn;
error?(hookContext: Readonly<HookContext<T, TData>>, error: unknown, hookHints?: HookHints): HooksReturn;
/**
* Runs after all other hook stages, regardless of success or error.
@ -37,8 +42,9 @@ export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = un
* @param hookHints
*/
finally?(
hookContext: Readonly<HookContext<T>>,
hookContext: Readonly<HookContext<T, TData>>,
evaluationDetails: EvaluationDetails<T>,
hookHints?: HookHints,
): HooksReturn;
}

View File

@ -2,10 +2,11 @@ import type { ProviderMetadata } from '../provider';
import type { ClientMetadata } from '../client';
import type { EvaluationContext, FlagValue, FlagValueType } from '../evaluation';
import type { Logger } from '../logger';
import type { HookData } from './hook-data';
export type HookHints = Readonly<Record<string, unknown>>;
export interface HookContext<T extends FlagValue = FlagValue> {
export interface HookContext<T extends FlagValue = FlagValue, TData = Record<string, unknown>> {
readonly flagKey: string;
readonly defaultValue: T;
readonly flagValueType: FlagValueType;
@ -13,8 +14,9 @@ export interface HookContext<T extends FlagValue = FlagValue> {
readonly clientMetadata: ClientMetadata;
readonly providerMetadata: ProviderMetadata;
readonly logger: Logger;
readonly hookData: HookData<TData>;
}
export interface BeforeHookContext extends HookContext {
export interface BeforeHookContext<T extends FlagValue = FlagValue, TData = Record<string, unknown>> extends HookContext<T, TData> {
context: EvaluationContext;
}

View File

@ -1,3 +1,4 @@
export * from './hook';
export * from './hooks';
export * from './evaluation-lifecycle';
export * from './hook-data';

View File

@ -0,0 +1,214 @@
import type { HookData, BaseHook, BeforeHookContext, HookContext } from '../src/hooks';
import { MapHookData } from '../src/hooks';
import type { FlagValue } from '../src/evaluation';
describe('Hook Data Type Safety', () => {
it('should provide type safety with typed hook data', () => {
// Define a strict type for hook data
interface MyHookData {
startTime: number;
userId: string;
metadata: { version: string; feature: boolean };
tags: string[];
}
const hookData = new MapHookData<MyHookData>();
// Type-safe setting and getting
hookData.set('startTime', 123456);
hookData.set('userId', 'user-123');
hookData.set('metadata', { version: '1.0.0', feature: true });
hookData.set('tags', ['tag1', 'tag2']);
// TypeScript should infer the correct return types
const startTime: number | undefined = hookData.get('startTime');
const userId: string | undefined = hookData.get('userId');
const metadata: { version: string; feature: boolean } | undefined = hookData.get('metadata');
const tags: string[] | undefined = hookData.get('tags');
// Verify the values
expect(startTime).toBe(123456);
expect(userId).toBe('user-123');
expect(metadata).toEqual({ version: '1.0.0', feature: true });
expect(tags).toEqual(['tag1', 'tag2']);
// Type-safe existence checks
expect(hookData.has('startTime')).toBe(true);
expect(hookData.has('userId')).toBe(true);
expect(hookData.has('metadata')).toBe(true);
expect(hookData.has('tags')).toBe(true);
// Type-safe deletion
expect(hookData.delete('tags')).toBe(true);
expect(hookData.has('tags')).toBe(false);
});
it('should support untyped usage for backward compatibility', () => {
const hookData: HookData = new MapHookData();
// Untyped usage still works
hookData.set('anyKey', 'anyValue');
hookData.set('numberKey', 42);
hookData.set('objectKey', { nested: true });
const value: unknown = hookData.get('anyKey');
const numberValue: unknown = hookData.get('numberKey');
const objectValue: unknown = hookData.get('objectKey');
expect(value).toBe('anyValue');
expect(numberValue).toBe(42);
expect(objectValue).toEqual({ nested: true });
});
it('should support mixed usage with typed and untyped keys', () => {
interface PartiallyTypedData {
correlationId: string;
timestamp: number;
}
const hookData: HookData<PartiallyTypedData> = new MapHookData<PartiallyTypedData>();
// Typed usage
hookData.set('correlationId', 'abc-123');
hookData.set('timestamp', Date.now());
// Untyped usage for additional keys
hookData.set('dynamicKey', 'dynamicValue');
// Type-safe retrieval for typed keys
const correlationId: string | undefined = hookData.get('correlationId');
const timestamp: number | undefined = hookData.get('timestamp');
// Untyped retrieval for dynamic keys
const dynamicValue: unknown = hookData.get('dynamicKey');
expect(correlationId).toBe('abc-123');
expect(typeof timestamp).toBe('number');
expect(dynamicValue).toBe('dynamicValue');
});
it('should work with complex nested types', () => {
interface ComplexHookData {
request: {
id: string;
headers: Record<string, string>;
body?: { [key: string]: unknown };
};
response: {
status: number;
data: unknown;
headers: Record<string, string>;
};
metrics: {
startTime: number;
endTime?: number;
duration?: number;
};
}
const hookData: HookData<ComplexHookData> = new MapHookData<ComplexHookData>();
const requestData = {
id: 'req-123',
headers: { 'Content-Type': 'application/json' },
body: { flag: 'test-flag' },
};
hookData.set('request', requestData);
hookData.set('metrics', { startTime: Date.now() });
const retrievedRequest = hookData.get('request');
const retrievedMetrics = hookData.get('metrics');
expect(retrievedRequest).toEqual(requestData);
expect(retrievedMetrics?.startTime).toBeDefined();
expect(typeof retrievedMetrics?.startTime).toBe('number');
});
it('should support generic type inference', () => {
// This function demonstrates how the generic types work in practice
function createTypedHookData<T>(): HookData<T> {
return new MapHookData<T>();
}
interface TimingData {
start: number;
checkpoint: number;
}
const timingHookData = createTypedHookData<TimingData>();
timingHookData.set('start', performance.now());
timingHookData.set('checkpoint', performance.now());
const start: number | undefined = timingHookData.get('start');
const checkpoint: number | undefined = timingHookData.get('checkpoint');
expect(typeof start).toBe('number');
expect(typeof checkpoint).toBe('number');
});
it('should work with BaseHook interface without casting', () => {
interface TestHookData {
testId: string;
startTime: number;
metadata: { version: string };
}
class TestTypedHook implements BaseHook<FlagValue, TestHookData> {
capturedData: { testId?: string; duration?: number } = {};
before(hookContext: BeforeHookContext<FlagValue, TestHookData>) {
// No casting needed - TypeScript knows the types
hookContext.hookData.set('testId', 'test-123');
hookContext.hookData.set('startTime', Date.now());
hookContext.hookData.set('metadata', { version: '1.0.0' });
}
after(hookContext: HookContext<FlagValue, TestHookData>) {
// Type-safe getting with proper return types
const testId: string | undefined = hookContext.hookData.get('testId');
const startTime: number | undefined = hookContext.hookData.get('startTime');
if (testId && startTime) {
this.capturedData = {
testId,
duration: Date.now() - startTime,
};
}
}
}
const hook = new TestTypedHook();
// Create mock contexts that satisfy the BaseHook interface
const mockBeforeContext: BeforeHookContext<FlagValue, TestHookData> = {
flagKey: 'test-flag',
defaultValue: true,
flagValueType: 'boolean',
context: {},
clientMetadata: {
name: 'test-client',
domain: 'test-domain',
providerMetadata: { name: 'test-provider' },
},
providerMetadata: { name: 'test-provider' },
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
hookData: new MapHookData<TestHookData>(),
};
const mockAfterContext: HookContext<FlagValue, TestHookData> = {
...mockBeforeContext,
context: Object.freeze({}),
};
// Execute the hook methods
hook.before!(mockBeforeContext);
hook.after!(mockAfterContext);
// Verify the typed hook worked correctly
expect(hook.capturedData.testId).toBe('test-123');
expect(hook.capturedData.duration).toBeDefined();
expect(typeof hook.capturedData.duration).toBe('number');
});
});

View File

@ -2,6 +2,7 @@ import { createEvaluationEvent } from '../src/telemetry/evaluation-event';
import { ErrorCode, StandardResolutionReasons, type EvaluationDetails } from '../src/evaluation/evaluation';
import type { HookContext } from '../src/hooks/hooks';
import { TelemetryAttribute, TelemetryFlagMetadata } from '../src/telemetry';
import { MapHookData } from '../src/hooks/hook-data';
describe('evaluationEvent', () => {
const flagKey = 'test-flag';
@ -25,6 +26,7 @@ describe('evaluationEvent', () => {
error: jest.fn(),
warn: jest.fn(),
},
hookData: new MapHookData(),
};
it('should return basic event body with mandatory fields', () => {

View File

@ -1,6 +1,26 @@
# Changelog
## [1.6.1](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.6.0...web-sdk-v1.6.1) (2025-08-14)
### 🐛 Bug Fixes
* update core dep ([#1228](https://github.com/open-feature/js-sdk/issues/1228)) ([845d24c](https://github.com/open-feature/js-sdk/commit/845d24c5fecc80de3080e49fde839f08ecac6b33))
## [1.6.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.5.0...web-sdk-v1.6.0) (2025-08-12)
### ✨ New Features
* add evaluation-scoped hook data ([#1216](https://github.com/open-feature/js-sdk/issues/1216)) ([07af3a9](https://github.com/open-feature/js-sdk/commit/07af3a9eda895e9edb24c7ee1e3c1c4f16e17431))
* **web-global-build:** impl ([#1225](https://github.com/open-feature/js-sdk/issues/1225)) ([40a512e](https://github.com/open-feature/js-sdk/commit/40a512e21204eb92dc3ef4161b383f9c1fd74da7))
### 📚 Documentation
* Clarify the behavior of setProviderAndWait ([#1180](https://github.com/open-feature/js-sdk/issues/1180)) ([4fe8d87](https://github.com/open-feature/js-sdk/commit/4fe8d87a2e5df2cbd4086cc4f4a380e8857ed8ba))
## [1.5.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.4.1...web-sdk-v1.5.0) (2025-04-11)

View File

@ -16,8 +16,8 @@
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
</a>
<!-- x-release-please-start-version -->
<a href="https://github.com/open-feature/js-sdk/releases/tag/web-sdk-v1.5.0">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.5.0&color=blue&style=for-the-badge" />
<a href="https://github.com/open-feature/js-sdk/releases/tag/web-sdk-v1.6.1">
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.6.1&color=blue&style=for-the-badge" />
</a>
<!-- x-release-please-end -->
<br/>

View File

@ -1,8 +1,10 @@
{
"name": "@openfeature/web-sdk",
"version": "1.5.0",
"version": "1.6.1",
"description": "OpenFeature SDK for Web",
"main": "./dist/cjs/index.js",
"unpkg": "dist/global/index.min.js",
"jsdelivr": "dist/global/index.min.js",
"files": [
"dist/"
],
@ -20,8 +22,10 @@
"clean": "shx rm -rf ./dist",
"build:web-esm": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=esm --outfile=./dist/esm/index.js --analyze",
"build:web-cjs": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze",
"build:web-global": "esbuild src/index.ts --bundle --sourcemap --target=es2015 --platform=browser --format=iife --outfile=./dist/global/index.js --global-name=OpenFeature --analyze",
"build:web-global:min": "esbuild src/index.ts --bundle --sourcemap --target=es2015 --platform=browser --format=iife --outfile=./dist/global/index.min.js --global-name=OpenFeature --minify --analyze",
"build:rollup-types": "rollup -c ../../rollup.config.mjs",
"build": "npm run clean && npm run build:web-esm && npm run build:web-cjs && npm run build:rollup-types",
"build": "npm run clean && npm run build:web-esm && npm run build:web-cjs && npm run build:web-global && npm run build:web-global:min && npm run build:rollup-types",
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
"current-version": "echo $npm_package_version",
"prepack": "shx cp ./../../LICENSE ./LICENSE",
@ -46,9 +50,9 @@
},
"homepage": "https://github.com/open-feature/js-sdk#readme",
"peerDependencies": {
"@openfeature/core": "^1.8.0"
"@openfeature/core": "^1.9.0"
},
"devDependencies": {
"@openfeature/core": "^1.8.0"
"@openfeature/core": "^1.9.0"
}
}

View File

@ -22,6 +22,7 @@ import {
StandardResolutionReasons,
instantiateErrorByErrorCode,
statusMatchesEvent,
MapHookData,
} from '@openfeature/core';
import type { FlagEvaluationOptions } from '../../evaluation';
import type { ProviderEvents } from '../../events';
@ -231,22 +232,26 @@ export class OpenFeatureClient implements Client {
...this.apiContextAccessor(this?.options?.domain),
};
// this reference cannot change during the course of evaluation
// it may be used as a key in WeakMaps
const hookContext: Readonly<HookContext> = {
flagKey,
defaultValue,
flagValueType: flagType,
clientMetadata: this.metadata,
providerMetadata: this._provider.metadata,
context,
logger: this._logger,
};
// Create hook context instances for each hook (stable object references for the entire evaluation)
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index.
const hookContexts = allHooksReversed.map<HookContext>(() =>
Object.freeze({
flagKey,
defaultValue,
flagValueType: flagType,
clientMetadata: this.metadata,
providerMetadata: this._provider.metadata,
context,
logger: this._logger,
hookData: new MapHookData(),
}),
);
let evaluationDetails: EvaluationDetails<T>;
try {
this.beforeHooks(allHooks, hookContext, options);
this.beforeHooks(allHooks, hookContexts, options);
this.shortCircuitIfNotReady();
@ -261,45 +266,48 @@ export class OpenFeatureClient implements Client {
if (resolutionDetails.errorCode) {
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
this.errorHooks(allHooksReversed, hookContext, err, options);
this.errorHooks(allHooksReversed, hookContexts, err, options);
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
} else {
this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
evaluationDetails = resolutionDetails;
}
} catch (err: unknown) {
this.errorHooks(allHooksReversed, hookContext, err, options);
this.errorHooks(allHooksReversed, hookContexts, err, options);
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
}
this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
return evaluationDetails;
}
private beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
Object.freeze(hookContext);
Object.freeze(hookContext.context);
for (const hook of hooks) {
private beforeHooks(hooks: Hook[], hookContexts: HookContext[], options: FlagEvaluationOptions) {
for (const [index, hook] of hooks.entries()) {
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks
const hookContext = hookContexts[hookContextIndex];
Object.freeze(hookContext);
Object.freeze(hookContext.context);
hook?.before?.(hookContext, Object.freeze(options.hookHints));
}
}
private afterHooks(
hooks: Hook[],
hookContext: HookContext,
hookContexts: HookContext[],
evaluationDetails: EvaluationDetails<FlagValue>,
options: FlagEvaluationOptions,
) {
// run "after" hooks sequentially
for (const hook of hooks) {
for (const [index, hook] of hooks.entries()) {
const hookContext = hookContexts[index];
hook?.after?.(hookContext, evaluationDetails, options.hookHints);
}
}
private errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
private errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) {
// run "error" hooks sequentially
for (const hook of hooks) {
for (const [index, hook] of hooks.entries()) {
try {
const hookContext = hookContexts[index];
hook?.error?.(hookContext, err, options.hookHints);
} catch (err) {
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
@ -313,13 +321,14 @@ export class OpenFeatureClient implements Client {
private finallyHooks(
hooks: Hook[],
hookContext: HookContext,
hookContexts: HookContext[],
evaluationDetails: EvaluationDetails<FlagValue>,
options: FlagEvaluationOptions,
) {
// run "finally" hooks sequentially
for (const hook of hooks) {
for (const [index, hook] of hooks.entries()) {
try {
const hookContext = hookContexts[index];
hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
} catch (err) {
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);

View File

@ -1,3 +1,3 @@
import type { BaseHook, FlagValue } from '@openfeature/core';
export type Hook = BaseHook<FlagValue, void, void>;
export type Hook<TData = Record<string, unknown>> = BaseHook<FlagValue, TData, void, void>;

View File

@ -0,0 +1,436 @@
import { OpenFeatureAPI } from '../src/open-feature';
import type { Client } from '../src/client';
import type { JsonValue, ResolutionDetails, HookContext, BeforeHookContext } from '@openfeature/core';
import { StandardResolutionReasons } from '@openfeature/core';
import type { Provider } from '../src/provider';
import type { Hook } from '../src/hooks';
const BOOLEAN_VALUE = true;
const STRING_VALUE = 'val';
const NUMBER_VALUE = 1;
const OBJECT_VALUE = { key: 'value' };
// A test hook that stores data in the before stage and retrieves it in after/error/finally
class TestHookWithData implements Hook {
beforeData: unknown;
afterData: unknown;
errorData: unknown;
finallyData: unknown;
before(hookContext: BeforeHookContext) {
// Store some data
hookContext.hookData.set('testKey', 'testValue');
hookContext.hookData.set('timestamp', Date.now());
hookContext.hookData.set('object', { nested: 'value' });
this.beforeData = hookContext.hookData.get('testKey');
}
after(hookContext: HookContext) {
// Retrieve data stored in before
this.afterData = hookContext.hookData.get('testKey');
}
error(hookContext: HookContext) {
// Retrieve data stored in before
this.errorData = hookContext.hookData.get('testKey');
}
finally(hookContext: HookContext) {
// Retrieve data stored in before
this.finallyData = hookContext.hookData.get('testKey');
}
}
// A timing hook that measures evaluation duration
class TimingHook implements Hook {
duration?: number;
before(hookContext: BeforeHookContext) {
hookContext.hookData.set('startTime', performance.now());
}
after(hookContext: HookContext) {
const startTime = hookContext.hookData.get('startTime') as number;
if (startTime) {
this.duration = performance.now() - startTime;
}
}
error(hookContext: HookContext) {
const startTime = hookContext.hookData.get('startTime') as number;
if (startTime) {
this.duration = performance.now() - startTime;
}
}
}
// Hook that tests hook data isolation
class IsolationTestHook implements Hook {
hookId: string;
constructor(id: string) {
this.hookId = id;
}
before(hookContext: BeforeHookContext) {
const storedId = hookContext.hookData.get('hookId');
if (storedId) {
throw new Error('Hook data isolation violated! Data is set in before hook.');
}
// Each hook instance should have its own data
hookContext.hookData.set('hookId', this.hookId);
hookContext.hookData.set(`data_${this.hookId}`, `value_${this.hookId}`);
}
after(hookContext: HookContext) {
// Verify we can only see our own data
const storedId = hookContext.hookData.get('hookId');
if (storedId !== this.hookId) {
throw new Error(`Hook data isolation violated! Expected ${this.hookId}, got ${storedId}`);
}
}
}
// Mock provider for testing
const MOCK_PROVIDER: Provider = {
metadata: { name: 'mock-provider' },
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
return {
value: BOOLEAN_VALUE,
variant: 'default',
reason: StandardResolutionReasons.DEFAULT,
};
},
resolveStringEvaluation(): ResolutionDetails<string> {
return {
value: STRING_VALUE,
variant: 'default',
reason: StandardResolutionReasons.DEFAULT,
};
},
resolveNumberEvaluation(): ResolutionDetails<number> {
return {
value: NUMBER_VALUE,
variant: 'default',
reason: StandardResolutionReasons.DEFAULT,
};
},
resolveObjectEvaluation<T extends JsonValue>(): ResolutionDetails<T> {
return {
value: OBJECT_VALUE as unknown as T,
variant: 'default',
reason: StandardResolutionReasons.DEFAULT,
};
},
} as Provider;
// Mock provider that throws an error
const ERROR_PROVIDER: Provider = {
metadata: { name: 'error-provider' },
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
throw new Error('Provider error');
},
resolveStringEvaluation(): ResolutionDetails<string> {
throw new Error('Provider error');
},
resolveNumberEvaluation(): ResolutionDetails<number> {
throw new Error('Provider error');
},
resolveObjectEvaluation<T extends JsonValue>(): ResolutionDetails<T> {
throw new Error('Provider error');
},
};
describe('Hook Data (Web SDK)', () => {
let client: Client;
let api: OpenFeatureAPI;
beforeEach(() => {
api = OpenFeatureAPI.getInstance();
api.clearHooks();
api.setProvider(MOCK_PROVIDER);
client = api.getClient();
});
afterEach(() => {
api.clearProviders();
});
describe('Basic Hook Data Functionality', () => {
it('should allow hooks to store and retrieve data across stages', () => {
const hook = new TestHookWithData();
client.addHooks(hook);
client.getBooleanValue('test-flag', false);
// Verify data was stored in before and retrieved in all other stages
expect(hook.beforeData).toBe('testValue');
expect(hook.afterData).toBe('testValue');
expect(hook.finallyData).toBe('testValue');
});
it('should support storing different data types', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const storedValues: any = {};
const hook: Hook = {
before(hookContext: BeforeHookContext) {
// Store various types
hookContext.hookData.set('string', 'test');
hookContext.hookData.set('number', 42);
hookContext.hookData.set('boolean', true);
hookContext.hookData.set('object', { key: 'value' });
hookContext.hookData.set('array', [1, 2, 3]);
hookContext.hookData.set('null', null);
hookContext.hookData.set('undefined', undefined);
},
after(hookContext: HookContext) {
storedValues.string = hookContext.hookData.get('string');
storedValues.number = hookContext.hookData.get('number');
storedValues.boolean = hookContext.hookData.get('boolean');
storedValues.object = hookContext.hookData.get('object');
storedValues.array = hookContext.hookData.get('array');
storedValues.null = hookContext.hookData.get('null');
storedValues.undefined = hookContext.hookData.get('undefined');
},
};
client.addHooks(hook);
client.getBooleanValue('test-flag', false);
expect(storedValues.string).toBe('test');
expect(storedValues.number).toBe(42);
expect(storedValues.boolean).toBe(true);
expect(storedValues.object).toEqual({ key: 'value' });
expect(storedValues.array).toEqual([1, 2, 3]);
expect(storedValues.null).toBeNull();
expect(storedValues.undefined).toBeUndefined();
});
it('should handle hook data in error scenarios', () => {
api.setProvider(ERROR_PROVIDER);
const hook = new TestHookWithData();
client.addHooks(hook);
client.getBooleanValue('test-flag', false);
// Verify data was accessible in error and finally stages
expect(hook.beforeData).toBe('testValue');
expect(hook.errorData).toBe('testValue');
expect(hook.finallyData).toBe('testValue');
expect(hook.afterData).toBeUndefined(); // after should not run on error
});
});
describe('Hook Data API', () => {
it('should support has() method', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hasResults: any = {};
const hook: Hook = {
before(hookContext: BeforeHookContext) {
hookContext.hookData.set('exists', 'value');
hasResults.beforeExists = hookContext.hookData.has('exists');
hasResults.beforeNotExists = hookContext.hookData.has('notExists');
},
after(hookContext: HookContext) {
hasResults.afterExists = hookContext.hookData.has('exists');
hasResults.afterNotExists = hookContext.hookData.has('notExists');
},
};
client.addHooks(hook);
client.getBooleanValue('test-flag', false);
expect(hasResults.beforeExists).toBe(true);
expect(hasResults.beforeNotExists).toBe(false);
expect(hasResults.afterExists).toBe(true);
expect(hasResults.afterNotExists).toBe(false);
});
it('should support delete() method', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deleteResults: any = {};
const hook: Hook = {
before(hookContext: BeforeHookContext) {
hookContext.hookData.set('toDelete', 'value');
deleteResults.hasBeforeDelete = hookContext.hookData.has('toDelete');
deleteResults.deleteResult = hookContext.hookData.delete('toDelete');
deleteResults.hasAfterDelete = hookContext.hookData.has('toDelete');
deleteResults.deleteAgainResult = hookContext.hookData.delete('toDelete');
},
};
client.addHooks(hook);
client.getBooleanValue('test-flag', false);
expect(deleteResults.hasBeforeDelete).toBe(true);
expect(deleteResults.deleteResult).toBe(true);
expect(deleteResults.hasAfterDelete).toBe(false);
expect(deleteResults.deleteAgainResult).toBe(false);
});
it('should support clear() method', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const clearResults: any = {};
const hook: Hook = {
before(hookContext: BeforeHookContext) {
hookContext.hookData.set('key1', 'value1');
hookContext.hookData.set('key2', 'value2');
hookContext.hookData.set('key3', 'value3');
clearResults.hasBeforeClear = hookContext.hookData.has('key1');
hookContext.hookData.clear();
clearResults.hasAfterClear = hookContext.hookData.has('key1');
},
after(hookContext: HookContext) {
// Verify all data was cleared
clearResults.afterHasKey1 = hookContext.hookData.has('key1');
clearResults.afterHasKey2 = hookContext.hookData.has('key2');
clearResults.afterHasKey3 = hookContext.hookData.has('key3');
},
};
client.addHooks(hook);
client.getBooleanValue('test-flag', false);
expect(clearResults.hasBeforeClear).toBe(true);
expect(clearResults.hasAfterClear).toBe(false);
expect(clearResults.afterHasKey1).toBe(false);
expect(clearResults.afterHasKey2).toBe(false);
expect(clearResults.afterHasKey3).toBe(false);
});
});
describe('Hook Data Isolation', () => {
it('should isolate data between different hook instances', () => {
const hook1 = new IsolationTestHook('hook1');
const hook2 = new IsolationTestHook('hook2');
const hook3 = new IsolationTestHook('hook3');
client.addHooks(hook1, hook2, hook3);
expect(client.getBooleanValue('test-flag', false)).toBe(true);
});
it('should isolate data between the same hook instance', () => {
const hook = new IsolationTestHook('hook');
client.addHooks(hook, hook);
expect(client.getBooleanValue('test-flag', false)).toBe(true);
});
it('should not share data between different evaluations', () => {
let firstEvalData: unknown;
let secondEvalData: unknown;
const hook: Hook = {
before(hookContext: BeforeHookContext) {
// Check if data exists from previous evaluation
const existingData = hookContext.hookData.get('evalData');
if (existingData) {
throw new Error('Hook data leaked between evaluations!');
}
hookContext.hookData.set('evalData', 'evaluation-specific');
},
after(hookContext: HookContext) {
if (!firstEvalData) {
firstEvalData = hookContext.hookData.get('evalData');
} else {
secondEvalData = hookContext.hookData.get('evalData');
}
},
};
client.addHooks(hook);
// First evaluation
client.getBooleanValue('test-flag', false);
// Second evaluation
client.getBooleanValue('test-flag', false);
expect(firstEvalData).toBe('evaluation-specific');
expect(secondEvalData).toBe('evaluation-specific');
});
it('should isolate data between global, client, and invocation hooks', () => {
const globalHook = new IsolationTestHook('global');
const clientHook = new IsolationTestHook('client');
const invocationHook = new IsolationTestHook('invocation');
api.addHooks(globalHook);
client.addHooks(clientHook);
expect(client.getBooleanValue('test-flag', false, { hooks: [invocationHook] })).toBe(true);
});
});
describe('Use Cases', () => {
it('should support timing measurements', () => {
const timingHook = new TimingHook();
client.addHooks(timingHook);
client.getBooleanValue('test-flag', false);
expect(timingHook.duration).toBeDefined();
expect(timingHook.duration).toBeGreaterThanOrEqual(0);
});
it('should support multi-stage validation accumulation', () => {
let finalErrors: string[] = [];
const validationHook: Hook = {
before(hookContext: BeforeHookContext) {
hookContext.hookData.set('errors', []);
// Simulate validation
const errors = hookContext.hookData.get('errors') as string[];
if (!hookContext.context.userId) {
errors.push('Missing userId');
}
if (!hookContext.context.region) {
errors.push('Missing region');
}
},
finally(hookContext: HookContext) {
finalErrors = (hookContext.hookData.get('errors') as string[]) || [];
},
};
client.addHooks(validationHook);
client.getBooleanValue('test-flag', false, {});
expect(finalErrors).toContain('Missing userId');
expect(finalErrors).toContain('Missing region');
});
it('should support request correlation', () => {
let correlationId: string | undefined;
const correlationHook: Hook = {
before(hookContext: BeforeHookContext) {
const id = `req-${Date.now()}-${Math.random()}`;
hookContext.hookData.set('correlationId', id);
},
after(hookContext: HookContext) {
correlationId = hookContext.hookData.get('correlationId') as string;
},
};
client.addHooks(correlationHook);
client.getBooleanValue('test-flag', false);
expect(correlationId).toBeDefined();
expect(correlationId).toMatch(/^req-\d+-[\d.]+$/);
});
});
});

View File

@ -20,7 +20,6 @@
"versioning": "default"
},
"packages/react": {
"release-as": "1.0.0",
"release-type": "node",
"prerelease": false,
"bump-minor-pre-major": true,