ckeditor5-blazor 1.9.0 → 1.10.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/elements/editable.d.ts +3 -11
  2. package/dist/elements/editable.d.ts.map +1 -1
  3. package/dist/elements/editor/editor.d.ts.map +1 -1
  4. package/dist/elements/editor/typings.d.ts +2 -1
  5. package/dist/elements/editor/typings.d.ts.map +1 -1
  6. package/dist/elements/editor/utils/cleanup-orphan-editor-elements.d.ts +8 -0
  7. package/dist/elements/editor/utils/cleanup-orphan-editor-elements.d.ts.map +1 -0
  8. package/dist/elements/editor/utils/create-editor-in-context.d.ts +6 -1
  9. package/dist/elements/editor/utils/create-editor-in-context.d.ts.map +1 -1
  10. package/dist/elements/editor/utils/index.d.ts +1 -0
  11. package/dist/elements/editor/utils/index.d.ts.map +1 -1
  12. package/dist/elements/editor/utils/wrap-with-watchdog.d.ts +7 -16
  13. package/dist/elements/editor/utils/wrap-with-watchdog.d.ts.map +1 -1
  14. package/dist/elements/ui-part.d.ts +3 -3
  15. package/dist/elements/ui-part.d.ts.map +1 -1
  16. package/dist/index.cjs +2 -2
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.mjs +459 -394
  19. package/dist/index.mjs.map +1 -1
  20. package/dist/interop/create-editable-blazor-interop.d.ts.map +1 -1
  21. package/dist/interop/create-editor-blazor-interop.d.ts.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 +44 -16
  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/elements/editable.ts +38 -58
  30. package/src/elements/editor/editor.ts +122 -101
  31. package/src/elements/editor/typings.ts +3 -1
  32. package/src/elements/editor/utils/cleanup-orphan-editor-elements.test.ts +285 -0
  33. package/src/elements/editor/utils/cleanup-orphan-editor-elements.ts +60 -0
  34. package/src/elements/editor/utils/create-editor-in-context.ts +8 -2
  35. package/src/elements/editor/utils/index.ts +1 -0
  36. package/src/elements/editor/utils/wrap-with-watchdog.test.ts +34 -14
  37. package/src/elements/editor/utils/wrap-with-watchdog.ts +15 -25
  38. package/src/elements/ui-part.test.ts +1 -1
  39. package/src/elements/ui-part.ts +12 -11
  40. package/src/interop/create-editable-blazor-interop.ts +19 -16
  41. package/src/interop/create-editor-blazor-interop.ts +15 -18
  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 +190 -88
  45. package/src/shared/async-registry.ts +179 -107
  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,4 +1,3 @@
1
- import type { EditorCreator } from './wrap-with-watchdog';
2
1
  import type { Context, ContextWatchdog, Editor, EditorConfig } from 'ckeditor5';
3
2
 
4
3
  import { uid } from '../../../shared';
@@ -41,7 +40,7 @@ export async function createEditorInContext({ element, context, creator, config
41
40
 
42
41
  // Destroying of context is async. There can be situation when the destroy of the context
43
42
  // and the destroy of the editor is called in parallel. It often happens during unmounting of
44
- // phoenix hooks. Let's make sure that descriptor informs other components, that context is being
43
+ // blazor hooks. Let's make sure that descriptor informs other components, that context is being
45
44
  // destroyed.
46
45
  const originalDestroy = context.destroy.bind(context);
47
46
  context.destroy = async () => {
@@ -87,3 +86,10 @@ type EditorContextDescriptor = {
87
86
  editorContextId: string;
88
87
  context: ContextWatchdog<Context>;
89
88
  };
89
+
90
+ /**
91
+ * Type representing an Editor creator with a create method.
92
+ */
93
+ type EditorCreator = {
94
+ create: (...args: any) => Promise<Editor>;
95
+ };
@@ -1,3 +1,4 @@
1
+ export * from './cleanup-orphan-editor-elements';
1
2
  export * from './create-editor-in-context';
2
3
  export * from './get-editor-roots-values';
3
4
  export * from './is-single-root-editor';
@@ -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
3
  const EDITOR_WATCHDOG_SYMBOL = Symbol.for('elixir-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
- };
@@ -136,7 +136,7 @@ describe('ui-part component', () => {
136
136
 
137
137
  it('should handle destruction when mounted promise is not resolved yet', async () => {
138
138
  document.body.innerHTML = '';
139
- EditorsRegistry.the.reset();
139
+ await EditorsRegistry.the.reset();
140
140
 
141
141
  const el = renderTestUIPart(createUIPartSnapshot('toolbar'));
142
142
 
@@ -8,9 +8,9 @@ import { queryAllEditorIds } from './editor/utils';
8
8
  */
9
9
  export class UIPartComponentElement extends HTMLElement {
10
10
  /**
11
- * The promise that resolves when the UI part is mounted.
11
+ * Stops observing the editor registry and immediately runs any pending cleanup.
12
12
  */
13
- private mountedPromise: Promise<void> | null = null;
13
+ private unmountEffect: VoidFunction | null = null;
14
14
 
15
15
  /**
16
16
  * Mounts the UI part component.
@@ -26,9 +26,9 @@ export class UIPartComponentElement extends HTMLElement {
26
26
  return;
27
27
  }
28
28
 
29
- // If the editor is not registered yet, we will wait for it to be registered.
30
29
  this.style.display = 'block';
31
- this.mountedPromise = EditorsRegistry.the.execute(editorId, (editor) => {
30
+
31
+ this.unmountEffect = EditorsRegistry.the.mountEffect(editorId, (editor) => {
32
32
  if (!this.isConnected) {
33
33
  return;
34
34
  }
@@ -44,22 +44,23 @@ export class UIPartComponentElement extends HTMLElement {
44
44
  }
45
45
 
46
46
  this.appendChild(uiPart.element);
47
+
48
+ return () => {
49
+ this.innerHTML = '';
50
+ };
47
51
  });
48
52
  }
49
53
 
50
54
  /**
51
55
  * Destroys the UI part component. Unmounts UI parts from the editor.
52
56
  */
53
- async disconnectedCallback() {
57
+ disconnectedCallback() {
54
58
  // Let's hide the element during destruction to prevent flickering.
55
59
  this.style.display = 'none';
56
60
 
57
- // Let's wait for the mounted promise to resolve before proceeding with destruction.
58
- await this.mountedPromise;
59
- this.mountedPromise = null;
60
-
61
- // Unmount all UI parts from the editor.
62
- this.innerHTML = '';
61
+ // Stop observing the registry and run cleanup immediately.
62
+ this.unmountEffect?.();
63
+ this.unmountEffect = null;
63
64
  }
64
65
  }
65
66
 
@@ -20,6 +20,8 @@ export function createEditableBlazorInterop(element: HTMLElement, interop: DotNe
20
20
  const rootName = element.getAttribute('data-cke-root-name') ?? 'main';
21
21
 
22
22
  let unmounted = false;
23
+ let stopEffect: VoidFunction | null = null;
24
+
23
25
  let editorRef: unknown | null = null;
24
26
 
25
27
  let sync = createNoopSync<string>();
@@ -46,12 +48,8 @@ export function createEditableBlazorInterop(element: HTMLElement, interop: DotNe
46
48
  }
47
49
  };
48
50
 
49
- /**
50
- * Initializes the focus tracker and model listeners for the editor owning this editable.
51
- */
52
- const initializeSynchronization = async () => {
53
- const editor = await EditorsRegistry.the.waitFor(editorId);
54
- editorRef = DotNet.createJSObjectReference(editor);
51
+ stopEffect = EditorsRegistry.the.mountEffect(editorId, (editor) => {
52
+ editorRef = globalThis.DotNet.createJSObjectReference(editor);
55
53
 
56
54
  sync = createEditorValueSync(editor, {
57
55
  getCurrentValue: () => editor.getData({ rootName }) ?? '',
@@ -60,9 +58,20 @@ export function createEditableBlazorInterop(element: HTMLElement, interop: DotNe
60
58
  });
61
59
 
62
60
  syncRootAttributes = createRootAttributesUpdater(editor, rootName);
63
- };
64
61
 
65
- void initializeSynchronization();
62
+ return () => {
63
+ sync.unmount();
64
+
65
+ /* v8 ignore else -- @preserve */
66
+ if (editorRef) {
67
+ globalThis.DotNet?.disposeJSObjectReference(editorRef);
68
+ editorRef = null;
69
+ }
70
+
71
+ syncRootAttributes = null;
72
+ };
73
+ });
74
+
66
75
  document.body.addEventListener(CKEditor5ChangeDataEvent.EVENT_NAME, onDataChange);
67
76
  markElementAsInteractive(element);
68
77
 
@@ -76,14 +85,10 @@ export function createEditableBlazorInterop(element: HTMLElement, interop: DotNe
76
85
  }
77
86
 
78
87
  document.body.removeEventListener(CKEditor5ChangeDataEvent.EVENT_NAME, onDataChange);
79
- sync.unmount();
80
88
 
81
- if (editorRef) {
82
- DotNet.disposeJSObjectReference(editorRef);
83
- editorRef = null;
84
- }
89
+ stopEffect?.();
90
+ stopEffect = null;
85
91
 
86
- syncRootAttributes = null;
87
92
  unmounted = true;
88
93
  },
89
94
 
@@ -97,8 +102,6 @@ export function createEditableBlazorInterop(element: HTMLElement, interop: DotNe
97
102
  }
98
103
 
99
104
  await EditorsRegistry.the.waitFor(editorId);
100
-
101
- // Ensure sync is initialized before forwarding (waitFor guarantees the editor exists)
102
105
  sync.setValue(value);
103
106
  },
104
107