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
text/typescript
/// #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