From f7985210f4857f1c959b26d7d663858c03922c4d Mon Sep 17 00:00:00 2001 From: Mateusz Baginski Date: Tue, 12 Nov 2024 10:14:05 +0100 Subject: [PATCH 1/2] Call `onChangeInitializedEditors` on startup of `CKEditorContext` if there are already initialized editors. --- demos/cdn-multiroot-react/main.tsx | 2 +- demos/cdn-react/main.tsx | 2 +- demos/npm-multiroot-react/main.tsx | 2 +- demos/npm-react/ContextDemo.tsx | 1 + demos/npm-react/main.tsx | 2 +- package.json | 5 +- src/context/useInitializedCKEditorsMap.ts | 18 +- tests/_utils/editor.ts | 4 + .../useInitializedCKEditorsMap.test.tsx | 157 ++++++++++++++++++ vite.config.ts | 2 +- yarn.lock | 58 +++---- 11 files changed, 211 insertions(+), 42 deletions(-) create mode 100644 tests/context/useInitializedCKEditorsMap.test.tsx diff --git a/demos/cdn-multiroot-react/main.tsx b/demos/cdn-multiroot-react/main.tsx index 73fd291e..cc513baf 100644 --- a/demos/cdn-multiroot-react/main.tsx +++ b/demos/cdn-multiroot-react/main.tsx @@ -8,7 +8,7 @@ import App from './App.js'; const element = document.getElementById( 'root' ) as HTMLDivElement; -if ( __REACT_VERSION__ === 16 ) { +if ( __REACT_VERSION__ <= 17 ) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const ReactDOM = await import( 'react-dom' ); diff --git a/demos/cdn-react/main.tsx b/demos/cdn-react/main.tsx index 4fa941c3..6557fbd9 100644 --- a/demos/cdn-react/main.tsx +++ b/demos/cdn-react/main.tsx @@ -8,7 +8,7 @@ import { App } from './App.js'; const element = document.getElementById( 'root' ) as HTMLDivElement; -if ( __REACT_VERSION__ === 16 ) { +if ( __REACT_VERSION__ <= 17 ) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const ReactDOM = await import( 'react-dom' ); diff --git a/demos/npm-multiroot-react/main.tsx b/demos/npm-multiroot-react/main.tsx index 73fd291e..cc513baf 100644 --- a/demos/npm-multiroot-react/main.tsx +++ b/demos/npm-multiroot-react/main.tsx @@ -8,7 +8,7 @@ import App from './App.js'; const element = document.getElementById( 'root' ) as HTMLDivElement; -if ( __REACT_VERSION__ === 16 ) { +if ( __REACT_VERSION__ <= 17 ) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const ReactDOM = await import( 'react-dom' ); diff --git a/demos/npm-react/ContextDemo.tsx b/demos/npm-react/ContextDemo.tsx index 3c1ee6aa..935abb55 100644 --- a/demos/npm-react/ContextDemo.tsx +++ b/demos/npm-react/ContextDemo.tsx @@ -44,6 +44,7 @@ export default function ContextDemo( props: ContextDemoProps ): JSX.Element { context={ ClassicEditor.Context as any } contextWatchdog={ ClassicEditor.ContextWatchdog as any } onChangeInitializedEditors={ editors => { + console.log( 'Initialized editors:', editors ); setState( editors as any ); } } > diff --git a/demos/npm-react/main.tsx b/demos/npm-react/main.tsx index 73fd291e..cc513baf 100644 --- a/demos/npm-react/main.tsx +++ b/demos/npm-react/main.tsx @@ -8,7 +8,7 @@ import App from './App.js'; const element = document.getElementById( 'root' ) as HTMLDivElement; -if ( __REACT_VERSION__ === 16 ) { +if ( __REACT_VERSION__ <= 17 ) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const ReactDOM = await import( 'react-dom' ); diff --git a/package.json b/package.json index 508778ee..5642194b 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,8 @@ "react-dom": "^18.0.0", "react16": "npm:react@^16.0.0", "react16-dom": "npm:react-dom@^16.0.0", + "react17": "npm:react@^17.0.0", + "react17-dom": "npm:react-dom@^17.0.0", "react18": "npm:react@^18.0.0", "react18-dom": "npm:react-dom@^18.0.0", "react19": "npm:react@19.0.0-beta-26f2496093-20240514", @@ -83,8 +85,9 @@ "node": ">=18.0.0" }, "scripts": { - "dev": "echo \"Use 'dev:16', 'dev:18', or 'dev:19' depending on the version of React you want to test\"", + "dev": "echo \"Use 'dev:16', 'dev:17, 'dev:18', or 'dev:19' depending on the version of React you want to test\"", "dev:16": "REACT_VERSION=16 vite", + "dev:17": "REACT_VERSION=17 vite", "dev:18": "REACT_VERSION=18 vite", "dev:19": "REACT_VERSION=19 vite", "build": "vite build && tsc --emitDeclarationOnly", diff --git a/src/context/useInitializedCKEditorsMap.ts b/src/context/useInitializedCKEditorsMap.ts index 14a62ffb..6030e986 100644 --- a/src/context/useInitializedCKEditorsMap.ts +++ b/src/context/useInitializedCKEditorsMap.ts @@ -6,7 +6,7 @@ import { useEffect } from 'react'; import { useRefSafeCallback } from '../hooks/useRefSafeCallback.js'; -import type { CollectionAddEvent, Context, ContextWatchdog, Editor } from 'ckeditor5'; +import type { CollectionAddEvent, Context, ContextWatchdog, Editor, GetCallback } from 'ckeditor5'; import type { ContextWatchdogValue } from './ckeditorcontext.js'; import { @@ -80,15 +80,25 @@ export const useInitializedCKEditorsMap = ( }; // Add the existing editors to the map. - const onAddEditor = ( _: unknown, editor: Editor ) => { + const trackEditorLifecycle = ( editor: Editor ) => { editor.once( 'ready', onEditorStatusChange, { priority: 'lowest' } ); editor.once( 'destroy', onEditorStatusChange, { priority: 'lowest' } ); }; - editors.on>( 'add', onAddEditor ); + const onAddEditorToCollection: GetCallback> = ( _, editor ) => { + trackEditorLifecycle( editor ); + }; + + editors.forEach( trackEditorLifecycle ); + editors.on>( 'add', onAddEditorToCollection ); + + // Fire the initial change event if there is at least one editor ready, otherwise wait for the first ready editor. + if ( Array.from( editors ).some( editor => editor.state === 'ready' ) ) { + onEditorStatusChange(); + } return () => { - editors.off( 'add', onAddEditor ); + editors.off( 'add', onAddEditorToCollection ); }; }, [ currentContextWatchdog ] ); }; diff --git a/tests/_utils/editor.ts b/tests/_utils/editor.ts index 54d97df9..d4bea904 100644 --- a/tests/_utils/editor.ts +++ b/tests/_utils/editor.ts @@ -18,6 +18,7 @@ export default class MockEditor { // In order to tests events, we need to somehow mock those properties. public static _on = (): void => {}; + public static _once = (): void => {}; public static _model = { document: createDocument(), @@ -30,11 +31,13 @@ export default class MockEditor { } }; + public declare state?: string; public declare element?: HTMLElement; public declare config?: Record; public declare model: any; public declare editing: any; public declare on: any; + public declare once: any; public declare data: any; public declare createEditable: any; public declare ui: any; @@ -52,6 +55,7 @@ export default class MockEditor { this.model = MockEditor._model; this.editing = MockEditor._editing; this.on = MockEditor._on; + this.once = MockEditor._once; this.data = { get() { return ''; diff --git a/tests/context/useInitializedCKEditorsMap.test.tsx b/tests/context/useInitializedCKEditorsMap.test.tsx new file mode 100644 index 00000000..87f71855 --- /dev/null +++ b/tests/context/useInitializedCKEditorsMap.test.tsx @@ -0,0 +1,157 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { Collection } from 'ckeditor5'; + +import { useInitializedCKEditorsMap } from '../../src/context/useInitializedCKEditorsMap.js'; + +import type { ContextWatchdogValue } from '../../src/context/ckeditorcontext.js'; +import type { CKEditorConfigContextMetadata } from '../../src/context/setCKEditorReactContextMetadata.js'; + +import MockEditor from '../_utils/editor.js'; + +describe( 'useInitializedCKEditorsMap', () => { + afterEach( () => { + vi.clearAllMocks(); + } ); + + it( 'should not call onChangeInitializedEditors when context is not initialized', () => { + const onChangeInitializedEditors = vi.fn(); + const mockWatchdog = { + status: 'initializing' as const, + watchdog: null + }; + + renderHook( () => useInitializedCKEditorsMap( { + currentContextWatchdog: mockWatchdog, + onChangeInitializedEditors + } ) ); + + expect( onChangeInitializedEditors ).not.toHaveBeenCalled(); + } ); + + it( 'should not track editors that are not ready', () => { + const editors = new Collection(); + const onChangeInitializedEditors = vi.fn(); + const notReadyEditor = createMockEditor( 'initializing', { name: '1' } ); + editors.add( notReadyEditor ); + + renderHook( () => useInitializedCKEditorsMap( { + currentContextWatchdog: createMockContextWatchdog( editors ), + onChangeInitializedEditors + } ) ); + + expect( onChangeInitializedEditors ).not.toHaveBeenCalled(); + } ); + + it( 'should track ready editors', () => { + const editors = new Collection(); + const onChangeInitializedEditors = vi.fn(); + const readyEditor = createMockEditor( 'ready', { name: '1' } ); + editors.add( readyEditor ); + + renderHook( () => useInitializedCKEditorsMap( { + currentContextWatchdog: createMockContextWatchdog( editors ), + onChangeInitializedEditors + } ) ); + + expect( onChangeInitializedEditors ).toHaveBeenCalledWith( + expect.objectContaining( { + '1': { + instance: readyEditor, + metadata: { + name: '1' + } + } + } ), + expect.anything() + ); + } ); + + it( 'should handle adding new editors to collection', () => { + const editors = new Collection(); + const onChangeInitializedEditors = vi.fn(); + + renderHook( () => useInitializedCKEditorsMap( { + currentContextWatchdog: createMockContextWatchdog( editors ), + onChangeInitializedEditors + } ) ); + + const newEditor = createMockEditor( 'ready', { name: '2' } ); + editors.add( newEditor ); + + // Simulate 'ready' event + const readyCallback = newEditor.once.mock.calls.find( ( call: any ) => call[ 0 ] === 'ready' )![ 1 ]; + readyCallback(); + + expect( onChangeInitializedEditors ).toHaveBeenLastCalledWith( + expect.objectContaining( { + '2': { + instance: newEditor, + metadata: { + name: '2' + } + } + } ), + expect.anything() + ); + } ); + + it( 'should handle editor destruction', () => { + const editors = new Collection(); + const onChangeInitializedEditors = vi.fn(); + const editor = createMockEditor( 'ready', { name: '1' } ); + editors.add( editor ); + + renderHook( () => useInitializedCKEditorsMap( { + currentContextWatchdog: createMockContextWatchdog( editors ), + onChangeInitializedEditors + } ) ); + + // Simulate 'destroy' event + const destroyCallback = editor.once.mock.calls.find( ( call: any ) => call[ 0 ] === 'destroy' )![ 1 ]; + editors.remove( editor ); + destroyCallback(); + + expect( onChangeInitializedEditors ).toHaveBeenLastCalledWith( + expect.objectContaining( {} ), + expect.anything() + ); + } ); +} ); + +function createMockEditor( state = 'ready', contextMetadata: CKEditorConfigContextMetadata, config = {} ) { + const editor = new MockEditor( document.createElement( 'div' ), { + get( name: string ) { + if ( name === '$__CKEditorReactContextMetadata' ) { + return contextMetadata; + } + + if ( Object.prototype.hasOwnProperty.call( config, name ) ) { + return config[ name ]; + } + + return undefined; + } + } ); + + editor.state = state; + editor.once = vi.fn(); + + return editor; +} + +function createMockContextWatchdog( editors = new Collection() ) { + return ( { + status: 'initialized' as const, + watchdog: { + context: { + editors + } + } + } ) as unknown as ContextWatchdogValue; +} diff --git a/vite.config.ts b/vite.config.ts index f93c36b5..4b37ae74 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -93,7 +93,7 @@ export default defineConfig( { resolve: { alias: { 'react': resolve( __dirname, `node_modules/react${ REACT_VERSION }` ), - 'react-dom/client': resolve( __dirname, `node_modules/react${ REACT_VERSION }-dom${ REACT_VERSION === 16 ? '' : '/client' }` ), + 'react-dom/client': resolve( __dirname, `node_modules/react${ REACT_VERSION }-dom${ REACT_VERSION <= 17 ? '' : '/client' }` ), 'react-dom': resolve( __dirname, `node_modules/react${ REACT_VERSION }-dom` ) } }, diff --git a/yarn.lock b/yarn.lock index d32fb509..57f3da80 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7618,7 +7618,7 @@ raw-loader@^4.0.1: loader-utils "^2.0.0" schema-utils "^3.0.0" -react-dom@^18.0.0: +react-dom@^18.0.0, "react18-dom@npm:react-dom@^18.0.0": version "18.3.1" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== @@ -7660,15 +7660,24 @@ react-refresh@^0.14.2: object-assign "^4.1.1" prop-types "^15.6.2" -"react18-dom@npm:react-dom@^18.0.0": - version "18.3.1" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" - integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== +"react17-dom@npm:react-dom@^17.0.0": + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" + integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== dependencies: loose-envify "^1.1.0" - scheduler "^0.23.2" + object-assign "^4.1.1" + scheduler "^0.20.2" + +"react17@npm:react@^17.0.0": + version "17.0.2" + resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" + integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" -"react18@npm:react@^18.0.0": +"react18@npm:react@^18.0.0", react@^18.0.0: version "18.3.1" resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== @@ -7687,13 +7696,6 @@ react-refresh@^0.14.2: resolved "https://registry.yarnpkg.com/react/-/react-19.0.0-beta-26f2496093-20240514.tgz#3a0d63746b3f9ebd461a0731191bd08047fb1dbb" integrity sha512-ZsU/WjNZ6GfzMWsq2DcGjElpV9it8JmETHm9mAJuOJNhuJcWJxt8ltCJabONFRpDFq1A/DP0d0KFj9CTJVM4VA== -react@^18.0.0: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" - integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== - dependencies: - loose-envify "^1.1.0" - read-cache@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" @@ -8038,6 +8040,14 @@ scheduler@^0.19.1: loose-envify "^1.1.0" object-assign "^4.1.1" +scheduler@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" + integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler@^0.23.2: version "0.23.2" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" @@ -8440,7 +8450,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -8454,13 +8464,6 @@ strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -9213,16 +9216,7 @@ workerpool@^6.5.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@7.0.0, wrap-ansi@^6.2.0, wrap-ansi@^7.0.0, wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@7.0.0, wrap-ansi@^6.2.0, wrap-ansi@^7.0.0, wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== From e06806b6179cc43161fe3ad7d8ba56daaf6a764e Mon Sep 17 00:00:00 2001 From: Mateusz Baginski Date: Tue, 12 Nov 2024 13:09:29 +0100 Subject: [PATCH 2/2] Fix typo --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5642194b..859b2c31 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "node": ">=18.0.0" }, "scripts": { - "dev": "echo \"Use 'dev:16', 'dev:17, 'dev:18', or 'dev:19' depending on the version of React you want to test\"", + "dev": "echo \"Use 'dev:16', 'dev:17', 'dev:18', or 'dev:19' depending on the version of React you want to test\"", "dev:16": "REACT_VERSION=16 vite", "dev:17": "REACT_VERSION=17 vite", "dev:18": "REACT_VERSION=18 vite",