@boostdev/design-system-components 2.4.1 → 2.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -196,6 +196,37 @@ describe('applyMasonryLayout', () => {
196
196
  expect(b.style.alignSelf).toBe('start');
197
197
  });
198
198
 
199
+ it('skips re-writing row-spans when measured heights match the cached value', () => {
200
+ // ResizeObserver fires freely on `<img>` decode in Firefox even when the
201
+ // parent's reserved aspect-ratio box hasn't actually changed. Without this
202
+ // short-circuit, every fire triggers a `grid-auto-flow: dense` re-pack —
203
+ // visible as image overlap during fast scroll because Firefox's compositor
204
+ // hasn't caught up with the newly shuffled item positions.
205
+ const container = makeContainer(10);
206
+ const item = makeItem(50);
207
+ container.append(item);
208
+ applyMasonryLayout(container, [item]);
209
+ expect(item.style.gridRowEnd).toBe('span 60');
210
+
211
+ // Simulate something else writing to gridRowEnd — if the polyfill re-runs
212
+ // unnecessarily, this would be overwritten.
213
+ item.style.gridRowEnd = 'span 999';
214
+ applyMasonryLayout(container, [item]);
215
+ expect(item.style.gridRowEnd).toBe('span 999');
216
+ });
217
+
218
+ it('re-writes row-spans when measured heights change', () => {
219
+ const container = makeContainer(10);
220
+ const item = makeItem(50);
221
+ container.append(item);
222
+ applyMasonryLayout(container, [item]);
223
+ expect(item.style.gridRowEnd).toBe('span 60');
224
+
225
+ Object.defineProperty(item, 'offsetHeight', { configurable: true, value: 80 });
226
+ applyMasonryLayout(container, [item]);
227
+ expect(item.style.gridRowEnd).toBe('span 90');
228
+ });
229
+
199
230
  it('clearMasonryLayout undoes container and child styles', () => {
200
231
  const container = makeContainer(10);
201
232
  const item = makeItem(50);
@@ -95,6 +95,16 @@ export type MasonryOptions = {
95
95
  // items pinned to their polyfill-assigned span.
96
96
  const ORIGINAL_COL_ATTR = 'data-bds-masonry-original-column';
97
97
 
98
+ // Data attribute caching the last applied row-span. Used to short-circuit no-op
99
+ // re-runs: when every child's newly-measured span matches the cached value, the
100
+ // polyfill skips all writes. This prevents `grid-auto-flow: dense` from
101
+ // re-packing on spurious ResizeObserver fires (subpixel fluctuations, `<img>`
102
+ // decode in Firefox where offsetHeight briefly wobbles). Dense re-packing
103
+ // during fast scroll in Firefox is visible as images overlapping, because
104
+ // Firefox's compositor paints cached tiles from the pre-shuffle layout on top
105
+ // of the newly laid out items for a frame or two.
106
+ const LAST_SPAN_ATTR = 'data-bds-masonry-span';
107
+
98
108
  export function applyMasonryLayout(
99
109
  container: HTMLElement,
100
110
  rawChildren: HTMLElement[],
@@ -180,11 +190,27 @@ export function applyMasonryLayout(
180
190
  // O(N) forced-reflow cost of a read-then-write loop.
181
191
  const heights = children.map((child) => child.offsetHeight);
182
192
 
193
+ // Compute spans; skip the entire write pass if every span matches the
194
+ // previously-applied value. ResizeObserver fires freely in Firefox during
195
+ // image decode and scroll — without this guard, each fire triggers a dense
196
+ // re-pack that's visible as overlap on fast scroll (the compositor hasn't
197
+ // caught up with the newly shuffled positions).
198
+ const spans = heights.map((h) => Math.max(1, Math.ceil(h + targetGap)));
199
+ let changed = false;
200
+ for (let i = 0; i < children.length; i++) {
201
+ const prev = children[i].getAttribute(LAST_SPAN_ATTR);
202
+ if (prev !== String(spans[i])) {
203
+ changed = true;
204
+ break;
205
+ }
206
+ }
207
+ if (!changed) return;
208
+
183
209
  // Pass 3 — batch all row-span writes. No reads in between, so the browser can
184
210
  // coalesce style invalidation and do layout once for the whole batch.
185
211
  for (let i = 0; i < children.length; i++) {
186
- const span = Math.max(1, Math.ceil(heights[i] + targetGap));
187
- children[i].style.gridRowEnd = `span ${span}`;
212
+ children[i].style.gridRowEnd = `span ${spans[i]}`;
213
+ children[i].setAttribute(LAST_SPAN_ATTR, String(spans[i]));
188
214
  }
189
215
  }
190
216
 
@@ -230,6 +256,7 @@ export function clearMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
230
256
  child.style.gridRowEnd = '';
231
257
  child.style.alignSelf = '';
232
258
  child.style.overflowAnchor = '';
259
+ child.removeAttribute(LAST_SPAN_ATTR);
233
260
  // Restore any `gridColumn` value the polyfill overwrote. Items the
234
261
  // polyfill never touched have no ORIGINAL_COL_ATTR and are left alone.
235
262
  restoreOriginalColumn(child);