-
Notifications
You must be signed in to change notification settings - Fork 3.7k
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
perf: code-split codemirror & lottie + optimize the build for modern apps #21602
perf: code-split codemirror & lottie + optimize the build for modern apps #21602
Conversation
(This retains stuff like classes, async functions, etc as-is)
This commit - replaces all usages of CodeEditor with LazyCodeEditor - makes LazyCodeEditor lazy-load the CodeEditor chunk on demand
…itor This removes the need for DynamicTextFieldControl.tsx to pull in code (`getLineCommentString()`) from the CodeEditor directory
AutocompleteDataType is imported by ~40 files around the codebase. Previously, it was defined in `utils/autocomplete/CodemirrorTernService`, which relies on the CodeMirror import. This meant that any file importing AutocompleteDataType also imported CodeMirror. This commit fixes that by moving AutocompleteDataType into a separate file.
aa26305
to
0b75083
Compare
app/client/.eslintrc.js
Outdated
// `no-restricted-imports` is disabled, as recommended in https://typescript-eslint.io/rules/no-restricted-imports/. | ||
// Please use @typescript-eslint/no-restricted-imports below instead. | ||
"no-restricted-imports": "off", | ||
"@typescript-eslint/no-restricted-imports": [ | ||
"error", | ||
{ | ||
paths: [ | ||
{ | ||
name: "codemirror", | ||
message: | ||
"Reason: If you want to call CodeMirror.on(), CodeMirror.Pos(), or similar functions, please don’t import CodeMirror directly. (This will cause it to be bundled in the main chunk.) Instead, assuming your function has access to CodeMirror’s editor or doc, use getCodeMirrorNamespaceFromEditor() or getCodeMirrorNamespaceFromDoc() functions to get the CodeMirror namespace from the editor or the doc.", | ||
// Allow type imports as they don’t lead to bundling the dependency | ||
allowTypeImports: true, | ||
}, | ||
], | ||
paths: [ | ||
{ | ||
name: "lottie-web", | ||
message: | ||
"Reason: Please don’t import lottie directly as it’s very large. Instead, use the utils/lazyLottie wrapper.", | ||
// Allow type imports as they don’t lead to bundling the dependency | ||
allowTypeImports: true, | ||
}, | ||
], | ||
}, | ||
], | ||
// Annoyingly, the `no-restricted-imports` rule doesn’t allow to restrict imports of | ||
// `editorComponents/CodeEditor` but not `editorComponents/CodeEditor/*`: https://stackoverflow.com/q/64995811/1192426 | ||
// So we’re using `no-restricted-syntax` instead. | ||
"no-restricted-syntax": [ | ||
"error", | ||
{ | ||
// Match all | ||
// - `import` statements | ||
// - that are not `import type` statements – we allow type imports as they don’t lead to bundling the dependency | ||
// - that import `editorComponents/CodeEditor` or `editorComponents/CodeEditor/index` but not `editorComponents/CodeEditor/<anything else>` | ||
// Note: using `\\u002F` instead of `/` due to https://eslint.org/docs/latest/extend/selectors#known-issues | ||
selector: | ||
"ImportDeclaration[importKind!='type'][source.value=/editorComponents\\u002FCodeEditor(\\u002Findex)?$/]", | ||
message: | ||
"Please don’t import CodeEditor directly – this will cause it to be bundled in the main chunk. Instead, use the LazyCodeEditor component.", | ||
}, | ||
], |
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.
The diff for this file is a bit annoying because .json
got renamed into .js
, and git didn’t match them. But the only new lines are these...
app/client/.eslintrc.js
Outdated
eslintConfig.overrides = [ | ||
// For CodeEditor, disable CodeEditor- and CodeMirror-specific import rules | ||
{ | ||
files: ["**/components/editorComponents/CodeEditor/**/*"], | ||
rules: { | ||
"@typescript-eslint/no-restricted-imports": | ||
getRestrictedImportsOverrideForCodeEditor(), | ||
"no-restricted-syntax": getRestrictedSyntaxOverrideForCodeEditor(), | ||
}, | ||
}, | ||
]; | ||
|
||
function getRestrictedImportsOverrideForCodeEditor() { | ||
const [errorLevel, existingRules] = | ||
eslintConfig.rules["@typescript-eslint/no-restricted-imports"]; | ||
|
||
const newPatterns = (existingRules.patterns ?? []).filter( | ||
(i) => i.group[0] !== "**/components/editorComponents/CodeEditor", | ||
); | ||
const newPaths = (existingRules.paths ?? []).filter( | ||
(i) => i.name !== "codemirror", | ||
); | ||
|
||
if (newPatterns.length === 0 && newPaths.length === 0) { | ||
return ["off"]; | ||
} | ||
|
||
return [errorLevel, { patterns: newPatterns, paths: newPaths }]; | ||
} | ||
|
||
function getRestrictedSyntaxOverrideForCodeEditor() { | ||
const [errorLevel, ...existingRules] = | ||
eslintConfig.rules["no-restricted-syntax"]; | ||
|
||
const newRules = existingRules.filter( | ||
(i) => | ||
i.selector !== | ||
"ImportDeclaration[source.value=/editorComponents\\u002FCodeEditor(\\u002Findex)?$/]", | ||
); | ||
|
||
if (newRules.length === 0) { | ||
return ["off"]; | ||
} | ||
|
||
return [errorLevel, ...newRules]; | ||
} |
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.
...and these
">0.2%", | ||
"not dead", | ||
"not ie <= 11", | ||
"not op_mini all" | ||
"defaults and supports es6-module" |
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.
Why the switch?
If you take the old Browserslist query (>0.2%, not dead, not ie <= 11, not op_mini all
) and paste it into the Babel REPL, you’ll see that this query makes classes and async functions transpile down to ES5:
The reason for this can be seen at browsersl.ist. This query matches Android 4, which has a 0.3% browser share and does not support any of the modern JS features:
To solve this, instead of adding yet another exception, I decided to switch to defaults and supports es6-module
. defaults
means > 0.5%, last 2 versions, Firefox ESR, not dead
, whereas supports es6-module
excludes weird ancient browsers like Opera Mini or KaiOS 2.5.
This switch keeps all the stuff like async/await
as-is and saves 0.67 minified MBs in the main bundle alone.
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 removed unused Lottie animations...)
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.
(...and renamed existing ones to .txt
so webpack can process them as an asset/resource
module instead of bundling the JSON right into the app.)
type LazyCodeEditorState = | ||
// The initial state when the state machine is initialized | ||
| "idle" | ||
// The state when the CodeEditor chunk is loading | ||
| "loading" | ||
// The state when the CodeEditor chunk is loading, but the user has interacted with the placeholder | ||
| "loading-interacted" | ||
// The state when we’re waiting for the idle callback to fire to render CodeEditor | ||
| "waiting-idle-callback" | ||
// The state when the CodeEditor must be rendered | ||
| "active" | ||
// The state when the CodeEditor must be rendered and made focused | ||
| "active-focused" | ||
// The state when the CodeEditor chunk failed to load | ||
| "error"; |
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.
To code-split the code editor, I re-used the existing LazyCodeEditorWrapper
component.
The component already had logic to
- rendering the code editor with
requestIdleCallback()
- and to speed up that render when the user interacts with the placeholder
With the addition of lazy-loading, the logic got really complex, so I decided to implement it as a barebones state machine. The comments should (hopefully) make it clear what’s going on, but just for the reference, here’s a full diagram of all states: https://stately.ai/registry/editor/b6ae19f0-6adb-47f7-a00b-dac5cf9a61bd?machineId=8e0d268e-57b2-4fe5-8f65-cb363a57f141
@@ -1 +1,6 @@ | |||
declare module "*.module.css"; | |||
|
|||
declare module "*.txt" { |
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.
Why is this necessary?
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.
This is needed to give import like
import welcomeConfettiAnimationURL from "assets/lottie/welcome-confetti.json.txt"; |
correct types.
@@ -0,0 +1,236 @@ | |||
import React, { useState, useEffect, useRef, useCallback } from "react"; |
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.
@hetunandu Could you review this a code?
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.
Looks good to me
@@ -0,0 +1,179 @@ | |||
/** @type {import('eslint').Linter.Config} */ |
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.
Why is this necessary?
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.
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.
// Annoyingly, the `no-restricted-imports` rule doesn’t allow to restrict imports of | ||
// `editorComponents/CodeEditor` but not `editorComponents/CodeEditor/*`: https://stackoverflow.com/q/64995811/1192426 | ||
// So we’re using `no-restricted-syntax` instead. | ||
"no-restricted-syntax": [ |
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.
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 about this? 0f463db
This splits out the base ESLint config (src/.eslintrc.base.json
) and makes the following packages use it:
client
client/cypress
client/packages/storybook
shared/ast
(🆕)
This definitely creates a bunch of new ESLint errors (all or almost all of them formatting-based), but I’d rather resolve them when we’re merging this upstream, since #21442 also tuned the ESLint configs somewhat.
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.
Yes, it's better that way. I think shared/ast
can be skipped for now. I would like to move it to packages.
As for formatting, please pay attention to #21679.
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’ll keep the ESLint changes for shared/ast
for now. I added them for consistency [to make every project with .eslintrc.json
inherit a shared config], and, as I understand, they won’t complicate moving shared/ast
to packages
.
But let me know if I misunderstood, and the inheritance there is actively harmful.)
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.
Thanks! The config will not complicate the movement. )
if (CachedCodeEditor) { | ||
this.state = "waiting-idle-callback"; | ||
} else { | ||
this.state = "loading"; |
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.
@iamakulov ,
This is the only place where we change the state to loading
, and the state has to be loading
to trigger the import. If the state is idle handleStateChange
is exiting, where exactly does the state change to loading?
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.
Here!
appsmith/app/client/src/components/editorComponents/LazyCodeEditor/index.tsx
Lines 186 to 188 in 4504a62
useEffect(() => { | |
stateMachine.current.transition("RENDERED"); | |
}, []); |
Note that I intentionally trigger the event from useEffect
. It’s not the fastest approach (we’re delaying loading until useEffect()
fires), but running side effects from render()
is not 100% safe, especially with concurrent React 18 features.
(Although I just tried, and I can’t come up with a case where that will actually lead to bugs in practice. But better safe than sorry I guess.)
This PR might be easier to review commit-by-commit.
Description
This PR
class
,async
/await
, etc is not transpiled anymoreIn total, this saves 1.34 minified MBs (8.34 → 7 MB), getting rid of these modules (+ making others smaller, thanks to the modern build target):
Type of change
How Has This Been Tested?
Test Plan
Issues raised during DP testing
Checklist:
Dev activity
QA activity: