@tldraw/utils 4.1.0-next.b6dfe9bccde9 → 4.1.0-next.b73a0d46b63f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/dist-cjs/index.d.ts +1350 -80
  2. package/dist-cjs/index.js +5 -5
  3. package/dist-cjs/lib/ExecutionQueue.js +79 -0
  4. package/dist-cjs/lib/ExecutionQueue.js.map +2 -2
  5. package/dist-cjs/lib/PerformanceTracker.js +43 -0
  6. package/dist-cjs/lib/PerformanceTracker.js.map +2 -2
  7. package/dist-cjs/lib/array.js +3 -1
  8. package/dist-cjs/lib/array.js.map +2 -2
  9. package/dist-cjs/lib/bind.js.map +2 -2
  10. package/dist-cjs/lib/cache.js +27 -5
  11. package/dist-cjs/lib/cache.js.map +2 -2
  12. package/dist-cjs/lib/control.js +12 -0
  13. package/dist-cjs/lib/control.js.map +2 -2
  14. package/dist-cjs/lib/debounce.js.map +2 -2
  15. package/dist-cjs/lib/error.js.map +2 -2
  16. package/dist-cjs/lib/file.js +76 -11
  17. package/dist-cjs/lib/file.js.map +2 -2
  18. package/dist-cjs/lib/function.js.map +2 -2
  19. package/dist-cjs/lib/hash.js.map +2 -2
  20. package/dist-cjs/lib/id.js.map +2 -2
  21. package/dist-cjs/lib/iterable.js.map +2 -2
  22. package/dist-cjs/lib/json-value.js.map +1 -1
  23. package/dist-cjs/lib/media/apng.js.map +2 -2
  24. package/dist-cjs/lib/media/avif.js.map +2 -2
  25. package/dist-cjs/lib/media/gif.js.map +2 -2
  26. package/dist-cjs/lib/media/media.js +130 -4
  27. package/dist-cjs/lib/media/media.js.map +2 -2
  28. package/dist-cjs/lib/media/png.js +141 -0
  29. package/dist-cjs/lib/media/png.js.map +2 -2
  30. package/dist-cjs/lib/media/webp.js +1 -0
  31. package/dist-cjs/lib/media/webp.js.map +2 -2
  32. package/dist-cjs/lib/network.js.map +2 -2
  33. package/dist-cjs/lib/number.js.map +2 -2
  34. package/dist-cjs/lib/object.js +1 -1
  35. package/dist-cjs/lib/object.js.map +2 -2
  36. package/dist-cjs/lib/perf.js.map +2 -2
  37. package/dist-cjs/lib/reordering.js.map +2 -2
  38. package/dist-cjs/lib/retry.js.map +2 -2
  39. package/dist-cjs/lib/sort.js.map +2 -2
  40. package/dist-cjs/lib/storage.js.map +2 -2
  41. package/dist-cjs/lib/stringEnum.js.map +2 -2
  42. package/dist-cjs/lib/throttle.js.map +2 -2
  43. package/dist-cjs/lib/timers.js +103 -4
  44. package/dist-cjs/lib/timers.js.map +2 -2
  45. package/dist-cjs/lib/types.js.map +1 -1
  46. package/dist-cjs/lib/url.js.map +2 -2
  47. package/dist-cjs/lib/value.js.map +2 -2
  48. package/dist-cjs/lib/version.js.map +2 -2
  49. package/dist-cjs/lib/warn.js.map +2 -2
  50. package/dist-esm/index.d.mts +1350 -80
  51. package/dist-esm/index.mjs +1 -1
  52. package/dist-esm/lib/ExecutionQueue.mjs +79 -0
  53. package/dist-esm/lib/ExecutionQueue.mjs.map +2 -2
  54. package/dist-esm/lib/PerformanceTracker.mjs +43 -0
  55. package/dist-esm/lib/PerformanceTracker.mjs.map +2 -2
  56. package/dist-esm/lib/array.mjs +3 -1
  57. package/dist-esm/lib/array.mjs.map +2 -2
  58. package/dist-esm/lib/bind.mjs.map +2 -2
  59. package/dist-esm/lib/cache.mjs +27 -5
  60. package/dist-esm/lib/cache.mjs.map +2 -2
  61. package/dist-esm/lib/control.mjs +12 -0
  62. package/dist-esm/lib/control.mjs.map +2 -2
  63. package/dist-esm/lib/debounce.mjs.map +2 -2
  64. package/dist-esm/lib/error.mjs.map +2 -2
  65. package/dist-esm/lib/file.mjs +76 -11
  66. package/dist-esm/lib/file.mjs.map +2 -2
  67. package/dist-esm/lib/function.mjs.map +2 -2
  68. package/dist-esm/lib/hash.mjs.map +2 -2
  69. package/dist-esm/lib/id.mjs.map +2 -2
  70. package/dist-esm/lib/iterable.mjs.map +2 -2
  71. package/dist-esm/lib/media/apng.mjs.map +2 -2
  72. package/dist-esm/lib/media/avif.mjs.map +2 -2
  73. package/dist-esm/lib/media/gif.mjs.map +2 -2
  74. package/dist-esm/lib/media/media.mjs +130 -4
  75. package/dist-esm/lib/media/media.mjs.map +2 -2
  76. package/dist-esm/lib/media/png.mjs +141 -0
  77. package/dist-esm/lib/media/png.mjs.map +2 -2
  78. package/dist-esm/lib/media/webp.mjs +1 -0
  79. package/dist-esm/lib/media/webp.mjs.map +2 -2
  80. package/dist-esm/lib/network.mjs.map +2 -2
  81. package/dist-esm/lib/number.mjs.map +2 -2
  82. package/dist-esm/lib/object.mjs.map +2 -2
  83. package/dist-esm/lib/perf.mjs.map +2 -2
  84. package/dist-esm/lib/reordering.mjs.map +2 -2
  85. package/dist-esm/lib/retry.mjs.map +2 -2
  86. package/dist-esm/lib/sort.mjs.map +2 -2
  87. package/dist-esm/lib/storage.mjs.map +2 -2
  88. package/dist-esm/lib/stringEnum.mjs.map +2 -2
  89. package/dist-esm/lib/throttle.mjs.map +2 -2
  90. package/dist-esm/lib/timers.mjs +103 -4
  91. package/dist-esm/lib/timers.mjs.map +2 -2
  92. package/dist-esm/lib/url.mjs.map +2 -2
  93. package/dist-esm/lib/value.mjs.map +2 -2
  94. package/dist-esm/lib/version.mjs.map +2 -2
  95. package/dist-esm/lib/warn.mjs.map +2 -2
  96. package/package.json +1 -1
  97. package/src/lib/ExecutionQueue.test.ts +162 -20
  98. package/src/lib/ExecutionQueue.ts +110 -1
  99. package/src/lib/PerformanceTracker.test.ts +124 -0
  100. package/src/lib/PerformanceTracker.ts +63 -1
  101. package/src/lib/array.test.ts +263 -1
  102. package/src/lib/array.ts +183 -14
  103. package/src/lib/bind.test.ts +47 -0
  104. package/src/lib/bind.ts +69 -4
  105. package/src/lib/cache.test.ts +73 -0
  106. package/src/lib/cache.ts +47 -6
  107. package/src/lib/control.test.ts +50 -0
  108. package/src/lib/control.ts +198 -9
  109. package/src/lib/debounce.ts +28 -3
  110. package/src/lib/error.test.ts +60 -0
  111. package/src/lib/error.ts +27 -1
  112. package/src/lib/file.test.ts +49 -0
  113. package/src/lib/file.ts +117 -12
  114. package/src/lib/function.ts +11 -0
  115. package/src/lib/hash.test.ts +99 -0
  116. package/src/lib/hash.ts +69 -2
  117. package/src/lib/id.test.ts +32 -0
  118. package/src/lib/id.ts +53 -5
  119. package/src/lib/iterable.test.ts +25 -0
  120. package/src/lib/iterable.ts +4 -5
  121. package/src/lib/json-value.ts +71 -4
  122. package/src/lib/media/apng.test.ts +67 -0
  123. package/src/lib/media/apng.ts +38 -21
  124. package/src/lib/media/avif.test.ts +26 -0
  125. package/src/lib/media/avif.ts +34 -0
  126. package/src/lib/media/gif.test.ts +52 -0
  127. package/src/lib/media/gif.ts +25 -2
  128. package/src/lib/media/media.test.ts +58 -0
  129. package/src/lib/media/media.ts +220 -11
  130. package/src/lib/media/png.ts +162 -1
  131. package/src/lib/media/webp.test.ts +81 -0
  132. package/src/lib/media/webp.ts +33 -1
  133. package/src/lib/network.test.ts +38 -0
  134. package/src/lib/network.ts +6 -0
  135. package/src/lib/number.test.ts +74 -0
  136. package/src/lib/number.ts +29 -5
  137. package/src/lib/object.test.ts +236 -0
  138. package/src/lib/object.ts +194 -14
  139. package/src/lib/perf.ts +75 -3
  140. package/src/lib/reordering.test.ts +168 -0
  141. package/src/lib/reordering.ts +62 -4
  142. package/src/lib/retry.test.ts +77 -0
  143. package/src/lib/retry.ts +47 -1
  144. package/src/lib/sort.test.ts +36 -0
  145. package/src/lib/sort.ts +22 -1
  146. package/src/lib/storage.test.ts +130 -0
  147. package/src/lib/storage.tsx +54 -8
  148. package/src/lib/stringEnum.ts +20 -1
  149. package/src/lib/throttle.ts +46 -8
  150. package/src/lib/timers.test.ts +75 -0
  151. package/src/lib/timers.ts +124 -5
  152. package/src/lib/types.ts +126 -4
  153. package/src/lib/url.test.ts +44 -0
  154. package/src/lib/url.ts +40 -1
  155. package/src/lib/value.test.ts +102 -0
  156. package/src/lib/value.ts +67 -3
  157. package/src/lib/version.test.ts +494 -56
  158. package/src/lib/version.ts +36 -1
  159. package/src/lib/warn.test.ts +64 -0
  160. package/src/lib/warn.ts +43 -2
package/src/lib/array.ts CHANGED
@@ -1,15 +1,52 @@
1
1
  /**
2
- * Rotate the contents of an array.
2
+ * Rotate the contents of an array by a specified offset.
3
3
  *
4
+ * Creates a new array with elements shifted to the left by the specified number of positions.
5
+ * Both positive and negative offsets result in left shifts (elements move left, with elements
6
+ * from the front wrapping to the back).
7
+ *
8
+ * @param arr - The array to rotate
9
+ * @param offset - The number of positions to shift left (both positive and negative values shift left)
10
+ * @returns A new array with elements shifted left by the specified offset
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * rotateArray([1, 2, 3, 4], 1) // [2, 3, 4, 1]
15
+ * rotateArray([1, 2, 3, 4], -1) // [2, 3, 4, 1]
16
+ * rotateArray(['a', 'b', 'c'], 2) // ['c', 'a', 'b']
17
+ * ```
4
18
  * @public
5
19
  */
6
20
  export function rotateArray<T>(arr: T[], offset: number): T[] {
7
- return arr.map((_, i) => arr[(i + offset) % arr.length])
21
+ if (arr.length === 0) return []
22
+
23
+ // Based on the test expectations, both positive and negative offsets
24
+ // should rotate left (shift elements to the left)
25
+ const normalizedOffset = ((Math.abs(offset) % arr.length) + arr.length) % arr.length
26
+
27
+ // Slice the array at the offset point and concatenate
28
+ return [...arr.slice(normalizedOffset), ...arr.slice(0, normalizedOffset)]
8
29
  }
9
30
 
10
31
  /**
11
- * Deduplicate the items in an array
32
+ * Remove duplicate items from an array.
33
+ *
34
+ * Creates a new array with duplicate items removed. Uses strict equality by default,
35
+ * or a custom equality function if provided. Order of first occurrence is preserved.
36
+ *
37
+ * @param input - The array to deduplicate
38
+ * @param equals - Optional custom equality function to compare items (defaults to strict equality)
39
+ * @returns A new array with duplicate items removed
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * dedupe([1, 2, 2, 3, 1]) // [1, 2, 3]
44
+ * dedupe(['a', 'b', 'a', 'c']) // ['a', 'b', 'c']
12
45
  *
46
+ * // With custom equality function
47
+ * const objects = [{id: 1}, {id: 2}, {id: 1}]
48
+ * dedupe(objects, (a, b) => a.id === b.id) // [{id: 1}, {id: 2}]
49
+ * ```
13
50
  * @public
14
51
  */
15
52
  export function dedupe<T>(input: T[], equals?: (a: any, b: any) => boolean): T[] {
@@ -25,17 +62,67 @@ export function dedupe<T>(input: T[], equals?: (a: any, b: any) => boolean): T[]
25
62
  return result
26
63
  }
27
64
 
28
- /** @internal */
65
+ /**
66
+ * Remove null and undefined values from an array.
67
+ *
68
+ * Creates a new array with all null and undefined values filtered out.
69
+ * The resulting array has a refined type that excludes null and undefined.
70
+ *
71
+ * @param arr - The array to compact
72
+ * @returns A new array with null and undefined values removed
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * compact([1, null, 2, undefined, 3]) // [1, 2, 3]
77
+ * compact(['a', null, 'b', undefined]) // ['a', 'b']
78
+ * ```
79
+ * @internal
80
+ */
29
81
  export function compact<T>(arr: T[]): NonNullable<T>[] {
30
82
  return arr.filter((i) => i !== undefined && i !== null) as any
31
83
  }
32
84
 
33
- /** @internal */
85
+ /**
86
+ * Get the last element of an array.
87
+ *
88
+ * Returns the last element of an array, or undefined if the array is empty.
89
+ * Works with readonly arrays and preserves the element type.
90
+ *
91
+ * @param arr - The array to get the last element from
92
+ * @returns The last element of the array, or undefined if the array is empty
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * last([1, 2, 3]) // 3
97
+ * last(['a', 'b', 'c']) // 'c'
98
+ * last([]) // undefined
99
+ * ```
100
+ * @internal
101
+ */
34
102
  export function last<T>(arr: readonly T[]): T | undefined {
35
103
  return arr[arr.length - 1]
36
104
  }
37
105
 
38
- /** @internal */
106
+ /**
107
+ * Find the item in an array with the minimum value according to a function.
108
+ *
109
+ * Finds the array item that produces the smallest value when passed through
110
+ * the provided function. Returns undefined for empty arrays.
111
+ *
112
+ * @param arr - The array to search
113
+ * @param fn - Function to compute the comparison value for each item
114
+ * @returns The item with the minimum value, or undefined if the array is empty
115
+ *
116
+ * @example
117
+ * ```ts
118
+ * const people = [{name: 'Alice', age: 30}, {name: 'Bob', age: 25}]
119
+ * minBy(people, p => p.age) // {name: 'Bob', age: 25}
120
+ *
121
+ * minBy([3, 1, 4, 1, 5], x => x) // 1
122
+ * minBy([], x => x) // undefined
123
+ * ```
124
+ * @internal
125
+ */
39
126
  export function minBy<T>(arr: readonly T[], fn: (item: T) => number): T | undefined {
40
127
  let min: T | undefined
41
128
  let minVal = Infinity
@@ -49,7 +136,26 @@ export function minBy<T>(arr: readonly T[], fn: (item: T) => number): T | undefi
49
136
  return min
50
137
  }
51
138
 
52
- /** @internal */
139
+ /**
140
+ * Find the item in an array with the maximum value according to a function.
141
+ *
142
+ * Finds the array item that produces the largest value when passed through
143
+ * the provided function. Returns undefined for empty arrays.
144
+ *
145
+ * @param arr - The array to search
146
+ * @param fn - Function to compute the comparison value for each item
147
+ * @returns The item with the maximum value, or undefined if the array is empty
148
+ *
149
+ * @example
150
+ * ```ts
151
+ * const people = [{name: 'Alice', age: 30}, {name: 'Bob', age: 25}]
152
+ * maxBy(people, p => p.age) // {name: 'Alice', age: 30}
153
+ *
154
+ * maxBy([3, 1, 4, 1, 5], x => x) // 5
155
+ * maxBy([], x => x) // undefined
156
+ * ```
157
+ * @internal
158
+ */
53
159
  export function maxBy<T>(arr: readonly T[], fn: (item: T) => number): T | undefined {
54
160
  let max: T | undefined
55
161
  let maxVal: number = -Infinity
@@ -64,13 +170,26 @@ export function maxBy<T>(arr: readonly T[], fn: (item: T) => number): T | undefi
64
170
  }
65
171
 
66
172
  /**
67
- * Partitions an array into two arrays, one with items that satisfy the predicate, and one with
68
- * items that do not.
173
+ * Split an array into two arrays based on a predicate function.
174
+ *
175
+ * Partitions an array into two arrays: one containing items that satisfy
176
+ * the predicate, and another containing items that do not. The original array order is preserved.
69
177
  *
70
178
  * @param arr - The array to partition
71
- * @param predicate - The predicate to use to partition the array
72
- * @returns A tuple of two arrays, the first one with items that satisfy the predicate and the
73
- * second one with the ones that dont
179
+ * @param predicate - The predicate function to test each item
180
+ * @returns A tuple of two arrays: [satisfying items, non-satisfying items]
181
+ *
182
+ * @example
183
+ * ```ts
184
+ * const [evens, odds] = partition([1, 2, 3, 4, 5], x => x % 2 === 0)
185
+ * // evens: [2, 4], odds: [1, 3, 5]
186
+ *
187
+ * const [adults, minors] = partition(
188
+ * [{name: 'Alice', age: 30}, {name: 'Bob', age: 17}],
189
+ * person => person.age >= 18
190
+ * )
191
+ * // adults: [{name: 'Alice', age: 30}], minors: [{name: 'Bob', age: 17}]
192
+ * ```
74
193
  * @internal
75
194
  */
76
195
  export function partition<T>(arr: T[], predicate: (item: T) => boolean): [T[], T[]] {
@@ -86,7 +205,30 @@ export function partition<T>(arr: T[], predicate: (item: T) => boolean): [T[], T
86
205
  return [satisfies, doesNotSatisfy]
87
206
  }
88
207
 
89
- /** @internal */
208
+ /**
209
+ * Check if two arrays are shallow equal.
210
+ *
211
+ * Compares two arrays for shallow equality by checking if they have the same length
212
+ * and the same elements at each index using Object.is comparison. Returns true if arrays are
213
+ * the same reference, have different lengths, or any elements differ.
214
+ *
215
+ * @param arr1 - First array to compare
216
+ * @param arr2 - Second array to compare
217
+ * @returns True if arrays are shallow equal, false otherwise
218
+ *
219
+ * @example
220
+ * ```ts
221
+ * areArraysShallowEqual([1, 2, 3], [1, 2, 3]) // true
222
+ * areArraysShallowEqual([1, 2, 3], [1, 2, 4]) // false
223
+ * areArraysShallowEqual(['a', 'b'], ['a', 'b']) // true
224
+ * areArraysShallowEqual([1, 2], [1, 2, 3]) // false
225
+ *
226
+ * const obj = {x: 1}
227
+ * areArraysShallowEqual([obj], [obj]) // true (same reference)
228
+ * areArraysShallowEqual([{x: 1}], [{x: 1}]) // false (different objects)
229
+ * ```
230
+ * @internal
231
+ */
90
232
  export function areArraysShallowEqual<T>(arr1: readonly T[], arr2: readonly T[]): boolean {
91
233
  if (arr1 === arr2) return true
92
234
  if (arr1.length !== arr2.length) return false
@@ -98,7 +240,34 @@ export function areArraysShallowEqual<T>(arr1: readonly T[], arr2: readonly T[])
98
240
  return true
99
241
  }
100
242
 
101
- /** @internal */
243
+ /**
244
+ * Merge custom entries with defaults, replacing defaults that have matching keys.
245
+ *
246
+ * Combines two arrays by keeping all custom entries and only the default entries
247
+ * that don't have a matching key in the custom entries. Custom entries always override defaults.
248
+ * The result contains remaining defaults first, followed by all custom entries.
249
+ *
250
+ * @param key - The property name to use as the unique identifier
251
+ * @param customEntries - Array of custom entries that will override defaults
252
+ * @param defaults - Array of default entries
253
+ * @returns A new array with defaults filtered out where custom entries exist, plus all custom entries
254
+ *
255
+ * @example
256
+ * ```ts
257
+ * const defaults = [{type: 'text', value: 'default'}, {type: 'number', value: 0}]
258
+ * const custom = [{type: 'text', value: 'custom'}]
259
+ *
260
+ * mergeArraysAndReplaceDefaults('type', custom, defaults)
261
+ * // Result: [{type: 'number', value: 0}, {type: 'text', value: 'custom'}]
262
+ *
263
+ * const tools = [{id: 'select', name: 'Select'}, {id: 'draw', name: 'Draw'}]
264
+ * const customTools = [{id: 'select', name: 'Custom Select'}]
265
+ *
266
+ * mergeArraysAndReplaceDefaults('id', customTools, tools)
267
+ * // Result: [{id: 'draw', name: 'Draw'}, {id: 'select', name: 'Custom Select'}]
268
+ * ```
269
+ * @internal
270
+ */
102
271
  export function mergeArraysAndReplaceDefaults<
103
272
  const Key extends string,
104
273
  T extends { [K in Key]: string },
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { bind } from './bind'
3
+
4
+ describe('bind decorator', () => {
5
+ describe('legacy TypeScript decorator format', () => {
6
+ it('should maintain method binding when extracted', () => {
7
+ class TestClass {
8
+ value = 42
9
+
10
+ @bind
11
+ getValue() {
12
+ return this.value
13
+ }
14
+ }
15
+
16
+ const instance = new TestClass()
17
+ const { getValue } = instance
18
+
19
+ expect(getValue()).toBe(42)
20
+ })
21
+
22
+ it('should work with methods that modify instance state', () => {
23
+ class TestClass {
24
+ count = 0
25
+
26
+ @bind
27
+ increment() {
28
+ this.count++
29
+ return this.count
30
+ }
31
+ }
32
+
33
+ const instance = new TestClass()
34
+ const { increment } = instance
35
+
36
+ expect(increment()).toBe(1)
37
+ expect(increment()).toBe(2)
38
+ expect(instance.count).toBe(2)
39
+ })
40
+
41
+ it('should throw error when decorating non-method', () => {
42
+ expect(() => {
43
+ bind({}, 'notAMethod', { value: 'not a function', configurable: true } as any)
44
+ }).toThrow('Only methods can be decorated with @bind. <notAMethod> is not a method!')
45
+ })
46
+ })
47
+ })
package/src/lib/bind.ts CHANGED
@@ -6,9 +6,29 @@
6
6
  import { assert } from './control'
7
7
 
8
8
  /**
9
- * `@bind` is a decorator that binds the method to the instance of the class (legacy stage-2
10
- * typescript decorators).
9
+ * Decorator that binds a method to its class instance (legacy stage-2 TypeScript decorators).
10
+ * When applied to a class method, ensures `this` always refers to the class instance,
11
+ * even when the method is called as a callback or event handler.
11
12
  *
13
+ * @param target - The prototype of the class being decorated
14
+ * @param propertyKey - The name of the method being decorated
15
+ * @param descriptor - The property descriptor for the method being decorated
16
+ * @returns The modified property descriptor with bound method access
17
+ * @example
18
+ * ```typescript
19
+ * class MyClass {
20
+ * name = 'example';
21
+ *
22
+ * @bind
23
+ * getName() {
24
+ * return this.name;
25
+ * }
26
+ * }
27
+ *
28
+ * const instance = new MyClass();
29
+ * const callback = instance.getName;
30
+ * console.log(callback()); // 'example' (this is properly bound)
31
+ * ```
12
32
  * @public
13
33
  */
14
34
  export function bind<T extends (...args: any[]) => any>(
@@ -18,8 +38,26 @@ export function bind<T extends (...args: any[]) => any>(
18
38
  ): TypedPropertyDescriptor<T>
19
39
 
20
40
  /**
21
- * `@bind` is a decorator that binds the method to the instance of the class (TC39 decorators).
41
+ * Decorator that binds a method to its class instance (TC39 decorators standard).
42
+ * When applied to a class method, ensures `this` always refers to the class instance,
43
+ * even when the method is called as a callback or event handler.
44
+ *
45
+ * @param originalMethod - The original method being decorated
46
+ * @param context - The decorator context containing metadata about the method
47
+ * @example
48
+ * ```typescript
49
+ * class EventHandler {
50
+ * message = 'Hello World';
22
51
  *
52
+ * @bind
53
+ * handleClick() {
54
+ * console.log(this.message);
55
+ * }
56
+ * }
57
+ *
58
+ * const handler = new EventHandler();
59
+ * document.addEventListener('click', handler.handleClick); // 'this' is properly bound
60
+ * ```
23
61
  * @public
24
62
  */
25
63
  export function bind<This extends object, T extends (...args: any[]) => any>(
@@ -27,7 +65,34 @@ export function bind<This extends object, T extends (...args: any[]) => any>(
27
65
  context: ClassMethodDecoratorContext<This, T>
28
66
  ): void
29
67
 
30
- /** @public */
68
+ /**
69
+ * Universal decorator implementation that handles both legacy stage-2 and TC39 decorator formats.
70
+ * Automatically detects the decorator format based on the number of arguments and binds the
71
+ * decorated method to the class instance, preventing common `this` context issues.
72
+ *
73
+ * @param args - Either legacy decorator arguments (target, propertyKey, descriptor) or TC39 decorator arguments (originalMethod, context)
74
+ * @returns Property descriptor for legacy decorators, or void for TC39 decorators
75
+ * @example
76
+ * ```typescript
77
+ * // Works with both decorator formats
78
+ * class Calculator {
79
+ * multiplier = 2;
80
+ *
81
+ * @bind
82
+ * multiply(value: number) {
83
+ * return value * this.multiplier;
84
+ * }
85
+ * }
86
+ *
87
+ * const calc = new Calculator();
88
+ * const multiplyFn = calc.multiply;
89
+ * console.log(multiplyFn(5)); // 10 (this.multiplier is accessible)
90
+ *
91
+ * // Useful for event handlers and callbacks
92
+ * setTimeout(calc.multiply, 100, 3); // 6
93
+ * ```
94
+ * @public
95
+ */
31
96
  export function bind(
32
97
  ...args: // legacy stage-2 typescript decorators
33
98
  | [_target: object, propertyKey: string, descriptor: PropertyDescriptor]
@@ -0,0 +1,73 @@
1
+ import { vi } from 'vitest'
2
+ import { WeakCache } from './cache'
3
+
4
+ describe('WeakCache', () => {
5
+ it('should compute and cache value on first call, return cached value on subsequent calls', () => {
6
+ const cache = new WeakCache<{ id: number }, string>()
7
+ const key = { id: 1 }
8
+ const callback = vi.fn((item: { id: number }) => `value-${item.id}`)
9
+
10
+ const result1 = cache.get(key, callback)
11
+ const result2 = cache.get(key, callback)
12
+
13
+ expect(result1).toBe('value-1')
14
+ expect(result2).toBe('value-1')
15
+ expect(result1).toBe(result2) // Same reference
16
+ expect(callback).toHaveBeenCalledTimes(1)
17
+ })
18
+
19
+ it('should handle different callbacks for the same key', () => {
20
+ const cache = new WeakCache<{ id: number }, string>()
21
+ const key = { id: 1 }
22
+ const callback1 = vi.fn(() => 'first-computation')
23
+ const callback2 = vi.fn(() => 'second-computation')
24
+
25
+ const result1 = cache.get(key, callback1)
26
+ const result2 = cache.get(key, callback2)
27
+
28
+ expect(result1).toBe('first-computation')
29
+ expect(result2).toBe('first-computation')
30
+ expect(callback1).toHaveBeenCalledTimes(1)
31
+ expect(callback2).not.toHaveBeenCalled()
32
+ })
33
+
34
+ it('should maintain separate cache entries for different object references', () => {
35
+ const cache = new WeakCache<{ id: number }, string>()
36
+ const key1 = { id: 1 }
37
+ const key2 = { id: 1 } // Different object with same properties
38
+ const callback = vi.fn((item: { id: number }) => `value-${item.id}`)
39
+
40
+ const _result1 = cache.get(key1, callback)
41
+ const _result2 = cache.get(key2, callback)
42
+
43
+ expect(callback).toHaveBeenCalledTimes(2)
44
+ })
45
+
46
+ it('should handle null and undefined return values', () => {
47
+ const cache = new WeakCache<object, string | null | undefined>()
48
+ const key1 = { type: 'null' }
49
+ const key2 = { type: 'undefined' }
50
+
51
+ const result1 = cache.get(key1, () => null)
52
+ const result2 = cache.get(key2, () => undefined)
53
+ const result3 = cache.get(key1, () => 'should-not-be-called')
54
+ const result4 = cache.get(key2, () => 'should-not-be-called')
55
+
56
+ expect(result1).toBe(null)
57
+ expect(result2).toBe(undefined)
58
+ expect(result3).toBe(null)
59
+ expect(result4).toBe(undefined)
60
+ })
61
+
62
+ it('should not cache errors - callbacks that throw should be re-executed', () => {
63
+ const cache = new WeakCache<object, string>()
64
+ const key = { id: 1 }
65
+ const errorCallback = vi.fn(() => {
66
+ throw new Error('Computation failed')
67
+ })
68
+
69
+ expect(() => cache.get(key, errorCallback)).toThrow('Computation failed')
70
+ expect(() => cache.get(key, errorCallback)).toThrow('Computation failed')
71
+ expect(errorCallback).toHaveBeenCalledTimes(2)
72
+ })
73
+ })
package/src/lib/cache.ts CHANGED
@@ -1,17 +1,58 @@
1
1
  /**
2
- * A micro cache used when storing records in memory (using a WeakMap).
2
+ * A lightweight cache implementation using WeakMap for storing key-value pairs.
3
+ *
4
+ * A micro cache that stores computed values associated with object keys.
5
+ * Uses WeakMap internally, which means keys can be garbage collected when no other
6
+ * references exist, and only object keys are supported. Provides lazy computation
7
+ * with memoization.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * const cache = new WeakCache<User, string>()
12
+ * const user = { id: 1, name: 'Alice' }
13
+ *
14
+ * // Get cached value, computing it if not present
15
+ * const displayName = cache.get(user, (u) => `${u.name} (#${u.id})`)
16
+ * // Returns 'Alice (#1)'
17
+ *
18
+ * // Subsequent calls return cached value
19
+ * const sameName = cache.get(user, (u) => `${u.name} (#${u.id})`)
20
+ * // Returns 'Alice (#1)' without recomputing
21
+ * ```
3
22
  * @public
4
23
  */
5
24
  export class WeakCache<K extends object, V> {
6
- /** The map of items to their cached values. */
25
+ /**
26
+ * The internal WeakMap storage for cached key-value pairs.
27
+ *
28
+ * @public
29
+ */
7
30
  items = new WeakMap<K, V>()
8
31
 
9
32
  /**
10
- * Get the cached value for a given record. If the record is not present in the map, the callback
11
- * will be used to create the value (with the result being stored in the cache for next time).
33
+ * Get the cached value for a given key, computing it if not already cached.
34
+ *
35
+ * Retrieves the cached value associated with the given key. If no cached
36
+ * value exists, calls the provided callback function to compute the value, stores it
37
+ * in the cache, and returns it. Subsequent calls with the same key will return the
38
+ * cached value without recomputation.
39
+ *
40
+ * @param item - The object key to retrieve the cached value for
41
+ * @param cb - Callback function that computes the value when not already cached
42
+ * @returns The cached value if it exists, otherwise the newly computed value from the callback
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * const cache = new WeakCache<HTMLElement, DOMRect>()
47
+ * const element = document.getElementById('my-element')!
48
+ *
49
+ * // First call computes and caches the bounding rect
50
+ * const rect1 = cache.get(element, (el) => el.getBoundingClientRect())
12
51
  *
13
- * @param item - The item to get.
14
- * @param cb - The callback to use to create the value when a cached value is not found.
52
+ * // Second call returns cached value
53
+ * const rect2 = cache.get(element, (el) => el.getBoundingClientRect())
54
+ * // rect1 and rect2 are the same object
55
+ * ```
15
56
  */
16
57
  get<P extends K>(item: P, cb: (item: P) => V) {
17
58
  if (!this.items.has(item)) {
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { assert, assertExists, exhaustiveSwitchError, promiseWithResolve } from './control'
3
+
4
+ describe('exhaustiveSwitchError', () => {
5
+ it('should throw an error with the unhandled value', () => {
6
+ expect(() => exhaustiveSwitchError('unhandled' as never)).toThrow(
7
+ 'Unknown switch case unhandled'
8
+ )
9
+ })
10
+
11
+ it('should throw with property value when provided', () => {
12
+ const value = { type: 'unknown', data: 'test' } as never
13
+ expect(() => exhaustiveSwitchError(value, 'type')).toThrow('Unknown switch case unknown')
14
+ })
15
+ })
16
+
17
+ describe('assert', () => {
18
+ it('should throw with custom message when provided', () => {
19
+ expect(() => assert(false, 'Custom error message')).toThrow('Custom error message')
20
+ expect(() => assert(null, 'Value cannot be null')).toThrow('Value cannot be null')
21
+ })
22
+ })
23
+
24
+ describe('assertExists', () => {
25
+ it('should throw with custom message when provided', () => {
26
+ expect(() => assertExists(null, 'Custom null message')).toThrow('Custom null message')
27
+ expect(() => assertExists(undefined, 'Custom undefined message')).toThrow(
28
+ 'Custom undefined message'
29
+ )
30
+ })
31
+ })
32
+
33
+ describe('promiseWithResolve', () => {
34
+ it('should resolve when resolve is called', async () => {
35
+ const deferred = promiseWithResolve<string>()
36
+
37
+ setTimeout(() => deferred.resolve('resolved value'), 0)
38
+
39
+ const result = await deferred
40
+ expect(result).toBe('resolved value')
41
+ })
42
+
43
+ it('should reject when reject is called', async () => {
44
+ const deferred = promiseWithResolve<string>()
45
+
46
+ setTimeout(() => deferred.reject('rejected reason'), 0)
47
+
48
+ await expect(deferred).rejects.toBe('rejected reason')
49
+ })
50
+ })