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.
This commit is contained in:
@@ -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. */}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user