Skip to content

Commit b24f536

Browse files
authored
fix(BlockList): fix race condition in BlockList (#185)
* fix(BlockList): fix race condition in BlockList * perf(BlockList): use faster way to determine list changes * feat(GlobalScheduler): integrate Page Visibility API to manage background tab behavior and add cleanup method * fix(Scheduler): fix cleanup debounce/throttle utils
1 parent 2417795 commit b24f536

File tree

7 files changed

+291
-58
lines changed

7 files changed

+291
-58
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* BlocksList Comparison Benchmark
3+
*
4+
* Run: node benchmarks/blocklist-comparison.bench.js
5+
*
6+
*/
7+
8+
import isEqual from "lodash/isEqual.js";
9+
import { bench, group, run } from "mitata";
10+
11+
// Mock BlockState structure
12+
class BlockState {
13+
constructor(id) {
14+
this.id = id;
15+
this.data = { x: Math.random() * 1000, y: Math.random() * 1000 };
16+
}
17+
}
18+
19+
// OLD METHOD: lodash.isEqual with sort
20+
function oldComparison(newStates, oldStates) {
21+
return !isEqual(newStates.map((state) => state.id).sort(), oldStates.map((state) => state.id).sort());
22+
}
23+
24+
// NEW METHOD: Set-based comparison
25+
function newComparison(newStates, oldStates) {
26+
if (newStates.length !== oldStates.length) return true;
27+
28+
const oldIds = new Set(oldStates.map((state) => state.id));
29+
return newStates.some((state) => !oldIds.has(state.id));
30+
}
31+
32+
// Helper to create test data
33+
function createBlocks(count) {
34+
return Array.from({ length: count }, (_, i) => new BlockState(`block-${i}`));
35+
}
36+
37+
// Test scenarios
38+
const scenarios = [
39+
{ name: "10 blocks (small)", count: 10 },
40+
{ name: "50 blocks (medium)", count: 50 },
41+
{ name: "100 blocks (large)", count: 100 },
42+
{ name: "500 blocks (very large)", count: 500 },
43+
{ name: "1000 blocks (huge)", count: 1000 },
44+
];
45+
46+
// Run benchmarks for each scenario
47+
for (const scenario of scenarios) {
48+
const blocks1 = createBlocks(scenario.count);
49+
const blocks2 = [...blocks1]; // Same blocks
50+
const blocks3 = [...blocks1.slice(0, -1), new BlockState("block-changed")]; // One changed
51+
52+
group(`${scenario.name} - No changes (equal)`, () => {
53+
bench("Old method (isEqual + sort)", () => {
54+
oldComparison(blocks1, blocks2);
55+
});
56+
57+
bench("New method (Set-based)", () => {
58+
newComparison(blocks1, blocks2);
59+
});
60+
});
61+
62+
group(`${scenario.name} - One block changed`, () => {
63+
bench("Old method (isEqual + sort)", () => {
64+
oldComparison(blocks1, blocks3);
65+
});
66+
67+
bench("New method (Set-based)", () => {
68+
newComparison(blocks1, blocks3);
69+
});
70+
});
71+
}
72+
73+
// Run all benchmarks
74+
await run({
75+
units: false, // Don't show units in results
76+
silent: false, // Show output
77+
avg: true, // Show average time
78+
json: false, // Don't output JSON
79+
colors: true, // Use colors
80+
min_max: true, // Show min/max
81+
collect: false, // Don't collect gc
82+
percentiles: true, // Show percentiles
83+
});

docs/system/scheduler-system.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,17 @@ classDiagram
2222
class GlobalScheduler {
2323
-schedulers: IScheduler[][]
2424
-_cAFID: number
25+
-visibilityChangeHandler: Function | null
2526
+addScheduler(scheduler, index)
2627
+removeScheduler(scheduler, index)
2728
+start()
2829
+stop()
30+
+destroy()
2931
+tick()
3032
+performUpdate()
33+
-setupVisibilityListener()
34+
-handleVisibilityChange()
35+
-cleanupVisibilityListener()
3136
}
3237
3338
class Scheduler {
@@ -83,6 +88,78 @@ sequenceDiagram
8388
Browser->>GlobalScheduler: requestAnimationFrame (next frame)
8489
```
8590

91+
## Browser Background Behavior and Page Visibility
92+
93+
> ⚠️ **Important:** Browsers throttle or completely pause `requestAnimationFrame` execution when a tab is in the background. This is a critical consideration for the scheduler system.
94+
95+
### Background Tab Behavior
96+
97+
When a browser tab is not visible (e.g., opened in background, user switched to another tab), browsers implement the following optimizations:
98+
99+
| Browser | Behavior | Impact on Scheduler |
100+
|---------|----------|---------------------|
101+
| **Chrome** | Throttles rAF to 1 FPS | Severe slowdown, ~60x slower |
102+
| **Firefox** | Pauses rAF completely | Complete halt until tab visible |
103+
| **Safari** | Pauses rAF completely | Complete halt until tab visible |
104+
| **Edge** | Throttles rAF to 1 FPS | Severe slowdown, ~60x slower |
105+
106+
### Why Browsers Do This
107+
108+
Browsers pause or throttle `requestAnimationFrame` in background tabs for several reasons:
109+
110+
1. **Battery Life** - Reduces CPU usage on mobile devices and laptops
111+
2. **Performance** - Frees up resources for the active tab
112+
3. **Fairness** - Prevents background tabs from consuming too many resources
113+
4. **User Experience** - Prioritizes the visible tab
114+
115+
### Page Visibility API Integration
116+
117+
To handle this behavior, `GlobalScheduler` integrates with the [Page Visibility API](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API):
118+
119+
```typescript
120+
// In GlobalScheduler constructor
121+
private setupVisibilityListener(): void {
122+
if (typeof document === "undefined") {
123+
return; // Not in browser environment
124+
}
125+
126+
this.visibilityChangeHandler = this.handleVisibilityChange;
127+
document.addEventListener("visibilitychange", this.visibilityChangeHandler);
128+
}
129+
130+
private handleVisibilityChange(): void {
131+
// Only update if page becomes visible and scheduler is running
132+
if (!document.hidden && this._cAFID) {
133+
// Perform immediate update when tab becomes visible
134+
this.performUpdate();
135+
}
136+
}
137+
```
138+
139+
### Visibility Change Flow
140+
141+
```mermaid
142+
sequenceDiagram
143+
participant User
144+
participant Browser
145+
participant Document
146+
participant GlobalScheduler
147+
participant Components
148+
149+
User->>Browser: Switch to another tab
150+
Browser->>Document: Set document.hidden = true
151+
Browser->>GlobalScheduler: Pause requestAnimationFrame
152+
Note over GlobalScheduler: rAF callbacks stop executing
153+
154+
User->>Browser: Switch back to graph tab
155+
Browser->>Document: Set document.hidden = false
156+
Document->>GlobalScheduler: Fire 'visibilitychange' event
157+
GlobalScheduler->>GlobalScheduler: handleVisibilityChange()
158+
GlobalScheduler->>GlobalScheduler: performUpdate() (immediate)
159+
GlobalScheduler->>Components: Update all components
160+
Browser->>GlobalScheduler: Resume requestAnimationFrame
161+
```
162+
86163
## Update Scheduling
87164

88165
The scheduling system coordinates when component updates happen:
@@ -245,6 +322,17 @@ export const scheduler = globalScheduler;
245322

246323
This allows components to share a single scheduler instance and animation frame loop.
247324

325+
### Cleanup
326+
327+
In rare cases where you need to completely destroy the scheduler (e.g., testing, cleanup):
328+
329+
```typescript
330+
// Stop scheduler and remove all event listeners
331+
globalScheduler.destroy();
332+
```
333+
334+
> **Note:** In normal application usage, you don't need to call `destroy()`. The global scheduler is designed to run for the entire lifetime of the application.
335+
248336
## Debugging the Scheduler
249337

250338
Debugging issues with the scheduler can be challenging, but there are several techniques you can use to identify and resolve problems:

package-lock.json

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
"jest": "^30.0.5",
128128
"jest-canvas-mock": "^2.5.2",
129129
"jest-environment-jsdom": "^30.0.5",
130+
"mitata": "^1.0.34",
130131
"monaco-editor": "^0.52.0",
131132
"prettier": "^3.0.0",
132133
"process": "^0.11.10",

src/lib/Scheduler.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,50 @@ export class GlobalScheduler {
2020
private schedulers: [IScheduler[], IScheduler[], IScheduler[], IScheduler[], IScheduler[]];
2121
private _cAFID: number;
2222
private toRemove: Array<[IScheduler, ESchedulerPriority]> = [];
23+
private visibilityChangeHandler: (() => void) | null = null;
2324

2425
constructor() {
2526
this.tick = this.tick.bind(this);
27+
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
2628

2729
this.schedulers = [[], [], [], [], []];
30+
this.setupVisibilityListener();
31+
}
32+
33+
/**
34+
* Setup listener for page visibility changes.
35+
* When tab becomes visible after being hidden, force immediate update.
36+
* This fixes the issue where tabs opened in background don't render HTML until interaction.
37+
*/
38+
private setupVisibilityListener(): void {
39+
if (typeof document === "undefined") {
40+
return; // Not in browser environment
41+
}
42+
43+
this.visibilityChangeHandler = this.handleVisibilityChange;
44+
document.addEventListener("visibilitychange", this.visibilityChangeHandler);
45+
}
46+
47+
/**
48+
* Handle page visibility changes.
49+
* When page becomes visible, perform immediate update if scheduler is running.
50+
*/
51+
private handleVisibilityChange(): void {
52+
// Only update if page becomes visible and scheduler is running
53+
if (!document.hidden && this._cAFID) {
54+
// Perform immediate update when tab becomes visible
55+
this.performUpdate();
56+
}
57+
}
58+
59+
/**
60+
* Cleanup visibility listener
61+
*/
62+
private cleanupVisibilityListener(): void {
63+
if (this.visibilityChangeHandler && typeof document !== "undefined") {
64+
document.removeEventListener("visibilitychange", this.visibilityChangeHandler);
65+
this.visibilityChangeHandler = null;
66+
}
2867
}
2968

3069
public getSchedulers() {
@@ -51,6 +90,15 @@ export class GlobalScheduler {
5190
this._cAFID = undefined;
5291
}
5392

93+
/**
94+
* Cleanup method to be called when GlobalScheduler is no longer needed.
95+
* Stops the scheduler and removes event listeners.
96+
*/
97+
public destroy(): void {
98+
this.stop();
99+
this.cleanupVisibilityListener();
100+
}
101+
54102
public tick() {
55103
this.performUpdate();
56104
this._cAFID = rAF(this.tick);

0 commit comments

Comments
 (0)