UNPKG

uicore-ts

Version:

UICore is a library to build native-like user interfaces using pure Typescript. No HTML is needed at all. Components are described as TS classes and all user interactions are handled explicitly. This library is strongly inspired by the UIKit framework tha

1,177 lines (1,016 loc) 150 kB
/// #if DEV /** * UILayoutDebugger * * A development-only utility for visualizing and debugging the UIView layout * system. Disabled entirely at runtime unless explicitly enabled. * * FEATURES * ───────── * * 1. Record-and-replay step debugger * Every layout pass is recorded as an ordered sequence of steps. Each step * captures the view that was laid out, its frame before and after, and the * frames assigned to each of its subviews. After the pass completes you can * scrub through the steps one at a time in the overlay UI, seeing exactly * which view was processed at each point and how the frames changed. * * 2. Live breakpoint step-through * When breakpoint mode is enabled (UILayoutDebugger.enableBreakpoints()), a * special sentinel line is executed before each view's layoutIfNeeded() call. * Put a browser debugger breakpoint on that line and the JS debugger will * pause before every layout step — the full live JS stack and all object * state are available at that point, exactly as with any other breakpoint. * * The sentinel line is: * const breakpointOnThisLine = "Add a breakpoint on this line to step through layout." * Search for that string in the Sources panel to find it quickly. * * 3. View-tree heat-map overlay * A floating panel renders the full view hierarchy as an indented tree. * Each node is coloured by how many times it was laid out in the most recent * pass: untouched (grey), once (green), twice (orange), three-or-more (red). * The node currently active in the step scrubber is highlighted in blue. * Hovering a node shows its class, elementID, and frame. * * INTEGRATION POINTS IN UIView.ts * ──────────────────────────────── * Add the following calls alongside the existing UILayoutCycleTracer calls: * * layoutViewsIfNeeded(): * window.UILayoutDebugger?.willBeginLayoutPass(UIView._viewsToLayout) // before the while loop * window.UILayoutDebugger?.willBeginIteration(iteration) // inside the while loop, top * window.UILayoutDebugger?.willLayoutView(view) // before view.layoutIfNeeded() * [breakpoint sentinel — see below] * view.layoutIfNeeded() * window.UILayoutDebugger?.didLayoutView(view) // after view.layoutIfNeeded() * window.UILayoutDebugger?.didFinishLayoutPass(iteration) // after the while loop * * layoutSubviews(): * window.UILayoutDebugger?.willSetSubviewFrames(this) // before the subview loop * [existing subview loop] * window.UILayoutDebugger?.didSetSubviewFrames(this) // after the subview loop * * The breakpoint sentinel block (inside layoutViewsIfNeeded, before * view.layoutIfNeeded()): * * if (window.UILayoutDebugger?._shouldHitBreakpoint(view)) { * const breakpointOnThisLine = "Add a breakpoint on this line to step through layout." * } * * USAGE * ───── * UILayoutDebugger.enable() — record traces and show the overlay * UILayoutDebugger.disable() — hide overlay and stop recording * UILayoutDebugger.enableBreakpoints() — also pause at each layout step * UILayoutDebugger.stepForward() — advance the replay scrubber by one * UILayoutDebugger.stepBack() — retreat the replay scrubber by one * UILayoutDebugger.goToStep(n) — jump to step n (0-based) */ // ─── Data model ───────────────────────────────────────────────────────────── interface UILayoutDebugFrame { top: number left: number width: number height: number } /** A snapshot of a view's intrinsic size cache at a point in time. */ interface UILayoutDebugCacheSnapshot { entryCount: number entries: Record<string, { width: number; height: number }> isShared: boolean sharedKey?: string /** Whether _frameCache was populated at snapshot time. */ hasFrameCache: boolean /** Snapshot of _frameCache if populated; null if absent. */ frameCache: UILayoutDebugFrame | null /** Whether _frameCacheForVirtualLayouting was populated at snapshot time. */ hasVirtualFrameCache: boolean /** Snapshot of _frameCacheForVirtualLayouting if populated; null if absent. */ virtualFrameCache: UILayoutDebugFrame | null } /** * Global UITextMeasurement cache sizes at a snapshot instant. * These are not per-view — they're attached to the snapshot as a whole. */ interface UILayoutDebugTextMeasurementSnapshot { preparedCacheSize: number styleCacheSize: number } interface UILayoutDebugSubviewRecord { viewIndex: number // _UIViewIndex of the subview className: string elementID: string frameBefore: UILayoutDebugFrame | null frameAfter: UILayoutDebugFrame | null } /** What caused a view to enter the layout queue. */ interface UILayoutDebugTrigger { callerFunction: string // first application frame, e.g. "MyView.layoutSubviews" cleanStack: string // full cleaned stack string } /** One step = one call to layoutIfNeeded() on one view. */ interface UILayoutDebugStep { stepIndex: number iteration: number viewIndex: number // _UIViewIndex of the laid-out view className: string elementID: string frameBefore: UILayoutDebugFrame | null frameAfter: UILayoutDebugFrame | null cacheBefore: UILayoutDebugCacheSnapshot | null cacheAfter: UILayoutDebugCacheSnapshot | null subviewRecords: UILayoutDebugSubviewRecord[] trigger: UILayoutDebugTrigger | null // what called setNeedsLayout on this view } interface UILayoutDebugTreeNode { viewIndex: number className: string elementID: string depth: number frame: UILayoutDebugFrame | null layoutCount: number // times laid out in the recorded pass cacheAfterPass: UILayoutDebugCacheSnapshot | null // intrinsic cache state after the pass children: UILayoutDebugTreeNode[] } interface UILayoutDebugTrace { passIndex: number steps: UILayoutDebugStep[] roots: UILayoutDebugTreeNode[] totalIterations: number cacheChanges: UILayoutDebugCacheChangeEvent[] } /** A flat snapshot of every view's frame and intrinsic cache at a point in time. */ interface UILayoutDebugStateSnapshot { label: string takenAt: number // Date.now() views: Map<number, UILayoutDebugViewState> /** Global UITextMeasurement cache sizes at the instant this snapshot was taken. */ textMeasurement: UILayoutDebugTextMeasurementSnapshot } interface UILayoutDebugViewState { viewIndex: number className: string elementID: string frame: UILayoutDebugFrame | null cache: UILayoutDebugCacheSnapshot | null } type UILayoutDebugDiffKind = "appeared" | "disappeared" | "frame" | "cache" | "both" | "unchanged" interface UILayoutDebugViewDiff { kind: UILayoutDebugDiffKind viewIndex: number className: string elementID: string baselineFrame: UILayoutDebugFrame | null currentFrame: UILayoutDebugFrame | null baselineCache: UILayoutDebugCacheSnapshot | null currentCache: UILayoutDebugCacheSnapshot | null } /** * Fired when _getCachedIntrinsicSize returns a value that differs from the * last value we observed for that view+cacheKey combination. */ interface UILayoutDebugCacheChangeEvent { eventIndex: number stepIndex: number // which step was active when the write occurred (-1 = between steps) iteration: number viewIndex: number className: string elementID: string cacheKey: string // raw key, e.g. "h_0__w_500" newValue: { width: number; height: number } callerFunction: string // first app-code frame at point of write cleanStack: string } // ─── Main class ────────────────────────────────────────────────────────────── export class UILayoutDebugger { // ── Runtime guard ──────────────────────────────────────────────────────── // The #if DEV preprocessor comment may not be present in every build. // This flag is the authoritative runtime gate. All hook methods check it // first and are no-ops unless _isEnabled is true. static _isEnabled: boolean = false static _breakpointsEnabled: boolean = false static get isEnabled(): boolean { return UILayoutDebugger._isEnabled } static get breakpointsEnabled(): boolean { return UILayoutDebugger._breakpointsEnabled } // ── Recording state ────────────────────────────────────────────────────── static _passIndex: number = 0 static _currentTrace: UILayoutDebugTrace | null = null static _currentIteration: number = 0 // Pending step being built as a view is being laid out static _pendingStep: UILayoutDebugStep | null = null // Subview frames captured during layoutSubviews() of the pending step's view static _pendingSubviewsBefore: Map<number, UILayoutDebugFrame | null> = new Map() // Per-view layout counts for the current pass (used for tree colouring) static _layoutCountsThisPass: Map<number, number> = new Map() // Live view object references keyed by _UIViewIndex, populated during the // pass and used to build the subtree forest in didFinishLayoutPass. static _liveViewRegistry: Map<number, any> = new Map() // First setNeedsLayout trigger per view per pass. Only the first enqueue // is recorded — subsequent redundant calls on the same view are ignored. static _triggerMap: Map<number, UILayoutDebugTrigger> = new Map() // Stack frames belonging to framework internals that should be stripped // from the top of a captured stack so the first visible frame is always // application code. static _noiseFramePrefixes: string[] = [ "UILayoutDebugger", "UIView.setNeedsLayout", "setNeedsLayout", "UIView.didLayoutSubviews", "didLayoutSubviews", "UIView.layoutSubviews", "UIView.layoutIfNeeded", "layoutIfNeeded", "UIView.layoutViewsIfNeeded", "layoutViewsIfNeeded", "UIView._setCachedIntrinsicSize", "_setCachedIntrinsicSize", ] // All completed traces, newest first static _traces: UILayoutDebugTrace[] = [] static readonly maxStoredTraces = 20 // ── Replay state ───────────────────────────────────────────────────────── static _replayTraceIndex: number = 0 // which trace is shown in left/single pane static _replayStepIndex: number = -1 // -1 = before any step // ── Compare mode state ──────────────────────────────────────────────────── static _compareMode: boolean = false static _frameFilter: "all" | "changed" | "unchanged" = "all" /** * When true, frame comparisons ignore origin (x/y) and only consider size * (width/height) — i.e. they diff bounds rather than frames. * A position-only move does not trigger a layout recompute of content, so * bounds mode surfaces only the changes that actually matter for sizing. */ static _boundsBasedDiff: boolean = false static _compareTraceIndex: number = 1 // which trace is shown in right pane static _compareStepIndex: number = -1 // Shared expand/collapse state for the tree in compare mode, keyed by // viewIndex. When both trees render from the same map, toggling one node // collapses/expands the same node in both panes simultaneously. static _sharedExpandState: Map<number, boolean> = new Map() // Expand state for the single-column pass inspector. Kept persistent so // the live inspector can sync from it. static _singleExpandState: Map<number, boolean> = new Map() // Expand state for the live inspector panel. static _liveExpandState: Map<number, boolean> = new Map() // ── Public API ─────────────────────────────────────────────────────────── static enable() { UILayoutDebugger._isEnabled = true UILayoutDebugger._ensureOverlay() UILayoutDebugger._renderOverlay() console.log( "%c[UILayoutDebugger] ENABLED — recording layout traces and showing overlay.", "color: #4CAF50; font-weight: bold" ) } static disable() { UILayoutDebugger._isEnabled = false UILayoutDebugger._breakpointsEnabled = false UILayoutDebugger._removeOverlay() console.log( "%c[UILayoutDebugger] DISABLED.", "color: #9E9E9E; font-weight: bold" ) } /** * Enable the breakpoint sentinel. Once enabled, _shouldHitBreakpoint() * returns true before every layoutIfNeeded() call so the browser debugger * can pause on the sentinel line in UIView.ts. */ static enableBreakpoints() { if (!UILayoutDebugger._isEnabled) { UILayoutDebugger.enable() } UILayoutDebugger._breakpointsEnabled = true console.log( "%c[UILayoutDebugger] Breakpoint mode ON. " + "Search for 'breakpointOnThisLine' in Sources to set your breakpoint.", "color: #FF9800; font-weight: bold" ) } static disableBreakpoints() { UILayoutDebugger._breakpointsEnabled = false console.log( "%c[UILayoutDebugger] Breakpoint mode OFF.", "color: #9E9E9E" ) } // ── Replay controls ────────────────────────────────────────────────────── static stepForward() { if (!UILayoutDebugger._isEnabled) { return } const trace = UILayoutDebugger._currentReplayTrace if (!trace) { return } const next = UILayoutDebugger._replayStepIndex + 1 UILayoutDebugger.goToStep(Math.min(next, trace.steps.length - 1)) } static stepBack() { if (!UILayoutDebugger._isEnabled) { return } UILayoutDebugger.goToStep(Math.max(UILayoutDebugger._replayStepIndex - 1, -1)) } static goToStep(stepIndex: number) { if (!UILayoutDebugger._isEnabled) { return } UILayoutDebugger._replayStepIndex = stepIndex UILayoutDebugger._renderOverlay() } static goToCompareStep(stepIndex: number) { if (!UILayoutDebugger._isEnabled) { return } UILayoutDebugger._compareStepIndex = stepIndex UILayoutDebugger._renderOverlay() } static showTrace(traceIndex: number) { if (!UILayoutDebugger._isEnabled) { return } const clamped = Math.max(0, Math.min(traceIndex, UILayoutDebugger._traces.length - 1)) UILayoutDebugger._replayTraceIndex = clamped UILayoutDebugger._replayStepIndex = -1 UILayoutDebugger._singleExpandState = new Map() UILayoutDebugger._renderOverlay() } static showCompareTrace(traceIndex: number) { if (!UILayoutDebugger._isEnabled) { return } const clamped = Math.max(0, Math.min(traceIndex, UILayoutDebugger._traces.length - 1)) UILayoutDebugger._compareTraceIndex = clamped UILayoutDebugger._compareStepIndex = -1 UILayoutDebugger._renderOverlay() } static get _currentReplayTrace(): UILayoutDebugTrace | null { return UILayoutDebugger._traces[UILayoutDebugger._replayTraceIndex] ?? null } static get _currentCompareTrace(): UILayoutDebugTrace | null { return UILayoutDebugger._traces[UILayoutDebugger._compareTraceIndex] ?? null } // ── Hook: called from layoutViewsIfNeeded() ────────────────────────────── static willBeginLayoutPass(viewsToLayout: any[]) { if (!UILayoutDebugger._isEnabled) { return } UILayoutDebugger._currentTrace = { passIndex: UILayoutDebugger._passIndex++, steps: [], roots: [], cacheChanges: [], totalIterations: 0, } UILayoutDebugger._currentIteration = 0 UILayoutDebugger._layoutCountsThisPass = new Map() UILayoutDebugger._liveViewRegistry = new Map() UILayoutDebugger._pendingStep = null UILayoutDebugger._pendingSubviewsBefore = new Map() } static willBeginIteration(iteration: number) { if (!UILayoutDebugger._isEnabled) { return } UILayoutDebugger._currentIteration = iteration } /** * Called from setNeedsLayout() each time a view is enqueued. * Only the *first* enqueue per view per pass is recorded — that is the * call that actually caused the view to enter the queue. Subsequent calls * on the same view within the same pass are redundant and ignored. */ static viewDidCallSetNeedsLayout(view: any) { if (!UILayoutDebugger._isEnabled) { return } const viewIdx: number = view?._UIViewIndex ?? -1 if (viewIdx < 0) { return } // Only record the first enqueue per view per pass. if (UILayoutDebugger._triggerMap.has(viewIdx)) { return } const rawStack = new Error().stack ?? "" const cleanStack = UILayoutDebugger._cleanStack(rawStack) const callerFunction = UILayoutDebugger._extractCallerFunctionName(cleanStack) UILayoutDebugger._triggerMap.set(viewIdx, { callerFunction, cleanStack }) } /** * Called from _setCachedIntrinsicSize() after the value is written. * Every write is a change by definition, so no history comparison is needed. * * Call site in UIView.ts, at the end of _setCachedIntrinsicSize(): * * window.UILayoutDebugger?.didSetCachedIntrinsicSize(this, cacheKey, size) */ static didSetCachedIntrinsicSize(view: any, cacheKey: string, value: any) { if (!UILayoutDebugger._isEnabled) { return } const trace = UILayoutDebugger._currentTrace if (!trace) { return } const viewIdx: number = view?._UIViewIndex ?? -1 if (viewIdx < 0) { return } const rawStack = new Error().stack ?? "" const cleanStack = UILayoutDebugger._cleanStack(rawStack) const callerFunction = UILayoutDebugger._extractCallerFunctionName(cleanStack) const event: UILayoutDebugCacheChangeEvent = { eventIndex: trace.cacheChanges.length, stepIndex: UILayoutDebugger._pendingStep?.stepIndex ?? -1, iteration: UILayoutDebugger._currentIteration, viewIndex: viewIdx, className: view?.constructor?.name ?? "UnknownView", elementID: view?.elementID ?? String(viewIdx), cacheKey, newValue: { width: value?.width ?? 0, height: value?.height ?? 0 }, callerFunction, cleanStack, } trace.cacheChanges.push(event) } static willLayoutView(view: any) { if (!UILayoutDebugger._isEnabled) { return } const stepIndex = UILayoutDebugger._currentTrace?.steps.length ?? 0 const viewIdx: number = view?._UIViewIndex ?? -1 // Keep a live reference so didFinishLayoutPass can reach .rootView. if (viewIdx >= 0) { UILayoutDebugger._liveViewRegistry.set(viewIdx, view) } UILayoutDebugger._pendingStep = { stepIndex, iteration: UILayoutDebugger._currentIteration, viewIndex: viewIdx, className: view?.constructor?.name ?? "UnknownView", elementID: view?.elementID ?? String(viewIdx), frameBefore: UILayoutDebugger._captureFrame(view), frameAfter: null, cacheBefore: UILayoutDebugger._captureCache(view), cacheAfter: null, subviewRecords: [], trigger: UILayoutDebugger._triggerMap.get(viewIdx) ?? null, } // Consume the trigger so it doesn't linger across future passes. UILayoutDebugger._triggerMap.delete(viewIdx) // Capture subview frames *before* layout. The post-layout capture // happens in didSetSubviewFrames() which is called from layoutSubviews(). UILayoutDebugger._pendingSubviewsBefore = new Map() const subviews: any[] = view?.subviews ?? [] for (let i = 0; i < subviews.length; i++) { const sv = subviews[i] const idx: number = sv?._UIViewIndex ?? -i UILayoutDebugger._pendingSubviewsBefore.set(idx, UILayoutDebugger._captureFrame(sv)) } } /** * Called immediately after view.layoutIfNeeded(). Closes the pending step * with the post-layout frame. */ static didLayoutView(view: any) { if (!UILayoutDebugger._isEnabled) { return } const step = UILayoutDebugger._pendingStep if (!step) { return } step.frameAfter = UILayoutDebugger._captureFrame(view) step.cacheAfter = UILayoutDebugger._captureCache(view) const viewIdx: number = view?._UIViewIndex ?? -1 const prev = UILayoutDebugger._layoutCountsThisPass.get(viewIdx) ?? 0 UILayoutDebugger._layoutCountsThisPass.set(viewIdx, prev + 1) UILayoutDebugger._currentTrace?.steps.push(step) UILayoutDebugger._pendingStep = null } static didFinishLayoutPass(iterationCount: number) { if (!UILayoutDebugger._isEnabled) { return } const trace = UILayoutDebugger._currentTrace if (!trace) { return } trace.totalIterations = iterationCount // Build a single full-tree snapshot by starting from the global root. // Any laid-out view has a .rootView property that walks to the top of // the hierarchy, giving full context around the affected views. const anyView = UILayoutDebugger._liveViewRegistry.values().next().value const rootView = anyView?.rootView if (rootView) { UILayoutDebugger._lastKnownRootView = rootView const visited = new Set<number>() const rootIdx: number = rootView._UIViewIndex ?? -1 if (rootIdx >= 0) { visited.add(rootIdx) } trace.roots = [UILayoutDebugger._buildTreeSnapshot(rootView, 0, visited)] } // Prepend and evict old traces. UILayoutDebugger._traces.unshift(trace) if (UILayoutDebugger._traces.length > UILayoutDebugger.maxStoredTraces) { UILayoutDebugger._traces.length = UILayoutDebugger.maxStoredTraces } UILayoutDebugger._replayTraceIndex = 0 UILayoutDebugger._replayStepIndex = -1 UILayoutDebugger._currentTrace = null UILayoutDebugger._liveViewRegistry.clear() UILayoutDebugger._renderOverlay() } /** Discard all recorded traces and reset the replay state. */ static clearTraces() { UILayoutDebugger._traces = [] UILayoutDebugger._passIndex = 0 UILayoutDebugger._replayTraceIndex = 0 UILayoutDebugger._compareTraceIndex = 1 UILayoutDebugger._replayStepIndex = -1 UILayoutDebugger._compareStepIndex = -1 UILayoutDebugger._renderOverlay() } // ── Baseline / diff API ────────────────────────────────────────────────── /** Capture the current view tree state as the baseline for future diffs. */ static captureBaseline() { if (!UILayoutDebugger._isEnabled) { return } const snap = UILayoutDebugger._captureStateSnapshot("Baseline") if (!snap) { console.warn("[UILayoutDebugger] captureBaseline: no root view found yet — trigger a layout pass first.") return } UILayoutDebugger._baseline = snap UILayoutDebugger._diffSnapshot = null UILayoutDebugger._diffMode = false UILayoutDebugger._renderOverlay() console.log( `%c[UILayoutDebugger] Baseline captured — ${snap.views.size} views.`, "color: #88ddff; font-weight: bold" ) } /** Capture the current state and diff it against the baseline. */ static captureAndDiff() { if (!UILayoutDebugger._isEnabled) { return } if (!UILayoutDebugger._baseline) { console.warn("[UILayoutDebugger] captureAndDiff: no baseline set. Call captureBaseline() first.") return } const snap = UILayoutDebugger._captureStateSnapshot("Current") if (!snap) { console.warn("[UILayoutDebugger] captureAndDiff: could not find root view.") return } UILayoutDebugger._diffSnapshot = snap UILayoutDebugger._diffMode = true UILayoutDebugger._renderOverlay() } static clearDiff() { UILayoutDebugger._baseline = null UILayoutDebugger._diffSnapshot = null UILayoutDebugger._diffMode = false UILayoutDebugger._renderOverlay() } /** * ☢ Stale Layout Report * * The single authoritative way to discover missing cache invalidations. * * What this does, in order: * 1. Snapshots every view's frame and intrinsic cache right now (the * "before" state — potentially stale/incorrect). * 2. Calls performForcedSubtreeLayout() on the root view, which nukes all * caches and forces a complete cold remeasure of the entire tree. * 3. Snapshots again ("after" state — ground truth). * 4. Diffs the two snapshots. Any view that changed between before and * after had stale state that was never correctly invalidated. * 5. For each changed view, cross-references the cache writes from the * forced pass so you can see exactly which call path recomputed the * correct value — working backwards from that to find the missing * invalidation site. * * The result is shown in the ☢ Stale panel to the right of the pass * inspector. Views corrected by the forced pass are also tinted amber in * the pass inspector tree on the subsequent pass. * * Limitations: * - Calls performForcedSubtreeLayout(), which is itself a nuclear option. * The tree will be left in its corrected state — not the buggy state. * Use this at the point where the bug is visible, not before. * - The forced layout will generate a new trace (the remeasure pass). * The ☢ panel cross-references its cache writes automatically. * - Only intrinsic-size cache corrections are cross-referenced. Frame * corrections are shown as diffs but do not yet have a write-stack. */ static captureStaleLayoutReport() { if (!UILayoutDebugger._isEnabled) { return } const rootView = UILayoutDebugger._lastKnownRootView if (!rootView) { console.warn( "[UILayoutDebugger] captureStaleLayoutReport: no root view found yet — " + "trigger a layout pass first." ) return } // Step 1 — snapshot before. // We pin rootView here and pass it directly to a targeted walk rather than // going through _lastKnownRootView, because performForcedSubtreeLayout // drives layoutViewsIfNeeded synchronously, which fires didFinishLayoutPass, // which may update _lastKnownRootView to a detached view that was temporarily // inserted into document.body for intrinsic-size measurement. If that // happened the "after" snapshot would walk from a completely different root // than "before", causing spurious diffs for every view in the real tree. const before = UILayoutDebugger._captureStateSnapshotFromRoot(rootView, "Before (potentially stale)") // Step 2 — nuclear reset rootView.performForcedSubtreeLayout?.() // Step 3 — snapshot after (ground truth), using the same pinned root. const after = UILayoutDebugger._captureStateSnapshotFromRoot(rootView, "After (forced cold remeasure)") // Step 4 — diff const diffs = UILayoutDebugger._diffSnapshots(before, after) .filter(d => d.kind !== "unchanged") // Step 5 — collect cache writes from the forced pass (the trace that was // just recorded by performForcedSubtreeLayout), keyed by viewIndex. const forcedPassCacheChanges = new Map<number, UILayoutDebugCacheChangeEvent[]>() const forcedTrace = UILayoutDebugger._traces[0] ?? null // newest = the forced pass const forcedPassIndex = forcedTrace?.passIndex ?? -1 if (forcedTrace) { for (const ev of forcedTrace.cacheChanges) { let bucket = forcedPassCacheChanges.get(ev.viewIndex) if (!bucket) { bucket = [] forcedPassCacheChanges.set(ev.viewIndex, bucket) } bucket.push(ev) } } UILayoutDebugger._staleReportResult = { before, after, diffs, forcedPassCacheChanges, passIndex: forcedPassIndex } UILayoutDebugger._staleReportMode = true UILayoutDebugger._renderOverlay() const correctedCount = diffs.length console.log( `%c[UILayoutDebugger] Stale layout report: ${correctedCount} view(s) had stale state corrected by forced layout.`, "color: #ffaa55; font-weight: bold" ) } static clearStaleReport() { UILayoutDebugger._staleReportResult = null UILayoutDebugger._staleReportMode = false UILayoutDebugger._renderOverlay() } static toggleLiveInspector() { UILayoutDebugger._liveInspectorMode = !UILayoutDebugger._liveInspectorMode UILayoutDebugger._renderOverlay() } static _captureStateSnapshot(label: string): UILayoutDebugStateSnapshot | null { const rootView = UILayoutDebugger._lastKnownRootView if (!rootView) { return null } return UILayoutDebugger._captureStateSnapshotFromRoot(rootView, label) } /** * Like _captureStateSnapshot but walks from an explicit root rather than * _lastKnownRootView. Use this whenever the root must be pinned across a * call that may update _lastKnownRootView (e.g. captureStaleLayoutReport, * which drives a layout pass internally). */ static _captureStateSnapshotFromRoot(rootView: any, label: string): UILayoutDebugStateSnapshot { const views = new Map<number, UILayoutDebugViewState>() UILayoutDebugger._walkViewTree(rootView, views, new Set()) // Read UITextMeasurement global cache sizes. Both maps are private, but // accessible via the class reference on window if exposed, or via the // module-level import. We reach them defensively so the debugger never // throws if the import shape changes. const tm: any = (window as any).UITextMeasurement const textMeasurement: UILayoutDebugTextMeasurementSnapshot = { preparedCacheSize: tm?._preparedCache?.size ?? -1, styleCacheSize: tm?.globalStyleCache?.size ?? -1, } return { label, takenAt: Date.now(), views, textMeasurement } } static _walkViewTree( view: any, out: Map<number, UILayoutDebugViewState>, visited: Set<number>, ) { const idx: number = view?._UIViewIndex ?? -1 if (idx < 0 || visited.has(idx)) { return } visited.add(idx) out.set(idx, { viewIndex: idx, className: view?.constructor?.name ?? "UnknownView", elementID: view?.elementID ?? String(idx), frame: UILayoutDebugger._captureFrame(view), cache: UILayoutDebugger._captureCache(view), }) const subviews: any[] = view?.subviews ?? [] for (const sv of subviews) { UILayoutDebugger._walkViewTree(sv, out, visited) } } static _diffSnapshots( baseline: UILayoutDebugStateSnapshot, current: UILayoutDebugStateSnapshot, ): UILayoutDebugViewDiff[] { const diffs: UILayoutDebugViewDiff[] = [] const allKeys = new Set([...baseline.views.keys(), ...current.views.keys()]) for (const idx of allKeys) { const b = baseline.views.get(idx) ?? null const c = current.views.get(idx) ?? null if (!b) { diffs.push({ kind: "appeared", viewIndex: idx, className: c!.className, elementID: c!.elementID, baselineFrame: null, currentFrame: c!.frame, baselineCache: null, currentCache: c!.cache }) continue } if (!c) { diffs.push({ kind: "disappeared", viewIndex: idx, className: b.className, elementID: b.elementID, baselineFrame: b.frame, currentFrame: null, baselineCache: b.cache, currentCache: null }) continue } const frameChanged = UILayoutDebugger._framesEqual(b.frame, c.frame) === false const cacheChanged = UILayoutDebugger._cachesEqual(b.cache, c.cache) === false const kind: UILayoutDebugDiffKind = frameChanged && cacheChanged ? "both" : frameChanged ? "frame" : cacheChanged ? "cache" : "unchanged" diffs.push({ kind, viewIndex: idx, className: c.className, elementID: c.elementID, baselineFrame: b.frame, currentFrame: c.frame, baselineCache: b.cache, currentCache: c.cache }) } // Sort: appeared, disappeared, both, frame, cache, unchanged const order: Record<UILayoutDebugDiffKind, number> = { appeared: 0, disappeared: 1, both: 2, frame: 3, cache: 4, unchanged: 5, } diffs.sort((a, b) => order[a.kind] - order[b.kind]) return diffs } static _framesEqual(a: UILayoutDebugFrame | null, b: UILayoutDebugFrame | null): boolean { if (!a && !b) { return true } if (!a || !b) { return false } if (UILayoutDebugger._boundsBasedDiff) { // Bounds mode: ignore origin, compare size only. return a.width === b.width && a.height === b.height } return a.left === b.left && a.top === b.top && a.width === b.width && a.height === b.height } static _cachesEqual(a: UILayoutDebugCacheSnapshot | null, b: UILayoutDebugCacheSnapshot | null): boolean { if (!a && !b) { return true } if (!a || !b) { return false } if (a.entryCount !== b.entryCount) { return false } for (const key of Object.keys(a.entries)) { const ae = a.entries[key] const be = b.entries[key] if (!be || ae.width !== be.width || ae.height !== be.height) { return false } } if (a.hasFrameCache !== b.hasFrameCache) { return false } if (a.hasFrameCache && b.hasFrameCache) { const af = a.frameCache, bf = b.frameCache if (!af || !bf || af.top !== bf.top || af.left !== bf.left || af.width !== bf.width || af.height !== bf.height) { return false } } if (a.hasVirtualFrameCache !== b.hasVirtualFrameCache) { return false } if (a.hasVirtualFrameCache && b.hasVirtualFrameCache) { const av = a.virtualFrameCache, bv = b.virtualFrameCache if (!av || !bv || av.top !== bv.top || av.left !== bv.left || av.width !== bv.width || av.height !== bv.height) { return false } } return true } // ── Hook: called from layoutSubviews() ─────────────────────────────────── /** * Called at the top of layoutSubviews(), before the subview frame loop. * Nothing to do here — before-frames were already captured in willLayoutView(). */ static willSetSubviewFrames(_view: any) { // Reserved for future use; before-frames captured in willLayoutView(). } /** * Called at the bottom of layoutSubviews(), after the subview frame loop. * Merges the before/after subview frames into the pending step. */ static didSetSubviewFrames(view: any) { if (!UILayoutDebugger._isEnabled) { return } const step = UILayoutDebugger._pendingStep if (!step) { return } const subviews: any[] = view?.subviews ?? [] for (let i = 0; i < subviews.length; i++) { const sv = subviews[i] const idx: number = sv?._UIViewIndex ?? -i const record: UILayoutDebugSubviewRecord = { viewIndex: idx, className: sv?.constructor?.name ?? "UnknownView", elementID: sv?.elementID ?? String(idx), frameBefore: UILayoutDebugger._pendingSubviewsBefore.get(idx) ?? null, frameAfter: UILayoutDebugger._captureFrame(sv), } step.subviewRecords.push(record) } } // ── Breakpoint sentinel ────────────────────────────────────────────────── /** * Returns true when breakpoints are enabled, causing the sentinel block * in UIView.ts to execute. Put a browser debugger breakpoint on the * `const breakpointOnThisLine` assignment inside that block. */ static _shouldHitBreakpoint(_view: any): boolean { return UILayoutDebugger._isEnabled && UILayoutDebugger._breakpointsEnabled } // ── Internal helpers ───────────────────────────────────────────────────── static _cleanStack(rawStack: string): string { const lines = rawStack.split("\n") let firstAppFrameIndex = 1 // skip the "Error" header on line 0 for (let i = 1; i < lines.length; i++) { const trimmed = lines[i].trim() // V8 frame: "at ClassName.methodName (file:line:col)" // or: "at methodName (file:line:col)" // or: "at file:line:col" // Extract just the function/method name for noise matching. const atMatch = trimmed.match(/^at\s+([\w.<>$\s]+?)\s*(?:\(|$)/) const frameName = atMatch ? atMatch[1].trim() : trimmed // The method name is the part after the last dot (if any). const methodName = frameName.includes(".") ? frameName.slice(frameName.lastIndexOf(".") + 1) : frameName const isNoise = UILayoutDebugger._noiseFramePrefixes.some(prefix => { // Match against the full qualified name OR just the method name, // so "setNeedsLayout" catches UITextField.setNeedsLayout too. return frameName === prefix || methodName === prefix || frameName.endsWith("." + prefix) }) if (!isNoise) { firstAppFrameIndex = i break } } return lines.slice(firstAppFrameIndex).join("\n") } static _extractCallerFunctionName(cleanStack: string): string { const firstLine = cleanStack.split("\n")[0]?.trim() ?? "" // V8: "at ClassName.methodName (file:line:col)" const atMatch = firstLine.match(/^at\s+([\w.<>$\s]+?)\s*(?:\(|$)/) if (atMatch) { return atMatch[1].trim() } return firstLine.substring(0, 80) || "(unknown)" } static _captureFrame(view: any): UILayoutDebugFrame | null { const f = view?._frame if (!f) { return null } return { top: f.top ?? f.y ?? 0, left: f.left ?? f.x ?? 0, width: f.width ?? 0, height: f.height ?? 0, } } static _captureCache(view: any): UILayoutDebugCacheSnapshot | null { if (!view) { return null } const sharedKey: string | undefined = view.sharedIntrinsicSizeCacheIdentifier const isShared = !!sharedKey let rawEntries: Record<string, any> if (isShared) { rawEntries = (view.constructor?._sharedIntrinsicSizeCaches ?? view.__proto__?.constructor?._sharedIntrinsicSizeCaches) ?.get(sharedKey) ?? {} } else { rawEntries = view._intrinsicSizesCache ?? {} } const entries: Record<string, { width: number; height: number }> = {} for (const key of Object.keys(rawEntries)) { const r = rawEntries[key] entries[key] = { width: r?.width ?? 0, height: r?.height ?? 0 } } // Frame caches — these are per-instance UIRectangle | undefined fields. const rawFrameCache = view._frameCache const rawVirtualFrameCache = view._frameCacheForVirtualLayouting const frameCache: UILayoutDebugFrame | null = rawFrameCache ? { top: rawFrameCache.top ?? rawFrameCache.y ?? 0, left: rawFrameCache.left ?? rawFrameCache.x ?? 0, width: rawFrameCache.width ?? 0, height: rawFrameCache.height ?? 0 } : null const virtualFrameCache: UILayoutDebugFrame | null = rawVirtualFrameCache ? { top: rawVirtualFrameCache.top ?? rawVirtualFrameCache.y ?? 0, left: rawVirtualFrameCache.left ?? rawVirtualFrameCache.x ?? 0, width: rawVirtualFrameCache.width ?? 0, height: rawVirtualFrameCache.height ?? 0 } : null return { entryCount: Object.keys(entries).length, entries, isShared, sharedKey, hasFrameCache: rawFrameCache !== undefined, frameCache, hasVirtualFrameCache: rawVirtualFrameCache !== undefined, virtualFrameCache, } } static _buildTreeSnapshot( view: any, depth: number, visited: Set<number> = new Set(), ): UILayoutDebugTreeNode { const idx: number = view?._UIViewIndex ?? -1 const node: UILayoutDebugTreeNode = { viewIndex: idx, className: view?.constructor?.name ?? "UnknownView", elementID: view?.elementID ?? String(idx), depth, frame: UILayoutDebugger._captureFrame(view), layoutCount: UILayoutDebugger._layoutCountsThisPass.get(idx) ?? 0, cacheAfterPass: UILayoutDebugger._captureCache(view), children: [], } const subviews: any[] = view?.subviews ?? [] for (let i = 0; i < subviews.length; i++) { const sv = subviews[i] const svIdx: number = sv?._UIViewIndex ?? -1 if (svIdx < 0 || visited.has(svIdx)) { continue } visited.add(svIdx) node.children.push(UILayoutDebugger._buildTreeSnapshot(sv, depth + 1, visited)) } return node } // ── Baseline / diff state ───────────────────────────────────────────────── static _baseline: UILayoutDebugStateSnapshot | null = null static _diffSnapshot: UILayoutDebugStateSnapshot | null = null static _diffMode: boolean = false static _liveInspectorMode: boolean = false // ── Stale layout report state ───────────────────────────────────────────── /** Result of the last captureStaleLayoutReport() run. */ static _staleReportResult: { before: UILayoutDebugStateSnapshot after: UILayoutDebugStateSnapshot diffs: UILayoutDebugViewDiff[] /** viewIndex → cacheChanges from the forced-layout pass, for cross-referencing */ forcedPassCacheChanges: Map<number, UILayoutDebugCacheChangeEvent[]> passIndex: number } | null = null /** Whether the stale report side-panel is open. */ static _staleReportMode: boolean = false // Persists across passes so captureBaseline() works between passes. static _lastKnownRootView: any = null /** * Finds the first trace (chronologically) recorded after baselineTakenAt * that contains a step for the given viewIndex. Returns {traceIndex, stepIndex} * into _traces, or null if none found. */ static _findCausingTrace( viewIndex: number, baselineTakenAt: number, ): { traceIndex: number; stepIndex: number; passIndex: number } | null { // _traces is newest-first, so iterate in reverse for chronological order for (let ti = UILayoutDebugger._traces.length - 1; ti >= 0; ti--) { const trace = UILayoutDebugger._traces[ti] // We don't store a timestamp on traces, but passIndex is monotonically // increasing. The baseline was taken at a wall-clock time; the closest // proxy is to find traces whose passIndex is greater than any pass that // completed before the baseline. Since we can't correlate exactly, we // just find the first trace (oldest) that touched the view and show it. const si = trace.steps.findIndex(s => s.viewIndex === viewIndex) if (si >= 0) { return { traceIndex: ti, stepIndex: si, passIndex: trace.passIndex } } } return null } // ── Overlay UI ─────────────────────────────────────────────────────────── static _overlayRoot: HTMLElement | null = null static _overlayVisible: boolean = true static _helpMode: boolean = false static _ensureOverlay() { if (UILayoutDebugger._overlayRoot) { return } const root = document.createElement("div") root.id = "__UILayoutDebugger_overlay" root.style.cssText = [ "position: fixed", "top: 8px", "right: 8px", "max-height: calc(100vh - 16px)", "background: rgba(15, 15, 20, 0.96)", "color: #e8e8e8", "font: 11px/1.4 'SF Mono', 'Menlo', 'Consolas', monospace", "border-radius: 8px", "border: 1px solid rgba(255,255,255,0.12)", "box-shadow: 0 8px 32px rgba(0,0,0,0.6)", "z-index: 2147483647", "display: flex", "flex-direction: column", "overflow: hidden", "user-select: none", ].join("; ") document.body.appendChild(root) UILayoutDebugger._overlayRoot = root UILayoutDebugger._makeDraggable(root) } static _removeOverlay() { UILayoutDebugger._overlayRoot?.remove() UILayoutDebugger._overlayRoot = null } static _renderOverlay() { const root = UILayoutDebugger._overlayRoot if (!root) { return } const cmp = UILayoutDebugger._compareMode const diff = UILayoutDebugger._diffMode && !!UILayoutDebugger._baseline && !!UILayoutDebugger._diffSnapshot const live = UILayoutDebugger._liveInspectorMode && !!UILayoutDebugger._lastKnownRootView const stale = UILayoutDebugger._staleReportMode && !!UILayoutDebugger._staleReportResult const passWidth = cmp ? 1140 : 570 const extraWidth = (diff ? 320 : 0) + (live ? 320 : 0) + (stale ? 360 : 0) root.style.width = (passWidth + extraWidth) + "px" root.innerHTML = "" // ── Header ────────────────────────────────────────────────────────── // ── Header row 1: title + core controls ───────────────────────────── const headerRow1 = UILayoutDebugger._el("div", [ "padding: 8px 10px 5px", "background: rgba(255,255,255,0.05)", "display: flex", "align-items: center", "gap: 6px", "cursor: move", "flex-shrink: 0", ]) headerRow1.dataset.dragHandle = "1" const title = UILayoutDebugger._el("span", ["flex: 1", "font-weight: bold", "font-size: 11px", "color: #c8d8ff"]) title.textContent = "⚙ UILayoutDebugger" const helpBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle( UILayoutDebugger._helpMode ? "#ffcc88" : "#9090a8" )) helpBtn.textContent = "ⓘ" helpBtn.title = "Show help" helpBtn.onclick = () => { UILayoutDebugger._helpMode = !UILayoutDebugger._helpMode UILayoutDebugger._renderOverlay() } const bpBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle( UILayoutDebugger._breakpointsEnabled ? "#ffaa33" : "#9090a8" )) bpBtn.textContent = UILayoutDebugger._breakpointsEnabled ? "⏸ BP ON" : "⏸ BP OFF" bpBtn.title = "Toggle breakpoint step-through" bpBtn.onclick = () => { UILayoutDebugger._breakpointsEnabled ? UILayoutDebugger.disableBreakpoints() : UILayoutDebugger.enableBreakpoints() UILayoutDebugger._renderOverlay() } const cmpBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle(cmp ? "#7bc8ff" : "#9090a8")) cmpBtn.textContent = cmp ? "⧉ Compare ON" : "⧉ Compare" cmpBtn.title = "Toggle side-by-side pass comparison" cmpBtn.onclick = () => { UILayoutDebugger._compareMode = !UILayoutDebugger._compareMode if (UILayoutDebugger._compareMode) { UILayoutDebugger._compareTraceIndex = Math.min(1, UILayoutDebugger._traces.length - 1) UILayoutDebugger._compareStepIndex = -1 UILayoutDebugger._sharedExpan