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,11 @@
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 declare function areMapsEqual(map1: Map<any, any> | null, map2: Map<any, any>): boolean;
11
+ //# sourceMappingURL=are-maps-equal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"are-maps-equal.d.ts","sourceRoot":"","sources":["../../../src/shared/are-maps-equal.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,GAAG,IAAI,EAAE,IAAI,EAAE,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,GAAG,OAAO,CAYrF"}
@@ -19,6 +19,15 @@ export declare class AsyncRegistry<T extends Destructible> {
19
19
  * Set of watchers that observe changes to the registry.
20
20
  */
21
21
  private readonly watchers;
22
+ /**
23
+ * Batch nesting depth. When > 0, watcher notifications are deferred.
24
+ */
25
+ private batchDepth;
26
+ /**
27
+ * Snapshot of the last state dispatched to watchers, used for change detection.
28
+ */
29
+ private lastNotifiedItems;
30
+ private lastNotifiedErrors;
22
31
  /**
23
32
  * Executes a function on an item.
24
33
  * If the item is not yet registered, it will wait for it to be registered.
@@ -29,6 +38,14 @@ export declare class AsyncRegistry<T extends Destructible> {
29
38
  * @returns A promise that resolves with the result of the function.
30
39
  */
31
40
  execute<R, E extends T = T>(id: RegistryId | null, onSuccess: (item: E) => R, onError?: (error: any) => void): Promise<Awaited<R>>;
41
+ /**
42
+ * Reactively binds a mount/unmount lifecycle to a single registry item.
43
+ *
44
+ * @param id The ID of the item to observe.
45
+ * @param onMount Function executed when the item mounts.
46
+ * @returns A function that stops observing and immediately runs any pending cleanup.
47
+ */
48
+ mountEffect<E extends T = T>(id: RegistryId | null, onMount: (item: E) => (() => void) | void): () => void;
32
49
  /**
33
50
  * Registers an item.
34
51
  *
@@ -53,14 +70,21 @@ export declare class AsyncRegistry<T extends Destructible> {
53
70
  * Un-registers an item.
54
71
  *
55
72
  * @param id The ID of the item.
73
+ * @param resetPendingCallbacks If true resets pending callbacks.
56
74
  */
57
- unregister(id: RegistryId | null): void;
75
+ unregister(id: RegistryId | null, resetPendingCallbacks?: boolean): void;
58
76
  /**
59
77
  * Gets all registered items.
60
78
  *
61
79
  * @returns An array of all registered items.
62
80
  */
63
81
  getItems(): T[];
82
+ /**
83
+ * Returns single registered item.
84
+ *
85
+ * @returns Registered item.
86
+ */
87
+ getItem(id: RegistryId | null): T | undefined;
64
88
  /**
65
89
  * Checks if an item with the given ID is registered.
66
90
  *
@@ -81,6 +105,22 @@ export declare class AsyncRegistry<T extends Destructible> {
81
105
  * This will call the `destroy` method on each item.
82
106
  */
83
107
  destroyAll(): Promise<void>;
108
+ /**
109
+ * Destroys all registered editors and removes all watchers.
110
+ */
111
+ reset(): Promise<void>;
112
+ /**
113
+ * Executes a callback while deferring all watcher notifications.
114
+ * A single notification is fired synchronously after the callback returns,
115
+ * but only if the registry actually changed.
116
+ *
117
+ * Batches can be nested — watchers are notified only when the outermost
118
+ * batch completes.
119
+ *
120
+ * @param fn The callback to execute.
121
+ * @returns The return value of the callback.
122
+ */
123
+ batch<R>(fn: () => R): R;
84
124
  /**
85
125
  * Registers a watcher that will be called whenever the registry changes.
86
126
  *
@@ -95,9 +135,9 @@ export declare class AsyncRegistry<T extends Destructible> {
95
135
  */
96
136
  unwatch(watcher: RegistryWatcher<T>): void;
97
137
  /**
98
- * Notifies all watchers about changes to the registry.
138
+ * Immediately dispatches the current state to all watchers if it changed.
99
139
  */
100
- private notifyWatchers;
140
+ private flushWatchers;
101
141
  /**
102
142
  * Gets or creates pending callbacks for a specific ID.
103
143
  *
@@ -105,13 +145,6 @@ export declare class AsyncRegistry<T extends Destructible> {
105
145
  * @returns The pending callbacks structure.
106
146
  */
107
147
  private getPendingCallbacks;
108
- /**
109
- * Registers an item as the default (null ID) item if it's the first one.
110
- *
111
- * @param id The ID of the item being registered.
112
- * @param item The item instance.
113
- */
114
- private registerAsDefault;
115
148
  }
116
149
  /**
117
150
  * Interface for objects that can be destroyed.
@@ -1 +1 @@
1
- {"version":3,"file":"async-registry.d.ts","sourceRoot":"","sources":["../../../src/shared/async-registry.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,qBAAa,aAAa,CAAC,CAAC,SAAS,YAAY;IAC/C;;OAEG;IACH,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAmC;IAEzD;;OAEG;IACH,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAqC;IAE1E;;OAEG;IACH,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqD;IAEtF;;OAEG;IACH,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiC;IAE1D;;;;;;;;OAQG;IACH,OAAO,CAAC,CAAC,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,EACxB,EAAE,EAAE,UAAU,GAAG,IAAI,EACrB,SAAS,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,EACzB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,GAC7B,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IAgCtB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,EAAE,UAAU,GAAG,IAAI,EAAE,IAAI,EAAE,CAAC,GAAG,IAAI;IAqB9C;;;;;OAKG;IACH,KAAK,CAAC,EAAE,EAAE,UAAU,GAAG,IAAI,EAAE,KAAK,EAAE,GAAG,GAAG,IAAI;IAqB9C;;;;OAIG;IACH,WAAW,CAAC,EAAE,EAAE,UAAU,GAAG,IAAI,GAAG,IAAI;IAWxC;;;;OAIG;IACH,UAAU,CAAC,EAAE,EAAE,UAAU,GAAG,IAAI,GAAG,IAAI;IAgBvC;;;;OAIG;IACH,QAAQ,IAAI,CAAC,EAAE;IAIf;;;;;OAKG;IACH,OAAO,CAAC,EAAE,EAAE,UAAU,GAAG,IAAI,GAAG,OAAO;IAIvC;;;;;;OAMG;IACH,OAAO,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,UAAU,GAAG,IAAI,GAAG,OAAO,CAAC,CAAC,CAAC;IAM3D;;;OAGG;IACG,UAAU;IAehB;;;;;OAKG;IACH,KAAK,CAAC,OAAO,EAAE,eAAe,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI;IAY9C;;;;OAIG;IACH,OAAO,CAAC,OAAO,EAAE,eAAe,CAAC,CAAC,CAAC,GAAG,IAAI;IAI1C;;OAEG;IACH,OAAO,CAAC,cAAc;IAStB;;;;;OAKG;IACH,OAAO,CAAC,mBAAmB;IAW3B;;;;;OAKG;IACH,OAAO,CAAC,iBAAiB;CAK1B;AAED;;GAEG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,OAAO,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC;CAC7B,CAAC;AAEF;;GAEG;AACH,KAAK,UAAU,GAAG,MAAM,CAAC;AAUzB;;GAEG;AACH,KAAK,eAAe,CAAC,CAAC,IAAI,CACxB,KAAK,EAAE,GAAG,CAAC,UAAU,GAAG,IAAI,EAAE,CAAC,CAAC,EAChC,MAAM,EAAE,GAAG,CAAC,UAAU,GAAG,IAAI,EAAE,KAAK,CAAC,KAClC,IAAI,CAAC"}
1
+ {"version":3,"file":"async-registry.d.ts","sourceRoot":"","sources":["../../../src/shared/async-registry.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ export * from './are-maps-equal';
1
2
  export * from './async-registry';
2
3
  export * from './camel-case';
3
4
  export * from './debounce';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/shared/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,YAAY,CAAC;AAC3B,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,QAAQ,CAAC;AACvB,cAAc,iBAAiB,CAAC;AAChC,cAAc,WAAW,CAAC;AAC1B,cAAc,OAAO,CAAC;AACtB,cAAc,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/shared/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,YAAY,CAAC;AAC3B,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,QAAQ,CAAC;AACvB,cAAc,iBAAiB,CAAC;AAChC,cAAc,WAAW,CAAC;AAC1B,cAAc,OAAO,CAAC;AACtB,cAAc,YAAY,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ckeditor5-livewire",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "CKEditor 5 integration for Laravel Livewire",
5
5
  "author": "Mateusz Bagiński <cziken58@gmail.com>",
6
6
  "license": "MIT",
@@ -32,9 +32,9 @@
32
32
  "@vitest/coverage-v8": "^4.1.0",
33
33
  "ckeditor5": "^47.6.0",
34
34
  "ckeditor5-premium-features": "^47.6.0",
35
- "happy-dom": "^20.0.1",
35
+ "happy-dom": "^20.8.9",
36
36
  "typescript": "^5.5.4",
37
- "vite": "^8.0.0",
37
+ "vite": "^8.0.5",
38
38
  "vite-plugin-dts": "^4.5.4",
39
39
  "vitest": "^4.1.0"
40
40
  },
@@ -342,7 +342,9 @@ describe('context component', () => {
342
342
 
343
343
  await livewireStub.$internal.unmountComponent(editorId);
344
344
 
345
- expect(context?.editors.first).toBeNull();
345
+ await vi.waitFor(() => {
346
+ expect(context?.editors.first).toBeNull();
347
+ });
346
348
  });
347
349
 
348
350
  it('should remove the context instance from the registry on destroy', async () => {
@@ -1,9 +1,10 @@
1
- import type { MultiRootEditor } from 'ckeditor5';
1
+ import type { DecoupledEditor, Editor, MultiRootEditor } from 'ckeditor5';
2
2
 
3
3
  import type { RootAttributesUpdater } from './utils';
4
4
 
5
5
  import { debounce } from '../shared';
6
6
  import { EditorsRegistry } from './editor/editors-registry';
7
+ import { isMultirootEditorInstance } from './editor/utils';
7
8
  import { ClassHook } from './hook';
8
9
  import { createRootAttributesUpdater, isWireModelConnected } from './utils';
9
10
 
@@ -11,11 +12,6 @@ import { createRootAttributesUpdater, isWireModelConnected } from './utils';
11
12
  * Editable hook for Livewire. It allows you to create editables for multi-root editors.
12
13
  */
13
14
  export class EditableComponentHook extends ClassHook<Snapshot> {
14
- /**
15
- * The promise that resolves when the editable is mounted.
16
- */
17
- private editorPromise: Promise<MultiRootEditor | null> | null = null;
18
-
19
15
  /**
20
16
  * The root attributes updater for the editable's root.
21
17
  */
@@ -32,16 +28,15 @@ export class EditableComponentHook extends ClassHook<Snapshot> {
32
28
  override mounted() {
33
29
  const { editorId, rootName, content } = this.canonical;
34
30
 
35
- // If the editor is not registered yet, we will wait for it to be registered.
36
- this.editorPromise = EditorsRegistry.the.execute(editorId, (editor: MultiRootEditor) => {
31
+ const unmountEffect = EditorsRegistry.the.mountEffect(editorId, (editor: MultiRootEditor | DecoupledEditor) => {
37
32
  /* v8 ignore next if -- @preserve */
38
33
  if (this.isBeingDestroyed()) {
39
- return null;
34
+ return;
40
35
  }
41
36
 
42
- const { ui, editing, model } = editor;
37
+ const root = editor.model.document.getRoot(rootName);
43
38
 
44
- if (model.document.getRoot(rootName)) {
39
+ if (root?.isAttached()) {
45
40
  // If the newly added root already exists, but the newly added editable has content,
46
41
  // we need to update the root data with the editable content.
47
42
  if (content !== null) {
@@ -54,7 +49,11 @@ export class EditableComponentHook extends ClassHook<Snapshot> {
54
49
  }
55
50
  }
56
51
  }
57
- else {
52
+
53
+ /* v8 ignore next else -- @preserve */
54
+ if (!root && isMultirootEditorInstance(editor)) {
55
+ const { ui, editing } = editor;
56
+
58
57
  editor.addRoot(rootName, {
59
58
  isUndoable: false,
60
59
  ...content !== null && {
@@ -70,19 +69,56 @@ export class EditableComponentHook extends ClassHook<Snapshot> {
70
69
  }
71
70
 
72
71
  // Add livewire sync.
73
- this.syncTypingContentPush(editor);
74
- this.setupPendingReceivedContentHandlers(editor);
72
+ const cleanupSync = this.syncTypingContentPush(editor);
73
+ const cleanupPending = this.setupPendingReceivedContentHandlers(editor);
74
+
75
75
  this.applyRootAttributes(editor);
76
76
 
77
- return editor;
77
+ return () => {
78
+ cleanupSync();
79
+ cleanupPending();
80
+
81
+ // Remove root attributes we may have set on this root.
82
+ this.rootAttributesUpdater?.(null);
83
+
84
+ // Unmount root from the editor if editor is still registered.
85
+ /* v8 ignore next else -- @preserve */
86
+ if (editor.state !== 'destroyed') {
87
+ const root = editor.model.document.getRoot(rootName);
88
+
89
+ /* v8 ignore next if -- @preserve */
90
+ if (root && isMultirootEditorInstance(editor)) {
91
+ // Detaching editables seem to be buggy when something removed DOM element of the editable (e.g. Livewire re-render) before
92
+ // the editable is unmounted. To prevent errors in such cases, we will try to detach the editable if it exists, but ignore errors.
93
+ try {
94
+ /* v8 ignore else -- @preserve */
95
+ if (editor.ui.view.editables[rootName]) {
96
+ editor.detachEditable(root);
97
+ }
98
+ }
99
+ catch (err) {
100
+ // Ignore errors when detaching editable.
101
+ /* v8 ignore next -- @preserve */
102
+ console.error('Unable unmount editable from root:', err);
103
+ }
104
+
105
+ if (root.isAttached()) {
106
+ editor.detachRoot(rootName, false);
107
+ }
108
+ }
109
+ }
110
+ };
78
111
  });
112
+
113
+ this.onBeforeDestroy(unmountEffect);
79
114
  }
80
115
 
81
116
  /**
82
117
  * Called when the component is updated by Livewire.
83
118
  */
84
119
  override async afterCommitSynced(): Promise<void> {
85
- const editor = (await this.editorPromise)!;
120
+ const { editorId } = this.canonical;
121
+ const editor = await EditorsRegistry.the.waitFor(editorId);
86
122
 
87
123
  this.applyCanonicalContentToEditor(editor);
88
124
  this.applyRootAttributes(editor);
@@ -91,35 +127,17 @@ export class EditableComponentHook extends ClassHook<Snapshot> {
91
127
  /**
92
128
  * Destroys the editable component. Unmounts root from the editor.
93
129
  */
94
- override async destroyed() {
95
- const { rootName } = this.canonical;
96
-
130
+ override destroyed() {
97
131
  // Let's hide the element during destruction to prevent flickering.
132
+ // Root detachment and attribute cleanup are handled by the mountEffect cleanup function.
98
133
  this.element.style.display = 'none';
99
-
100
- // Let's wait for the mounted promise to resolve before proceeding with destruction.
101
- const editor = await this.editorPromise;
102
- this.editorPromise = null;
103
-
104
- // Remove root attributes we may have set on this root.
105
- this.rootAttributesUpdater?.(null);
106
-
107
- // Unmount root from the editor if editor is still registered.
108
- if (editor && editor.state !== 'destroyed') {
109
- const root = editor.model.document.getRoot(rootName);
110
-
111
- /* v8 ignore next if -- @preserve */
112
- if (root && 'detachEditable' in editor) {
113
- editor.detachEditable(root);
114
- editor.detachRoot(rootName, false);
115
- }
116
- }
117
134
  }
118
135
 
119
136
  /**
120
137
  * Setups the content sync from the editor to Livewire on user input with debounce.
138
+ * Returns a cleanup function that unregisters all event listeners.
121
139
  */
122
- private syncTypingContentPush(editor: MultiRootEditor) {
140
+ private syncTypingContentPush(editor: MultiRootEditor | DecoupledEditor): () => void {
123
141
  const { rootName, saveDebounceMs } = this.canonical;
124
142
 
125
143
  const input = this.element.querySelector<HTMLInputElement>('input');
@@ -130,6 +148,12 @@ export class EditableComponentHook extends ClassHook<Snapshot> {
130
148
  return;
131
149
  }
132
150
 
151
+ const root = editor.model.document.getRoot(rootName);
152
+
153
+ if (!root?.isAttached()) {
154
+ return;
155
+ }
156
+
133
157
  const html = editor.getData({ rootName });
134
158
 
135
159
  if (input) {
@@ -152,17 +176,18 @@ export class EditableComponentHook extends ClassHook<Snapshot> {
152
176
  editor.model.document.on('change:data', onChangeData);
153
177
  sync();
154
178
 
155
- this.onBeforeDestroy(() => {
179
+ return () => {
156
180
  isDestroyed = true;
157
181
  editor.model.document.off('change:data', onChangeData);
158
- });
182
+ };
159
183
  }
160
184
 
161
185
  /**
162
186
  * Sets up handlers that manage pending incoming content (clears pending
163
187
  * content on user edits and applies pending content on blur).
188
+ * Returns a cleanup function that unregisters all event listeners.
164
189
  */
165
- private setupPendingReceivedContentHandlers(editor: MultiRootEditor): void {
190
+ private setupPendingReceivedContentHandlers(editor: Editor): () => void {
166
191
  const { ui, model } = editor;
167
192
  const { focusTracker } = ui;
168
193
  const { rootName } = this.canonical;
@@ -184,16 +209,16 @@ export class EditableComponentHook extends ClassHook<Snapshot> {
184
209
  model.document.on('change:data', onDataChange);
185
210
  focusTracker.on('change:isFocused', onFocusChange);
186
211
 
187
- this.onBeforeDestroy(() => {
212
+ return () => {
188
213
  model.document.off('change:data', onDataChange);
189
214
  focusTracker.off('change:isFocused', onFocusChange);
190
- });
215
+ };
191
216
  }
192
217
 
193
218
  /**
194
219
  * Applies canonical content to the editor while respecting focus/pending state.
195
220
  */
196
- private applyCanonicalContentToEditor(editor: MultiRootEditor): void {
221
+ private applyCanonicalContentToEditor(editor: Editor): void {
197
222
  if (!isWireModelConnected(this.element)) {
198
223
  return;
199
224
  }
@@ -212,13 +237,15 @@ export class EditableComponentHook extends ClassHook<Snapshot> {
212
237
  return;
213
238
  }
214
239
 
215
- editor.setData({ [rootName]: content ?? '' });
240
+ editor.setData({
241
+ [rootName]: content ?? '',
242
+ });
216
243
  }
217
244
 
218
245
  /**
219
246
  * Applies root attributes from the Livewire snapshot to the editor root.
220
247
  */
221
- private applyRootAttributes(editor: MultiRootEditor): void {
248
+ private applyRootAttributes(editor: Editor): void {
222
249
  const { rootName, rootAttributes } = this.canonical;
223
250
 
224
251
  this.rootAttributesUpdater ??= createRootAttributesUpdater(editor, rootName);
@@ -488,11 +488,11 @@ describe('editor component', () => {
488
488
  await vi.advanceTimersByTimeAsync(399);
489
489
  expect($wire.set).not.toHaveBeenCalled();
490
490
 
491
- await vi.advanceTimersByTimeAsync(1);
491
+ await vi.advanceTimersByTimeAsync(20);
492
492
  expect($wire.set).toHaveBeenCalledExactlyOnceWith('content', { main: '<p>Debounce test</p>' });
493
493
  });
494
494
 
495
- it('should immediately send `$wire.set` when editor is not focused', async () => {
495
+ it('should almost immediately send `$wire.set` when editor is not focused', async () => {
496
496
  const { $wire } = livewireStub.$internal.appendComponentToDOM<EditorSnapshot>({
497
497
  name: 'ckeditor5',
498
498
  el: createEditorHtmlElement(),
@@ -507,6 +507,8 @@ describe('editor component', () => {
507
507
  vi.mocked($wire.set).mockClear();
508
508
  editor.setData('<p>Debounce test</p>');
509
509
 
510
+ await vi.advanceTimersByTimeAsync(20);
511
+
510
512
  expect($wire.set).toHaveBeenCalledExactlyOnceWith('content', { main: '<p>Debounce test</p>' });
511
513
  });
512
514
 
@@ -531,7 +533,7 @@ describe('editor component', () => {
531
533
  await vi.advanceTimersByTimeAsync(399);
532
534
  expect(input.value).to.be.equal('<p>Initial content</p>');
533
535
 
534
- await vi.advanceTimersByTimeAsync(1);
536
+ await vi.advanceTimersByTimeAsync(20);
535
537
  expect(input.value).to.be.equal('<p>Debounce test</p>');
536
538
  });
537
539
  });
@@ -667,6 +669,11 @@ describe('editor component', () => {
667
669
  const originalEditor = await waitForTestEditor('editor-with-watchdog');
668
670
  const watchdog = unwrapEditorWatchdog(originalEditor)!;
669
671
 
672
+ (watchdog as any)._fire('error', {
673
+ error: new Error('Mock'),
674
+ causesRestart: true,
675
+ });
676
+
670
677
  (watchdog as any)._restart();
671
678
 
672
679
  await vi.waitFor(async () => {
@@ -675,6 +682,34 @@ describe('editor component', () => {
675
682
  expect(newEditor).not.equals(originalEditor);
676
683
  });
677
684
  });
685
+
686
+ it('should not restart editor if only `causesRestart: false` error occurs', async () => {
687
+ livewireStub.$internal.appendComponentToDOM<EditorSnapshot>({
688
+ name: 'ckeditor5',
689
+ el: createEditorHtmlElement({
690
+ id: 'editor-with-watchdog',
691
+ }),
692
+ canonical: {
693
+ ...createEditorSnapshot(),
694
+ editorId: 'editor-with-watchdog',
695
+ watchdog: true,
696
+ },
697
+ });
698
+
699
+ const originalEditor = await waitForTestEditor('editor-with-watchdog');
700
+ const watchdog = unwrapEditorWatchdog(originalEditor)!;
701
+
702
+ (watchdog as any)._fire('error', {
703
+ error: new Error('Mock'),
704
+ causesRestart: false,
705
+ });
706
+
707
+ await vi.waitFor(async () => {
708
+ const newEditor = await waitForTestEditor('editor-with-watchdog');
709
+
710
+ expect(newEditor).to.be.equal(originalEditor);
711
+ });
712
+ });
678
713
  });
679
714
 
680
715
  describe('livewire <> editor synchronization', () => {
@@ -718,7 +753,7 @@ describe('editor component', () => {
718
753
  vi.mocked($wire.set).mockClear();
719
754
  editor.setData('<p>New content</p>');
720
755
 
721
- await vi.advanceTimersByTimeAsync(1);
756
+ await vi.advanceTimersByTimeAsync(20);
722
757
 
723
758
  expect($wire.set).toHaveBeenCalledWith('content', { main: '<p>New content</p>' });
724
759
  vi.useRealTimers();
@@ -743,7 +778,7 @@ describe('editor component', () => {
743
778
  editor.setData('<p>Updated content</p>');
744
779
  focusTracker.isFocused = true;
745
780
 
746
- await vi.advanceTimersByTimeAsync(1);
781
+ await vi.advanceTimersByTimeAsync(20);
747
782
 
748
783
  expect($wire.set).toHaveBeenCalledWith('content', { main: '<p>Updated content</p>' });
749
784
  expect($wire.set).toHaveBeenCalledWith('focused', true);
@@ -769,7 +804,7 @@ describe('editor component', () => {
769
804
  vi.mocked($wire.set).mockClear();
770
805
  editor.setData('<p>New content</p>');
771
806
 
772
- await vi.advanceTimersByTimeAsync(1);
807
+ await vi.advanceTimersByTimeAsync(20);
773
808
 
774
809
  expect($wire.set).toHaveBeenCalledWith('content', { main: '<p>New content</p>' });
775
810
  vi.useRealTimers();
@@ -830,7 +865,7 @@ describe('editor component', () => {
830
865
  vi.mocked($wire.dispatch).mockClear();
831
866
  editor.setData('<p>Content change event test</p>');
832
867
 
833
- await vi.advanceTimersByTimeAsync(1);
868
+ await vi.advanceTimersByTimeAsync(20);
834
869
 
835
870
  expect($wire.dispatch).toHaveBeenCalledExactlyOnceWith('editor-content-changed', {
836
871
  editorId: DEFAULT_TEST_EDITOR_ID,
@@ -1071,7 +1106,7 @@ describe('editor component', () => {
1071
1106
  const input = getTestEditorInput();
1072
1107
 
1073
1108
  editor.setData('<p>Form integration test</p>');
1074
- await vi.advanceTimersByTimeAsync(1);
1109
+ await vi.advanceTimersByTimeAsync(20);
1075
1110
 
1076
1111
  expect(input.value).to.be.equal('<p>Form integration test</p>');
1077
1112
  });
@@ -1123,7 +1158,7 @@ describe('editor component', () => {
1123
1158
 
1124
1159
  // Submit the form.
1125
1160
  input.closest('form')!.dispatchEvent(new Event('submit', { bubbles: true }));
1126
- await vi.advanceTimersByTimeAsync(1);
1161
+ await vi.advanceTimersByTimeAsync(20);
1127
1162
 
1128
1163
  // Value should be synced immediately on form submit.
1129
1164
  expect(input.value).to.be.equal('<p>Form integration test</p>');