Stop a bound dashboard setting rebuilding every tile

A setting driven by a flow re-renders whatever reads it on every message.
`LookProvider` and `PaletteProvider` built a fresh context value each time,
and `CanvasSurface` called its render prop again, so a value on `theme`,
`palette`, `look`, `background`, `touch` or `locked` reconciled the whole
widget tree and re-ran every chart's `setData`.

The palette is now read as roles once per set of colours, the tokens and
both context values are memoised on it, and the canvas holds the tiles it
built while the callback and the scale stay put. `WidgetBody` is memoised
too: its two props are stable while the document is, so a grid rendered for
its own reasons no longer rebuilds every chart with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfgHWr7AQDiLsTQVu7hpKB
This commit is contained in:
2026-09-14 14:38:59 +02:00
co-authored by Claude Opus 5
parent 265cea38d2
commit aa3e2e8b81
6 changed files with 95 additions and 23 deletions
@@ -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 (
<div ref={ref} className="relative size-full overflow-hidden">
@@ -238,7 +247,7 @@ export function CanvasSurface({
{dots ? (
<div className="dot-canvas pointer-events-none absolute inset-0" />
) : null}
{children(scale)}
{content}
</div>
</div>
</LookProvider>
@@ -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(
() => <DashboardView dashboard={dashboard} rail={hasRail} />,
[dashboard, hasRail],
)
return (
<div className="relative size-full">
{stacked ? (
@@ -41,7 +50,7 @@ export function PanelSurface({
// The panel's own surface, scaled to fit. No dots: nothing is being
// arranged here.
<CanvasSurface dashboard={dashboard} rail={rail} touch={touch}>
{() => <DashboardView dashboard={dashboard} rail={Boolean(rail)} />}
{canvas}
</CanvasSurface>
)}
{/* Outside the canvas, so it needs the look stated for it. */}
+21 -3
View File
@@ -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 (
<PaletteContext.Provider value={palette}>
{children}
@@ -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<Canvas>({
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 (
<LookContext.Provider
value={{
look: root["data-look"],
touch: root["data-touch"] !== undefined,
style: root.style,
}}
>
{children}
</LookContext.Provider>
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 <LookContext.Provider value={value}>{children}</LookContext.Provider>
}
@@ -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")
+13 -2
View File
@@ -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 (
<p className="flex h-full items-center justify-center gap-2 text-muted-foreground text-sm">