1. Which first?
Phase 1 of the speed note. The cheap frontend edits. Not the full breakdown, and not the backend inbox rewrite.
Those edits sit in a few methods today. getUserEmail() waits 1000 ms to guess the other participant from messageList[0], even though the inbox row already has users[0].gsi4pk. History and chat-user-validation share userValidationCancel$, so starting one cancels the other. msgrightLoader hides the thread and the composer together. ngAfterViewChecked() calls detectChanges() on the whole 3,118-line template every cycle.
That is a small, measurable PR. History should show around 0.6 s after the component starts instead of 1.9 s, on the speed note's 300 ms assumption. No new files. No new endpoints.
Splitting first feels responsible because the file is ugly. It also copies the serial load, the shared cancel subject, and the one loader flag into every new child. Then Phase 1 becomes a cross-file rewrite of a tree we just invented. I have watched that kind of PR sit for weeks because nobody can tell if a missing tip prompt came from the move or from the new request timing.
The counter is real. Reviewing a behavior change in a 4,000-line class is miserable. Keep the Phase 1 diff tiny on purpose. Delete the timer. Give history and validation their own streams. Split the loader flag. Drop ngAfterViewChecked(). Track rows by groupId and messages by sk. Stop. Do not "clean up" the class in the same PR.
2. What the tree should look like
Today the route is one component. Modals already live next to it. ImageAudioMessageComponent is a second composer, not a bubble. Leave it until the main composer is out.
MessageContainerComponent route, 8,619 lines
├── Messages tab
│ ├── inbox (search, tabs, rows, mailing lists)
│ └── thread (header, history, composer)
├── Automated messages tab
│ └── app-table + AutomatedMessageModalComponent
└── already separate
├── NewMessageModelComponent
├── MessagePriceModelComponent
├── EditParticipantsModalComponent
└── share overlays (merch, portfolio, events)
The destination is a thin page plus a store. Children stay dumb enough for OnPush. Heavy widgets load when they are used.
MessagesPageComponent route shell, tabs, mobile back
MessagesStore inbox, selected thread, permissions, cache
InboxPanelComponent
├── InboxSearchComponent
├── ConversationListComponent
│ ├── ConversationRowComponent track groupId
│ └── BroadcastRowComponent mailing list / bulk
└── InboxEmptyComponent
ThreadPanelComponent
├── ThreadHeaderComponent avatar, name, online, block
├── MessageListComponent track sk / uinqueId
│ └── MessageItemComponent
│ ├── MediaMessageComponent @defer on viewport
│ └── AudioMessageComponent @defer on viewport
└── ComposerComponent
├── AttachmentTrayComponent paid / free drag list
├── TipComposerComponent
├── AudioRecorderComponent
└── emoji picker @defer on interaction
AutomatedMessagesPanelComponent @defer when that tab is open
└── existing table + modal
click-time import()
├── NewMessageModelComponent
├── MessagePriceModelComponent
├── EditParticipantsModalComponent
└── merch / portfolio / event share
Stop there. A header title does not need its own component. MessageItemComponent can switch on text, paid lock, share preview, and unlock without five more files. Virtualize the inbox later, and only if a creator's first page is regularly past about 100 rows. Leave the history list alone until a trace says otherwise. Variable-height media makes that list a bad first virtual-scroll target.
MessagesStore is the real split. Inbox selection, history pages, permissions, optimistic send, and websocket row patches have to leave the route component or the children will pass 40 inputs around and we will have made the pile worse. The speed note already wants this service for early fetch and a warm cache. Extract it once, use it for both jobs.
3. Both at once, or one after the other?
One after the other. Four PRs. Not one branch that splits, speeds, and "modernizes."
Phase 1 speedup in the current container. Timers, parallel streams, loader split, identity tracking, no ngAfterViewChecked(). Measure with performance.mark on a production build.
Store, then the first UI cut. MessagesStore first, then inbox / thread / composer as three components. Behavior stays the same. New files use Angular 21 APIs from the start.
Phase 2 deferral. Emoji, audio player, automated tab, share modals, video CSS. This is why the split exists. @defer and import() are awkward while those widgets sit in one imports array.
Phase 3 cache, early start, pagination, websocket row deltas. Pair with backend when those endpoints exist. Do not block Phase 1 on them.
Doing the split and the speedup in the same PR mixes a move with a behavior change. If a paid DM prompt vanishes, you will not know which half did it. The speed note already said each phase should ship and get measured before the next one. I agree with that part even where I disagree with starting on the tree.
You can discuss Phase 2 file names while Phase 1 is in review. You should not land them together.
4. Angular 21 in this task?
Ride along from the split. Do not do it before Phase 1. Do not do it as a repo-wide rewrite in this workstream.
The September 21-practices audit is stale in the places that matter for boot. AppModule is gone. HTTP is provideHttpClient(withFetch()). Hydration and provideRouter are in. The messages route is already standalone. Control flow already landed in this template. What is left is how new code is written, plus a long tail that is not this page's problem.
| Work | When | Why |
|---|---|---|
Signals, input() / output(), OnPush, inject(), takeUntilDestroyed(), @defer on new messages files | With the split | New files should not be born as 2018 Angular |
Rewrite the current container to signals and OnPush | Do not | OnPush plus detectChanges() in ngAfterViewChecked() on a 3,100-line template will look empty until someone clicks |
Zoneless, NgOptimizedImage everywhere, typed forms everywhere, Vitest | Later, other work | None of that unblocks the 1 s timer or the 450 KB of unused route JavaScript |
| Another framework bump | Already done | Package is 21.2.23. This is source style, not ng update |
Phase 1 stays default change detection on the old class. That is ugly and I would still do it. Switching the giant component to OnPush while it still mutates fields from websocket callbacks, setTimeout, and jQuery-adjacent DOM work is a QA trap. The children can be OnPush because they only read store signals.
What I would not do
Wait for new inbox APIs before deleting the timer
The backend questions in the speed note are still open. Typical payload size, whether history is already paged, how slow validation is, Dynamo keys. Fine. None of that is required to stop waiting a full second for messageList[0].
Extract every toolbar control
A 40-line "new message" button component is busywork. Split on load boundaries and on state ownership. Inbox, thread, composer, automated tab, store. That is the cut.
Treat the speed note as a ticket list
It says so itself. Phase 1 is a good ticket list. The v2 inbox contract, inline first thread, and summary table are proposals. Argue those with backend after Phase 1 numbers exist.
Risks if we ignore the order
| Move | What breaks |
|---|---|
| Split first | Paid DM, tip-required, white-label default chat, and optimistic uinqueId sends get rewired twice |
| Speed + split + 21 APIs in one PR | A review nobody can finish, and a revert that throws away the wait cut with the file move |
| OnPush on the current container | History, typing, and incoming websocket rows stop painting |
| Phase 3 before Phase 1 | We design cache keys on top of a load path that still sleeps for 1.5 s |
Things that still must not break after any of these PRs. Paid DM and tip-required stay disabled until permissions arrive, never default to free. White-label fans still open the owner chat when the user list is hidden. setInitalDisplay() still prefers Priority, then General, then Bulk. Header unread counts still match. Optimistic sends still match on uinqueId.
Still open
- The four backend questions in the speed note are unanswered. Do not block Phase 1 on them.
- I have no logged-in timings either. Replace the 0.6 s / 1.9 s guesses after Phase 1 ships.
ImageAudioMessageComponentduplicates composer logic. Fold it after the main composer is a component, not before.- App-shell JavaScript is still about 1.74 MB gzipped. A cold load of
/messagespays for that no matter how small this route gets. Separate task.