Skip to content

Move the tiptap composer behind a first-focus handoff - #2390

Closed
vburojevic wants to merge 3 commits into
get-bb:mainfrom
vburojevic:bb/mobile-perf2/composer-first-focus
Closed

Move the tiptap composer behind a first-focus handoff#2390
vburojevic wants to merge 3 commits into
get-bb:mainfrom
vburojevic:bb/mobile-perf2/composer-first-focus

Conversation

@vburojevic

Copy link
Copy Markdown
Contributor

What was wrong

The tiptap/prosemirror editor graph sat in SplitWorkspaceRoute's static closure: 487 KB raw / 121 KB brotli parsed and compiled before route content paints on every thread, compose, and plugin-panel page, for an editor the user has not tapped. bundle-budget.json already named the intended fix — "allowed until it moves behind a first-focus handoff".

What changed

  • PromptBoxShell.tsx (new): the composer's exact closed-state chrome with zero tiptap imports. Its preview is a .ProseMirror-shaped contenteditable="false" surface, so existing placeholder/compact-preview CSS applies unchanged; a saved draft shows immediately.
  • PromptBox.tsx (new): renders the shell until the first compose intent (tap/focus, paste, drop, plus-menu action, programmatic focus request, desktop autoFocus), then dynamically imports and permanently mounts PromptBoxInternal. An invisible interim textarea owns the interim: the tap focuses it natively (soft keyboard opens in-gesture), keystrokes and pastes flush live into the draft, the swap defers during IME composition (never cutting a composition), and the editor takes focus at mount. The chunk prefetches at idle / pointerenter, so the handoff is normally mount-only. Manual import() instead of React.lazy so the swap can wait out IME composition.
  • bundle-budget.json: all 32 observed @tiptap/*/prosemirror-* packages gated on PromptBoxInternal.tsx in onDemandPackages; route closure budgets re-ratcheted 2,268,430→1,765,479 raw, 605,332→489,779 brotli so regression fails CI.

Honest sizing note: the measured closure saving is 451 KB raw / 104 KB brotli — the old 1.07 MB chunk was only ~49% tiptap; the rest is unrelated code that stays eager.

How you verified

  • New handoff suite (PromptBox.test.tsx, 8 tests) fails 7/8 against the eager architecture and passes 8/8 after: draft survives first tap, keystrokes/paste before mount land in the editor, file paste attaches, focusEnd()/focusEndKey realize and focus, Enter submits from the shell.
  • Typecheck/lint green; promptbox+thread-detail suites 485 passed; full @bb/app suite 3,350 passed.
  • Build + check:bundle green: closure 538.5→434.8 KB brotli; why-eager reports 0 eagerly reachable tiptap/prosemirror modules; a simulated static edge fails the check.
  • Merged-tree WebKit QA (iPhone viewport): shell tap mounts tiptap, typing lands, draft cleared on send. Reviewer to exercise CJK IME + autocorrect on a physical device.

AGENT GENERATED

vburojevic and others added 3 commits August 24, 2026 17:31
…oxShell

The tiptap/prosemirror graph (261.9 KB brotli) sits in SplitWorkspaceRoute's
static closure because the composer chrome and the editor live in one module.
Step 1 of the first-focus handoff (plan 008): extract the closed-state look —
frame, placeholder, draft preview, action row, send button — into
PromptBoxShell with zero tiptap imports.

The shell's preview surface is a `.ProseMirror`-shaped contenteditable=false
div, so the existing placeholder and compact-truncation CSS in app.css
applies unchanged; draft text and mention pills render from the controlled
value, so a saved draft shows immediately. Shared chrome (PromptSubmitButton,
frame classes, chrome-target selector) and the editorless prompt-action
append (new prompt-action-draft.ts) move out of PromptBoxInternal, which now
imports them — the dependency is one-way so future features land in the
internal module. PromptBoxInternal also grows the takeFocusOnCreate handoff
prop the wrapper will use in step 2.

A Ladle story renders the shell beside the mounted editor's closed state for
the parity check (empty, saved draft, mention pill, compact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Step 2 of plan 008. PromptBox renders the shell until the first compose
intent — a tap/focus on the text region, a paste or drop, a plus-menu
action, or a programmatic focus request (promptBoxRef.focusEnd(), a
focusEndKey change, desktop autoFocus) — then dynamically imports
PromptBoxInternal and mounts it for good.

While the chunk loads, an invisible interim textarea overlaid on the text
region owns the focus: the tap focuses it natively (so the soft keyboard
opens inside the user's gesture) and every keystroke and paste flushes
straight into the controlled draft, which the shell preview echoes. Empty
drafts render the interim text natively (native caret and IME preview);
continuing drafts stay invisible behind the preview echo plus a caret
affordance. The swap defers during IME composition so no composition is
cut; at swap the editor mounts with the full draft and takes over focus
(takeFocusOnCreate) with the caret at the end. The chunk also prefetches at
idle and on pointerenter/focus so the handoff is normally mount-only.

FollowUpPromptBox and NewThreadPromptBox mount PromptBox instead of
PromptBoxInternal; the FollowUpPromptBox suite mocks the new boundary. New
handoff tests fail against the eager architecture (7/8; the autoFocus
parity test is trivially green there) and pass after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Step 3 of plan 008. Every @tiptap/* and prosemirror-* package observed in
the graph moves into onDemandPackages gated on PromptBoxInternal.tsx (the
dynamic-import target of the PromptBox handoff), so any new static edge
into the editor graph fails check:bundle no matter which chunk it comes
from. The same canonical package names join SplitWorkspaceRoute's
forbiddenPackages, mirroring how katex is listed in both.

Route closure budgets re-ratchet to 10% above the measured closure:
2,268,430 -> 1,765,479 raw and 605,332 -> 489,779 brotli. Measured closure
after the handoff: 1,604,980 raw / 445,253 brotli, down from 2018.4 KB raw
/ 538.5 KB brotli before it. The editor gate chunk is 487,251 raw /
121,126 brotli, fetched at idle or on first compose intent instead of
before route content paints.

The plan's ~240 KB brotli estimate attributed the whole 1.07 MB
workspace-checkout-display chunk to tiptap; the chunk also carried ~550 KB
of unrelated app modules (ansi-to-html/entities and timeline code) that
stay eager, so the realized closure saving is 451 KB raw / 104 KB brotli
plus the budget headroom reclaimed by the ratchet (503 KB raw / 116 KB
brotli).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@SawyerHood

Copy link
Copy Markdown
Collaborator

Closing this PR for rework after an adversarial review of the #2385#2393 set. Method: two independent multi-agent review passes over a worktree at this PR head; every finding went to three independent refuters (code trace, a throwaway experiment against the real code, and an impact judge), and only findings that survived at least two of three are listed. The branch stays as-is; please reopen this PR or open a new one when the blocking items are addressed.

Blocking

  1. A failed editor-chunk import is cached forever; the composer never realizes againapps/app/src/components/promptbox/PromptBox.tsx:54. loadPromptBoxInternalModule caches a rejected import(), and requestRealize latches realizeRequestedRef.current = true before the load with no .catch. One failed fetch (flaky network, or a stale tab after a deploy rotated chunk hashes) leaves the composer dead for the life of the page, and the rejection escapes as an unhandled promise rejection. The repo already has createRetryingModuleLoader for this. Fix: use it, reset the latch on failure so the next intent retries, and .catch the prefetch paths. Add a test that rejects the first import and asserts a second focus mounts the editor.

Fix together with the above (each confirmed by experiment)

  • Safari IME: confirming a CJK candidate with Enter submits the draft from the interim textarea (PromptBox.tsx:364). The interim checks only isComposing; the mounted editor has the 500 ms post-compositionend window, the keyCode === 229 check, and the WeakSet. Mirror them, and seed compositionEndedAtRef when the swap is triggered from compositionend.
  • The swap replaces the whole chrome at an arbitrary time (promise resolution), closing an open model picker, plus menu, or voice bar under the user's finger (PromptBox.tsx:486). Keep the chrome stable and swap only the editor region, or gate the swap on no open overlay.
  • Shell attachment thumbnails are inert (lightbox hard-wired to null) and attach-from-menu never realizes the editor (PromptBoxShell.tsx:594).
  • The first plus-menu action before mount drops focus to <body> (PromptBox.tsx:477); the eager editor focused the caret after the inserted pill.
  • The first tap into a saved draft always puts the caret at the end, not at the tap point, and saved text cannot be selected until the editor exists (PromptBox.tsx:520).
  • A restored draft is hidden from assistive tech: the preview is aria-hidden and the only accessible textbox is the always-empty interim textarea (PromptBoxShell.tsx:541).
  • All 8 handoff tests run with a fine pointer (the test setup's matchMedia polyfill returns false), so the coarse-pointer path this PR exists for has zero coverage (PromptBox.test.tsx:113). Reverting the coarse-pointer clause in the handoff effect keeps the suite green.

Low

  • The shell submit button drops the editor's iOS focus guard and pointer-vs-keyboard blur distinction (PromptBoxShell.tsx:759).
  • The form-level drop handler preventDefaults text drops into the interim textarea (PromptBoxShell.tsx:395).
  • Ctrl/Alt/Meta+Enter insert a newline in the interim where the editor submits (PromptBox.tsx:388); iPad hardware Enter diverges from the editor's special case (PromptBox.tsx:386); aria-keyshortcuts is missing (PromptBox.tsx:506).
  • COMPOSER_EDITOR_MAX_HEIGHT_BY_LAYOUT says "shared with ComposerEditorSlot" but that file keeps its own copy (PromptBoxShell.tsx:85).

Open risk

No one verified on a device that the iOS keyboard stays up across the swap; the refuters could only run desktop WebKit. Test that on a phone before investing in the tail above; if the keyboard drops, the swap must happen synchronously inside the tap gesture.

Suggested salvage

The design is right and the bundle win is real (measured 104 KB brotli off the route closure on the merged tree). Fix the loader, the IME guard, and chrome stability; add coarse-pointer tests; then reopen after a device test.

AGENT GENERATED

@SawyerHood SawyerHood closed this Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants