From a8530830a0d4cab7a7d5c7663a7066d3cd837db5 Mon Sep 17 00:00:00 2001 From: Shamim Date: Tue, 27 Oct 2026 05:33:47 +0600 Subject: [PATCH 1/2] perf: keep the photo lightbox and the date picker off the first load Every page paid for two libraries it only needed after a click. The layout imported the photo lightbox (a viewer, three plugins and two stylesheets), and the composer imported the schedule picker, which pulls in a whole calendar. Both sat in the bundle every visitor downloads before the app can hydrate, on a screen where neither is reachable until something is opened. Walking the static import graph showed what that cost: 194 modules and 22 packages reachable from the layout, 201 and 22 from the log. The lightbox was reachable twice over, once through the layout and once through the composer's image picker, and the calendar once through the composer. Loading the viewer and the picker lazily takes all three packages out of the eager graph: yet-another-react-lightbox, @daypicker/react and Radix's select. The layout now reaches 170 modules and 19 packages, the log 197 and 19. What moved stays a chunk away rather than a page away: the browser fetches it right after hydration, and the lightbox and the picker only appear once the photo or the schedule section is opened. Validating a scheduled time needed the date arithmetic without the calendar, so it moved to "@/lib/schedule", which the composer imports directly. --- .../js/components/chat/message-parts.tsx | 29 +++++++++++----- resources/js/components/commit-composer.tsx | 33 ++++++++++++------- .../js/components/commit-image-picker.tsx | 30 +++++++++++------ resources/js/components/schedule-picker.tsx | 19 +---------- resources/js/layouts/app-layout.tsx | 18 ++++++++-- resources/js/lib/schedule.ts | 22 +++++++++++++ 6 files changed, 99 insertions(+), 52 deletions(-) create mode 100644 resources/js/lib/schedule.ts diff --git a/resources/js/components/chat/message-parts.tsx b/resources/js/components/chat/message-parts.tsx index 604c0758b..ae8a8c648 100644 --- a/resources/js/components/chat/message-parts.tsx +++ b/resources/js/components/chat/message-parts.tsx @@ -1,6 +1,6 @@ import { Image, Mic, Pin, Reply, SmilePlus } from 'lucide-react'; import type { ReactNode, RefObject } from 'react'; -import { useEffect, useRef, useState } from 'react'; +import { lazy, Suspense, useEffect, useRef, useState } from 'react'; import { show as showMessageImage } from '@/actions/App/Http/Controllers/MessageImageController'; import MessageLinkPreviewController from '@/actions/App/Http/Controllers/MessageLinkPreviewController'; import { preloadEmojiGroups } from '@/components/chat/message-reactions'; @@ -9,7 +9,6 @@ import { CommitBody } from '@/components/commit-body'; import { objectUrlFor } from '@/components/commit-image-picker'; import { CommitImages } from '@/components/commit-images'; import type { ShownImage } from '@/components/commit-images'; -import { ImageViewer } from '@/components/image-viewer'; import { LinkPreview } from '@/components/link-preview'; import { bodySegments } from '@/lib/code'; import { isReplySwipe, replyPull, replyThreshold } from '@/lib/reply-swipe'; @@ -22,6 +21,16 @@ import type { MessageReply, } from '@/types'; +/** + * Only needed once an image in a message is opened, which keeps the viewer and its styles out of the chat dock's + * bundle, and so out of the first load of every page the dock is part of. + */ +const ImageViewer = lazy(() => + import('@/components/image-viewer').then((module) => ({ + default: module.ImageViewer, + })), +); + /** * Who wrote a message, as a reply's ghost names them. */ @@ -135,13 +144,15 @@ export function MessageImages({ images }: { images: MessageImage[] | File[] }) { ) : ( <> - setViewedIndex(null)} - downloadUrl={downloadUrl} - /> + + setViewedIndex(null)} + downloadUrl={downloadUrl} + /> + )} diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx index a8898760d..0e3304746 100644 --- a/resources/js/components/commit-composer.tsx +++ b/resources/js/components/commit-composer.tsx @@ -1,7 +1,7 @@ import { Form, Link, usePage } from '@inertiajs/react'; import { ChartBar, Clock3 } from 'lucide-react'; import type { KeyboardEvent } from 'react'; -import { useEffect, useRef, useState } from 'react'; +import { lazy, Suspense, useEffect, useRef, useState } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; import ScheduledCommitController from '@/actions/App/Http/Controllers/ScheduledCommitController'; import { ActionTooltip } from '@/components/action-tooltip'; @@ -32,10 +32,6 @@ import { LinkCardPicker } from '@/components/link-card-picker'; import { MentionTextarea } from '@/components/mention-textarea'; import type { PollDraft } from '@/components/poll-editor'; import { emptyPollDraft, PollEditor } from '@/components/poll-editor'; -import { - isExistingLocalTime, - SchedulePicker, -} from '@/components/schedule-picker'; import { StashLabel } from '@/components/stash-label'; import { Button } from '@/components/ui/button'; import { useImageAttachments } from '@/hooks/use-image-attachments'; @@ -57,6 +53,7 @@ import { } from '@/lib/commit'; import type { IssueKind } from '@/lib/issue-commands'; import { hasModKey, useKeyLabels } from '@/lib/keyboard'; +import { isExistingLocalTime } from '@/lib/schedule'; import { characterCount, cn, @@ -66,6 +63,16 @@ import { import { show } from '@/routes/user'; import type { User } from '@/types'; +/** + * Scheduling is behind a toggle, and the calendar it opens is heavy, so it loads when the composer is used + * rather than with the composer itself. Validating a time only needs the arithmetic in "@/lib/schedule". + */ +const SchedulePicker = lazy(() => + import('@/components/schedule-picker').then((module) => ({ + default: module.SchedulePicker, + })), +); + // The ring moves quickly while a post starts and slows toward the limit, yet only closes at the limit. function bodyProgress(count: number): number { return Math.log1p(count) / Math.log1p(maxBodyLength); @@ -370,13 +377,15 @@ function CommitComposerBody({ > Submit at - + + +

+ import('@/components/image-viewer').then((module) => ({ + default: module.ImageViewer, + })), +); + /** * Keeps the files the server would accept, and tells why any others were left out. */ @@ -298,14 +306,16 @@ export function ImagePickerPreviews({ })} - image.url} - downloadFilename={downloadFilename} - /> + + image.url} + downloadFilename={downloadFilename} + /> + {editing !== null && (

hour); const minutes = Array.from({ length: 12 }, (_, index) => index * 5); -function pad(value: number): string { - return String(value).padStart(2, '0'); -} - -/** - * Writes a date the way a `datetime-local` input does, in the browser's timezone. - */ -export function localDateTime(date: Date): string { - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; -} - -/** - * A local time skipped by a daylight saving change, like 02:30 on the night clocks go forward, comes back shifted. - */ -export function isExistingLocalTime(value: string): boolean { - return localDateTime(new Date(value)) === value; -} - function startOfDay(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } diff --git a/resources/js/layouts/app-layout.tsx b/resources/js/layouts/app-layout.tsx index 3e87fb4c4..5917e0a24 100644 --- a/resources/js/layouts/app-layout.tsx +++ b/resources/js/layouts/app-layout.tsx @@ -1,5 +1,5 @@ import { Head, usePage, usePoll } from '@inertiajs/react'; -import { useCallback, useEffect, useState } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useState } from 'react'; import { AppAside } from '@/components/app-aside'; import { AppNav, @@ -12,7 +12,6 @@ import { CommitComposerDialog, focusVisibleComposer, } from '@/components/commit-composer-dialog'; -import { CommitImageViewer } from '@/components/commit-image-viewer'; import { CommitStarPolling } from '@/components/commit-star-polling'; import { DeletedPageDialog } from '@/components/deleted-page-dialog'; import { endStream, GoLiveDialog } from '@/components/go-live-dialog'; @@ -36,6 +35,17 @@ import type { SearchAction } from '@/lib/search-actions'; import { cn } from '@/lib/utils'; import type { AppLayoutProps } from '@/types'; +/** + * The photo lightbox is only needed once a photo is opened, and it is heavy enough (a viewer, three plugins and + * their stylesheets) to keep off the first load every page pays for. Loading it lazily leaves it out of the + * bundle the layout is part of, and the browser fetches it right after hydration instead of blocking on it. + */ +const CommitImageViewer = lazy(() => + import('@/components/commit-image-viewer').then((module) => ({ + default: module.CommitImageViewer, + })), +); + export default function AppLayout({ children }: AppLayoutProps) { const { component, @@ -152,7 +162,9 @@ export default function AppLayout({ children }: AppLayoutProps) { {deletedPage === null ? ( - + + + ) : ( )} diff --git a/resources/js/lib/schedule.ts b/resources/js/lib/schedule.ts new file mode 100644 index 000000000..f2ce165ae --- /dev/null +++ b/resources/js/lib/schedule.ts @@ -0,0 +1,22 @@ +/** + * The pieces of the schedule picker that are only date arithmetic, kept apart from the picker itself so that + * anything validating a scheduled time does not have to load the calendar to do it. + */ + +export function pad(value: number): string { + return String(value).padStart(2, '0'); +} + +/** + * Writes a date the way a `datetime-local` input does, in the browser's timezone. + */ +export function localDateTime(date: Date): string { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +/** + * A local time skipped by a daylight saving change, like 02:30 on the night clocks go forward, comes back shifted. + */ +export function isExistingLocalTime(value: string): boolean { + return localDateTime(new Date(value)) === value; +} From 9c1a90513cf3f63ee9f1269c11fbadfcc386de59 Mon Sep 17 00:00:00 2001 From: Shamim Date: Tue, 27 Oct 2026 06:13:59 +0600 Subject: [PATCH 2/2] perf: fetch the image viewer when a preview opens, not when one is attached Making the viewer lazy only moved it off the critical path: React starts the import as soon as the element renders, so a composer holding an attached image pulled the whole lightbox down even when nobody opened it. It is now rendered on the first open and left rendered afterwards, so the chunk is asked for once somebody actually looks at an image. Closing still passes a null index, which is what keeps the lightbox animating out and keeping the caption it had, rather than being torn down mid-close. --- .../js/components/commit-image-picker.tsx | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/resources/js/components/commit-image-picker.tsx b/resources/js/components/commit-image-picker.tsx index c0fb3c202..0fa310f5c 100644 --- a/resources/js/components/commit-image-picker.tsx +++ b/resources/js/components/commit-image-picker.tsx @@ -175,6 +175,10 @@ export function ImagePickerPreviews({ const id = useId(); const [describedIndex, setDescribedIndex] = useState(null); const [viewedIndex, setViewedIndex] = useState(null); + // The viewer is a chunk of its own, and rendering it at mount fetches it as soon as a composer holds an + // image, whether or not a preview is ever opened. It is mounted on the first open and left mounted after + // that, so closing it passes a null index: the lightbox animates out and keeps the state it had. + const [hasOpenedViewer, setHasOpenedViewer] = useState(false); const urls = images.map(objectUrlFor); if (images.length === 0) { @@ -192,6 +196,11 @@ export function ImagePickerPreviews({ const altButtonId = (index: number) => `${id}-alt-${index}`; const previewButtonId = (index: number) => `${id}-preview-${index}`; + const openViewer = (index: number) => { + setHasOpenedViewer(true); + setViewedIndex(index); + }; + const closeViewer = () => { const indexToFocus = activeViewedIndex; setViewedIndex(null); @@ -246,7 +255,7 @@ export function ImagePickerPreviews({