|
| 1 | +import snippet from '../../snippets/Interaction/Forced-Synchronous-Layout.js?raw' |
| 2 | +import { Snippet } from '../../components/Snippet' |
| 3 | + |
| 4 | +# Forced Synchronous Layout Detector |
| 5 | + |
| 6 | +### Overview |
| 7 | + |
| 8 | +Detects the **Forced Synchronous Layout (FSL)** pattern at runtime — when JavaScript reads geometric properties from the DOM immediately after mutating styles, forcing the browser to perform layout synchronously on the main thread. |
| 9 | + |
| 10 | +**Why this matters:** |
| 11 | + |
| 12 | +Normally the browser batches style recalculations and layout at the end of each frame. FSL breaks this contract: reading a geometric property after a style mutation forces the browser to flush pending style changes and recalculate layout _right now_, before the current task finishes. This blocks the main thread and contributes directly to long tasks, poor INP, and jank. |
| 13 | + |
| 14 | +**A typical FSL pattern:** |
| 15 | + |
| 16 | +```js |
| 17 | +container.classList.toggle('state-a'); // invalidates styles |
| 18 | +container.scrollTop = 0; // forces layout synchronously → FSL |
| 19 | +``` |
| 20 | + |
| 21 | +**The fix — double rAF:** |
| 22 | + |
| 23 | +```js |
| 24 | +container.classList.toggle('state-a'); |
| 25 | +requestAnimationFrame(() => { // 1st rAF: browser processes styles |
| 26 | + requestAnimationFrame(() => { // 2nd rAF: layout tree is clean |
| 27 | + container.scrollTop = 0; // no FSL |
| 28 | + }); |
| 29 | +}); |
| 30 | +``` |
| 31 | + |
| 32 | +**What this snippet intercepts:** |
| 33 | + |
| 34 | +Mutation sources — detected synchronously: |
| 35 | + |
| 36 | +| API | Coverage | |
| 37 | +|-----|----------| |
| 38 | +| `classList.add/remove/toggle/replace` | `DOMTokenList.prototype` | |
| 39 | +| `element.setAttribute('class'/'style', ...)` | `Element.prototype` | |
| 40 | +| `element.style.setProperty(...)` | `CSSStyleDeclaration.prototype` | |
| 41 | +| `element.style.cssText = ...` | `CSSStyleDeclaration.prototype` | |
| 42 | + |
| 43 | +Geometric reads/writes — triggers the FSL warning: |
| 44 | + |
| 45 | +| Property / Method | Type | Prototype | |
| 46 | +|-------------------|------|-----------| |
| 47 | +| `scrollTop`, `scrollLeft` | read + write | `Element.prototype` | |
| 48 | +| `scrollWidth`, `scrollHeight` | read | `Element.prototype` | |
| 49 | +| `clientTop`, `clientLeft`, `clientWidth`, `clientHeight` | read | `Element.prototype` | |
| 50 | +| `offsetTop`, `offsetLeft`, `offsetWidth`, `offsetHeight` | read | `HTMLElement.prototype` | |
| 51 | +| `getBoundingClientRect()` | read | `Element.prototype` | |
| 52 | + |
| 53 | +### Snippet |
| 54 | + |
| 55 | +<Snippet code={snippet} /> |
| 56 | + |
| 57 | +### Understanding the Output |
| 58 | + |
| 59 | +When an FSL is detected, the console shows: |
| 60 | + |
| 61 | +``` |
| 62 | +⚠️ [FSL Detector] Forced Synchronous Layout detected! |
| 63 | + Property : scrollTop (write) |
| 64 | + Element : div#scroll-container.scroll-container |
| 65 | + Since last mutation: 0.3 ms |
| 66 | + Stack trace: |
| 67 | + at set scrollTop (snippet) |
| 68 | + at reproduceIssue (main.js:62) |
| 69 | + at HTMLButtonElement.<anonymous> (main.js:94) |
| 70 | +``` |
| 71 | + |
| 72 | +| Field | Description | |
| 73 | +|-------|-------------| |
| 74 | +| **Property** | The geometric property accessed and whether it was a read or write | |
| 75 | +| **Element** | `tagName#id.classes` of the element involved | |
| 76 | +| **Since last mutation** | Milliseconds between the style/class mutation and the forced layout | |
| 77 | +| **Stack trace** | Full call stack to locate the offending code | |
| 78 | + |
| 79 | +With the double-`requestAnimationFrame` fix applied, no warnings appear — the dirty flag is cleared before the geometric access. |
| 80 | + |
| 81 | +### How It Works |
| 82 | + |
| 83 | +```mermaid |
| 84 | +sequenceDiagram |
| 85 | + participant JS as JavaScript |
| 86 | + participant MO as MutationObserver |
| 87 | + participant RAF as requestAnimationFrame |
| 88 | + participant Det as FSL Detector |
| 89 | +
|
| 90 | + JS->>JS: element.classList.toggle(...) |
| 91 | + MO->>Det: mutation fired → isDirty = true |
| 92 | + Det->>RAF: schedule clean (isDirty = false) |
| 93 | + JS->>Det: element.scrollTop = 0 |
| 94 | + Det->>Det: isDirty? YES → ⚠️ warn FSL |
| 95 | + RAF->>Det: callback fires → isDirty = false |
| 96 | + Note over Det: Next access after rAF: no warning |
| 97 | +``` |
| 98 | + |
| 99 | +### Limitations |
| 100 | + |
| 101 | +- Detects FSL only for the intercepted geometric properties. `getComputedStyle()` and `window.getComputedStyle()` also trigger layout but are not covered. |
| 102 | +- Direct property assignments on `element.style` (e.g. `element.style.display = 'none'`) are not intercepted — only `setProperty`, `cssText`, `setAttribute`, and `classList` methods are. Adding individual CSS property descriptors would be impractical. |
| 103 | +- Intercepts mutations on any element in the document, not scoped to `document.body`. Mutations on `<head>` elements (e.g. dynamic `<style>` injection) would also set the dirty flag. |
| 104 | +- The overhead of intercepting prototype methods on every mutation and layout read can slow down pages with very frequent DOM changes. Use only during development and debugging. |
| 105 | +- `stopFSLDetector()` restores all intercepted prototypes to their original descriptors. |
| 106 | + |
| 107 | +### Use Case: Angular CDK Virtual Scroll |
| 108 | + |
| 109 | +The classic FSL scenario in Angular applications using `CdkScrollable`: a route change triggers a CSS class toggle on the scroll container, followed by an immediate `scrollTop = 0` reset before the browser has processed the new styles. |
| 110 | + |
| 111 | +```js |
| 112 | +// Problematic pattern (triggers FSL) |
| 113 | +container.classList.toggle('state-a'); |
| 114 | +container.scrollTop = 0; // forces layout before browser flushes styles |
| 115 | + |
| 116 | +// Fixed pattern (double rAF) |
| 117 | +container.classList.toggle('state-a'); |
| 118 | +requestAnimationFrame(() => { |
| 119 | + requestAnimationFrame(() => { |
| 120 | + container.scrollTop = 0; |
| 121 | + }); |
| 122 | +}); |
| 123 | +``` |
| 124 | + |
| 125 | +A reproducible demo is available at [github.com/nucliweb/forced-synchronous-layout](https://github.com/nucliweb/forced-synchronous-layout). |
| 126 | + |
| 127 | +### Further Reading |
| 128 | + |
| 129 | +- [Forced Synchronous Layout](https://joanleon.dev/en/forced-synchronous-layout/) | Joan Leon |
| 130 | +- [Avoid large, complex layouts and layout thrashing](https://web.dev/articles/avoid-large-complex-layouts-and-layout-thrashing) | web.dev |
| 131 | +- [What forces layout / reflow](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) | Paul Irish |
| 132 | +- [Rendering performance](https://developer.chrome.com/docs/devtools/performance) | Chrome DevTools |
| 133 | +- [Long Animation Frames API](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongAnimationFrameTiming) | MDN |
0 commit comments