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:
Tom Moor
2026-07-17 07:12:28 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 7329652ce9
commit 863f09371c
16 changed files with 141 additions and 70 deletions
+2 -2
View File
@@ -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,
},
{
+2 -2
View File
@@ -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,
},
{
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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;
}
+2 -2
View File
@@ -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,
+3 -3
View File
@@ -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,
}));
+3 -3
View File
@@ -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);
});
});
});
+47
View File
@@ -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;
}
}
-41
View File
@@ -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.
*