Unified · Split
| @@ -877,7 +877,11 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null) { · 876 unmodified lines | ||
| 877 | 877 | const hostWidth = currentHost.clientWidth; |
| 878 | 878 | |
| 879 | 879 | setLayout((current) => { |
| 880 | − const next = resolveRestingComposerControlsLayout({ ...measurement, hostWidth }); | |
| 880 | + const next = resolveRestingComposerControlsLayout({ | |
| 881 | + ...measurement, | |
| 882 | + hostWidth, | |
| 883 | + previous: current, | |
| 884 | + }); | |
| 881 | 885 | return next.hiddenCount === current.hiddenCount && next.visible === current.visible |
| 882 | 886 | ? current |
| 883 | 887 | : next; |
| @@ -300,3 +300,82 @@ describe("context strip labels and resting composer controls", () => { · 299 unmodified lines | ||
| 300 | 300 | ).toEqual({ hiddenCount: 2, visible: true }); |
| 301 | 301 | }); |
| 302 | 302 | }); |
| 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 | +}); | |
| @@ -111,10 +111,15 @@ export function resolveRestingComposerControlsNaturalWidth( · 110 unmodified lines | ||
| 111 | 111 | * the overflow menu, the picker may contract to its minimum readable width; |
| 112 | 112 | * below that the whole cluster hides rather than clipping. |
| 113 | 113 | */ |
| 114 | +const RESTING_CONTROLS_SLACK_PX = 1; | |
| 115 | + | |
| 114 | 116 | export function resolveRestingComposerControlsLayout( |
| 115 | − input: RestingComposerControlsMeasurement & { hostWidth: number }, | |
| 117 | + input: RestingComposerControlsMeasurement & { | |
| 118 | + hostWidth: number; | |
| 119 | + previous?: { hiddenCount: number; visible: boolean }; | |
| 120 | + }, | |
| 116 | 121 | ): { hiddenCount: number; visible: boolean } { |
| 117 | − const { blockWidths, hostWidth } = input; | |
| 122 | + const { blockWidths, hostWidth, previous } = input; | |
| 118 | 123 | let hiddenCount = 0; |
| 119 | 124 | while ( |
| 120 | 125 | hiddenCount < blockWidths.length && |
| @@ -122,7 +127,22 @@ export function resolveRestingComposerControlsLayout( · 1 unmodified line | ||
| 122 | 127 | ) { |
| 123 | 128 | hiddenCount += 1; |
| 124 | 129 | } |
| 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); | |
| 125 | 143 | const visible = |
| 126 | − restingComposerControlsWidth(input, hiddenCount, input.minimumFixedWidth) <= hostWidth; | |
| 144 | + previous && !previous.visible | |
| 145 | + ? minimumWidth <= hostWidth - RESTING_CONTROLS_SLACK_PX | |
| 146 | + : minimumWidth <= hostWidth; | |
| 127 | 147 | return { hiddenCount, visible }; |
| 128 | 148 | } |