Skip to content

[bug]: Spreadsheet/list layouts drop to ~4–6 fps at 10k work items; universal sibling CSS selectors (x + * / x ~ *) block windowing #9892

Description

@codeatspeed

Is there an existing issue for this?

  • I have searched the existing issues

Current behavior

The spreadsheet and list layouts get very slow as more work items are loaded. At 10k loaded items the spreadsheet scrolls at about 4 fps and the list at about 6 fps. Windowing the rows (virtualization) should fix that, but I found it can't reach 60 fps because of 12 CSS rules in the compiled app stylesheet. Each one ends in a universal selector after a sibling combinator (x + * / x ~ *). As long as any one of them exists, Chromium restyles every following sibling, subtree included, whenever a child is inserted or removed. A windowed table inserts and removes rows on every scroll step, so it pays that cost on every frame.

Three sources emit these rules. All three have to change: removing any subset gave no measurable gain in my tests.

Selector (each emitted twice) Source
.[&>*:not(span)~*]:-ms-px > :not(span) ~ * @makeplane/propel pill (dist/elements/pill/variants.js)
.[&>*:not(span)~*]:rounded-s-none > :not(span) ~ * @makeplane/propel pill
.[&>*+*]:rounded-s-none > * + * @makeplane/propel split-button (dist/elements/split-button/variants.js)
.[&>*+*]:border-s-0 > * + * @makeplane/propel split-button
.prose :where(hr + *), :where(h2 + *), :where(h3 + *), :where(h4 + *) @tailwindcss/typography, loaded via @plugin in apps/web/styles/globals.css
:is(.table-wrapper table td, .table-wrapper table th) .ProseMirror-widget + * packages/editor/src/styles/table.css

Expected: the spreadsheet (and list) layouts keep a small, constant DOM regardless of how many items are loaded, and scroll at 60 fps. I have a windowed spreadsheet implementation that does this, but only once these rules are gone (numbers below).

Proposed fixes

On your side, in @makeplane/propel (its source repo isn't public, so I can't open a PR there):

  • split-button: [&>*+*]:rounded-s-none / [&>*+*]:border-s-0 → [&>*:not(:first-child)]:…. This matches the same elements without a sibling combinator.
  • pill: [&>*:not(span)~*]:-ms-px / …:rounded-s-none needs an equivalent that avoids ~ *, based on the pill's real child structure. For example, if the leading span is always first, use [&>*:not(:first-child)] or a class on the grouped children.

In this repo (I'm happy to open these PRs):

  1. packages/editor/src/styles/table.css: replace .ProseMirror-widget + * (it only resets padding-top) with a selector that doesn't end in + *.
  2. apps/web/styles/globals.css: override or disable the typography plugin's hr + *, h2 + *, h3 + *, h4 + * rules (they only reset margin-top). I haven't yet verified whether Tailwind v4's @plugin config can do this cleanly or whether it needs a small custom prose layer.
  3. Window the spreadsheet rows with @tanstack/react-virtual, which is already in the catalog and used by @plane/blocks. Details are under "Benchmarks → windowed spreadsheet".

Items 1 and 2 on their own don't move the numbers, because the propel rules keep the cost. Item 3 only lands without a regression once all of the rules are gone.

Steps to reproduce

  1. Seed a project with 10k work items (5 states, 20 labels, 8 members, 1,900 sub-work items). I used a small idempotent manage.py command and can share it.
  2. Build apps/web for production and open the project's Work items in the spreadsheet layout (the same happens in the list layout).
  3. Scroll until all 10k items have loaded, then scroll up and down: frames drop to about 4 fps (p95 frame 233 ms).
  4. To see the CSS effect in DevTools, run this on the page and scroll again with a windowed table. It deletes the 12 rules:
for (const sheet of document.styleSheets) {
  const walk = (list) => {
    for (let i = list.length - 1; i >= 0; i--) {
      const r = list[i];
      if (r.cssRules && !r.selectorText) walk(r.cssRules);
      else if (/[+~]\s*\*/.test(r.selectorText ?? "")) (r.parentRule ?? r.parentStyleSheet).deleteRule(i);
    }
  };
  try { walk(sheet.cssRules); } catch {}
}

A Chrome trace with disabled-by-default-devtools.timeline.invalidationTracking shows Invalidation set invalidates subtree on the <tbody> for each row insertion (about 3.5k elements restyled, against a median of 48 per recalc).

Benchmarks

Production build of preview@888b0869dc (web 1.4.2, React 19.2.8, MobX 6.12.0), headless Chromium 153 (Playwright 1.63), 1440×900, no CPU throttling, Apple M2. Scrolling is a scripted CDP mouse-wheel gesture at 3000 px/s (down and back up, up to 30k px each way) with pagination held during the measured pass. The table values are medians of 3 runs (min–max) unless noted.

1. Baseline: scroll, memory, DOM (current preview)
metric list kanban* spreadsheet
first row (ms) 1419 (1416–1445) 1209 (1150–1329) 1782 (1751–1980)
DOM elements @ initial 4,011 3,229 3,724
heap MB @ initial 54.1 48.4 47.9
p95 frame @ initial (ms) 16.8 16.8 33.4
rows loaded @ deep 10,000 2,127 10,000
DOM elements @ deep 122,750 11,022 23,501
heap MB @ deep 269.9 76.3 192.8
active IntersectionObservers @ deep 10,000 2,136 10,000
p95 frame @ deep (ms) 166.7 16.7 233.4
dropped frames @ deep (20 s scroll) 1,039 2 1,132
long-task ms @ deep (20 s scroll) 13,332 0 19,158
layout / style ms @ deep scroll 1,162 / 1,102 873 / 530 3,425 / 2,177
MobX derivation runs @ deep scroll 31,425 9,446 61,900

*Kanban columns scroll independently; "deep" is one fully loaded 2,007-card column.

Where the time goes at 10k (Chrome trace, main thread, list layout, 20 s scroll): PrePaint 4.5 s, Paint 2.6 s, style+layout 3.1 s, HitTest 2.1 s, IntersectionObserver computation 1.6 s, JS 5.9 s. The cost grows with the placeholder DOM that RenderIfVisible leaves mounted for every loaded row. The per-row IntersectionObservers themselves are about 8%. Spreadsheet: Paint 11.6 s, plus 4.1 s of forced layout inside the horizontal-shadow scroll handler (spreadsheet-table.tsx, reading scrollLeft).

2. Baseline: interaction latency vs loaded rows

Median of 3 runs × 5 reps. "visible" means time from the drop/click to the first frame showing the change.

Kanban: drag a card to the next column

metric 150 2,000 10,000
visible (ms) 20.1 68.1 260.2
max event duration (ms) 112 232 784
max long animation frame (ms) 60.2 191.2 718.7
MobX runs 615 615 584
React commits 9 9 9

MobX and React work stay flat. The growth is in the browser: at 10k, removeChild is 21% of self time and elementsFromPoint (drag hit testing) 4%.

Spreadsheet: change a row's priority

metric 150 2,000 10,000
visible (ms) 61.4 18.6 55.0
max long animation frame (ms) 60.4 96.4 233.3
MobX runs 1,843 3,643 11,543

That's about one MobX derivation per mounted row. The dropdown's positioning also forces a layout of the full table (getBoundingClientRect 19%, @base-ui 18%).

3. Windowed spreadsheet (@tanstack/react-virtual), with and without the CSS rules

Deep (10k loaded), per-row windowing, CSS unchanged, median of 3:

metric baseline windowed
DOM elements 23,501 2,989
heap MB 192.8 119.5
p50 / p95 frame (ms) 200 / 233 50 / 50
long-task ms (20 s scroll) 19,158 357
edit: max long animation frame (ms) 233 0
edit: MobX runs 11,543 1,269 (flat across depths)

Shallow (100 loaded), where the CSS problem shows. Single scroll pass, 2 runs each:

variant p50 frame p95 frame style ms
baseline (preview) 16.7 33 ~170
windowed, per-row 50 50–67 ~1,500
windowed, per-row, 12 rules removed 16.7 16.8–33 ~205
windowed, in-repo rules removed only (4) 50 67 ~1,500
windowed, propel rules removed only (8) 50 50 ~1,520
windowed, 20-row chunks (one <tbody> each) 16.7 116 ~720
windowed, 20-row chunks, 12 rules removed 16.7 33 ~143

So windowing is only a clear win, at every depth, once all the universal sibling rules are gone. Chunking reduces the damage but leaves a hitch whenever a chunk mounts.

Kanban move at 2k cards with the 12 rules removed (1 run × 5 reps, indicative only): visible 67 → 36 ms, max long animation frame 182 → 151 ms.

Environment

Deploy preview

Browser

Google Chrome

Edition

Community

Version

preview @ 888b086 (web 1.4.2)


I'm happy to share the seed command and the Playwright/CDP benchmark harness. Once the propel side is sorted, I can open PRs for fixes 1–3, and in the meantime for the in-repo CSS alone.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions