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.
*/
@@ -167,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) {
@@ -184,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);
@@ -238,7 +255,7 @@ export function ImagePickerPreviews({