@@ -155,9 +160,7 @@ function Changesets() {
{showChangeset && (
<>
Changeset
-
- {JSON.stringify(mockDiffRevision.changeset?.changes, null, 2)}
-
+ {JSON.stringify(changeset?.changes, null, 2)}
>
)}
>
diff --git a/app/scenes/Document/components/Comments/CommentThreadItem.tsx b/app/scenes/Document/components/Comments/CommentThreadItem.tsx
index 27e71ac17d..31cc86ca5a 100644
--- a/app/scenes/Document/components/Comments/CommentThreadItem.tsx
+++ b/app/scenes/Document/components/Comments/CommentThreadItem.tsx
@@ -27,7 +27,9 @@ import { resolveCommentFactory } from "~/actions/definitions/comments";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import CommentMenu from "~/menus/CommentMenu";
-import CommentEditor from "./CommentEditor";
+import lazyWithRetry from "~/utils/lazyWithRetry";
+
+const CommentEditor = lazyWithRetry(() => import("./CommentEditor"));
import { HighlightedText } from "./HighlightText";
import { useDocumentContext } from "~/components/DocumentContext";
diff --git a/app/scenes/Document/components/DocumentTitle.tsx b/app/scenes/Document/components/DocumentTitle.tsx
index 6f0be38ada..f0aefb818d 100644
--- a/app/scenes/Document/components/DocumentTitle.tsx
+++ b/app/scenes/Document/components/DocumentTitle.tsx
@@ -25,7 +25,9 @@ import { PopoverButton } from "~/components/IconPicker/components/PopoverButton"
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import { useTranslation } from "react-i18next";
-import IconPicker from "~/components/IconPicker";
+import lazyWithRetry from "~/utils/lazyWithRetry";
+
+const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
type Props = {
/** ID of the associated document */
diff --git a/app/scenes/Document/components/RevisionViewer.tsx b/app/scenes/Document/components/RevisionViewer.tsx
index 9dbe954693..c8450ca9ff 100644
--- a/app/scenes/Document/components/RevisionViewer.tsx
+++ b/app/scenes/Document/components/RevisionViewer.tsx
@@ -13,6 +13,7 @@ import { richExtensions, withComments } from "@shared/editor/nodes";
import Diff from "@shared/editor/extensions/Diff";
import useQuery from "~/hooks/useQuery";
import { type Editor as TEditor } from "~/editor";
+import { ChangesetHelper } from "@shared/editor/lib/ChangesetHelper";
type Props = Omit & {
/** The ID of the revision */
@@ -44,15 +45,18 @@ function RevisionViewer(props: Props, ref: React.Ref) {
* Create editor extensions with the Diff extension configured to render
* the calculated changes as decorations in the editor.
*/
- const extensions = React.useMemo(
- () => [
+ const extensions = React.useMemo(() => {
+ const changeset = ChangesetHelper.getChangeset(
+ revision.data,
+ revision.before?.data
+ );
+ return [
...withComments(richExtensions),
- ...(showChanges && revision.changeset?.changes
- ? [new Diff({ changes: revision.changeset?.changes })]
+ ...(showChanges && changeset?.changes
+ ? [new Diff({ changes: changeset?.changes })]
: []),
- ],
- [revision.changeset, showChanges]
- );
+ ];
+ }, [revision.data, showChanges]);
return (
diff --git a/server/editor/index.ts b/server/editor/index.ts
index adde91a062..0807d17247 100644
--- a/server/editor/index.ts
+++ b/server/editor/index.ts
@@ -1,5 +1,8 @@
+import data from "@emoji-mart/data";
+import type { EmojiMartData } from "@emoji-mart/data";
import { Schema } from "prosemirror-model";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
+import { populateEmojiData } from "@shared/editor/lib/emoji";
import {
basicExtensions,
richExtensions,
@@ -7,6 +10,8 @@ import {
} from "@shared/editor/nodes";
import Mention from "@shared/editor/nodes/Mention";
+populateEmojiData(data as EmojiMartData);
+
const extensions = withComments(richExtensions);
export const extensionManager = new ExtensionManager(extensions);
diff --git a/shared/editor/extensions/CodeHighlighting.ts b/shared/editor/extensions/CodeHighlighting.ts
index 22ac0964c3..e171c19f76 100644
--- a/shared/editor/extensions/CodeHighlighting.ts
+++ b/shared/editor/extensions/CodeHighlighting.ts
@@ -4,7 +4,7 @@ import type { Node } from "prosemirror-model";
import type { Transaction } from "prosemirror-state";
import { Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
-import refractor from "refractor/core";
+import type refractorType from "refractor/core";
import { getLoaderForLanguage, getRefractorLangForLanguage } from "../lib/code";
import { isRemoteTransaction } from "../lib/multiplayer";
import { findBlockNodes } from "../queries/findChildren";
@@ -21,8 +21,17 @@ const languagePromises: Record<
Promise | undefined
> = {};
+let refractor: typeof refractorType | undefined;
+
+/** Lazily load refractor core. */
+async function getRefractor() {
+ refractor ??= (await import("refractor/core")).default;
+ return refractor;
+}
+
async function loadLanguage(language: string) {
- if (!language || refractor.registered(language)) {
+ const r = await getRefractor();
+ if (!language || r.registered(language)) {
return;
}
@@ -37,7 +46,7 @@ async function loadLanguage(language: string) {
languagePromises[language] = loader()
.then((syntax) => {
- refractor.register(syntax);
+ r.register(syntax);
return language;
})
.catch((err) => {
@@ -73,7 +82,7 @@ function getDecorations({
).filter((item) => item.node.type.name === name);
function parseNodes(
- nodes: refractor.RefractorNode[],
+ nodes: refractorType.RefractorNode[],
classNames: string[] = []
): {
text: string;
@@ -133,10 +142,10 @@ function getDecorations({
if (!lang) {
// do nothing
- } else if (refractor.registered(lang)) {
+ } else if (refractor?.registered(lang)) {
languagesToImport.delete(language);
- const nodes = refractor.highlight(block.node.textContent, lang);
+ const nodes = refractor!.highlight(block.node.textContent, lang);
const newDecorations = parseNodes(nodes)
.map((node: ParsedNode) => {
const from = startPos;
@@ -222,14 +231,12 @@ export function CodeHighlighting({
},
view: (view) => {
if (!highlighted) {
- // we don't highlight code blocks on the first render as part of mounting
- // as it's expensive (relative to the rest of the document). Instead let
- // it render un-highlighted and then trigger a defered render of highlighting
- // by updating the plugins metadata
- requestAnimationFrame(() => {
+ void getRefractor().then(() => {
if (!view.isDestroyed) {
view.dispatch(
- view.state.tr.setMeta("codeHighlighting", { loaded: true })
+ view.state.tr.setMeta("codeHighlighting", {
+ langLoaded: true,
+ })
);
}
});
diff --git a/shared/editor/lib/emoji.test.ts b/shared/editor/lib/emoji.test.ts
index 89a78a7e23..e6bc1664e2 100644
--- a/shared/editor/lib/emoji.test.ts
+++ b/shared/editor/lib/emoji.test.ts
@@ -1,4 +1,8 @@
-import { getNameFromEmoji, getEmojiFromName } from "./emoji";
+import { getNameFromEmoji, getEmojiFromName, loadEmojiData } from "./emoji";
+
+beforeAll(async () => {
+ await loadEmojiData();
+});
describe("getNameFromEmoji", () => {
it("returns the correct shortcode", () => {
diff --git a/shared/editor/lib/emoji.ts b/shared/editor/lib/emoji.ts
index 81efc6d999..f7cc870d97 100644
--- a/shared/editor/lib/emoji.ts
+++ b/shared/editor/lib/emoji.ts
@@ -1,6 +1,6 @@
-import data, { type EmojiMartData } from "@emoji-mart/data";
+import type { EmojiMartData } from "@emoji-mart/data";
-export const emojiMartToGemoji = {
+export const emojiMartToGemoji: Record = {
"+1": "thumbs_up",
"-1": "thumbs_down",
};
@@ -16,21 +16,52 @@ export const snakeCase = (str: string) => str.replace(/(\w)-(\w)/g, "$1_$2");
/**
* A map of emoji shortcode to emoji character. The shortcode is snake cased
* for backwards compatibility with those already encoded into documents.
+ * Populated lazily on first access to avoid loading @emoji-mart/data in the
+ * initial bundle.
*/
-export const nameToEmoji: Record = Object.values(
- (data as EmojiMartData).emojis
-).reduce((acc, emoji) => {
- const convertedId = snakeCase(emoji.id);
- // @ts-expect-error emojiMartToGemoji is a valid map
- acc[emojiMartToGemoji[convertedId] ?? convertedId] = emoji.skins[0].native;
- return acc;
-}, {});
+export let nameToEmoji: Record = {};
+
+let emojiDataLoaded = false;
+
+/**
+ * Synchronously populate nameToEmoji from the given emoji data. This mutates
+ * the existing object so references captured at init time (e.g. by
+ * markdown-it-emoji) are also updated.
+ *
+ * @param data The emoji mart data to populate from.
+ */
+export function populateEmojiData(data: EmojiMartData): void {
+ if (emojiDataLoaded) {
+ return;
+ }
+ for (const emoji of Object.values(data.emojis)) {
+ const convertedId = snakeCase(emoji.id);
+ nameToEmoji[emojiMartToGemoji[convertedId] ?? convertedId] =
+ emoji.skins[0].native;
+ }
+ emojiDataLoaded = true;
+}
+
+/**
+ * Lazily load the emoji data and populate nameToEmoji. Use this on the client
+ * to avoid including @emoji-mart/data in the initial bundle.
+ *
+ * @returns the populated nameToEmoji map.
+ */
+export async function loadEmojiData(): Promise> {
+ if (emojiDataLoaded) {
+ return nameToEmoji;
+ }
+ const { default: data } = await import("@emoji-mart/data");
+ populateEmojiData(data as EmojiMartData);
+ return nameToEmoji;
+}
/**
* Get the emoji character for a given emoji shortcode.
*
- * @param name The emoji shortcode
- * @returns The emoji character
+ * @param name The emoji shortcode.
+ * @returns the emoji character.
*/
export const getEmojiFromName = (name: string) =>
nameToEmoji[name.replace(/:/g, "")] ?? "?";
@@ -38,8 +69,8 @@ export const getEmojiFromName = (name: string) =>
/**
* Get the emoji shortcode for a given emoji character.
*
- * @param emoji The emoji character
- * @returns The emoji shortcode
+ * @param emoji The emoji character.
+ * @returns the emoji shortcode.
*/
export const getNameFromEmoji = (emoji: string) =>
Object.entries(nameToEmoji).find(([, value]) => value === emoji)?.[0];
diff --git a/shared/editor/nodes/Emoji.tsx b/shared/editor/nodes/Emoji.tsx
index f42dc5b673..106ff04fac 100644
--- a/shared/editor/nodes/Emoji.tsx
+++ b/shared/editor/nodes/Emoji.tsx
@@ -9,7 +9,7 @@ import type { Command } from "prosemirror-state";
import { Plugin, TextSelection } from "prosemirror-state";
import type { Primitive } from "utility-types";
import Extension from "../lib/Extension";
-import { getEmojiFromName } from "../lib/emoji";
+import { getEmojiFromName, loadEmojiData } from "../lib/emoji";
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
import emojiRule from "../rules/emoji";
import { isUUID } from "validator";
@@ -17,6 +17,13 @@ import type { ComponentProps } from "../types";
import { CustomEmoji } from "../../components/CustomEmoji";
export default class Emoji extends Extension {
+ constructor() {
+ super();
+ // Begin loading emoji data as soon as this extension is instantiated so
+ // it is available by the time the editor renders emoji nodes.
+ void loadEmojiData();
+ }
+
get type() {
return "node";
}
diff --git a/vite.config.ts b/vite.config.ts
index 9de1584343..93697d5569 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -3,7 +3,8 @@ import path from "node:path";
import react from "@vitejs/plugin-react-oxc";
import browserslistToEsbuild from "browserslist-to-esbuild";
import webpackStats from "rollup-plugin-webpack-stats";
-import { ServerOptions, defineConfig } from "vite";
+import type { ServerOptions } from "vite";
+import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
import environment from "./server/utils/environment";
@@ -160,6 +161,67 @@ export default () =>
assetFileNames: "assets/[name].[hash][extname]",
chunkFileNames: "assets/[name].[hash].js",
entryFileNames: "assets/[name].[hash].js",
+ advancedChunks: {
+ groups: [
+ // Shared utilities used across the app — higher priority
+ // prevents them being absorbed into lazy vendor chunks
+ {
+ name: "vendor-shared",
+ test: /node_modules[\\/]uuid|vite[\\/]preload-helper/,
+ priority: 30,
+ },
+ {
+ name: "vendor-react",
+ test: /node_modules[\\/](react|react-dom|scheduler|react-router)/,
+ priority: 20,
+ },
+ {
+ name: "vendor-prosemirror",
+ test: /node_modules[\\/](@benrbray[\\/])?prosemirror/,
+ priority: 20,
+ },
+ {
+ name: "vendor-collab",
+ test: /node_modules[\\/](yjs|y-prosemirror|y-indexeddb|@hocuspocus|lib0)/,
+ priority: 20,
+ },
+ {
+ name: "vendor-styled",
+ test: /node_modules[\\/]styled-components/,
+ priority: 20,
+ },
+ {
+ name: "vendor-mermaid",
+ test: /node_modules[\\/](mermaid|cytoscape|cytoscape-fcose|layout-base|dagre-d3-es|langium|chevrotain|roughjs|@mermaid-js)/,
+ priority: 20,
+ },
+ {
+ name: "vendor-katex",
+ test: /node_modules[\\/]katex/,
+ priority: 20,
+ },
+ {
+ name: "vendor-emoji",
+ test: /node_modules[\\/](@emoji-mart|emoji-mart)/,
+ priority: 20,
+ },
+ {
+ name: "vendor-lodash",
+ test: /node_modules[\\/](lodash|lodash-es)/,
+ priority: 20,
+ },
+ {
+ name: "vendor-date",
+ test: /node_modules[\\/]date-fns/,
+ priority: 20,
+ },
+ {
+ name: "vendor-sentry",
+ test: /node_modules[\\/]@sentry/,
+ priority: 20,
+ },
+ ],
+ },
},
},
},