ckeditor5-livewire 1.9.0 → 1.11.0

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 (46) hide show
  1. package/dist/hooks/editable.d.ts +3 -5
  2. package/dist/hooks/editable.d.ts.map +1 -1
  3. package/dist/hooks/editor/editor.d.ts +0 -4
  4. package/dist/hooks/editor/editor.d.ts.map +1 -1
  5. package/dist/hooks/editor/plugins/livewire-sync.d.ts.map +1 -1
  6. package/dist/hooks/editor/utils/cleanup-orphan-editor-elements.d.ts +8 -0
  7. package/dist/hooks/editor/utils/cleanup-orphan-editor-elements.d.ts.map +1 -0
  8. package/dist/hooks/editor/utils/create-editor-in-context.d.ts +6 -1
  9. package/dist/hooks/editor/utils/create-editor-in-context.d.ts.map +1 -1
  10. package/dist/hooks/editor/utils/index.d.ts +2 -0
  11. package/dist/hooks/editor/utils/index.d.ts.map +1 -1
  12. package/dist/hooks/editor/utils/is-multiroot-editor-instance.d.ts +6 -0
  13. package/dist/hooks/editor/utils/is-multiroot-editor-instance.d.ts.map +1 -0
  14. package/dist/hooks/editor/utils/wrap-with-watchdog.d.ts +7 -16
  15. package/dist/hooks/editor/utils/wrap-with-watchdog.d.ts.map +1 -1
  16. package/dist/hooks/ui-part.d.ts +2 -6
  17. package/dist/hooks/ui-part.d.ts.map +1 -1
  18. package/dist/index.cjs +2 -2
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.mjs +297 -220
  21. package/dist/index.mjs.map +1 -1
  22. package/dist/shared/are-maps-equal.d.ts +11 -0
  23. package/dist/shared/are-maps-equal.d.ts.map +1 -0
  24. package/dist/shared/async-registry.d.ts +43 -10
  25. package/dist/shared/async-registry.d.ts.map +1 -1
  26. package/dist/shared/index.d.ts +1 -0
  27. package/dist/shared/index.d.ts.map +1 -1
  28. package/package.json +3 -3
  29. package/src/hooks/context/context.test.ts +3 -1
  30. package/src/hooks/editable.ts +73 -46
  31. package/src/hooks/editor/editor.test.ts +44 -9
  32. package/src/hooks/editor/editor.ts +159 -149
  33. package/src/hooks/editor/plugins/livewire-sync.ts +17 -8
  34. package/src/hooks/editor/utils/cleanup-orphan-editor-elements.test.ts +285 -0
  35. package/src/hooks/editor/utils/cleanup-orphan-editor-elements.ts +60 -0
  36. package/src/hooks/editor/utils/create-editor-in-context.ts +6 -2
  37. package/src/hooks/editor/utils/index.ts +2 -0
  38. package/src/hooks/editor/utils/is-multiroot-editor-instance.ts +8 -0
  39. package/src/hooks/editor/utils/wrap-with-watchdog.test.ts +34 -14
  40. package/src/hooks/editor/utils/wrap-with-watchdog.ts +16 -26
  41. package/src/hooks/ui-part.ts +10 -16
  42. package/src/shared/are-maps-equal.test.ts +56 -0
  43. package/src/shared/are-maps-equal.ts +22 -0
  44. package/src/shared/async-registry.test.ts +212 -31
  45. package/src/shared/async-registry.ts +178 -61
  46. package/src/shared/index.ts +1 -0
@@ -0,0 +1,285 @@
1
+ import type { Editor } from 'ckeditor5';
2
+
3
+ import { beforeEach, describe, expect, it } from 'vitest';
4
+
5
+ import { cleanupOrphanEditorElements } from './cleanup-orphan-editor-elements';
6
+
7
+ describe('cleanupOrphanEditorElements', () => {
8
+ beforeEach(() => {
9
+ document.body.innerHTML = '';
10
+ });
11
+
12
+ it('should remove uiElement from the DOM if it is connected', () => {
13
+ const uiElement = document.createElement('div');
14
+ document.body.appendChild(uiElement);
15
+
16
+ const mockEditor = {
17
+ ui: { element: uiElement },
18
+ } as unknown as Editor;
19
+
20
+ expect(uiElement.isConnected).toBe(true);
21
+
22
+ cleanupOrphanEditorElements(mockEditor);
23
+
24
+ expect(uiElement.isConnected).toBe(false);
25
+ });
26
+
27
+ it('should not remove uiElement from the DOM if it has proper attribute', () => {
28
+ const uiElement = document.createElement('div');
29
+
30
+ uiElement.setAttribute('data-cke-controlled', '');
31
+ document.body.appendChild(uiElement);
32
+
33
+ const mockEditor = {
34
+ ui: { element: uiElement },
35
+ } as unknown as Editor;
36
+
37
+ expect(uiElement.isConnected).toBe(true);
38
+
39
+ cleanupOrphanEditorElements(mockEditor);
40
+
41
+ expect(uiElement.isConnected).toBe(true);
42
+ });
43
+
44
+ it('should not throw an error if uiElement is not connected or does not exist', () => {
45
+ const uiElement = document.createElement('div');
46
+
47
+ const mockEditor = {
48
+ ui: { element: uiElement },
49
+ } as unknown as Editor;
50
+
51
+ expect(() => cleanupOrphanEditorElements(mockEditor)).not.toThrow();
52
+ expect(() => cleanupOrphanEditorElements({ ui: {} } as Editor)).not.toThrow();
53
+ });
54
+
55
+ it('should remove toolbar element from the DOM if it is connected', () => {
56
+ const toolbarElement = document.createElement('div');
57
+ document.body.appendChild(toolbarElement);
58
+
59
+ const mockEditor = {
60
+ ui: {
61
+ view: {
62
+ toolbar: { element: toolbarElement },
63
+ },
64
+ },
65
+ } as unknown as Editor;
66
+
67
+ expect(toolbarElement.isConnected).toBe(true);
68
+
69
+ cleanupOrphanEditorElements(mockEditor);
70
+
71
+ expect(toolbarElement.isConnected).toBe(false);
72
+ });
73
+
74
+ it('should clear toolbar element instead of removing it when it has data-cke-controlled', () => {
75
+ const toolbarElement = document.createElement('div');
76
+ toolbarElement.setAttribute('data-cke-controlled', '');
77
+ toolbarElement.innerHTML = '<button>Bold</button>';
78
+ document.body.appendChild(toolbarElement);
79
+
80
+ const mockEditor = {
81
+ ui: {
82
+ view: {
83
+ toolbar: { element: toolbarElement },
84
+ },
85
+ },
86
+ } as unknown as Editor;
87
+
88
+ expect(toolbarElement.isConnected).toBe(true);
89
+
90
+ cleanupOrphanEditorElements(mockEditor);
91
+
92
+ expect(toolbarElement.isConnected).toBe(true);
93
+ expect(toolbarElement.innerHTML).toBe('');
94
+ });
95
+
96
+ it('should not throw if toolbar element is absent', () => {
97
+ const mockEditor = {
98
+ ui: {
99
+ view: {
100
+ toolbar: {},
101
+ },
102
+ },
103
+ } as unknown as Editor;
104
+
105
+ expect(() => cleanupOrphanEditorElements(mockEditor)).not.toThrow();
106
+ });
107
+
108
+ it('should remove menuBarView element from the DOM if it is connected', () => {
109
+ const menuBarElement = document.createElement('div');
110
+ document.body.appendChild(menuBarElement);
111
+
112
+ const mockEditor = {
113
+ ui: {
114
+ view: {
115
+ menuBarView: { element: menuBarElement },
116
+ },
117
+ },
118
+ } as unknown as Editor;
119
+
120
+ expect(menuBarElement.isConnected).toBe(true);
121
+
122
+ cleanupOrphanEditorElements(mockEditor);
123
+
124
+ expect(menuBarElement.isConnected).toBe(false);
125
+ });
126
+
127
+ it('should clear menuBarView element instead of removing it when it has data-cke-controlled', () => {
128
+ const menuBarElement = document.createElement('div');
129
+ menuBarElement.setAttribute('data-cke-controlled', '');
130
+ menuBarElement.innerHTML = '<nav>File Edit</nav>';
131
+ document.body.appendChild(menuBarElement);
132
+
133
+ const mockEditor = {
134
+ ui: {
135
+ view: {
136
+ menuBarView: { element: menuBarElement },
137
+ },
138
+ },
139
+ } as unknown as Editor;
140
+
141
+ expect(menuBarElement.isConnected).toBe(true);
142
+
143
+ cleanupOrphanEditorElements(mockEditor);
144
+
145
+ expect(menuBarElement.isConnected).toBe(true);
146
+ expect(menuBarElement.innerHTML).toBe('');
147
+ });
148
+
149
+ it('should not throw if menuBarView element is absent', () => {
150
+ const mockEditor = {
151
+ ui: {
152
+ view: {
153
+ menuBarView: {},
154
+ },
155
+ },
156
+ } as unknown as Editor;
157
+
158
+ expect(() => cleanupOrphanEditorElements(mockEditor)).not.toThrow();
159
+ });
160
+
161
+ it('should remove all three ui elements when all are connected', () => {
162
+ const uiElement = document.createElement('div');
163
+ const toolbarElement = document.createElement('div');
164
+ const menuBarElement = document.createElement('div');
165
+
166
+ document.body.append(uiElement, toolbarElement, menuBarElement);
167
+
168
+ const mockEditor = {
169
+ ui: {
170
+ element: uiElement,
171
+ view: {
172
+ toolbar: { element: toolbarElement },
173
+ menuBarView: { element: menuBarElement },
174
+ },
175
+ },
176
+ } as unknown as Editor;
177
+
178
+ cleanupOrphanEditorElements(mockEditor);
179
+
180
+ expect(uiElement.isConnected).toBe(false);
181
+ expect(toolbarElement.isConnected).toBe(false);
182
+ expect(menuBarElement.isConnected).toBe(false);
183
+ });
184
+
185
+ it('should remove _bodyCollectionContainer from the DOM if it is connected', () => {
186
+ const container = document.createElement('div');
187
+ document.body.appendChild(container);
188
+
189
+ const mockEditor = {
190
+ ui: {
191
+ view: {
192
+ body: {
193
+ _bodyCollectionContainer: container,
194
+ },
195
+ },
196
+ },
197
+ } as unknown as Editor;
198
+
199
+ expect(container.isConnected).toBe(true);
200
+ cleanupOrphanEditorElements(mockEditor);
201
+ expect(container.isConnected).toBe(false);
202
+ });
203
+
204
+ it('should clean up corresponding attributes and classes from domRoots', () => {
205
+ const rootElement = document.createElement('div');
206
+
207
+ rootElement.setAttribute('contenteditable', 'true');
208
+ rootElement.setAttribute('role', 'textbox');
209
+ rootElement.setAttribute('aria-label', 'Rich Text Editor');
210
+ rootElement.setAttribute('aria-multiline', 'true');
211
+ rootElement.setAttribute('spellcheck', 'false');
212
+
213
+ rootElement.classList.add(
214
+ 'ck',
215
+ 'ck-content',
216
+ 'ck-editor__editable',
217
+ 'ck-rounded-corners',
218
+ 'ck-editor__editable_inline',
219
+ 'ck-blurred',
220
+ 'ck-focused',
221
+ 'my-custom-class',
222
+ );
223
+
224
+ const domRoots = new Map();
225
+ domRoots.set('main', rootElement);
226
+
227
+ const mockEditor = {
228
+ editing: {
229
+ view: {
230
+ domRoots,
231
+ },
232
+ },
233
+ } as unknown as Editor;
234
+
235
+ cleanupOrphanEditorElements(mockEditor);
236
+
237
+ expect(rootElement.hasAttribute('contenteditable')).toBe(false);
238
+ expect(rootElement.hasAttribute('role')).toBe(false);
239
+ expect(rootElement.hasAttribute('aria-label')).toBe(false);
240
+ expect(rootElement.hasAttribute('aria-multiline')).toBe(false);
241
+ expect(rootElement.hasAttribute('spellcheck')).toBe(false);
242
+
243
+ const removedClasses = [
244
+ 'ck',
245
+ 'ck-content',
246
+ 'ck-editor__editable',
247
+ 'ck-rounded-corners',
248
+ 'ck-editor__editable_inline',
249
+ 'ck-blurred',
250
+ 'ck-focused',
251
+ ];
252
+
253
+ removedClasses.forEach((className) => {
254
+ expect(rootElement.classList.contains(className)).toBe(false);
255
+ });
256
+
257
+ expect(rootElement.classList.contains('my-custom-class')).toBe(true);
258
+ });
259
+
260
+ it('should ignore objects in domRoots that are not instances of HTMLElement', () => {
261
+ const fakeRoot = {
262
+ removeAttribute: () => {},
263
+ classList: { remove: () => {} },
264
+ };
265
+
266
+ const domRoots = new Map();
267
+ domRoots.set('main', fakeRoot);
268
+
269
+ const mockEditor = {
270
+ editing: {
271
+ view: {
272
+ domRoots,
273
+ },
274
+ },
275
+ } as unknown as Editor;
276
+
277
+ expect(() => cleanupOrphanEditorElements(mockEditor)).not.toThrow();
278
+ });
279
+
280
+ it('should fail gracefully on an empty editor object', () => {
281
+ const emptyEditor = {} as unknown as Editor;
282
+
283
+ expect(() => cleanupOrphanEditorElements(emptyEditor)).not.toThrow();
284
+ });
285
+ });
@@ -0,0 +1,60 @@
1
+ import type { Editor } from 'ckeditor5';
2
+
3
+ /**
4
+ * Removes all DOM elements injected by a specific CKEditor instance.
5
+ * Call this before assigning a new instance (e.g. in the 'restart' watchdog handler),
6
+ * because the watchdog does not clean up the previous editor's DOM on its own.
7
+ */
8
+ export function cleanupOrphanEditorElements(editor: Editor): void {
9
+ const uiElements = [
10
+ editor.ui?.element,
11
+ editor.ui?.view?.toolbar?.element,
12
+ editor.ui?.view?.menuBarView?.element,
13
+ ].filter(Boolean) as HTMLElement[];
14
+
15
+ for (const uiElement of uiElements) {
16
+ removeOrReset(uiElement);
17
+ }
18
+
19
+ const bodyCollectionContainer = (editor.ui as any)?.view?.body?._bodyCollectionContainer;
20
+
21
+ if (bodyCollectionContainer?.isConnected) {
22
+ removeOrReset(bodyCollectionContainer);
23
+ }
24
+
25
+ const editingView = editor.editing?.view;
26
+
27
+ if (editingView) {
28
+ for (const domRoot of editingView.domRoots.values()) {
29
+ if (!(domRoot instanceof HTMLElement)) {
30
+ continue;
31
+ }
32
+
33
+ domRoot.removeAttribute('contenteditable');
34
+ domRoot.removeAttribute('role');
35
+ domRoot.removeAttribute('aria-label');
36
+ domRoot.removeAttribute('aria-multiline');
37
+ domRoot.removeAttribute('spellcheck');
38
+ domRoot.classList.remove(
39
+ 'ck',
40
+ 'ck-content',
41
+ 'ck-editor__editable',
42
+ 'ck-rounded-corners',
43
+ 'ck-editor__editable_inline',
44
+ 'ck-blurred',
45
+ 'ck-focused',
46
+ );
47
+
48
+ removeOrReset(domRoot);
49
+ }
50
+ }
51
+
52
+ function removeOrReset(element: HTMLElement) {
53
+ if (element.hasAttribute('data-cke-controlled')) {
54
+ element.innerHTML = '';
55
+ }
56
+ else {
57
+ element.remove();
58
+ }
59
+ }
60
+ }
@@ -1,7 +1,5 @@
1
1
  import type { Context, ContextWatchdog, Editor, EditorConfig } from 'ckeditor5';
2
2
 
3
- import type { EditorCreator } from './wrap-with-watchdog';
4
-
5
3
  import { uid } from '../../../shared';
6
4
 
7
5
  /**
@@ -70,6 +68,12 @@ export function unwrapEditorContext(editor: Editor): EditorContextDescriptor | n
70
68
  return null;
71
69
  }
72
70
 
71
+ /**
72
+ * Type representing an Editor creator with a create method.
73
+ */
74
+ type EditorCreator = {
75
+ create: (...args: any) => Promise<Editor>;
76
+ };
73
77
  /**
74
78
  * Parameters for creating an editor in a context.
75
79
  */
@@ -1,5 +1,7 @@
1
+ export * from './cleanup-orphan-editor-elements';
1
2
  export * from './create-editor-in-context';
2
3
  export * from './get-editor-roots-values';
4
+ export * from './is-multiroot-editor-instance';
3
5
  export * from './is-single-root-editor';
4
6
  export * from './load-editor-constructor';
5
7
  export * from './load-editor-plugins';
@@ -0,0 +1,8 @@
1
+ import type { Editor, MultiRootEditor } from 'ckeditor5';
2
+
3
+ /**
4
+ * Check if passed editor is multiroot editor.
5
+ */
6
+ export function isMultirootEditorInstance(editor: Editor): editor is MultiRootEditor {
7
+ return 'addEditable' in editor.ui;
8
+ }
@@ -15,31 +15,51 @@ describe('wrap with watchdog', () => {
15
15
  element.remove();
16
16
  });
17
17
 
18
- it('returns editor instance after calling Constructor.create', async () => {
19
- const { Constructor } = await wrapWithWatchdog(ClassicEditor);
20
- const editor = await Constructor.create(element, {
21
- licenseKey: 'GPL',
22
- });
18
+ it('returns editor instance after starting the watchdog', async () => {
19
+ const factory = () => ClassicEditor.create(element, { licenseKey: 'GPL' });
20
+ const watchdog = await wrapWithWatchdog(factory, null);
23
21
 
24
- expect(editor).toBeInstanceOf(ClassicEditor);
22
+ await watchdog.create({});
25
23
 
26
- await editor.destroy();
24
+ expect(watchdog.editor).toBeInstanceOf(ClassicEditor);
25
+
26
+ await watchdog.destroy();
27
27
  });
28
28
 
29
29
  it('returns instance of watchdog', async () => {
30
- const { watchdog } = await wrapWithWatchdog(ClassicEditor);
30
+ const factory = () => ClassicEditor.create(element, { licenseKey: 'GPL' });
31
+ const watchdog = await wrapWithWatchdog(factory, null);
31
32
 
32
33
  expect(watchdog).toBeInstanceOf(EditorWatchdog);
33
34
  });
34
35
 
35
36
  it('should be possible to unwrap watchdog from editor instance', async () => {
36
- const { Constructor } = await wrapWithWatchdog(ClassicEditor);
37
- const editor = await Constructor.create(element, {
38
- licenseKey: 'GPL',
39
- });
37
+ const factory = () => ClassicEditor.create(element, { licenseKey: 'GPL' });
38
+ const watchdog = await wrapWithWatchdog(factory, null);
39
+
40
+ await watchdog.create({});
41
+
42
+ expect(unwrapEditorWatchdog(watchdog.editor!)).toBeInstanceOf(EditorWatchdog);
43
+
44
+ await watchdog.destroy();
45
+ });
46
+
47
+ it('rebuilds config by calling factory again on restart', async () => {
48
+ let callCount = 0;
49
+ const factory = async () => {
50
+ callCount++;
51
+ return ClassicEditor.create(element, { licenseKey: 'GPL' });
52
+ };
53
+
54
+ const watchdog = await wrapWithWatchdog(factory, null);
55
+ await watchdog.create({});
56
+
57
+ expect(callCount).toBe(1);
58
+
59
+ await (watchdog as any)._restart();
40
60
 
41
- expect(unwrapEditorWatchdog(editor)).toBeInstanceOf(EditorWatchdog);
61
+ expect(callCount).toBe(2);
42
62
 
43
- await editor.destroy();
63
+ await watchdog.destroy();
44
64
  });
45
65
  });
@@ -1,35 +1,32 @@
1
- import type { Editor, EditorWatchdog } from 'ckeditor5';
1
+ import type { Editor, EditorWatchdog, WatchdogConfig } from 'ckeditor5';
2
2
 
3
- const EDITOR_WATCHDOG_SYMBOL = Symbol.for('elixir-editor-watchdog');
3
+ const EDITOR_WATCHDOG_SYMBOL = Symbol.for('livewire-editor-watchdog');
4
4
 
5
5
  /**
6
- * Wraps an Editor creator with a watchdog for automatic recovery.
6
+ * Wraps an editor factory with a watchdog for automatic recovery.
7
+ * The factory is invoked on each (re)start, so configuration is rebuilt every time.
7
8
  *
8
- * @param Editor - The Editor creator to wrap.
9
- * @returns The Editor creator wrapped with a watchdog.
9
+ * @param factory Async function that creates and returns an Editor instance.
10
+ * @param watchdogConfig Configuration of the watchdog.
11
+ * @returns The watchdog instance.
10
12
  */
11
- export async function wrapWithWatchdog(Editor: EditorCreator) {
13
+ export async function wrapWithWatchdog(factory: () => Promise<Editor>, watchdogConfig?: WatchdogConfig | null) {
12
14
  const { EditorWatchdog } = await import('ckeditor5');
13
- const watchdog = new EditorWatchdog(Editor);
14
15
 
15
- watchdog.setCreator(async (...args: Parameters<typeof Editor['create']>) => {
16
- const editor = await Editor.create(...args);
16
+ const watchdog = new EditorWatchdog(null, watchdogConfig ?? {
17
+ crashNumberLimit: 10,
18
+ minimumNonErrorTimePeriod: 5000,
19
+ });
20
+
21
+ watchdog.setCreator(async () => {
22
+ const editor = await factory();
17
23
 
18
24
  (editor as any)[EDITOR_WATCHDOG_SYMBOL] = watchdog;
19
25
 
20
26
  return editor;
21
27
  });
22
28
 
23
- return {
24
- watchdog,
25
- Constructor: {
26
- create: async (...args: Parameters<typeof Editor['create']>) => {
27
- await watchdog.create(...args);
28
-
29
- return watchdog.editor!;
30
- },
31
- },
32
- };
29
+ return watchdog;
33
30
  }
34
31
 
35
32
  /**
@@ -42,10 +39,3 @@ export function unwrapEditorWatchdog(editor: Editor): EditorWatchdog | null {
42
39
 
43
40
  return null;
44
41
  }
45
-
46
- /**
47
- * Type representing an Editor creator with a create method.
48
- */
49
- export type EditorCreator = {
50
- create: (...args: any) => Promise<Editor>;
51
- };
@@ -5,19 +5,13 @@ import { ClassHook } from './hook';
5
5
  * UI Part hook for Livewire. It allows you to create UI parts for multi-root editors.
6
6
  */
7
7
  export class UIPartComponentHook extends ClassHook<Snapshot> {
8
- /**
9
- * The promise that resolves when the UI part is mounted.
10
- */
11
- private mountedPromise: Promise<void> | null = null;
12
-
13
8
  /**
14
9
  * Mounts the UI part component.
15
10
  */
16
- override async mounted() {
11
+ override mounted() {
17
12
  const { editorId, name } = this.canonical;
18
13
 
19
- // If the editor is not registered yet, we will wait for it to be registered.
20
- this.mountedPromise = EditorsRegistry.the.execute(editorId, (editor) => {
14
+ const unmountEffect = EditorsRegistry.the.mountEffect(editorId, (editor) => {
21
15
  /* v8 ignore next if -- @preserve */
22
16
  if (this.isBeingDestroyed()) {
23
17
  return;
@@ -34,22 +28,22 @@ export class UIPartComponentHook extends ClassHook<Snapshot> {
34
28
  }
35
29
 
36
30
  this.element.appendChild(uiPart.element);
31
+
32
+ return () => {
33
+ this.element.innerHTML = '';
34
+ };
37
35
  });
36
+
37
+ this.onBeforeDestroy(unmountEffect);
38
38
  }
39
39
 
40
40
  /**
41
41
  * Destroys the UI part component. Unmounts UI parts from the editor.
42
42
  */
43
- override async destroyed() {
43
+ override destroyed() {
44
44
  // Let's hide the element during destruction to prevent flickering.
45
+ // The innerHTML cleanup is handled by the mountEffect cleanup function.
45
46
  this.element.style.display = 'none';
46
-
47
- // Let's wait for the mounted promise to resolve before proceeding with destruction.
48
- await this.mountedPromise;
49
- this.mountedPromise = null;
50
-
51
- // Unmount all UI parts from the editor.
52
- this.element.innerHTML = '';
53
47
  }
54
48
  }
55
49
 
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { areMapsEqual } from './are-maps-equal';
4
+
5
+ describe('areMapsEqual', () => {
6
+ it('should return true for two empty maps', () => {
7
+ expect(areMapsEqual(new Map(), new Map())).toBe(true);
8
+ });
9
+
10
+ it('should return true for maps with identical keys and primitive values', () => {
11
+ const map1 = new Map([['a', 1], ['b', 2]]);
12
+ const map2 = new Map([['a', 1], ['b', 2]]);
13
+
14
+ expect(areMapsEqual(map1, map2)).toBe(true);
15
+ });
16
+
17
+ it('should return false if map sizes are different', () => {
18
+ const map1 = new Map([['a', 1]]);
19
+ const map2 = new Map([['a', 1], ['b', 2]]);
20
+
21
+ expect(areMapsEqual(map1, map2)).toBe(false);
22
+ });
23
+
24
+ it('should return false if the keys are different', () => {
25
+ const map1 = new Map([['a', 1], ['c', 2]]);
26
+ const map2 = new Map([['a', 1], ['b', 2]]);
27
+
28
+ expect(areMapsEqual(map1, map2)).toBe(false);
29
+ });
30
+
31
+ it('should return false if the values for the same keys are different', () => {
32
+ const map1 = new Map([['a', 1], ['b', 3]]);
33
+ const map2 = new Map([['a', 1], ['b', 2]]);
34
+
35
+ expect(areMapsEqual(map1, map2)).toBe(false);
36
+ });
37
+
38
+ it('should return false if the first map (map1) is null', () => {
39
+ const map2 = new Map([['a', 1]]);
40
+
41
+ expect(areMapsEqual(null, map2)).toBe(false);
42
+ });
43
+
44
+ it('should correctly handle objects as values using shallow comparison (reference equality)', () => {
45
+ const obj = { id: 1 };
46
+
47
+ const map1 = new Map([['key', obj]]);
48
+ const map2 = new Map([['key', obj]]);
49
+
50
+ expect(areMapsEqual(map1, map2)).toBe(true);
51
+
52
+ const map3 = new Map([['key', { id: 1 }]]);
53
+
54
+ expect(areMapsEqual(map1, map3)).toBe(false);
55
+ });
56
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Compares two Map structures for equality based on their contents.
3
+ * The function checks if the maps have the same size, contain the exact same keys,
4
+ * and have strictly equal values (using shallow comparison).
5
+ *
6
+ * @param map1 - The first map to compare (can be null).
7
+ * @param map2 - The second map to compare.
8
+ * @returns Returns `true` if the maps are identical in terms of keys and values, otherwise `false`.
9
+ */
10
+ export function areMapsEqual(map1: Map<any, any> | null, map2: Map<any, any>): boolean {
11
+ if (!map1 || map1.size !== map2.size) {
12
+ return false;
13
+ }
14
+
15
+ for (const [key, value] of map1) {
16
+ if (!map2.has(key) || map2.get(key) !== value) {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ return true;
22
+ }