Skip to content

Commit 297e8ac

Browse files
committed
feat: add debounce hook
Signed-off-by: Todd Baert <[email protected]>
1 parent 15ae73b commit 297e8ac

17 files changed

+713
-1
lines changed

.release-please-manifest.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@
2222
"libs/providers/unleash-web": "0.1.1",
2323
"libs/providers/growthbook": "0.1.2",
2424
"libs/providers/aws-ssm": "0.1.3",
25-
"libs/providers/flagsmith": "0.1.1"
25+
"libs/providers/flagsmith": "0.1.1",
26+
"libs/hooks/debounce": "0.1.0"
2627
}

libs/hooks/debounce/.eslintrc.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"extends": "../../../.eslintrc.json",
3+
"ignorePatterns": ["!**/*"],
4+
"overrides": [
5+
{
6+
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
7+
"rules": {}
8+
},
9+
{
10+
"files": ["*.ts", "*.tsx"],
11+
"rules": {}
12+
},
13+
{
14+
"files": ["*.js", "*.jsx"],
15+
"rules": {}
16+
},
17+
{
18+
"files": ["*.json"],
19+
"parser": "jsonc-eslint-parser",
20+
"rules": {
21+
"@nx/dependency-checks": [
22+
"error",
23+
{
24+
"ignoredFiles": ["{projectRoot}/eslint.config.{js,cjs,mjs}"]
25+
}
26+
]
27+
}
28+
}
29+
]
30+
}

libs/hooks/debounce/README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Debounce Hook
2+
3+
This is a utility "meta" hook, which can be used to effectively debounce or rate limit other hooks based on various parameters.
4+
This can be especially useful for certain UI frameworks and SDKs that frequently re-render and re-evaluate flags (React, Angular, etc).
5+
6+
## Installation
7+
8+
```
9+
$ npm install @openfeature/debounce-hook
10+
```
11+
12+
### Peer dependencies
13+
14+
Confirm that the following peer dependencies are installed:
15+
16+
```
17+
$ npm install @openfeature/web-sdk
18+
```
19+
20+
NOTE: if you're using the React or Angular SDKs, you don't need to directly install this web SDK.
21+
22+
## Usage
23+
24+
The hook maintains a simple LRU cache and keeps a record of recent evaluations based on a user-defined key-generation function (keySupplier).
25+
Simply wrap your hook with the debounce hook by passing it a constructor arg, and then configure the remaining options.
26+
In the example below, we wrap a logging hook so that it only logs a maximum of once a minute for each flag key, no matter how many times that flag is evaluated.
27+
28+
```ts
29+
// a function defining the key for the hook stage
30+
const supplier = (flagKey: string, context: EvaluationContext, details: EvaluationDetails<T>) => flagKey;
31+
32+
const hook = new DebounceHook<string>(loggingHook, {
33+
afterCacheKeySupplier: supplier, // if the key calculated by the supplier exists in the cache, the wrapped hook's stage will not run
34+
ttlMs: 60_000, // how long to cache keys for
35+
maxCacheItems: 100, // max amount of items to keep in the LRU cache
36+
cacheErrors: false // whether or not to cache the errors thrown by hook stages
37+
});
38+
```
39+
40+
## Development
41+
42+
### Building
43+
44+
Run `nx package hooks-debounce` to build the library.
45+
46+
### Running unit tests
47+
48+
Run `nx test hooks-debounce` to execute the unit tests via [Jest](https://jestjs.io).
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"presets": [["minify", { "builtIns": false }]]
3+
}

libs/hooks/debounce/jest.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export default {
2+
displayName: 'debounce',
3+
preset: '../../../jest.preset.js',
4+
transform: {
5+
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
6+
},
7+
moduleFileExtensions: ['ts', 'js', 'html'],
8+
coverageDirectory: '../coverage/hooks',
9+
};

libs/hooks/debounce/package.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "@openfeature/debounce-hook",
3+
"version": "0.0.1",
4+
"dependencies": {
5+
"tslib": "^2.3.0"
6+
},
7+
"main": "./src/index.js",
8+
"typings": "./src/index.d.ts",
9+
"scripts": {
10+
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi",
11+
"current-version": "echo $npm_package_version"
12+
},
13+
"license": "Apache-2.0",
14+
"peerDependencies": {
15+
"@openfeature/web-sdk": "^1.6.0"
16+
}
17+
}

libs/hooks/debounce/project.json

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
{
2+
"name": "debounce",
3+
"$schema": "../node_modules/nx/schemas/project-schema.json",
4+
"sourceRoot": "hooks/src",
5+
"projectType": "library",
6+
"release": {
7+
"version": {
8+
"generatorOptions": {
9+
"packageRoot": "dist/{projectRoot}",
10+
"currentVersionResolver": "git-tag"
11+
}
12+
}
13+
},
14+
"tags": [],
15+
"targets": {
16+
"nx-release-publish": {
17+
"options": {
18+
"packageRoot": "dist/{projectRoot}"
19+
}
20+
},
21+
"lint": {
22+
"executor": "@nx/eslint:lint"
23+
},
24+
"test": {
25+
"executor": "@nx/jest:jest",
26+
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
27+
"options": {
28+
"jestConfig": "{projectRoot}/jest.config.ts"
29+
}
30+
},
31+
"package": {
32+
"executor": "@nx/rollup:rollup",
33+
"outputs": ["{options.outputPath}"],
34+
"options": {
35+
"project": "libs/hooks/debounce/package.json",
36+
"outputPath": "dist/libs/hooks/debounce",
37+
"entryFile": "libs/hooks/debounce/src/index.ts",
38+
"tsConfig": "libs/hooks/debounce/tsconfig.lib.json",
39+
"compiler": "tsc",
40+
"generateExportsField": true,
41+
"umdName": "debounce",
42+
"external": "all",
43+
"format": ["cjs", "esm"],
44+
"assets": [
45+
{
46+
"glob": "package.json",
47+
"input": "./assets",
48+
"output": "./src/"
49+
},
50+
{
51+
"glob": "LICENSE",
52+
"input": "./",
53+
"output": "./"
54+
},
55+
{
56+
"glob": "README.md",
57+
"input": "./libs/hooks/debounce",
58+
"output": "./"
59+
}
60+
]
61+
}
62+
},
63+
"publish": {
64+
"executor": "nx:run-commands",
65+
"options": {
66+
"command": "npm run publish-if-not-exists",
67+
"cwd": "dist/libs/hooks/debounce"
68+
},
69+
"dependsOn": [
70+
{
71+
"projects": "self",
72+
"target": "package"
73+
}
74+
]
75+
}
76+
}
77+
}

libs/hooks/debounce/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './lib/debounce-hook';
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import type { EvaluationDetails, Hook, HookContext } from '@openfeature/web-sdk';
2+
import { DebounceHook } from './debounce-hook';
3+
4+
describe('DebounceHook', () => {
5+
describe('caching', () => {
6+
afterAll(() => {
7+
jest.resetAllMocks();
8+
});
9+
10+
const innerHook: Hook = {
11+
before: jest.fn(),
12+
after: jest.fn(),
13+
error: jest.fn(),
14+
finally: jest.fn(),
15+
};
16+
17+
const supplier = (flagKey: string) => flagKey;
18+
19+
const hook = new DebounceHook<string>(innerHook, {
20+
beforeCacheKeySupplier: supplier,
21+
afterCacheKeySupplier: supplier,
22+
errorCacheKeySupplier: supplier,
23+
finallyCacheKeySupplier: supplier,
24+
ttlMs: 60_000,
25+
maxCacheItems: 100,
26+
});
27+
28+
const evaluationDetails: EvaluationDetails<string> = {
29+
value: 'testValue',
30+
} as EvaluationDetails<string>;
31+
const err: Error = new Error('fake error!');
32+
const context = {};
33+
const hints = {};
34+
35+
it.each([
36+
{
37+
flagKey: 'flag1',
38+
calledTimesTotal: 1,
39+
},
40+
{
41+
flagKey: 'flag2',
42+
calledTimesTotal: 2,
43+
},
44+
{
45+
flagKey: 'flag1',
46+
calledTimesTotal: 2, // should not have been incremented, same cache key
47+
},
48+
])('should cache each stage based on supplier', ({ flagKey, calledTimesTotal }) => {
49+
hook.before({ flagKey, context } as HookContext, hints);
50+
hook.after({ flagKey, context } as HookContext, evaluationDetails, hints);
51+
hook.error({ flagKey, context } as HookContext, err, hints);
52+
hook.finally({ flagKey, context } as HookContext, evaluationDetails, hints);
53+
54+
expect(innerHook.before).toHaveBeenNthCalledWith(calledTimesTotal, expect.objectContaining({ context }), hints);
55+
expect(innerHook.after).toHaveBeenNthCalledWith(
56+
calledTimesTotal,
57+
expect.objectContaining({ context }),
58+
evaluationDetails,
59+
hints,
60+
);
61+
expect(innerHook.error).toHaveBeenNthCalledWith(
62+
calledTimesTotal,
63+
expect.objectContaining({ context }),
64+
err,
65+
hints,
66+
);
67+
expect(innerHook.finally).toHaveBeenNthCalledWith(
68+
calledTimesTotal,
69+
expect.objectContaining({ context }),
70+
evaluationDetails,
71+
hints,
72+
);
73+
});
74+
});
75+
76+
describe('options', () => {
77+
afterAll(() => {
78+
jest.resetAllMocks();
79+
});
80+
81+
it('maxCacheItems should limit size', () => {
82+
const innerHook: Hook = {
83+
before: jest.fn(),
84+
};
85+
86+
const hook = new DebounceHook<string>(innerHook, {
87+
beforeCacheKeySupplier: (flagKey: string) => flagKey,
88+
ttlMs: 60_000,
89+
maxCacheItems: 1,
90+
});
91+
92+
hook.before({ flagKey: 'flag1' } as HookContext, {});
93+
hook.before({ flagKey: 'flag2' } as HookContext, {});
94+
hook.before({ flagKey: 'flag1' } as HookContext, {});
95+
96+
// every invocation should have run since we have only maxCacheItems: 1
97+
expect(innerHook.before).toHaveBeenCalledTimes(3);
98+
});
99+
100+
it('noop if supplier not defined', () => {
101+
const innerHook: Hook = {
102+
before: jest.fn(),
103+
after: jest.fn(),
104+
error: jest.fn(),
105+
finally: jest.fn(),
106+
};
107+
108+
const flagKey = 'some-flag';
109+
const context = {};
110+
const hints = {};
111+
112+
// no suppliers defined, so we no-op (do no caching)
113+
const hook = new DebounceHook<string>(innerHook, {
114+
ttlMs: 60_000,
115+
maxCacheItems: 100,
116+
});
117+
118+
const evaluationDetails: EvaluationDetails<string> = {
119+
value: 'testValue',
120+
} as EvaluationDetails<string>;
121+
122+
for (let i = 0; i < 3; i++) {
123+
hook.before({ flagKey, context } as HookContext, hints);
124+
hook.after({ flagKey, context } as HookContext, evaluationDetails, hints);
125+
hook.error({ flagKey, context } as HookContext, hints);
126+
hook.finally({ flagKey, context } as HookContext, evaluationDetails, hints);
127+
}
128+
129+
// every invocation should have run since we have only maxCacheItems: 1
130+
expect(innerHook.before).toHaveBeenCalledTimes(3);
131+
expect(innerHook.after).toHaveBeenCalledTimes(3);
132+
expect(innerHook.error).toHaveBeenCalledTimes(3);
133+
expect(innerHook.finally).toHaveBeenCalledTimes(3);
134+
});
135+
136+
it.each([
137+
{
138+
cacheErrors: false,
139+
timesCalled: 2, // should be called each time since the hook always errors
140+
},
141+
{
142+
cacheErrors: true,
143+
timesCalled: 1, // should be called once since we cached the error
144+
},
145+
])('should cache errors if cacheErrors set', ({ cacheErrors, timesCalled }) => {
146+
const innerErrorHook: Hook = {
147+
before: jest.fn(() => {
148+
// throw an error
149+
throw new Error('fake!');
150+
}),
151+
};
152+
153+
const flagKey = 'some-flag';
154+
const context = {};
155+
156+
// this hook caches error invocations
157+
const hook = new DebounceHook<string>(innerErrorHook, {
158+
beforeCacheKeySupplier: (flagKey: string) => flagKey,
159+
maxCacheItems: 100,
160+
ttlMs: 60_000,
161+
cacheErrors,
162+
});
163+
164+
expect(() => hook.before({ flagKey, context } as HookContext)).toThrow();
165+
expect(() => hook.before({ flagKey, context } as HookContext)).toThrow();
166+
167+
expect(innerErrorHook.before).toHaveBeenCalledTimes(timesCalled);
168+
});
169+
});
170+
});

0 commit comments

Comments
 (0)