History

fix(web): settle the resting composer layout with a pixel of slack (#9482)

Matheus Timbó Pereira committed 2 months ago

Commit 0ba06a11 parent f54ab903 files · +107 −4Browse files

Unified · Split

3 files
  1. apps/web/src/components/chat/ChatComposer.tsx +5 −1
  2. apps/web/src/components/composerFooterLayout.test.ts +79 −0
  3. apps/web/src/components/composerFooterLayout.ts +23 −3
apps/web/src/components/chat/ChatComposer.tsx+5−1
@@ -877,7 +877,11 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null) { · 876 unmodified lines
877877 const hostWidth = currentHost.clientWidth;
878878
879879 setLayout((current) => {
880− const next = resolveRestingComposerControlsLayout({ ...measurement, hostWidth });
880+ const next = resolveRestingComposerControlsLayout({
881+ ...measurement,
882+ hostWidth,
883+ previous: current,
884+ });
881885 return next.hiddenCount === current.hiddenCount && next.visible === current.visible
882886 ? current
883887 : next;
apps/web/src/components/composerFooterLayout.test.ts+79−0
@@ -300,3 +300,82 @@ describe("context strip labels and resting composer controls", () => { · 299 unmodified lines
300300 ).toEqual({ hiddenCount: 2, visible: true });
301301 });
302302 });
303+
304+describe("resolveRestingComposerControlsLayout hysteresis", () => {
305+ // Same cluster as above: picker 140 natural / 96 minimum plus a 9px
306+ // separator, traits 60, mode 140, overflow 24, gap 4.
307+ const base = {
308+ gap: 4,
309+ naturalFixedWidth: 149,
310+ minimumFixedWidth: 105,
311+ blockWidths: [60, 140],
312+ overflowWidth: 24,
313+ };
314+
315+ it("keeps a block in overflow when re-showing it would leave no slack", () => {
316+ // 149 + 60 + 140 + 4 * 2 = 357 fills the host exactly.
317+ expect(
318+ resolveRestingComposerControlsLayout({
319+ ...base,
320+ hostWidth: 357,
321+ previous: { hiddenCount: 1, visible: true },
322+ }),
323+ ).toEqual({ hiddenCount: 1, visible: true });
324+ });
325+
326+ it("re-shows a block once the host clears the slack margin", () => {
327+ expect(
328+ resolveRestingComposerControlsLayout({
329+ ...base,
330+ hostWidth: 358,
331+ previous: { hiddenCount: 1, visible: true },
332+ }),
333+ ).toEqual({ hiddenCount: 0, visible: true });
334+ });
335+
336+ it("still resolves from scratch when there is no previous layout", () => {
337+ expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 357 })).toEqual({
338+ hiddenCount: 0,
339+ visible: true,
340+ });
341+ });
342+
343+ it("settles when the measured picker width jitters below a pixel", () => {
344+ // Regression guard for React error #185 ("Maximum update depth
345+ // exceeded"). The composer re-measures on every render, so the picker's
346+ // recovered natural width can land a fraction of a pixel apart between
347+ // renders. Without hysteresis that flips hiddenCount forever.
348+ let layout = { hiddenCount: 0, visible: true };
349+ const settled: string[] = [];
350+ for (let index = 0; index < 10; index += 1) {
351+ layout = resolveRestingComposerControlsLayout({
352+ ...base,
353+ // The picker's recovered natural width lands a half pixel apart
354+ // between renders.
355+ naturalFixedWidth: index % 2 === 0 ? 149 : 149.5,
356+ hostWidth: 357,
357+ previous: layout,
358+ });
359+ if (index >= 2) settled.push(`${layout.hiddenCount}:${layout.visible}`);
360+ }
361+ expect(new Set(settled).size).toBe(1);
362+ });
363+
364+ it("keeps the cluster hidden until its minimum width clears the slack", () => {
365+ // 105 + 24 + 4 = 133 is the exact minimum for the hidden cluster.
366+ expect(
367+ resolveRestingComposerControlsLayout({
368+ ...base,
369+ hostWidth: 133,
370+ previous: { hiddenCount: 2, visible: false },
371+ }),
372+ ).toEqual({ hiddenCount: 2, visible: false });
373+ expect(
374+ resolveRestingComposerControlsLayout({
375+ ...base,
376+ hostWidth: 134,
377+ previous: { hiddenCount: 2, visible: false },
378+ }),
379+ ).toEqual({ hiddenCount: 2, visible: true });
380+ });
381+});
apps/web/src/components/composerFooterLayout.ts+23−3
@@ -111,10 +111,15 @@ export function resolveRestingComposerControlsNaturalWidth( · 110 unmodified lines
111111 * the overflow menu, the picker may contract to its minimum readable width;
112112 * below that the whole cluster hides rather than clipping.
113113 */
114+const RESTING_CONTROLS_SLACK_PX = 1;
115+
114116 export function resolveRestingComposerControlsLayout(
115− input: RestingComposerControlsMeasurement & { hostWidth: number },
117+ input: RestingComposerControlsMeasurement & {
118+ hostWidth: number;
119+ previous?: { hiddenCount: number; visible: boolean };
120+ },
116121 ): { hiddenCount: number; visible: boolean } {
117− const { blockWidths, hostWidth } = input;
122+ const { blockWidths, hostWidth, previous } = input;
118123 let hiddenCount = 0;
119124 while (
120125 hiddenCount < blockWidths.length &&
@@ -122,7 +127,22 @@ export function resolveRestingComposerControlsLayout( · 1 unmodified line
122127 ) {
123128 hiddenCount += 1;
124129 }
130+ // Growing the overflow menu is unconditional, or the controls would clip.
131+ // Shrinking it has to earn a pixel of slack first: the picker is flexible,
132+ // so its natural width is recovered from a truncated label whose
133+ // scrollWidth is integral while the rendered box is fractional. The
134+ // composer re-measures on every render, so without that margin a host
135+ // sitting exactly on a threshold flips a block in and out until React
136+ // gives up with "Maximum update depth exceeded".
137+ if (previous && hiddenCount < previous.hiddenCount) {
138+ if (restingComposerControlsWidth(input, hiddenCount) > hostWidth - RESTING_CONTROLS_SLACK_PX) {
139+ hiddenCount = Math.min(previous.hiddenCount, blockWidths.length);
140+ }
141+ }
142+ const minimumWidth = restingComposerControlsWidth(input, hiddenCount, input.minimumFixedWidth);
125143 const visible =
126− restingComposerControlsWidth(input, hiddenCount, input.minimumFixedWidth) <= hostWidth;
144+ previous && !previous.visible
145+ ? minimumWidth <= hostWidth - RESTING_CONTROLS_SLACK_PX
146+ : minimumWidth <= hostWidth;
127147 return { hiddenCount, visible };
128148 }