Substantial performance boost upgrades over 6 files.#759
Open
Foly93 wants to merge 1 commit into
Open
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Sozi Performance Optimization Report (Generated from diff between original repo and performance-optimized repo)
SUMMARY OF PERFORMANCE IMPACT
All changes fall into three categories:
O(n) → O(1) lookups (Selection.js, Controller.js, Timeline.js)
Replace linear Array.indexOf() scans with Set.has() for membership checks.
This is the dominant optimization since these checks happen on every user
interaction and scale linearly with presentation size.
Skip redundant work (Presentation.js, Camera.js)
Both avoid unnecessary CPU/DOM work that grows with presentation complexity.
Coalescing/throttling (VirtualDOMView.js, Timeline.js)
These prevent wasted work during rapid-fire events.
TABLE OF CONTENTS
1. src/js/model/Selection.js
DIFF:
--- a/src/js/model/Selection.js
+++ b/src/js/model/Selection.js
@@ -27,14 +27,46 @@ Replace public arrays with private backing arrays (_selectedFrames, _selectedLayers) and add parallel Set instances (_selectedFrameSet, _selectedLayerSet) for O(1) lookups. Add getter/setter accessors that auto-rebuild the Set when the array is replaced wholesale.
@@ -46,8 +78,8 @@ Update toStorable() to reference the private backing arrays instead of public arrays (which are now getter-only).
@@ -57,21 +89,25 @@ Update fromStorable() to populate the Set instances in parallel with the arrays, keeping both data structures in sync.
@@ -82,8 +118,8 @@ Update currentFrame getter to use private backing array.
@@ -93,7 +129,16 @@ Convert hasFrames() from O(n) indexOf to O(1) Set.has(). Add new hasFrame() single-element method to avoid temporary array allocation when checking one frame.
@@ -101,8 +146,9 @@ Convert addFrame() to use Set.has() for the duplicate check (O(1) instead of O(n)), and add the element to the Set alongside the array.
@@ -111,9 +157,11 @@ Convert removeFrame() to use Set.delete() for O(1) existence check before splicing the array.
@@ -125,12 +173,16 @@ Convert toggleFrameSelection() to use Set.has()/Set.delete() for O(1) membership checks instead of indexOf/splice.
@@ -140,7 +192,16 @@ Convert hasLayers() to Set.has() for O(1) lookups and add hasLayer() single-element method to avoid array wrapping overhead.
@@ -148,8 +209,9 @@ Convert addLayer() to use Set.has() for the duplicate check (O(1) instead of O(n)), and add the layer to the Set alongside the array.
@@ -158,9 +220,11 @@ Convert removeLayer() to use Set.delete() for O(1) existence check before splicing the array.
@@ -172,12 +236,16 @@ Convert toggleLayerSelection() to use Set.has()/Set.delete() for O(1) membership checks instead of indexOf/splice.
DESCRIPTION:
The public arrays
selectedFramesandselectedLayerswere replaced with private backing arrays (_selectedFrames,_selectedLayers) with getter/setter accessors. Two newSetinstances (_selectedFrameSet,_selectedLayerSet) were added to maintain parallel O(1) lookup structures. New single-element query methodshasFrame(frame)andhasLayer(layer)were introduced alongside the existing multi-elementhasFrames(frames)andhasLayers(layers). All internal membership checks (indexOf) were replaced withSet.has()calls.WHY CHANGED:
Array.indexOf()performs a linear scan of the entire array to find an element. With 100+ frames and layers being common in large presentations, every call is O(n) where n is the number of frames or layers. These membership checks are called on virtually every user interaction (click, drag, zoom, frame change), so the cost multiplies rapidly.HOW IT IMPROVES PERFORMANCE:
Set.has()is O(1) (constant time) vsArray.indexOf()O(n) (linear time)selectedFramesorselectedLayersis replaced wholesale, the Set is rebuilt automaticallyhasFrame(frame)andhasLayer(layer)methods avoid the overhead of creating a temporary single-element array and calling the multi-element variants (e.g.,hasLayers([layer]))2. src/js/Controller.js
DIFF:
--- a/src/js/Controller.js
+++ b/src/js/Controller.js
@@ -113,12 +113,24 @@ Add parallel Set instances (_editableLayerSet, _defaultLayerSet) for O(1) lookups alongside the editableLayers and defaultLayers arrays.
@@ -197,12 +209,14 @@ Convert fromStorable() to use Set.has() for O(1) duplicate check instead of indexOf(), and populate the Set alongside the array.
@@ -291,11 +305,13 @@ Cache this.selection.currentFrame to a local variable to avoid repeated deep property access in the loop. Add null guard to skip work when no frame is selected. Remove redundant comment.
@@ -337,9 +353,11 @@ Convert rebuildDefaultLayers() to use Set.has() for O(1) editable layer check, and populate _defaultLayerSet alongside the array.
@@ -564,7 +582,7 @@ Use hasFrame(frame) instead of hasFrames([frame]) to avoid wrapping a single frame in a temporary array and calling the multi-element variant.
@@ -603,7 +621,7 @@ Use hasLayer(layer) instead of hasLayers([layer]) to avoid temporary array allocation and eliminate the .every() iterator overhead.
@@ -622,13 +640,16 @@ Convert addLayer() to use Set.has()/Set.delete() for O(1) membership checks when adding to editableLayers and removing from defaultLayers.
@@ -657,9 +678,14 @@ Convert addAllLayers() to use Set.delete() for O(1) removal from default layers instead of indexOf/splice.
@@ -686,8 +712,12 @@ Convert removeLayer() to use Set.delete() for O(1) existence check before splicing from editableLayers array.
@@ -700,6 +730,7 @@ Add layer to _defaultLayerSet when it is moved back to default layers after being removed from editable set.
@@ -768,7 +799,7 @@ Use hasLayer(layer) instead of hasLayers([layer]) to avoid temporary array allocation.
@@ -786,7 +817,7 @@ Use hasLayer(layer) instead of hasLayers([layer]) to avoid temporary array allocation.
@@ -938,7 +969,7 @@ Use hasFrame(frame) instead of hasFrames([frame]) to avoid temporary array allocation.
@@ -1102,7 +1133,7 @@ Use hasFrame(frame) instead of hasFrames([frame]) to avoid temporary array allocation in the copy-paste properties path.
DESCRIPTION:
Added two
Setinstances parallel to theeditableLayersanddefaultLayersarrays. Every operation that modifies these arrays (addLayer, addAllLayers, removeLayer, fromStorable, rebuildDefaultLayers) now also updates the corresponding Set. All membership checks usingindexOf()are replaced withSet.has(). All calls toselection.hasLayers([layer])andselection.hasFrames([frame])(which wrap a single item in an array) are replaced with the newselection.hasLayer(layer)andselection.hasFrame(frame). Additionally,onFrameChange()cachesthis.selection.currentFrameto a local variable and adds an early return guard if it is null.WHY CHANGED:
The Controller is the central orchestrator and is invoked on every user action. It frequently checks whether layers are editable, whether they belong to the default set, and whether layers/frames are selected. Each such check previously required an O(n) linear scan. The single-element wrapper pattern (
hasLayers([layer])) also created a temporary array on every call, adding GC pressure.HOW IT IMPROVES PERFORMANCE:
_editableLayerSet.has(layer)is O(1) vseditableLayers.indexOf(layer)O(n)_defaultLayerSet.has(layer)is O(1) vsdefaultLayers.indexOf(layer)O(n)hasLayer(layer)instead ofhasLayers([layer])avoids creating a temporary array object and eliminates the.every()iterator overheadcurrentFrameinonFrameChange()avoids repeated deep property access throughthis.selection.currentFrameinside the loop bodyif (!currentFrame) return;prevents unnecessary work when no frame is selected3. src/js/model/Presentation.js
DIFF:
--- a/src/js/model/Presentation.js
+++ b/src/js/model/Presentation.js
@@ -1055,18 +1055,27 @@ Add optional layerIndices parameter to updateLinkedLayers() so callers can specify which layers to update. When omitted, auto-filter to only layers with linked frames. Early return if no layers need updating. Replace forEach with for...of loop.
@@ -1081,7 +1090,7 @@
DESCRIPTION:
The
updateLinkedLayers()method was modified to accept an optionallayerIndicesparameter. When called without arguments, it now computes the set of layer indices that actually have at least one linked frame (i.e.,frame.layerProperties[layerIndex].link === true) and only processes those. An early return was added if no layers need updating. The iteration was also changed fromArray.forEach()to afor...ofloop for better performance with the filtered array.WHY CHANGED:
updateLinkedLayers()is called extensively throughout Controller.js (18 call sites). In the original code, it iterated over ALL layers and for each layer iterated over ALL frames to find the last frame withlink === true. For a presentation with 100 layers and 100 frames, this means 10,000 iterations per call. While Sozi auto-links new frames to the previous frame by default (so most layers have at least one linked frame initially), seasoned users often unlink layers to reduce overhead in large presentations. The optimization naturally rewards this behavior — the more layers are explicitly unlinked, the fewer iterationsupdateLinkedLayers()performs.HOW IT IMPROVES PERFORMANCE:
this.frames.some(). This is an O(n*m) scan, BUT:layerIndicesis not provided (many callers could eventually be refactored to pass specific indices)layerIndicesdirectly, the filter step is skipped entirely, and only the specified layers are updatedif (!layersToUpdate.length) return;avoids all work when no layer has any linked framesfor...ofloop is faster thanArray.forEach()because it avoids the function call overhead per iteration4. src/js/player/Camera.js
DIFF:
--- a/src/js/player/Camera.js
+++ b/src/js/player/Camera.js
@@ -78,6 +78,18 @@ Add _stateVersion and _lastUpdatedVersion counters to track whether camera state has changed since the last DOM update.
@@ -227,6 +239,7 @@ Bump _stateVersion on rotate() to signal that the DOM needs updating.
@@ -244,6 +257,7 @@ Bump _stateVersion on zoom() to signal that the DOM needs updating.
@@ -270,6 +284,7 @@ Bump _stateVersion on translate() to signal that the DOM needs updating.
@@ -292,6 +307,7 @@ Bump _stateVersion on setClip() to signal that the DOM needs updating.
@@ -392,12 +408,25 @@ Add copy() method that bumps _stateVersion, and add early-return guard in update() that skips all DOM writes if state has not changed since last update.
@@ -530,5 +559,7 @@ Bump _stateVersion at the end of the animation interpolation to signal that the camera state has been modified and the DOM needs updating.
DESCRIPTION:
A dirty-state tracking mechanism was added to Camera:
_stateVersion: a monotonically increasing counter that is incremented every time the camera state is mutated (rotate, zoom, translate, setClip, copy, or interpolate/update during animation)_lastUpdatedVersion: stores the_stateVersionvalue that was last flushed to the DOM viaupdate()update()now compares the two at the start; if equal, it returns immediately without touching the DOMA
copy(state)method was added that delegates to the parent class'scopy()and then increments_stateVersion.WHY CHANGED:
SVG DOM manipulation (setting attributes on clipRect, mask, and SVG group elements) triggers layout recalculations in the browser and is one of the most expensive operations. During frame transitions, animations, or when multiple operations are applied in sequence,
update()could be called many times with the same state (e.g., if no camera change occurred between frames). Each call unconditionally wrote to the DOM.HOW IT IMPROVES PERFORMANCE:
translatethenupdatetwice) are collapsed into a single DOM write5. src/js/view/VirtualDOMView.js
DIFF:
--- a/src/js/view/VirtualDOMView.js
+++ b/src/js/view/VirtualDOMView.js
@@ -57,23 +57,39 @@ Refactor repaint() to use requestAnimationFrame with coalescing. Multiple calls within the same frame are collapsed into one render. Add afterRender() hook for subclasses to perform DOM work after the virtual DOM is committed.
DESCRIPTION:
The
repaint()method was refactored to userequestAnimationFrame()with coalescing. Instead of executing the virtual DOM render immediately on every call, it now schedules a single animation frame callback. Ifrepaint()is called again before that callback fires, the previous request is cancelled and rescheduled. A newafterRender()hook was added for subclasses to perform additional DOM work after the virtual DOM has been patched.WHY CHANGED:
During continuous user interactions like dragging a layer or resizing the window,
repaint()can be called many times within a single animation frame (e.g., from multiple event listeners). Each call triggered an immediate full virtual DOM tree diff and patch, most of which would never be visible because they would be overwritten by the next call before the browser painted.HOW IT IMPROVES PERFORMANCE:
repaint()calls within the same frame are collapsed into onecancelAnimationFrame()ensures only the latest state is rendered6. src/js/view/Timeline.js
DIFF:
--- a/src/js/view/Timeline.js
+++ b/src/js/view/Timeline.js
@@ -115,7 +115,10 @@ Move timeline quadrant scroll-sync logic from repaint() to afterRender() hook, so layout adjustments happen after the virtual DOM commit rather than during it.
@@ -155,6 +158,7 @@
@@ -180,11 +184,11 @@ Add key attribute to the delete/add buttons row for efficient VDOM diffing. Use cached selection variable instead of this.selection.
@@ -195,7 +199,7 @@
@@ -212,8 +216,8 @@ Use Set.has() for O(1) default layer filter in the add-layer dropdown. Add key attribute to option elements.
@@ -221,7 +225,7 @@ Add key attribute to the default layer row for efficient VDOM diffing.
@@ -240,8 +244,8 @@ Use Set.has() for O(1) editable layer filter in the layer list. Add key attribute to each layer row for efficient VDOM diffing.
@@ -258,12 +262,12 @@ Use Set.has() for O(1) layer selection check. Add key attribute to the collapse row.
@@ -271,10 +275,11 @@ Add key to frame numbers row and individual frame number cells. Use Set.has() for O(1) frame selection check.
@@ -294,12 +299,13 @@ Add key to frame titles row and individual title cells. Use Set.has() for O(1) frame selection check.
@@ -307,35 +313,38 @@ Add keys to the entire timeline grid container, table, default cells row, editable layer rows, and individual grid cells. Use Set.has() for O(1) layer/frame selection checks and editable/default membership filters.
DESCRIPTION:
Multiple changes were made to the Timeline virtual DOM rendering:
repaint()to a newafterRender()hook (inherited from VirtualDOMView).repaint()now simply callssuper.repaint().Array.indexOf()membership checks for layer and frame selection state were replaced withSet.has()calls (viaselection.hasLayer(layer),selection.hasFrame(frame),controller._editableLayerSet.has(layer),controller._defaultLayerSet.has(layer)).keyattributes:"tl-buttons","tl-add-layer","default-row","lr-<index>","collapse-row","frame-nums","frame-titles","default-cells","collapse-cells""fn-<index>","ft-<index>","cd-<index>","c-<layerIndex>-<frameIndex>","cc-<index>","opt-<index>"this.selectionis cached to a localselectionvariable.WHY CHANGED:
The Timeline is the most complex view in Sozi, rendering a grid with rows for each editable layer and columns for each frame. With 100+ frames and layers, this creates thousands of DOM nodes. The virtual DOM library uses keys to identify nodes across renders; without keys, it falls back to positional matching, which causes unnecessary node recreation when the list changes. Additionally, the layout adjustment (aligning scroll positions of the four timeline quadrants) was previously run inside the
repaint()callback, which meant it could trigger layout thrash during the render pass. TheafterRender()hook defers this to after the virtual DOM has been committed. The same O(n) → O(1) Set lookup pattern from other files was applied here too.HOW IT IMPROVES PERFORMANCE:
selection.hasLayer(layer)is O(1) vsselection.selectedLayers.indexOf(layer)O(n). With two lookups per cell (selected state) plus two per row (visible layer filter) plus two per header cell, this saves thousands of linear scans per render.selectionis accessed dozens of times during render; caching avoids repeated property lookup chains.Closes #753