Skip to content

Commit 00056a7

Browse files
committed
Determine which tiles are on screen in a more stable manner
Instead of tracking for each individual tile whether it's visible, just track the total number of tiles that appear on screen. This ought to make the whole thing a lot less dynamic, which is crucial given that our UI renders asynchronously and RxJS doesn't really support cyclic dependencies in any rigorous way. In particular this ought to make the following kind of situation impossible: 1. There 3 tiles, ABC. A and B are on screen. 2. Now C becomes important. The requested order is now CAB. 3. To reduce the size of the layout shift, the algorithm selects to swap just B and C in the original order, giving ACB. However, the UI is blocked and doesn't render this order yet. 4. For whatever reason, a spurious update of the importance algorithm occurs. It once again requests CAB. 5. Now because the UI was blocked, the layout still thinks that A and B are on screen (rather than A and C). It thinks that C is some weird island of "off-screen territory" in the middle of the tile order. This confuses it into swapping A and C rather than keeping the layout stable. The reality is that whenever we think N tiles are visible on screen, we're always referring to the first N tiles in the grid. It's best if the code reflects this assumption.
1 parent 77facd0 commit 00056a7

14 files changed

+96
-135
lines changed

src/grid/Grid.tsx

Lines changed: 43 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import {
2424
createContext,
2525
forwardRef,
2626
memo,
27-
useCallback,
2827
useContext,
2928
useEffect,
3029
useMemo,
@@ -54,7 +53,6 @@ interface Tile<Model> {
5453
id: string;
5554
model: Model;
5655
onDrag: DragCallback | undefined;
57-
setVisible: (visible: boolean) => void;
5856
}
5957

6058
type PlacedTile<Model> = Tile<Model> & Rect;
@@ -88,7 +86,6 @@ interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
8886
id: string;
8987
model: Model;
9088
onDrag?: DragCallback;
91-
onVisibilityChange?: (visible: boolean) => void;
9289
style?: CSSProperties;
9390
className?: string;
9491
}
@@ -115,24 +112,47 @@ function offset(element: HTMLElement, relativeTo: Element): Offset {
115112
}
116113
}
117114

115+
export type VisibleTilesCallback = (visibleTiles: number) => void;
116+
118117
interface LayoutContext {
119118
setGeneration: Dispatch<SetStateAction<number | null>>;
119+
setVisibleTilesCallback: Dispatch<
120+
SetStateAction<VisibleTilesCallback | null>
121+
>;
120122
}
121123

122124
const LayoutContext = createContext<LayoutContext | null>(null);
123125

126+
function useLayoutContext(): LayoutContext {
127+
const context = useContext(LayoutContext);
128+
if (context === null)
129+
throw new Error("useUpdateLayout called outside a Grid layout context");
130+
return context;
131+
}
132+
124133
/**
125134
* Enables Grid to react to layout changes. You must call this in your Layout
126135
* component or else Grid will not be reactive.
127136
*/
128137
export function useUpdateLayout(): void {
129-
const context = useContext(LayoutContext);
130-
if (context === null)
131-
throw new Error("useUpdateLayout called outside a Grid layout context");
132-
138+
const { setGeneration } = useLayoutContext();
133139
// On every render, tell Grid that the layout may have changed
134-
useEffect(() =>
135-
context.setGeneration((prev) => (prev === null ? 0 : prev + 1)),
140+
useEffect(() => setGeneration((prev) => (prev === null ? 0 : prev + 1)));
141+
}
142+
143+
/**
144+
* Asks Grid to call a callback whenever the number of visible tiles may have
145+
* changed.
146+
*/
147+
export function useVisibleTiles(callback: VisibleTilesCallback): void {
148+
const { setVisibleTilesCallback } = useLayoutContext();
149+
useEffect(
150+
() => setVisibleTilesCallback(() => callback),
151+
[callback, setVisibleTilesCallback],
152+
);
153+
useEffect(
154+
() => (): void => setVisibleTilesCallback(null),
155+
[setVisibleTilesCallback],
136156
);
137157
}
138158

@@ -245,39 +265,20 @@ export function Grid<
245265
const windowHeight = useObservableEagerState(windowHeightObservable);
246266
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
247267
const [generation, setGeneration] = useState<number | null>(null);
268+
const [visibleTilesCallback, setVisibleTilesCallback] =
269+
useState<VisibleTilesCallback | null>(null);
248270
const tiles = useInitial(() => new Map<string, Tile<TileModel>>());
249271
const prefersReducedMotion = usePrefersReducedMotion();
250272

251273
const Slot: FC<SlotProps<TileModel>> = useMemo(
252274
() =>
253-
function Slot({
254-
id,
255-
model,
256-
onDrag,
257-
onVisibilityChange,
258-
style,
259-
className,
260-
...props
261-
}) {
275+
function Slot({ id, model, onDrag, style, className, ...props }) {
262276
const ref = useRef<HTMLDivElement | null>(null);
263-
const prevVisible = useRef<boolean | null>(null);
264-
const setVisible = useCallback(
265-
(visible: boolean) => {
266-
if (
267-
onVisibilityChange !== undefined &&
268-
visible !== prevVisible.current
269-
) {
270-
onVisibilityChange(visible);
271-
prevVisible.current = visible;
272-
}
273-
},
274-
[onVisibilityChange],
275-
);
276277

277278
useEffect(() => {
278-
tiles.set(id, { id, model, onDrag, setVisible });
279+
tiles.set(id, { id, model, onDrag });
279280
return (): void => void tiles.delete(id);
280-
}, [id, model, onDrag, setVisible]);
281+
}, [id, model, onDrag]);
281282

282283
return (
283284
<div
@@ -307,7 +308,10 @@ export function Grid<
307308
[],
308309
);
309310

310-
const context: LayoutContext = useMemo(() => ({ setGeneration }), []);
311+
const context: LayoutContext = useMemo(
312+
() => ({ setGeneration, setVisibleTilesCallback }),
313+
[setVisibleTilesCallback],
314+
);
311315

312316
// Combine the tile definitions and slots together to create placed tiles
313317
const placedTiles = useMemo(() => {
@@ -342,9 +346,11 @@ export function Grid<
342346
);
343347

344348
useEffect(() => {
345-
for (const tile of placedTiles)
346-
tile.setVisible(tile.y + tile.height <= visibleHeight);
347-
}, [placedTiles, visibleHeight]);
349+
visibleTilesCallback?.(
350+
placedTiles.filter((tile) => tile.y + tile.height <= visibleHeight)
351+
.length,
352+
);
353+
}, [placedTiles, visibleTilesCallback, visibleHeight]);
348354

349355
// Drag state is stored in a ref rather than component state, because we use
350356
// react-spring's imperative API during gestures to improve responsiveness

src/grid/GridLayout.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { type GridLayout as GridLayoutModel } from "../state/CallViewModel";
1313
import styles from "./GridLayout.module.css";
1414
import { useInitial } from "../useInitial";
1515
import { type CallLayout, arrangeTiles } from "./CallLayout";
16-
import { type DragCallback, useUpdateLayout } from "./Grid";
16+
import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid";
1717

1818
interface GridCSSProperties extends CSSProperties {
1919
"--gap": string;
@@ -73,6 +73,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
7373
// The scrolling part of the layout is where all the grid tiles live
7474
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
7575
useUpdateLayout();
76+
useVisibleTiles(model.setVisibleTiles);
7677
const { width, height: minHeight } = useObservableEagerState(minBounds);
7778
const { gap, tileWidth, tileHeight } = useMemo(
7879
() => arrangeTiles(width, minHeight, model.grid.length),
@@ -93,13 +94,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
9394
}
9495
>
9596
{model.grid.map((m) => (
96-
<Slot
97-
key={m.id}
98-
className={styles.slot}
99-
id={m.id}
100-
model={m}
101-
onVisibilityChange={m.setVisible}
102-
/>
97+
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
10398
))}
10499
</div>
105100
);

src/grid/OneOnOneLayout.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
5252
<Slot
5353
id={model.remote.id}
5454
model={model.remote}
55-
onVisibilityChange={model.remote.setVisible}
5655
className={styles.container}
5756
style={{ width: tileWidth, height: tileHeight }}
5857
>
@@ -61,7 +60,6 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
6160
id={model.local.id}
6261
model={model.local}
6362
onDrag={onDragLocalTile}
64-
onVisibilityChange={model.local.setVisible}
6563
data-block-alignment={pipAlignmentValue.block}
6664
data-inline-alignment={pipAlignmentValue.inline}
6765
/>

src/grid/SpotlightExpandedLayout.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ export const makeSpotlightExpandedLayout: CallLayout<
6363
id={model.pip.id}
6464
model={model.pip}
6565
onDrag={onDragPip}
66-
onVisibilityChange={model.pip.setVisible}
6766
data-block-alignment={pipAlignmentValue.block}
6867
data-inline-alignment={pipAlignmentValue.inline}
6968
/>

src/grid/SpotlightLandscapeLayout.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import classNames from "classnames";
1212
import { type CallLayout } from "./CallLayout";
1313
import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel";
1414
import styles from "./SpotlightLandscapeLayout.module.css";
15-
import { useUpdateLayout } from "./Grid";
15+
import { useUpdateLayout, useVisibleTiles } from "./Grid";
1616

1717
/**
1818
* An implementation of the "spotlight landscape" layout, in which the spotlight
@@ -50,6 +50,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
5050
ref,
5151
) {
5252
useUpdateLayout();
53+
useVisibleTiles(model.setVisibleTiles);
5354
useObservableEagerState(minBounds);
5455
const withIndicators =
5556
useObservableEagerState(model.spotlight.media).length > 1;
@@ -63,13 +64,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
6364
/>
6465
<div className={styles.grid}>
6566
{model.grid.map((m) => (
66-
<Slot
67-
key={m.id}
68-
className={styles.slot}
69-
id={m.id}
70-
model={m}
71-
onVisibilityChange={m.setVisible}
72-
/>
67+
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
7368
))}
7469
</div>
7570
</div>

src/grid/SpotlightPortraitLayout.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import classNames from "classnames";
1212
import { type CallLayout, arrangeTiles } from "./CallLayout";
1313
import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
1414
import styles from "./SpotlightPortraitLayout.module.css";
15-
import { useUpdateLayout } from "./Grid";
15+
import { useUpdateLayout, useVisibleTiles } from "./Grid";
1616

1717
interface GridCSSProperties extends CSSProperties {
1818
"--grid-gap": string;
@@ -54,6 +54,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
5454
ref,
5555
) {
5656
useUpdateLayout();
57+
useVisibleTiles(model.setVisibleTiles);
5758
const { width } = useObservableEagerState(minBounds);
5859
const { gap, tileWidth, tileHeight } = arrangeTiles(
5960
width,
@@ -84,13 +85,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
8485
/>
8586
<div className={styles.grid}>
8687
{model.grid.map((m) => (
87-
<Slot
88-
key={m.id}
89-
className={styles.slot}
90-
id={m.id}
91-
model={m}
92-
onVisibilityChange={m.setVisible}
93-
/>
88+
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
9489
))}
9590
</div>
9691
</div>

src/state/CallViewModel.test.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -402,10 +402,7 @@ test("participants stay in the same order unless to appear/disappear", () => {
402402
// We imagine that only three tiles (the first three) will be visible
403403
// on screen at a time
404404
vm.layout.subscribe((layout) => {
405-
if (layout.type === "grid") {
406-
for (let i = 0; i < layout.grid.length; i++)
407-
layout.grid[i].setVisible(i < 3);
408-
}
405+
if (layout.type === "grid") layout.setVisibleTiles(3);
409406
});
410407
},
411408
});

0 commit comments

Comments
 (0)