From 5604d686980853980c5c680f0176f4363d3ff6dd Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Sat, 24 Oct 2026 11:56:13 +0200 Subject: [PATCH 1/4] Patch widget layouts in place through reactive proxies so a diff only re-renders the widgets it changes --- frontend/eslint.config.js | 4 +- frontend/src/components/panels/Data.svelte | 2 +- .../src/components/panels/Inspector.svelte | 2 +- frontend/src/components/panels/Layers.svelte | 6 +- frontend/src/components/panels/Welcome.svelte | 2 +- .../src/components/widgets/NodeSection.svelte | 31 ++++--- .../components/widgets/WidgetLayout.svelte | 12 +-- .../src/components/widgets/WidgetSpan.svelte | 32 +++---- .../src/components/widgets/WidgetTable.svelte | 9 +- .../src/components/window/StatusBar.svelte | 8 +- .../src/components/window/TitleBar.svelte | 6 +- frontend/src/stores/document.ts | 38 +++----- frontend/src/stores/portfolio.ts | 44 ++++------ .../utility-functions/layout-state.svelte.ts | 7 ++ frontend/src/utility-functions/widgets.ts | 88 ++++++++++++------- 15 files changed, 155 insertions(+), 136 deletions(-) create mode 100644 frontend/src/utility-functions/layout-state.svelte.ts diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index f6f89953ee1..e909faac8a8 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -136,9 +136,9 @@ export default defineConfig([ rules: { "import/order": ["error", { ...importOrder, "newlines-between": "ignore" }] }, }, { - files: ["**/*.svelte"], + files: ["**/*.svelte", "**/*.svelte.ts"], languageOptions: { - // Parse the ` {#each layout as layoutGroup} diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte index e391d0e66c7..9e3d5f5d855 100644 --- a/frontend/src/components/widgets/WidgetSpan.svelte +++ b/frontend/src/components/widgets/WidgetSpan.svelte @@ -42,19 +42,21 @@ const editor = getContext("editor"); const colorPickerStore = getContext("colorPicker"); - export let widgets: WidgetInstance[]; - export let direction: "row" | "column"; - export let layoutTarget: LayoutTarget; - - let className = ""; - export { className as class }; - export let classes: Record = {}; - - export let narrow = false; + type Props = { + widgets: WidgetInstance[]; + direction: "row" | "column"; + layoutTarget: LayoutTarget; + class?: string; + classes?: Record; + narrow?: boolean; + }; + let { widgets, direction, layoutTarget, class: className = "", classes = {}, narrow = false }: Props = $props(); - $: extraClasses = Object.entries(classes) - .flatMap(([className, stateName]) => (stateName ? [className] : [])) - .join(" "); + const extraClasses = $derived( + Object.entries(classes) + .flatMap(([className, stateName]) => (stateName ? [className] : [])) + .join(" "), + ); function widgetValueCommit(widgetIndex: number, value: unknown) { editor.widgetValueCommit(layoutTarget, widgets[widgetIndex].widgetId, value); @@ -362,11 +364,11 @@ {#each widgets as widget, widgetIndex} {@const unwrapped = unwrapWidget(widget)} {#if unwrapped} - {@const { component, props, slot } = resolveWidget(unwrapped, widgetIndex)} + {@const { component: Component, props, slot } = resolveWidget(unwrapped, widgetIndex)} {#if props !== undefined && slot !== undefined} - {slot} + {slot} {:else if props !== undefined} - + {/if} {/if} {/each} diff --git a/frontend/src/components/widgets/WidgetTable.svelte b/frontend/src/components/widgets/WidgetTable.svelte index f20924bbb05..a9b6e1d43f6 100644 --- a/frontend/src/components/widgets/WidgetTable.svelte +++ b/frontend/src/components/widgets/WidgetTable.svelte @@ -2,10 +2,13 @@ import WidgetSpan from "/src/components/widgets/WidgetSpan.svelte"; import type { LayoutTarget, WidgetTable } from "/wrapper/pkg/graphite_wasm_wrapper"; - export let widgetData: WidgetTable; - export let layoutTarget: LayoutTarget; + type Props = { + widgetData: WidgetTable; + layoutTarget: LayoutTarget; + }; + let { widgetData, layoutTarget }: Props = $props(); - $: columns = widgetData.tableWidgets.length > 0 ? widgetData.tableWidgets[0].length : 0; + const columns = $derived(widgetData.tableWidgets.length > 0 ? widgetData.tableWidgets[0].length : 0); diff --git a/frontend/src/components/window/StatusBar.svelte b/frontend/src/components/window/StatusBar.svelte index 99deabb6019..33c8c1e8ced 100644 --- a/frontend/src/components/window/StatusBar.svelte +++ b/frontend/src/components/window/StatusBar.svelte @@ -4,23 +4,21 @@ import Separator from "/src/components/widgets/labels/Separator.svelte"; import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte"; import type { SubscriptionsRouter } from "/src/subscriptions-router"; + import { createLayout } from "/src/utility-functions/layout-state.svelte"; import { patchLayout } from "/src/utility-functions/widgets"; - import type { Layout } from "/wrapper/pkg/graphite_wasm_wrapper"; const subscriptions = getContext("subscriptions"); - let statusBarHintsLayout: Layout = []; - let statusBarInfoLayout: Layout = []; + const statusBarHintsLayout = createLayout(); + const statusBarInfoLayout = createLayout(); onMount(() => { subscriptions.subscribeLayoutUpdate("StatusBarHints", (data) => { patchLayout(statusBarHintsLayout, data); - statusBarHintsLayout = statusBarHintsLayout; }); subscriptions.subscribeLayoutUpdate("StatusBarInfo", (data) => { patchLayout(statusBarInfoLayout, data); - statusBarInfoLayout = statusBarInfoLayout; }); }); diff --git a/frontend/src/components/window/TitleBar.svelte b/frontend/src/components/window/TitleBar.svelte index a93c0181efa..481c306acf5 100644 --- a/frontend/src/components/window/TitleBar.svelte +++ b/frontend/src/components/window/TitleBar.svelte @@ -8,8 +8,9 @@ import type { FullscreenStore } from "/src/stores/fullscreen"; import type { TooltipStore } from "/src/stores/tooltip"; import type { SubscriptionsRouter } from "/src/subscriptions-router"; + import { createLayout } from "/src/utility-functions/layout-state.svelte"; import { patchLayout } from "/src/utility-functions/widgets"; - import type { EditorWrapper, Layout } from "/wrapper/pkg/graphite_wasm_wrapper"; + import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; const keyboardLockApiSupported = navigator.keyboard !== undefined && "lock" in navigator.keyboard; @@ -19,7 +20,7 @@ const fullscreen = getContext("fullscreen"); const tooltip = getContext("tooltip"); - let menuBarLayout: Layout = []; + const menuBarLayout = createLayout(); $: showFullscreenButton = $appWindow.platform === "Web" || $fullscreen.windowFullscreen || (import.meta.env.MODE === "native" && $appWindow.fullscreen); $: isFullscreen = import.meta.env.MODE === "native" ? $appWindow.fullscreen : $fullscreen.windowFullscreen; @@ -29,7 +30,6 @@ onMount(() => { subscriptions.subscribeLayoutUpdate("MenuBar", (data) => { patchLayout(menuBarLayout, data); - menuBarLayout = menuBarLayout; }); }); diff --git a/frontend/src/stores/document.ts b/frontend/src/stores/document.ts index 905c481bd54..cf753e4edf6 100644 --- a/frontend/src/stores/document.ts +++ b/frontend/src/stores/document.ts @@ -1,7 +1,8 @@ import { tick } from "svelte"; -import { writable } from "svelte/store"; +import { get, writable } from "svelte/store"; import type { Writable } from "svelte/store"; import type { SubscriptionsRouter } from "/src/subscriptions-router"; +import { createLayout } from "/src/utility-functions/layout-state.svelte"; import { patchLayout } from "/src/utility-functions/widgets"; import type { Layout, RenderStatus } from "/wrapper/pkg/graphite_wasm_wrapper"; @@ -18,11 +19,11 @@ type DocumentStoreState = { renderStatus: RenderStatus; }; const initialState: DocumentStoreState = { - toolOptionsLayout: [], - documentBarLayout: [], - toolShelfLayout: [], - workingColorsLayout: [], - nodeGraphControlBarLayout: [], + toolOptionsLayout: createLayout(), + documentBarLayout: createLayout(), + toolShelfLayout: createLayout(), + workingColorsLayout: createLayout(), + nodeGraphControlBarLayout: createLayout(), graphViewOverlayOpen: false, fadeArtwork: 100, renderStatus: "Rendering", @@ -50,46 +51,31 @@ export function createDocumentStore(subscriptions: SubscriptionsRouter) { subscriptions.subscribeLayoutUpdate("ToolOptions", async (data) => { await tick(); - update((state) => { - patchLayout(state.toolOptionsLayout, data); - return state; - }); + patchLayout(get(store).toolOptionsLayout, data); }); subscriptions.subscribeLayoutUpdate("DocumentBar", async (data) => { await tick(); - update((state) => { - patchLayout(state.documentBarLayout, data); - return state; - }); + patchLayout(get(store).documentBarLayout, data); }); subscriptions.subscribeLayoutUpdate("ToolShelf", async (data) => { await tick(); - update((state) => { - patchLayout(state.toolShelfLayout, data); - return state; - }); + patchLayout(get(store).toolShelfLayout, data); }); subscriptions.subscribeLayoutUpdate("WorkingColors", async (data) => { await tick(); - update((state) => { - patchLayout(state.workingColorsLayout, data); - return state; - }); + patchLayout(get(store).workingColorsLayout, data); }); subscriptions.subscribeLayoutUpdate("NodeGraphControlBar", async (data) => { await tick(); - update((state) => { - patchLayout(state.nodeGraphControlBarLayout, data); - return state; - }); + patchLayout(get(store).nodeGraphControlBarLayout, data); }); subscriptions.subscribeFrontendMessage("UpdateGraphViewOverlay", (data) => { diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index acff69036a9..531f8222fe6 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -4,6 +4,7 @@ import { writable } from "svelte/store"; import type { Writable } from "svelte/store"; import type { SubscriptionsRouter } from "/src/subscriptions-router"; import { acceptStringFromFilters, downloadFile, upload } from "/src/utility-functions/files"; +import { createLayout } from "/src/utility-functions/layout-state.svelte"; import { rasterizeSVGCanvas } from "/src/utility-functions/rasterization"; import { patchLayout } from "/src/utility-functions/widgets"; import type { EditorWrapper, DocumentInfo, LayerPanelEntry, LayerStructureEntry, Layout, WorkspacePanelLayout } from "/wrapper/pkg/graphite_wasm_wrapper"; @@ -37,28 +38,20 @@ const store: Writable = import.meta.hot?.data?.store || wri if (import.meta.hot) import.meta.hot.data.store = store; const { subscribe, update } = store; -export const welcomeScreenButtonsLayout = makeLayoutStore("welcomeScreenButtonsLayout"); -export const inspectorLayout = makeLayoutStore("inspectorLayout"); -export const dataPanelLayout = makeLayoutStore("dataPanelLayout"); -export const layersPanelControlBarLeftLayout = makeLayoutStore("layersPanelControlBarLeftLayout"); -export const layersPanelBottomBarLeftLayout = makeLayoutStore("layersPanelBottomBarLeftLayout"); -export const layersPanelBottomBarRightLayout = makeLayoutStore("layersPanelBottomBarRightLayout"); +export const welcomeScreenButtonsLayout = persistedLayout("welcomeScreenButtonsLayout"); +export const inspectorLayout = persistedLayout("inspectorLayout"); +export const dataPanelLayout = persistedLayout("dataPanelLayout"); +export const layersPanelControlBarLeftLayout = persistedLayout("layersPanelControlBarLeftLayout"); +export const layersPanelBottomBarLeftLayout = persistedLayout("layersPanelBottomBarLeftLayout"); +export const layersPanelBottomBarRightLayout = persistedLayout("layersPanelBottomBarRightLayout"); -// Each panel layout has its own dedicated store so a layout update only re-renders that panel's consumers. +// Each panel layout is its own reactive layout, patched in place so an update only re-renders the widgets it changes. // Putting them at module scope (not inside the component) lets them survive a Svelte remount during a // panel-tree restructure, since the backend's diff-based updates aren't re-sent on subscribe. -function makeLayoutStore(name: string): Writable { - const persisted = import.meta.hot?.data?.[name]; - const layoutStore: Writable = persisted || writable([]); - if (import.meta.hot) import.meta.hot.data[name] = layoutStore; - return layoutStore; -} - -function patchLayoutStore(layoutStore: Writable, data: Parameters[1]) { - layoutStore.update((layout) => { - patchLayout(layout, data); - return layout; - }); +function persistedLayout(name: string): Layout { + const layout: Layout = import.meta.hot?.data?.[name] || createLayout(); + if (import.meta.hot) import.meta.hot.data[name] = layout; + return layout; } export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: EditorWrapper) { @@ -132,35 +125,34 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: }); }); - // Each panel layout uses its own store so updates only re-render that panel's consumers subscriptions.subscribeLayoutUpdate("WelcomeScreenButtons", async (data) => { await tick(); - patchLayoutStore(welcomeScreenButtonsLayout, data); + patchLayout(welcomeScreenButtonsLayout, data); }); subscriptions.subscribeLayoutUpdate("Inspector", async (data) => { await tick(); - patchLayoutStore(inspectorLayout, data); + patchLayout(inspectorLayout, data); }); subscriptions.subscribeLayoutUpdate("DataPanel", async (data) => { await tick(); - patchLayoutStore(dataPanelLayout, data); + patchLayout(dataPanelLayout, data); }); subscriptions.subscribeLayoutUpdate("LayersPanelControlLeftBar", async (data) => { await tick(); - patchLayoutStore(layersPanelControlBarLeftLayout, data); + patchLayout(layersPanelControlBarLeftLayout, data); }); subscriptions.subscribeLayoutUpdate("LayersPanelBottomLeftBar", async (data) => { await tick(); - patchLayoutStore(layersPanelBottomBarLeftLayout, data); + patchLayout(layersPanelBottomBarLeftLayout, data); }); subscriptions.subscribeLayoutUpdate("LayersPanelBottomRightBar", async (data) => { await tick(); - patchLayoutStore(layersPanelBottomBarRightLayout, data); + patchLayout(layersPanelBottomBarRightLayout, data); }); subscriptions.subscribeFrontendMessage("UpdateDocumentLayerStructure", (data) => { diff --git a/frontend/src/utility-functions/layout-state.svelte.ts b/frontend/src/utility-functions/layout-state.svelte.ts new file mode 100644 index 00000000000..2d0ca0ee464 --- /dev/null +++ b/frontend/src/utility-functions/layout-state.svelte.ts @@ -0,0 +1,7 @@ +import type { Layout } from "/wrapper/pkg/graphite_wasm_wrapper"; + +// A layout held in a deeply reactive proxy, so `patchLayout` changing it in place re-runs only the effects that read the changed widgets +export function createLayout(): Layout { + const layout = $state([]); + return layout; +} diff --git a/frontend/src/utility-functions/widgets.ts b/frontend/src/utility-functions/widgets.ts index e47726eb9b9..1ad5c8598ad 100644 --- a/frontend/src/utility-functions/widgets.ts +++ b/frontend/src/utility-functions/widgets.ts @@ -21,6 +21,7 @@ export function styleText(styles: Record): string | undefined { } type UIItem = Layout | LayoutGroup | WidgetInstance[][] | WidgetInstance[] | WidgetInstance; + // Updates a widget layout based on a list of updates, giving the new layout by mutating the `layout` argument export function patchLayout(layout: /* &mut */ Layout, diffs: WidgetDiff[]) { diffs.forEach((update) => { @@ -32,46 +33,71 @@ export function patchLayout(layout: /* &mut */ Layout, diffs: WidgetDiff[]) { else if ("widget" in newValue) newContent = newValue.widget; else throw new Error("DiffUpdate invalid"); - // Find the object where the diff applies to - const diffObject = update.widgetPath.reduce((targetLayout, index: bigint): UIItem | undefined => { - const i = Number(index); - - if (targetLayout && "Column" in targetLayout) return targetLayout.Column.columnWidgets[i]; - if (targetLayout && "Row" in targetLayout) return targetLayout.Row.rowWidgets[i]; - if (targetLayout && "Table" in targetLayout) return targetLayout.Table.tableWidgets[i]; - if (targetLayout && "NodeSection" in targetLayout) return targetLayout.NodeSection.layout[i]; - if (targetLayout && "widget" in targetLayout && "widgetId" in targetLayout) { - if ("PopoverButton" in targetLayout.widget && targetLayout.widget.PopoverButton.popoverLayout) { - return targetLayout.widget.PopoverButton.popoverLayout[i]; - } - // eslint-disable-next-line no-console - console.error("Tried to index widget"); - return targetLayout; - } + // An empty path updates the whole layout + const path = update.widgetPath.map(Number); + const lastIndex = path.pop(); + if (lastIndex === undefined) { + if (isPlainContainer(newContent)) reconcile(layout, newContent); + return; + } - return targetLayout?.[i]; - }, layout); + // Find the list holding the item the diff applies to + const parent = path.reduce((list: UIItem[] | undefined, index) => indexedChildren(list?.[index]), indexedChildren(layout)); // Exit if we failed to produce a valid patch for the existing layout. // This means that the backend assumed an existing layout that doesn't exist in the frontend. This can happen, for // example, if a panel is destroyed in the frontend but was never cleared in the backend, so the next time the backend // tries to update the layout, it attempts to insert only the changes against the old layout that no longer exists. - if (diffObject === undefined) { + if (parent?.[lastIndex] === undefined) { // eslint-disable-next-line no-console - console.error("In `patchLayout`, the `diffObject` is undefined. The layout has not been updated. See the source code comment above this error for hints."); + console.error("In `patchLayout`, the diff's target is undefined. The layout has not been updated. See the source code comment above this error for hints."); return; } - // If this is a list with a length, then set the length to 0 to clear the list - if ("length" in diffObject) { - diffObject.length = 0; - } - // Remove all of the keys from the old object - Object.keys(diffObject).forEach((key) => Reflect.deleteProperty(diffObject, key)); - - // Assign keys to the new object - // `Object.assign` works but `diffObject = update.newValue;` doesn't. - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign - Object.assign(diffObject, newContent); + reconcileEntry(parent, lastIndex, newContent); }); } + +// The list that an index in a widget path selects from within the given layout item +function indexedChildren(item: UIItem | undefined): UIItem[] | undefined { + if (!item) return undefined; + if (Array.isArray(item)) return item; + if ("Column" in item) return item.Column.columnWidgets; + if ("Row" in item) return item.Row.rowWidgets; + if ("Table" in item) return item.Table.tableWidgets; + if ("NodeSection" in item) return item.NodeSection.layout; + if ("PopoverButton" in item.widget) return item.widget.PopoverButton.popoverLayout; + return undefined; +} + +// Whether the value is a plain object or array, the only kinds a layout is built from +function isPlainContainer(value: unknown): value is object { + if (typeof value !== "object" || !value) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === Array.prototype; +} + +// Sets `container[key]` to `value`, changing the existing value in place where it keeps its shape so a reactive layout only invalidates what changed. +// A value that changes shape (like a widget changing kind) is replaced whole, so readers never see an object half-changed. +function reconcileEntry(container: object, key: PropertyKey, value: unknown) { + const existing: unknown = Reflect.get(container, key); + + if (isPlainContainer(existing) && isPlainContainer(value) && haveSameShape(existing, value)) { + reconcile(existing, value); + } else if (!Object.is(existing, value)) { + Reflect.set(container, key, value); + } +} + +// Changes `target` in place to deeply equal `source`, which has the same keys unless both are arrays +function reconcile(target: object, source: object) { + if (Array.isArray(target) && Array.isArray(source) && target.length > source.length) target.length = source.length; + Object.entries(source).forEach(([key, value]) => reconcileEntry(target, key, value)); +} + +function haveSameShape(first: object, second: object): boolean { + if (Array.isArray(first) || Array.isArray(second)) return Array.isArray(first) && Array.isArray(second); + + const firstKeys = Object.keys(first); + return firstKeys.length === Object.keys(second).length && firstKeys.every((key) => Object.hasOwn(second, key)); +} From e973a0af62f9c42a3188299a447faab265b870d9 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Sat, 24 Oct 2026 11:56:22 +0200 Subject: [PATCH 2/4] Parse theme colors once and reuse one canvas for CSS color parsing --- frontend/src/utility-functions/colors.ts | 28 +++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/frontend/src/utility-functions/colors.ts b/frontend/src/utility-functions/colors.ts index 362ec5297c0..37900a4af31 100644 --- a/frontend/src/utility-functions/colors.ts +++ b/frontend/src/utility-functions/colors.ts @@ -31,6 +31,27 @@ export function isSRgba8(value: unknown): value is SRGBA8 { return !!value && typeof value === "object" && "red" in value; } +let colorParsingCanvasContext: CanvasRenderingContext2D | undefined; + +// The 1x1 canvas context that color parsing reuses +function colorParsingContext(): CanvasRenderingContext2D | undefined { + if (colorParsingCanvasContext) return colorParsingCanvasContext; + + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + colorParsingCanvasContext = canvas.getContext("2d", { willReadFrequently: true }) || undefined; + return colorParsingCanvasContext; +} + +// The theme colors by CSS variable name, read once since reading computed styles forces a style recalculation +const themeColors = new Map(); + +function themeColor(variableName: string): SRGBA8 | undefined { + if (!themeColors.has(variableName)) themeColors.set(variableName, sRgba8FromCSS(window.getComputedStyle(document.body).getPropertyValue(variableName))); + return themeColors.get(variableName); +} + // Parse a CSS color string into an `SRGBA8`. Uses a canvas to delegate parsing to the browser. export function sRgba8FromCSS(colorCode: string): SRGBA8 | undefined { // Allow single-digit hex value inputs @@ -40,10 +61,7 @@ export function sRgba8FromCSS(colorCode: string): SRGBA8 | undefined { colorValue = `#${digit}${digit}${digit}`; } - const canvas = document.createElement("canvas"); - canvas.width = 1; - canvas.height = 1; - const context = canvas.getContext("2d", { willReadFrequently: true }); + const context = colorParsingContext(); if (!context) return undefined; context.clearRect(0, 0, 1, 1); @@ -152,7 +170,7 @@ export function sRgba8ContrastingColor(color: SRGBA8 | undefined): "black" | "wh export function contrastingOutlineFactor(value: FillChoice, proximityColor: string | [string, string], proximityRange: number): number { const pair = Array.isArray(proximityColor) ? [proximityColor[0], proximityColor[1]] : [proximityColor, proximityColor]; - const [range1, range2] = pair.map((color) => sRgba8FromCSS(window.getComputedStyle(document.body).getPropertyValue(color))); + const [range1, range2] = pair.map(themeColor); const contrast = (color: SRGBA8 | undefined): number => { if (!color) return 0; From b68810b20c23d5d2c978279a1e7fe6badadbcba8 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Sat, 24 Oct 2026 12:15:00 +0200 Subject: [PATCH 3/4] Apply layout, ruler, scrollbar and cursor updates without waiting a tick so they share the frame's flush --- .../src/components/panels/Document.svelte | 12 +++-------- frontend/src/stores/dialog.ts | 13 +++--------- frontend/src/stores/document.ts | 21 +++++-------------- frontend/src/stores/portfolio.ts | 19 ++++++----------- 4 files changed, 17 insertions(+), 48 deletions(-) diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index 892509c9614..8df677a5962 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -500,23 +500,17 @@ }); // Update scrollbars and rulers - subscriptions.subscribeFrontendMessage("UpdateDocumentScrollbars", async (data) => { - await tick(); - + subscriptions.subscribeFrontendMessage("UpdateDocumentScrollbars", (data) => { const { position, size, multiplier } = data; updateDocumentScrollbars(position, size, multiplier); }); - subscriptions.subscribeFrontendMessage("UpdateDocumentRulers", async (data) => { - await tick(); - + subscriptions.subscribeFrontendMessage("UpdateDocumentRulers", (data) => { const { origin, spacing, interval, visible, tilt, flip, selectionQuad } = data; updateDocumentRulers(origin, spacing, interval, visible, tilt, flip, selectionQuad || undefined); }); // Update mouse cursor icon - subscriptions.subscribeFrontendMessage("UpdateMouseCursor", async (data) => { - await tick(); - + subscriptions.subscribeFrontendMessage("UpdateMouseCursor", (data) => { updateMouseCursor(data.cursor); }); diff --git a/frontend/src/stores/dialog.ts b/frontend/src/stores/dialog.ts index dbc4145b612..9198034d630 100644 --- a/frontend/src/stores/dialog.ts +++ b/frontend/src/stores/dialog.ts @@ -1,4 +1,3 @@ -import { tick } from "svelte"; import { writable } from "svelte/store"; import type { Writable } from "svelte/store"; import type { IconName } from "/src/icons"; @@ -51,9 +50,7 @@ export function createDialogStore(subscriptions: SubscriptionsRouter, editor: Ed }); }); - subscriptions.subscribeLayoutUpdate("DialogButtons", async (data) => { - await tick(); - + subscriptions.subscribeLayoutUpdate("DialogButtons", (data) => { update((state) => { patchLayout(state.buttons, data); @@ -61,9 +58,7 @@ export function createDialogStore(subscriptions: SubscriptionsRouter, editor: Ed }); }); - subscriptions.subscribeLayoutUpdate("DialogColumn1", async (data) => { - await tick(); - + subscriptions.subscribeLayoutUpdate("DialogColumn1", (data) => { update((state) => { patchLayout(state.column1, data); @@ -71,9 +66,7 @@ export function createDialogStore(subscriptions: SubscriptionsRouter, editor: Ed }); }); - subscriptions.subscribeLayoutUpdate("DialogColumn2", async (data) => { - await tick(); - + subscriptions.subscribeLayoutUpdate("DialogColumn2", (data) => { update((state) => { patchLayout(state.column2, data); diff --git a/frontend/src/stores/document.ts b/frontend/src/stores/document.ts index cf753e4edf6..b67ec21ecbf 100644 --- a/frontend/src/stores/document.ts +++ b/frontend/src/stores/document.ts @@ -1,4 +1,3 @@ -import { tick } from "svelte"; import { get, writable } from "svelte/store"; import type { Writable } from "svelte/store"; import type { SubscriptionsRouter } from "/src/subscriptions-router"; @@ -48,33 +47,23 @@ export function createDocumentStore(subscriptions: SubscriptionsRouter) { }); }); - subscriptions.subscribeLayoutUpdate("ToolOptions", async (data) => { - await tick(); - + subscriptions.subscribeLayoutUpdate("ToolOptions", (data) => { patchLayout(get(store).toolOptionsLayout, data); }); - subscriptions.subscribeLayoutUpdate("DocumentBar", async (data) => { - await tick(); - + subscriptions.subscribeLayoutUpdate("DocumentBar", (data) => { patchLayout(get(store).documentBarLayout, data); }); - subscriptions.subscribeLayoutUpdate("ToolShelf", async (data) => { - await tick(); - + subscriptions.subscribeLayoutUpdate("ToolShelf", (data) => { patchLayout(get(store).toolShelfLayout, data); }); - subscriptions.subscribeLayoutUpdate("WorkingColors", async (data) => { - await tick(); - + subscriptions.subscribeLayoutUpdate("WorkingColors", (data) => { patchLayout(get(store).workingColorsLayout, data); }); - subscriptions.subscribeLayoutUpdate("NodeGraphControlBar", async (data) => { - await tick(); - + subscriptions.subscribeLayoutUpdate("NodeGraphControlBar", (data) => { patchLayout(get(store).nodeGraphControlBarLayout, data); }); diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index 531f8222fe6..107dc1ff841 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -1,4 +1,3 @@ -import { tick } from "svelte"; import { SvelteMap } from "svelte/reactivity"; import { writable } from "svelte/store"; import type { Writable } from "svelte/store"; @@ -125,33 +124,27 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: }); }); - subscriptions.subscribeLayoutUpdate("WelcomeScreenButtons", async (data) => { - await tick(); + subscriptions.subscribeLayoutUpdate("WelcomeScreenButtons", (data) => { patchLayout(welcomeScreenButtonsLayout, data); }); - subscriptions.subscribeLayoutUpdate("Inspector", async (data) => { - await tick(); + subscriptions.subscribeLayoutUpdate("Inspector", (data) => { patchLayout(inspectorLayout, data); }); - subscriptions.subscribeLayoutUpdate("DataPanel", async (data) => { - await tick(); + subscriptions.subscribeLayoutUpdate("DataPanel", (data) => { patchLayout(dataPanelLayout, data); }); - subscriptions.subscribeLayoutUpdate("LayersPanelControlLeftBar", async (data) => { - await tick(); + subscriptions.subscribeLayoutUpdate("LayersPanelControlLeftBar", (data) => { patchLayout(layersPanelControlBarLeftLayout, data); }); - subscriptions.subscribeLayoutUpdate("LayersPanelBottomLeftBar", async (data) => { - await tick(); + subscriptions.subscribeLayoutUpdate("LayersPanelBottomLeftBar", (data) => { patchLayout(layersPanelBottomBarLeftLayout, data); }); - subscriptions.subscribeLayoutUpdate("LayersPanelBottomRightBar", async (data) => { - await tick(); + subscriptions.subscribeLayoutUpdate("LayersPanelBottomRightBar", (data) => { patchLayout(layersPanelBottomBarRightLayout, data); }); From a778606976b964efde631d74a67f4d762a11dd18 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Sat, 24 Oct 2026 12:23:55 +0200 Subject: [PATCH 4/4] Skip layer structure updates that match the current structure instead of rebuilding the Layers panel --- frontend/src/stores/portfolio.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index 107dc1ff841..625b08f3b72 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -1,5 +1,5 @@ import { SvelteMap } from "svelte/reactivity"; -import { writable } from "svelte/store"; +import { get, writable } from "svelte/store"; import type { Writable } from "svelte/store"; import type { SubscriptionsRouter } from "/src/subscriptions-router"; import { acceptStringFromFilters, downloadFile, upload } from "/src/utility-functions/files"; @@ -149,6 +149,9 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: }); subscriptions.subscribeFrontendMessage("UpdateDocumentLayerStructure", (data) => { + // The backend resends the structure whenever the graph changes, so an unchanged one is skipped instead of rebuilding the Layers panel + if (deepEqual(get(store).layerStructure, data.layerStructure)) return; + update((state) => { state.layerStructure = data.layerStructure; return state; @@ -168,6 +171,17 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: return { subscribe }; } +// Whether two values built from plain objects, arrays and primitives are structurally equal +function deepEqual(first: unknown, second: unknown): boolean { + if (Object.is(first, second)) return true; + if (typeof first !== "object" || typeof second !== "object" || !first || !second) return false; + if (Array.isArray(first) !== Array.isArray(second)) return false; + + const firstEntries = Object.entries(first); + if (firstEntries.length !== Object.keys(second).length) return false; + return firstEntries.every(([key, value]) => Object.hasOwn(second, key) && deepEqual(value, Reflect.get(second, key))); +} + // Keeps only the given layers' details (by ID string), the only ones the backend keeps current, or none if those kept were for another document export function retainLayerDetails(layerIds: Set, otherDocument: boolean) { layersWithDetails = layerIds;