├── .npmignore ├── tsconfig.es5.json ├── src ├── tests │ ├── main.ts │ ├── test-utils.ts │ ├── exceptions.ts │ ├── performance.ts │ ├── cache.ts │ ├── key-trie.ts │ ├── deps.ts │ ├── context.ts │ └── api.ts ├── context.ts ├── helpers.ts ├── dep.ts ├── index.ts └── entry.ts ├── README.md ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── tsconfig.json ├── .gitignore ├── LICENSE ├── rollup.config.js └── package.json /.npmignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /lib/tests 3 | tsconfig.json 4 | tsconfig.es5.json 5 | rollup.config.js 6 | -------------------------------------------------------------------------------- /tsconfig.es5.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES5", 5 | "module": "ES2015", 6 | "outDir": "lib/es5" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/tests/main.ts: -------------------------------------------------------------------------------- 1 | import "./api"; 2 | import "./deps"; 3 | import "./cache"; 4 | import "./key-trie"; 5 | import "./context"; 6 | import "./exceptions"; 7 | import "./performance"; 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # optimism [![Build Status](https://github.com/benjamn/optimism/workflows/CI/badge.svg)](https://github.com/benjamn/optimism/actions) 2 | 3 | Composable reactive caching with efficient invalidation. 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "npm" 7 | directory: "/" 8 | schedule: 9 | interval: "daily" 10 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import { Slot } from "@wry/context"; 2 | import { AnyEntry } from "./entry.js"; 3 | 4 | export const parentEntrySlot = new Slot(); 5 | 6 | export function nonReactive(fn: () => R): R { 7 | return parentEntrySlot.withValue(void 0, fn); 8 | } 9 | 10 | export { Slot } 11 | export { 12 | bind as bindContext, 13 | noContext, 14 | setTimeout, 15 | asyncFromGen, 16 | } from "@wry/context"; 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "ES2015", 5 | "moduleResolution": "node", 6 | "rootDir": "./src", 7 | "outDir": "./lib", 8 | "sourceMap": true, 9 | "declaration": true, 10 | "importHelpers": true, 11 | "lib": ["es2015"], 12 | "types": ["node", "mocha"], 13 | "strict": true, 14 | "noImplicitAny": true, 15 | "noUnusedLocals": true, 16 | "esModuleInterop": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/tests/test-utils.ts: -------------------------------------------------------------------------------- 1 | export function permutations(array: T[], start = 0): T[][] { 2 | if (start === array.length) return [[]]; 3 | const item = array[start]; 4 | const results: T[][] = []; 5 | permutations(array, start + 1).forEach(perm => { 6 | perm.forEach((_, i) => { 7 | const copy = perm.slice(0); 8 | copy.splice(i, 0, item); 9 | results.push(copy); 10 | }); 11 | results.push(perm.concat(item)); 12 | }); 13 | return results; 14 | } 15 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | export type NoInfer = [T][T extends any ? 0 : never]; 2 | 3 | export const { 4 | hasOwnProperty, 5 | } = Object.prototype; 6 | 7 | export const arrayFromSet: (set: Set) => T[] = 8 | Array.from || 9 | function (set) { 10 | const array: any[] = []; 11 | set.forEach(item => array.push(item)); 12 | return array; 13 | }; 14 | 15 | export type Unsubscribable = { 16 | unsubscribe?: void | (() => any); 17 | } 18 | 19 | export function maybeUnsubscribe(entryOrDep: Unsubscribable) { 20 | const { unsubscribe } = entryOrDep; 21 | if (typeof unsubscribe === "function") { 22 | entryOrDep.unsubscribe = void 0; 23 | unsubscribe(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | name: Test on node ${{ matrix.node_version }} and ${{ matrix.os }} 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | node_version: ['16', '18', '20', '21', '22'] 16 | os: [ubuntu-latest] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node_version }} 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node_version }} 24 | 25 | - name: npm install, build and test 26 | run: | 27 | npm install 28 | npm run build --if-present 29 | npm test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # Ignore generated TypeScript files. 40 | lib 41 | 42 | # Cache directory for rollup-plugin-typescript2 43 | .rpt2_cache 44 | 45 | # Cache directory for Reify-compiled tests 46 | .reify-cache 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Ben Newman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from "fs/promises"; 2 | 3 | const globals = { 4 | __proto__: null, 5 | tslib: "tslib", 6 | assert: "assert", 7 | crypto: "crypto", 8 | "@wry/equality": "wryEquality", 9 | "@wry/context": "wryContext", 10 | "@wry/trie": "wryTrie", 11 | "@wry/caches": "wryCaches", 12 | }; 13 | 14 | function external(id) { 15 | return id in globals; 16 | } 17 | 18 | function build(input, output, format) { 19 | return { 20 | input, 21 | external, 22 | output: { 23 | file: output, 24 | format, 25 | sourcemap: true, 26 | globals 27 | }, 28 | ...(output.endsWith(".cjs") ? { plugins: [ 29 | { // Inspired by https://github.com/apollographql/apollo-client/pull/9716, 30 | // this workaround ensures compatibility with versions of React Native 31 | // that refuse to load .cjs modules as CommonJS (to be fixed in v0.72): 32 | name: "copy *.cjs to *.cjs.native.js", 33 | async writeBundle({ file }) { 34 | const buffer = await readFile(file); 35 | await writeFile(file + ".native.js", buffer); 36 | }, 37 | }, 38 | ]} : null), 39 | }; 40 | } 41 | 42 | export default [ 43 | build( 44 | "lib/es5/index.js", 45 | "lib/bundle.cjs", 46 | "cjs" 47 | ), 48 | build( 49 | "lib/tests/main.js", 50 | "lib/tests/bundle.js", 51 | "esm" 52 | ), 53 | build( 54 | "lib/es5/tests/main.js", 55 | "lib/tests/bundle.cjs", 56 | "cjs" 57 | ), 58 | ]; 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "optimism", 3 | "version": "0.18.1", 4 | "author": "Ben Newman ", 5 | "description": "Composable reactive caching with efficient invalidation.", 6 | "keywords": [ 7 | "caching", 8 | "cache", 9 | "invalidation", 10 | "reactive", 11 | "reactivity", 12 | "dependency", 13 | "tracking", 14 | "tracker", 15 | "memoization" 16 | ], 17 | "type": "module", 18 | "main": "lib/bundle.cjs", 19 | "module": "lib/index.js", 20 | "types": "lib/index.d.ts", 21 | "license": "MIT", 22 | "homepage": "https://github.com/benjamn/optimism#readme", 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/benjamn/optimism.git" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/benjamn/optimism/issues" 29 | }, 30 | "scripts": { 31 | "build": "npm run clean && npm run tsc:es5 && tsc && rollup -c && rimraf lib/es5", 32 | "tsc:es5": "tsc -p tsconfig.es5.json", 33 | "clean": "rimraf lib", 34 | "prepare": "npm run build", 35 | "mocha": "mocha --require source-map-support/register --reporter spec --full-trace", 36 | "test:cjs": "npm run mocha -- lib/tests/bundle.cjs", 37 | "test:esm": "npm run mocha -- lib/tests/bundle.js", 38 | "test": "npm run test:esm && npm run test:cjs" 39 | }, 40 | "devDependencies": { 41 | "@types/mocha": "^10.0.1", 42 | "@types/node": "^20.2.5", 43 | "@wry/equality": "^0.5.7", 44 | "mocha": "^10.2.0", 45 | "rimraf": "^5.0.0", 46 | "rollup": "^3.20.0", 47 | "source-map-support": "^0.5.19", 48 | "typescript": "^5.0.2" 49 | }, 50 | "dependencies": { 51 | "@wry/caches": "^1.0.0", 52 | "@wry/context": "^0.7.0", 53 | "@wry/trie": "^0.5.0", 54 | "tslib": "^2.3.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/tests/exceptions.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import { wrap } from "../index.js"; 3 | 4 | describe("exceptions", function () { 5 | it("should be cached", function () { 6 | const error = new Error("expected"); 7 | let threw = false; 8 | function throwOnce() { 9 | if (!threw) { 10 | threw = true; 11 | throw error; 12 | } 13 | return "already threw"; 14 | } 15 | 16 | const wrapper = wrap(throwOnce); 17 | 18 | try { 19 | wrapper(); 20 | throw new Error("unreached"); 21 | } catch (e) { 22 | assert.strictEqual(e, error); 23 | } 24 | 25 | try { 26 | wrapper(); 27 | throw new Error("unreached"); 28 | } catch (e) { 29 | assert.strictEqual(e, error); 30 | } 31 | 32 | wrapper.dirty(); 33 | assert.strictEqual(wrapper(), "already threw"); 34 | assert.strictEqual(wrapper(), "already threw"); 35 | wrapper.dirty(); 36 | assert.strictEqual(wrapper(), "already threw"); 37 | }); 38 | 39 | it("should memoize a throwing fibonacci function", function () { 40 | const fib = wrap((n: number) => { 41 | if (n < 2) throw n; 42 | try { 43 | fib(n - 1); 44 | } catch (minusOne: any) { 45 | try { 46 | fib(n - 2); 47 | } catch (minusTwo: any) { 48 | throw minusOne + minusTwo; 49 | } 50 | } 51 | throw new Error("unreached"); 52 | }); 53 | 54 | function check(n: number, expected: number) { 55 | try { 56 | fib(n); 57 | throw new Error("unreached"); 58 | } catch (result) { 59 | assert.strictEqual(result, expected); 60 | } 61 | } 62 | 63 | check(78, 8944394323791464); 64 | check(68, 72723460248141); 65 | check(58, 591286729879); 66 | check(48, 4807526976); 67 | fib.dirty(28); 68 | check(38, 39088169); 69 | check(28, 317811); 70 | check(18, 2584); 71 | check(8, 21); 72 | fib.dirty(20); 73 | check(78, 8944394323791464); 74 | check(10, 55); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/dep.ts: -------------------------------------------------------------------------------- 1 | import { AnyEntry } from "./entry.js"; 2 | import { OptimisticWrapOptions } from "./index.js"; 3 | import { parentEntrySlot } from "./context.js"; 4 | import { 5 | hasOwnProperty, 6 | Unsubscribable, 7 | maybeUnsubscribe, 8 | arrayFromSet, 9 | } from "./helpers.js"; 10 | 11 | type EntryMethodName = keyof typeof EntryMethods; 12 | const EntryMethods = { 13 | setDirty: true, // Mark parent Entry as needing to be recomputed (default) 14 | dispose: true, // Detach parent Entry from parents and children, but leave in LRU cache 15 | forget: true, // Fully remove parent Entry from LRU cache and computation graph 16 | }; 17 | 18 | export type OptimisticDependencyFunction = 19 | ((key: TKey) => void) & { 20 | dirty: (key: TKey, entryMethodName?: EntryMethodName) => void; 21 | }; 22 | 23 | export type Dep = Set & { 24 | subscribe: OptimisticWrapOptions<[TKey]>["subscribe"]; 25 | } & Unsubscribable; 26 | 27 | export function dep(options?: { 28 | subscribe: Dep["subscribe"]; 29 | }) { 30 | const depsByKey = new Map>(); 31 | const subscribe = options && options.subscribe; 32 | 33 | function depend(key: TKey) { 34 | const parent = parentEntrySlot.getValue(); 35 | if (parent) { 36 | let dep = depsByKey.get(key); 37 | if (!dep) { 38 | depsByKey.set(key, dep = new Set as Dep); 39 | } 40 | parent.dependOn(dep); 41 | if (typeof subscribe === "function") { 42 | maybeUnsubscribe(dep); 43 | dep.unsubscribe = subscribe(key); 44 | } 45 | } 46 | } 47 | 48 | depend.dirty = function dirty( 49 | key: TKey, 50 | entryMethodName?: EntryMethodName, 51 | ) { 52 | const dep = depsByKey.get(key); 53 | if (dep) { 54 | const m: EntryMethodName = ( 55 | entryMethodName && 56 | hasOwnProperty.call(EntryMethods, entryMethodName) 57 | ) ? entryMethodName : "setDirty"; 58 | // We have to use arrayFromSet(dep).forEach instead of dep.forEach, 59 | // because modifying a Set while iterating over it can cause elements in 60 | // the Set to be removed from the Set before they've been iterated over. 61 | arrayFromSet(dep).forEach(entry => entry[m]()); 62 | depsByKey.delete(key); 63 | maybeUnsubscribe(dep); 64 | } 65 | }; 66 | 67 | return depend as OptimisticDependencyFunction; 68 | } 69 | -------------------------------------------------------------------------------- /src/tests/performance.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import { wrap, dep, KeyTrie } from "../index"; 3 | 4 | describe("performance", function () { 5 | this.timeout(30000); 6 | 7 | it("should be able to tolerate lots of Entry objects", function () { 8 | let counter = 0; 9 | const child = wrap((a: any, b: any) => counter++); 10 | const parent = wrap((obj1: object, num: number, obj2: object) => { 11 | child(obj1, counter); 12 | child(counter, obj2); 13 | return counter++; 14 | }); 15 | for (let i = 0; i < 100000; ++i) { 16 | parent({}, i, {}); 17 | } 18 | }); 19 | 20 | const keys: object[] = []; 21 | for (let i = 0; i < 100000; ++i) { 22 | keys.push({ i }); 23 | } 24 | 25 | it("should be able to tolerate lots of deps", function () { 26 | const d = dep(); 27 | const parent = wrap((id: number) => { 28 | keys.forEach(d); 29 | return id; 30 | }); 31 | parent(1); 32 | parent(2); 33 | parent(3); 34 | keys.forEach(key => d.dirty(key)); 35 | }); 36 | 37 | it("can speed up sorting with O(array.length) cache lookup", function () { 38 | let counter = 0; 39 | const trie = new KeyTrie(false); 40 | const sort = wrap((array: number[]) => { 41 | ++counter; 42 | return array.slice(0).sort(); 43 | }, { 44 | makeCacheKey(array) { 45 | return trie.lookupArray(array); 46 | } 47 | }); 48 | 49 | assert.deepEqual(sort([2, 1, 5, 4]), [1, 2, 4, 5]); 50 | assert.strictEqual(counter, 1); 51 | assert.strictEqual( 52 | sort([2, 1, 5, 4]), 53 | sort([2, 1, 5, 4]), 54 | ); 55 | assert.strictEqual(counter, 1); 56 | 57 | assert.deepEqual(sort([3, 2, 1]), [1, 2, 3]); 58 | assert.strictEqual(counter, 2); 59 | 60 | const bigArray: number[] = []; 61 | for (let i = 0; i < 100000; ++i) { 62 | bigArray.push(Math.round(Math.random() * 100)); 63 | } 64 | 65 | const bigArrayCopy = bigArray.slice(0); 66 | const rawSortStartTime = Date.now(); 67 | bigArrayCopy.sort(); 68 | const rawSortTime = Date.now() - rawSortStartTime; 69 | 70 | assert.deepEqual( 71 | sort(bigArray), 72 | bigArrayCopy, 73 | ); 74 | 75 | const cachedSortStartTime = Date.now(); 76 | const cached = sort(bigArray); 77 | const cachedSortTime = Date.now() - cachedSortStartTime; 78 | 79 | assert.deepEqual(cached, bigArrayCopy); 80 | assert.ok( 81 | cachedSortTime <= rawSortTime, 82 | `cached: ${cachedSortTime}ms, raw: ${rawSortTime}ms`, 83 | ); 84 | assert.strictEqual(counter, 3); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /src/tests/cache.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import { StrongCache as Cache } from "@wry/caches"; 3 | 4 | describe("least-recently-used cache", function () { 5 | it("can hold lots of elements", function () { 6 | const cache = new Cache(); 7 | const count = 1000000; 8 | 9 | for (let i = 0; i < count; ++i) { 10 | cache.set(i, String(i)); 11 | } 12 | 13 | cache.clean(); 14 | 15 | assert.strictEqual((cache as any).map.size, count); 16 | assert.ok(cache.has(0)); 17 | assert.ok(cache.has(count - 1)); 18 | assert.strictEqual(cache.get(43), "43"); 19 | }); 20 | 21 | it("evicts excess old elements", function () { 22 | const max = 10; 23 | const evicted = []; 24 | const cache = new Cache(max, (value, key) => { 25 | assert.strictEqual(String(key), value); 26 | evicted.push(key); 27 | }); 28 | 29 | const count = 100; 30 | const keys = []; 31 | for (let i = 0; i < count; ++i) { 32 | cache.set(i, String(i)); 33 | keys.push(i); 34 | } 35 | 36 | cache.clean(); 37 | 38 | assert.strictEqual((cache as any).map.size, max); 39 | assert.strictEqual(evicted.length, count - max); 40 | 41 | for (let i = count - max; i < count; ++i) { 42 | assert.ok(cache.has(i)); 43 | } 44 | }); 45 | 46 | it("can cope with small max values", function () { 47 | const cache = new Cache(2); 48 | 49 | function check(...sequence: number[]) { 50 | cache.clean(); 51 | 52 | let entry = (cache as any).newest; 53 | const forwards = []; 54 | while (entry) { 55 | forwards.push(entry.key); 56 | entry = entry.older; 57 | } 58 | assert.deepEqual(forwards, sequence); 59 | 60 | const backwards = []; 61 | entry = (cache as any).oldest; 62 | while (entry) { 63 | backwards.push(entry.key); 64 | entry = entry.newer; 65 | } 66 | backwards.reverse(); 67 | assert.deepEqual(backwards, sequence); 68 | 69 | sequence.forEach(function (n) { 70 | assert.strictEqual((cache as any).map.get(n).value, n + 1); 71 | }); 72 | 73 | if (sequence.length > 0) { 74 | assert.strictEqual((cache as any).newest.key, sequence[0]); 75 | assert.strictEqual( 76 | (cache as any).oldest.key, 77 | sequence[sequence.length - 1] 78 | ); 79 | } 80 | } 81 | 82 | cache.set(1, 2); 83 | check(1); 84 | 85 | cache.set(2, 3); 86 | check(2, 1); 87 | 88 | cache.set(3, 4); 89 | check(3, 2); 90 | 91 | cache.get(2); 92 | check(2, 3); 93 | 94 | cache.set(4, 5); 95 | check(4, 2); 96 | 97 | assert.strictEqual(cache.has(1), false); 98 | assert.strictEqual(cache.get(2), 3); 99 | assert.strictEqual(cache.has(3), false); 100 | assert.strictEqual(cache.get(4), 5); 101 | 102 | cache.delete(2); 103 | check(4); 104 | cache.delete(4); 105 | check(); 106 | 107 | assert.strictEqual((cache as any).newest, null); 108 | assert.strictEqual((cache as any).oldest, null); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/tests/key-trie.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import { KeyTrie } from "../index"; 3 | 4 | describe("KeyTrie", function () { 5 | it("can be imported", function () { 6 | assert.strictEqual(typeof KeyTrie, "function"); 7 | }); 8 | 9 | it("can hold objects weakly", function () { 10 | const trie = new KeyTrie(true); 11 | assert.strictEqual((trie as any).weakness, true); 12 | const obj1 = {}; 13 | assert.strictEqual( 14 | trie.lookup(obj1, 2, 3), 15 | trie.lookup(obj1, 2, 3), 16 | ); 17 | const obj2 = {}; 18 | assert.notStrictEqual( 19 | trie.lookup(1, obj2), 20 | trie.lookup(1, obj2, 3), 21 | ); 22 | assert.strictEqual((trie as any).weak.has(obj1), true); 23 | assert.strictEqual((trie as any).strong.has(obj1), false); 24 | assert.strictEqual((trie as any).strong.get(1).weak.has(obj2), true); 25 | assert.strictEqual((trie as any).strong.get(1).weak.get(obj2).strong.has(3), true); 26 | }); 27 | 28 | it("can disable WeakMap", function () { 29 | const trie = new KeyTrie(false); 30 | assert.strictEqual((trie as any).weakness, false); 31 | const obj1 = {}; 32 | assert.strictEqual( 33 | trie.lookup(obj1, 2, 3), 34 | trie.lookup(obj1, 2, 3), 35 | ); 36 | const obj2 = {}; 37 | assert.notStrictEqual( 38 | trie.lookup(1, obj2), 39 | trie.lookup(1, obj2, 3), 40 | ); 41 | assert.strictEqual(typeof (trie as any).weak, "undefined"); 42 | assert.strictEqual((trie as any).strong.has(obj1), true); 43 | assert.strictEqual((trie as any).strong.has(1), true); 44 | assert.strictEqual((trie as any).strong.get(1).strong.has(obj2), true); 45 | assert.strictEqual((trie as any).strong.get(1).strong.get(obj2).strong.has(3), true); 46 | }); 47 | 48 | it("can produce data types other than Object", function () { 49 | const symbolTrie = new KeyTrie(true, args => Symbol.for(args.join("."))); 50 | const s123 = symbolTrie.lookup(1, 2, 3); 51 | assert.strictEqual(s123.toString(), "Symbol(1.2.3)"); 52 | assert.strictEqual(s123, symbolTrie.lookup(1, 2, 3)); 53 | assert.strictEqual(s123, symbolTrie.lookupArray([1, 2, 3])); 54 | const sNull = symbolTrie.lookup(); 55 | assert.strictEqual(sNull.toString(), "Symbol()"); 56 | 57 | const regExpTrie = new KeyTrie(true, args => new RegExp("^(" + args.join("|") + ")$")); 58 | const rXYZ = regExpTrie.lookup("x", "y", "z"); 59 | assert.strictEqual(rXYZ.test("w"), false); 60 | assert.strictEqual(rXYZ.test("x"), true); 61 | assert.strictEqual(rXYZ.test("y"), true); 62 | assert.strictEqual(rXYZ.test("z"), true); 63 | assert.strictEqual(String(rXYZ), "/^(x|y|z)$/"); 64 | 65 | class Data { 66 | constructor(public readonly args: any[]) {} 67 | } 68 | const dataTrie = new KeyTrie(true, args => new Data(args)); 69 | function checkData(...args: any[]) { 70 | const data = dataTrie.lookupArray(args); 71 | assert.strictEqual(data instanceof Data, true); 72 | assert.notStrictEqual(data.args, args); 73 | assert.deepEqual(data.args, args); 74 | assert.strictEqual(data, dataTrie.lookup(...args)); 75 | assert.strictEqual(data, dataTrie.lookupArray(arguments)); 76 | return data; 77 | } 78 | const datas = [ 79 | checkData(), 80 | checkData(1), 81 | checkData(1, 2), 82 | checkData(2), 83 | checkData(2, 3), 84 | checkData(true, "a"), 85 | checkData(/asdf/i, "b", function oyez() {}), 86 | ]; 87 | // Verify that all Data objects are distinct. 88 | assert.strictEqual(new Set(datas).size, datas.length); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /src/tests/deps.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import { wrap, dep } from "../index"; 3 | 4 | describe("OptimisticDependencyFunction", () => { 5 | it("can dirty OptimisticWrapperFunctions", () => { 6 | const numberDep = dep(); 7 | const stringDep = dep(); 8 | let callCount = 0; 9 | 10 | const fn = wrap((n: number, s: string) => { 11 | numberDep(n); 12 | stringDep(s); 13 | ++callCount; 14 | return s.repeat(n); 15 | }); 16 | 17 | assert.strictEqual(fn(0, "oyez"), ""); 18 | assert.strictEqual(callCount, 1); 19 | assert.strictEqual(fn(1, "oyez"), "oyez"); 20 | assert.strictEqual(callCount, 2); 21 | assert.strictEqual(fn(2, "oyez"), "oyezoyez"); 22 | assert.strictEqual(callCount, 3); 23 | 24 | assert.strictEqual(fn(0, "oyez"), ""); 25 | assert.strictEqual(fn(1, "oyez"), "oyez"); 26 | assert.strictEqual(fn(2, "oyez"), "oyezoyez"); 27 | assert.strictEqual(callCount, 3); 28 | 29 | numberDep.dirty(0); 30 | assert.strictEqual(fn(0, "oyez"), ""); 31 | assert.strictEqual(callCount, 4); 32 | assert.strictEqual(fn(1, "oyez"), "oyez"); 33 | assert.strictEqual(callCount, 4); 34 | assert.strictEqual(fn(2, "oyez"), "oyezoyez"); 35 | assert.strictEqual(callCount, 4); 36 | 37 | stringDep.dirty("mlem"); 38 | assert.strictEqual(fn(0, "oyez"), ""); 39 | assert.strictEqual(callCount, 4); 40 | 41 | stringDep.dirty("oyez"); 42 | assert.strictEqual(fn(2, "oyez"), "oyezoyez"); 43 | assert.strictEqual(callCount, 5); 44 | assert.strictEqual(fn(1, "oyez"), "oyez"); 45 | assert.strictEqual(callCount, 6); 46 | assert.strictEqual(fn(0, "oyez"), ""); 47 | assert.strictEqual(callCount, 7); 48 | 49 | assert.strictEqual(fn(0, "oyez"), ""); 50 | assert.strictEqual(fn(1, "oyez"), "oyez"); 51 | assert.strictEqual(fn(2, "oyez"), "oyezoyez"); 52 | assert.strictEqual(callCount, 7); 53 | }); 54 | 55 | it("should be forgotten when parent is recomputed", () => { 56 | const d = dep(); 57 | let callCount = 0; 58 | let shouldDepend = true; 59 | 60 | const parent = wrap((id: string) => { 61 | if (shouldDepend) d(id); 62 | return ++callCount; 63 | }); 64 | 65 | assert.strictEqual(parent("oyez"), 1); 66 | assert.strictEqual(parent("oyez"), 1); 67 | assert.strictEqual(parent("mlem"), 2); 68 | assert.strictEqual(parent("mlem"), 2); 69 | 70 | d.dirty("mlem"); 71 | assert.strictEqual(parent("oyez"), 1); 72 | assert.strictEqual(parent("mlem"), 3); 73 | 74 | d.dirty("oyez"); 75 | assert.strictEqual(parent("oyez"), 4); 76 | assert.strictEqual(parent("mlem"), 3); 77 | 78 | parent.dirty("oyez"); 79 | shouldDepend = false; 80 | assert.strictEqual(parent("oyez"), 5); 81 | assert.strictEqual(parent("mlem"), 3); 82 | d.dirty("oyez"); 83 | shouldDepend = true; 84 | assert.strictEqual(parent("oyez"), 5); 85 | assert.strictEqual(parent("mlem"), 3); 86 | // This still has no effect because the previous call to parent("oyez") 87 | // was cached. 88 | d.dirty("oyez"); 89 | assert.strictEqual(parent("oyez"), 5); 90 | assert.strictEqual(parent("mlem"), 3); 91 | parent.dirty("oyez"); 92 | assert.strictEqual(parent("oyez"), 6); 93 | assert.strictEqual(parent("mlem"), 3); 94 | d.dirty("oyez"); 95 | assert.strictEqual(parent("oyez"), 7); 96 | assert.strictEqual(parent("mlem"), 3); 97 | 98 | parent.dirty("mlem"); 99 | shouldDepend = false; 100 | assert.strictEqual(parent("oyez"), 7); 101 | assert.strictEqual(parent("mlem"), 8); 102 | d.dirty("oyez"); 103 | d.dirty("mlem"); 104 | assert.strictEqual(parent("oyez"), 9); 105 | assert.strictEqual(parent("mlem"), 8); 106 | d.dirty("oyez"); 107 | d.dirty("mlem"); 108 | assert.strictEqual(parent("oyez"), 9); 109 | assert.strictEqual(parent("mlem"), 8); 110 | shouldDepend = true; 111 | parent.dirty("mlem"); 112 | assert.strictEqual(parent("oyez"), 9); 113 | assert.strictEqual(parent("mlem"), 10); 114 | d.dirty("oyez"); 115 | d.dirty("mlem"); 116 | assert.strictEqual(parent("oyez"), 9); 117 | assert.strictEqual(parent("mlem"), 11); 118 | }); 119 | 120 | it("supports subscribing and unsubscribing", function () { 121 | let subscribeCallCount = 0; 122 | let unsubscribeCallCount = 0; 123 | let parentCallCount = 0; 124 | 125 | function check(counts: { 126 | subscribe: number; 127 | unsubscribe: number; 128 | parent: number; 129 | }) { 130 | assert.strictEqual(counts.subscribe, subscribeCallCount); 131 | assert.strictEqual(counts.unsubscribe, unsubscribeCallCount); 132 | assert.strictEqual(counts.parent, parentCallCount); 133 | } 134 | 135 | const d = dep({ 136 | subscribe(key: string) { 137 | ++subscribeCallCount; 138 | return () => { 139 | ++unsubscribeCallCount; 140 | }; 141 | }, 142 | }); 143 | 144 | assert.strictEqual(subscribeCallCount, 0); 145 | assert.strictEqual(unsubscribeCallCount, 0); 146 | 147 | const parent = wrap((key: string) => { 148 | d(key); 149 | return ++parentCallCount; 150 | }); 151 | 152 | assert.strictEqual(parent("rawr"), 1); 153 | check({ subscribe: 1, unsubscribe: 0, parent: 1 }); 154 | assert.strictEqual(parent("rawr"), 1); 155 | check({ subscribe: 1, unsubscribe: 0, parent: 1 }); 156 | assert.strictEqual(parent("blep"), 2); 157 | check({ subscribe: 2, unsubscribe: 0, parent: 2 }); 158 | assert.strictEqual(parent("rawr"), 1); 159 | check({ subscribe: 2, unsubscribe: 0, parent: 2 }); 160 | assert.strictEqual(parent("blep"), 2); 161 | check({ subscribe: 2, unsubscribe: 0, parent: 2 }); 162 | 163 | d.dirty("blep"); 164 | check({ subscribe: 2, unsubscribe: 1, parent: 2 }); 165 | assert.strictEqual(parent("rawr"), 1); 166 | check({ subscribe: 2, unsubscribe: 1, parent: 2 }); 167 | d.dirty("blep"); // intentionally redundant 168 | check({ subscribe: 2, unsubscribe: 1, parent: 2 }); 169 | assert.strictEqual(parent("blep"), 3); 170 | check({ subscribe: 3, unsubscribe: 1, parent: 3 }); 171 | assert.strictEqual(parent("blep"), 3); 172 | check({ subscribe: 3, unsubscribe: 1, parent: 3 }); 173 | 174 | d.dirty("rawr"); 175 | check({ subscribe: 3, unsubscribe: 2, parent: 3 }); 176 | assert.strictEqual(parent("blep"), 3); 177 | check({ subscribe: 3, unsubscribe: 2, parent: 3 }); 178 | assert.strictEqual(parent("rawr"), 4); 179 | check({ subscribe: 4, unsubscribe: 2, parent: 4 }); 180 | assert.strictEqual(parent("blep"), 3); 181 | check({ subscribe: 4, unsubscribe: 2, parent: 4 }); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /src/tests/context.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import { 3 | wrap, 4 | setTimeout, 5 | asyncFromGen, 6 | noContext, 7 | nonReactive, 8 | Slot, 9 | } from '../index.js'; 10 | 11 | describe("asyncFromGen", function () { 12 | it("is importable", function () { 13 | assert.strictEqual(typeof asyncFromGen, "function"); 14 | }); 15 | 16 | it("works like an async function", asyncFromGen(function*(): Generator< 17 | number | Promise, 18 | Promise, 19 | number 20 | > { 21 | let sum = 0; 22 | const limit = yield new Promise(resolve => { 23 | setTimeout(() => resolve(10), 10); 24 | }); 25 | for (let i = 0; i < limit; ++i) { 26 | sum += yield i + 1; 27 | } 28 | assert.strictEqual(sum, 55); 29 | return Promise.resolve("ok"); 30 | })); 31 | 32 | it("properly handles exceptions", async function () { 33 | const fn = asyncFromGen(function*(throwee?: object): Generator< 34 | Promise | object, 35 | string, 36 | string 37 | > { 38 | const result = yield Promise.resolve("ok"); 39 | if (throwee) { 40 | throw yield throwee; 41 | } 42 | return result; 43 | }); 44 | 45 | const okPromise = fn(); 46 | const expected = {}; 47 | const koPromise = fn(expected); 48 | 49 | assert.strictEqual(await okPromise, "ok"); 50 | 51 | try { 52 | await koPromise; 53 | throw new Error("not reached"); 54 | } catch (error) { 55 | assert.strictEqual(error, expected); 56 | } 57 | 58 | try { 59 | await fn(Promise.resolve("oyez")); 60 | throw new Error("not reached"); 61 | } catch (thrown) { 62 | assert.strictEqual(thrown, "oyez"); 63 | } 64 | 65 | const catcher = asyncFromGen(function*() { 66 | try { 67 | yield Promise.reject(new Error("expected")); 68 | throw new Error("not reached"); 69 | } catch (error: any) { 70 | assert.strictEqual(error.message, "expected"); 71 | } 72 | return "ok"; 73 | }); 74 | 75 | return catcher().then(result => { 76 | assert.strictEqual(result, "ok"); 77 | }); 78 | }); 79 | 80 | it("can be cached", async function () { 81 | let parentCounter = 0; 82 | const parent = wrap(asyncFromGen(function*(x: number): Generator< 83 | Promise, 84 | number, 85 | number 86 | > { 87 | ++parentCounter; 88 | const a = yield new Promise(resolve => setTimeout(() => { 89 | resolve(child(x)); 90 | }, 10)); 91 | const b = yield new Promise(resolve => setTimeout(() => { 92 | resolve(child(x + 1)); 93 | }, 20)); 94 | return a * b; 95 | })); 96 | 97 | let childCounter = 0; 98 | const child = wrap((x: number) => { 99 | return ++childCounter; 100 | }); 101 | 102 | assert.strictEqual(parentCounter, 0); 103 | assert.strictEqual(childCounter, 0); 104 | const parentPromise = parent(123); 105 | assert.strictEqual(parentCounter, 1); 106 | assert.strictEqual(await parentPromise, 2); 107 | assert.strictEqual(childCounter, 2); 108 | 109 | assert.strictEqual(parent(123), parentPromise); 110 | assert.strictEqual(parentCounter, 1); 111 | assert.strictEqual(childCounter, 2); 112 | 113 | child.dirty(123); 114 | 115 | assert.strictEqual(await parent(123), 3 * 2); 116 | assert.strictEqual(parentCounter, 2); 117 | assert.strictEqual(childCounter, 3); 118 | 119 | assert.strictEqual(await parent(456), 4 * 5); 120 | assert.strictEqual(parentCounter, 3); 121 | assert.strictEqual(childCounter, 5); 122 | 123 | assert.strictEqual(parent(666), parent(666)); 124 | assert.strictEqual(await parent(666), await parent(666)); 125 | assert.strictEqual(parentCounter, 4); 126 | assert.strictEqual(childCounter, 7); 127 | 128 | child.dirty(667); 129 | 130 | assert.strictEqual(await parent(667), 8 * 9); 131 | assert.strictEqual(await parent(667), 8 * 9); 132 | assert.strictEqual(parentCounter, 5); 133 | assert.strictEqual(childCounter, 9); 134 | 135 | assert.strictEqual(await parent(123), 3 * 2); 136 | assert.strictEqual(parentCounter, 5); 137 | assert.strictEqual(childCounter, 9); 138 | }); 139 | }); 140 | 141 | describe("noContext", function () { 142 | it("prevents registering dependencies", function () { 143 | let parentCounter = 0; 144 | const parent = wrap(() => { 145 | return [++parentCounter, noContext(child)]; 146 | }); 147 | 148 | let childCounter = 0; 149 | const child = wrap(() => ++childCounter); 150 | 151 | assert.deepEqual(parent(), [1, 1]); 152 | assert.deepEqual(parent(), [1, 1]); 153 | parent.dirty(); 154 | assert.deepEqual(parent(), [2, 1]); 155 | // Calling child.dirty() does not dirty the parent: 156 | child.dirty(); 157 | assert.deepEqual(parent(), [2, 1]); 158 | parent.dirty(); 159 | assert.deepEqual(parent(), [3, 2]); 160 | assert.deepEqual(parent(), [3, 2]); 161 | parent.dirty(); 162 | assert.deepEqual(parent(), [4, 2]); 163 | }); 164 | }); 165 | 166 | describe("nonReactive", function () { 167 | const otherSlot = new Slot(); 168 | 169 | it("censors only optimism-related context", function () { 170 | let innerCounter = 0; 171 | const inner = wrap(() => ++innerCounter); 172 | const outer = wrap(() => ({ 173 | fromInner: nonReactive(() => inner()), 174 | fromOther: nonReactive(() => otherSlot.getValue()), 175 | })); 176 | assert.strictEqual(otherSlot.getValue(), undefined); 177 | otherSlot.withValue("preserved", () => { 178 | assert.deepEqual(outer(), { fromInner: 1, fromOther: "preserved" }); 179 | assert.deepEqual(outer(), { fromInner: 1, fromOther: "preserved" }); 180 | inner.dirty(); 181 | assert.deepEqual(outer(), { fromInner: 1, fromOther: "preserved" }); 182 | assert.strictEqual(inner(), 2); 183 | outer.dirty(); 184 | assert.deepEqual(outer(), { fromInner: 2, fromOther: "preserved" }); 185 | }); 186 | assert.strictEqual(otherSlot.getValue(), undefined); 187 | }); 188 | 189 | it("same test using noContext, for comparison", function () { 190 | let innerCounter = 0; 191 | const inner = wrap(() => ++innerCounter); 192 | const outer = wrap(() => ({ 193 | fromInner: noContext(inner), 194 | fromOther: noContext(() => otherSlot.getValue()), 195 | })); 196 | assert.strictEqual(otherSlot.getValue(), undefined); 197 | otherSlot.withValue("preserved", () => { 198 | assert.deepEqual(outer(), { fromInner: 1, fromOther: void 0 }); 199 | assert.deepEqual(outer(), { fromInner: 1, fromOther: void 0 }); 200 | inner.dirty(); 201 | assert.deepEqual(outer(), { fromInner: 1, fromOther: void 0 }); 202 | assert.strictEqual(inner(), 2); 203 | outer.dirty(); 204 | assert.deepEqual(outer(), { fromInner: 2, fromOther: void 0 }); 205 | }); 206 | assert.strictEqual(otherSlot.getValue(), undefined); 207 | }); 208 | }); 209 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Trie } from "@wry/trie"; 2 | 3 | import { StrongCache, CommonCache } from "@wry/caches"; 4 | import { Entry, AnyEntry } from "./entry.js"; 5 | import { parentEntrySlot } from "./context.js"; 6 | import type { NoInfer } from "./helpers.js"; 7 | 8 | // These helper functions are important for making optimism work with 9 | // asynchronous code. In order to register parent-child dependencies, 10 | // optimism needs to know about any currently active parent computations. 11 | // In ordinary synchronous code, the parent context is implicit in the 12 | // execution stack, but asynchronous code requires some extra guidance in 13 | // order to propagate context from one async task segment to the next. 14 | export { 15 | bindContext, 16 | noContext, 17 | nonReactive, 18 | setTimeout, 19 | asyncFromGen, 20 | Slot, 21 | } from "./context.js"; 22 | 23 | // A lighter-weight dependency, similar to OptimisticWrapperFunction, except 24 | // with only one argument, no makeCacheKey, no wrapped function to recompute, 25 | // and no result value. Useful for representing dependency leaves in the graph 26 | // of computation. Subscriptions are supported. 27 | export { dep, OptimisticDependencyFunction } from "./dep.js"; 28 | 29 | // The defaultMakeCacheKey function is remarkably powerful, because it gives 30 | // a unique object for any shallow-identical list of arguments. If you need 31 | // to implement a custom makeCacheKey function, you may find it helpful to 32 | // delegate the final work to defaultMakeCacheKey, which is why we export it 33 | // here. However, you may want to avoid defaultMakeCacheKey if your runtime 34 | // does not support WeakMap, or you have the ability to return a string key. 35 | // In those cases, just write your own custom makeCacheKey functions. 36 | let defaultKeyTrie: Trie | undefined; 37 | export function defaultMakeCacheKey(...args: any[]): object { 38 | const trie = defaultKeyTrie || ( 39 | defaultKeyTrie = new Trie(typeof WeakMap === "function") 40 | ); 41 | return trie.lookupArray(args); 42 | } 43 | 44 | // If you're paranoid about memory leaks, or you want to avoid using WeakMap 45 | // under the hood, but you still need the behavior of defaultMakeCacheKey, 46 | // import this constructor to create your own tries. 47 | export { Trie as KeyTrie } 48 | 49 | export type OptimisticWrapperFunction< 50 | TArgs extends any[], 51 | TResult, 52 | TKeyArgs extends any[] = TArgs, 53 | TCacheKey = any, 54 | > = ((...args: TArgs) => TResult) & { 55 | // Get the current number of Entry objects in the LRU cache. 56 | readonly size: number; 57 | 58 | // Snapshot of wrap options used to create this wrapper function. 59 | options: OptionsWithCacheInstance; 60 | 61 | // "Dirty" any cached Entry stored for the given arguments, marking that Entry 62 | // and its ancestors as potentially needing to be recomputed. The .dirty(...) 63 | // method of an optimistic function takes the same parameter types as the 64 | // original function by default, unless a keyArgs function is configured, and 65 | // then it matters that .dirty takes TKeyArgs instead of TArgs. 66 | dirty: (...args: TKeyArgs) => void; 67 | // A version of .dirty that accepts a key returned by .getKey. 68 | dirtyKey: (key: TCacheKey | undefined) => void; 69 | 70 | // Examine the current value without recomputing it. 71 | peek: (...args: TKeyArgs) => TResult | undefined; 72 | // A version of .peek that accepts a key returned by .getKey. 73 | peekKey: (key: TCacheKey | undefined) => TResult | undefined; 74 | 75 | // Completely remove the entry from the cache, dirtying any parent entries. 76 | forget: (...args: TKeyArgs) => boolean; 77 | // A version of .forget that accepts a key returned by .getKey. 78 | forgetKey: (key: TCacheKey | undefined) => boolean; 79 | 80 | // In order to use the -Key version of the above functions, you need a key 81 | // rather than the arguments used to compute the key. These two functions take 82 | // TArgs or TKeyArgs and return the corresponding TCacheKey. If no keyArgs 83 | // function has been configured, TArgs will be the same as TKeyArgs, and thus 84 | // getKey and makeCacheKey will be synonymous. 85 | getKey: (...args: TArgs) => TCacheKey | undefined; 86 | 87 | // This property is equivalent to the makeCacheKey function provided in the 88 | // OptimisticWrapOptions, or (if no options.makeCacheKey function is provided) 89 | // a default implementation of makeCacheKey. This function is also exposed as 90 | // optimistic.options.makeCacheKey, somewhat redundantly. 91 | makeCacheKey: (...args: TKeyArgs) => TCacheKey | undefined; 92 | }; 93 | 94 | export { CommonCache } 95 | export interface CommonCacheConstructor extends Function { 96 | new >(max?: number, dispose?: (value: V, key?: K) => void): CommonCache; 97 | } 98 | 99 | export type OptimisticWrapOptions< 100 | TArgs extends any[], 101 | TKeyArgs extends any[] = TArgs, 102 | TCacheKey = any, 103 | TResult = any, 104 | > = { 105 | // The maximum number of cache entries that should be retained before the 106 | // cache begins evicting the oldest ones. 107 | max?: number; 108 | // Transform the raw arguments to some other type of array, which will then 109 | // be passed to makeCacheKey. 110 | keyArgs?: (...args: TArgs) => TKeyArgs; 111 | // The makeCacheKey function takes the same arguments that were passed to 112 | // the wrapper function and returns a single value that can be used as a key 113 | // in a Map to identify the cached result. 114 | makeCacheKey?: (...args: NoInfer) => TCacheKey | undefined; 115 | // Called when a new value is computed to allow efficient normalization of 116 | // results over time, for example by returning older if equal(newer, older). 117 | normalizeResult?: (newer: TResult, older: TResult) => TResult; 118 | // If provided, the subscribe function should either return an unsubscribe 119 | // function or return nothing. 120 | subscribe?: (...args: TArgs) => void | (() => any); 121 | cache?: CommonCache, Entry, NoInfer>> 122 | | CommonCacheConstructor, NoInfer, NoInfer>; 123 | }; 124 | 125 | export interface OptionsWithCacheInstance< 126 | TArgs extends any[], 127 | TKeyArgs extends any[] = TArgs, 128 | TCacheKey = any, 129 | TResult = any, 130 | > extends OptimisticWrapOptions { 131 | cache: CommonCache, Entry, NoInfer>>; 132 | }; 133 | 134 | const caches = new Set>(); 135 | 136 | export function wrap< 137 | TArgs extends any[], 138 | TResult, 139 | TKeyArgs extends any[] = TArgs, 140 | TCacheKey = any, 141 | >(originalFunction: (...args: TArgs) => TResult, { 142 | max = Math.pow(2, 16), 143 | keyArgs, 144 | makeCacheKey = (defaultMakeCacheKey as () => TCacheKey), 145 | normalizeResult, 146 | subscribe, 147 | cache: cacheOption = StrongCache, 148 | }: OptimisticWrapOptions = Object.create(null)) { 149 | const cache: CommonCache> = 150 | typeof cacheOption === "function" 151 | ? new cacheOption(max, entry => entry.dispose()) 152 | : cacheOption; 153 | 154 | const optimistic = function (): TResult { 155 | const key = makeCacheKey.apply( 156 | null, 157 | keyArgs ? keyArgs.apply(null, arguments as any) : arguments as any 158 | ); 159 | 160 | if (key === void 0) { 161 | return originalFunction.apply(null, arguments as any); 162 | } 163 | 164 | let entry = cache.get(key)!; 165 | if (!entry) { 166 | cache.set(key, entry = new Entry(originalFunction)); 167 | entry.normalizeResult = normalizeResult; 168 | entry.subscribe = subscribe; 169 | // Give the Entry the ability to trigger cache.delete(key), even though 170 | // the Entry itself does not know about key or cache. 171 | entry.forget = () => cache.delete(key); 172 | } 173 | 174 | const value = entry.recompute( 175 | Array.prototype.slice.call(arguments) as TArgs, 176 | ); 177 | 178 | // Move this entry to the front of the least-recently used queue, 179 | // since we just finished computing its value. 180 | cache.set(key, entry); 181 | 182 | caches.add(cache); 183 | 184 | // Clean up any excess entries in the cache, but only if there is no 185 | // active parent entry, meaning we're not in the middle of a larger 186 | // computation that might be flummoxed by the cleaning. 187 | if (! parentEntrySlot.hasValue()) { 188 | caches.forEach(cache => cache.clean()); 189 | caches.clear(); 190 | } 191 | 192 | return value; 193 | } as OptimisticWrapperFunction; 194 | 195 | Object.defineProperty(optimistic, "size", { 196 | get: () => cache.size, 197 | configurable: false, 198 | enumerable: false, 199 | }); 200 | 201 | Object.freeze(optimistic.options = { 202 | max, 203 | keyArgs, 204 | makeCacheKey, 205 | normalizeResult, 206 | subscribe, 207 | cache, 208 | }); 209 | 210 | function dirtyKey(key: TCacheKey | undefined) { 211 | const entry = key && cache.get(key); 212 | if (entry) { 213 | entry.setDirty(); 214 | } 215 | } 216 | optimistic.dirtyKey = dirtyKey; 217 | optimistic.dirty = function dirty() { 218 | dirtyKey(makeCacheKey.apply(null, arguments as any)); 219 | }; 220 | 221 | function peekKey(key: TCacheKey | undefined) { 222 | const entry = key && cache.get(key); 223 | if (entry) { 224 | return entry.peek(); 225 | } 226 | } 227 | optimistic.peekKey = peekKey; 228 | optimistic.peek = function peek() { 229 | return peekKey(makeCacheKey.apply(null, arguments as any)); 230 | }; 231 | 232 | function forgetKey(key: TCacheKey | undefined) { 233 | return key ? cache.delete(key) : false; 234 | } 235 | optimistic.forgetKey = forgetKey; 236 | optimistic.forget = function forget() { 237 | return forgetKey(makeCacheKey.apply(null, arguments as any)); 238 | }; 239 | 240 | optimistic.makeCacheKey = makeCacheKey; 241 | optimistic.getKey = keyArgs ? function getKey() { 242 | return makeCacheKey.apply(null, keyArgs.apply(null, arguments as any)); 243 | } : makeCacheKey as (...args: any[]) => TCacheKey | undefined; 244 | 245 | return Object.freeze(optimistic); 246 | } 247 | -------------------------------------------------------------------------------- /src/entry.ts: -------------------------------------------------------------------------------- 1 | import { parentEntrySlot } from "./context.js"; 2 | import { OptimisticWrapOptions } from "./index.js"; 3 | import { Dep } from "./dep.js"; 4 | import { maybeUnsubscribe, arrayFromSet, Unsubscribable } from "./helpers.js"; 5 | 6 | const emptySetPool: Set[] = []; 7 | const POOL_TARGET_SIZE = 100; 8 | 9 | // Since this package might be used browsers, we should avoid using the 10 | // Node built-in assert module. 11 | function assert(condition: any, optionalMessage?: string) { 12 | if (! condition) { 13 | throw new Error(optionalMessage || "assertion failure"); 14 | } 15 | } 16 | 17 | // Since exceptions are cached just like normal values, we need an efficient 18 | // way of representing unknown, ordinary, and exceptional values. 19 | type Value = 20 | | [] // unknown 21 | | [T] // known value 22 | | [void, any]; // known exception 23 | 24 | function valueIs(a: Value, b: Value) { 25 | const len = a.length; 26 | return ( 27 | // Unknown values are not equal to each other. 28 | len > 0 && 29 | // Both values must be ordinary (or both exceptional) to be equal. 30 | len === b.length && 31 | // The underlying value or exception must be the same. 32 | a[len - 1] === b[len - 1] 33 | ); 34 | } 35 | 36 | function valueGet(value: Value): T { 37 | switch (value.length) { 38 | case 0: throw new Error("unknown value"); 39 | case 1: return value[0]; 40 | case 2: throw value[1]; 41 | } 42 | } 43 | 44 | function valueCopy(value: Value): Value { 45 | return value.slice(0) as Value; 46 | } 47 | 48 | export type AnyEntry = Entry; 49 | 50 | export class Entry { 51 | public static count = 0; 52 | 53 | public normalizeResult: OptimisticWrapOptions["normalizeResult"]; 54 | public subscribe: OptimisticWrapOptions["subscribe"]; 55 | public unsubscribe: Unsubscribable["unsubscribe"]; 56 | 57 | public readonly parents = new Set(); 58 | public readonly childValues = new Map>(); 59 | 60 | // When this Entry has children that are dirty, this property becomes 61 | // a Set containing other Entry objects, borrowed from emptySetPool. 62 | // When the set becomes empty, it gets recycled back to emptySetPool. 63 | public dirtyChildren: Set | null = null; 64 | 65 | public dirty = true; 66 | public recomputing = false; 67 | public readonly value: Value = []; 68 | 69 | constructor( 70 | public readonly fn: (...args: TArgs) => TValue, 71 | ) { 72 | ++Entry.count; 73 | } 74 | 75 | public peek(): TValue | undefined { 76 | if (this.value.length === 1 && !mightBeDirty(this)) { 77 | rememberParent(this); 78 | return this.value[0]; 79 | } 80 | } 81 | 82 | // This is the most important method of the Entry API, because it 83 | // determines whether the cached this.value can be returned immediately, 84 | // or must be recomputed. The overall performance of the caching system 85 | // depends on the truth of the following observations: (1) this.dirty is 86 | // usually false, (2) this.dirtyChildren is usually null/empty, and thus 87 | // (3) valueGet(this.value) is usually returned without recomputation. 88 | public recompute(args: TArgs): TValue { 89 | assert(! this.recomputing, "already recomputing"); 90 | rememberParent(this); 91 | return mightBeDirty(this) 92 | ? reallyRecompute(this, args) 93 | : valueGet(this.value); 94 | } 95 | 96 | public setDirty() { 97 | if (this.dirty) return; 98 | this.dirty = true; 99 | reportDirty(this); 100 | // We can go ahead and unsubscribe here, since any further dirty 101 | // notifications we receive will be redundant, and unsubscribing may 102 | // free up some resources, e.g. file watchers. 103 | maybeUnsubscribe(this); 104 | } 105 | 106 | public dispose() { 107 | this.setDirty(); 108 | 109 | // Sever any dependency relationships with our own children, so those 110 | // children don't retain this parent Entry in their child.parents sets, 111 | // thereby preventing it from being fully garbage collected. 112 | forgetChildren(this); 113 | 114 | // Because this entry has been kicked out of the cache (in index.js), 115 | // we've lost the ability to find out if/when this entry becomes dirty, 116 | // whether that happens through a subscription, because of a direct call 117 | // to entry.setDirty(), or because one of its children becomes dirty. 118 | // Because of this loss of future information, we have to assume the 119 | // worst (that this entry might have become dirty very soon), so we must 120 | // immediately mark this entry's parents as dirty. Normally we could 121 | // just call entry.setDirty() rather than calling parent.setDirty() for 122 | // each parent, but that would leave this entry in parent.childValues 123 | // and parent.dirtyChildren, which would prevent the child from being 124 | // truly forgotten. 125 | eachParent(this, (parent, child) => { 126 | parent.setDirty(); 127 | forgetChild(parent, this); 128 | }); 129 | } 130 | 131 | public forget() { 132 | // The code that creates Entry objects in index.ts will replace this method 133 | // with one that actually removes the Entry from the cache, which will also 134 | // trigger the entry.dispose method. 135 | this.dispose(); 136 | } 137 | 138 | private deps: Set> | null = null; 139 | 140 | public dependOn(dep: Dep) { 141 | dep.add(this); 142 | if (! this.deps) { 143 | this.deps = emptySetPool.pop() || new Set>(); 144 | } 145 | this.deps.add(dep); 146 | } 147 | 148 | public forgetDeps() { 149 | if (this.deps) { 150 | arrayFromSet(this.deps).forEach(dep => dep.delete(this)); 151 | this.deps.clear(); 152 | emptySetPool.push(this.deps); 153 | this.deps = null; 154 | } 155 | } 156 | } 157 | 158 | function rememberParent(child: AnyEntry) { 159 | const parent = parentEntrySlot.getValue(); 160 | if (parent) { 161 | child.parents.add(parent); 162 | 163 | if (! parent.childValues.has(child)) { 164 | parent.childValues.set(child, []); 165 | } 166 | 167 | if (mightBeDirty(child)) { 168 | reportDirtyChild(parent, child); 169 | } else { 170 | reportCleanChild(parent, child); 171 | } 172 | 173 | return parent; 174 | } 175 | } 176 | 177 | function reallyRecompute(entry: AnyEntry, args: any[]) { 178 | forgetChildren(entry); 179 | 180 | // Set entry as the parent entry while calling recomputeNewValue(entry). 181 | parentEntrySlot.withValue(entry, recomputeNewValue, [entry, args]); 182 | 183 | if (maybeSubscribe(entry, args)) { 184 | // If we successfully recomputed entry.value and did not fail to 185 | // (re)subscribe, then this Entry is no longer explicitly dirty. 186 | setClean(entry); 187 | } 188 | 189 | return valueGet(entry.value); 190 | } 191 | 192 | function recomputeNewValue(entry: AnyEntry, args: any[]) { 193 | entry.recomputing = true; 194 | 195 | const { normalizeResult } = entry; 196 | let oldValueCopy: Value | undefined; 197 | if (normalizeResult && entry.value.length === 1) { 198 | oldValueCopy = valueCopy(entry.value); 199 | } 200 | 201 | // Make entry.value an empty array, representing an unknown value. 202 | entry.value.length = 0; 203 | 204 | try { 205 | // If entry.fn succeeds, entry.value will become a normal Value. 206 | entry.value[0] = entry.fn.apply(null, args); 207 | 208 | // If we have a viable oldValueCopy to compare with the (successfully 209 | // recomputed) new entry.value, and they are not already === identical, give 210 | // normalizeResult a chance to pick/choose/reuse parts of oldValueCopy[0] 211 | // and/or entry.value[0] to determine the final cached entry.value. 212 | if (normalizeResult && oldValueCopy && !valueIs(oldValueCopy, entry.value)) { 213 | try { 214 | entry.value[0] = normalizeResult(entry.value[0], oldValueCopy[0]); 215 | } catch { 216 | // If normalizeResult throws, just use the newer value, rather than 217 | // saving the exception as entry.value[1]. 218 | } 219 | } 220 | 221 | } catch (e) { 222 | // If entry.fn throws, entry.value will hold that exception. 223 | entry.value[1] = e; 224 | } 225 | 226 | // Either way, this line is always reached. 227 | entry.recomputing = false; 228 | } 229 | 230 | function mightBeDirty(entry: AnyEntry) { 231 | return entry.dirty || !!(entry.dirtyChildren && entry.dirtyChildren.size); 232 | } 233 | 234 | function setClean(entry: AnyEntry) { 235 | entry.dirty = false; 236 | 237 | if (mightBeDirty(entry)) { 238 | // This Entry may still have dirty children, in which case we can't 239 | // let our parents know we're clean just yet. 240 | return; 241 | } 242 | 243 | reportClean(entry); 244 | } 245 | 246 | function reportDirty(child: AnyEntry) { 247 | eachParent(child, reportDirtyChild); 248 | } 249 | 250 | function reportClean(child: AnyEntry) { 251 | eachParent(child, reportCleanChild); 252 | } 253 | 254 | function eachParent( 255 | child: AnyEntry, 256 | callback: (parent: AnyEntry, child: AnyEntry) => any, 257 | ) { 258 | const parentCount = child.parents.size; 259 | if (parentCount) { 260 | const parents = arrayFromSet(child.parents); 261 | for (let i = 0; i < parentCount; ++i) { 262 | callback(parents[i], child); 263 | } 264 | } 265 | } 266 | 267 | // Let a parent Entry know that one of its children may be dirty. 268 | function reportDirtyChild(parent: AnyEntry, child: AnyEntry) { 269 | // Must have called rememberParent(child) before calling 270 | // reportDirtyChild(parent, child). 271 | assert(parent.childValues.has(child)); 272 | assert(mightBeDirty(child)); 273 | const parentWasClean = !mightBeDirty(parent); 274 | 275 | if (! parent.dirtyChildren) { 276 | parent.dirtyChildren = emptySetPool.pop() || new Set; 277 | 278 | } else if (parent.dirtyChildren.has(child)) { 279 | // If we already know this child is dirty, then we must have already 280 | // informed our own parents that we are dirty, so we can terminate 281 | // the recursion early. 282 | return; 283 | } 284 | 285 | parent.dirtyChildren.add(child); 286 | 287 | // If parent was clean before, it just became (possibly) dirty (according to 288 | // mightBeDirty), since we just added child to parent.dirtyChildren. 289 | if (parentWasClean) { 290 | reportDirty(parent); 291 | } 292 | } 293 | 294 | // Let a parent Entry know that one of its children is no longer dirty. 295 | function reportCleanChild(parent: AnyEntry, child: AnyEntry) { 296 | // Must have called rememberChild(child) before calling 297 | // reportCleanChild(parent, child). 298 | assert(parent.childValues.has(child)); 299 | assert(! mightBeDirty(child)); 300 | 301 | const childValue = parent.childValues.get(child)!; 302 | if (childValue.length === 0) { 303 | parent.childValues.set(child, valueCopy(child.value)); 304 | } else if (! valueIs(childValue, child.value)) { 305 | parent.setDirty(); 306 | } 307 | 308 | removeDirtyChild(parent, child); 309 | 310 | if (mightBeDirty(parent)) { 311 | return; 312 | } 313 | 314 | reportClean(parent); 315 | } 316 | 317 | function removeDirtyChild(parent: AnyEntry, child: AnyEntry) { 318 | const dc = parent.dirtyChildren; 319 | if (dc) { 320 | dc.delete(child); 321 | if (dc.size === 0) { 322 | if (emptySetPool.length < POOL_TARGET_SIZE) { 323 | emptySetPool.push(dc); 324 | } 325 | parent.dirtyChildren = null; 326 | } 327 | } 328 | } 329 | 330 | // Removes all children from this entry and returns an array of the 331 | // removed children. 332 | function forgetChildren(parent: AnyEntry) { 333 | if (parent.childValues.size > 0) { 334 | parent.childValues.forEach((_value, child) => { 335 | forgetChild(parent, child); 336 | }); 337 | } 338 | 339 | // Remove this parent Entry from any sets to which it was added by the 340 | // addToSet method. 341 | parent.forgetDeps(); 342 | 343 | // After we forget all our children, this.dirtyChildren must be empty 344 | // and therefore must have been reset to null. 345 | assert(parent.dirtyChildren === null); 346 | } 347 | 348 | function forgetChild(parent: AnyEntry, child: AnyEntry) { 349 | child.parents.delete(parent); 350 | parent.childValues.delete(child); 351 | removeDirtyChild(parent, child); 352 | } 353 | 354 | function maybeSubscribe(entry: AnyEntry, args: any[]) { 355 | if (typeof entry.subscribe === "function") { 356 | try { 357 | maybeUnsubscribe(entry); // Prevent double subscriptions. 358 | entry.unsubscribe = entry.subscribe.apply(null, args); 359 | } catch (e) { 360 | // If this Entry has a subscribe function and it threw an exception 361 | // (or an unsubscribe function it previously returned now throws), 362 | // return false to indicate that we were not able to subscribe (or 363 | // unsubscribe), and this Entry should remain dirty. 364 | entry.setDirty(); 365 | return false; 366 | } 367 | } 368 | 369 | // Returning true indicates either that there was no entry.subscribe 370 | // function or that it succeeded. 371 | return true; 372 | } 373 | -------------------------------------------------------------------------------- /src/tests/api.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import { createHash } from "crypto"; 3 | import { 4 | wrap, 5 | defaultMakeCacheKey, 6 | OptimisticWrapperFunction, 7 | CommonCache, 8 | } from "../index"; 9 | import { equal } from '@wry/equality'; 10 | import { wrapYieldingFiberMethods } from '@wry/context'; 11 | import { dep } from "../dep"; 12 | import { permutations } from "./test-utils"; 13 | 14 | type NumThunk = OptimisticWrapperFunction<[], number>; 15 | 16 | describe("optimism", function () { 17 | it("sanity", function () { 18 | assert.strictEqual(typeof wrap, "function"); 19 | assert.strictEqual(typeof defaultMakeCacheKey, "function"); 20 | }); 21 | 22 | it("works with single functions", function () { 23 | const test = wrap(function (x: string) { 24 | return x + salt; 25 | }, { 26 | makeCacheKey: function (x: string) { 27 | return x; 28 | } 29 | }); 30 | 31 | let salt = "salt"; 32 | assert.strictEqual(test("a"), "asalt"); 33 | 34 | salt = "NaCl"; 35 | assert.strictEqual(test("a"), "asalt"); 36 | assert.strictEqual(test("b"), "bNaCl"); 37 | 38 | test.dirty("a"); 39 | assert.strictEqual(test("a"), "aNaCl"); 40 | }); 41 | 42 | it("can manually specify a cache instance", () => { 43 | class Cache implements CommonCache { 44 | private _cache = new Map() 45 | has = this._cache.has.bind(this._cache); 46 | get = this._cache.get.bind(this._cache); 47 | delete = this._cache.delete.bind(this._cache); 48 | get size(){ return this._cache.size } 49 | set(key: K, value: V): V { 50 | this._cache.set(key, value); 51 | return value; 52 | } 53 | clean(){}; 54 | } 55 | 56 | const cache = new Cache(); 57 | 58 | const wrapped = wrap( 59 | (obj: { value: string }) => obj.value + " transformed", 60 | { 61 | cache, 62 | makeCacheKey(obj) { 63 | return obj.value; 64 | }, 65 | } 66 | ); 67 | assert.ok(cache instanceof Cache); 68 | assert.strictEqual(wrapped({ value: "test" }), "test transformed"); 69 | assert.strictEqual(wrapped({ value: "test" }), "test transformed"); 70 | cache.get("test").value[0] = "test modified"; 71 | assert.strictEqual(wrapped({ value: "test" }), "test modified"); 72 | }); 73 | 74 | it("can manually specify a cache constructor", () => { 75 | class Cache implements CommonCache { 76 | private _cache = new Map() 77 | has = this._cache.has.bind(this._cache); 78 | get = this._cache.get.bind(this._cache); 79 | delete = this._cache.delete.bind(this._cache); 80 | get size(){ return this._cache.size } 81 | set(key: K, value: V): V { 82 | this._cache.set(key, value); 83 | return value; 84 | } 85 | clean(){}; 86 | } 87 | 88 | const wrapped = wrap( 89 | (obj: { value: string }) => obj.value + " transformed", 90 | { 91 | cache: Cache, 92 | makeCacheKey(obj) { 93 | return obj.value; 94 | }, 95 | } 96 | ); 97 | assert.ok(wrapped.options.cache instanceof Cache); 98 | assert.strictEqual(wrapped({ value: "test" }), "test transformed"); 99 | assert.strictEqual(wrapped({ value: "test" }), "test transformed"); 100 | wrapped.options.cache.get("test").value[0] = "test modified"; 101 | assert.strictEqual(wrapped({ value: "test" }), "test modified"); 102 | }); 103 | 104 | it("works with two layers of functions", function () { 105 | const files: { [key: string]: string } = { 106 | "a.js": "a", 107 | "b.js": "b" 108 | }; 109 | 110 | const fileNames = Object.keys(files); 111 | 112 | const read = wrap(function (path: string) { 113 | return files[path]; 114 | }); 115 | 116 | const hash = wrap(function (paths: string[]) { 117 | const h = createHash("sha1"); 118 | paths.forEach(function (path) { 119 | h.update(read(path)); 120 | }); 121 | return h.digest("hex"); 122 | }); 123 | 124 | const hash1 = hash(fileNames); 125 | files["a.js"] += "yy"; 126 | const hash2 = hash(fileNames); 127 | read.dirty("a.js"); 128 | const hash3 = hash(fileNames); 129 | files["b.js"] += "ee"; 130 | read.dirty("b.js"); 131 | const hash4 = hash(fileNames); 132 | 133 | assert.strictEqual(hash1, hash2); 134 | assert.notStrictEqual(hash1, hash3); 135 | assert.notStrictEqual(hash1, hash4); 136 | assert.notStrictEqual(hash3, hash4); 137 | }); 138 | 139 | it("works with subscription functions", function () { 140 | let dirty: () => void; 141 | let sep = ","; 142 | const unsubscribed = Object.create(null); 143 | const test = wrap(function (x: string) { 144 | return [x, x, x].join(sep); 145 | }, { 146 | max: 1, 147 | subscribe: function (x: string) { 148 | dirty = function () { 149 | test.dirty(x); 150 | }; 151 | 152 | delete unsubscribed[x]; 153 | 154 | return function () { 155 | unsubscribed[x] = true; 156 | }; 157 | } 158 | }); 159 | 160 | assert.strictEqual(test("a"), "a,a,a"); 161 | 162 | assert.strictEqual(test("b"), "b,b,b"); 163 | assert.deepEqual(unsubscribed, { a: true }); 164 | 165 | assert.strictEqual(test("c"), "c,c,c"); 166 | assert.deepEqual(unsubscribed, { 167 | a: true, 168 | b: true 169 | }); 170 | 171 | sep = ":"; 172 | 173 | assert.strictEqual(test("c"), "c,c,c"); 174 | assert.deepEqual(unsubscribed, { 175 | a: true, 176 | b: true 177 | }); 178 | 179 | dirty!(); 180 | 181 | assert.strictEqual(test("c"), "c:c:c"); 182 | assert.deepEqual(unsubscribed, { 183 | a: true, 184 | b: true 185 | }); 186 | 187 | assert.strictEqual(test("d"), "d:d:d"); 188 | assert.deepEqual(unsubscribed, { 189 | a: true, 190 | b: true, 191 | c: true 192 | }); 193 | }); 194 | 195 | // The fibers coroutine library no longer works with Node.js v16. 196 | it.skip("is not confused by fibers", function () { 197 | const Fiber = wrapYieldingFiberMethods(require("fibers")); 198 | 199 | const order = []; 200 | let result1 = "one"; 201 | let result2 = "two"; 202 | 203 | const f1 = new Fiber(function () { 204 | order.push(1); 205 | 206 | const o1 = wrap(function () { 207 | Fiber.yield(); 208 | return result1; 209 | }); 210 | 211 | order.push(2); 212 | assert.strictEqual(o1(), "one"); 213 | order.push(3); 214 | result1 += ":dirty"; 215 | assert.strictEqual(o1(), "one"); 216 | order.push(4); 217 | Fiber.yield(); 218 | order.push(5); 219 | assert.strictEqual(o1(), "one"); 220 | order.push(6); 221 | o1.dirty(); 222 | order.push(7); 223 | assert.strictEqual(o1(), "one:dirty"); 224 | order.push(8); 225 | assert.strictEqual(o2(), "two:dirty"); 226 | order.push(9); 227 | }); 228 | 229 | result2 = "two" 230 | const o2 = wrap(function () { 231 | return result2; 232 | }); 233 | 234 | order.push(0); 235 | 236 | f1.run(); 237 | assert.deepEqual(order, [0, 1, 2]); 238 | 239 | // The primary goal of this test is to make sure this call to o2() 240 | // does not register a dirty-chain dependency for o1. 241 | assert.strictEqual(o2(), "two"); 242 | 243 | f1.run(); 244 | assert.deepEqual(order, [0, 1, 2, 3, 4]); 245 | 246 | // If the call to o2() captured o1() as a parent, then this o2.dirty() 247 | // call will report the o1() call dirty, which is not what we want. 248 | result2 += ":dirty"; 249 | o2.dirty(); 250 | 251 | f1.run(); 252 | // The call to o1() between order.push(5) and order.push(6) should not 253 | // yield, because it should still be cached, because it should not be 254 | // dirty. However, the call to o1() between order.push(7) and 255 | // order.push(8) should yield, because we call o1.dirty() explicitly, 256 | // which is why this assertion stops at 7. 257 | assert.deepEqual(order, [0, 1, 2, 3, 4, 5, 6, 7]); 258 | 259 | f1.run(); 260 | assert.deepEqual(order, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); 261 | }); 262 | 263 | it("marks evicted cache entries dirty", function () { 264 | let childSalt = "*"; 265 | let child = wrap(function (x: string) { 266 | return x + childSalt; 267 | }, { max: 1 }); 268 | 269 | let parentSalt = "^"; 270 | const parent = wrap(function (x: string) { 271 | return child(x) + parentSalt; 272 | }); 273 | 274 | assert.strictEqual(parent("asdf"), "asdf*^"); 275 | 276 | childSalt = "&"; 277 | parentSalt = "%"; 278 | 279 | assert.strictEqual(parent("asdf"), "asdf*^"); 280 | assert.strictEqual(child("zxcv"), "zxcv&"); 281 | assert.strictEqual(parent("asdf"), "asdf&%"); 282 | }); 283 | 284 | it("handles children throwing exceptions", function () { 285 | const expected = new Error("oyez"); 286 | 287 | const child = wrap(function () { 288 | throw expected; 289 | }); 290 | 291 | const parent = wrap(function () { 292 | try { 293 | child(); 294 | } catch (e) { 295 | return e; 296 | } 297 | }); 298 | 299 | assert.strictEqual(parent(), expected); 300 | assert.strictEqual(parent(), expected); 301 | 302 | child.dirty(); 303 | assert.strictEqual(parent(), expected); 304 | 305 | parent.dirty(); 306 | assert.strictEqual(parent(), expected); 307 | }); 308 | 309 | it("reports clean children to correct parents", function () { 310 | let childResult = "a"; 311 | const child = wrap(function () { 312 | return childResult; 313 | }); 314 | 315 | const parent = wrap(function (x: any) { 316 | return child() + x; 317 | }); 318 | 319 | assert.strictEqual(parent(1), "a1"); 320 | assert.strictEqual(parent(2), "a2"); 321 | 322 | childResult = "b"; 323 | child.dirty(); 324 | 325 | // If this call to parent(1) mistakenly reports child() as clean to 326 | // parent(2), then the second assertion will fail by returning "a2". 327 | assert.strictEqual(parent(1), "b1"); 328 | assert.strictEqual(parent(2), "b2"); 329 | }); 330 | 331 | it("supports object cache keys", function () { 332 | let counter = 0; 333 | const wrapped = wrap(function (a: any, b: any) { 334 | return counter++; 335 | }); 336 | 337 | const a = {}; 338 | const b = {}; 339 | 340 | // Different combinations of distinct object references should 341 | // increment the counter. 342 | assert.strictEqual(wrapped(a, a), 0); 343 | assert.strictEqual(wrapped(a, b), 1); 344 | assert.strictEqual(wrapped(b, a), 2); 345 | assert.strictEqual(wrapped(b, b), 3); 346 | 347 | // But the same combinations of arguments should return the same 348 | // cached values when passed again. 349 | assert.strictEqual(wrapped(a, a), 0); 350 | assert.strictEqual(wrapped(a, b), 1); 351 | assert.strictEqual(wrapped(b, a), 2); 352 | assert.strictEqual(wrapped(b, b), 3); 353 | }); 354 | 355 | it("supports falsy non-void cache keys", function () { 356 | let callCount = 0; 357 | const wrapped = wrap((key: number | string | null | boolean | undefined) => { 358 | ++callCount; 359 | return key; 360 | }, { 361 | makeCacheKey(key) { 362 | return key; 363 | }, 364 | }); 365 | 366 | assert.strictEqual(wrapped(0), 0); 367 | assert.strictEqual(callCount, 1); 368 | assert.strictEqual(wrapped(0), 0); 369 | assert.strictEqual(callCount, 1); 370 | 371 | assert.strictEqual(wrapped(""), ""); 372 | assert.strictEqual(callCount, 2); 373 | assert.strictEqual(wrapped(""), ""); 374 | assert.strictEqual(callCount, 2); 375 | 376 | assert.strictEqual(wrapped(null), null); 377 | assert.strictEqual(callCount, 3); 378 | assert.strictEqual(wrapped(null), null); 379 | assert.strictEqual(callCount, 3); 380 | 381 | assert.strictEqual(wrapped(false), false); 382 | assert.strictEqual(callCount, 4); 383 | assert.strictEqual(wrapped(false), false); 384 | assert.strictEqual(callCount, 4); 385 | 386 | assert.strictEqual(wrapped(0), 0); 387 | assert.strictEqual(wrapped(""), ""); 388 | assert.strictEqual(wrapped(null), null); 389 | assert.strictEqual(wrapped(false), false); 390 | assert.strictEqual(callCount, 4); 391 | 392 | assert.strictEqual(wrapped(1), 1); 393 | assert.strictEqual(wrapped("oyez"), "oyez"); 394 | assert.strictEqual(wrapped(true), true); 395 | assert.strictEqual(callCount, 7); 396 | 397 | assert.strictEqual(wrapped(void 0), void 0); 398 | assert.strictEqual(wrapped(void 0), void 0); 399 | assert.strictEqual(wrapped(void 0), void 0); 400 | assert.strictEqual(callCount, 10); 401 | }); 402 | 403 | it("detects problematic cycles", function () { 404 | const self: NumThunk = wrap(function () { 405 | return self() + 1; 406 | }); 407 | 408 | const mutualA: NumThunk = wrap(function () { 409 | return mutualB() + 1; 410 | }); 411 | 412 | const mutualB: NumThunk = wrap(function () { 413 | return mutualA() + 1; 414 | }); 415 | 416 | function check(fn: typeof self) { 417 | try { 418 | fn(); 419 | throw new Error("should not get here"); 420 | } catch (e: any) { 421 | assert.strictEqual(e.message, "already recomputing"); 422 | } 423 | 424 | // Try dirtying the function, now that there's a cycle in the Entry 425 | // graph. This should succeed. 426 | fn.dirty(); 427 | } 428 | 429 | check(self); 430 | check(mutualA); 431 | check(mutualB); 432 | 433 | let returnZero = true; 434 | const fn: NumThunk = wrap(function () { 435 | if (returnZero) { 436 | returnZero = false; 437 | return 0; 438 | } 439 | returnZero = true; 440 | return fn() + 1; 441 | }); 442 | 443 | assert.strictEqual(fn(), 0); 444 | assert.strictEqual(returnZero, false); 445 | 446 | returnZero = true; 447 | assert.strictEqual(fn(), 0); 448 | assert.strictEqual(returnZero, true); 449 | 450 | fn.dirty(); 451 | 452 | returnZero = false; 453 | check(fn); 454 | }); 455 | 456 | it("tolerates misbehaving makeCacheKey functions", function () { 457 | type NumNum = OptimisticWrapperFunction<[number], number>; 458 | 459 | let chaos = false; 460 | let counter = 0; 461 | const allOddsDep = wrap(() => ++counter); 462 | 463 | const sumOdd: NumNum = wrap((n: number) => { 464 | allOddsDep(); 465 | if (n < 1) return 0; 466 | if (n % 2 === 1) { 467 | return n + sumEven(n - 1); 468 | } 469 | return sumEven(n); 470 | }, { 471 | makeCacheKey(n) { 472 | // Even though the computation completes, returning "constant" causes 473 | // cycles in the Entry graph. 474 | return chaos ? "constant" : n; 475 | } 476 | }); 477 | 478 | const sumEven: NumNum = wrap((n: number) => { 479 | if (n < 1) return 0; 480 | if (n % 2 === 0) { 481 | return n + sumOdd(n - 1); 482 | } 483 | return sumOdd(n); 484 | }); 485 | 486 | function check() { 487 | sumEven.dirty(10); 488 | sumOdd.dirty(10); 489 | if (chaos) { 490 | try { 491 | sumOdd(10); 492 | } catch (e: any) { 493 | assert.strictEqual(e.message, "already recomputing"); 494 | } 495 | try { 496 | sumEven(10); 497 | } catch (e: any) { 498 | assert.strictEqual(e.message, "already recomputing"); 499 | } 500 | } else { 501 | assert.strictEqual(sumEven(10), 55); 502 | assert.strictEqual(sumOdd(10), 55); 503 | } 504 | } 505 | 506 | check(); 507 | 508 | allOddsDep.dirty(); 509 | sumEven.dirty(10); 510 | check(); 511 | 512 | allOddsDep.dirty(); 513 | allOddsDep(); 514 | check(); 515 | 516 | chaos = true; 517 | check(); 518 | 519 | allOddsDep.dirty(); 520 | allOddsDep(); 521 | check(); 522 | 523 | allOddsDep.dirty(); 524 | check(); 525 | 526 | chaos = false; 527 | allOddsDep.dirty(); 528 | check(); 529 | 530 | chaos = true; 531 | sumOdd.dirty(9); 532 | sumOdd.dirty(7); 533 | sumOdd.dirty(5); 534 | check(); 535 | 536 | chaos = false; 537 | check(); 538 | }); 539 | 540 | it("supports options.keyArgs", function () { 541 | const sumNums = wrap((...args: any[]) => ({ 542 | sum: args.reduce( 543 | (sum, arg) => typeof arg === "number" ? arg + sum : sum, 544 | 0, 545 | ) as number, 546 | }), { 547 | keyArgs(...args) { 548 | return args.filter(arg => typeof arg === "number"); 549 | }, 550 | }); 551 | 552 | assert.strictEqual(sumNums().sum, 0); 553 | assert.strictEqual(sumNums("asdf", true, sumNums).sum, 0); 554 | 555 | const sumObj1 = sumNums(1, "zxcv", true, 2, false, 3); 556 | assert.strictEqual(sumObj1.sum, 6); 557 | // These results are === sumObj1 because the numbers involved are identical. 558 | assert.strictEqual(sumNums(1, 2, 3), sumObj1); 559 | assert.strictEqual(sumNums("qwer", 1, 2, true, 3, [3]), sumObj1); 560 | assert.strictEqual(sumNums("backwards", 3, 2, 1).sum, 6); 561 | assert.notStrictEqual(sumNums("backwards", 3, 2, 1), sumObj1); 562 | 563 | sumNums.dirty(1, 2, 3); 564 | const sumObj2 = sumNums(1, 2, 3); 565 | assert.strictEqual(sumObj2.sum, 6); 566 | assert.notStrictEqual(sumObj2, sumObj1); 567 | assert.strictEqual(sumNums("a", 1, "b", 2, "c", 3), sumObj2); 568 | }); 569 | 570 | it("supports wrap(fn, {...}).options to reflect input options", function () { 571 | const keyArgs: () => [] = () => []; 572 | function makeCacheKey() { return "constant"; } 573 | function subscribe() {} 574 | let normalizeCalls: [number, number][] = []; 575 | function normalizeResult(newer: number, older: number) { 576 | normalizeCalls.push([newer, older]); 577 | return newer; 578 | } 579 | 580 | let counter1 = 0; 581 | const wrapped = wrap(() => ++counter1, { 582 | max: 10, 583 | keyArgs, 584 | makeCacheKey, 585 | normalizeResult, 586 | subscribe, 587 | }); 588 | assert.strictEqual(wrapped.options.max, 10); 589 | assert.strictEqual(wrapped.options.keyArgs, keyArgs); 590 | assert.strictEqual(wrapped.options.makeCacheKey, makeCacheKey); 591 | assert.strictEqual(wrapped.options.normalizeResult, normalizeResult); 592 | assert.strictEqual(wrapped.options.subscribe, subscribe); 593 | 594 | assert.deepEqual(normalizeCalls, []); 595 | assert.strictEqual(wrapped(), 1); 596 | assert.deepEqual(normalizeCalls, []); 597 | assert.strictEqual(wrapped(), 1); 598 | assert.deepEqual(normalizeCalls, []); 599 | wrapped.dirty(); 600 | assert.deepEqual(normalizeCalls, []); 601 | assert.strictEqual(wrapped(), 2); 602 | assert.deepEqual(normalizeCalls, [[2, 1]]); 603 | assert.strictEqual(wrapped(), 2); 604 | wrapped.dirty(); 605 | assert.strictEqual(wrapped(), 3); 606 | assert.deepEqual(normalizeCalls, [[2, 1], [3, 2]]); 607 | assert.strictEqual(wrapped(), 3); 608 | assert.deepEqual(normalizeCalls, [[2, 1], [3, 2]]); 609 | assert.strictEqual(wrapped(), 3); 610 | 611 | let counter2 = 0; 612 | const wrappedWithDefaults = wrap(() => ++counter2); 613 | assert.strictEqual(wrappedWithDefaults.options.max, Math.pow(2, 16)); 614 | assert.strictEqual(wrappedWithDefaults.options.keyArgs, void 0); 615 | assert.strictEqual(typeof wrappedWithDefaults.options.makeCacheKey, "function"); 616 | assert.strictEqual(wrappedWithDefaults.options.normalizeResult, void 0); 617 | assert.strictEqual(wrappedWithDefaults.options.subscribe, void 0); 618 | }); 619 | 620 | it("tolerates cycles when propagating dirty/clean signals", function () { 621 | let counter = 0; 622 | const dep = wrap(() => ++counter); 623 | 624 | const callChild = () => child(); 625 | let parentBody = callChild; 626 | const parent = wrap(() => { 627 | dep(); 628 | return parentBody(); 629 | }); 630 | 631 | const callParent = () => parent(); 632 | let childBody = () => "child"; 633 | const child = wrap(() => { 634 | dep(); 635 | return childBody(); 636 | }); 637 | 638 | assert.strictEqual(parent(), "child"); 639 | 640 | childBody = callParent; 641 | parentBody = () => "parent"; 642 | child.dirty(); 643 | assert.strictEqual(child(), "parent"); 644 | dep.dirty(); 645 | assert.strictEqual(child(), "parent"); 646 | }); 647 | 648 | it("is not confused by eviction during recomputation", function () { 649 | const fib: OptimisticWrapperFunction<[number], number> = 650 | wrap(function (n: number) { 651 | if (n > 1) { 652 | return fib(n - 1) + fib(n - 2); 653 | } 654 | return n; 655 | }, { 656 | max: 10 657 | }); 658 | 659 | assert.strictEqual(fib.options.max, 10); 660 | 661 | assert.strictEqual(fib(78), 8944394323791464); 662 | assert.strictEqual(fib(68), 72723460248141); 663 | assert.strictEqual(fib(58), 591286729879); 664 | assert.strictEqual(fib(48), 4807526976); 665 | assert.strictEqual(fib(38), 39088169); 666 | assert.strictEqual(fib(28), 317811); 667 | assert.strictEqual(fib(18), 2584); 668 | assert.strictEqual(fib(8), 21); 669 | }); 670 | 671 | it("allows peeking the current value", function () { 672 | const sumFirst = wrap(function (n: number): number { 673 | return n < 1 ? 0 : n + sumFirst(n - 1); 674 | }); 675 | 676 | assert.strictEqual(sumFirst.peek(3), void 0); 677 | assert.strictEqual(sumFirst.peek(2), void 0); 678 | assert.strictEqual(sumFirst.peek(1), void 0); 679 | assert.strictEqual(sumFirst.peek(0), void 0); 680 | assert.strictEqual(sumFirst(3), 6); 681 | assert.strictEqual(sumFirst.peek(3), 6); 682 | assert.strictEqual(sumFirst.peek(2), 3); 683 | assert.strictEqual(sumFirst.peek(1), 1); 684 | assert.strictEqual(sumFirst.peek(0), 0); 685 | 686 | assert.strictEqual(sumFirst.peek(7), void 0); 687 | assert.strictEqual(sumFirst(10), 55); 688 | assert.strictEqual(sumFirst.peek(9), 55 - 10); 689 | assert.strictEqual(sumFirst.peek(8), 55 - 10 - 9); 690 | assert.strictEqual(sumFirst.peek(7), 55 - 10 - 9 - 8); 691 | 692 | sumFirst.dirty(7); 693 | // Everything from 7 and above is now unpeekable. 694 | assert.strictEqual(sumFirst.peek(10), void 0); 695 | assert.strictEqual(sumFirst.peek(9), void 0); 696 | assert.strictEqual(sumFirst.peek(8), void 0); 697 | assert.strictEqual(sumFirst.peek(7), void 0); 698 | // Since 6 < 7, its value is still cached. 699 | assert.strictEqual(sumFirst.peek(6), 6 * 7 / 2); 700 | }); 701 | 702 | it("allows forgetting entries", function () { 703 | const ns: number[] = []; 704 | const sumFirst = wrap(function (n: number): number { 705 | ns.push(n); 706 | return n < 1 ? 0 : n + sumFirst(n - 1); 707 | }); 708 | 709 | function inclusiveDescendingRange(n: number, limit = 0) { 710 | const range: number[] = []; 711 | while (n >= limit) range.push(n--); 712 | return range; 713 | } 714 | 715 | assert.strictEqual(sumFirst(10), 55); 716 | assert.deepStrictEqual(ns, inclusiveDescendingRange(10)); 717 | 718 | assert.strictEqual(sumFirst.forget(6), true); 719 | assert.strictEqual(sumFirst(4), 10); 720 | assert.deepStrictEqual(ns, inclusiveDescendingRange(10)); 721 | 722 | assert.strictEqual(sumFirst(11), 66); 723 | assert.deepStrictEqual(ns, [ 724 | ...inclusiveDescendingRange(10), 725 | ...inclusiveDescendingRange(11, 6), 726 | ]); 727 | 728 | assert.strictEqual(sumFirst.forget(3), true); 729 | assert.strictEqual(sumFirst(7), 28); 730 | assert.deepStrictEqual(ns, [ 731 | ...inclusiveDescendingRange(10), 732 | ...inclusiveDescendingRange(11, 6), 733 | ...inclusiveDescendingRange(7, 3), 734 | ]); 735 | 736 | assert.strictEqual(sumFirst.forget(123), false); 737 | assert.strictEqual(sumFirst.forget(-1), false); 738 | assert.strictEqual(sumFirst.forget("7" as any), false); 739 | assert.strictEqual((sumFirst.forget as any)(6, 4), false); 740 | }); 741 | 742 | it("allows forgetting entries by key", function () { 743 | const ns: number[] = []; 744 | const sumFirst = wrap(function (n: number): number { 745 | ns.push(n); 746 | return n < 1 ? 0 : n + sumFirst(n - 1); 747 | }, { 748 | makeCacheKey: function (x: number) { 749 | return x * 2; 750 | } 751 | }); 752 | 753 | assert.strictEqual(sumFirst.options.makeCacheKey!(7), 14); 754 | assert.strictEqual(sumFirst(10), 55); 755 | 756 | /* 757 | * Verify: 758 | * 1- Calling forgetKey will remove the entry. 759 | * 2- Calling forgetKey again will return false. 760 | * 3- Callling forget on the same entry will return false. 761 | */ 762 | assert.strictEqual(sumFirst.forgetKey(6 * 2), true); 763 | assert.strictEqual(sumFirst.forgetKey(6 * 2), false); 764 | assert.strictEqual(sumFirst.forget(6), false); 765 | 766 | /* 767 | * Verify: 768 | * 1- Calling forget will remove the entry. 769 | * 2- Calling forget again will return false. 770 | * 3- Callling forgetKey on the same entry will return false. 771 | */ 772 | assert.strictEqual(sumFirst.forget(7), true); 773 | assert.strictEqual(sumFirst.forget(7), false); 774 | assert.strictEqual(sumFirst.forgetKey(7 * 2), false); 775 | 776 | /* 777 | * Verify you can query an entry key. 778 | */ 779 | assert.strictEqual(sumFirst.getKey(9), 18); 780 | assert.strictEqual(sumFirst.forgetKey(sumFirst.getKey(9)), true); 781 | assert.strictEqual(sumFirst.forgetKey(sumFirst.getKey(9)), false); 782 | assert.strictEqual(sumFirst.forget(9), false); 783 | }); 784 | 785 | it("exposes optimistic.{size,options.cache.size} properties", function () { 786 | const d = dep(); 787 | const fib = wrap((n: number): number => { 788 | d("shared"); 789 | return n > 1 ? fib(n - 1) + fib(n - 2) : n; 790 | }, { 791 | makeCacheKey(n) { 792 | return n; 793 | }, 794 | }); 795 | 796 | function size() { 797 | assert.strictEqual(fib.options.cache.size, fib.size); 798 | return fib.size; 799 | } 800 | 801 | assert.strictEqual(size(), 0); 802 | 803 | assert.strictEqual(fib(0), 0); 804 | assert.strictEqual(fib(1), 1); 805 | assert.strictEqual(fib(2), 1); 806 | assert.strictEqual(fib(3), 2); 807 | assert.strictEqual(fib(4), 3); 808 | assert.strictEqual(fib(5), 5); 809 | assert.strictEqual(fib(6), 8); 810 | assert.strictEqual(fib(7), 13); 811 | assert.strictEqual(fib(8), 21); 812 | 813 | assert.strictEqual(size(), 9); 814 | 815 | fib.dirty(6); 816 | // Merely dirtying an Entry does not remove it from the LRU cache. 817 | assert.strictEqual(size(), 9); 818 | 819 | fib.forget(6); 820 | // Forgetting an Entry both dirties it and removes it from the LRU cache. 821 | assert.strictEqual(size(), 8); 822 | 823 | fib.forget(4); 824 | assert.strictEqual(size(), 7); 825 | 826 | // This way of calling d.dirty causes any parent Entry objects to be 827 | // forgotten (removed from the LRU cache). 828 | d.dirty("shared", "forget"); 829 | assert.strictEqual(size(), 0); 830 | }); 831 | 832 | describe("wrapOptions.normalizeResult", function () { 833 | it("can normalize array results", function () { 834 | const normalizeArgs: [number[], number[]][] = []; 835 | const range = wrap((n: number) => { 836 | let result = []; 837 | for (let i = 0; i < n; ++i) { 838 | result[i] = i; 839 | } 840 | return result; 841 | }, { 842 | normalizeResult(newer, older) { 843 | normalizeArgs.push([newer, older]); 844 | return equal(newer, older) ? older : newer; 845 | }, 846 | }); 847 | 848 | const r3a = range(3); 849 | assert.deepStrictEqual(r3a, [0, 1, 2]); 850 | // Nothing surprising, just regular caching. 851 | assert.strictEqual(r3a, range(3)); 852 | 853 | // Force range(3) to be recomputed below. 854 | range.dirty(3); 855 | 856 | const r3b = range(3); 857 | assert.deepStrictEqual(r3b, [0, 1, 2]); 858 | 859 | assert.strictEqual(r3a, r3b); 860 | 861 | assert.deepStrictEqual(normalizeArgs, [ 862 | [r3b, r3a], 863 | ]); 864 | // Though r3a and r3b ended up ===, the normalizeResult callback should 865 | // have been called with two !== arrays. 866 | assert.notStrictEqual( 867 | normalizeArgs[0][0], 868 | normalizeArgs[0][1], 869 | ); 870 | }); 871 | 872 | it("can normalize recursive array results", function () { 873 | const range = wrap((n: number): number[] => { 874 | if (n <= 0) return []; 875 | return range(n - 1).concat(n - 1); 876 | }, { 877 | normalizeResult: (newer, older) => equal(newer, older) ? older : newer, 878 | }); 879 | 880 | const ranges = [ 881 | range(0), 882 | range(1), 883 | range(2), 884 | range(3), 885 | range(4), 886 | ]; 887 | 888 | assert.deepStrictEqual(ranges[0], []); 889 | assert.deepStrictEqual(ranges[1], [0]); 890 | assert.deepStrictEqual(ranges[2], [0, 1]); 891 | assert.deepStrictEqual(ranges[3], [0, 1, 2]); 892 | assert.deepStrictEqual(ranges[4], [0, 1, 2, 3]); 893 | 894 | const perms = permutations(ranges[4]); 895 | assert.strictEqual(perms.length, 4 * 3 * 2 * 1); 896 | 897 | // For each permutation of the range sizes, check that strict equality 898 | // holds for r[i] and range(i) for all i after dirtying each number. 899 | let count = 0; 900 | perms.forEach(perm => { 901 | perm.forEach(toDirty => { 902 | range.dirty(toDirty); 903 | perm.forEach(i => { 904 | assert.strictEqual(ranges[i], range(i)); 905 | ++count; 906 | }); 907 | }) 908 | }); 909 | assert.strictEqual(count, perms.length * 4 * 4); 910 | }); 911 | 912 | it("exceptions thrown by normalizeResult are ignored", function () { 913 | const normalizeCalls: [string | number, string | number][] = []; 914 | 915 | const maybeThrow = wrap((value: string | number, shouldThrow: boolean) => { 916 | if (shouldThrow) throw value; 917 | return value; 918 | }, { 919 | makeCacheKey(value, shouldThrow) { 920 | return JSON.stringify({ 921 | // Coerce the value to a string so we can trigger normalizeResult 922 | // using either 2 or "2" below. 923 | value: String(value), 924 | shouldThrow, 925 | }); 926 | }, 927 | normalizeResult(a, b) { 928 | normalizeCalls.push([a, b]); 929 | throw new Error("from normalizeResult (expected)"); 930 | }, 931 | }); 932 | 933 | assert.strictEqual(maybeThrow(1, false), 1); 934 | assert.strictEqual(maybeThrow(2, false), 2); 935 | 936 | maybeThrow.dirty(2, false); 937 | assert.strictEqual(maybeThrow("2", false), "2"); 938 | assert.strictEqual(maybeThrow(2, false), "2"); 939 | maybeThrow.dirty(2, false); 940 | assert.strictEqual(maybeThrow(2, false), 2); 941 | assert.strictEqual(maybeThrow("2", false), 2); 942 | 943 | assert.throws( 944 | () => maybeThrow(3, true), 945 | error => error === 3, 946 | ); 947 | 948 | assert.throws( 949 | () => maybeThrow("3", true), 950 | // Still 3 because the previous maybeThrow(3, true) exception is cached. 951 | error => error === 3, 952 | ); 953 | 954 | maybeThrow.dirty(3, true); 955 | assert.throws( 956 | () => maybeThrow("3", true), 957 | error => error === "3", 958 | ); 959 | 960 | // Even though the exception thrown by normalizeResult was ignored, check 961 | // that it was in fact called (twice). 962 | assert.deepStrictEqual(normalizeCalls, [ 963 | ["2", 2], 964 | [2, "2"], 965 | ]); 966 | }); 967 | }); 968 | }); 969 | --------------------------------------------------------------------------------