diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 7ffcfc12bd2ec..f0f1f0875dc06 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -127,7 +127,7 @@ "@rocket.chat/logo": "~0.33.1", "@rocket.chat/media-calls": "workspace:^", "@rocket.chat/media-signaling": "workspace:^", - "@rocket.chat/memo": "~0.31.25", + "@rocket.chat/memo": "workspace:~", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/message-types": "workspace:~", "@rocket.chat/model-typings": "workspace:^", diff --git a/fuselage.sh b/fuselage.sh index d58544335c540..3df4aa146a43f 100755 --- a/fuselage.sh +++ b/fuselage.sh @@ -118,11 +118,10 @@ if [[ $action == "next-all" || $action == "latest-all" ]]; then 📦 @rocket.chat/fuselage-hooks [UPDATING to $targetVersion version...] 📦 @rocket.chat/icons [UPDATING to $targetVersion version...] 📦 @rocket.chat/logo [UPDATING to $targetVersion version...] -📦 @rocket.chat/memo [UPDATING to $targetVersion version...] 📦 @rocket.chat/onboarding-ui [UPDATING to $targetVersion version...] 📦 @rocket.chat/layout [UPDATING to $targetVersion version...]" - eval "yarn up @rocket.chat/emitter@$targetVersion @rocket.chat/fuselage-toastbar@$targetVersion @rocket.chat/fuselage-tokens@$targetVersion @rocket.chat/css-in-js@$targetVersion @rocket.chat/styled@$targetVersion @rocket.chat/fuselage@$targetVersion @rocket.chat/fuselage-hooks@$targetVersion @rocket.chat/icons@$targetVersion @rocket.chat/logo@$targetVersion @rocket.chat/memo@$targetVersion @rocket.chat/onboarding-ui@$targetVersion @rocket.chat/layout@$targetVersion" + eval "yarn up @rocket.chat/emitter@$targetVersion @rocket.chat/fuselage-toastbar@$targetVersion @rocket.chat/fuselage-tokens@$targetVersion @rocket.chat/css-in-js@$targetVersion @rocket.chat/styled@$targetVersion @rocket.chat/fuselage@$targetVersion @rocket.chat/fuselage-hooks@$targetVersion @rocket.chat/icons@$targetVersion @rocket.chat/logo@$targetVersion @rocket.chat/onboarding-ui@$targetVersion @rocket.chat/layout@$targetVersion" exit 1 fi diff --git a/packages/memo/jest.config.ts b/packages/memo/jest.config.ts new file mode 100644 index 0000000000000..c18c8ae02465c --- /dev/null +++ b/packages/memo/jest.config.ts @@ -0,0 +1,6 @@ +import server from '@rocket.chat/jest-presets/server'; +import type { Config } from 'jest'; + +export default { + preset: server.preset, +} satisfies Config; diff --git a/packages/memo/package.json b/packages/memo/package.json new file mode 100644 index 0000000000000..5fef6aa09b862 --- /dev/null +++ b/packages/memo/package.json @@ -0,0 +1,50 @@ +{ + "name": "@rocket.chat/memo", + "version": "0.31.25", + "description": "Memoization utilities", + "keywords": [ + "memoize" + ], + "bugs": { + "url": "https://github.com/RocketChat/Rocket.Chat/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/RocketChat/Rocket.Chat.git", + "directory": "packages/memo" + }, + "license": "MIT", + "author": { + "name": "Rocket.Chat", + "url": "https://rocket.chat/" + }, + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "files": [ + "/dist" + ], + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "test": "jest", + "testunit": "jest", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@rocket.chat/jest-presets": "workspace:~", + "@rocket.chat/tsconfig": "workspace:~", + "eslint": "~9.39.5", + "jest": "~30.2.0", + "prettier": "~3.3.3", + "ts-jest": "~29.4.11", + "typescript": "~5.9.3" + }, + "volta": { + "extends": "../../package.json" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/memo/src/index.ts b/packages/memo/src/index.ts new file mode 100644 index 0000000000000..f684b36ced39d --- /dev/null +++ b/packages/memo/src/index.ts @@ -0,0 +1 @@ +export { memoize, clear, type MemoizableFunction, type MemoizedFunction, type Options } from './memoize'; diff --git a/packages/memo/src/memoize.spec.ts b/packages/memo/src/memoize.spec.ts new file mode 100644 index 0000000000000..3a4b99e40d4ad --- /dev/null +++ b/packages/memo/src/memoize.spec.ts @@ -0,0 +1,105 @@ +import { memoize, clear } from './memoize'; + +it('should memoize a function that takes no parameter', () => { + const fn = jest.fn(() => 'foo'); + const memoized = jest.fn(memoize(fn)); + + memoized(undefined); + memoized(undefined); + + expect(memoized).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenCalledTimes(1); + + expect(fn).toHaveReturnedWith('foo'); + expect(memoized).toHaveReturnedWith('foo'); +}); + +it('should memoize a function that takes one parameter', () => { + const fn = jest.fn((i: number) => i + 1); + const memoized = jest.fn(memoize(fn)); + + memoized(5); + memoized(5); + memoized(2); + + expect(memoized).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenCalledTimes(2); + + expect(fn).toHaveNthReturnedWith(1, 6); + expect(fn).toHaveNthReturnedWith(2, 3); + + expect(memoized).toHaveNthReturnedWith(1, 6); + expect(memoized).toHaveNthReturnedWith(2, 6); + expect(memoized).toHaveNthReturnedWith(3, 3); +}); + +describe('clear', () => { + it('should discard cached values of a memoized function', () => { + const fn = jest.fn(() => 'foo'); + const memoized = memoize(fn); + const spiedMemoized = jest.fn(memoized); + + spiedMemoized(undefined); + spiedMemoized(undefined); + clear(memoized); + spiedMemoized(undefined); + + expect(spiedMemoized).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenCalledTimes(2); + + expect(fn).toHaveReturnedWith('foo'); + expect(spiedMemoized).toHaveReturnedWith('foo'); + }); + + it('should do nothing when a non-memoized function is passed', () => { + const fn = jest.fn(() => 'foo'); + + expect(() => clear(fn)).not.toThrow(); + }); +}); + +describe('timeout', () => { + it('should memoize a function that takes one parameter and clear after x ms', () => { + jest.useFakeTimers(); + + const fn = jest.fn((i: number) => i + 1); + const memoized = jest.fn(memoize(fn, { maxAge: 3000 })); + + memoized(5); + jest.advanceTimersByTime(2000); + memoized(5); + jest.advanceTimersByTime(2000); + memoized(5); + jest.advanceTimersByTime(3000); + memoized(5); + + expect(fn).toHaveBeenCalledTimes(2); + + expect(memoized).toHaveNthReturnedWith(1, 6); + expect(memoized).toHaveNthReturnedWith(2, 6); + expect(memoized).toHaveNthReturnedWith(3, 6); + expect(memoized).toHaveNthReturnedWith(4, 6); + }); + + it('should memoize a function caching for two parameters and clearing both after x ms each one', () => { + jest.useFakeTimers(); + + const fn = jest.fn((i: number) => i + 1); + const memoized = jest.fn(memoize(fn, { maxAge: 3000 })); + + memoized(5); + jest.advanceTimersByTime(2000); + memoized(6); + jest.advanceTimersByTime(2000); + + memoized(6); + memoized(5); + + expect(fn).toHaveBeenCalledTimes(3); + + expect(memoized).toHaveNthReturnedWith(1, 6); + expect(memoized).toHaveNthReturnedWith(2, 7); + expect(memoized).toHaveNthReturnedWith(3, 7); + expect(memoized).toHaveNthReturnedWith(4, 6); + }); +}); diff --git a/packages/memo/src/memoize.ts b/packages/memo/src/memoize.ts new file mode 100644 index 0000000000000..3b9262233cfd9 --- /dev/null +++ b/packages/memo/src/memoize.ts @@ -0,0 +1,59 @@ +export type MemoizableFunction = (this: T, arg: A) => R; +export type MemoizedFunction = (this: T, arg: A) => R; + +export type Options = { + maxAge: number; +}; + +const store = new WeakMap, Map>(); + +const isCachedValue = (cachedValue: R | undefined, arg: A, cache: Map): cachedValue is R => + cache.has(arg) && cache.get(arg) === cachedValue; + +export const memoize = (fn: MemoizableFunction, _options?: Options): MemoizedFunction => { + const cache = new Map(); + const cacheTimers = new Map>(); + + const memoized: MemoizedFunction = function (this, arg) { + const cleanUp = (): void => { + cache.delete(arg); + cacheTimers.delete(arg); + }; + + const cachedValue = cache.get(arg); + + if (isCachedValue(cachedValue, arg, cache)) { + const oldTimer = cacheTimers.get(arg); + if (oldTimer) { + clearTimeout(oldTimer); + } + + if (_options) { + const timer = setTimeout(cleanUp, _options.maxAge); + cacheTimers.set(arg, timer); + } + + return cachedValue; + } + + const result = fn.call(this, arg); + + cache.set(arg, result); + + if (_options) { + const timer = setTimeout(cleanUp, _options.maxAge); + cacheTimers.set(arg, timer); + } + + return result; + }; + + store.set(memoized as MemoizableFunction, cache); + + return memoized; +}; + +export const clear = (fn: MemoizedFunction): void => { + const cache = store.get(fn); + cache?.clear(); +}; diff --git a/packages/memo/tsconfig.cjs.json b/packages/memo/tsconfig.cjs.json new file mode 100644 index 0000000000000..f06fae752ab38 --- /dev/null +++ b/packages/memo/tsconfig.cjs.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist/cjs" + }, + "exclude": ["**/*.spec.*", "jest.config.*"] +} diff --git a/packages/memo/tsconfig.esm.json b/packages/memo/tsconfig.esm.json new file mode 100644 index 0000000000000..196377c39d63d --- /dev/null +++ b/packages/memo/tsconfig.esm.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "esnext", + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist/esm" + }, + "exclude": ["**/*.spec.*", "jest.config.*"] +} diff --git a/packages/memo/tsconfig.json b/packages/memo/tsconfig.json new file mode 100644 index 0000000000000..9b3a9596ad65e --- /dev/null +++ b/packages/memo/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@rocket.chat/tsconfig/base.json", + "include": ["src/**/*"] +} diff --git a/yarn.lock b/yarn.lock index a2732384c8c92..7579488b93561 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9743,12 +9743,19 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/memo@npm:^0.31.25, @rocket.chat/memo@npm:~0.31.25": - version: 0.31.25 - resolution: "@rocket.chat/memo@npm:0.31.25" - checksum: 10/92d595c68d76a5258fb37ed4639e2709ba290c5d240df1272d81a2ab6b4be28ee2dd5b721dad940fe2638a89e8d14e684a970c59890003a06ce6088c655b7c0e - languageName: node - linkType: hard +"@rocket.chat/memo@npm:^0.31.25, @rocket.chat/memo@workspace:packages/memo, @rocket.chat/memo@workspace:~": + version: 0.0.0-use.local + resolution: "@rocket.chat/memo@workspace:packages/memo" + dependencies: + "@rocket.chat/jest-presets": "workspace:~" + "@rocket.chat/tsconfig": "workspace:~" + eslint: "npm:~9.39.5" + jest: "npm:~30.2.0" + prettier: "npm:~3.3.3" + ts-jest: "npm:~29.4.11" + typescript: "npm:~5.9.3" + languageName: unknown + linkType: soft "@rocket.chat/message-parser@workspace:^, @rocket.chat/message-parser@workspace:packages/message-parser, @rocket.chat/message-parser@workspace:~": version: 0.0.0-use.local @@ -9867,7 +9874,7 @@ __metadata: "@rocket.chat/logo": "npm:~0.33.1" "@rocket.chat/media-calls": "workspace:^" "@rocket.chat/media-signaling": "workspace:^" - "@rocket.chat/memo": "npm:~0.31.25" + "@rocket.chat/memo": "workspace:~" "@rocket.chat/message-parser": "workspace:^" "@rocket.chat/message-types": "workspace:~" "@rocket.chat/mock-providers": "workspace:^"