Skip to content

Commit e55647e

Browse files
Copilotargyleink
andauthored
Replace setTimeout(0) with Svelte's tick() for batching store updates (#122)
* Initial plan * Initial analysis of Svelte performance issues Co-authored-by: argyleink <[email protected]> * Optimize Svelte data flows to reduce input lag Co-authored-by: argyleink <[email protected]> * Address code review feedback: fix race condition, improve comments Co-authored-by: argyleink <[email protected]> * Replace setTimeout(0) with Svelte's tick() for proper batching Co-authored-by: argyleink <[email protected]> * Address code review: improve tick() batching comments and validation order Co-authored-by: argyleink <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: argyleink <[email protected]>
1 parent 2a0a174 commit e55647e

2 files changed

Lines changed: 84 additions & 14 deletions

File tree

src/components/GradientStops.svelte

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// @ts-nocheck
33
import {flip} from 'svelte/animate'
44
import {fade,scale} from 'svelte/transition'
5+
import {tick} from 'svelte'
56
67
import { tooltip } from 'svooltip'
78
@@ -245,7 +246,11 @@
245246
dropPos = null
246247
}
247248
248-
function slidingPosition(e, stop) {
249+
// Batched update for sliding position to reduce store updates during rapid slider changes
250+
// Uses Svelte's tick() to defer store update until after pending state changes
251+
let slidingPending = false
252+
async function slidingPosition(e, stop) {
253+
// Apply position sync immediately (mutates the stop object directly)
249254
const range = [
250255
stop.position1 + 1,
251256
stop.position1 + 2,
@@ -255,7 +260,15 @@
255260
if (range.includes(stop.position2)) {
256261
stop.position2 = stop.position1
257262
}
258-
$gradient_stops = [...$gradient_stops]
263+
// Schedule a single batched store update via Svelte's tick()
264+
// Multiple rapid calls will mutate stops synchronously, but only one
265+
// store update fires after tick() resolves - capturing all mutations
266+
if (!slidingPending) {
267+
slidingPending = true
268+
await tick()
269+
$gradient_stops = [...$gradient_stops]
270+
slidingPending = false
271+
}
259272
}
260273
</script>
261274

src/store/layers.ts

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { writable, get } from 'svelte/store'
2+
import { tick } from 'svelte'
23

34
import { gradient_type, gradient_space, gradient_interpolation, gradient_stops, gradient_angles } from './gradient'
45
import { linear_angle, linear_named_angle } from './linear'
@@ -7,6 +8,13 @@ import { conic_angle, conic_position, conic_named_position } from './conic'
78

89
import { buildGradientStrings } from '../utils/gradientString'
910

11+
// Pending mutations for batching rapid store updates via Svelte's tick()
12+
let pendingLayerUpdates: ((l: GradientLayer) => void)[] = []
13+
// Track the target layer index when updates are queued to prevent applying to wrong layer
14+
let pendingLayerIndex: number | null = null
15+
// Track whether a tick-based flush is already scheduled
16+
let flushScheduled = false
17+
1018
// Minimal layer shape mirroring the single-store shape
1119
export type GradientLayer = {
1220
id: string
@@ -40,7 +48,7 @@ function snapshotFromStores(): GradientLayer {
4048
type: get(gradient_type),
4149
space: get(gradient_space),
4250
interpolation: get(gradient_interpolation),
43-
stops: JSON.parse(JSON.stringify(get(gradient_stops))),
51+
stops: structuredClone(get(gradient_stops)),
4452
linear: {
4553
named_angle: get(linear_named_angle),
4654
angle: get(linear_angle),
@@ -81,30 +89,78 @@ function applyLayerToStores(layer: GradientLayer) {
8189
conic_named_position.set(layer.conic.named_position)
8290
conic_position.set({ ...layer.conic.position })
8391

84-
gradient_stops.set(JSON.parse(JSON.stringify(layer.stops)))
92+
gradient_stops.set(structuredClone(layer.stops))
8593
}
8694
finally {
8795
// release in next microtask to let subscribers flush
8896
queueMicrotask(() => { isApplyingLayerToStores = false })
8997
}
9098
}
9199

92-
function updateActiveLayer(mutator: (l: GradientLayer) => void) {
100+
// Flush pending layer updates - called via tick() to batch multiple store changes
101+
async function flushPendingUpdates() {
102+
// Wait for Svelte to finish processing current reactive updates
103+
await tick()
104+
105+
// Capture state AFTER tick() resolves - this ensures all synchronous updates
106+
// that triggered before the await are included
107+
const targetIdx = pendingLayerIndex
108+
const updates = pendingLayerUpdates
93109
const list = get(layers)
94-
const idx = get(active_layer_index) ?? 0
95-
if (!list.length || idx < 0 || idx >= list.length) return
110+
111+
// Reset state to allow new batches to form
112+
pendingLayerUpdates = []
113+
flushScheduled = false
114+
pendingLayerIndex = null
115+
116+
// Verify we have updates and target layer still exists and is valid
117+
if (!updates.length || targetIdx === null || !list.length || targetIdx < 0 || targetIdx >= list.length) {
118+
return
119+
}
120+
96121
const copy = [...list]
97-
const layer = { ...copy[idx],
98-
linear: { ...copy[idx].linear },
99-
radial: { ...copy[idx].radial, position: { ...copy[idx].radial.position } },
100-
conic: { ...copy[idx].conic, position: { ...copy[idx].conic.position } },
122+
const layer = { ...copy[targetIdx],
123+
linear: { ...copy[targetIdx].linear },
124+
radial: { ...copy[targetIdx].radial, position: { ...copy[targetIdx].radial.position } },
125+
conic: { ...copy[targetIdx].conic, position: { ...copy[targetIdx].conic.position } },
101126
}
102-
mutator(layer)
127+
128+
// Apply all pending mutations in order
129+
for (const m of updates) {
130+
m(layer)
131+
}
132+
103133
layer.cachedCss = buildGradientStrings(layer)
104-
copy[idx] = layer
134+
copy[targetIdx] = layer
105135
layers.set(copy)
106136
}
107137

138+
// Batch multiple rapid store updates into a single layer update to reduce renders
139+
function updateActiveLayer(mutator: (l: GradientLayer) => void) {
140+
const currentIdx = get(active_layer_index) ?? 0
141+
142+
// If layer changed since pending updates were queued, discard stale updates
143+
if (pendingLayerIndex !== null && pendingLayerIndex !== currentIdx) {
144+
pendingLayerUpdates = []
145+
pendingLayerIndex = null
146+
}
147+
148+
// Track which layer these updates are for
149+
if (pendingLayerIndex === null) {
150+
pendingLayerIndex = currentIdx
151+
}
152+
153+
pendingLayerUpdates.push(mutator)
154+
155+
// Schedule a flush via Svelte's tick() if not already scheduled
156+
// tick() returns a promise that resolves after pending state changes are applied
157+
// All synchronous store subscriptions will add their mutators before tick() resolves
158+
if (!flushScheduled) {
159+
flushScheduled = true
160+
flushPendingUpdates()
161+
}
162+
}
163+
108164
// Public API
109165
export function addLayer({ seed = 'duplicate', position = 'top' as 'top' | 'bottom' } = {}) {
110166
const base = seed === 'duplicate' ? snapshotFromStores() : defaultLayer()
@@ -290,4 +346,5 @@ radial_position.subscribe(v => { if (!isApplyingLayerToStores) updateActiveLayer
290346
conic_angle.subscribe(v => { if (!isApplyingLayerToStores) updateActiveLayer(l => { l.conic.angle = v as any }) })
291347
conic_named_position.subscribe(v => { if (!isApplyingLayerToStores) updateActiveLayer(l => { l.conic.named_position = v }) })
292348
conic_position.subscribe(v => { if (!isApplyingLayerToStores) updateActiveLayer(l => { l.conic.position = { ...(v as any) } }) })
293-
gradient_stops.subscribe(v => { if (!isApplyingLayerToStores) updateActiveLayer(l => { l.stops = JSON.parse(JSON.stringify(v)) }) })
349+
// Use structuredClone for a faster deep clone than JSON.parse/stringify
350+
gradient_stops.subscribe(v => { if (!isApplyingLayerToStores) updateActiveLayer(l => { l.stops = structuredClone(v) }) })

0 commit comments

Comments
 (0)