File size: 8,854 Bytes
fa9c65f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
/**
 * CLS mover attribution (#5332, #4580).
 *
 * `largestShiftTarget`/shifted-content rankings name shift VICTIMS β€” what
 * moved β€” not MOVERS β€” what changed size and pushed them. That distinction
 * was proven the hard way twice: fixing the banner (#5137) removed `#main`
 * from the victim rankings, and pinning the ranked panels' heights (#5333)
 * left field CLS unmoved because the pinned panels were themselves victims.
 *
 * This tracker names movers directly. It keeps a per-panel geometry cache
 * (stable mover key -> {top, height}) and, on every qualifying layout-shift delivery,
 * diffs the current geometry against the cache: a panel whose HEIGHT changed
 * is a mover; a panel whose position changed at constant height is a victim;
 * a panel present now but absent from the cache is an insertion (mount-order
 * suspects). The six-record ring's three largest deliveries ride the CLS
 * Sentry report (bad-tail only, same volume policy) as compact strings.
 *
 * The diff core is pure and unit-tested without DOM (tests/cls-mover-tracker).
 */

export interface PanelRect {
  top: number;
  height: number;
}

export interface PanelGeometryDiff {
  heightChangers: Array<{ key: string; delta: number }>;
  movedOnly: string[];
  inserted: string[];
  /** Panels in the cache but gone from the layout β€” removal collapses their
   *  occupied height and pulls siblings up, a mover class of its own. */
  removed: string[];
}

export interface MoverRecord extends PanelGeometryDiff {
  /** startTime of the latest layout-shift entry in this delivery, rounded. */
  t: number;
  /** Sum of the non-input layout-shift values delivered together. */
  value: number;
  /** Number of layout-shift entries represented when a delivery was batched. */
  entryCount?: number;
  /** The shift arrived before any baseline snapshot existed β€” the mover is
   *  unattributable, but the report should still show a big shift happened. */
  coldStart?: boolean;
}

/** Ignore sub-pixel/jitter deltas β€” real row growth is tens of pixels. */
const GEOMETRY_JITTER_PX = 2;
/** Only diff on shifts big enough to matter; the cache still refreshes below it. */
const RECORD_SHIFT_THRESHOLD = 0.05;
/** Ring size for recorded diffs; the report keeps the top 3 by value. */
const MAX_RECORDS = 6;
/** Cache refreshes are rate-limited between recorded diffs. */
const CACHE_REFRESH_MIN_MS = 500;

/** Pure: classify panels by what changed between two geometry snapshots. */
export function diffPanelGeometry(
  cache: Record<string, PanelRect>,
  current: Record<string, PanelRect>,
): PanelGeometryDiff {
  const heightChangers: Array<{ key: string; delta: number }> = [];
  const movedOnly: string[] = [];
  const inserted: string[] = [];
  const removed = Object.keys(cache).filter((key) => !(key in current));
  for (const [key, rect] of Object.entries(current)) {
    const prev = cache[key];
    if (!prev) {
      inserted.push(key);
      continue;
    }
    const dH = rect.height - prev.height;
    const dTop = rect.top - prev.top;
    if (Math.abs(dH) > GEOMETRY_JITTER_PX) {
      heightChangers.push({ key, delta: Math.round(dH) });
    } else if (Math.abs(dTop) > GEOMETRY_JITTER_PX) {
      movedOnly.push(key);
    }
  }
  return { heightChangers, movedOnly, inserted, removed };
}

/**
 * Pure: compact per-record strings for the Sentry extra, largest shift first,
 * capped at three. Example: "t=1240 v=0.31 sized:threat-timeline+180 moved:2".
 */
export function formatMoverRecords(records: MoverRecord[]): string[] {
  return [...records]
    .sort((a, b) => b.value - a.value)
    .slice(0, 3)
    .map((r) => {
      const parts = [`t=${r.t} v=${r.value}`];
      if (r.heightChangers.length > 0) {
        // 'sized:' not 'grew:' β€” the signed delta carries direction, and a
        // shrinking panel that pulled space away is a mover too (review P2).
        parts.push(
          `sized:${r.heightChangers
            .map((c) => `${c.key}${c.delta >= 0 ? '+' : ''}${c.delta}`)
            .join(',')}`,
        );
      }
      if (r.inserted.length > 0) parts.push(`ins:${r.inserted.join(',')}`);
      if (r.removed.length > 0) parts.push(`rem:${r.removed.join(',')}`);
      if (r.movedOnly.length > 0) parts.push(`moved:${r.movedOnly.length}`);
      if ((r.entryCount ?? 1) > 1) parts.push(`n=${r.entryCount}`);
      if (r.coldStart) parts.push('cold');
      return parts.join(' ');
    });
}

let records: MoverRecord[] = [];
let cache: Record<string, PanelRect> | null = null;
let lastRefresh = 0;
let started = false;
let observer: PerformanceObserver | null = null;
let onPageShow: ((event: PageTransitionEvent) => void) | null = null;

function snapshotPanels(): Record<string, PanelRect> | null {
  const grids = ['panelsGrid', 'mapBottomGrid']
    .map((id) => document.getElementById(id))
    .filter((grid): grid is HTMLElement => grid !== null);
  if (grids.length === 0) return null;
  const out: Record<string, PanelRect> = {};
  for (const grid of grids) {
    for (const el of grid.querySelectorAll<HTMLElement>(':scope > [data-panel], :scope > [data-cls-mover]')) {
      const key = el.dataset.panel ?? el.dataset.clsMover;
      if (!key) continue;
      const rect = el.getBoundingClientRect();
      out[key] = { top: Math.round(rect.top + window.scrollY), height: Math.round(rect.height) };
    }
  }
  return out;
}

function resetMoverState(): void {
  records = [];
  cache = snapshotPanels();
  lastRefresh = cache ? performance.now() : 0;
}

/** Records captured at shift time, for the CLS report to attach at hide time. */
export function getMoverRecordStrings(): string[] {
  return formatMoverRecords(records);
}

/** Test hook: reset module state. */
export function resetClsMoverTrackingForTesting(): void {
  observer?.disconnect();
  observer = null;
  if (onPageShow && typeof window !== 'undefined') window.removeEventListener('pageshow', onPageShow);
  onPageShow = null;
  records = [];
  cache = null;
  lastRefresh = 0;
  started = false;
}

/**
 * Start shift-time geometry tracking. Browser-only, idempotent, and inert
 * when PerformanceObserver/layout-shift is unavailable. Reads ~80 panel rects
 * per qualifying shift β€” the observer callback runs after layout, so the
 * reads are clean; refreshes between recorded shifts are rate-limited.
 */
export function startClsMoverTracking(): void {
  if (started || typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return;
  started = true;
  resetMoverState();
  try {
    observer = new PerformanceObserver((list) => {
      const entries = list.getEntries() as Array<PerformanceEntry & { value: number; hadRecentInput: boolean }>;
      if (entries.length === 0) return;
      const now = performance.now();

      // Callback-time geometry cannot be separated when input and non-input
      // entries are delivered together. Refresh the baseline once and skip
      // attribution rather than charging input-driven movement to a later CLS.
      if (entries.some((entry) => entry.hadRecentInput)) {
        const current = snapshotPanels();
        if (current) {
          cache = current;
          lastRefresh = now;
        }
        return;
      }

      const value = entries.reduce((sum, entry) => sum + entry.value, 0);
      const latest = entries[entries.length - 1]!;
      if (value >= RECORD_SHIFT_THRESHOLD) {
        const current = snapshotPanels();
        if (!current) return;
        const roundedValue = Math.round(value * 1000) / 1000;
        const entryCount = entries.length > 1 ? entries.length : undefined;
        if (!cache) {
          records.push({
            t: Math.round(latest.startTime), value: roundedValue, entryCount,
            heightChangers: [], movedOnly: [], inserted: [], removed: [],
            coldStart: true,
          });
        } else {
          const diff = diffPanelGeometry(cache, current);
          if (diff.heightChangers.length > 0 || diff.inserted.length > 0 || diff.removed.length > 0 || diff.movedOnly.length > 0) {
            records.push({ t: Math.round(latest.startTime), value: roundedValue, entryCount, ...diff });
          }
        }
        if (records.length > MAX_RECORDS) records = records.slice(-MAX_RECORDS);
        cache = current;
        lastRefresh = now;
      } else if (now - lastRefresh > CACHE_REFRESH_MIN_MS) {
        const current = snapshotPanels();
        if (current) {
          cache = current;
          lastRefresh = now;
        }
      }
    });
    observer.observe({ type: 'layout-shift', buffered: true });
    onPageShow = (event: PageTransitionEvent): void => {
      if (event.persisted) resetMoverState();
    };
    window.addEventListener('pageshow', onPageShow);
  } catch {
    /* layout-shift unsupported (Safari/Firefox) β€” CLS reporting is Chromium-sourced anyway. */
  }
}