| import { markLcpDebug, type LcpMarkSnapshot } from '@/utils/lcp-debug'; |
| import { sanitizeWebVitalUrl } from '@/bootstrap/web-vitals-utils'; |
|
|
| type LcpElementSnapshot = { |
| className: string; |
| closest: string; |
| id: string; |
| selector: string; |
| tagName: string; |
| |
| |
| |
| text: string; |
| textLength: number; |
| }; |
|
|
| type LcpResourceGroup = { |
| category: string; |
| count: number; |
| encodedBodySize: number; |
| transferSize: number; |
| largest?: { |
| duration: number; |
| initiatorType: string; |
| name: string; |
| startTime: number; |
| transferSize: number; |
| }; |
| }; |
|
|
| type LcpContextSnapshot = { |
| devicePixelRatio: number; |
| theme: string; |
| variant: string; |
| viewport: { |
| height: number; |
| width: number; |
| }; |
| visibilityState: string; |
| }; |
|
|
| type LcpEntrySnapshot = { |
| context: LcpContextSnapshot; |
| element: LcpElementSnapshot | null; |
| loadTime: number; |
| renderTime: number; |
| resources: LcpResourceGroup[]; |
| size: number; |
| startTime: number; |
| url: string; |
| }; |
|
|
| export type WmLcpDebugState = { |
| enabled: true; |
| entries: LcpEntrySnapshot[]; |
| getSnapshot: () => { |
| context: LcpContextSnapshot; |
| entries: LcpEntrySnapshot[]; |
| marks: LcpMarkSnapshot[]; |
| resources: LcpResourceGroup[]; |
| }; |
| marks: LcpMarkSnapshot[]; |
| observerInstalled: boolean; |
| }; |
|
|
| declare global { |
| interface Window { |
| __wmLcpDebug?: WmLcpDebugState; |
| } |
| } |
|
|
| const DEBUG_QUERY_PARAM = 'wm_lcp_debug'; |
| const DEBUG_STORAGE_KEYS = ['wm_lcp_debug', 'wm-lcp-debug']; |
| |
| |
| |
| |
| |
| const DEBUG_TEXT_QUERY_PARAM = 'wm_lcp_text'; |
| const DEBUG_TEXT_STORAGE_KEYS = ['wm_lcp_text', 'wm-lcp-text']; |
| const MAX_ENTRIES = 20; |
| const MAX_RESOURCE_GROUPS = 12; |
| const MAX_TEXT_LENGTH = 140; |
|
|
| type LcpPerformanceEntry = PerformanceEntry & { |
| element?: Element; |
| loadTime?: number; |
| renderTime?: number; |
| size?: number; |
| url?: string; |
| }; |
|
|
| function capText(value: string, maxLength = MAX_TEXT_LENGTH): string { |
| return value.replace(/\s+/g, ' ').trim().slice(0, maxLength); |
| } |
|
|
| function getWindowStorage(kind: 'localStorage' | 'sessionStorage'): Storage | undefined { |
| if (typeof window === 'undefined') return undefined; |
| try { |
| return window[kind]; |
| } catch { |
| return undefined; |
| } |
| } |
|
|
| function getStorageFlag(storage: Storage | undefined, key: string): string | null { |
| try { |
| return storage?.getItem(key) ?? null; |
| } catch { |
| return null; |
| } |
| } |
|
|
| function isTruthyDebugFlag(value: string | null): boolean { |
| return value === '1' || value === 'true' || value === 'yes'; |
| } |
|
|
| function isFlagEnabled(queryParam: string, storageKeys: string[]): boolean { |
| if (typeof window === 'undefined') return false; |
| try { |
| const params = new URL(window.location.href).searchParams; |
| if (isTruthyDebugFlag(params.get(queryParam))) return true; |
| } catch { |
| |
| } |
|
|
| const sessionStorage = getWindowStorage('sessionStorage'); |
| const localStorage = getWindowStorage('localStorage'); |
| for (const key of storageKeys) { |
| if ( |
| isTruthyDebugFlag(getStorageFlag(sessionStorage, key)) |
| || isTruthyDebugFlag(getStorageFlag(localStorage, key)) |
| ) { |
| return true; |
| } |
| } |
| return false; |
| } |
|
|
| function isLcpDebugEnabled(): boolean { |
| return isFlagEnabled(DEBUG_QUERY_PARAM, DEBUG_STORAGE_KEYS); |
| } |
|
|
| function isLcpTextCaptureEnabled(): boolean { |
| return isFlagEnabled(DEBUG_TEXT_QUERY_PARAM, DEBUG_TEXT_STORAGE_KEYS); |
| } |
|
|
| function getClassName(element: Element): string { |
| const className = element.className; |
| if (typeof className === 'string') return className; |
| if (className && typeof className === 'object' && 'baseVal' in className) { |
| return String((className as SVGAnimatedString).baseVal ?? ''); |
| } |
| return ''; |
| } |
|
|
| function escapeClassName(name: string): string { |
| if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { |
| return CSS.escape(name); |
| } |
| return name.replace(/[^a-zA-Z0-9_-]/g, (char) => `\\${char}`); |
| } |
|
|
| function buildSelector(element: Element): string { |
| const tag = element.tagName.toLowerCase(); |
| const id = element.id ? `#${element.id}` : ''; |
| const classes = getClassName(element) |
| .split(/\s+/) |
| .filter(Boolean) |
| .slice(0, 4) |
| .map((name) => `.${escapeClassName(name)}`) |
| .join(''); |
| return `${tag}${id}${classes}`.slice(0, 180); |
| } |
|
|
| function closestAttributionLabel(element: Element): string { |
| const panel = element.closest<HTMLElement>('[data-panel-id]'); |
| if (panel?.dataset.panelId) return `panel:${panel.dataset.panelId}`; |
| if (element.closest('[data-shell-lcp]')) return 'shell-lcp'; |
| if (element.closest('.skeleton-shell')) return 'shell'; |
| if (element.closest('#mapContainer')) return 'map-container'; |
| if (element.closest('#mapSection')) return 'map-section'; |
| if (element.closest('.map-renderer-shell')) return 'map-renderer-shell'; |
| if (element.closest('.panel')) return 'panel'; |
| return ''; |
| } |
|
|
| function snapshotElement(element: Element | undefined): LcpElementSnapshot | null { |
| if (!element) return null; |
| const rawText = capText(element.textContent ?? ''); |
| return { |
| className: capText(getClassName(element), 240), |
| closest: closestAttributionLabel(element), |
| id: element.id, |
| selector: buildSelector(element), |
| tagName: element.tagName.toLowerCase(), |
| text: isLcpTextCaptureEnabled() ? rawText : '', |
| textLength: rawText.length, |
| }; |
| } |
|
|
| function classifyCriticalResource(name: string, initiatorType: string): string { |
| const lower = name.toLowerCase(); |
| if (lower.includes('/api/bootstrap')) return 'bootstrap'; |
| if (lower.includes('/api/news/v1/list-feed-digest')) return 'feed-digest'; |
| if (lower.includes('/data/countries.geojson')) return 'country-geometry'; |
| if (lower.includes('/data/countries-50m.json') || lower.includes('/data/countries-110m.json')) return 'map-topology'; |
| if ( |
| lower.includes('mapcontainer') |
| || lower.includes('deckglmap') |
| || lower.includes('globemap') |
| || lower.includes('maplibre') |
| || lower.includes('deck-stack') |
| || lower.includes('protomaps') |
| ) { |
| return 'map-chunk'; |
| } |
| if (lower.includes('sentry') || lower.includes('clerk') || lower.includes('vercel') || lower.includes('analytics')) { |
| return 'secondary-startup'; |
| } |
| if (initiatorType === 'script' || lower.endsWith('.js')) return 'script'; |
| if (initiatorType === 'css' || lower.endsWith('.css')) return 'style'; |
| if (lower.includes('/api/')) return 'api'; |
| if (lower.includes('/assets/') || lower.includes('/data/')) return 'static'; |
| return 'other'; |
| } |
|
|
| function summarizeCriticalResources(upToStartTime = Number.POSITIVE_INFINITY): LcpResourceGroup[] { |
| if (typeof performance === 'undefined' || typeof performance.getEntriesByType !== 'function') return []; |
| const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[]; |
| const groups = new Map<string, LcpResourceGroup>(); |
|
|
| for (const resource of resources) { |
| if (resource.startTime > upToStartTime + 1) continue; |
| const category = classifyCriticalResource(resource.name, resource.initiatorType); |
| if (category === 'other') continue; |
| const group = groups.get(category) ?? { |
| category, |
| count: 0, |
| encodedBodySize: 0, |
| transferSize: 0, |
| }; |
| const transferSize = Math.max(0, resource.transferSize || 0); |
| group.count += 1; |
| group.encodedBodySize += Math.max(0, resource.encodedBodySize || 0); |
| group.transferSize += transferSize; |
| if (!group.largest || transferSize > group.largest.transferSize) { |
| group.largest = { |
| duration: Math.round(resource.duration), |
| initiatorType: resource.initiatorType, |
| name: sanitizeWebVitalUrl(resource.name), |
| startTime: Math.round(resource.startTime), |
| transferSize, |
| }; |
| } |
| groups.set(category, group); |
| } |
|
|
| return Array.from(groups.values()) |
| .sort((a, b) => b.transferSize - a.transferSize || b.encodedBodySize - a.encodedBodySize || a.category.localeCompare(b.category)) |
| .slice(0, MAX_RESOURCE_GROUPS); |
| } |
|
|
| function snapshotContext(): LcpContextSnapshot { |
| const root = typeof document !== 'undefined' ? document.documentElement : null; |
| return { |
| devicePixelRatio: Math.round((typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1) * 100) / 100, |
| theme: root?.dataset.theme ?? '', |
| variant: root?.dataset.variant ?? '', |
| viewport: { |
| height: Math.round(typeof window !== 'undefined' ? window.innerHeight || root?.clientHeight || 0 : root?.clientHeight || 0), |
| width: Math.round(typeof window !== 'undefined' ? window.innerWidth || root?.clientWidth || 0 : root?.clientWidth || 0), |
| }, |
| visibilityState: typeof document !== 'undefined' ? document.visibilityState : '', |
| }; |
| } |
|
|
| function getOrCreateDebugState(): WmLcpDebugState { |
| const existing = window.__wmLcpDebug; |
| if (existing?.enabled) return existing; |
|
|
| const state: WmLcpDebugState = { |
| enabled: true, |
| entries: [], |
| getSnapshot: () => ({ |
| context: snapshotContext(), |
| entries: state.entries.slice(), |
| marks: state.marks.slice(), |
| resources: summarizeCriticalResources(), |
| }), |
| marks: [], |
| observerInstalled: false, |
| }; |
| window.__wmLcpDebug = state; |
| return state; |
| } |
|
|
| function pushCapped<T>(items: T[], item: T, cap: number): void { |
| items.push(item); |
| if (items.length > cap) items.splice(0, items.length - cap); |
| } |
|
|
| function recordLcpEntry(entry: LcpPerformanceEntry): void { |
| const state = window.__wmLcpDebug; |
| if (!state?.enabled) return; |
| pushCapped(state.entries, { |
| context: snapshotContext(), |
| element: snapshotElement(entry.element), |
| loadTime: Math.round(entry.loadTime ?? 0), |
| renderTime: Math.round(entry.renderTime ?? 0), |
| resources: summarizeCriticalResources(entry.startTime), |
| size: Math.round(entry.size ?? 0), |
| startTime: Math.round(entry.startTime), |
| url: sanitizeWebVitalUrl(entry.url), |
| }, MAX_ENTRIES); |
| } |
|
|
| export function installLcpAttributionDebug(): void { |
| if (typeof window === 'undefined' || !isLcpDebugEnabled()) return; |
| const state = getOrCreateDebugState(); |
| if (state.observerInstalled) return; |
| state.observerInstalled = true; |
|
|
| try { |
| const observer = new PerformanceObserver((list) => { |
| for (const entry of list.getEntries()) { |
| recordLcpEntry(entry as LcpPerformanceEntry); |
| } |
| }); |
| observer.observe({ type: 'largest-contentful-paint', buffered: true }); |
| } catch { |
| markLcpDebug('wm:lcp-debug:observer-unavailable'); |
| } |
|
|
| document.addEventListener('visibilitychange', () => { |
| markLcpDebug(`wm:visibility:${document.visibilityState}`); |
| }, { passive: true }); |
| window.addEventListener('pagehide', () => { |
| markLcpDebug('wm:pagehide'); |
| }, { passive: true }); |
|
|
| markLcpDebug('wm:lcp-debug:installed'); |
| } |
|
|
| export const __testing__ = { |
| capText, |
| classifyCriticalResource, |
| isLcpDebugEnabled, |
| isLcpTextCaptureEnabled, |
| isTruthyDebugFlag, |
| sanitizeResourceUrl: sanitizeWebVitalUrl, |
| snapshotContext, |
| }; |
|
|