-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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
Lazily load CodeMirror assets #4500
Lazily load CodeMirror assets #4500
Conversation
https://webpack.js.org/guides/lazy-loading/ - I guess this would be a regular way to async load assets, but those seem to be very special so I think it this might be the best way to proceed. Keep working on it 👍 |
components/code-editor/index.js
Outdated
import withLazyDependencies from '../higher-order/with-lazy-dependencies'; | ||
|
||
// TODO: How can we avoid repeating all of this? | ||
const defaultSettings = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How is it exposed for Plugins' editor?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Normally these settings are assigned into wp.codeEditor.defaultSettings
by wp_enqueue_code_editor
here:
But since the whole idea of this PR is to avoid calling wp_enqueue_code_editor
(since it enqueues a lot of heavy assets), I've had to re-declare these editor settings.
One possibility is that we could have Core expose the default settings separately e.g. with a new wp_default_code_editor_settings()
method.
Any better ideas, @westonruter?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@noisysocks We actually did have a separate function originally for getting the settings, but then combined them into wp_enqueue_code_editor()
: WordPress/better-code-editing@70b96ca
Oh the irony.
Until a wp_default_code_editor_settings()
is introduced, a workaround could be to do this:
$gutenberg_editor_settings = array();
function gutenberg_capture_code_editor_settings_and_short_circuit_enqueues( $settings ) {
global $gutenberg_editor_settings;
$gutenberg_editor_settings = $settings;
return false;
}
add_filter( 'wp_code_editor_settings', 'gutenberg_capture_code_editor_settings_and_short_circuit_enqueues', 1000 );
wp_enqueue_code_editor();
remove_filter( 'wp_code_editor_settings', 'gutenberg_capture_code_editor_settings_and_short_circuit_enqueues', 1000 );
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a fun hack!
Thanks Weston. I'll play with it, and (eventually) create a trac ticket to put back wp_code_editor_settings
.
components/code-editor/index.js
Outdated
@@ -62,8 +140,19 @@ class CodeEditor extends Component { | |||
} | |||
|
|||
render() { | |||
return <textarea ref={ ref => this.textarea = ref } value={ this.props.value } />; | |||
return <textarea ref={ ref => ( this.textarea = ref ) } value={ this.props.value } />; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In other places I often see something like this:
// constructor
this.bindTextarea = ref => ( this.textarea = ref );
// render
return <textarea ref={ this.bindTextarea } value={ this.props.value } />;
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, isn't this pattern preferred because it means bindTextarea
doesn't change from render
-to-render
and so it is slightly more performant?
componentDidMount() { | ||
loadDependencies( scripts, styles ).then( () => { | ||
this.setState( { hasLoaded: true } ); | ||
} ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if any of the dependencies won't load?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Aye, we'll need to add a loading state and error state to withLazyDependencies
.
Maybe something like this:
const LazyComponent = withLazyDependencies( {
scripts: [ 'foo', 'bar' ],
styles: [ 'baz' ],
loadingComponent() {
return <Spinner />;
},
errorComponent( message ) {
return <div>{ sprintf( __( 'Uh oh! %s' ), message ) }</div>
},
} )( MyComponent );
In this PR we lazily load the assets that are regularly added to the page with But, yes, this is different to what we usually mean by lazy loading which is having Webpack defer the loading of a particular chunk until the application actually needs it. I've tried to avoid this ambiguity by choosing to use the word dependency. This is what we call enqueued assets and styles in Core. I'm open to other suggestions though! 😀 |
e2c619d
to
7714d1e
Compare
Introduces a new <CodeEditor> component which wraps a CodeMirror enhanced <textarea>. This component is then used to provide syntax highliting in the Custom HTML block.
This prevents the cursor from moving around to different blocks while editing code.
Make the CodeEditor in the HTML block look vaguely like what Joen mocked up in #1386.
Prevent the iframe from capturing pointer events and preventing the user from being able to select the block.
`componentDidUpdate` and `componentDidMount` fire after DOM reconcilation but before the frame is painted. CodeMirror's `focus()` method seems to only work *after* paint. Scheduling `focus()` to happen after the next frame is painted seems to be the best that we can do.
We only care that the component renders without an error and doesn't change unintentionally, which makes this a perfect use case for snapshot testing.
Bumps up the z-index of every element that is at the same depth as the block toolbar. By making these indicies greater than 4, we ensure our contextual elements appear over the CodeMirror editor. This stops the CodeMirror gutter from appearing over the block toolbar.
- Permit pressing ESCAPE to unfocus the currently selected block. - Allow pressing UP/DOWN to move focus to the previous/next block when the cursor is at the start/end of the editor.
7714d1e
to
7847ea2
Compare
e438832
to
b1db31d
Compare
components/code-editor/index.js
Outdated
@@ -102,4 +183,29 @@ class CodeEditor extends Component { | |||
} | |||
} | |||
|
|||
export default CodeEditor; | |||
export default withLazyDependencies( { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you think about calling it withLazyLoadedAssets
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like dependency since it's the word we use in Core. Happy to be persuaded otherwise though 🙂
components/code-editor/index.js
Outdated
'csslint', | ||
'jshint', | ||
// TODO: Gotta check if user can unfiltered_html | ||
...( true ? [ 'htmlhint-kses' ] : [] ), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can always give an option to pass object or function to this HOC. When there is a function you can call it with all props passed to the component and based on this perform required filtering.
const alreadyLoaded = {}; | ||
|
||
function loadScript( url ) { | ||
if ( alreadyLoaded[ url ] ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We perform url
based checks but pass a list of registered assets. What if there are two concurrent component being loaded at the same time where the same asset is being requested? Example:
withLazyDependencies( { scripts: [ 'foo', 'bar' ] } ( ComponentA );
withLazyDependencies( { scripts: [ 'foo', 'dummy' ] } ( ComponentB );
Should we care? I guess it depends on what we load, if scripts have side-effects then we probably should avoid loading it twice.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to solve it now? Not necessarily. I'm sharing so we were ready to take that into account when more than one component uses this HOC.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch! We should change this to check handles rather than URLs.
b1db31d
to
3bb0874
Compare
I'm glad that there was effort put into making this generic with the There was a bit of mention of such an idea in #2756:
|
I'll change the HOC to define a default loading/error placeholder 🙂
This is a cool idea. It makes a lot of sense to implement asynchronous Since the |
3bb0874
to
811ed1a
Compare
Adds a higher order function which wraps a component and lazily loads registered scripts and styles. This lets us avoid using wp_enqueue_code_editor() which adds considerable bulk (~ 1.9 MB) to the page load.
Avoid having to re-declare our CodeMirror configuration by capturing the settings from a wp_enqueue_code_editor() filter.
`htmlhint-kses` is only needed for users who don't have the `unfiltered_html` capability. We can determine this by checking for the presense of `wp.codeEditor.defaultSettingshtmlhint.kses`.
811ed1a
to
87714ec
Compare
This is ready to be reviewed now 😊 |
This feels a bit odd to me for a couple reasons though I love the direction it's going. First of all it's odd to me that we're communicating via globals here instead of what feels more idiomatic to JavaScript projects. That is, if I had a lazy-loading wrapper component I'd expect to pass it a function with takes the dependencies and produces a component. withLazyStuff( {
scripts: [ 'CodeMirror' ]
} )( ( { CodeMirror } ) => ( {
edit( attributes ) { … }
save( … ) { … }
} ) And inherent in that would be some indication that each dependency is either loading or loaded. Instead here it seems like we're waiting for all the dependencies to load and then we completely bail without drawing the wrapped component if any dependency fails. That's the second thing that I question here. Why should we block the render of a component just because it's not fully available? (Please pardon me if I'm misunderstanding the wrapper). When I put together the CodeMirror-based code editor, for example, I wanted to provide a basic I'd love to encourage a system of progressive enhancement where the wrapped component still has the control over its own render and lifecycle so that we don't give each other the all-or-nothing proposition. Finally, and maybe I think you wrote the right code here, but there's an inherent tradeoff to be made between using Thanks for working on this! It's super important. |
The core assumption that I've been working off of is that we want to use the version of CodeMirror that is exposed via WordPress exposes this configured editor via the I didn't look into using using
This is a cool idea. I worry though that the UX might be jarring. For example, if a user begins typing code into the plain text area, we would have to be careful to not lose their place once CodeMirror is loaded. I'm not totally convinced that the benefits of this kind of progressive enhancement outweigh the additional complexity involved in implementing it. |
that seems like a very good reason for a clear comment on why we're diverging from what I think many people will be expecting.
I'm not sure this is our tradeoff. We may still be able to ensure they are the same. I don't think it has to be solved here in order to resolve #4500 but at the same time I feel much more hesitant to build this as a generalized system in the wrapper vs. just performing this behavior in #4500 itself as a unique and one-off async instantiation.
This is up to the component itself. What is ultimately jarring is seeing a blank page. If for any reason these asynchronous calls fail then the editor is staring at unexplained placeholders. We can encode intentional behaviors in the components based on the presence of these dependencies. For example, in a very simple but still quite effective way we can say "wait up until one second for CodeMirror to load but if it doesn't load after that then just display the
it's not even additional complexity. the wrapper as written returns withLazyLoaded( {
'CodeMirror',
'bestDep'
} )( deps => ( {
edit( attributes, setAttributes ) {
Promise.all( deps ).then( () => setAttributes( { allLoaded: true } ) )
if ( ! allLoaded ) {
return <Placeholder />;
}
// do rest of normal logic
…
}
} ) Another component may only want enhancements… withLazyLoaded( {
'CodeMirror',
'bestDep'
} )( deps => ( {
BestDep = null,
edit( attributes, setAttributes ) {
deps.bestDep.then(
dep => BestDep = dep,
error => setAttributes( { couldNotLoad: error } )
)
…
return (
{ attributes.couldNotLoad && <ErrorNotice error={ attributes.couldNotLoad } /> }
{ this.dep && <BestDep /> }
)
}
} ) so that code is really sparse and probably really wrong but that's the idea. the promise exposes the lifecycle of the asynchronous load and there is some way to pass those dependencies as props/attributes. even with globals we can still do that when we want by transforming the global into a function parameter. (in the case where the global script does global things and we have nothing else to do that's not needed but then again we don't really need a wrapper for it either). could it be that for #4500 we don't really need a wrapper component but instead just need a way for Gutenberg to enqueue scripts? that seems like a much simpler problem with less need to understand how the API of a wrapper-component should operate. could add those scripts as properties on the block definition even then Gutenberg could lazy-load them for the blocks. |
Thanks for the detailed notes @dmsnell!
Yeah, OK. Perhaps let's punt on introducing
This is similar to what @aduth brought up in his comment above: we could have Gutenberg asynchronously load the script and style handles that are passed into I'm just not sure how we'd go about making one of our core block types (the HTML block) use server-side block registration. Would
My only hesitation with doing this is that it increases the surface area of the blocks API. |
In my approach, we'd change to have every single block emit its own bundle, then load only the block scripts which are relevant at a given time (editor page load for recent or common blocks, blocks intended to be inserted, etc). cc @gziolo |
☝️ because otherwise we wouldn't help ourselves too much - the server would still send the JavaScript even when not opening the editor |
How do we feel about, for now, lazily loading the WordPress CodeMirror library in a one-off manner? That is, I'll change this PR to remove the reusable HOC. I'd like to get CodeMirror into the HTML block sooner rather than later so that we can collect early feedback on it. Splitting blocks and their dependencies into seperate chunks sounds ideal but it will take some further discussion and time to get the details right. |
d9cbfaa
to
e51f342
Compare
This PR should have its base set to
master
once #4348 is merged!🤠 What this is
In #4348, we added the CodeMirror library so as to provide syntax highlighting and a nice editor experience for writing HTML blocks.
Unfortunately, though, this means an extra ~1.9 MB of assets are loaded when initialising Gutenberg.
The solution is to lazily load these assets. That is, rather than loading the CodeMirror library when Gutenberg initialises, we load it when a HTML block is first inserted into the post.
This PR accomplishes this by changing the
CodeEditor
component to use a newwithLazyDependencies
HOC:gutenberg/components/code-editor/index.js
Lines 110 to 129 in 87714ec
When
CodeEditor
is mounted, a<script src="/wp-admin/wp-load-scripts.php?load...">
node and a<link rel="stylesheet" href="/wp-admin/wp-load-styles.php?load=...">
node is inserted into the DOM which causes the assets to be asynchronously loaded.Check out the included README to read more about
withLazyDependencies
.✅ How to test