mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
perf: Keep vendor-prosemirror out of the eager authenticated bundle (#13026)
* perf: Keep vendor-prosemirror out of the eager authenticated bundle Split the data-only helpers `getEmpty` and `isEmpty` out of `ProsemirrorHelper` into a new prosemirror-free `ProsemirrorDataHelper` module, and point the sidebar, DocumentNew, and other lightweight callers at it. This removes the static edge from the authenticated entry graph to `prosemirror-model`, deferring the ~653 KB vendor-prosemirror chunk until an editor surface is actually opened. Fixes #13005 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Use non-optional collection.data in defined branch Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7329652ce9
commit
863f09371c
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { ProsemirrorDataHelper } from "@shared/utils/ProsemirrorDataHelper";
|
||||
import { metaDisplay, shortcutSeparator } from "@shared/utils/keyboard";
|
||||
import type Share from "~/models/Share";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -42,7 +42,7 @@ function SharedSidebar({ share }: Props) {
|
||||
const shareId = share.urlId || share.id;
|
||||
const collection = collections.get(rootNode?.id);
|
||||
const hideRootNode = collection
|
||||
? ProsemirrorHelper.isEmptyData(collection?.data)
|
||||
? ProsemirrorDataHelper.isEmpty(collection.data)
|
||||
: false;
|
||||
|
||||
const handleOpenSearch = useCallback(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { ProsemirrorDataHelper } from "@shared/utils/ProsemirrorDataHelper";
|
||||
import type Collection from "~/models/Collection";
|
||||
import type Document from "~/models/Document";
|
||||
import type { RefHandle } from "~/components/EditableTitle";
|
||||
@@ -79,7 +79,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
collectionId: collection.id,
|
||||
title: input,
|
||||
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
data: ProsemirrorDataHelper.getEmpty(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import { DocumentPermission, UserPreference } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { ProsemirrorDataHelper } from "@shared/utils/ProsemirrorDataHelper";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import type Collection from "~/models/Collection";
|
||||
import type Document from "~/models/Document";
|
||||
@@ -353,7 +353,7 @@ const DocumentLinkInner = observer(function DocumentLinkInner({
|
||||
document?.fullWidth ??
|
||||
user.getPreference(UserPreference.FullWidthDocuments),
|
||||
title: input,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
data: ProsemirrorDataHelper.getEmpty(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useEffect, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { ProsemirrorDataHelper } from "@shared/utils/ProsemirrorDataHelper";
|
||||
import type Collection from "~/models/Collection";
|
||||
import type Document from "~/models/Document";
|
||||
import type Star from "~/models/Star";
|
||||
@@ -152,7 +152,7 @@ const StarredDocumentLink = observer(function StarredDocumentLink({
|
||||
document.fullWidth ??
|
||||
user.getPreference(UserPreference.FullWidthDocuments),
|
||||
title: input,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
data: ProsemirrorDataHelper.getEmpty(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
@@ -298,7 +298,7 @@ const StarredCollectionLink = observer(function StarredCollectionLink({
|
||||
collectionId: collection.id,
|
||||
title: input,
|
||||
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
data: ProsemirrorDataHelper.getEmpty(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import { s } from "@shared/styles";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { ProsemirrorDataHelper } from "@shared/utils/ProsemirrorDataHelper";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import type Collection from "~/models/Collection";
|
||||
import type Document from "~/models/Document";
|
||||
@@ -72,7 +72,7 @@ function Overview({ collection, readOnly }: Props) {
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection.id,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
data: ProsemirrorDataHelper.getEmpty(),
|
||||
...params,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -51,7 +51,7 @@ import Overview from "./components/Overview";
|
||||
import { Header } from "./components/Header";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { ProsemirrorDataHelper } from "@shared/utils/ProsemirrorDataHelper";
|
||||
|
||||
const CollectionScene = observer(function CollectionScene_() {
|
||||
const params = useParams<{ collectionSlug?: string }>();
|
||||
@@ -73,7 +73,7 @@ const CollectionScene = observer(function CollectionScene_() {
|
||||
const collection = collections.get(id);
|
||||
const can = usePolicy(collection);
|
||||
const hasDescription = collection?.data
|
||||
? !ProsemirrorHelper.isEmptyData(collection.data)
|
||||
? !ProsemirrorDataHelper.isEmpty(collection.data)
|
||||
: false;
|
||||
|
||||
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
||||
import type { RouteComponentProps, StaticContext } from "react-router";
|
||||
import { Redirect, useLocation } from "react-router";
|
||||
import { toError } from "@shared/utils/error";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { ProsemirrorDataHelper } from "@shared/utils/ProsemirrorDataHelper";
|
||||
import { RevisionHelper } from "@shared/utils/RevisionHelper";
|
||||
import type Document from "~/models/Document";
|
||||
import type Revision from "~/models/Revision";
|
||||
@@ -169,7 +169,7 @@ function DataLoader({ match, children }: Props) {
|
||||
{
|
||||
collectionId: nested ? undefined : document.collectionId,
|
||||
parentDocumentId: nested ? document.id : document.parentDocumentId,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
data: ProsemirrorDataHelper.getEmpty(),
|
||||
...params,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { ProsemirrorDataHelper } from "@shared/utils/ProsemirrorDataHelper";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import Flex from "~/components/Flex";
|
||||
import PlaceholderDocument from "~/components/PlaceholderDocument";
|
||||
@@ -47,7 +47,7 @@ function DocumentNew() {
|
||||
user.getPreference(UserPreference.FullWidthDocuments),
|
||||
templateId: query.get("templateId") ?? undefined,
|
||||
title: query.get("title") ?? "",
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
data: ProsemirrorDataHelper.getEmpty(),
|
||||
},
|
||||
{
|
||||
publish: collection?.id || parentDocumentId ? true : undefined,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { errToString } from "@shared/utils/error";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { ProsemirrorDataHelper } from "@shared/utils/ProsemirrorDataHelper";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Button from "~/components/Button";
|
||||
@@ -92,7 +92,7 @@ const TemplateSetting = observer(function Template_({ template }: Props) {
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!template.data || ProsemirrorHelper.isEmptyData(template.data)) {
|
||||
if (!template.data || ProsemirrorDataHelper.isEmpty(template.data)) {
|
||||
toast.message(t("A template must have content"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { errToString } from "@shared/utils/error";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { ProsemirrorDataHelper } from "@shared/utils/ProsemirrorDataHelper";
|
||||
import Template from "~/models/Template";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
@@ -54,7 +54,7 @@ function TemplateNewScene() {
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!template.data || ProsemirrorHelper.isEmptyData(template.data)) {
|
||||
if (!template.data || ProsemirrorDataHelper.isEmpty(template.data)) {
|
||||
toast.message(t("A template must have content"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { APIResponseError, APIErrorCode } from "@notionhq/client";
|
||||
import type { ImportTaskInput, ImportTaskOutput } from "@shared/schema";
|
||||
import type { IntegrationService, ProsemirrorDoc } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { ProsemirrorDataHelper } from "@shared/utils/ProsemirrorDataHelper";
|
||||
import { CollectionValidation, DocumentValidation } from "@shared/validations";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Integration } from "@server/models";
|
||||
@@ -125,7 +125,7 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
||||
...databaseInfo,
|
||||
icon: emoji,
|
||||
externalId: item.externalId,
|
||||
content: ProsemirrorHelper.getEmptyDocument() as ProsemirrorDoc,
|
||||
content: ProsemirrorDataHelper.getEmpty() as ProsemirrorDoc,
|
||||
collectionExternalId,
|
||||
children: pages.map((page) => ({
|
||||
type: page.type,
|
||||
|
||||
@@ -22,7 +22,7 @@ import Logger from "@server/logging/Logger";
|
||||
import type { ImportTask } from "@server/models";
|
||||
import { Attachment } from "@server/models";
|
||||
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { ProsemirrorDataHelper } from "@shared/utils/ProsemirrorDataHelper";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import FileStorage from "@server/storage/files";
|
||||
import type {
|
||||
@@ -323,7 +323,7 @@ export default class JSONAPIImportTask extends APIImportTask<Service> {
|
||||
urlId: c.export.urlId,
|
||||
icon: c.export.icon,
|
||||
color: c.export.color,
|
||||
data: c.export.data ?? ProsemirrorHelper.getEmptyDocument(),
|
||||
data: c.export.data ?? ProsemirrorDataHelper.getEmpty(),
|
||||
attachmentIdMap,
|
||||
})),
|
||||
];
|
||||
@@ -335,7 +335,7 @@ export default class JSONAPIImportTask extends APIImportTask<Service> {
|
||||
icon: c.export.icon,
|
||||
color: c.export.color,
|
||||
content: rewriteAttachmentReferences(
|
||||
c.export.data ?? ProsemirrorHelper.getEmptyDocument(),
|
||||
c.export.data ?? ProsemirrorDataHelper.getEmpty(),
|
||||
attachmentIdMap
|
||||
) as ProsemirrorDoc,
|
||||
}));
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Attachment } from "@server/models";
|
||||
import AttachmentHelper, {
|
||||
Buckets,
|
||||
} from "@server/models/helpers/AttachmentHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { ProsemirrorDataHelper } from "@shared/utils/ProsemirrorDataHelper";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import FileStorage from "@server/storage/files";
|
||||
import type { ZipTreeNode } from "@server/utils/ZipHelper";
|
||||
@@ -369,7 +369,7 @@ export default class MarkdownAPIImportTask extends APIImportTask<Markdown> {
|
||||
const collectionOutputs: ImportTaskOutput = collections.map((c) => ({
|
||||
externalId: c.id,
|
||||
title: c.title,
|
||||
content: ProsemirrorHelper.getEmptyDocument() as ProsemirrorDoc,
|
||||
content: ProsemirrorDataHelper.getEmpty() as ProsemirrorDoc,
|
||||
}));
|
||||
|
||||
// First wave of document tasks: only top-level docs in each collection.
|
||||
@@ -434,7 +434,7 @@ export default class MarkdownAPIImportTask extends APIImportTask<Markdown> {
|
||||
taskOutput.push({
|
||||
externalId: item.externalId,
|
||||
title: item.title,
|
||||
content: ProsemirrorHelper.getEmptyDocument() as ProsemirrorDoc,
|
||||
content: ProsemirrorDataHelper.getEmpty() as ProsemirrorDoc,
|
||||
});
|
||||
} else {
|
||||
const transformedMarkdown = this.rewriteMarkdown(item);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ProsemirrorData } from "../types";
|
||||
import { ProsemirrorDataHelper } from "./ProsemirrorDataHelper";
|
||||
|
||||
describe("ProsemirrorDataHelper", () => {
|
||||
describe("getEmpty", () => {
|
||||
it("returns a new empty document each call", () => {
|
||||
const a = ProsemirrorDataHelper.getEmpty();
|
||||
const b = ProsemirrorDataHelper.getEmpty();
|
||||
expect(a).toEqual({
|
||||
type: "doc",
|
||||
content: [{ content: [], type: "paragraph" }],
|
||||
});
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("produces data considered empty", () => {
|
||||
expect(
|
||||
ProsemirrorDataHelper.isEmpty(ProsemirrorDataHelper.getEmpty())
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEmpty", () => {
|
||||
it("returns false when the root is not a doc", () => {
|
||||
const data: ProsemirrorData = { type: "paragraph" };
|
||||
expect(ProsemirrorDataHelper.isEmpty(data)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a doc with no content", () => {
|
||||
expect(ProsemirrorDataHelper.isEmpty({ type: "doc" })).toBe(true);
|
||||
expect(ProsemirrorDataHelper.isEmpty({ type: "doc", content: [] })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true for a doc with a single empty paragraph", () => {
|
||||
const data: ProsemirrorData = {
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [] }],
|
||||
};
|
||||
expect(ProsemirrorDataHelper.isEmpty(data)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the single paragraph has content", () => {
|
||||
const data: ProsemirrorData = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "hi" }] },
|
||||
],
|
||||
};
|
||||
expect(ProsemirrorDataHelper.isEmpty(data)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when there are multiple nodes", () => {
|
||||
const data: ProsemirrorData = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [] },
|
||||
{ type: "paragraph", content: [] },
|
||||
],
|
||||
};
|
||||
expect(ProsemirrorDataHelper.isEmpty(data)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ProsemirrorData } from "../types";
|
||||
|
||||
/**
|
||||
* Helpers that operate on plain `ProsemirrorData` JSON.
|
||||
*/
|
||||
export class ProsemirrorDataHelper {
|
||||
/**
|
||||
* Get a new empty document.
|
||||
*
|
||||
* @returns a new empty document as JSON.
|
||||
*/
|
||||
static getEmpty(): ProsemirrorData {
|
||||
return {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
content: [],
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the data looks like an empty document.
|
||||
*
|
||||
* @param data The ProsemirrorData to check.
|
||||
* @returns True if the document is empty.
|
||||
*/
|
||||
static isEmpty(data: ProsemirrorData): boolean {
|
||||
if (data.type !== "doc") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.content?.length === 1) {
|
||||
const node = data.content[0];
|
||||
return (
|
||||
node.type === "paragraph" &&
|
||||
(node.content === null ||
|
||||
node.content === undefined ||
|
||||
node.content.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
return !data.content || data.content.length === 0;
|
||||
}
|
||||
}
|
||||
@@ -76,47 +76,6 @@ export class ProsemirrorHelper {
|
||||
return removeMarksInner(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new empty document.
|
||||
*
|
||||
* @returns a new empty document as JSON.
|
||||
*/
|
||||
static getEmptyDocument(): ProsemirrorData {
|
||||
return {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
content: [],
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the data looks like an empty document.
|
||||
*
|
||||
* @param data The ProsemirrorData to check.
|
||||
* @returns True if the document is empty.
|
||||
*/
|
||||
static isEmptyData(data: ProsemirrorData): boolean {
|
||||
if (data.type !== "doc") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.content?.length === 1) {
|
||||
const node = data.content[0];
|
||||
return (
|
||||
node.type === "paragraph" &&
|
||||
(node.content === null ||
|
||||
node.content === undefined ||
|
||||
node.content.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
return !data.content || data.content.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the node as plain text.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user