Improve web frontend performance during zooming and panning (#3337)

* WIP debounce frontend ui updates

* Reduce the number of frontend updates performed

* Improve menu bar diffing

* Cleanup in dispatcher

* Fix comment
This commit is contained in:
Dennis Kobert 2025-11-08 14:01:11 +01:00 committed by GitHub
parent 9be207f4c5
commit 72a291d808
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 72 additions and 10 deletions

View File

@ -8,6 +8,7 @@ use crate::messages::prelude::*;
pub struct Dispatcher {
message_queues: Vec<VecDeque<Message>>,
pub responses: Vec<FrontendMessage>,
pub frontend_update_messages: Vec<Message>,
pub message_handlers: DispatcherMessageHandlers,
}
@ -42,19 +43,24 @@ impl DispatcherMessageHandlers {
/// The last occurrence of the message in the message queue is sufficient to ensure correct behavior.
/// In addition, these messages do not change any state in the backend (aside from caches).
const SIDE_EFFECT_FREE_MESSAGES: &[MessageDiscriminant] = &[
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::Document(DocumentMessageDiscriminant::PropertiesPanel(
PropertiesPanelMessageDiscriminant::Refresh,
))),
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::Document(DocumentMessageDiscriminant::DocumentStructureChanged)),
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::Document(DocumentMessageDiscriminant::Overlays(OverlaysMessageDiscriminant::Draw))),
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::Document(DocumentMessageDiscriminant::NodeGraph(
NodeGraphMessageDiscriminant::RunDocumentGraph,
))),
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::SubmitActiveGraphRender),
MessageDiscriminant::Frontend(FrontendMessageDiscriminant::TriggerFontLoad),
];
/// Since we don't need to update the frontend multiple times per frame,
/// we have a set of messages which we will buffer until the next frame is requested.
const FRONTEND_UPDATE_MESSAGES: &[MessageDiscriminant] = &[
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::Document(DocumentMessageDiscriminant::PropertiesPanel(
PropertiesPanelMessageDiscriminant::Refresh,
))),
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::UpdateDocumentWidgets),
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::Document(DocumentMessageDiscriminant::Overlays(OverlaysMessageDiscriminant::Draw))),
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::Document(DocumentMessageDiscriminant::RenderRulers)),
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::Document(DocumentMessageDiscriminant::RenderScrollbars)),
MessageDiscriminant::Frontend(FrontendMessageDiscriminant::UpdateDocumentLayerStructure),
MessageDiscriminant::Frontend(FrontendMessageDiscriminant::TriggerFontLoad),
];
const DEBUG_MESSAGE_BLOCK_LIST: &[MessageDiscriminant] = &[
MessageDiscriminant::Broadcast(BroadcastMessageDiscriminant::TriggerEvent(EventMessageDiscriminant::AnimationFrame)),
@ -105,6 +111,19 @@ impl Dispatcher {
while let Some(message) = self.message_queues.last_mut().and_then(VecDeque::pop_front) {
// Skip processing of this message if it will be processed later (at the end of the shallowest level queue)
if FRONTEND_UPDATE_MESSAGES.contains(&message.to_discriminant()) {
let already_in_queue = self.message_queues.first().is_some_and(|queue| queue.contains(&message));
if already_in_queue {
self.cleanup_queues(false);
continue;
} else if self.message_queues.len() > 1 {
if !self.frontend_update_messages.contains(&message) {
self.frontend_update_messages.push(message);
}
self.cleanup_queues(false);
continue;
}
}
if SIDE_EFFECT_FREE_MESSAGES.contains(&message.to_discriminant()) {
let already_in_queue = self.message_queues.first().filter(|queue| queue.contains(&message)).is_some();
if already_in_queue {
@ -128,6 +147,9 @@ impl Dispatcher {
// Process the action by forwarding it to the relevant message handler, or saving the FrontendMessage to be sent to the frontend
match message {
Message::Animation(message) => {
if let AnimationMessage::IncrementFrameCounter = &message {
self.message_queues[0].extend(self.frontend_update_messages.drain(..));
}
self.message_handlers.animation_message_handler.process_message(message, &mut queue, ());
}
Message::AppWindow(message) => {

View File

@ -484,13 +484,19 @@ impl LayoutGroup {
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)]
pub struct WidgetHolder {
#[serde(rename = "widgetId")]
pub widget_id: WidgetId,
pub widget: Widget,
}
impl PartialEq for WidgetHolder {
fn eq(&self, other: &Self) -> bool {
self.widget == other.widget
}
}
impl WidgetHolder {
#[deprecated(since = "0.0.0", note = "Please use the builder pattern, e.g. TextLabel::new(\"hello\").widget_holder()")]
pub fn new(widget: Widget) -> Self {
@ -502,6 +508,26 @@ impl WidgetHolder {
/// Diffing updates self (where self is old) based on new, updating the list of modifications as it does so.
pub fn diff(&mut self, new: Self, widget_path: &mut [usize], widget_diffs: &mut Vec<WidgetDiff>) {
if let (Widget::PopoverButton(button1), Widget::PopoverButton(button2)) = (&mut self.widget, &new.widget) {
if button1.disabled == button2.disabled
&& button1.style == button2.style
&& button1.menu_direction == button2.menu_direction
&& button1.icon == button2.icon
&& button1.tooltip == button2.tooltip
&& button1.tooltip_shortcut == button2.tooltip_shortcut
&& button1.popover_min_width == button2.popover_min_width
{
let mut new_widget_path = widget_path.to_vec();
for (i, (a, b)) in button1.popover_layout.iter_mut().zip(button2.popover_layout.iter()).enumerate() {
new_widget_path.push(i);
a.diff(b.clone(), &mut new_widget_path, widget_diffs);
new_widget_path.pop();
}
self.widget = new.widget;
return;
}
}
// If there have been changes to the actual widget (not just the id)
if self.widget != new.widget {
// We should update to the new widget value as well as a new widget id

View File

@ -19,6 +19,7 @@ pub struct CheckboxInput {
pub tooltip: String,
#[serde(rename = "forLabel")]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub for_label: CheckboxId,
#[serde(skip)]

View File

@ -36,7 +36,8 @@ pub enum SeparatorType {
Section,
}
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, Debug, PartialEq, Eq, Default, WidgetBuilder, specta::Type)]
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, Debug, Eq, Default, WidgetBuilder, specta::Type)]
#[derivative(PartialEq)]
pub struct TextLabel {
pub disabled: bool,
@ -62,6 +63,7 @@ pub struct TextLabel {
pub tooltip: String,
#[serde(rename = "forCheckbox")]
#[derivative(PartialEq = "ignore")]
pub for_checkbox: CheckboxId,
// Body

View File

@ -5,8 +5,8 @@ use graph_craft::document::value::{RenderOutput, TaggedValue};
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput};
use graph_craft::proto::GraphErrors;
use graph_craft::wasm_application_io::EditorPreferences;
use graphene_std::application_io::TimingInformation;
use graphene_std::application_io::{NodeGraphUpdateMessage, RenderConfig};
use graphene_std::application_io::{SurfaceFrame, TimingInformation};
use graphene_std::renderer::{RenderMetadata, format_transform_matrix};
use graphene_std::text::FontCache;
use graphene_std::transform::Footprint;
@ -54,6 +54,7 @@ pub struct NodeGraphExecutor {
futures: VecDeque<(u64, ExecutionContext)>,
node_graph_hash: u64,
previous_node_to_inspect: Option<NodeId>,
last_svg_canvas: Option<SurfaceFrame>,
}
#[derive(Debug, Clone)]
@ -76,6 +77,7 @@ impl NodeGraphExecutor {
node_graph_hash: 0,
current_execution_id: 0,
previous_node_to_inspect: None,
last_svg_canvas: None,
};
(node_runtime, node_executor)
}
@ -413,14 +415,19 @@ impl NodeGraphExecutor {
// Send to frontend
responses.add(FrontendMessage::UpdateImageData { image_data });
responses.add(FrontendMessage::UpdateDocumentArtwork { svg });
self.last_svg_canvas = None;
}
RenderOutputType::CanvasFrame(frame) => {
RenderOutputType::CanvasFrame(frame) => 'block: {
if self.last_svg_canvas == Some(frame) {
break 'block;
}
let matrix = format_transform_matrix(frame.transform);
let transform = if matrix.is_empty() { String::new() } else { format!(" transform=\"{matrix}\"") };
let svg = format!(
r#"<svg><foreignObject width="{}" height="{}"{transform}><div data-canvas-placeholder="{}"></div></foreignObject></svg>"#,
frame.resolution.x, frame.resolution.y, frame.surface_id.0
);
self.last_svg_canvas = Some(frame);
responses.add(FrontendMessage::UpdateDocumentArtwork { svg });
}
RenderOutputType::Texture { .. } => {}

View File

@ -1045,8 +1045,12 @@ async fn poll_node_graph_evaluation() {
crate::NODE_GRAPH_ERROR_DISPLAYED.store(false, Ordering::SeqCst);
}
// Batch responses to pool frontend updates
let batched = Message::Batched {
messages: messages.into_iter().collect(),
};
// Send each `FrontendMessage` to the JavaScript frontend
for response in messages.into_iter().flat_map(|message| editor.handle_message(message)) {
for response in editor.handle_message(batched) {
handle.send_frontend_message_to_js(response);
}