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

Asynchronously load tinyMCE the first time a classic block is edited #21684

Closed
wants to merge 15 commits into from
Closed

Asynchronously load tinyMCE the first time a classic block is edited #21684

wants to merge 15 commits into from

Conversation

sarayourfriend
Copy link
Contributor

@sarayourfriend sarayourfriend commented Apr 17, 2020

Description

Added LazyLoad component. This component allows us to wrap existing components and delay them until required 3rd party scripts and styles are asynchronously loaded. It also provides a hook for post-load setup that resolves before the children are rendered, guaranteeing that the children aren't active until the world around them is set up correctly.

I've then wrapped ClassicEdit in LazyLoad. This 100% does not work and is just a proof of concept.

A related PR: #21244

We'd replace load-scripts/styles in the current code here with requests to those endpoints.

Types of changes

New feature

Checklist:

  • My code is tested.
  • My code follows the WordPress code style.
  • My code follows the accessibility standards.
  • My code has proper inline documentation.
  • I've included developer documentation if appropriate.
  • I've updated all React Native files affected by any refactorings/renamings in this PR.

Testing instructions

  1. Checkout Editor: Move printing TinyMCE scripts into an action wordpress-develop#232 locally and configure Gutenberg to point towards wordpress-develop by following the instructions for wp-env configuration here: https://github.com/WordPress/gutenberg/blob/master/packages/env/README.md#wp-envjson. Run WordPress using npx wp-env start inside your Gutenberg repo.
  2. Checkout this PR locally and run npm run dev
  3. Open the block editor and search the page source for the TinyMCE source code; ensure it does not exist. Additionally, check that window.tinymce === undefined
  4. Edit a classic block and watch the network tab in your browser. Note that TinyMCE is loaded and window.tinymce is defined.
  5. Ensure TinyMCE translations are loading correctly by changing your user's locale to one with different translations than what you're starting with and confirming that the translations worked.
  6. Confirm that exiting a classic block and re-entering it (or another classic block) does not make a new request for TinyMCE or the translations.

This component allows us to wrap existing components
and delay them until required 3rd party scripts and
styles are asynchronously loaded. It also provides a
hook for post-load setup that resolves before the
children are rendered, guaranteeing that the children
aren't active until the world around them is set up
correctly.
echo "<!-- Skipping TinyMCE in favor of loading async -->";
}
}
remove_action( 'print_tinymce_scripts', 'wp_print_tinymce_scripts' );
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This action needs to be added in core, example PR here: WordPress/wordpress-develop#232

<LazyLoad
scripts={ [ 'wp-tinymce' ] }
styles={ [ 'wp-tinymce' ] }
onLoaded={ () => window.wpMceTranslation() }
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not actually sure whether this would be how we do it. Given we can fully control what gets printed when Gutenberg is loading through the print_tinymce_scripts we can 100% wrap wp_mce_translation's result in a function to run in onLoaded... but it's also been floated that along with the new REST API that will provide the TinyMCE script, we could return the info necessary for initializing the library's i18n as a JSON blob.

Right now I think I prefer just printing this wrapped in a function in the action, but curious what the trade offs people can think of would be.

sarayourfriend and others added 8 commits April 22, 2020 10:11
…ation

We were duplicating WP_Scripts and WP_Styles initialization
and therefore misrepresnting what was handles were actually
accessible through this API. By using the object, which for both
these classes is accessed through a getter for the static global
for each WP_Dependencies subclass, we can return the handles
that are actually available. This makes it so that any script
added by other plugins will also appear, for example.

This doesn't change the accessibility of those handles, they were
always already accessible.

Something to consider is whether we should implement a blacklist,
if there are scripts/styles that must never be requested in this
way, as in, if they were then we're doing something really wrong.
I'm not sure if any scripts like that exist today.
Rather than using `load-scripts.php` which only loads a small
subset of dependencies into a locally managed WP_Scripts/WP_Styles,
we can use the two new REST endpoints that use the globals
available throughout the rest of WP.

Each endpoint returns the full list of the URIs for the requested
dependencies as well as their respective dependencies. When multiple
dependencies are passed as in this format:

	/wp/v2/scripts/?dependency=dep_1&dependency=dep_2

The endpoint will return:

	[
		{
			handle: 'dep_1-dependency',
			src: '...',
		},
		{
			handle: 'dep_1-actual',
			src: '...'
		},
		{
			handle: 'dep_2-dependency',
			src: '...',
		},
		{
			handle: 'dep_2-actual',
			src: '...',
		}
	]

That is, it will return descriptions for the list of
dependencies you pass to the endpoint, as well as those
dependencies dependencies, in the correct order in which
they would need to be loaded.

That means we can consume the response and just create a
script or link element for each response to be able to
load the dependencies we've requested.

We'll also store the actually loaded dependency handles
rather than the ones requested through the props. The thought
here being that one component might have a dependency
which itself depends on the dependency of another component.

Using the example response above, if component Foo depends on
`dep_1-dependency` and Bar depends on `dep_1-actual`, if Bar
is rendered first, then Foo does not need to have its dependencies
asynchronously loaded as they were already loaded as part of
loading Bar's dependencies.

At this point, this code doesn't actually fully work for
the classic block. Something is still broken and I'm still
trying to work out what exactly it is. However, the scripts
and styles do indeed load and the initialization is successful.

I _think_ there might just be a problem where something isn't
being correctly awaited, but I've run out of steam today to
figure out just what's going on.
} else {
echo "<!-- Skipping TinyMCE in favor of loading async -->";
echo "<script type='text/javascript'>\n" .
"window.wpMceTranslation = function() {\n" .
Copy link
Member

@gziolo gziolo Apr 27, 2020

Choose a reason for hiding this comment

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

Shouldn't it be part of the REST API response in this async scenario?

I don't think it can be as simple as that for the block editor. Plugins still can register Meta Boxes that use TinyMCE heavily. AFC is a great example to test with.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah. Definitely an open question to me... I guess we can create a filter? The issue is the endpoint returns just the URI for the dependency, not the dependency script itself, so we can’t just concatenate the translation bit to it.

An alternative to this is to just have a tinymce-i18n endpoint to just return the json needed, the async onLoaded prop is suitable for that.

See inline comment for explanation for why this is necessary.

Also refactors the dependency loading code into a single function.
All of the logic is shared except for the actual DOM element creation.
While the component was pretty simple to understand on one read
through when there was no need for blocking, it didn't make sense to
repeat that complexity and the comment for it.

This also lets the component hide the complexity of loadScripts and
loadStyles from the exported function, as well as the already-loaded-
dependency tracking inside those closures.
Uses the "private" _WP_Editors class to retrieve the TinyMCE
translations JSON blob and requisite metadata for initializing
the editor on the frontend.

If we decide to follow this approach, we'll want to refactor
the methods used from _WP_Editors into free functions, or else
move them into a separate class that is only used for getting
the TinyMCE translations.

One marked improvement we could achieve is to avoid json_encoding
the translations only to have the JSON.stringified on the client.
We should just send them down as an object rather than an encoded
string. I didn't do that for this commit because it's going to
require a wee bit of refactoring in wp_mce_translation to get
the raw translation object back rather than a string. That refactoring
should just go along with the refactor that pulls stuff out of
_WP_Editors.
Comment on lines +229 to +239
onLoaded={ async () => {
const {
translations,
locale,
locale_script_handle: localeScriptHandle,
} = await apiFetch( { path: '/wp/v2/tinymce-i18n' } );

const { tinymce } = window;
tinymce.addI18n( locale, JSON.stringify( translations ) );
tinymce.ScriptLoader.markDone( localeScriptHandle );
} }
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@gziolo I think these changes will be interesting to you 😊

cc @griffbrad because we'd also discussed using an endpoint to retrieve the translation json.

*
* @see WP_REST_Controller
*/
class WP_REST_TinyMCE_I18n_Controller extends WP_REST_Controller {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Still POCing, needs tests and an actually correct schema!

@azaozz
Copy link
Contributor

azaozz commented Apr 28, 2020

I'm probably missing something (or a lot of things) but... Why is all the REST API stuff needed here? What is the purpose of it? Is it just to get a script's URL? Why would it need to use REST and then load the script(s) or stylesheet(s) without using REST? How is that better than loading them directly and not using REST at all?

If the current Script Loader in core needs to be used, it would be pretty simple to add a function that will get a script's handle as param, run wp, calculate the dependencies and output either the script tags plus the inline scripts, or the (concatenated) JS or CSS as a blob, or... anything else that makes sense.

In TinyMCE's case using REST imho seems even more "not-useful" as it doesn't have any dependencies and generally cannot be used as dependency. Getting the URL to tinymce.min.js would be as simple as doing window.tinyMCEPreInit.baseURL + '/tinymce.min.js';

@azaozz azaozz mentioned this pull request Apr 28, 2020
6 tasks
@StevenDufresne
Copy link
Contributor

I definitely share @azaozz's concern (and his missing of things 😊) that the REST calls to retrieve assets seem unnecessary seeing that we already know which blocks are registered and which assets are needed for those blocks on page load.

Theoretically it may feel nice to reduce ms in page loading time, but depending on number of unique blocks, we may move to a place where the page loads a few ms faster but we've introduced a longer wait to interact because x number of blocks has queued up x number of http requests and the browser is throttling the requests (most noticeable in high latency contexts).

With that being said, this PR is great because the REST call strategy is necessary when we don't know the assets ahead of time, as is the case with the block directory (although the block directory code can't be wrapped by a lazy load component at this moment in time). Anything learned here will be helpful for the block directory.

@aduth
Copy link
Member

aduth commented Apr 28, 2020

@azaozz @StevenDufresne If this was only ever intended to support TinyMCE, it might not be necessary to go to the extent of implementing a set of REST API endpoints. But assuming we'd want to apply this pattern to other blocks, or to other parts of the editor screen, I sense it's not something we can prepare ahead of time during the initial load. At least, I would think the full dependency tree of all scripts would be quite large and not something to send over the wire (worth verifying). Instead, I think we'd need to be able to compute on-the-fly if and when a script is needed to be loaded. As I see it, the endpoint largely encompasses what you've described as a function receiving a script handle, just that it's exposed via the REST API.

To the worry about delays from network requests: For the ones we can know ahead of time, there's already a pattern to preload REST API results at load time, so that the editor never needs to actually send a network request (rest_preload_api_request, editor example).

@sarayourfriend
Copy link
Contributor Author

Yes, to reiterate what @aduth is saying here and in #21738 (where I also wrote some more general comments about this being a generic approach to a specific solution... because we had to start somewhere and TinyMCE is a big win if we can get it), I don't want us to get bogged down in the weeds of TinyMCE's particularities.

RE: whether TinyMCE is too complicated a starting point for this... I feel strongly like I must be missing something here given who all is skeptical of this... but the draft PR does indeed work, you can try it out if you run WP with WordPress/wordpress-develop#232 to add the action that the Gutenberg PR uses to prevent TinyMCE from loading at the start. TinyMCE will not be loaded, but the second you edit a classic block, you'll see TinyMCE start to load and eventually the block works. I'll add more specific testing instructions to the PR to make it clear that this is a working POC.

RE: @azaozz concern about why a REST API is necessary: I'm in total agreement with @StevenDufresne here... the REST API helps out with the block directory is a non-trivial way. Below I propose a hybridzed approach based on @aduth's suggestion at the end of this comment #21738 (comment). Hopefully this will help explicate why we do need a REST API while also addressing your concern about spinning up WP for too many REST requests. For now if only the classic block uses this, I don't think the concern about excessive requests applies yet, and below I've described a way we can proactively mitigate that problem by basically doing as you've suggested @azaozz and using preloaded URIs.

By tracking the already loaded dependencies on the frontend inside a single component/object, we avoid having to pass around a list of the already loaded handles, which @azaozz mentioned was a big concern in the original trac ticket discussing async script loading. How I see it is that we don't need to have something that is expressly a concern of the client's state leak into WP... I think we can completely separate these concerns between the server and the client.

@aduth that's great that there's a way to preload that information to make it accessible without needing to make another request. Totally understand the problems here. I think that given there's a need for this in the block directory, I wonder if we can do a hybridized approach.

@aduth mentioned using the block registration API for this. That sounds great to me, it's totally possible for LazyLoad to grab the scripts/styles handles from the block metadata, and having it in the block metadata would make it also accessible for preloading the block's assets. Then all LazyLoad needs to do is say: do I already have the list of dependency handles and URIs for this block (i.e., have they been pre-loaded)? If so (like when the block is not being added through the directory), use those. If not, then we can make some rest calls to get the dependencies.

I think that this doesn't actually add a whole lot of complexity to the LazyLoad component, nor do I think it would be too difficult.

Because TinyMCE is such a big win (@griffbrad puts it a nice way... that TinyMCE is the equivalent of react, react-dom, and loads more) I'd like to propose the following:

  1. If y'all could tell me what it would take to get this PR merged (beyond adding tests and proper documentation) then I will get it to that point ASAP. Maybe making this experimental would help ease some concerns so folks don't start diving right into utilizing this (especially given pt 2 below)? What blockers do y'all see for me blocking this PR aside from tests and docs?
  2. As a next step/fast-follow, I'll start augmenting the block registration API to handle preloading dependency URIs, as a sort of v0.2 of LazyLoad. This is in-line with my general goals for this project... I took this up as a good starting point for moving towards a generally async block API, TinyMCE just made sense from a perspective of how big a win it'll be for almost every single block editor user.

@azaozz
Copy link
Contributor

azaozz commented Apr 28, 2020

the REST call strategy is necessary when we don't know the assets ahead of time, as is the case with the block directory

Right. The question is "how" to do this in a good, future-proof way, not whether to use REST or not. As far as I see there are two general directions:

  1. Get the new scripts URLs (using REST), then load them with some sort of scriptLoader that can preserve loading order or just plain old document.write() in the HTML head. This assumes script dependencies will be "partially calculated" in js (to remove already loaded scripts). Handling of "before" and "after" scripts may be somewhat problematic and needs to be built-in the scriptLoader.
  2. Inject a script node in the head and pass the new "script handle" and already loaded handles in the src. Then in PHP calculate the dependencies, remove the already loaded, and output the rest as a concatenated script (that goes directly inside the <script> tag). Alternatively instead of injecting a script node it may be a REST request with eval(). Here the loading order will be preserved as the scripts are concatenated in order, including the "before" and "after". It would be a bit harder to debug sometimes as the lazy-loaded scripts may be concatenated.

A third, imho best, option would be to refactor/re-think/redo WPs Script Loader and bring it from 2005 to 2020 :) Add support for defer, async, type=module, do HTTP/2 (non-concatenated) loading, etc. I know, this is way out-of-scope here but will need doing sooner or later, and adding something "temporary" now just pushes it back.

In short, thinking that #21244 and https://core.trac.wordpress.org/ticket/48654 are not quite ready yet and perhaps there is an opportunity to make this work better.

As for loading TinyMCE's scripts on demand, perhaps outputting the needed URLs on initial page load would be sufficient for now. The same is probably true for outputting the translation strings, they aren't that big.

@azaozz
Copy link
Contributor

azaozz commented Apr 28, 2020

I would think the full dependency tree of all scripts would be quite large and not something to send over the wire (worth verifying).

As far as I see the actual dependency data is not that big. The "before" and "after" add quite a bit to it but not sure they would be needed/wanted initially. However don't think we'd need to pre-load that data as we can get "chunks" of it through REST at any time, and just subtract the already loaded scripts.

Document types, props, functions, and use cases for the LazyLoad
component. Also adds WPNode to @wordpress/elements to allow for
the correct type annotation for a ReactNode prop (like children).
@StevenDufresne
Copy link
Contributor

@aduth @saramarcondes Great stuff!

I'll also admit that I overstated the HTTP request issues to draw out some of the mitigating strategies we have in mind. There are plenty of ways we will be able to create a progressive experience and this work is definitely the precursor, but in my opinion having some of those strategies on the board will be more beneficial than distracting.

How I see it is that we don't need to have something that is expressly a concern of the client's state leak into WP

Good call.

Here's some info on the Block Directory:

It currently works like this:

  • User searches for "Awesome Block", we return results (blocks)

    • We have the block assets in the response object from the search.
      • This is error prone, especially since we are making assumptions about how a plugin is architectured based solely on its code.
      • Plugin guidelines and a block.json file will make it easier, but the margin of error will never be 0.
  • The user clicks 'Add Block'

    • Call an endpoint to install the block
    • Use the assets returned in the first call and inject them into the dom
    • 🙏

A better flow would be:

  • User searches for "Awesome Block", we return results (blocks)
  • The user clicks 'Add Block'
    • Call an endpoint to install the block
      • Receive handle
    • Pass the handle to a Loader component/function.
      • It would have to run immediately -> The user wants to use the block immediately (based on the current experience)
    • Loader calls REST Endpoint, ... does its business.

The Loader component could easily be the LazyLoad component but as mentioned it needs to trigger immediately.

@sarayourfriend
Copy link
Contributor Author

sarayourfriend commented May 7, 2020

In the related issue, @gziolo mentioned that opening up an option for the classic block to be in block directory could potentially get the async loading capabilities. However, that would only solve the issue for when the classic block is not already used and would cause site owners to manage this preference. I think the solution that I have here where we create a way for blocks to load their dependencies even if they actually exist within a post is better, for a couple reasons:

  1. It doesn't require users to opt-in or change any of their behaviors to get a significant performance benefit
  2. It solves a broader range of TinyMCE performance problems (instead of just offsetting them for people not using TinyMCE, we solve it for everyone, even those using the classic block regularly)
  3. It creates a way for any block to follow this same pattern simply by declaring its dependencies (and the discussion above has prompted me to think more deeply of forward solutions)
  4. It sets us up to remove the responsibility of loaded dependencies from the block directory and off-load it to the block API itself (as in, we could remove the dependency loading code from the block directory altogether as the block's dependencies would be loaded when the block is first used, whether that's immediately after the block is installed or later if we decide user behaviors aren't inline with the current dependency loading strategy).

Ultimately, I think this boils down to this: the block directory already asynchronously loads dependencies for the blocks that support it, but once you have installed a block, you're now paying the penalty for it every time you load your editor. This means that blocks have a hidden cost that isn't obvious and would be impossible to communicate. Ultimately, that means that block developers get saddled with trying to make harder performance decisions than are necessary, if they're performance minded at all.

Moving towards a solution where we could get all blocks with dependencies to asynchronously load their dependencies means that we remove that hidden cost. It also opens us up for nice performance wins for core blocks with big dependencies like the classic block. In fact, that's the best place for us to start because we can experiment with a heavy use case to see what approaches are long-term viable.

@azaozz and @ellatrix, something @gziolo mentioned is that there may be some hidden dependency on TinyMCE in the editor that I haven't noticed yet. @gziolo said that y'all two would be the best to ask about this. When I search through the Gutenberg codebase, the only references to window.tinymce are either null safe or inside the edit of the classic block (which is guarded by the dependency loading wrapper). Is there anywhere else I need to take a look to make sure the core editor is okay? Smoke testing it locally I haven't noticed any issues. Also note that this work only prevents TinyMCE's default loading behavior when in the block editor, so everywhere else in the admin that uses TinyMCE won't be disrupted.

@sarayourfriend
Copy link
Contributor Author

Closing in favor of #23068 which is a more focused PR for strictly addressing async TinyMCE without any of the extra frills.

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