diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2f27abacb..d0607e61c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,7 +167,7 @@ jobs: bundle-size: needs: [setup, types, changes] - if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }} + if: ${{ (needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true') && github.repository == 'outline/outline' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 diff --git a/app/editor/components/EmojiMenu.tsx b/app/editor/components/EmojiMenu.tsx index da2b54ca10..f25355c489 100644 --- a/app/editor/components/EmojiMenu.tsx +++ b/app/editor/components/EmojiMenu.tsx @@ -39,7 +39,6 @@ const EmojiMenu = (props: Props) => { .map((item) => { // We snake_case the shortcode for backwards compatability with gemoji to // avoid multiple formats being written into documents. - // @ts-expect-error emojiMartToGemoji key const id = emojiMartToGemoji[item.id] || item.id; const type = determineIconType(id); const value = type === IconType.Custom ? id : snakeCase(id); diff --git a/app/models/Collection.ts b/app/models/Collection.ts index a44217e81c..bdf6899ff3 100644 --- a/app/models/Collection.ts +++ b/app/models/Collection.ts @@ -7,7 +7,6 @@ import { NavigationNodeType, type ProsemirrorData, } from "@shared/types"; -import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; import { sortNavigationNodes } from "@shared/utils/collections"; import type CollectionsStore from "~/stores/CollectionsStore"; import type Document from "~/models/Document"; @@ -128,12 +127,6 @@ export default class Collection extends ParanoidModel { return !this.permission; } - /** Returns whether the collection description is not empty. */ - @computed - get hasDescription(): boolean { - return this.data ? !ProsemirrorHelper.isEmptyData(this.data) : false; - } - @computed get isStarred(): boolean { return !!this.store.rootStore.stars.orderedData.find( diff --git a/app/models/Revision.ts b/app/models/Revision.ts index e09e001823..4017f373eb 100644 --- a/app/models/Revision.ts +++ b/app/models/Revision.ts @@ -7,7 +7,6 @@ import ParanoidModel from "./base/ParanoidModel"; import Field from "./decorators/Field"; import Relation from "./decorators/Relation"; import type RevisionsStore from "~/stores/RevisionsStore"; -import { ChangesetHelper } from "@shared/editor/lib/ChangesetHelper"; import { client } from "~/utils/ApiClient"; class Revision extends ParanoidModel { @@ -97,11 +96,6 @@ class Revision extends ParanoidModel { : null; } - @computed - get changeset() { - return ChangesetHelper.getChangeset(this.data, this.before?.data); - } - /** * Triggers a download of the revision in the specified format. * diff --git a/app/scenes/Collection/components/Overview.tsx b/app/scenes/Collection/components/Overview.tsx index 62c08b88fc..8e364759cf 100644 --- a/app/scenes/Collection/components/Overview.tsx +++ b/app/scenes/Collection/components/Overview.tsx @@ -87,7 +87,7 @@ function Overview({ collection, readOnly }: Props) { return ( <> {collections.isSaving && } - {(collection.hasDescription || can.update) && ( + {can.update && ( Loading…}> (); @@ -67,14 +68,17 @@ const CollectionScene = observer(function CollectionScene_() { const id = params.collectionSlug || ""; const urlId = id.split("-").pop() ?? ""; - const collection: Collection | null | undefined = collections.get(id); + const collection = collections.get(id); const can = usePolicy(collection); + const hasDescription = collection?.data + ? !ProsemirrorHelper.isEmptyData(collection.data) + : false; const { pins, count } = usePinnedDocuments(urlId, collection?.id); const [collectionTab, setCollectionTab] = usePersistedState( `collection-tab:${collection?.id}`, - collection?.hasDescription ? CollectionTab.Overview : CollectionTab.Recent, + hasDescription ? CollectionTab.Overview : CollectionTab.Recent, { listen: false, } @@ -130,7 +134,7 @@ const CollectionScene = observer(function CollectionScene_() { return ; } - const showOverview = can.update || collection?.hasDescription; + const showOverview = can.update || hasDescription; return ( @@ -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, + }, + ], + }, }, }, },