diff --git a/frontend/src/components/Dashboard/DashboardView.tsx b/frontend/src/components/Dashboard/DashboardView.tsx index 3e294b7..06abb5d 100644 --- a/frontend/src/components/Dashboard/DashboardView.tsx +++ b/frontend/src/components/Dashboard/DashboardView.tsx @@ -1,5 +1,5 @@ import { motion } from "motion/react" -import { useEffect, useRef, useState } from "react" +import { useEffect, useMemo, useRef, useState } from "react" import type { DashboardDef_Output, Placement, WidgetDef } from "@/client" import { cn } from "@/lib/utils" @@ -188,6 +188,15 @@ export function CanvasSurface({ const scale = Math.min(box.width / width, box.height / height) const root = useCanvasRoot(dashboard, touch) const area = areaOf(dashboard, Boolean(rail)) + // The arrangement, held across the renders that are not about it. This + // component reads the dashboard's own settings, so a bound one wakes it on + // every message the flow sends — and calling a render prop again rebuilds + // every tile. With the callback and the scale both unmoved React skips the + // whole subtree instead. + const content = useMemo( + () => (scale > 0 ? children(scale) : null), + [children, scale], + ) return (
@@ -238,7 +247,7 @@ export function CanvasSurface({ {dots ? (
) : null} - {children(scale)} + {content}
diff --git a/frontend/src/components/Dashboard/PanelSurface.tsx b/frontend/src/components/Dashboard/PanelSurface.tsx index ab21a07..b4648d9 100644 --- a/frontend/src/components/Dashboard/PanelSurface.tsx +++ b/frontend/src/components/Dashboard/PanelSurface.tsx @@ -1,3 +1,5 @@ +import { useCallback } from "react" + import type { Dashboard } from "@/components/Dashboard/DashboardView" import { CanvasSurface, @@ -33,6 +35,13 @@ export function PanelSurface({ /** Whether the screen this hangs on is touched. Stated by the panel route. */ touch?: boolean }) { + const hasRail = Boolean(rail) + // Stable, so the canvas can keep the tiles it already built when one of the + // dashboard's own settings re-renders the route above this. + const canvas = useCallback( + () => , + [dashboard, hasRail], + ) return (
{stacked ? ( @@ -41,7 +50,7 @@ export function PanelSurface({ // The panel's own surface, scaled to fit. No dots: nothing is being // arranged here. - {() => } + {canvas} )} {/* Outside the canvas, so it needs the look stated for it. */} diff --git a/frontend/src/components/Dashboard/settings.tsx b/frontend/src/components/Dashboard/settings.tsx index abd5d57..3d00362 100644 --- a/frontend/src/components/Dashboard/settings.tsx +++ b/frontend/src/components/Dashboard/settings.tsx @@ -1,9 +1,9 @@ -import { createContext, useContext } from "react" +import { createContext, useContext, useMemo } from "react" import type { DashboardDef_Output, SettingDef } from "@/client" import { NO_PALETTE } from "@/components/Common/UplotChart" import { useLiveValue } from "@/components/Flow/liveStore" -import { isLight, parsePalette, rolesOf } from "./ui/core/theme" +import { isLight, parsePalette, type Roles, rolesOf } from "./ui/core/theme" /** * The dashboard's own settings channel. @@ -109,6 +109,24 @@ export const useDashboardPalette = ( dashboard: DashboardDef_Output | undefined, ): string[] => parsePalette(useSetting(dashboard, "palette")) +/** + * The palette as roles, keeping its identity while the colours keep theirs. + * + * `parsePalette` and `rolesOf` build a fresh array and a fresh object on every + * render, and a bound setting re-renders whatever reads it on every message the + * flow sends — so without this the two contexts below hand every widget a new + * value several times a second. The colours joined are the whole key: roles are + * derived from the hexes and from nothing else (`ui/core/theme.check.ts`). + */ +export function useDashboardRoles( + dashboard: DashboardDef_Output | undefined, +): Roles | null { + const hexes = useDashboardPalette(dashboard) + const key = hexes.join(",") + // biome-ignore lint/correctness/useExhaustiveDependencies: the colours are the dependency; `hexes` is a fresh array every render. + return useMemo(() => rolesOf(hexes), [key]) +} + /** An image drawn under the widgets, or `""` for the look's own ground. */ export function useDashboardBackground( dashboard: DashboardDef_Output | undefined, @@ -173,7 +191,7 @@ export function PaletteProvider({ dashboard: DashboardDef_Output | undefined children: React.ReactNode }) { - const palette = rolesOf(useDashboardPalette(dashboard))?.series ?? NO_PALETTE + const palette = useDashboardRoles(dashboard)?.series ?? NO_PALETTE return ( {children} diff --git a/frontend/src/components/Dashboard/ui/core/look.tsx b/frontend/src/components/Dashboard/ui/core/look.tsx index be3290f..8449fa5 100644 --- a/frontend/src/components/Dashboard/ui/core/look.tsx +++ b/frontend/src/components/Dashboard/ui/core/look.tsx @@ -10,17 +10,17 @@ * menu is portalled to `body` and lands outside the canvas. A surface that * floats out of the tree re-states the tokens rather than borrowing the app's. */ -import { createContext, useContext } from "react" +import { createContext, useContext, useMemo } from "react" import type { DashboardDef_Output } from "@/client" import { useTheme } from "@/components/theme-provider" import { type Look, useDashboardLook, - useDashboardPalette, + useDashboardRoles, useDashboardTheme, } from "../../settings" -import { rolesOf, tokenStyle } from "./theme" +import { tokenStyle } from "./theme" type Canvas = { look: Look @@ -30,6 +30,9 @@ type Canvas = { style: React.CSSProperties } +/** A dashboard that names no palette, as one object rather than one per render. */ +const NO_TOKENS: React.CSSProperties = {} + const LookContext = createContext({ look: "material", touch: false, @@ -60,11 +63,18 @@ export function useCanvasRoot( ) { const { resolvedTheme } = useTheme() const stated = useDashboardTheme(dashboard) - const roles = rolesOf(useDashboardPalette(dashboard)) + const roles = useDashboardRoles(dashboard) const look = useDashboardLook(dashboard) + // The tokens are handed to every widget through the context below, so they + // have to outlive a render: a fresh object here is a re-render of the whole + // canvas for every message a bound setting carries. + const style = useMemo( + () => (roles ? (tokenStyle(roles) as React.CSSProperties) : NO_TOKENS), + [roles], + ) return { className: stated || (resolvedTheme === "dark" ? "dark" : "light"), - style: (roles ? tokenStyle(roles) : {}) as React.CSSProperties, + style, "data-look": look, "data-touch": touch ? "" : undefined, "data-palette": roles ? "" : undefined, @@ -89,15 +99,15 @@ export function LookProvider({ children: React.ReactNode }) { const root = useCanvasRoot(dashboard, touch) - return ( - - {children} - + const look = root["data-look"] + const touched = root["data-touch"] !== undefined + const { style } = root + // A bound setting wakes this component on every message its flow sends, and + // what the widgets below actually react to is the value — so it changes only + // when one of the three things it carries does. + const value = useMemo( + () => ({ look, touch: touched, style }), + [look, touched, style], ) + return {children} } diff --git a/frontend/src/components/Dashboard/ui/core/theme.check.ts b/frontend/src/components/Dashboard/ui/core/theme.check.ts index 9cde8cf..7e2c761 100644 --- a/frontend/src/components/Dashboard/ui/core/theme.check.ts +++ b/frontend/src/components/Dashboard/ui/core/theme.check.ts @@ -182,4 +182,19 @@ for (const [name, hexes] of [ } } +// --- what the memo key assumes ------------------------------------------- +// +// `useDashboardRoles` keeps one `Roles` per palette, keyed on the colours +// joined, so that the look and palette contexts can hand a wall panel the same +// value on every message a bound setting carries. That key is only faithful +// while roles are a function of the hexes and of nothing else. + +for (const hexes of [CHOSEN, ["#0f172a"], ["#f4f1ea", "#ffffff"], []]) { + assert.deepEqual( + rolesOf([...hexes]), + rolesOf([...hexes]), + "the same colours are the same roles; the palette is the whole key", + ) +} + console.log("theme: ok") diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx index c588305..c8d779b 100644 --- a/frontend/src/components/Dashboard/widgets.tsx +++ b/frontend/src/components/Dashboard/widgets.tsx @@ -1,5 +1,5 @@ import { TriangleAlert } from "lucide-react" -import { useState } from "react" +import { memo, useState } from "react" import { ErrorBoundary } from "react-error-boundary" import type { WidgetDef } from "@/client" @@ -749,7 +749,7 @@ const RENDERERS: Partial< color: ColorWidget, } -export function WidgetBody({ widget, dashboard }: WidgetProps) { +function WidgetBodyComponent({ widget, dashboard }: WidgetProps) { const Renderer = RENDERERS[widget.type] if (!Renderer) { return ( @@ -769,6 +769,17 @@ export function WidgetBody({ widget, dashboard }: WidgetProps) { ) } +/** + * What a tile draws, held across a render of the grid around it. + * + * Both props stay put while the document does — a widget keeps its identity, + * and the dashboard is a name — so a grid re-rendered for reasons of its own no + * longer rebuilds every chart with it. Nothing a tile watches is held back by + * the boundary: its bound value, the look and the palette all reach it below + * here, through its own subscriptions. + */ +export const WidgetBody = memo(WidgetBodyComponent) + function WidgetFailed() { return (