design/DECLARATIVE_PAGE_BOUNDS.md
Rama main · réplica de lectura del repositorio original.
# Declarative Page Bounds
## Problem Statement
The page content area's bounds (the region where tab `WebContentsView`s are
placed) are currently measured in the renderer process using
`getBoundingClientRect()` and sent to the main process via IPC. This approach
has several problems:
- **Latency.** The renderer measurement round-trip takes 2-3 frames (~33-50ms):
`getBoundingClientRect()` -> React `setState` -> `useEffect` -> IPC send ->
main process handler. During this window the tab view is positioned using stale
bounds.
- **Layout reflows.** `getBoundingClientRect()` forces the browser to
synchronously compute layout if any pending style/DOM changes exist. The
`useBoundingRect` hook calls it on every rAF tick during its burst, which can
interact poorly with other layout work in the same frame.
- **Sidebar tween gap.** When the attached sidebar animates in or out (100ms CSS
`transition-[margin]`), the content area's width changes continuously. The
current `ResizeObserver` + rAF burst in `useBoundingRect` does not use
`observingWithLoop: true` for `BrowserContent`, so it only captures a few
intermediate frames rather than tracking the full transition. The
`WebContentsView` lags behind or snaps at the end.
- **Complexity.** The main process has a parallel "fast path" for window resize
that caches `pageInsets` and recomputes bounds from `getContentSize()`. This
creates two sources of truth (`_isResizing` flag, debounce timer, inset cache)
that must be carefully coordinated.
- **Fragility.** Floating-point epsilon comparisons (0.5px), settle-frame
heuristics, and debounce timers are all workarounds for the fundamental issue:
the main process is guessing when renderer-reported bounds are stale.
### What already works well
- The resize fast-path concept (recompute from insets + `getContentSize()`) is
sound; it just needs to be generalized.
- The spring-physics `TabBoundsController` for mode transitions (glance/split)
is unrelated and stays unchanged.
- Portal and omnibox bounds genuinely depend on dynamic DOM position and are
out of scope.
---
## Core Idea
The page content area's position is fully determined by a small set of **layout
parameters** that the renderer already knows:
| Parameter | Source | When it changes |
| ------------------ | ------------------------------ | -------------------------------- |
| `topbarHeight` | `AdaptiveTopbarProvider` | Platform detection, sidebar side |
| `topbarVisible` | `AdaptiveTopbarProvider` | Fullscreen enter/exit |
| `sidebarWidth` | `recordedSidebarSizeRef` | User drag, sidebar toggle |
| `sidebarSide` | Settings (`sidebarSide`) | User preference change |
| `sidebarVisible` | `BrowserSidebarProvider` | Toggle, floating trigger |
| `sidebarAnimating` | `BrowserSidebarProvider` | Mount/unmount 100ms animation |
| `outerPadding` | Fixed at `12` (Tailwind `p-3`) | Never (unless redesigned) |
Instead of measuring the DOM and sending pixel coordinates, the renderer sends
these parameters to the main process. The main process computes bounds
arithmetically from `getContentSize()` and the parameters, eliminating
`getBoundingClientRect()` from the critical path entirely.
---
## Layout Geometry
The current CSS layout (from `main.tsx`) produces bounds as follows. All units
are pixels; `cw` and `ch` are from `BrowserWindow.getContentSize()`.
```
topbarVisible = true:
┌──────────────────────────── cw ────────────────────────────┐
│ AdaptiveTopbar (height = topbarHeight) │
├────────────────────────────────────────────────────────────┤
│ │ pad │ │ pad │ │
│ [side] │ 12 │ BrowserContent │ 12 │ [sidebar] │
│ [bar ] │ │ (flex-1) │ │ [or pad ] │
│ [or ] │ │ │ │ │
│ [pad ] │ │ ← tab views go here → │ │ │
│ │ │ │ │ │
│ │ │ (with pt-0) │ │ │
│ │ │ │ │ │
├────────┴─────┴──────────────────────────┴─────┴────────────┤
│ bottom padding (12px from py-3) │
└────────────────────────────────────────────────────────────┘
topbarVisible = false (macOS, sidebar on left):
┌──────────────────────────── cw ────────────────────────────┐
│ │ pad │ │ pad │ │
│ [side] │ 12 │ BrowserContent │ 12 │ pad 12 │
│ [bar ] │ │ (flex-1) │ │ │
│ │ │ (with py-3) │ │ │
│ │ │ │ │ │
├────────┴─────┴──────────────────────────┴─────┴────────────┤
│ bottom padding (12px from py-3) │
└────────────────────────────────────────────────────────────┘
```
**Attached sidebar:**
- Total sidebar footprint = `sidebarWidth + handleOrPadWidth`.
- When sidebar is present: `sidebarWidth` (the `PixelBasedResizablePanel`
pixel width) + a 12px resize handle area (the `SidebarResizeHandle`, which
is `w-3`).
- When sidebar is absent: a 12px spacer (`<div className="w-3" />`).
- During the sidebar hide animation, the panel applies a negative margin equal
to `sidebarWidth`, collapsing it from `sidebarWidth` to `0` over 100ms. The
handle/spacer stays at 12px throughout.
**Floating sidebar:**
- Rendered in a `PortalComponent` (separate overlay window). Zero layout impact
on `BrowserContent`. The content area bounds do not change.
### Bounds formula
```
paddingH = 12 (left spacer/handle + right spacer/handle)
paddingTop = topbarVisible ? topbarHeight : 12
paddingBot = 12
sidebarSpace = sidebarVisible ? effectiveSidebarWidth : 0
where effectiveSidebarWidth lerps from 0 → sidebarWidth over animation
x = (sidebarSide === "left" ? sidebarSpace : 0) + 12
y = paddingTop
width = cw - sidebarSpace - paddingH
height = ch - paddingTop - paddingBot
```
The `12` on both sides accounts for the spacer/handle divs (`w-3 = 12px`)
that always exist between the sidebar panel (or its absence) and the content.
---
## Sidebar Tween Handling
The attached sidebar uses a 100ms `ease-in-out` CSS `transition-[margin]`. The
sidebar's pixel width stays constant; a negative margin slides it off-screen.
From the layout's perspective, `BrowserContent` grows/shrinks as the margin
animates.
### Approach: main-process eased interpolation
When the renderer reports that a sidebar animation has started, the main
process interpolates `effectiveSidebarWidth` between the start and end values
over the same 100ms duration with the same `ease-in-out` timing function. This
mirrors the CSS transition exactly, so the `WebContentsView` tracks the content
area without any renderer round-trip during the animation.
The flow:
```
Renderer Main Process
──────── ────────────
sidebar toggled
→ startAnimation()
→ send IPC: page:set-layout-params { receives layout params
..., with sidebarAnimating: true
sidebarVisible: false, and sidebarVisible: false
sidebarAnimating: true
}
starts 100ms interpolation:
effectiveSidebarWidth:
sidebarWidth → 0
using ease-in-out curve
recomputing pageBounds
each frame (~16ms ticks)
100ms later: interpolation settles
→ stopAnimation() effectiveSidebarWidth = 0
→ send IPC: page:set-layout-params {
...,
sidebarAnimating: false
}
confirms final state
```
The interpolation runs a `setTimeout`-based loop at ~4ms intervals (~25 ticks
per 100ms animation) in the main process, using **Tailwind's `ease-in-out`**
easing function: `cubic-bezier(0.4, 0, 0.2, 1)`. Note: this is NOT the CSS
standard `ease-in-out` keyword which is `cubic-bezier(0.42, 0, 0.58, 1)` —
Tailwind uses a different, front-loaded curve.
For opening, the interpolation goes `0 → sidebarWidth`. For closing,
`sidebarWidth → 0`. The direction is determined by the `sidebarVisible` flag
in the layout params.
**Floating sidebar:** No interpolation needed. The floating sidebar is an
overlay (`PortalComponent`) and does not affect `BrowserContent` bounds.
---
## New Shared Types
```typescript
// src/shared/flow/types.ts
export interface PageLayoutParams {
/** Pixel height of the topbar (0 when not applicable, e.g. macOS with left sidebar). */
topbarHeight: number;
/** Whether the topbar is currently rendered. */
topbarVisible: boolean;
/** Pixel width of the sidebar panel (not including the handle/spacer). */
sidebarWidth: number;
/** Which side the sidebar attaches to. */
sidebarSide: "left" | "right";
/**
* Whether the attached sidebar is currently visible (taking up layout space).
* Floating sidebars do not affect layout and should not change this flag.
*/
sidebarVisible: boolean;
/**
* Whether the sidebar is currently animating (opening or closing).
* When true, the main process interpolates effectiveSidebarWidth between
* the previous and target values over 100ms with ease-in-out timing.
*/
sidebarAnimating: boolean;
}
```
---
## New IPC Channel
### `page:set-layout-params`
Replaces `page:set-bounds` for the primary page bounds flow.
- **Direction:** Renderer -> Main (fire-and-forget, `ipcRenderer.send`)
- **Payload:** `PageLayoutParams`
- **When sent:**
- On initial mount (once layout parameters are known)
- When sidebar visibility changes (toggle on/off)
- When sidebar animation starts or ends
- When sidebar is resized by the user (drag handle)
- When sidebar side setting changes
- When topbar height/visibility changes (unlikely at runtime)
- When fullscreen state changes
This is significantly less frequent than the current approach, which sends
updated bounds on every rAF tick during any layout change.
---
## Main Process Changes
### `BrowserWindowInstance` (browser.ts)
Replace the current `pageBounds` / `pageInsets` / `_isResizing` mechanism:
```typescript
class BrowserWindowInstance {
// Remove:
// private pageInsets: { ... } | null = null;
// private _isResizing: boolean;
// private _resizeEndTimer: ...;
// Add:
private layoutParams: PageLayoutParams | null = null;
private sidebarInterpolation: SidebarInterpolation | null = null;
public setLayoutParams(params: PageLayoutParams): void {
const prevParams = this.layoutParams;
this.layoutParams = params;
if (params.sidebarAnimating && prevParams) {
// Start interpolation from previous effective width to new target
const fromWidth = prevParams.sidebarVisible ? prevParams.sidebarWidth : 0;
const toWidth = params.sidebarVisible ? params.sidebarWidth : 0;
this.sidebarInterpolation = new SidebarInterpolation(
fromWidth,
toWidth,
SIDEBAR_ANIMATE_DURATION, // 100ms, matching CSS
() => {
this.recomputePageBounds();
},
() => {
// Animation complete callback
this.sidebarInterpolation = null;
this.recomputePageBounds();
}
);
this.sidebarInterpolation.start();
} else {
// No animation — apply immediately
if (this.sidebarInterpolation) {
this.sidebarInterpolation.stop();
this.sidebarInterpolation = null;
}
this.recomputePageBounds();
}
}
private recomputePageBounds(): void {
if (!this.layoutParams) return;
const [cw, ch] = this.browserWindow.getContentSize();
const { topbarHeight, topbarVisible, sidebarWidth, sidebarSide, sidebarVisible } = this.layoutParams;
// Effective sidebar width (animated or static)
let effectiveSidebarWidth: number;
if (this.sidebarInterpolation) {
effectiveSidebarWidth = this.sidebarInterpolation.currentValue;
} else {
effectiveSidebarWidth = sidebarVisible ? sidebarWidth : 0;
}
const PADDING = 12;
const padTop = topbarVisible ? topbarHeight : PADDING;
const padBottom = PADDING;
const x = (sidebarSide === "left" ? effectiveSidebarWidth : 0) + PADDING;
const y = padTop;
const width = Math.max(0, cw - effectiveSidebarWidth - PADDING * 2);
const height = Math.max(0, ch - padTop - padBottom);
const newBounds: PageBounds = { x, y, width, height };
this.pageBounds = newBounds;
this.emit("page-bounds-changed", newBounds);
tabsController.handlePageBoundsChanged(this.id);
}
}
```
The `resize` event handler simplifies to:
```typescript
browserWindow.on("resize", () => {
this.recomputePageBounds();
});
```
No `_isResizing` flag, no inset cache, no debounce timer.
### `SidebarInterpolation` class
A small utility that drives a value from A to B over a duration with
Tailwind's `ease-in-out` timing, ticking via `setTimeout` at ~4ms intervals
(~25 ticks per 100ms animation):
```typescript
const SIDEBAR_ANIMATE_DURATION = 100; // ms, matches CSS
class SidebarInterpolation {
public currentValue: number;
private startTime: number = 0;
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(
private from: number,
private to: number,
private duration: number,
private onTick: () => void,
private onComplete: () => void
) {
this.currentValue = from;
}
start(): void {
this.startTime = performance.now();
this.currentValue = this.from;
this.tick();
}
stop(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
private tick = (): void => {
const elapsed = performance.now() - this.startTime;
const t = Math.min(elapsed / this.duration, 1);
const eased = easeInOut(t);
this.currentValue = this.from + (this.to - this.from) * eased;
this.onTick();
if (t < 1) {
this.timer = setTimeout(this.tick, MS_PER_FRAME);
} else {
this.currentValue = this.to;
this.onTick();
this.timer = null;
this.onComplete();
}
};
}
/** Tailwind ease-in-out: cubic-bezier(0.4, 0, 0.2, 1) */
function easeInOut(t: number): number {
// The actual implementation uses a Newton-Raphson cubic-bezier solver
// for exact matching. See sidebar-interpolation.ts.
// This pseudocode shows the concept; the quadratic approximation below
// does NOT match the Tailwind curve and is kept only for illustration.
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
}
```
---
## Renderer Changes
### `BrowserContent` (browser-content.tsx)
Remove `useBoundingRect` entirely. Replace with a `useEffect` that sends
layout parameters when they change:
```typescript
function BrowserContent() {
const { mode, recordedSidebarSizeRef, isAnimating } = useBrowserSidebar();
const { topbarHeight, topbarVisible } = useAdaptiveTopbar();
const sidebarVisible = mode.startsWith("attached-");
const sidebarSide = mode.includes("-left") ? "left" : mode.includes("-right") ? "right" : "left";
useEffect(() => {
const params: PageLayoutParams = {
topbarHeight,
topbarVisible,
sidebarWidth: recordedSidebarSizeRef.current,
sidebarSide,
sidebarVisible,
sidebarAnimating: isAnimating,
};
flow.page.setLayoutParams(params);
}, [topbarHeight, topbarVisible, sidebarVisible, sidebarSide, isAnimating,
recordedSidebarSizeRef]);
return (
<div className={cn("rounded-lg flex-1 relative remove-app-drag bg-white/20")} />
);
}
```
The `useBoundingRect` hook is no longer imported here. The component becomes a
simple placeholder `<div>` for visual styling only.
### `useBoundingRect` hook
Not deleted. It is still used by:
- Portal components (`portal.tsx`) for overlay positioning
- Omnibox positioning (`address-bar.tsx`)
- macOS traffic light tracking (`macos.tsx`)
- Extension popup anchoring (`browser-action-provider.tsx`)
- Resizable panel pixel calculations (`resizable-extras.tsx`)
These use cases genuinely depend on dynamic DOM position and cannot be
replaced with a declarative model.
---
## Fullscreen Handling
When fullscreen is entered or exited, the renderer sends updated layout params
with `topbarVisible: false` (fullscreen hides the topbar). The main process
also detects fullscreen via the `enter-full-screen` / `leave-full-screen`
events and can immediately recompute bounds using `getContentSize()` with
`topbarVisible = false`, without waiting for the renderer.
The `TabLayoutManager` already has a fullscreen override that uses
`getContentSize()` directly (tab-layout.ts:111-113), so this path is
unaffected.
---
## Validation During Migration
During the transition, keep the old `page:set-bounds` IPC handler and add a
debug assertion that compares the renderer-measured bounds against the
main-process-computed bounds:
```typescript
// Temporary validation (remove after confidence period)
ipcMain.on("page:set-bounds", (event, rendererBounds: PageBounds) => {
const window = getWindow(event.sender);
if (!window) return;
const computed = window.pageBounds;
const dx = Math.abs(computed.x - rendererBounds.x);
const dy = Math.abs(computed.y - rendererBounds.y);
const dw = Math.abs(computed.width - rendererBounds.width);
const dh = Math.abs(computed.height - rendererBounds.height);
if (dx > 2 || dy > 2 || dw > 2 || dh > 2) {
console.warn("[page-bounds] Mismatch!", { computed, rendererBounds });
}
});
```
This lets you verify the declarative model matches reality before removing the
old code path.
---
## Summary of Changes
| Component | Before | After |
| ------------------------------ | ------------------------------------------------------ | ---------------------------------------------------------- |
| `BrowserContent` | `useBoundingRect` + `getBoundingClientRect` rAF loop | `useLayoutEffect` sends `PageLayoutParams` on state change |
| `browser.ts` `setPageBounds` | Stores bounds, caches insets, `_isResizing` debounce | Removed |
| `browser.ts` `setLayoutParams` | N/A | Computes bounds from params + `getContentSize()` |
| `browser.ts` resize handler | Recomputes from cached insets with `_isResizing` guard | Calls `recomputePageBounds()` directly |
| Sidebar tween | Bounds lag behind CSS animation | Main-process 100ms ease-in-out interpolation |
| Floating sidebar | No change needed | No change needed |
| Fullscreen | Clears insets, waits for renderer | Detects via event, recomputes immediately |
| IPC frequency | ~60 calls/sec during any layout change | ~1-5 calls per structural change |
| `useBoundingRect` hook | Used by `BrowserContent` | Removed from `BrowserContent`; kept for portals/omnibox |
### Benefits
- **Zero `getBoundingClientRect` calls** on the critical page bounds path.
- **Zero renderer round-trip latency** for resize or sidebar toggle.
- **Pixel-perfect sidebar tween tracking** via synchronized interpolation.
- **Single source of truth** for page bounds: `recomputePageBounds()` in the
main process.
- **~95% reduction in IPC traffic** for page bounds updates.
- **Simpler code:** no inset cache, no `_isResizing` flag, no debounce timer,
no epsilon comparisons, no settle-frame heuristics.