From ddefe9d0e3f482f0f3a4c7ce27d1b99d8bf7a626 Mon Sep 17 00:00:00 2001 From: Eric King Date: Mon, 9 Mar 2026 01:12:28 -0500 Subject: [PATCH 1/3] chore(deps-dev): updated `pnpm` version --- package.json | 2 +- pnpm-lock.yaml | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index d892b7e..4773778 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "url": "https://github.com/webdeveric/utils/issues" }, "homepage": "https://github.com/webdeveric/utils/#readme", - "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017", + "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268", "scripts": { "clean": "rimraf ./dist/", "prebuild": "pnpm clean", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03f7922..dfce461 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,10 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@24.11.0)(jiti@2.6.1)(jsdom@28.1.0)(yaml@2.8.2) + dist/cjs: {} + + dist/mjs: {} + packages: '@acemir/cssom@0.9.31': From 402d7c2db8685672203a76be15dd411de44bb8fa Mon Sep 17 00:00:00 2001 From: Eric King Date: Mon, 9 Mar 2026 01:13:06 -0500 Subject: [PATCH 2/3] chore(deps-dev): updated dev dependencies --- package.json | 2 +- pnpm-lock.yaml | 84 +++++++++++++++++++++++++------------------------- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index 4773778..42ee968 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "devDependencies": { "@commitlint/config-conventional": "^20.4.3", "@commitlint/types": "^20.4.3", - "@types/node": "^24.11.0", + "@types/node": "^24.12.0", "@vitest/coverage-v8": "^4.0.18", "@webdeveric/eslint-config-ts": "^0.12.0", "@webdeveric/prettier-config": "^0.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfce461..d15f6dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,11 +15,11 @@ importers: specifier: ^20.4.3 version: 20.4.3 '@types/node': - specifier: ^24.11.0 - version: 24.11.0 + specifier: ^24.12.0 + version: 24.12.0 '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@24.11.0)(jiti@2.6.1)(jsdom@28.1.0)(yaml@2.8.2)) + version: 4.0.18(vitest@4.0.18(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(yaml@2.8.2)) '@webdeveric/eslint-config-ts': specifier: ^0.12.0 version: 0.12.0(eslint@8.57.1)(typescript@6.0.0-dev.20260304) @@ -28,7 +28,7 @@ importers: version: 0.3.0(prettier@3.8.1) commitlint: specifier: ^20.4.3 - version: 20.4.3(@types/node@24.11.0)(typescript@6.0.0-dev.20260304) + version: 20.4.3(@types/node@24.12.0)(typescript@6.0.0-dev.20260304) commitlint-plugin-cspell: specifier: ^0.6.0 version: 0.6.0(@commitlint/lint@20.4.3) @@ -76,7 +76,7 @@ importers: version: 0.19.1 vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@24.11.0)(jiti@2.6.1)(jsdom@28.1.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(yaml@2.8.2) dist/cjs: {} @@ -469,8 +469,8 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.0.29': - resolution: {integrity: sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg==} + '@csstools/css-syntax-patches-for-csstree@1.1.0': + resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==} '@csstools/css-tokenizer@4.0.0': resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} @@ -1069,8 +1069,8 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/node@24.11.0': - resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==} + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -1294,8 +1294,8 @@ packages: peerDependencies: prettier: '>=2.4.0' - '@webdeveric/utils@0.74.1': - resolution: {integrity: sha512-N8QDJfLedKyd9f26dRFeOQzlIYob5dK4Cna9Dw1gwLnAX5mFrLkkEGf9lNHYvv+TQmBVAAtHRQww9Rdv/9vXEg==} + '@webdeveric/utils@0.74.2': + resolution: {integrity: sha512-10uJD34sgp1HAV0wPbAstQSBbb5OwDHp1H2AhOfGRFAloNshv/IhJsKZ3ZlOkN+NhmD95BQMlIf2wBztonI55w==} engines: {node: '>=18.0.0'} abbrev@4.0.0: @@ -3577,11 +3577,11 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} - tldts-core@7.0.24: - resolution: {integrity: sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==} + tldts-core@7.0.25: + resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==} - tldts@7.0.24: - resolution: {integrity: sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==} + tldts@7.0.25: + resolution: {integrity: sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==} hasBin: true to-regex-range@5.0.1: @@ -4034,11 +4034,11 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@commitlint/cli@20.4.3(@types/node@24.11.0)(typescript@6.0.0-dev.20260304)': + '@commitlint/cli@20.4.3(@types/node@24.12.0)(typescript@6.0.0-dev.20260304)': dependencies: '@commitlint/format': 20.4.3 '@commitlint/lint': 20.4.3 - '@commitlint/load': 20.4.3(@types/node@24.11.0)(typescript@6.0.0-dev.20260304) + '@commitlint/load': 20.4.3(@types/node@24.12.0)(typescript@6.0.0-dev.20260304) '@commitlint/read': 20.4.3 '@commitlint/types': 20.4.3 tinyexec: 1.0.2 @@ -4085,14 +4085,14 @@ snapshots: '@commitlint/rules': 20.4.3 '@commitlint/types': 20.4.3 - '@commitlint/load@20.4.3(@types/node@24.11.0)(typescript@6.0.0-dev.20260304)': + '@commitlint/load@20.4.3(@types/node@24.12.0)(typescript@6.0.0-dev.20260304)': dependencies: '@commitlint/config-validator': 20.4.3 '@commitlint/execute-rule': 20.0.0 '@commitlint/resolve-extends': 20.4.3 '@commitlint/types': 20.4.3 cosmiconfig: 9.0.1(typescript@6.0.0-dev.20260304) - cosmiconfig-typescript-loader: 6.2.0(@types/node@24.11.0)(cosmiconfig@9.0.1(typescript@6.0.0-dev.20260304))(typescript@6.0.0-dev.20260304) + cosmiconfig-typescript-loader: 6.2.0(@types/node@24.12.0)(cosmiconfig@9.0.1(typescript@6.0.0-dev.20260304))(typescript@6.0.0-dev.20260304) is-plain-obj: 4.1.0 lodash.mergewith: 4.6.2 picocolors: 1.1.1 @@ -4383,7 +4383,7 @@ snapshots: dependencies: '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.0.29': {} + '@csstools/css-syntax-patches-for-csstree@1.1.0': {} '@csstools/css-tokenizer@4.0.0': {} @@ -4963,7 +4963,7 @@ snapshots: '@types/json5@0.0.29': {} - '@types/node@24.11.0': + '@types/node@24.12.0': dependencies: undici-types: 7.16.0 @@ -5121,7 +5121,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@24.11.0)(jiti@2.6.1)(jsdom@28.1.0)(yaml@2.8.2))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -5133,7 +5133,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@24.11.0)(jiti@2.6.1)(jsdom@28.1.0)(yaml@2.8.2) + vitest: 4.0.18(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(yaml@2.8.2) '@vitest/expect@4.0.18': dependencies: @@ -5144,13 +5144,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.2) '@vitest/pretty-format@4.0.18': dependencies: @@ -5192,7 +5192,7 @@ snapshots: dependencies: prettier: 3.8.1 - '@webdeveric/utils@0.74.1': {} + '@webdeveric/utils@0.74.2': {} abbrev@4.0.0: {} @@ -5484,9 +5484,9 @@ snapshots: '@commitlint/types': 20.4.3 cspell-lib: 9.7.0 - commitlint@20.4.3(@types/node@24.11.0)(typescript@6.0.0-dev.20260304): + commitlint@20.4.3(@types/node@24.12.0)(typescript@6.0.0-dev.20260304): dependencies: - '@commitlint/cli': 20.4.3(@types/node@24.11.0)(typescript@6.0.0-dev.20260304) + '@commitlint/cli': 20.4.3(@types/node@24.12.0)(typescript@6.0.0-dev.20260304) '@commitlint/types': 20.4.3 transitivePeerDependencies: - '@types/node' @@ -5533,9 +5533,9 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig-typescript-loader@6.2.0(@types/node@24.11.0)(cosmiconfig@9.0.1(typescript@6.0.0-dev.20260304))(typescript@6.0.0-dev.20260304): + cosmiconfig-typescript-loader@6.2.0(@types/node@24.12.0)(cosmiconfig@9.0.1(typescript@6.0.0-dev.20260304))(typescript@6.0.0-dev.20260304): dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 cosmiconfig: 9.0.1(typescript@6.0.0-dev.20260304) jiti: 2.6.1 typescript: 6.0.0-dev.20260304 @@ -5660,7 +5660,7 @@ snapshots: cssstyle@6.2.0: dependencies: '@asamuzakjp/css-color': 5.0.1 - '@csstools/css-syntax-patches-for-csstree': 1.0.29 + '@csstools/css-syntax-patches-for-csstree': 1.1.0 css-tree: 3.2.1 lru-cache: 11.2.6 @@ -7714,11 +7714,11 @@ snapshots: tinyrainbow@3.0.3: {} - tldts-core@7.0.24: {} + tldts-core@7.0.25: {} - tldts@7.0.24: + tldts@7.0.25: dependencies: - tldts-core: 7.0.24 + tldts-core: 7.0.25 to-regex-range@5.0.1: dependencies: @@ -7726,7 +7726,7 @@ snapshots: tough-cookie@6.0.0: dependencies: - tldts: 7.0.24 + tldts: 7.0.25 tr46@6.0.0: dependencies: @@ -7893,12 +7893,12 @@ snapshots: validate-package-exports@0.19.1: dependencies: '@npmcli/arborist': 9.4.0 - '@webdeveric/utils': 0.74.1 + '@webdeveric/utils': 0.74.2 npm-packlist: 10.0.4 transitivePeerDependencies: - supports-color - vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(yaml@2.8.2): + vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -7907,15 +7907,15 @@ snapshots: rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 fsevents: 2.3.3 jiti: 2.6.1 yaml: 2.8.2 - vitest@4.0.18(@types/node@24.11.0)(jiti@2.6.1)(jsdom@28.1.0)(yaml@2.8.2): + vitest@4.0.18(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -7932,10 +7932,10 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 jsdom: 28.1.0 transitivePeerDependencies: - jiti From b257f0ab23ab42f6c087df1a882d8ab3432852d3 Mon Sep 17 00:00:00 2001 From: Eric King Date: Mon, 9 Mar 2026 12:20:51 -0500 Subject: [PATCH 3/3] feat: added `memo()` --- src/memo.ts | 48 +++++++++++++++++++++ test/memo.test.ts | 104 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/memo.ts create mode 100644 test/memo.test.ts diff --git a/src/memo.ts b/src/memo.ts new file mode 100644 index 0000000..9e9cb99 --- /dev/null +++ b/src/memo.ts @@ -0,0 +1,48 @@ +import type { AnyFunction } from './types/common.js'; + +export type CacheKeyFn = (args: Parameters) => ReturnValue; + +export type DefaultCacheKeyFn = CacheKeyFn[0]> | string>; + +export type MemoizedFn> = Fn & { + cache: Map, ReturnType>; +}; + +/** + * Memoize a function + * + * @todo use `Map.getOrInsertComputed()` once it is widely available. + */ +export function memo>( + fn: Fn, + getCacheKey: CkFn, +): MemoizedFn; + +export function memo(fn: Fn): MemoizedFn>; + +export function memo>( + fn: Fn, + getCacheKey?: CkFn, +): MemoizedFn { + const defaultGetCacheKey: DefaultCacheKeyFn = (args) => args[0] ?? JSON.stringify(args); + + const resolvedGetCacheKey = getCacheKey ?? defaultGetCacheKey; + + const memoized = (...args: Parameters): ReturnType => { + const key = resolvedGetCacheKey(args); + + if (memoized.cache.has(key)) { + return memoized.cache.get(key); + } + + const value = fn(...args); + + memoized.cache.set(key, value); + + return value; + }; + + memoized.cache = new Map(); + + return memoized as MemoizedFn; +} diff --git a/test/memo.test.ts b/test/memo.test.ts new file mode 100644 index 0000000..a016580 --- /dev/null +++ b/test/memo.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { memo } from '../src/memo.js'; + +describe('memo()', () => { + it('Memoizes functions', () => { + const mock = vi.fn((str: string) => str); + + const fn = memo(mock); + + expect(mock).not.toHaveBeenCalled(); + + fn('test'); + + expect(mock).toHaveBeenCalledTimes(1); + + fn('test'); + + expect(mock).toHaveBeenCalledTimes(1); + + fn('test2'); + + expect(mock).toHaveBeenCalledTimes(2); + }); + + it('Memoizes async functions', async () => { + const mock = vi.fn(async (str: string): Promise => str); + + const fn = memo(mock); + + expect(mock).not.toHaveBeenCalled(); + + await fn('test'); + + expect(mock).toHaveBeenCalledTimes(1); + + await fn('test'); + + expect(mock).toHaveBeenCalledTimes(1); + + await fn('test2'); + + expect(mock).toHaveBeenCalledTimes(2); + + expect(fn('test3')).toBe(fn('test3')); + }); + + it('Cache is available on the memoized function', () => { + const mock = vi.fn((str: string) => str); + + const fn = memo(mock); + + expect(mock).not.toHaveBeenCalled(); + + fn('test'); + + expect(mock).toHaveBeenCalledTimes(1); + + fn('test'); + + expect(mock).toHaveBeenCalledTimes(1); + + fn.cache.clear(); + + fn('test'); + + expect(mock).toHaveBeenCalledTimes(2); + }); + + it('Cache key is configurable', () => { + const mock = vi.fn((_id: number, name: string) => name); + + const fn = memo(mock, ([id, name]) => `${id}-${name}-key`); + + fn(1, 'test'); + + expect(fn.cache.has('1-test-key')).toBe(true); + }); + + it('Default getCacheKey()', () => { + const mock = vi.fn((_id: number | null | undefined, name: string) => name); + + const fn = memo(mock); + + expect(mock).not.toHaveBeenCalled(); + + fn(1, 'test'); + + expect(mock).toHaveBeenCalledTimes(1); + + expect(fn.cache.has(1)).toBe(true); + + fn(null, 'test'); + + expect(mock).toHaveBeenCalledTimes(2); + + fn(undefined, 'test'); + + // `undefined` serialized to `null` when used in arrays. + expect(mock).toHaveBeenCalledTimes(2); + + expect(fn.cache.has('[null,"test"]')).toBe(true); + }); +});