Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cloud integration #510

Merged
merged 1 commit into from
Sep 5, 2024
Merged

Cloud integration #510

merged 1 commit into from
Sep 5, 2024

Conversation

Mati365
Copy link
Member

@Mati365 Mati365 commented Jul 24, 2024

React CDN Integration

Related Vue integration: ckeditor/ckeditor5-vue#301
Related Angular integration: ckeditor/ckeditor5-angular#431

❓ Tl;dr

The useCKEditorCloud hook is designed for integrating CKEditor from a CDN into React applications, aiming to simplify the process of adding CKEditor by managing its asynchronous loading, handling errors, and providing access to the editor and its dependencies once loaded. This addresses the common challenge in React and other frameworks of integrating third-party JavaScript libraries, which involves managing asynchronous script loading and ensuring scripts and styles are loaded in the correct order without blocking the main thread.

Additionally, the hook specifically tackles the challenges associated with loading CKEditor from its CDN. It optimizes the loading process by preventing redundant script injections and handling race conditions and caching effectively. This ensures that scripts are not injected multiple times if they are already present, which is crucial for applications that dynamically destroy and reinitialize components, including CKEditor , as users navigate. This approach enhances performance and user experience by ensuring efficient and error-free loading of CKEditor's assets from the CDN.

Demos

Plain editor from CDN (with external plugins): http://localhost:5174/demos/cdn-react/index.html
Multiroot hook: http://localhost:5174/demos/cdn-multiroot-react/index.html

🔧 General format of the useCKEditorCloud hook call

The useCKEditorCloud hook is responsible for returning information that:

  1. CKEditor is still being downloaded from the CDN with status = "loading".
  2. An error occurred during the download when status = "error", then further information is returned in the error field.
  3. Returning the editor in the data field and its dependencies when status = "success".

Simplest config

The simplest usage example that will load CKEditor from CDN and return its data in cloud.data:

const App = () => {
  const cloud = useCKEditorCloud( {
    version: '42.0.1',
    // Optional flags:
    // languages: [ 'pl', 'en', 'de' ],
    // withPremiumFeatures: true,
    // plugins: {}
  } );

  if ( cloud.status !== 'success' ) {
    return null;
  }

  const { ClassicEditor, Essentials } = cloud.CKEditor;

  class MyClassicEditor extends ClassicEditor {
    public static override builtinPlugins = [
      Essentials,
      // ... other plugins
    ];
  }

  return (
    <CKEditor
      editor={ MyClassicEditor }
      data="Hello World!"
    />
  );
};

CKBox config

CKBox integration example:

export const CKEditorCKBoxCloudDemo = ( { content }: CKEditorCKBoxCloudDemoProps ): ReactNode => {
  const cloud = useCKEditorCloud( {
    version: '42.0.1',
    withCKBox: {
      version: '2.5.1'
    }
  } );

  if ( cloud.status === 'error' ) {
    console.error( cloud );
  }

  if ( cloud.status !== 'success' ) {
    return <div>Loading...</div>;
  }

  const {
    CKBox,
    CKBoxImageEdit,
    CKFinder,
    CKFinderUploadAdapter
  } = cloud.CKEditor;

  const CKEditorClassic = useCKCdnClassicEditor( {
    cloud,
    additionalPlugins: [
      CKBox,
      CKFinder,
      CKFinderUploadAdapter,
      CKBoxImageEdit
    ],
    ...
  } );

  return (
    <CKEditor
      editor={ CKEditorClassic }
      data={ content }
    />
  );
};

Third party plugins config

A more advanced example that allows specify whether external stylesheets or scripts should be loaded:

const cloud = useCKEditorCloud({
  version: '42.0.1',
  plugins: {
    YourPlugin: {
      scripts: ["https://example.com/your-plugin.js"],
      stylesheets: ["https://example.com/your-plugin.css"],
      getExportedEntries: () => window.YourPlugin,
    },
  },
});

if (cloud.status === "success") {
  const { ClassicEditor, Bold, Essentials } = cloud.CKEditor;
  const { SlashCommand } = cloud.CKEditorPremiumFeatures;
  const { YourPlugin } = cloud.CKPlugins;
}

⚠️ Potential blockers

  1. 🔴 The Window typings of CKEditor 5 are not present. So we have to manually re-export them:
declare global {
	interface Window {
		CKEDITOR: typeof CKEditor;
		ckeditor5: Window['CKEDITOR'];
	}
}

which is problematic when we want use type with prototype (like EventInfo):

obraz

So at this moment we have this:

declare module 'https://cdn.ckeditor.com/typings/ckeditor5-premium-features.d.ts' {
	export type * from 'ckeditor5-premium-features';
}

and later:

import type { EventInfo } from 'https://cdn.ckeditor.com/typings/ckeditor5.d.ts';

const onReady = ( evt: EventInfo ) => { ... };
  1. 🟡 A potential issue is the lack of batching for translation file downloads. Currently, each language is a separate file that needs to be fetched. This means that an editor with 2 languages and premium features enabled has to download 4 separate language files. It would be better to download all the files at once in a single request.

🔧 Operation algorithm of useCKEditorCloud

Dependency packages

Under the hood, the hook uses objects describing the dependencies of bundles served in the CDN. These objects define which styles and which scripts must be injected on the page to receive objects on the window returned by getExportedEntries. In other words, to download a bundle from CKEditor5, we pass the link to our CDN in scripts, similarly in stylesheets, and in getExportedEntries we return the window.CKEDITOR5 object. This object will then be returned by the hook after loading is complete.

Package format:

export type CKCdnResourcesPack<R = any> = {

  /**
   * List of resources to preload, it should improve the performance of loading the resources.
   */
  preload?: Array<string>;

  /**
   * List of scripts to load. Scripts are loaded in the order they are defined.
   */
  scripts?: Array<string | ( () => Awaitable<unknown> )>;
  /**
   * List of stylesheets to load. Stylesheets are loaded in the order they are defined.
   */
  stylesheets?: Array<string>;

  /**
   * Get JS object with global variables exported by scripts.
   */
  getExportedEntries?: () => Awaitable<R>;
};

Currently, we have two pre-defined packages:

  • Defined by createCKCdnBaseBundlePack, the main editor package, which returns:

    {
      scripts: [
        'https://cdn.ckeditor.com/ckeditor5/42.0.2/ckeditor5.umd.js',
        // ... and translations
      ],
    
      stylesheets: [
        'https://cdn.ckeditor.com/ckeditor5/42.0.2/ckeditor5.css',
      ],
    
      getExportedEntries: () => window.CKEDITOR,
    }
  • Defined by createCKCdnPremiumBundlePack, the package with premium editor features, which returns:

    {
      scripts: [
        'https://cdn.ckeditor.com/ckeditor5-premium-features/42.0.2/ckeditor5-premium-features.umd.js',
        // ... and translations
      ],
    
      stylesheets: [
        'https://cdn.ckeditor.com/ckeditor5-premium-features/42.0.2/ckeditor5-premium-features.css',
      ],
    
      getExportedEntries: () => window.CKEDITOR_PREMIUM_FEATURES,
    }

Depending on the provided configuration, the useCKEditorCloud hook merges these packages using the combineCKCdnBundlesPacks method and transforms them into this form:

{
  scripts: [
    'https://cdn.ckeditor.com/ckeditor5/42.0.2/ckeditor5.umd.js',
    'https://cdn.ckeditor.com/ckeditor5-premium-features/42.0.2/ckeditor5-premium-features.umd.js',
    // ... and translations
  ],

  stylesheets: [
    'https://cdn.ckeditor.com/ckeditor5/42.0.2/ckeditor5.css',
    'https://cdn.ckeditor.com/ckeditor5-premium-features/42.0.2/ckeditor5-premium-features.css',
  ],

  getExportedEntries: () => ( {
    CKEditorPremiumFeatures: window.CKEDITOR_PREMIUM_FEATURES,
    CKEditor: window.CKEDITOR,
  } )
}

This form is extended by other UMD scripts (like WIRIS) and thanks to exports from createCKCdnBaseBundlePack, it allows specifying whether users want to download only styles, only JS, or both. By merging, we get the ability to load all css styles and js scripts before starting to load the main code.

HTML Injection

Preload

At the moment of the first render of the App component, the useCKEditorCloud will inject tags responsible for rel=preload into the head:

<link injected-by="ck-editor-react" rel="preload" as="style" href="https://cdn.ckeditor.com/ckeditor5/42.0.1/ckeditor5.css">
<link injected-by="ck-editor-react" rel="preload" as="script" href="https://cdn.ckeditor.com/ckeditor5/42.0.1/ckeditor5.umd.js">
<link injected-by="ck-editor-react" rel="preload" as="script" href="https://cdn.ckeditor.com/ckeditor5/42.0.1/translations/en.umd.js">

This informs the browser that while downloading .css files, it can also start downloading .umd.js files. This counteracts a dependency cascade where we would have to wait for the main CKEditor bundle to load, then the CKEditor Premium Features bundle, and then some custom plugin code before starting the editor initialization. preload loads them all in parallel. This is noticeable on slower connections, it is not required for the implementation itself, but significantly improves UX by about 500-600ms on load at Fast 3G speed.

Target resources

<link injected-by="ck-editor-react" rel="stylesheet" href="https://cdn.ckeditor.com/ckeditor5/42.0.1/ckeditor5.css">
<script injected-by="ck-editor-react" type="text/javascript" async="" src="https://cdn.ckeditor.com/ckeditor5/42.0.1/ckeditor5.umd.js"></script>
<script injected-by="ck-editor-react" type="text/javascript" async="" src="https://cdn.ckeditor.com/ckeditor5/42.0.1/translations/en.umd.js"></script>

When the hook has preloaded the resources, it proceeds to load the target scripts and creates a Promise from this. This is problematic because React does not support asynchronicity in components in the stable version. In the unstable version, it has a hook use, which currently cannot be used because we support React 16, which does not support it.

At this stage, it looks like this:

const pack = combineCKCdnBundlesPacks( {
  CKEditor: base,
  CKEditorPremiumFeatures: premium,
  CKPlugins: combineCKCdnBundlesPacks( plugins )
} );

const data = await loadCKCdnResourcesPack( pack );

Dependency packages are merged. All CSS styles are loaded, then JS, and finally plugins.

Handling Asynchrony in React

Since the handling of Promise in React without suspense does not exist, it is managed through a customly added hook useAsyncValue. Its usage looks roughly like this

const cloud = useAsyncValue(
  async (): Promise<CKEditorCloudResult<A>> => {
    const { packs } = normalizedConfig;
    const { base, premium, plugins } = packs;

    const pack = combineCKCdnBundlesPacks({
      CKEditor: base,
      CKEditorPremiumFeatures: premium,
      CKPlugins: combineCKCdnBundlesPacks( plugins )
    });

    return loadCKCdnResourcesPack(pack);
  },
  [serializedConfigKey]
);

After finishing loading, it will return:

{
  status: 'success',
  data: {
    CKEditor: window.CKEDITOR,
    CKEditorPremiumFeatures: window.CKEDITOR_PREMIUM_FEATURES,
    CKPlugins: {
      // additional external plugins loaded by the user
    }
  }
}

However, the hook also has loading, idle, and error states. The hook, with each change of serializedConfigKey, which is the config's form serialized to a string, reloads the data from the CDN. This allows controlling the loading of premium plugins and recreating the editor.

Handling race-conditions and cache

Each injecting function has a verification whether the script has already been embedded or not. Example for JS tags:

const INJECTED_SCRIPTS = new Map<string, Promise<void>>();
/**
 * Injects a script into the document.
 *
 * @param src The URL of the script to be injected.
 * @returns A promise that resolves when the script is loaded.
 */
export const injectScript = (src: string): Promise<void> => {
  // Return the promise if the script is already injected by this function.
  if (INJECTED_SCRIPTS.has(src)) {
    return INJECTED_SCRIPTS.get(src)!;
  }

  // Return the promise if the script is already present in the document but not injected by this function.
  // We are not sure if the script is loaded or not, so we have to show warning in this case.
  if (document.querySelector(`script[src="${src}"]`)) {
    console.warn('Script already injected:', src);
    return Promise.resolve();
  }

  // ...
}

thanks to this, destroying the component and initializing it at the moment when it was in the process of injecting the script does not cause problems and works. Navigation between pages that also have the same dependencies has been accelerated. This gives us the possibility to have 2 editors on the site - one having premium features, the other not.

🔧 General format of the withCKEditorCloud HOC

The main reason for the creation of this HOC is the hook useMyMultiRootEditor. Thanks to it, we avoid a situation where we have to add conditional rendering in the useMyMultiRootEditor hook:

const App = () => {
  const cloud = useCKEditorCloud({
    version: '42.0.1'
  });

  const result = useMultiRootEditor({
    editor: // What now? Cloud is not yet loaded.
  });
  if (cloud.status === 'loading') {
    return;
  }

  const { MultiRootEditor, Essentials } = cloud.CKEditor;

  class MyCustomMultiRootEditor extends MultiRootEditor {
    public static override builtinPlugins = [
      Essentials,
      ...
    ];
  };

  useMultiRootEditor({
    // ...
  })
};

In theory, modifying useMultiRootEditor is possible, but it would potentially cause problems with race-conditions and the stability of the integration itself at the time of quick loading flag switches cloud. Using HOC, we avoid conditioning, and the embedding component already has the cloud injected with initialized CKEditor constructors:

const withCKCloud = withCKEditorCloud({
  cloud: {
    version: '42.0.0',
    languages: ['en', 'de'],
    withPremiumFeatures: true
  }
});

const useMyMultiRootEditor = (cloud) => {
    const { MultiRootEditor, Essentials } = cloud.CKEditor;

    class MyCustomMultiRootEditor extends MultiRootEditor {
      public static override builtinPlugins = [
        Essentials,
        ...
      ];
    };
};

const MyComponent = withCKCloud(({ cloud }) => {
  const YourMultiRootEditor = useMyMultiRootEditor(cloud);
  const result = useMultiRootEditor({
    editor: YourMultiRootEditor
  });
});

@Mati365 Mati365 marked this pull request as draft July 24, 2024 07:09
@Mati365 Mati365 marked this pull request as ready for review July 28, 2024 15:33
@Mati365 Mati365 requested review from arkflpc and niegowski July 29, 2024 08:16
@Mati365 Mati365 requested a review from pszczesniak August 8, 2024 06:01
demos/cdn-react/CKEditorCKBoxCloudDemo.tsx Dismissed Show dismissed Hide dismissed
Copy link

@pszczesniak pszczesniak left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PoC is doing what should 👍

Few thoughts (applies to all frameworks):

We need to have on our mind that all 3rd party plugins need to be in UMD format - i mean, we know it, but we need to explicitly inform the community/integrators. If they use some plugins built for example by package-generator but not migrated yet (built and published) - it will be a problem.

Another thought - what about with plugins that are not published on NPM? I know that it's some kind of rare edge case but we got one - ckeditor5-mermaid, i know it's marked as experimental but do we want to only shrug to our potential clients?

In this particular case (ckeditor5-mermaid) we got dist in repo with UMD build, so it can be downloaded and hosted by integrator.

@Mati365
Copy link
Member Author

Mati365 commented Aug 12, 2024

@pszczesniak

Another thought - what about with plugins that are not published on NPM? I know that it's some kind of rare edge case but we got one - ckeditor5-mermaid, i know it's marked as experimental but do we want to only shrug to our potential clients?

const cloud = useCKEditorCloud({
  version: '42.0.1',
  plugins: {
    YourPlugin: {
      getExportedEntries: () => import( './your-plugin' ),
    },
  },
});

It can be done using async imports + proper globals configuration in bundlers (we have to point that ckeditor5 is CKEDITOR window dependency).

Copy link

@gorzelinski gorzelinski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested it briefly, and it works fine. I also looked through the files and added some nitpicks.

demos/cdn-multiroot-react/ContextMultiRootEditorDemo.tsx Outdated Show resolved Hide resolved
demos/cdn-multiroot-react/ContextMultiRootEditorDemo.tsx Outdated Show resolved Hide resolved
demos/cdn-react/CKEditorCKBoxCloudDemo.tsx Show resolved Hide resolved
src/cloud/cdn/ck/createCKCdnPremiumBundlePack.ts Outdated Show resolved Hide resolved
src/cloud/cdn/ck/createCKCdnPremiumBundlePack.ts Outdated Show resolved Hide resolved
src/cloud/useCKEditorCloud.tsx Outdated Show resolved Hide resolved
src/cloud/cdn/ck/createCKCdnUrl.ts Outdated Show resolved Hide resolved
@Mati365
Copy link
Member Author

Mati365 commented Aug 12, 2024

Thx @gorzelinski! I applied fixes.

demos/react/ContextDemo.tsx Show resolved Hide resolved
package.json Outdated Show resolved Hide resolved
@coveralls
Copy link

coveralls commented Aug 20, 2024

Pull Request Test Coverage Report for Build 60d18044-4cac-4919-9da1-9826eb395ef7

Details

  • 52 of 52 (100.0%) changed or added relevant lines in 5 files are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage remained the same at 100.0%

Totals Coverage Status
Change from base Build b63e3319-c29c-469a-9a26-761f5c32893e: 0.0%
Covered Lines: 582
Relevant Lines: 582

💛 - Coveralls

@Mati365 Mati365 changed the title Cloud integration PoC Cloud integration Aug 20, 2024
@Mati365 Mati365 requested a review from martnpaneq August 21, 2024 08:46
Copy link
Contributor

@martnpaneq martnpaneq left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks Good. I left two small comments to consider.

demos/cdn-react/CKEditorCloudPluginsDemo.tsx Show resolved Hide resolved
demos/cdn-react/CKEditorCloudDemo.tsx Outdated Show resolved Hide resolved
@Mati365 Mati365 merged commit ec8e603 into master Sep 5, 2024
5 checks passed
@Mati365 Mati365 deleted the ck/cloud-poc branch September 5, 2024 05:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants