mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
perf(workspace): paint the content area's rounded corner instead of clipping it (#1857)
* perf(workspace): paint the content area's rounded corner instead of clipping it
The `border-radius: 16px 0 0 0` on the scrolling content area made Blink
clip every composited effect inside it (the rail chevrons' and hero
controls' backdrop filters, running transform animations, isolated
groups) through a mask layer synthesized per effect: its shader path
for rounded clips needs four equal radii on macOS and a translation-only
transform between the clip and the effect, and the fallback mask is the
size of the whole clipped area, about 24 MB of tile memory at 2x on a
16" display. Twelve of them put the dashboard at 537-655 MB of tile
memory against Chromium's 512 MB budget ("tile memory limits exceeded",
blank tiles while scrolling).
The scroller now sits in a frame that carries the radius and the shadow
without clipping; the scroller clips to a rectangle and is a stacking
context; a 16px corner piece on the frame paints the notch in the body
colour. Dashboard tile memory (1728x1000 window at 2x, Xtream mock):
idle 537 -> 242 MB, slide switch 598 -> 303 MB, scrolling 655 -> 165 MB;
movie detail 110 -> 89 MB, series detail 187 -> 143 MB. The new
Electron E2E asserts that no element-less drawing layer spans half the
content area (eight did before).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(e2e): identify synthesized clip masks by missing owner node and reasons
A content layer's owner node is also missing when its first paint chunk
belongs to an anonymous box (the rail track and the content scroller
flipped between runs), so the dashboard compositing check now requires
both: no owner node and no compositing reason, which only a synthesized
mask has. It also waits for Blink's layer debug info to be filled after
enabling the LayerTree domain, and asserts that the backdrop-filtered
controls really are composited layers, so the mask check cannot pass
vacuously. Verified under `--disable-gpu` as well: the old scroller
radius still produces seven masks there, the fix none.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
f6ad98255e
commit
4c6540679d
6 files changed
+255
-14
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: perf
|
||||
area: workspace
|
||||
---
|
||||
|
||||
On Retina displays the dashboard and the movie and series pages no longer run
|
||||
out of GPU tile memory: the content area's rounded corner is now painted
|
||||
instead of clipped, so scrolling no longer leaves blank patches behind the
|
||||
rails and heroes.
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { CDPSession, Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
type CompositedLayer = {
|
||||
backendNodeId?: number;
|
||||
drawsContent: boolean;
|
||||
height: number;
|
||||
layerId: string;
|
||||
width: number;
|
||||
};
|
||||
|
||||
type CompositedLayerWithReasons = CompositedLayer & {
|
||||
/** `LayerTree.compositingReasons` ids, e.g. `BackdropFilter`. */
|
||||
reasons: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The composited layers of the page with their compositing reasons. Blink
|
||||
* fills the layer debug info (reasons, owner nodes) in its first
|
||||
* layerization after the domain is enabled, so the snapshot is retried
|
||||
* until a layer reports a reason.
|
||||
*/
|
||||
async function compositedLayers(
|
||||
cdp: CDPSession,
|
||||
page: Page
|
||||
): Promise<CompositedLayerWithReasons[]> {
|
||||
let layers: CompositedLayer[] = [];
|
||||
const onChange = (event: { layers?: CompositedLayer[] }) => {
|
||||
layers = event.layers ?? [];
|
||||
};
|
||||
cdp.on('LayerTree.layerTreeDidChange', onChange);
|
||||
let withReasons: CompositedLayerWithReasons[] = [];
|
||||
const snapshot = async () => {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve))
|
||||
)
|
||||
);
|
||||
const result: CompositedLayerWithReasons[] = [];
|
||||
for (const layer of layers) {
|
||||
let reasons: string[] = [];
|
||||
try {
|
||||
const answer = (await cdp.send('LayerTree.compositingReasons', {
|
||||
layerId: layer.layerId,
|
||||
})) as { compositingReasonIds?: string[] };
|
||||
reasons = answer.compositingReasonIds ?? [];
|
||||
} catch {
|
||||
// The layer went away between the snapshot and the query.
|
||||
}
|
||||
result.push({ ...layer, reasons });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
try {
|
||||
await cdp.send('LayerTree.enable');
|
||||
await expect
|
||||
.poll(async () => {
|
||||
withReasons = await snapshot();
|
||||
return withReasons.filter((layer) => layer.reasons.length > 0)
|
||||
.length;
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
await cdp.send('LayerTree.disable');
|
||||
} finally {
|
||||
cdp.off('LayerTree.layerTreeDidChange', onChange);
|
||||
}
|
||||
return withReasons;
|
||||
}
|
||||
|
||||
test.describe('Dashboard compositing', () => {
|
||||
// A `border-radius` on the content scroller made Blink clip every
|
||||
// composited effect inside it (the rail chevrons' and hero controls'
|
||||
// backdrop filters, running animations) through a synthesized mask layer
|
||||
// the size of the whole content area: ~24 MB of tile memory each at 2x,
|
||||
// a dozen on the dashboard, which took the renderer past Chromium's tile
|
||||
// budget ("tile memory limits exceeded") and left blank tiles while
|
||||
// scrolling. The corner is painted instead. A mask layer is synthesized,
|
||||
// not painted for an element: it has no owner node and no compositing
|
||||
// reason, unlike every content layer (a content layer's owner node can
|
||||
// also be missing when its first paint chunk belongs to an anonymous
|
||||
// box, so the node alone does not identify a mask). Blink synthesizes
|
||||
// them while layerizing, before any GPU work: under `--disable-gpu` (the
|
||||
// Linux CI launch, software compositing and raster) the controls are
|
||||
// still composited and the old radius still produced seven masks, so the
|
||||
// check guards the same path everywhere.
|
||||
test('does not synthesize content-area-sized clip masks for the composited rail and hero controls', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
|
||||
try {
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await goToDashboard(page);
|
||||
await expect(page.getByTestId('dashboard-hero')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId('dashboard-xtream-recently-added-rail')
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
// The composited effects this guards: every rail has two
|
||||
// backdrop-filtered chevrons, the hero its controls.
|
||||
expect(await page.locator('.rail__chev').count()).toBeGreaterThan(
|
||||
0
|
||||
);
|
||||
await expect(page.locator('.hero__controls')).toBeVisible();
|
||||
|
||||
const content = await page
|
||||
.locator('main.workspace-content')
|
||||
.evaluate((element) => ({
|
||||
width: element.clientWidth * window.devicePixelRatio,
|
||||
height: element.clientHeight * window.devicePixelRatio,
|
||||
}));
|
||||
const cdp = await app.electronApp.context().newCDPSession(page);
|
||||
const layers = await compositedLayers(cdp, page);
|
||||
await cdp.detach();
|
||||
|
||||
// The effects really are composited here: without this the
|
||||
// mask check below would pass on a page with nothing to clip.
|
||||
expect(
|
||||
layers.filter((layer) =>
|
||||
layer.reasons.includes('BackdropFilter')
|
||||
).length
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
const contentArea = content.width * content.height;
|
||||
const clipMasks = layers.filter(
|
||||
(layer) =>
|
||||
layer.drawsContent &&
|
||||
!layer.backendNodeId &&
|
||||
layer.reasons.length === 0 &&
|
||||
layer.width * layer.height >= contentArea / 2
|
||||
);
|
||||
expect(
|
||||
clipMasks.map(
|
||||
(layer) =>
|
||||
`${layer.layerId}: ${layer.width}x${layer.height}`
|
||||
)
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1017,6 +1017,10 @@ Avoid these:
|
||||
- making entire panes scroll when only the list should scroll
|
||||
- using dark translucent fills unchanged in light theme
|
||||
- solving cramped sidebars with smaller fonts instead of shorter labels
|
||||
- rounding a scroller's corners (`border-radius` with `overflow`) when it holds
|
||||
composited effects such as `backdrop-filter`: each effect gets a clip mask
|
||||
the size of the scroller; paint the corner instead (see the content surface
|
||||
corner in the [workspace shell contract](workspace-shell.md#content-surface-corner))
|
||||
|
||||
## Definition Of Done For UI Changes
|
||||
|
||||
|
||||
@@ -107,6 +107,26 @@ The shell is intentionally split into four persistent regions:
|
||||
4. Optional footer:
|
||||
1. External playback session bar when a docked session is visible.
|
||||
|
||||
### Content surface corner
|
||||
|
||||
`main.workspace-content` (the scroller) sits in `.workspace-content-frame`.
|
||||
The frame carries the surface's 16px top-left radius and its shadow and never
|
||||
clips; the scroller clips to a rectangle and is a stacking context; a 16px
|
||||
corner piece on the frame paints the notch in the body colour. The radius used
|
||||
to sit on the scroller, which made Blink clip every composited effect inside it
|
||||
(the rail chevrons' and hero controls' `backdrop-filter`, running transform
|
||||
animations, isolated groups) through a mask layer synthesized per effect: its
|
||||
shader path for rounded clips needs four equal radii on macOS and a
|
||||
translation-only transform between the clip and the effect, and the fallback
|
||||
mask is the size of the whole clipped area, about 24 MB of tile memory at 2x
|
||||
on a 16" display. A dozen of them took the dashboard past Chromium's 512 MB
|
||||
tile budget (`tile memory limits exceeded`, blank tiles while scrolling).
|
||||
`apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts` guards it: no
|
||||
drawing layer without an owner node and without a compositing reason (a
|
||||
synthesized mask, unlike any content layer) may span half the content area,
|
||||
and the backdrop-filtered controls must really be composited, so the check
|
||||
cannot pass on a page with nothing to clip.
|
||||
|
||||
`WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is
|
||||
kept as a thin template-facing API and delegates ownership to component-scoped
|
||||
services:
|
||||
|
||||
+16
-11
@@ -114,17 +114,22 @@
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- tabindex="-1": programmatic focus target for the drawer-close
|
||||
fallback when the toggle is gone (navigated to a route without
|
||||
a context panel); not in the Tab order. -->
|
||||
<main
|
||||
#workspaceContent
|
||||
class="workspace-content"
|
||||
tabindex="-1"
|
||||
[attr.inert]="contextDrawer.isOpen() ? '' : null"
|
||||
>
|
||||
<router-outlet />
|
||||
</main>
|
||||
<!-- The frame carries the content surface's rounded corner and
|
||||
shadow without clipping; the scroller inside clips to a
|
||||
rectangle (see the stylesheet for why). -->
|
||||
<div class="workspace-content-frame">
|
||||
<!-- tabindex="-1": programmatic focus target for the drawer-close
|
||||
fallback when the toggle is gone (navigated to a route without
|
||||
a context panel); not in the Tab order. -->
|
||||
<main
|
||||
#workspaceContent
|
||||
class="workspace-content"
|
||||
tabindex="-1"
|
||||
[attr.inert]="contextDrawer.isOpen() ? '' : null"
|
||||
>
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (facade.showExternalPlaybackBar()) {
|
||||
|
||||
+49
-3
@@ -74,13 +74,55 @@ app-workspace-shell-context-sidebar {
|
||||
background: var(--app-content-bg, var(--mat-sys-surface));
|
||||
}
|
||||
|
||||
// The content surface's rounded top-left corner is painted, not clipped.
|
||||
//
|
||||
// A `border-radius` on the scroller itself clips every composited layer inside
|
||||
// it (backdrop filters, running transform animations, isolated groups) through
|
||||
// a mask layer Blink synthesizes per composited effect: its shader path for
|
||||
// rounded clips needs four equal radii on macOS and a translation-only
|
||||
// transform between the clip and the effect, and the fallback mask is the size
|
||||
// of the whole clipped area (about 24 MB of tile memory at 2x on a 16" display,
|
||||
// one per rail chevron and hero control). Twelve of them put the dashboard over
|
||||
// Chromium's 512 MB tile budget ("tile memory limits exceeded", blank tiles
|
||||
// while scrolling). The frame keeps the radius for the shadow only and never
|
||||
// clips; the scroller clips to a rectangle; the frame's corner piece covers the
|
||||
// 16px notch in the body colour. The scroller is a stacking context, so nothing
|
||||
// scrolled under the corner can paint over the piece.
|
||||
.workspace-content-frame {
|
||||
--workspace-content-radius: 16px;
|
||||
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-radius: var(--workspace-content-radius) 0 0 0;
|
||||
box-shadow: -4px 8px 20px var(--app-content-shadow, rgba(0, 0, 0, 0.18));
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: var(--workspace-content-radius);
|
||||
height: var(--workspace-content-radius);
|
||||
border-top-left-radius: var(--workspace-content-radius);
|
||||
// The body colour outside the quarter circle: a transparent rounded
|
||||
// box's spread shadow, clipped to the box.
|
||||
box-shadow: 0 0 0 var(--workspace-content-radius)
|
||||
var(--app-rail-bg, var(--mat-sys-surface-container-low));
|
||||
clip-path: inset(0);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-content {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: var(--app-content-bg, var(--mat-sys-surface));
|
||||
border-radius: 16px 0 0 0;
|
||||
box-shadow: -4px 8px 20px var(--app-content-shadow, rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
@@ -134,9 +176,13 @@ app-workspace-shell-context-sidebar {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.workspace-content {
|
||||
.workspace-content-frame {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-now-playing {
|
||||
|
||||
Reference in new issue
Block a user