Is there an existing issue for this?
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):
packages/editor/src/styles/table.css: replace .ProseMirror-widget + * (it only resets padding-top) with a selector that doesn't end in + *.
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.
- 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
- 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.
- Build
apps/web for production and open the project's Work items in the spreadsheet layout (the same happens in the list layout).
- Scroll until all 10k items have loaded, then scroll up and down: frames drop to about 4 fps (p95 frame 233 ms).
- 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.
Is there an existing issue for this?
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.
.[&>*:not(span)~*]:-ms-px > :not(span) ~ *@makeplane/propelpill (dist/elements/pill/variants.js).[&>*:not(span)~*]:rounded-s-none > :not(span) ~ *@makeplane/propelpill.[&>*+*]:rounded-s-none > * + *@makeplane/propelsplit-button (dist/elements/split-button/variants.js).[&>*+*]:border-s-0 > * + *@makeplane/propelsplit-button.prose :where(hr + *),:where(h2 + *),:where(h3 + *),:where(h4 + *)@tailwindcss/typography, loaded via@plugininapps/web/styles/globals.css:is(.table-wrapper table td, .table-wrapper table th) .ProseMirror-widget + *packages/editor/src/styles/table.cssExpected: 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):[&>*+*]:rounded-s-none/[&>*+*]:border-s-0→[&>*:not(:first-child)]:…. This matches the same elements without a sibling combinator.[&>*:not(span)~*]:-ms-px/…:rounded-s-noneneeds an equivalent that avoids~ *, based on the pill's real child structure. For example, if the leadingspanis always first, use[&>*:not(:first-child)]or a class on the grouped children.In this repo (I'm happy to open these PRs):
packages/editor/src/styles/table.css: replace.ProseMirror-widget + *(it only resetspadding-top) with a selector that doesn't end in+ *.apps/web/styles/globals.css: override or disable the typography plugin'shr + *,h2 + *,h3 + *,h4 + *rules (they only resetmargin-top). I haven't yet verified whether Tailwind v4's@pluginconfig can do this cleanly or whether it needs a small customproselayer.@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
manage.pycommand and can share it.apps/webfor production and open the project's Work items in the spreadsheet layout (the same happens in the list layout).A Chrome trace with
disabled-by-default-devtools.timeline.invalidationTrackingshowsInvalidation set invalidates subtreeon 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)*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
RenderIfVisibleleaves 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-shadowscrollhandler (spreadsheet-table.tsx, readingscrollLeft).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
MobX and React work stay flat. The growth is in the browser: at 10k,
removeChildis 21% of self time andelementsFromPoint(drag hit testing) 4%.Spreadsheet: change a row's priority
That's about one MobX derivation per mounted row. The dropdown's positioning also forces a layout of the full table (
getBoundingClientRect19%,@base-ui18%).3. Windowed spreadsheet (
@tanstack/react-virtual), with and without the CSS rulesDeep (10k loaded), per-row windowing, CSS unchanged, median of 3:
Shallow (100 loaded), where the CSS problem shows. Single scroll pass, 2 runs each:
preview)<tbody>each)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.