Compare commits
17 Commits
core-v1.8.
...
main
Author | SHA1 | Date |
---|---|---|
|
ee236396cd | |
|
9aab3d053b | |
|
3105595926 | |
|
1dbbd5161b | |
|
2287083de7 | |
|
04139affcb | |
|
1e330a2e13 | |
|
7df8a8eedc | |
|
3b3515b601 | |
|
63a1feb213 | |
|
845d24c5fe | |
|
dc1970e717 | |
|
e6e5ff3edf | |
|
40a512e212 | |
|
28850b7f6d | |
|
aa232a9d6a | |
|
07af3a9eda |
|
@ -37,8 +37,11 @@ jobs:
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
- name: Test
|
- name: Test Jest Projects
|
||||||
run: npm run test
|
run: npm run test:jest
|
||||||
|
|
||||||
|
- name: Test Angular SDK
|
||||||
|
run: npm run test:angular
|
||||||
|
|
||||||
codecov-and-docs:
|
codecov-and-docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"packages/nest": "0.2.5",
|
"packages/nest": "0.2.5",
|
||||||
"packages/react": "1.0.0",
|
"packages/react": "1.0.1",
|
||||||
"packages/web": "1.5.0",
|
"packages/web": "1.6.1",
|
||||||
"packages/server": "1.18.0",
|
"packages/server": "1.19.0",
|
||||||
"packages/shared": "1.8.1",
|
"packages/shared": "1.9.0",
|
||||||
"packages/angular/projects/angular-sdk": "0.0.15"
|
"packages/angular/projects/angular-sdk": "0.0.16"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
// Use this configuration option to add custom reporters to Jest
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
|
@ -1,10 +1,15 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/js",
|
"name": "@openfeature/js",
|
||||||
|
"engines": {
|
||||||
|
"npm": "^10.0.0"
|
||||||
|
},
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "OpenFeature SDK for JavaScript",
|
"description": "OpenFeature SDK for JavaScript",
|
||||||
"scripts": {
|
"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-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-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",
|
"e2e": "npm run e2e-server && npm run e2e-web",
|
||||||
|
@ -54,14 +59,12 @@
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"jest-environment-node": "^29.7.0",
|
"jest-environment-node": "^29.7.0",
|
||||||
"jest-junit": "^16.0.0",
|
"jest-junit": "^16.0.0",
|
||||||
"jest-preset-angular": "^14.2.4",
|
|
||||||
"ng-packagr": "^18.2.1",
|
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"rollup": "^4.0.0",
|
"rollup": "^4.0.0",
|
||||||
"rollup-plugin-dts": "^6.1.1",
|
"rollup-plugin-dts": "^6.1.1",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"shx": "^0.3.4",
|
"shx": "^0.4.0",
|
||||||
"ts-jest": "^29.1.2",
|
"ts-jest": "^29.1.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
|
@ -69,9 +72,6 @@
|
||||||
"typescript": "^4.7.4",
|
"typescript": "^4.7.4",
|
||||||
"uuid": "^11.0.0"
|
"uuid": "^11.0.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
|
||||||
"typescript": "^4.7.4"
|
|
||||||
},
|
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/shared",
|
"packages/shared",
|
||||||
"packages/server",
|
"packages/server",
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
"prefix": "lib",
|
"prefix": "lib",
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular-devkit/build-angular:ng-packagr",
|
"builder": "@angular/build:ng-packagr",
|
||||||
"options": {
|
"options": {
|
||||||
"project": "projects/angular-sdk/ng-package.json"
|
"project": "projects/angular-sdk/ng-package.json"
|
||||||
},
|
},
|
||||||
|
@ -32,6 +32,15 @@
|
||||||
"projects/angular-sdk/**/*.html"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,37 +7,42 @@
|
||||||
"lint": "ng lint",
|
"lint": "ng lint",
|
||||||
"lint:fix": "ng lint --fix",
|
"lint:fix": "ng lint --fix",
|
||||||
"watch": "ng build --watch --configuration development",
|
"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",
|
"build": "ng build && npm run postbuild",
|
||||||
"postbuild": "shx cp ./../../LICENSE ./dist/angular/LICENSE",
|
"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"
|
"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,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^19.0.0",
|
"@angular-eslint/builder": "^20.1.1",
|
||||||
"@angular-eslint/builder": "19.3.0",
|
"@angular-eslint/eslint-plugin": "^20.1.1",
|
||||||
"@angular-eslint/eslint-plugin": "19.3.0",
|
"@angular-eslint/eslint-plugin-template": "^20.1.1",
|
||||||
"@angular-eslint/eslint-plugin-template": "19.3.0",
|
"@angular-eslint/schematics": "^20.1.1",
|
||||||
"@angular-eslint/schematics": "19.3.0",
|
"@angular-eslint/template-parser": "^20.1.1",
|
||||||
"@angular-eslint/template-parser": "19.3.0",
|
"@angular/animations": "^20.1.1",
|
||||||
"@angular/animations": "^19.0.0",
|
"@angular/build": "^20.1.1",
|
||||||
"@angular/cli": "^19.0.0",
|
"@angular/cli": "^20.1.1",
|
||||||
"@angular/common": "^19.0.0",
|
"@angular/common": "^20.1.1",
|
||||||
"@angular/compiler": "^19.0.0",
|
"@angular/compiler": "^20.1.1",
|
||||||
"@angular/compiler-cli": "^19.0.0",
|
"@angular/compiler-cli": "^20.1.1",
|
||||||
"@angular/core": "^19.0.0",
|
"@angular/core": "^20.1.1",
|
||||||
"@angular/forms": "^19.0.0",
|
"@angular/forms": "^20.1.1",
|
||||||
"@angular/platform-browser": "^19.0.0",
|
"@angular/platform-browser": "^20.1.1",
|
||||||
"@angular/platform-browser-dynamic": "^19.0.0",
|
"@angular/platform-browser-dynamic": "^20.1.1",
|
||||||
"@angular/router": "^19.0.0",
|
"@angular/router": "^20.1.1",
|
||||||
"@typescript-eslint/eslint-plugin": "7.18.0",
|
"@typescript-eslint/eslint-plugin": "7.18.0",
|
||||||
"@typescript-eslint/parser": "7.18.0",
|
"@typescript-eslint/parser": "7.18.0",
|
||||||
|
"@vitest/browser": "^3.2.4",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"jest-preset-angular": "^14.2.4",
|
"jsdom": "^26.1.0",
|
||||||
"ng-packagr": "^19.0.0",
|
"ng-packagr": "^20.1.0",
|
||||||
|
"playwright": "^1.53.2",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.8.3",
|
||||||
|
"vitest": "^3.2.4",
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1,6 +1,14 @@
|
||||||
# Changelog
|
# 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)
|
## [0.0.15](https://github.com/open-feature/js-sdk/compare/angular-sdk-v0.0.14...angular-sdk-v0.0.15) (2025-05-27)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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" />
|
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-start-version -->
|
<!-- x-release-please-start-version -->
|
||||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/angular-sdk-v0.0.15">
|
<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.15&color=blue&style=for-the-badge" />
|
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.0.16&color=blue&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-end -->
|
<!-- x-release-please-end -->
|
||||||
<br/>
|
<br/>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/angular-sdk",
|
"name": "@openfeature/angular-sdk",
|
||||||
"version": "0.0.15",
|
"version": "0.0.16",
|
||||||
"description": "OpenFeature Angular SDK",
|
"description": "OpenFeature Angular SDK",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
@ -17,18 +17,18 @@
|
||||||
"prepack": "shx cp ./../../../../LICENSE ./LICENSE"
|
"prepack": "shx cp ./../../../../LICENSE ./LICENSE"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@angular/common": "^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",
|
"@angular/core": "^16.2.12 || ^17.3.0 || ^18.0.0 || ^19.0.0 || ^20.0.0",
|
||||||
"@openfeature/web-sdk": "^1.4.1"
|
"@openfeature/web-sdk": "^1.4.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openfeature/core": "*",
|
"@openfeature/core": "^1.8.1",
|
||||||
"@openfeature/web-sdk": "*",
|
"@openfeature/web-sdk": "^1.5.0",
|
||||||
"@angular/common": "^19.0.0",
|
"@angular/common": "^20.1.2",
|
||||||
"@angular/core": "^19.0.0"
|
"@angular/core": "^20.1.2"
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|
|
@ -253,6 +253,7 @@ describe('FeatureFlagDirective', () => {
|
||||||
await expectRenderedText(fixture, 'case-2', 'Flag On');
|
await expectRenderedText(fixture, 'case-2', 'Flag On');
|
||||||
|
|
||||||
await updateFlagValue(provider, false);
|
await updateFlagValue(provider, false);
|
||||||
|
fixture.detectChanges(); // Ensure change detection after flag update
|
||||||
await expectRenderedText(fixture, 'case-2', 'Flag Off');
|
await expectRenderedText(fixture, 'case-2', 'Flag Off');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -393,7 +394,9 @@ describe('FeatureFlagDirective', () => {
|
||||||
await waitForClientReady(client);
|
await waitForClientReady(client);
|
||||||
await expectRenderedText(fixture, 'case-6', 'Flag On');
|
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');
|
await expectRenderedText(fixture, 'case-6', 'Flag Off');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -445,7 +448,9 @@ describe('FeatureFlagDirective', () => {
|
||||||
);
|
);
|
||||||
await OpenFeature.setProviderAndWait(newDomain, newProvider);
|
await OpenFeature.setProviderAndWait(newDomain, newProvider);
|
||||||
|
|
||||||
fixture.componentInstance.domain = newDomain;
|
fixture.componentRef.setInput('domain', newDomain);
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
await expectRenderedText(fixture, 'case-6', 'Flag Off');
|
await expectRenderedText(fixture, 'case-6', 'Flag Off');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -560,8 +565,7 @@ async function createTestingModule(config?: {
|
||||||
],
|
],
|
||||||
}).createComponent(TestComponent);
|
}).createComponent(TestComponent);
|
||||||
|
|
||||||
fixture.componentInstance.domain = domain;
|
fixture.componentRef.setInput('domain', domain);
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
|
|
||||||
const client = OpenFeature.getClient(domain);
|
const client = OpenFeature.getClient(domain);
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
OnInit,
|
OnInit,
|
||||||
TemplateRef,
|
TemplateRef,
|
||||||
ViewContainerRef,
|
ViewContainerRef,
|
||||||
|
inject,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
Client,
|
Client,
|
||||||
|
@ -35,6 +36,9 @@ class FeatureFlagDirectiveContext<T extends FlagValue> {
|
||||||
selector: '[featureFlag]',
|
selector: '[featureFlag]',
|
||||||
})
|
})
|
||||||
export abstract class FeatureFlagDirective<T extends FlagValue> implements OnInit, OnDestroy, OnChanges {
|
export abstract class FeatureFlagDirective<T extends FlagValue> implements OnInit, OnDestroy, OnChanges {
|
||||||
|
protected _changeDetectorRef: ChangeDetectorRef;
|
||||||
|
protected _viewContainerRef: ViewContainerRef;
|
||||||
|
|
||||||
protected _featureFlagDefault: T;
|
protected _featureFlagDefault: T;
|
||||||
protected _featureFlagDomain: string | undefined;
|
protected _featureFlagDomain: string | undefined;
|
||||||
|
|
||||||
|
@ -64,13 +68,7 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
|
||||||
protected _reconcilingTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
|
protected _reconcilingTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
|
||||||
protected _reconcilingViewRef: EmbeddedViewRef<unknown> | null;
|
protected _reconcilingViewRef: EmbeddedViewRef<unknown> | null;
|
||||||
|
|
||||||
protected constructor(
|
protected constructor() {}
|
||||||
protected _changeDetectorRef: ChangeDetectorRef,
|
|
||||||
protected _viewContainerRef: ViewContainerRef,
|
|
||||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<T>>,
|
|
||||||
) {
|
|
||||||
this._thenTemplateRef = templateRef;
|
|
||||||
}
|
|
||||||
|
|
||||||
set featureFlagDomain(domain: string | undefined) {
|
set featureFlagDomain(domain: string | undefined) {
|
||||||
/**
|
/**
|
||||||
|
@ -232,6 +230,10 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
|
||||||
selector: '[booleanFeatureFlag]',
|
selector: '[booleanFeatureFlag]',
|
||||||
})
|
})
|
||||||
export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> implements OnChanges {
|
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.
|
* The key of the boolean feature flag.
|
||||||
*/
|
*/
|
||||||
|
@ -242,12 +244,8 @@ export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> i
|
||||||
*/
|
*/
|
||||||
@Input({ required: true }) booleanFeatureFlagDefault: boolean;
|
@Input({ required: true }) booleanFeatureFlagDefault: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor() {
|
||||||
_changeDetectorRef: ChangeDetectorRef,
|
super();
|
||||||
_viewContainerRef: ViewContainerRef,
|
|
||||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<boolean>>,
|
|
||||||
) {
|
|
||||||
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override ngOnChanges() {
|
override ngOnChanges() {
|
||||||
|
@ -347,6 +345,10 @@ export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> i
|
||||||
selector: '[numberFeatureFlag]',
|
selector: '[numberFeatureFlag]',
|
||||||
})
|
})
|
||||||
export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> implements OnChanges {
|
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.
|
* The key of the number feature flag.
|
||||||
*/
|
*/
|
||||||
|
@ -362,12 +364,8 @@ export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> imp
|
||||||
*/
|
*/
|
||||||
@Input({ required: false }) numberFeatureFlagValue?: number;
|
@Input({ required: false }) numberFeatureFlagValue?: number;
|
||||||
|
|
||||||
constructor(
|
constructor() {
|
||||||
_changeDetectorRef: ChangeDetectorRef,
|
super();
|
||||||
_viewContainerRef: ViewContainerRef,
|
|
||||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<number>>,
|
|
||||||
) {
|
|
||||||
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override ngOnChanges() {
|
override ngOnChanges() {
|
||||||
|
@ -467,6 +465,10 @@ export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> imp
|
||||||
selector: '[stringFeatureFlag]',
|
selector: '[stringFeatureFlag]',
|
||||||
})
|
})
|
||||||
export class StringFeatureFlagDirective extends FeatureFlagDirective<string> implements OnChanges {
|
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.
|
* The key of the string feature flag.
|
||||||
*/
|
*/
|
||||||
|
@ -482,12 +484,8 @@ export class StringFeatureFlagDirective extends FeatureFlagDirective<string> imp
|
||||||
*/
|
*/
|
||||||
@Input({ required: false }) stringFeatureFlagValue?: string;
|
@Input({ required: false }) stringFeatureFlagValue?: string;
|
||||||
|
|
||||||
constructor(
|
constructor() {
|
||||||
_changeDetectorRef: ChangeDetectorRef,
|
super();
|
||||||
_viewContainerRef: ViewContainerRef,
|
|
||||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<string>>,
|
|
||||||
) {
|
|
||||||
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override ngOnChanges() {
|
override ngOnChanges() {
|
||||||
|
@ -587,6 +585,10 @@ export class StringFeatureFlagDirective extends FeatureFlagDirective<string> imp
|
||||||
selector: '[objectFeatureFlag]',
|
selector: '[objectFeatureFlag]',
|
||||||
})
|
})
|
||||||
export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlagDirective<T> implements OnChanges {
|
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.
|
* The key of the object feature flag.
|
||||||
*/
|
*/
|
||||||
|
@ -602,12 +604,8 @@ export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlag
|
||||||
*/
|
*/
|
||||||
@Input({ required: false }) objectFeatureFlagValue?: T;
|
@Input({ required: false }) objectFeatureFlagValue?: T;
|
||||||
|
|
||||||
constructor(
|
constructor() {
|
||||||
_changeDetectorRef: ChangeDetectorRef,
|
super();
|
||||||
_viewContainerRef: ViewContainerRef,
|
|
||||||
templateRef: TemplateRef<FeatureFlagDirectiveContext<T>>,
|
|
||||||
) {
|
|
||||||
super(_changeDetectorRef, _viewContainerRef, templateRef);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override ngOnChanges() {
|
override ngOnChanges() {
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
|
|
||||||
|
export default [provideZonelessChangeDetection()];
|
|
@ -3,15 +3,20 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/spec",
|
"outDir": "./out-tsc/spec",
|
||||||
"types": [
|
"types": [
|
||||||
"jest",
|
"vitest/globals",
|
||||||
"node"
|
"node"
|
||||||
],
|
],
|
||||||
|
"paths": {
|
||||||
|
"angular": [
|
||||||
|
"./dist/angular"
|
||||||
|
]
|
||||||
|
},
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"emitDecoratorMetadata": true
|
"emitDecoratorMetadata": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.spec.ts",
|
"src/**/*.spec.ts",
|
||||||
"src/**/*.d.ts",
|
"src/**/*.d.ts",
|
||||||
"setup-jest.ts"
|
"src/test-provider.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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());
|
|
|
@ -23,8 +23,8 @@
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"target": "ES2015",
|
"target": "ES2022",
|
||||||
"module": "ES2015",
|
"module": "ES2022",
|
||||||
"useDefineForClassFields": false,
|
"useDefineForClassFields": false,
|
||||||
"strictNullChecks": false,
|
"strictNullChecks": false,
|
||||||
"lib": [
|
"lib": [
|
||||||
|
|
|
@ -3,13 +3,14 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/spec",
|
"outDir": "./out-tsc/spec",
|
||||||
"types": [
|
"types": [
|
||||||
"jest"
|
"vitest/globals",
|
||||||
|
"node"
|
||||||
],
|
],
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"emitDecoratorMetadata": true
|
"emitDecoratorMetadata": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.spec.ts",
|
"projects/angular-sdk/src/**/*.spec.ts",
|
||||||
"src/**/*.d.ts"
|
"projects/angular-sdk/src/**/*.d.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
|
@ -1,5 +1,12 @@
|
||||||
# Changelog
|
# 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)
|
## [1.0.0](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.11...react-sdk-v1.0.0) (2025-04-14)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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" />
|
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-start-version -->
|
<!-- x-release-please-start-version -->
|
||||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/react-sdk-v1.0.0">
|
<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.0&color=blue&style=for-the-badge" />
|
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.0.1&color=blue&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-end -->
|
<!-- x-release-please-end -->
|
||||||
<br/>
|
<br/>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/react-sdk",
|
"name": "@openfeature/react-sdk",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"description": "OpenFeature React SDK",
|
"description": "OpenFeature React SDK",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
"files": [
|
"files": [
|
||||||
|
|
|
@ -8,7 +8,7 @@ import type {
|
||||||
JsonValue,
|
JsonValue,
|
||||||
} from '@openfeature/web-sdk';
|
} from '@openfeature/web-sdk';
|
||||||
import { ProviderEvents, ProviderStatus } 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 {
|
import {
|
||||||
DEFAULT_OPTIONS,
|
DEFAULT_OPTIONS,
|
||||||
isEqual,
|
isEqual,
|
||||||
|
@ -287,8 +287,7 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
||||||
const client = useOpenFeatureClient();
|
const client = useOpenFeatureClient();
|
||||||
const status = useOpenFeatureClientStatus();
|
const status = useOpenFeatureClientStatus();
|
||||||
const provider = useOpenFeatureProvider();
|
const provider = useOpenFeatureProvider();
|
||||||
|
const isFirstRender = useRef(true);
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
|
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
|
||||||
suspendUntilInitialized(provider, client);
|
suspendUntilInitialized(provider, client);
|
||||||
|
@ -298,17 +297,30 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
||||||
suspendUntilReconciled(client);
|
suspendUntilReconciled(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(
|
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(() =>
|
||||||
resolver(client).call(client, flagKey, defaultValue, options),
|
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.
|
// Maintain a mutable reference to the evaluation details to have a up-to-date reference in the handlers.
|
||||||
const evaluationDetailsRef = useRef<EvaluationDetails<T>>(evaluationDetails);
|
const evaluationDetailsRef = useRef<EvaluationDetails<T>>(evaluationDetails);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
evaluationDetailsRef.current = evaluationDetails;
|
evaluationDetailsRef.current = evaluationDetails;
|
||||||
}, [evaluationDetails]);
|
}, [evaluationDetails]);
|
||||||
|
|
||||||
const updateEvaluationDetailsCallback = () => {
|
const updateEvaluationDetailsCallback = useCallback(() => {
|
||||||
const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options);
|
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)) {
|
if (!isEqual(updatedEvaluationDetails.value, evaluationDetailsRef.current.value)) {
|
||||||
setEvaluationDetails(updatedEvaluationDetails);
|
setEvaluationDetails(updatedEvaluationDetails);
|
||||||
}
|
}
|
||||||
};
|
}, [client, flagKey, defaultValue, options, resolver]);
|
||||||
|
|
||||||
const configurationChangeCallback: EventHandler<ClientProviderEvents.ConfigurationChanged> = (eventDetails) => {
|
const configurationChangeCallback = useCallback<EventHandler<ClientProviderEvents.ConfigurationChanged>>(
|
||||||
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
|
(eventDetails) => {
|
||||||
updateEvaluationDetailsCallback();
|
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
|
||||||
}
|
updateEvaluationDetailsCallback();
|
||||||
};
|
}
|
||||||
|
},
|
||||||
|
[flagKey, updateEvaluationDetailsCallback],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
if (status === ProviderStatus.NOT_READY) {
|
if (status === ProviderStatus.NOT_READY) {
|
||||||
// update when the provider is ready
|
// update when the provider is ready
|
||||||
client.addHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback, { signal: controller.signal });
|
client.addHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback, { signal: controller.signal });
|
||||||
|
@ -348,7 +364,14 @@ function attachHandlersAndResolve<T extends FlagValue>(
|
||||||
// cleanup the handlers
|
// cleanup the handlers
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [
|
||||||
|
client,
|
||||||
|
status,
|
||||||
|
defaultedOptions.updateOnContextChanged,
|
||||||
|
defaultedOptions.updateOnConfigurationChanged,
|
||||||
|
updateEvaluationDetailsCallback,
|
||||||
|
configurationChangeCallback,
|
||||||
|
]);
|
||||||
|
|
||||||
return evaluationDetails;
|
return evaluationDetails;
|
||||||
}
|
}
|
||||||
|
|
|
@ -924,17 +924,124 @@ describe('evaluation', () => {
|
||||||
OpenFeature.setContext(SUSPEND_OFF, { user: TARGETED_USER });
|
OpenFeature.setContext(SUSPEND_OFF, { user: TARGETED_USER });
|
||||||
});
|
});
|
||||||
|
|
||||||
// expect to see static value until we reconcile
|
// With the fix for useState initialization, the hook now immediately
|
||||||
await waitFor(() => expect(screen.queryByText(STATIC_FLAG_VALUE_A)).toBeInTheDocument(), {
|
// reflects provider state changes. This is intentional to handle cases
|
||||||
timeout: DELAY / 2,
|
// where providers don't emit proper events.
|
||||||
});
|
// The value updates immediately to the targeted value.
|
||||||
|
|
||||||
// make sure we updated after reconciling
|
|
||||||
await waitFor(() => expect(screen.queryByText(TARGETED_FLAG_VALUE)).toBeInTheDocument(), {
|
await waitFor(() => expect(screen.queryByText(TARGETED_FLAG_VALUE)).toBeInTheDocument(), {
|
||||||
timeout: DELAY * 2,
|
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', () => {
|
describe('context, hooks and options', () => {
|
||||||
|
|
|
@ -288,7 +288,7 @@ describe('OpenFeatureProvider', () => {
|
||||||
{ timeout: DELAY * 4 },
|
{ timeout: DELAY * 4 },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('Will says hi')).toBeInTheDocument();
|
expect(screen.getByText('Will says aloha')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,27 @@
|
||||||
# Changelog
|
# 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)
|
## [1.18.0](https://github.com/open-feature/js-sdk/compare/server-sdk-v1.17.1...server-sdk-v1.18.0) (2025-04-11)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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" />
|
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-start-version -->
|
<!-- x-release-please-start-version -->
|
||||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/server-sdk-v1.18.0">
|
<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.18.0&color=blue&style=for-the-badge" />
|
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.19.0&color=blue&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-end -->
|
<!-- x-release-please-end -->
|
||||||
<br/>
|
<br/>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/server-sdk",
|
"name": "@openfeature/server-sdk",
|
||||||
"version": "1.18.0",
|
"version": "1.19.0",
|
||||||
"description": "OpenFeature SDK for JavaScript",
|
"description": "OpenFeature SDK for JavaScript",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
"files": [
|
"files": [
|
||||||
|
@ -48,9 +48,9 @@
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@openfeature/core": "^1.7.0"
|
"@openfeature/core": "^1.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openfeature/core": "^1.7.0"
|
"@openfeature/core": "^1.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ import {
|
||||||
StandardResolutionReasons,
|
StandardResolutionReasons,
|
||||||
instantiateErrorByErrorCode,
|
instantiateErrorByErrorCode,
|
||||||
statusMatchesEvent,
|
statusMatchesEvent,
|
||||||
|
MapHookData,
|
||||||
} from '@openfeature/core';
|
} from '@openfeature/core';
|
||||||
import type { FlagEvaluationOptions } from '../../evaluation';
|
import type { FlagEvaluationOptions } from '../../evaluation';
|
||||||
import type { ProviderEvents } from '../../events';
|
import type { ProviderEvents } from '../../events';
|
||||||
|
@ -276,22 +277,26 @@ export class OpenFeatureClient implements Client {
|
||||||
|
|
||||||
const mergedContext = this.mergeContexts(invocationContext);
|
const mergedContext = this.mergeContexts(invocationContext);
|
||||||
|
|
||||||
// this reference cannot change during the course of evaluation
|
// Create hook context instances for each hook (stable object references for the entire evaluation)
|
||||||
// it may be used as a key in WeakMaps
|
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
|
||||||
const hookContext: Readonly<HookContext> = {
|
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index.
|
||||||
flagKey,
|
const hookContexts = allHooksReversed.map<HookContext>(() =>
|
||||||
defaultValue,
|
Object.freeze({
|
||||||
flagValueType: flagType,
|
flagKey,
|
||||||
clientMetadata: this.metadata,
|
defaultValue,
|
||||||
providerMetadata: this._provider.metadata,
|
flagValueType: flagType,
|
||||||
context: mergedContext,
|
clientMetadata: this.metadata,
|
||||||
logger: this._logger,
|
providerMetadata: this._provider.metadata,
|
||||||
};
|
context: mergedContext,
|
||||||
|
logger: this._logger,
|
||||||
|
hookData: new MapHookData(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let evaluationDetails: EvaluationDetails<T>;
|
let evaluationDetails: EvaluationDetails<T>;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frozenContext = await this.beforeHooks(allHooks, hookContext, options);
|
const frozenContext = await this.beforeHooks(allHooks, hookContexts, mergedContext, options);
|
||||||
|
|
||||||
this.shortCircuitIfNotReady();
|
this.shortCircuitIfNotReady();
|
||||||
|
|
||||||
|
@ -306,53 +311,71 @@ export class OpenFeatureClient implements Client {
|
||||||
|
|
||||||
if (resolutionDetails.errorCode) {
|
if (resolutionDetails.errorCode) {
|
||||||
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
|
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);
|
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
|
||||||
} else {
|
} else {
|
||||||
await this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
|
await this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
|
||||||
evaluationDetails = resolutionDetails;
|
evaluationDetails = resolutionDetails;
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
await this.errorHooks(allHooksReversed, hookContext, err, options);
|
await this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
|
await this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
|
||||||
return evaluationDetails;
|
return evaluationDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
|
private async beforeHooks(
|
||||||
for (const hook of hooks) {
|
hooks: Hook[],
|
||||||
// freeze the hookContext
|
hookContexts: HookContext[],
|
||||||
Object.freeze(hookContext);
|
mergedContext: EvaluationContext,
|
||||||
|
options: FlagEvaluationOptions,
|
||||||
|
) {
|
||||||
|
let accumulatedContext = mergedContext;
|
||||||
|
|
||||||
// use Object.assign to avoid modification of frozen hookContext
|
for (const [index, hook] of hooks.entries()) {
|
||||||
Object.assign(hookContext.context, {
|
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks
|
||||||
...hookContext.context,
|
const hookContext = hookContexts[hookContextIndex];
|
||||||
...(await hook?.before?.(hookContext, Object.freeze(options.hookHints))),
|
|
||||||
});
|
// 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.
|
// after before hooks, freeze the EvaluationContext.
|
||||||
return Object.freeze(hookContext.context);
|
return Object.freeze(accumulatedContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterHooks(
|
private async afterHooks(
|
||||||
hooks: Hook[],
|
hooks: Hook[],
|
||||||
hookContext: HookContext,
|
hookContexts: HookContext[],
|
||||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||||
options: FlagEvaluationOptions,
|
options: FlagEvaluationOptions,
|
||||||
) {
|
) {
|
||||||
// run "after" hooks sequentially
|
// 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);
|
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
|
// run "error" hooks sequentially
|
||||||
for (const hook of hooks) {
|
for (const [index, hook] of hooks.entries()) {
|
||||||
try {
|
try {
|
||||||
|
const hookContext = hookContexts[index];
|
||||||
await hook?.error?.(hookContext, err, options.hookHints);
|
await hook?.error?.(hookContext, err, options.hookHints);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
||||||
|
@ -366,13 +389,14 @@ export class OpenFeatureClient implements Client {
|
||||||
|
|
||||||
private async finallyHooks(
|
private async finallyHooks(
|
||||||
hooks: Hook[],
|
hooks: Hook[],
|
||||||
hookContext: HookContext,
|
hookContexts: HookContext[],
|
||||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||||
options: FlagEvaluationOptions,
|
options: FlagEvaluationOptions,
|
||||||
) {
|
) {
|
||||||
// run "finally" hooks sequentially
|
// run "finally" hooks sequentially
|
||||||
for (const hook of hooks) {
|
for (const [index, hook] of hooks.entries()) {
|
||||||
try {
|
try {
|
||||||
|
const hookContext = hookContexts[index];
|
||||||
await hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
await hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import type { BaseHook, EvaluationContext, FlagValue } from '@openfeature/core';
|
import type { BaseHook, EvaluationContext, FlagValue } from '@openfeature/core';
|
||||||
|
|
||||||
export type Hook = BaseHook<
|
export type Hook<TData = Record<string, unknown>> = BaseHook<
|
||||||
FlagValue,
|
FlagValue,
|
||||||
|
TData,
|
||||||
Promise<EvaluationContext | void> | EvaluationContext | void,
|
Promise<EvaluationContext | void> | EvaluationContext | void,
|
||||||
Promise<void> | void
|
Promise<void> | void
|
||||||
>;
|
>;
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,5 +1,13 @@
|
||||||
# Changelog
|
# 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)
|
## [1.8.1](https://github.com/open-feature/js-sdk/compare/core-v1.8.0...core-v1.8.1) (2025-06-04)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/core",
|
"name": "@openfeature/core",
|
||||||
"version": "1.8.1",
|
"version": "1.9.0",
|
||||||
"description": "Shared OpenFeature JS components (server and web)",
|
"description": "Shared OpenFeature JS components (server and web)",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
"files": [
|
"files": [
|
||||||
|
|
|
@ -18,3 +18,4 @@ export abstract class OpenFeatureError extends Error {
|
||||||
this.cause = options?.cause;
|
this.cause = options?.cause;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,14 +1,19 @@
|
||||||
import type { BeforeHookContext, HookContext, HookHints } from './hooks';
|
import type { BeforeHookContext, HookContext, HookHints } from './hooks';
|
||||||
import type { EvaluationDetails, FlagValue } from '../evaluation';
|
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.
|
* Runs before flag values are resolved from the provider.
|
||||||
* If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext.
|
* If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext.
|
||||||
* @param hookContext
|
* @param hookContext
|
||||||
* @param hookHints
|
* @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.
|
* 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
|
* @param hookHints
|
||||||
*/
|
*/
|
||||||
after?(
|
after?(
|
||||||
hookContext: Readonly<HookContext<T>>,
|
hookContext: Readonly<HookContext<T, TData>>,
|
||||||
evaluationDetails: EvaluationDetails<T>,
|
evaluationDetails: EvaluationDetails<T>,
|
||||||
hookHints?: HookHints,
|
hookHints?: HookHints,
|
||||||
): HooksReturn;
|
): HooksReturn;
|
||||||
|
@ -28,7 +33,7 @@ export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = un
|
||||||
* @param error
|
* @param error
|
||||||
* @param hookHints
|
* @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.
|
* 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
|
* @param hookHints
|
||||||
*/
|
*/
|
||||||
finally?(
|
finally?(
|
||||||
hookContext: Readonly<HookContext<T>>,
|
hookContext: Readonly<HookContext<T, TData>>,
|
||||||
evaluationDetails: EvaluationDetails<T>,
|
evaluationDetails: EvaluationDetails<T>,
|
||||||
hookHints?: HookHints,
|
hookHints?: HookHints,
|
||||||
): HooksReturn;
|
): HooksReturn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,11 @@ import type { ProviderMetadata } from '../provider';
|
||||||
import type { ClientMetadata } from '../client';
|
import type { ClientMetadata } from '../client';
|
||||||
import type { EvaluationContext, FlagValue, FlagValueType } from '../evaluation';
|
import type { EvaluationContext, FlagValue, FlagValueType } from '../evaluation';
|
||||||
import type { Logger } from '../logger';
|
import type { Logger } from '../logger';
|
||||||
|
import type { HookData } from './hook-data';
|
||||||
|
|
||||||
export type HookHints = Readonly<Record<string, unknown>>;
|
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 flagKey: string;
|
||||||
readonly defaultValue: T;
|
readonly defaultValue: T;
|
||||||
readonly flagValueType: FlagValueType;
|
readonly flagValueType: FlagValueType;
|
||||||
|
@ -13,8 +14,9 @@ export interface HookContext<T extends FlagValue = FlagValue> {
|
||||||
readonly clientMetadata: ClientMetadata;
|
readonly clientMetadata: ClientMetadata;
|
||||||
readonly providerMetadata: ProviderMetadata;
|
readonly providerMetadata: ProviderMetadata;
|
||||||
readonly logger: Logger;
|
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;
|
context: EvaluationContext;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
export * from './hook';
|
export * from './hook';
|
||||||
export * from './hooks';
|
export * from './hooks';
|
||||||
export * from './evaluation-lifecycle';
|
export * from './evaluation-lifecycle';
|
||||||
|
export * from './hook-data';
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
|
@ -2,6 +2,7 @@ import { createEvaluationEvent } from '../src/telemetry/evaluation-event';
|
||||||
import { ErrorCode, StandardResolutionReasons, type EvaluationDetails } from '../src/evaluation/evaluation';
|
import { ErrorCode, StandardResolutionReasons, type EvaluationDetails } from '../src/evaluation/evaluation';
|
||||||
import type { HookContext } from '../src/hooks/hooks';
|
import type { HookContext } from '../src/hooks/hooks';
|
||||||
import { TelemetryAttribute, TelemetryFlagMetadata } from '../src/telemetry';
|
import { TelemetryAttribute, TelemetryFlagMetadata } from '../src/telemetry';
|
||||||
|
import { MapHookData } from '../src/hooks/hook-data';
|
||||||
|
|
||||||
describe('evaluationEvent', () => {
|
describe('evaluationEvent', () => {
|
||||||
const flagKey = 'test-flag';
|
const flagKey = 'test-flag';
|
||||||
|
@ -25,6 +26,7 @@ describe('evaluationEvent', () => {
|
||||||
error: jest.fn(),
|
error: jest.fn(),
|
||||||
warn: jest.fn(),
|
warn: jest.fn(),
|
||||||
},
|
},
|
||||||
|
hookData: new MapHookData(),
|
||||||
};
|
};
|
||||||
|
|
||||||
it('should return basic event body with mandatory fields', () => {
|
it('should return basic event body with mandatory fields', () => {
|
||||||
|
|
|
@ -1,6 +1,26 @@
|
||||||
# Changelog
|
# 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)
|
## [1.5.0](https://github.com/open-feature/js-sdk/compare/web-sdk-v1.4.1...web-sdk-v1.5.0) (2025-04-11)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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" />
|
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-start-version -->
|
<!-- x-release-please-start-version -->
|
||||||
<a href="https://github.com/open-feature/js-sdk/releases/tag/web-sdk-v1.5.0">
|
<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.5.0&color=blue&style=for-the-badge" />
|
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.6.1&color=blue&style=for-the-badge" />
|
||||||
</a>
|
</a>
|
||||||
<!-- x-release-please-end -->
|
<!-- x-release-please-end -->
|
||||||
<br/>
|
<br/>
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
{
|
{
|
||||||
"name": "@openfeature/web-sdk",
|
"name": "@openfeature/web-sdk",
|
||||||
"version": "1.5.0",
|
"version": "1.6.1",
|
||||||
"description": "OpenFeature SDK for Web",
|
"description": "OpenFeature SDK for Web",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
|
"unpkg": "dist/global/index.min.js",
|
||||||
|
"jsdelivr": "dist/global/index.min.js",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/"
|
"dist/"
|
||||||
],
|
],
|
||||||
|
@ -20,8 +22,10 @@
|
||||||
"clean": "shx rm -rf ./dist",
|
"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-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-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: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",
|
"postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json",
|
||||||
"current-version": "echo $npm_package_version",
|
"current-version": "echo $npm_package_version",
|
||||||
"prepack": "shx cp ./../../LICENSE ./LICENSE",
|
"prepack": "shx cp ./../../LICENSE ./LICENSE",
|
||||||
|
@ -46,9 +50,9 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
"homepage": "https://github.com/open-feature/js-sdk#readme",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@openfeature/core": "^1.8.0"
|
"@openfeature/core": "^1.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openfeature/core": "^1.8.0"
|
"@openfeature/core": "^1.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ import {
|
||||||
StandardResolutionReasons,
|
StandardResolutionReasons,
|
||||||
instantiateErrorByErrorCode,
|
instantiateErrorByErrorCode,
|
||||||
statusMatchesEvent,
|
statusMatchesEvent,
|
||||||
|
MapHookData,
|
||||||
} from '@openfeature/core';
|
} from '@openfeature/core';
|
||||||
import type { FlagEvaluationOptions } from '../../evaluation';
|
import type { FlagEvaluationOptions } from '../../evaluation';
|
||||||
import type { ProviderEvents } from '../../events';
|
import type { ProviderEvents } from '../../events';
|
||||||
|
@ -231,22 +232,26 @@ export class OpenFeatureClient implements Client {
|
||||||
...this.apiContextAccessor(this?.options?.domain),
|
...this.apiContextAccessor(this?.options?.domain),
|
||||||
};
|
};
|
||||||
|
|
||||||
// this reference cannot change during the course of evaluation
|
// Create hook context instances for each hook (stable object references for the entire evaluation)
|
||||||
// it may be used as a key in WeakMaps
|
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
|
||||||
const hookContext: Readonly<HookContext> = {
|
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index.
|
||||||
flagKey,
|
const hookContexts = allHooksReversed.map<HookContext>(() =>
|
||||||
defaultValue,
|
Object.freeze({
|
||||||
flagValueType: flagType,
|
flagKey,
|
||||||
clientMetadata: this.metadata,
|
defaultValue,
|
||||||
providerMetadata: this._provider.metadata,
|
flagValueType: flagType,
|
||||||
context,
|
clientMetadata: this.metadata,
|
||||||
logger: this._logger,
|
providerMetadata: this._provider.metadata,
|
||||||
};
|
context,
|
||||||
|
logger: this._logger,
|
||||||
|
hookData: new MapHookData(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let evaluationDetails: EvaluationDetails<T>;
|
let evaluationDetails: EvaluationDetails<T>;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.beforeHooks(allHooks, hookContext, options);
|
this.beforeHooks(allHooks, hookContexts, options);
|
||||||
|
|
||||||
this.shortCircuitIfNotReady();
|
this.shortCircuitIfNotReady();
|
||||||
|
|
||||||
|
@ -261,45 +266,48 @@ export class OpenFeatureClient implements Client {
|
||||||
|
|
||||||
if (resolutionDetails.errorCode) {
|
if (resolutionDetails.errorCode) {
|
||||||
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
|
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);
|
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
|
||||||
} else {
|
} else {
|
||||||
this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
|
this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
|
||||||
evaluationDetails = resolutionDetails;
|
evaluationDetails = resolutionDetails;
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
this.errorHooks(allHooksReversed, hookContext, err, options);
|
this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
||||||
}
|
}
|
||||||
this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
|
this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
|
||||||
return evaluationDetails;
|
return evaluationDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
private beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
|
private beforeHooks(hooks: Hook[], hookContexts: HookContext[], options: FlagEvaluationOptions) {
|
||||||
Object.freeze(hookContext);
|
for (const [index, hook] of hooks.entries()) {
|
||||||
Object.freeze(hookContext.context);
|
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks
|
||||||
|
const hookContext = hookContexts[hookContextIndex];
|
||||||
for (const hook of hooks) {
|
Object.freeze(hookContext);
|
||||||
|
Object.freeze(hookContext.context);
|
||||||
hook?.before?.(hookContext, Object.freeze(options.hookHints));
|
hook?.before?.(hookContext, Object.freeze(options.hookHints));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private afterHooks(
|
private afterHooks(
|
||||||
hooks: Hook[],
|
hooks: Hook[],
|
||||||
hookContext: HookContext,
|
hookContexts: HookContext[],
|
||||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||||
options: FlagEvaluationOptions,
|
options: FlagEvaluationOptions,
|
||||||
) {
|
) {
|
||||||
// run "after" hooks sequentially
|
// 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);
|
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
|
// run "error" hooks sequentially
|
||||||
for (const hook of hooks) {
|
for (const [index, hook] of hooks.entries()) {
|
||||||
try {
|
try {
|
||||||
|
const hookContext = hookContexts[index];
|
||||||
hook?.error?.(hookContext, err, options.hookHints);
|
hook?.error?.(hookContext, err, options.hookHints);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
||||||
|
@ -313,13 +321,14 @@ export class OpenFeatureClient implements Client {
|
||||||
|
|
||||||
private finallyHooks(
|
private finallyHooks(
|
||||||
hooks: Hook[],
|
hooks: Hook[],
|
||||||
hookContext: HookContext,
|
hookContexts: HookContext[],
|
||||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||||
options: FlagEvaluationOptions,
|
options: FlagEvaluationOptions,
|
||||||
) {
|
) {
|
||||||
// run "finally" hooks sequentially
|
// run "finally" hooks sequentially
|
||||||
for (const hook of hooks) {
|
for (const [index, hook] of hooks.entries()) {
|
||||||
try {
|
try {
|
||||||
|
const hookContext = hookContexts[index];
|
||||||
hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
import type { BaseHook, FlagValue } from '@openfeature/core';
|
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>;
|
||||||
|
|
|
@ -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.]+$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -20,7 +20,6 @@
|
||||||
"versioning": "default"
|
"versioning": "default"
|
||||||
},
|
},
|
||||||
"packages/react": {
|
"packages/react": {
|
||||||
"release-as": "1.0.0",
|
|
||||||
"release-type": "node",
|
"release-type": "node",
|
||||||
"prerelease": false,
|
"prerelease": false,
|
||||||
"bump-minor-pre-major": true,
|
"bump-minor-pre-major": true,
|
||||||
|
|
Loading…
Reference in New Issue