perf(mobile): constant-viewport chat scroller across keyboard transitions

Transforms on the chat scroller (content or container) forced WebKit to
rebuild its composited scrolling layers — a multi-second stall on long
chats. The scroller now gets no transforms at all: it keeps a constant
client height by extending below its shrunken region (by keyboard minus
the safe inset the shell gives up, so the settle snap is geometry-neutral)
and converting the keyboard strip into its own bottom padding, driven by
--oc-kb-scroll-inset from the very start of the rise. Nothing resizes for
the virtualizer, every reachable row stays mounted, and open/close is a
single cheap scrollTop write: the re-pin happens as the keyboard starts
rising, and the hide clamp lands behind the still-visible keyboard.

The composer and draft title keep sliding with the keyboard, now via
inline transforms set by the choreography — WebKit does not reliably
start transitions when a transform changes through a CSS custom property,
which had parked the composer until the keyboard finished.
This commit is contained in:
Bohdan Triapitsyn
2026-07-05 01:41:44 +03:00
parent b19f65f9b6
commit 2c220f3c52
3 changed files with 97 additions and 92 deletions
+47 -7
View File
@@ -210,7 +210,7 @@ const useNativeMobileChrome = (): void => {
// Keyboard slide choreography (see the "Native (Capacitor) keyboard handling"
// block in mobile.css for the full picture). `keyboardWillShow` fires at the
// START of the iOS keyboard animation and carries the final height; the
// visible motion is transform-only (--oc-kb-shift), and the shell's layout
// visible motion is transform-only (inline styles on the kb-movers), and the shell's layout
// height (--oc-kb-layout) snaps exactly once per open/close at the moment the
// resize is invisible. visualViewport tracking was tried but doesn't shrink
// under WKWebView's `resize: 'none'`, so these events are the reliable signal.
@@ -238,6 +238,26 @@ const useNativeMobileChrome = (): void => {
const dispatchKb = (type: 'oc:keyboard-intent' | 'oc:keyboard-anim' | 'oc:keyboard-settled', detail: Record<string, unknown>) => {
window.dispatchEvent(new CustomEvent(type, { detail }));
};
// Elements that ride the keyboard slide, with their travel factor. Driven
// by INLINE styles from here: WebKit does not reliably start a transition
// when the transform's value changes via a CSS custom property, which
// left the composer parked until the keyboard finished.
const getKbMovers = (): Array<{ el: HTMLElement; factor: number }> => {
const movers: Array<{ el: HTMLElement; factor: number }> = [];
const composer = document.querySelector<HTMLElement>('.oc-mobile-composer');
if (composer) movers.push({ el: composer, factor: 1 });
// The centered draft title moves half the shift — exactly where the
// center lands after the shell snap (see mobile.css notes).
const draftCenter = document.querySelector<HTMLElement>('.oc-draft-center');
if (draftCenter) movers.push({ el: draftCenter, factor: 0.5 });
return movers;
};
const clearKbMovers = () => {
for (const { el } of getKbMovers()) {
el.style.transition = '';
el.style.transform = '';
}
};
const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => {
clearSettle();
@@ -262,7 +282,18 @@ const useNativeMobileChrome = (): void => {
}
root.classList.add('oc-keyboard-open', 'oc-kb-animating', 'oc-kb-caret-hold');
setInset(keyboardHeight);
setVar('--oc-kb-shift', slide);
for (const { el, factor } of getKbMovers()) {
el.style.transition = `transform ${KB_ANIM_MS}ms ${KB_ANIM_EASING}`;
el.style.transform = `translateY(${-slide * factor}px)`;
}
// Reserve the keyboard strip inside the chat scroller NOW and re-pin
// immediately (settled = one cheap scrollTop write over already-mounted
// rows), so the chat bottom moves as the keyboard STARTS rising instead
// of waiting for it to finish. `slide` (keyboard minus the safe inset
// the shell gives up) is exactly the strip the scroller loses at
// settle, so pin position and settle stay geometry-neutral.
setVar('--oc-kb-scroll-inset', slide);
dispatchKb('oc:keyboard-settled', { open: true });
dispatchKb('oc:keyboard-anim', { phase: 'show', slide, durationMs: KB_ANIM_MS, easing: KB_ANIM_EASING });
settleTimer = window.setTimeout(() => {
settleTimer = null;
@@ -271,7 +302,7 @@ const useNativeMobileChrome = (): void => {
root.classList.remove('oc-kb-animating');
setVar('--oc-kb-layout', keyboardHeight);
layoutApplied = true;
setVar('--oc-kb-shift', 0);
clearKbMovers();
dispatchKb('oc:keyboard-settled', { open: true });
// Reveal the caret only after UIKit's own caret reposition window.
caretTimer = window.setTimeout(() => {
@@ -304,26 +335,34 @@ const useNativeMobileChrome = (): void => {
const slide = Math.max(0, keyboardHeight - safeBottomPx);
root.classList.remove('oc-keyboard-open');
setInset(0);
setVar('--oc-kb-scroll-inset', 0);
if (layoutApplied) {
// Settled-open → restore the full-height layout NOW (still hidden behind
// the keyboard) and FLIP the composer to its raised position without
// the keyboard) and FLIP the movers to their raised position without
// transitioning, so the next frame looks unchanged.
root.classList.remove('oc-kb-animating');
setVar('--oc-kb-layout', 0);
layoutApplied = false;
setVar('--oc-kb-shift', slide);
for (const { el, factor } of getKbMovers()) {
el.style.transition = 'none';
el.style.transform = `translateY(${-slide * factor}px)`;
}
// Force the style/layout flush so the transition below starts from the
// FLIP position instead of coalescing both writes into one frame.
void (document.querySelector('.oc-mobile-app-shell') as HTMLElement | null)?.offsetHeight;
}
// If the hide interrupted a show mid-animation (layout not applied yet),
// the shift transitions back down from wherever it currently is.
// the movers transition back down from wherever they currently are.
dispatchKb('oc:keyboard-anim', { phase: 'hide', slide, durationMs: KB_HIDE_MS, easing: KB_ANIM_EASING });
root.classList.add('oc-kb-animating', 'oc-kb-hide');
setVar('--oc-kb-shift', 0);
for (const { el } of getKbMovers()) {
el.style.transition = `transform ${KB_HIDE_MS}ms ${KB_ANIM_EASING}`;
el.style.transform = 'translateY(0px)';
}
settleTimer = window.setTimeout(() => {
settleTimer = null;
root.classList.remove('oc-kb-animating', 'oc-kb-hide');
clearKbMovers();
dispatchKb('oc:keyboard-settled', { open: false });
}, KB_HIDE_MS + 20);
};
@@ -378,6 +417,7 @@ const useNativeMobileChrome = (): void => {
root.style.removeProperty('--oc-keyboard-inset');
root.style.removeProperty('--oc-kb-shift');
root.style.removeProperty('--oc-kb-layout');
root.style.removeProperty('--oc-kb-scroll-inset');
};
}, []);
};
+13 -53
View File
@@ -716,24 +716,22 @@ export const useChatAutoFollow = ({
return () => observer.disconnect();
}, [armEntryStickQuiet, containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]);
// ── native keyboard slide (Capacitor iOS choreography) ──────────────────
// useNativeMobileChrome (MobileApp) drives the keyboard open/close as a
// transform-only slide: during the animation the shell layout does NOT change
// (show) or changes exactly once up-front (hide). Our job here:
// show: if pinned, slide the inner content up in sync with the composer.
// hide: the shell snaps back to full height first, which makes the browser
// clamp scrollTop — measure that clamp and FLIP the inner content so
// the frame looks unchanged, then let it slide down with the keyboard.
// settled: drop the transforms and do ONE instant re-pin in the same frame
// (invisible — this runs before the post-reflow paint).
// These events never fire outside the Capacitor iOS app, so the listeners are
// inert everywhere else.
// ── native keyboard transitions (Capacitor choreography) ────────────────
// The chat scroller gets NO transforms during the keyboard transition:
// transforming the scroll container (or its content) forces WebKit to
// rebuild the composited scrolling layers, which stalls for seconds on
// long chats. Instead the chat repositions with instant snaps that hide
// behind the keyboard itself:
// show: content stays put while the keyboard/composer slide over it; the
// settled event (shell layout snap) does ONE instant re-pin.
// hide: the shell layout is restored up-front — the scrollTop clamp
// happens while the keyboard still covers that region — and the
// settled event re-pins once at the end.
// During the window we only guard the scroll heuristics and the observer
// chase. These events never fire outside the Capacitor app.
React.useEffect(() => {
if (typeof window === 'undefined') return;
const innerOf = (el: HTMLElement): HTMLElement | null =>
el.firstElementChild instanceof HTMLElement ? el.firstElementChild : null;
const handleKeyboardAnim = (event: Event) => {
const detail = (event as CustomEvent<{ phase: 'show' | 'hide'; slide: number; durationMs: number; easing: string }>).detail;
if (!detail) return;
@@ -742,39 +740,6 @@ export const useChatAutoFollow = ({
// that land away from the auto marker — never read those as a user
// scroll-away.
animationGuardUntilRef.current = now() + detail.durationMs + ANIMATION_GUARD_MS;
const el = scrollRef.current;
if (!el) return;
const inner = innerOf(el);
if (!inner) return;
const transition = `transform ${detail.durationMs}ms ${detail.easing}`;
if (detail.phase === 'show') {
// Only pinned content rides the keyboard; a user reading history
// stays put (the composer slides over the bottom, like native apps).
if (stateRef.current !== 'following' || !canScroll(el)) return;
// Fold any pending re-pin distance into the same slide. The pill
// composer swaps to the full composer right before focusing, which
// shrinks the viewport without moving scrollTop — compensating it
// here makes keyboard + composer growth one motion instead of two.
const pending = Math.max(0, el.scrollHeight - el.scrollTop - el.clientHeight);
inner.style.transition = transition;
inner.style.transform = `translateY(${-(detail.slide + pending)}px)`;
return;
}
// hide: the layout var was restored just before this event — force the
// reflow now and measure how far the scrollTop clamp moved the content,
// then FLIP it back and slide to 0.
const before = el.scrollTop;
void el.clientHeight;
const clamped = before - el.scrollTop;
if (clamped > 0.5) {
inner.style.transition = 'none';
inner.style.transform = `translateY(${-clamped}px)`;
void inner.offsetHeight;
}
inner.style.transition = transition;
inner.style.transform = 'translateY(0px)';
};
const handleKeyboardSettled = () => {
@@ -784,11 +749,6 @@ export const useChatAutoFollow = ({
updateOverflowAndButton();
return;
}
const inner = innerOf(el);
if (inner) {
inner.style.transition = '';
inner.style.transform = '';
}
// Single deterministic re-pin, same task as the layout swap → lands
// before paint. (scrollToBottomNow, not scrollToBottom: this must not
// be gated on working/settling — the keyboard resize is a viewport
+37 -32
View File
@@ -552,8 +552,8 @@
choreography driven by useNativeMobileChrome:
show: shell keeps its full height for the whole 0.25s; the composer (and, when
pinned, the chat content see useChatAutoFollow) slides up via
--oc-kb-shift in sync with the keyboard; at the end the shell snaps to
pinned, the chat scroll container see useChatAutoFollow) slides up via
inline transforms in sync with the keyboard; at the end the shell snaps to
its final height (--oc-kb-layout, one reflow) and the shift is removed
in the same frame visually identical, so the swap is invisible.
hide: the shell snaps back to full height immediately (still hidden behind the
@@ -601,25 +601,41 @@
transition: height 0.25s cubic-bezier(0.38, 0.7, 0.125, 1);
}
/* Keyboard slide choreography (see the shell comment above). --oc-kb-shift is the
transform-only travel distance (keyboard height minus the bottom safe padding the
shell gives up when the keyboard is open). The transition is gated on
.oc-kb-animating so the end-of-animation swap (shift back to 0 while the layout
height takes over) is instant and invisible. */
:root.oc-capacitor-app .oc-mobile-composer {
transform: translateY(calc(-1 * var(--oc-kb-shift, 0px)));
}
/* Keyboard slide choreography: the composer (and the draft title, see
.oc-draft-center note below) are moved with INLINE transform/transition set
by useNativeMobileChrome WebKit does not reliably start transitions when a
transform changes via a CSS custom property, which parked the composer until
the keyboard finished. Only the sliding backdrop lives here. */
:root.oc-capacitor-app.oc-kb-animating .oc-mobile-composer {
transition: transform 0.25s cubic-bezier(0.38, 0.7, 0.125, 1);
/* While sliding, the composer passes over chat content that hasn't moved (user
scrolled up) give it an opaque backdrop so text doesn't show through the
form's transparent padding. */
background: var(--background);
}
/* Keyboard dismissal reads faster than the rise a full 0.25s down-slide feels
laggy, so the hide leg runs shorter. Must match KB_HIDE_MS in useNativeMobileChrome. */
:root.oc-capacitor-app.oc-kb-animating.oc-kb-hide .oc-mobile-composer {
transition-duration: 0.2s;
/* Constant-viewport trick for the chat scroller: when the keyboard settles the
shell shrinks by --oc-kb-layout, but the scroller keeps its full height by
extending that far below its (shrunken) region behind the composer and the
keyboard and converts the covered strip into its own bottom padding. For
the virtualizer NOTHING resizes across keyboard transitions: clientHeight is
constant, every row that can become visible is already mounted, and the
open/close reposition is a single cheap scrollTop change (the settle re-pin /
the clamp) instead of a viewport resize that mounts rows with a visible
pause on long chats. */
:root.oc-capacitor-app .oc-mobile-app-shell .chat-scroll {
/* The shell shrinks by the keyboard height BUT also gives up its bottom
safe-area padding at the same moment (see the shell's padding-bottom
formula) so the scroller's parent only loses (keyboard safe). Extend
by exactly that, or the settle grows clientHeight by the safe inset and
the pinned chat clamps one row down a beat after the keyboard opens. */
bottom: calc(-1 * max(var(--oc-kb-layout, 0px) - var(--oc-app-bottom-safe, 0px), 0px));
/* The padding leg is driven by its own variable so it can be applied at the
very START of the keyboard rise (with an immediate re-pin the bottom of
the chat moves up as the keyboard begins, not after it finishes), while
the bottom-extension leg still tracks the shell's settle-time shrink. Both
legs equal the keyboard height once settled, so the settle snap is
geometry-neutral for the scroller. */
padding-bottom: var(--oc-kb-scroll-inset, var(--oc-kb-layout, 0px));
}
/* WKWebView draws the text caret as a native layer that ignores CSS transforms:
@@ -649,23 +665,12 @@
display: none;
}
/* The draft title is vertically centered, so it has no scroll-pinning to
compensate the keyboard like the chat does: during the slide the shell keeps
its full height and only snaps shorter at settle, which made the centered
title glide down (starters collapsing) and then JUMP up (shell snap). Ride
the same choreography as the composer: translate the centered block up by
half the keyboard shift (exactly how far the center moves after the snap),
with the transition gated on .oc-kb-animating so the settle swap shift
back to 0 in the same frame the layout shrinks is invisible. */
:root.oc-capacitor-app .oc-draft-center {
transform: translateY(calc(-0.5 * var(--oc-kb-shift, 0px)));
}
:root.oc-capacitor-app.oc-kb-animating .oc-draft-center {
transition: transform 0.25s cubic-bezier(0.38, 0.7, 0.125, 1);
}
:root.oc-capacitor-app.oc-kb-animating.oc-kb-hide .oc-draft-center {
transition-duration: 0.2s;
}
/* The draft title (.oc-draft-center) is vertically centered, so it has no
scroll-pinning to compensate the keyboard like the chat does. It rides the
keyboard choreography with an INLINE transform of half the keyboard shift
(exactly how far the center moves after the shell snap) see the kb-movers
list in useNativeMobileChrome. No CSS rules needed here; the class only
marks the element for the mover query. */
@keyframes oc-composer-morph-fade {
from { opacity: 0; }