Skip to content

Commit a6ea287

Browse files
committed
feat: add Forced Synchronous Layout detector snippet
Detects FSL patterns at runtime by intercepting DOM mutation APIs synchronously (classList, setAttribute, setProperty, cssText) and proxying geometric properties (scrollTop, offsetWidth, getBoundingClientRect, etc.) on Element and HTMLElement prototypes. Exposes getFSLSummary() and stopFSLDetector() on window.
1 parent 0f71a56 commit a6ea287

3 files changed

Lines changed: 421 additions & 1 deletion

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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

pages/Interaction/_meta.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
"Long-Animation-Frames-Script-Attribution": "Long Animation Frames Script Attribution",
77
"Long-Animation-Frames-Helpers": "Long Animation Frames Helpers",
88
"LongTask": "Long Task",
9-
"Scroll-Performance": "Scroll Performance"
9+
"Scroll-Performance": "Scroll Performance",
10+
"Forced-Synchronous-Layout": "Forced Synchronous Layout"
1011
}

0 commit comments

Comments
 (0)