Skip to content

Commit 55eb679

Browse files
committed
Eliminate Tiptap (~500KB) from initial page load bundle
Root cause of 59s mobile FCP: Tiptap rich text editor was eagerly loaded on every dashboard page through three import paths: 1. dashboard-layout → ActivityLogDialog → RichTextEditor → @tiptap/* 2. mobile-nav → ActivityLogDialog → same chain 3. activity-feed → RichTextViewer → rich-text.ts → @tiptap/html + StarterKit Even RichTextViewer (just displays content) pulled in the full editor because rich-text.ts mixed lightweight utils with heavy Tiptap imports. Fixes: - Dynamic import ActivityLogDialog (dialog is closed by default, no reason to load 500KB of editor JS eagerly) - Dynamic import RichTextEditor in feed comments and activity detail (only loads when user expands comments) - Replace Tiptap's generateHTML in RichTextViewer with a lightweight ~2KB JSON→HTML converter (rich-text-html.ts) - Split rich-text.ts → rich-text-utils.ts for isEditorContentEmpty, MentionableUser type, and parsing functions (zero Tiptap deps) - Update all consumer imports to use lightweight utils module Result: Tiptap is completely absent from initial page load. It only loads on-demand when user interacts with the editor. https://claude.ai/code/session_012f5zvn8HuGn5waNPRYNAy2
1 parent e6aad93 commit 55eb679

11 files changed

Lines changed: 343 additions & 67 deletions

File tree

apps/web/app/challenges/[id]/activities/[activityId]/activity-detail-content.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,13 @@ import {
2626
} from 'lucide-react';
2727

2828
import { ConvexError } from 'convex/values';
29-
import { RichTextEditor } from '@/components/editor/rich-text-editor';
29+
import dynamic from 'next/dynamic';
3030
import { RichTextViewer } from '@/components/editor/rich-text-viewer';
31+
32+
const RichTextEditor = dynamic(
33+
() => import('@/components/editor/rich-text-editor').then((mod) => ({ default: mod.RichTextEditor })),
34+
{ ssr: false, loading: () => <div className="min-h-[120px] w-full animate-pulse rounded-md border border-input bg-background" /> }
35+
);
3136
import { UserAvatar, UserAvatarInline } from '@/components/user-avatar';
3237
import { Alert, AlertDescription } from '@/components/ui/alert';
3338
import { Badge } from '@/components/ui/badge';
@@ -66,7 +71,7 @@ import {
6671
} from '@/components/ui/select';
6772
import { Textarea } from '@/components/ui/textarea';
6873
import { useMentionableUsers } from '@/hooks/use-mentionable-users';
69-
import { isEditorContentEmpty, type MentionableUser } from '@/lib/rich-text';
74+
import { isEditorContentEmpty, type MentionableUser } from '@/lib/rich-text-utils';
7075
import { cn } from '@/lib/utils';
7176

7277
interface ActivityDetailContentProps {

apps/web/components/dashboard/activity-feed.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ import { useMutation, usePaginatedQuery } from 'convex/react';
1717
import { api } from '@repo/backend';
1818
import type { Id } from '@repo/backend/_generated/dataModel';
1919

20-
import { RichTextEditor } from '@/components/editor/rich-text-editor';
20+
import dynamic from 'next/dynamic';
2121
import { RichTextViewer } from '@/components/editor/rich-text-viewer';
22+
23+
const RichTextEditor = dynamic(
24+
() => import('@/components/editor/rich-text-editor').then((mod) => ({ default: mod.RichTextEditor })),
25+
{ ssr: false, loading: () => <div className="min-h-[120px] w-full animate-pulse rounded-md border border-input bg-background" /> }
26+
);
2227
import { useChallengeRealtime } from './challenge-realtime-context';
2328
import { UserAvatar, UserAvatarInline } from '@/components/user-avatar';
2429
import { Button } from '@/components/ui/button';
@@ -32,7 +37,7 @@ import {
3237
} from '@/components/ui/card';
3338
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
3439
import { useMentionableUsers } from '@/hooks/use-mentionable-users';
35-
import { isEditorContentEmpty, type MentionableUser } from '@/lib/rich-text';
40+
import { isEditorContentEmpty, type MentionableUser } from '@/lib/rich-text-utils';
3641
import {
3742
Dialog,
3843
DialogContent,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"use client";
2+
3+
import dynamic from "next/dynamic";
4+
import type { ReactNode } from "react";
5+
6+
const ActivityLogDialog = dynamic(
7+
() =>
8+
import("./activity-log-dialog").then((mod) => ({
9+
default: mod.ActivityLogDialog,
10+
})),
11+
{ ssr: false }
12+
);
13+
14+
interface ActivityLogDialogLazyProps {
15+
challengeId: string;
16+
challengeStartDate?: string;
17+
trigger?: ReactNode;
18+
}
19+
20+
export function ActivityLogDialogLazy(props: ActivityLogDialogLazyProps) {
21+
return <ActivityLogDialog {...props} />;
22+
}

apps/web/components/dashboard/activity-log-dialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
} from "@/components/ui/popover";
3838
import { DatePicker } from "@/components/ui/date-picker";
3939
import { useMentionableUsers } from "@/hooks/use-mentionable-users";
40-
import { isEditorContentEmpty } from "@/lib/rich-text";
40+
import { isEditorContentEmpty } from "@/lib/rich-text-utils";
4141
import { cn } from "@/lib/utils";
4242
import { localDateToIsoNoon, formatDateOnlyFromLocalDate, formatDateShortFromDateOnly } from "@/lib/date-only";
4343

apps/web/components/dashboard/dashboard-layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Plus } from "lucide-react";
55
import type { Doc } from "@repo/backend/_generated/dataModel";
66
import { formatDateShortFromDateOnly } from "@/lib/date-only";
77

8-
import { ActivityLogDialog } from "./activity-log-dialog";
8+
import { ActivityLogDialogLazy as ActivityLogDialog } from "./activity-log-dialog-lazy";
99
import { AnnouncementBanner } from "./announcement-banner";
1010
import { PaymentRequiredBanner } from "./payment-required-banner";
1111
import { DashboardNav } from "./dashboard-nav";

apps/web/components/dashboard/mobile-nav.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Link from "next/link";
44
import { usePathname } from "next/navigation";
55
import { Plus } from "lucide-react";
66

7-
import { ActivityLogDialog } from "./activity-log-dialog";
7+
import { ActivityLogDialogLazy as ActivityLogDialog } from "./activity-log-dialog-lazy";
88
import { navItems } from "./dashboard-nav";
99
import { cn } from "@/lib/utils";
1010

apps/web/components/editor/rich-text-viewer.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,48 @@
22

33
import { useMemo } from 'react';
44

5-
import { convertContentToHtml } from '@/lib/rich-text';
5+
import { convertContentToHtmlLite } from '@/lib/rich-text-html';
66
import { cn } from '@/lib/utils';
77

8+
type Maybe<T> = T | null | undefined;
9+
10+
function parseJsonContent(value: Maybe<string>) {
11+
if (typeof value !== 'string' || !value.trim().startsWith('{')) return null;
12+
try {
13+
const parsed = JSON.parse(value);
14+
return typeof parsed === 'object' && parsed !== null ? parsed : null;
15+
} catch {
16+
return null;
17+
}
18+
}
19+
20+
function escapeHtml(value: string): string {
21+
return value
22+
.replace(/&/g, '&amp;')
23+
.replace(/</g, '&lt;')
24+
.replace(/>/g, '&gt;')
25+
.replace(/"/g, '&quot;')
26+
.replace(/'/g, '&#039;');
27+
}
28+
29+
function toHtml(content: Maybe<string>): string | null {
30+
const doc = parseJsonContent(content);
31+
if (doc) {
32+
return convertContentToHtmlLite(doc);
33+
}
34+
if (typeof content === 'string' && content.trim().length > 0) {
35+
return escapeHtml(content).replace(/\n/g, '<br />');
36+
}
37+
return null;
38+
}
39+
840
interface RichTextViewerProps {
941
content?: string | null;
1042
className?: string;
1143
}
1244

1345
export function RichTextViewer({ content, className }: RichTextViewerProps) {
14-
const html = useMemo(() => convertContentToHtml(content), [content]);
46+
const html = useMemo(() => toHtml(content), [content]);
1547

1648
if (!html) {
1749
return null;

apps/web/hooks/use-mentionable-users.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useQuery } from 'convex/react';
44
import { api } from '@repo/backend';
55
import type { Id } from '@repo/backend/_generated/dataModel';
6-
import type { MentionableUser } from '@/lib/rich-text';
6+
import type { MentionableUser } from '@/lib/rich-text-utils';
77

88
export function useMentionableUsers(challengeId: string) {
99
const users = useQuery(api.queries.participations.getMentionable, {

apps/web/lib/rich-text-html.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* Lightweight JSON-to-HTML converter for Tiptap content.
3+
*
4+
* This replaces the heavy `generateHTML()` from @tiptap/html which pulls in
5+
* StarterKit (~500KB). The feed viewer only needs to render paragraphs, text
6+
* with marks, mentions, lists, blockquotes, code blocks, and hard breaks.
7+
*/
8+
9+
type JSONContent = {
10+
type?: string;
11+
attrs?: Record<string, unknown>;
12+
content?: JSONContent[];
13+
text?: string;
14+
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;
15+
};
16+
17+
function escapeHtml(value: string): string {
18+
return value
19+
.replace(/&/g, "&amp;")
20+
.replace(/</g, "&lt;")
21+
.replace(/>/g, "&gt;")
22+
.replace(/"/g, "&quot;")
23+
.replace(/'/g, "&#039;");
24+
}
25+
26+
function renderMarks(text: string, marks?: JSONContent["marks"]): string {
27+
if (!marks || marks.length === 0) return escapeHtml(text);
28+
29+
let html = escapeHtml(text);
30+
for (const mark of marks) {
31+
switch (mark.type) {
32+
case "bold":
33+
html = `<strong>${html}</strong>`;
34+
break;
35+
case "italic":
36+
html = `<em>${html}</em>`;
37+
break;
38+
case "strike":
39+
html = `<s>${html}</s>`;
40+
break;
41+
case "code":
42+
html = `<code>${html}</code>`;
43+
break;
44+
case "link": {
45+
const href = escapeHtml(String(mark.attrs?.href ?? ""));
46+
html = `<a href="${href}" rel="noopener noreferrer nofollow">${html}</a>`;
47+
break;
48+
}
49+
}
50+
}
51+
return html;
52+
}
53+
54+
function renderNode(node: JSONContent): string {
55+
if (node.type === "text" && typeof node.text === "string") {
56+
return renderMarks(node.text, node.marks);
57+
}
58+
59+
const children = node.content?.map(renderNode).join("") ?? "";
60+
61+
switch (node.type) {
62+
case "doc":
63+
return children;
64+
case "paragraph":
65+
return `<p>${children || "<br>"}</p>`;
66+
case "heading": {
67+
const level = Math.min(Math.max(Number(node.attrs?.level) || 1, 1), 6);
68+
return `<h${level}>${children}</h${level}>`;
69+
}
70+
case "blockquote":
71+
return `<blockquote>${children}</blockquote>`;
72+
case "bulletList":
73+
return `<ul>${children}</ul>`;
74+
case "orderedList": {
75+
const start = node.attrs?.start;
76+
const attr = start && start !== 1 ? ` start="${start}"` : "";
77+
return `<ol${attr}>${children}</ol>`;
78+
}
79+
case "listItem":
80+
return `<li>${children}</li>`;
81+
case "codeBlock":
82+
return `<pre><code>${children}</code></pre>`;
83+
case "horizontalRule":
84+
return "<hr>";
85+
case "hardBreak":
86+
return "<br>";
87+
case "mention": {
88+
const username =
89+
typeof node.attrs?.username === "string"
90+
? node.attrs.username
91+
: typeof node.attrs?.label === "string"
92+
? String(node.attrs.label).replace(/^@/, "")
93+
: String(node.attrs?.id ?? "");
94+
return `<span class="mention-token">@${escapeHtml(username)}</span>`;
95+
}
96+
default:
97+
return children;
98+
}
99+
}
100+
101+
/**
102+
* Convert Tiptap JSON content to HTML without importing @tiptap/html or StarterKit.
103+
* Supports paragraphs, headings, lists, blockquotes, code blocks, mentions,
104+
* and inline marks (bold, italic, strike, code, link).
105+
*/
106+
export function convertContentToHtmlLite(doc: JSONContent): string {
107+
return renderNode(doc);
108+
}

apps/web/lib/rich-text-utils.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Lightweight rich-text utilities that do NOT import Tiptap.
3+
*
4+
* Import from here (not rich-text.ts) when you only need parsing, empty checks,
5+
* or types — avoids pulling ~500KB of Tiptap into the client bundle.
6+
*/
7+
8+
type Maybe<T> = T | null | undefined;
9+
10+
type JSONContent = {
11+
type?: string;
12+
attrs?: Record<string, unknown>;
13+
content?: JSONContent[];
14+
text?: string;
15+
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;
16+
};
17+
18+
export interface MentionableUser {
19+
id: string;
20+
username: string;
21+
name: string | null;
22+
avatarUrl: string | null;
23+
}
24+
25+
export const MENTION_CLASS_NAME = 'mention-token';
26+
27+
function isJsonContent(value: Maybe<string>): value is string {
28+
return typeof value === 'string' && value.trim().startsWith('{');
29+
}
30+
31+
export function parseEditorContent(value: Maybe<string>): JSONContent | null {
32+
if (!isJsonContent(value)) {
33+
return null;
34+
}
35+
36+
try {
37+
const parsed = JSON.parse(value) as JSONContent;
38+
if (typeof parsed !== 'object' || parsed === null) {
39+
return null;
40+
}
41+
return parsed;
42+
} catch {
43+
return null;
44+
}
45+
}
46+
47+
function collectFromNode(
48+
node: JSONContent,
49+
state: { text: string[]; mentions: Set<string> },
50+
) {
51+
if (!node) return;
52+
53+
if (node.type === 'text' && typeof node.text === 'string') {
54+
state.text.push(node.text);
55+
}
56+
57+
if (node.type === 'mention') {
58+
const id = typeof node.attrs?.id === 'string' ? node.attrs.id : null;
59+
if (id) {
60+
state.mentions.add(id);
61+
}
62+
const label =
63+
typeof node.attrs?.label === 'string'
64+
? node.attrs.label
65+
: typeof node.attrs?.username === 'string'
66+
? `@${node.attrs.username}`
67+
: null;
68+
if (label) {
69+
state.text.push(label);
70+
}
71+
}
72+
73+
if (Array.isArray(node.content)) {
74+
for (const child of node.content) {
75+
collectFromNode(child, state);
76+
}
77+
}
78+
}
79+
80+
export function getPlainTextFromJson(doc: JSONContent | null): string {
81+
if (!doc) {
82+
return '';
83+
}
84+
85+
const state = { text: [] as string[], mentions: new Set<string>() };
86+
collectFromNode(doc, state);
87+
88+
return state.text.join(' ');
89+
}
90+
91+
export function getPlainTextFromValue(value: Maybe<string>): string {
92+
const doc = parseEditorContent(value);
93+
if (doc) {
94+
return getPlainTextFromJson(doc);
95+
}
96+
return typeof value === 'string' ? value : '';
97+
}
98+
99+
export function extractMentionedUserIds(value: Maybe<string>): string[] {
100+
const doc = parseEditorContent(value);
101+
if (!doc) {
102+
return [];
103+
}
104+
105+
const state = { text: [] as string[], mentions: new Set<string>() };
106+
collectFromNode(doc, state);
107+
return Array.from(state.mentions);
108+
}
109+
110+
export function isEditorContentEmpty(value: Maybe<string>): boolean {
111+
const text = getPlainTextFromValue(value);
112+
return text.trim().length === 0;
113+
}

0 commit comments

Comments
 (0)