Make layout reactivity less brittle

Follow-up to ea2d98179c

This took a couple of iterations to find something that works without creating update loops, but I think that by automatically informing Grid whenever a layout component is re-rendered, we'll have a much easier time ensuring that our layouts are fully reactive.
This commit is contained in:
Robin 2024-07-24 16:57:20 -04:00
parent c74cebcc4b
commit 447bac3280
6 changed files with 80 additions and 92 deletions

View File

@ -25,10 +25,15 @@ import {
CSSProperties, CSSProperties,
ComponentProps, ComponentProps,
ComponentType, ComponentType,
Dispatch,
FC, FC,
LegacyRef, LegacyRef,
ReactNode, ReactNode,
useCallback, SetStateAction,
createContext,
forwardRef,
memo,
useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
@ -113,6 +118,27 @@ function offset(element: HTMLElement, relativeTo: Element): Offset {
} }
} }
interface LayoutContext {
setGeneration: Dispatch<SetStateAction<number | null>>;
}
const LayoutContext = createContext<LayoutContext | null>(null);
/**
* Enables Grid to react to layout changes. You must call this in your Layout
* component or else Grid will not be reactive.
*/
export function useLayout(): void {
const context = useContext(LayoutContext);
if (context === null)
throw new Error("useLayout called outside of a Grid layout component");
// On every render, tell Grid that the layout may have changed
useEffect(() =>
context.setGeneration((prev) => (prev === null ? 0 : prev + 1)),
);
}
export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> { export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
ref: LegacyRef<R>; ref: LegacyRef<R>;
model: LayoutModel; model: LayoutModel;
@ -158,6 +184,11 @@ interface Drag {
export type DragCallback = (drag: Drag) => void; export type DragCallback = (drag: Drag) => void;
interface LayoutMemoProps<LayoutModel, TileModel, R extends HTMLElement>
extends LayoutProps<LayoutModel, TileModel, R> {
Layout: ComponentType<LayoutProps<LayoutModel, TileModel, R>>;
}
interface Props< interface Props<
LayoutModel, LayoutModel,
TileModel, TileModel,
@ -209,7 +240,7 @@ export function Grid<
const [gridRoot, gridRef2] = useState<HTMLElement | null>(null); const [gridRoot, gridRef2] = useState<HTMLElement | null>(null);
const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2); const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2);
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null); const [layoutRoot, layoutRef] = useState<HTMLElement | null>(null);
const [generation, setGeneration] = useState<number | null>(null); const [generation, setGeneration] = useState<number | null>(null);
const tiles = useInitial(() => new Map<string, Tile<TileModel>>()); const tiles = useInitial(() => new Map<string, Tile<TileModel>>());
const prefersReducedMotion = usePrefersReducedMotion(); const prefersReducedMotion = usePrefersReducedMotion();
@ -236,27 +267,22 @@ export function Grid<
[tiles], [tiles],
); );
const layoutRef = useCallback( // We must memoize the Layout component to break the update loop where a
(e: HTMLElement | null) => { // render of Grid causes a re-render of Layout, which in turn re-renders Grid
setLayoutRoot(e); const LayoutMemo = useMemo(
if (e !== null) () =>
setGeneration(parseInt(e.getAttribute("data-generation")!)); memo(
}, forwardRef<
[setLayoutRoot, setGeneration], LayoutRef,
LayoutMemoProps<LayoutModel, TileModel, LayoutRef>
>(function LayoutMemo({ Layout, ...props }, ref): ReactNode {
return <Layout {...props} ref={ref} />;
}),
),
[],
); );
useEffect(() => { const context: LayoutContext = useMemo(() => ({ setGeneration }), []);
if (layoutRoot !== null) {
const observer = new MutationObserver((mutations) => {
if (mutations.some((m) => m.type === "attributes")) {
setGeneration(parseInt(layoutRoot.getAttribute("data-generation")!));
}
});
observer.observe(layoutRoot, { attributes: true });
return (): void => observer.disconnect();
}
}, [layoutRoot, setGeneration]);
// Combine the tile definitions and slots together to create placed tiles // Combine the tile definitions and slots together to create placed tiles
const placedTiles = useMemo(() => { const placedTiles = useMemo(() => {
@ -279,10 +305,10 @@ export function Grid<
} }
return result; return result;
// The rects may change due to the grid updating to a new generation, but // The rects may change due to the grid resizing or updating to a new
// eslint can't statically verify this // generation, but eslint can't statically verify this
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [gridRoot, layoutRoot, tiles, generation]); }, [gridRoot, layoutRoot, tiles, gridBounds, generation]);
// Drag state is stored in a ref rather than component state, because we use // Drag state is stored in a ref rather than component state, because we use
// react-spring's imperative API during gestures to improve responsiveness // react-spring's imperative API during gestures to improve responsiveness
@ -463,7 +489,9 @@ export function Grid<
className={classNames(className, styles.grid)} className={classNames(className, styles.grid)}
style={style} style={style}
> >
<Layout ref={layoutRef} model={model} Slot={Slot} /> <LayoutContext.Provider value={context}>
<LayoutMemo ref={layoutRef} Layout={Layout} model={model} Slot={Slot} />
</LayoutContext.Provider>
{tileTransitions((spring, { id, model, onDrag, width, height }) => ( {tileTransitions((spring, { id, model, onDrag, width, height }) => (
<TileWrapper <TileWrapper
key={id} key={id}

View File

@ -20,7 +20,6 @@ import { useObservableEagerState } from "observable-hooks";
import { GridLayout as GridLayoutModel } from "../state/CallViewModel"; import { GridLayout as GridLayoutModel } from "../state/CallViewModel";
import styles from "./GridLayout.module.css"; import styles from "./GridLayout.module.css";
import { useReactiveState } from "../useReactiveState";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
import { import {
CallLayout, CallLayout,
@ -28,7 +27,7 @@ import {
TileModel, TileModel,
arrangeTiles, arrangeTiles,
} from "./CallLayout"; } from "./CallLayout";
import { DragCallback } from "./Grid"; import { DragCallback, useLayout } from "./Grid";
interface GridCSSProperties extends CSSProperties { interface GridCSSProperties extends CSSProperties {
"--gap": string; "--gap": string;
@ -49,7 +48,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile // The "fixed" (non-scrolling) part of the layout is where the spotlight tile
// lives // lives
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) { fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) {
const { width, height } = useObservableEagerState(minBounds); useLayout();
const alignment = useObservableEagerState( const alignment = useObservableEagerState(
useInitial(() => useInitial(() =>
spotlightAlignment.pipe( spotlightAlignment.pipe(
@ -68,10 +67,6 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
}, },
[model.spotlight], [model.spotlight],
); );
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[model.spotlight === undefined, width, height, alignment],
);
const onDragSpotlight: DragCallback = useCallback( const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) => ({ xRatio, yRatio }) =>
@ -83,7 +78,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
); );
return ( return (
<div ref={ref} className={styles.fixed} data-generation={generation}> <div ref={ref} className={styles.fixed}>
{tileModel && ( {tileModel && (
<Slot <Slot
className={styles.slot} className={styles.slot}
@ -100,17 +95,13 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
// The scrolling part of the layout is where all the grid tiles live // The scrolling part of the layout is where all the grid tiles live
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) { scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
useLayout();
const { width, height: minHeight } = useObservableEagerState(minBounds); const { width, height: minHeight } = useObservableEagerState(minBounds);
const { gap, tileWidth, tileHeight } = useMemo( const { gap, tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, minHeight, model.grid.length), () => arrangeTiles(width, minHeight, model.grid.length),
[width, minHeight, model.grid.length], [width, minHeight, model.grid.length],
); );
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[model.grid, width, minHeight],
);
const tileModels: GridTileModel[] = useMemo( const tileModels: GridTileModel[] = useMemo(
() => model.grid.map((vm) => ({ type: "grid", vm })), () => model.grid.map((vm) => ({ type: "grid", vm })),
[model.grid], [model.grid],
@ -119,7 +110,6 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
return ( return (
<div <div
ref={ref} ref={ref}
data-generation={generation}
className={styles.scrolling} className={styles.scrolling}
style={ style={
{ {

View File

@ -20,9 +20,8 @@ import classNames from "classnames";
import { OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel"; import { OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel";
import { CallLayout, GridTileModel, arrangeTiles } from "./CallLayout"; import { CallLayout, GridTileModel, arrangeTiles } from "./CallLayout";
import { useReactiveState } from "../useReactiveState";
import styles from "./OneOnOneLayout.module.css"; import styles from "./OneOnOneLayout.module.css";
import { DragCallback } from "./Grid"; import { DragCallback, useLayout } from "./Grid";
/** /**
* An implementation of the "one-on-one" layout, in which the remote participant * An implementation of the "one-on-one" layout, in which the remote participant
@ -35,10 +34,12 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
scrollingOnTop: false, scrollingOnTop: false,
fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) { fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) {
return <div ref={ref} data-generation={0} />; useLayout();
return <div ref={ref} />;
}), }),
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) { scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {
useLayout();
const { width, height } = useObservableEagerState(minBounds); const { width, height } = useObservableEagerState(minBounds);
const pipAlignmentValue = useObservableEagerState(pipAlignment); const pipAlignmentValue = useObservableEagerState(pipAlignment);
const { tileWidth, tileHeight } = useMemo( const { tileWidth, tileHeight } = useMemo(
@ -46,11 +47,6 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
[width, height], [width, height],
); );
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[width, height, pipAlignmentValue],
);
const remoteTileModel: GridTileModel = useMemo( const remoteTileModel: GridTileModel = useMemo(
() => ({ type: "grid", vm: model.remote }), () => ({ type: "grid", vm: model.remote }),
[model.remote], [model.remote],
@ -70,7 +66,7 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
); );
return ( return (
<div ref={ref} data-generation={generation} className={styles.layer}> <div ref={ref} className={styles.layer}>
<Slot <Slot
id={remoteTileModel.vm.id} id={remoteTileModel.vm.id}
model={remoteTileModel} model={remoteTileModel}

View File

@ -19,9 +19,8 @@ import { useObservableEagerState } from "observable-hooks";
import { SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel"; import { SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
import { CallLayout, GridTileModel, SpotlightTileModel } from "./CallLayout"; import { CallLayout, GridTileModel, SpotlightTileModel } from "./CallLayout";
import { DragCallback } from "./Grid"; import { DragCallback, useLayout } from "./Grid";
import styles from "./SpotlightExpandedLayout.module.css"; import styles from "./SpotlightExpandedLayout.module.css";
import { useReactiveState } from "../useReactiveState";
/** /**
* An implementation of the "expanded spotlight" layout, in which the spotlight * An implementation of the "expanded spotlight" layout, in which the spotlight
@ -29,27 +28,21 @@ import { useReactiveState } from "../useReactiveState";
*/ */
export const makeSpotlightExpandedLayout: CallLayout< export const makeSpotlightExpandedLayout: CallLayout<
SpotlightExpandedLayoutModel SpotlightExpandedLayoutModel
> = ({ minBounds, pipAlignment }) => ({ > = ({ pipAlignment }) => ({
scrollingOnTop: true, scrollingOnTop: true,
fixed: forwardRef(function SpotlightExpandedLayoutFixed( fixed: forwardRef(function SpotlightExpandedLayoutFixed(
{ model, Slot }, { model, Slot },
ref, ref,
) { ) {
const { width, height } = useObservableEagerState(minBounds); useLayout();
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[width, height, model.spotlight],
);
const spotlightTileModel: SpotlightTileModel = useMemo( const spotlightTileModel: SpotlightTileModel = useMemo(
() => ({ type: "spotlight", vms: model.spotlight, maximised: true }), () => ({ type: "spotlight", vms: model.spotlight, maximised: true }),
[model.spotlight], [model.spotlight],
); );
return ( return (
<div ref={ref} data-generation={generation} className={styles.layer}> <div ref={ref} className={styles.layer}>
<Slot <Slot
className={styles.spotlight} className={styles.spotlight}
id="spotlight" id="spotlight"
@ -63,14 +56,9 @@ export const makeSpotlightExpandedLayout: CallLayout<
{ model, Slot }, { model, Slot },
ref, ref,
) { ) {
const { width, height } = useObservableEagerState(minBounds); useLayout();
const pipAlignmentValue = useObservableEagerState(pipAlignment); const pipAlignmentValue = useObservableEagerState(pipAlignment);
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[width, height, model.pip === undefined, pipAlignmentValue],
);
const pipTileModel: GridTileModel | undefined = useMemo( const pipTileModel: GridTileModel | undefined = useMemo(
() => model.pip && { type: "grid", vm: model.pip }, () => model.pip && { type: "grid", vm: model.pip },
[model.pip], [model.pip],
@ -86,7 +74,7 @@ export const makeSpotlightExpandedLayout: CallLayout<
); );
return ( return (
<div ref={ref} data-generation={generation} className={styles.layer}> <div ref={ref} className={styles.layer}>
{pipTileModel && ( {pipTileModel && (
<Slot <Slot
className={styles.pip} className={styles.pip}

View File

@ -21,7 +21,7 @@ import classNames from "classnames";
import { CallLayout, GridTileModel, TileModel } from "./CallLayout"; import { CallLayout, GridTileModel, TileModel } from "./CallLayout";
import { SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel"; import { SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel";
import styles from "./SpotlightLandscapeLayout.module.css"; import styles from "./SpotlightLandscapeLayout.module.css";
import { useReactiveState } from "../useReactiveState"; import { useLayout } from "./Grid";
/** /**
* An implementation of the "spotlight landscape" layout, in which the spotlight * An implementation of the "spotlight landscape" layout, in which the spotlight
@ -37,7 +37,8 @@ export const makeSpotlightLandscapeLayout: CallLayout<
{ model, Slot }, { model, Slot },
ref, ref,
) { ) {
const { width, height } = useObservableEagerState(minBounds); useLayout();
useObservableEagerState(minBounds);
const tileModel: TileModel = useMemo( const tileModel: TileModel = useMemo(
() => ({ () => ({
type: "spotlight", type: "spotlight",
@ -46,13 +47,9 @@ export const makeSpotlightLandscapeLayout: CallLayout<
}), }),
[model.spotlight], [model.spotlight],
); );
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[model.grid.length, width, height, model.spotlight],
);
return ( return (
<div ref={ref} data-generation={generation} className={styles.layer}> <div ref={ref} className={styles.layer}>
<div className={styles.spotlight}> <div className={styles.spotlight}>
<Slot className={styles.slot} id="spotlight" model={tileModel} /> <Slot className={styles.slot} id="spotlight" model={tileModel} />
</div> </div>
@ -65,18 +62,15 @@ export const makeSpotlightLandscapeLayout: CallLayout<
{ model, Slot }, { model, Slot },
ref, ref,
) { ) {
const { width, height } = useObservableEagerState(minBounds); useLayout();
useObservableEagerState(minBounds);
const tileModels: GridTileModel[] = useMemo( const tileModels: GridTileModel[] = useMemo(
() => model.grid.map((vm) => ({ type: "grid", vm })), () => model.grid.map((vm) => ({ type: "grid", vm })),
[model.grid], [model.grid],
); );
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[model.spotlight.length, model.grid, width, height],
);
return ( return (
<div ref={ref} data-generation={generation} className={styles.layer}> <div ref={ref} className={styles.layer}>
<div <div
className={classNames(styles.spotlight, { className={classNames(styles.spotlight, {
[styles.withIndicators]: model.spotlight.length > 1, [styles.withIndicators]: model.spotlight.length > 1,

View File

@ -26,7 +26,7 @@ import {
} from "./CallLayout"; } from "./CallLayout";
import { SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel"; import { SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
import styles from "./SpotlightPortraitLayout.module.css"; import styles from "./SpotlightPortraitLayout.module.css";
import { useReactiveState } from "../useReactiveState"; import { useLayout } from "./Grid";
interface GridCSSProperties extends CSSProperties { interface GridCSSProperties extends CSSProperties {
"--grid-gap": string; "--grid-gap": string;
@ -48,7 +48,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
{ model, Slot }, { model, Slot },
ref, ref,
) { ) {
const { width, height } = useObservableEagerState(minBounds); useLayout();
const tileModel: TileModel = useMemo( const tileModel: TileModel = useMemo(
() => ({ () => ({
type: "spotlight", type: "spotlight",
@ -57,13 +57,9 @@ export const makeSpotlightPortraitLayout: CallLayout<
}), }),
[model.spotlight], [model.spotlight],
); );
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[model.grid.length, width, height, model.spotlight],
);
return ( return (
<div ref={ref} data-generation={generation} className={styles.layer}> <div ref={ref} className={styles.layer}>
<div className={styles.spotlight}> <div className={styles.spotlight}>
<Slot className={styles.slot} id="spotlight" model={tileModel} /> <Slot className={styles.slot} id="spotlight" model={tileModel} />
</div> </div>
@ -75,7 +71,8 @@ export const makeSpotlightPortraitLayout: CallLayout<
{ model, Slot }, { model, Slot },
ref, ref,
) { ) {
const { width, height } = useObservableEagerState(minBounds); useLayout();
const { width } = useObservableEagerState(minBounds);
const { gap, tileWidth, tileHeight } = arrangeTiles( const { gap, tileWidth, tileHeight } = arrangeTiles(
width, width,
0, 0,
@ -85,15 +82,10 @@ export const makeSpotlightPortraitLayout: CallLayout<
() => model.grid.map((vm) => ({ type: "grid", vm })), () => model.grid.map((vm) => ({ type: "grid", vm })),
[model.grid], [model.grid],
); );
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[model.spotlight.length, model.grid, width, height],
);
return ( return (
<div <div
ref={ref} ref={ref}
data-generation={generation}
className={styles.layer} className={styles.layer}
style={ style={
{ {