Broken debounce in tableheader.js causes layout thrashing on large tables (e.g. permissions page filtering)
>>> [!note] Migrated issue
<!-- Drupal.org comment -->
<!-- Migrated from issue #3595195. -->
Reported by: [rashid yaqoob](https://www.drupal.org/user/3651721)
Related to !797
>>>
<h3>Problem/Motivation</h3>
<p>The <code>ResizeObserver</code> callback in <code>js/tableheader.js</code> misuses <code>Drupal.debounce</code>:</p>
<pre>const resizeHandler = new ResizeObserver(() => {<br> Drupal.debounce(this.updateTableHeader(el), 150);<br>});</pre><p><code>this.updateTableHeader(el)</code> is executed immediately, and its return value (<code>undefined</code>) is passed to <code>Drupal.debounce()</code>. As a result, a debounced function is created but never invoked. The intended 150 ms debounce therefore never occurs, and <code>updateTableHeader()</code> runs synchronously on every <code>ResizeObserver</code> notification.</p>
<p>This has significant performance implications because <code>updateTableHeader()</code> performs multiple layout-sensitive operations. It calls <code>getBoundingClientRect()</code> on the <code>&lt;thead&gt;</code> and on each <code>&lt;th&gt;</code> element in both the original table and the sticky header clone, then writes calculated widths back as inline styles. These reads and writes force synchronous layout recalculations.</p>
<p>On large tables that resize frequently, this can lead to severe UI jank. A notable example is the permissions page (<code>/admin/people/permissions</code>). Core's permission filter (<code>user.permissions.js</code>) continuously shows and hides table rows as the user types in the filter input. Each row visibility change can trigger a table resize, causing the <code>ResizeObserver</code> to fire and <code>updateTableHeader()</code> to perform a complete re-measurement of all header cells.</p>
<p>In environments with many modules and roles, this can result in hundreds of forced reflows per keystroke, leading to long main-thread tasks and visibly degraded input responsiveness.</p>
<h3>Steps to reproduce</h3>
<ol>
<li>Install a Drupal site with a large number of enabled modules and several user roles, using Gin as the administration theme.</li>
<li>Navigate to <code>/admin/people/permissions</code>.</li>
<li>Open browser DevTools and start a performance recording.</li>
<li>Type into the <strong>"Filter by permission name"</strong> field.</li>
<li>Observe long tasks in the performance profile dominated by <code>updateTableHeader()</code> and forced reflows.</li>
<li>Notice that typing becomes sluggish, with the filter input appearing janky or temporarily unresponsive.</li>
</ol>
<h3>Proposed resolution</h3>
<p>Create the debounced function once and invoke it from the observer callback:</p>
<pre>const debouncedUpdate = Drupal.debounce(() => this.updateTableHeader(el), 150);<br>const resizeHandler = new ResizeObserver(() => {<br> debouncedUpdate();<br>});<br>resizeHandler.observe(el);</pre><p>This restores the intended behavior: at most one header re-measurement per 150 ms burst of resize events.</p>
<h3>Remaining tasks</h3>
<p>Review and commit.</p>
<h3>User interface changes</h3>
<p>None. Visual behavior is unchanged; the sticky header still updates after resize events.</p>
<h3>API changes</h3>
<p>None.</p>
> Related issue: [Issue #3309353](https://www.drupal.org/node/3309353)
> Related issue: [Issue #3472217](https://www.drupal.org/node/3472217)
issue