Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions benchmarks/blocklist-comparison.bench.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* BlocksList Comparison Benchmark
*
* Run: node benchmarks/blocklist-comparison.bench.js
*
*/

import isEqual from "lodash/isEqual.js";
import { bench, group, run } from "mitata";

// Mock BlockState structure
class BlockState {
constructor(id) {
this.id = id;
this.data = { x: Math.random() * 1000, y: Math.random() * 1000 };
}
}

// OLD METHOD: lodash.isEqual with sort
function oldComparison(newStates, oldStates) {
return !isEqual(newStates.map((state) => state.id).sort(), oldStates.map((state) => state.id).sort());
}

// NEW METHOD: Set-based comparison
function newComparison(newStates, oldStates) {
if (newStates.length !== oldStates.length) return true;

const oldIds = new Set(oldStates.map((state) => state.id));
return newStates.some((state) => !oldIds.has(state.id));
}

// Helper to create test data
function createBlocks(count) {
return Array.from({ length: count }, (_, i) => new BlockState(`block-${i}`));
}

// Test scenarios
const scenarios = [
{ name: "10 blocks (small)", count: 10 },
{ name: "50 blocks (medium)", count: 50 },
{ name: "100 blocks (large)", count: 100 },
{ name: "500 blocks (very large)", count: 500 },
{ name: "1000 blocks (huge)", count: 1000 },
];

// Run benchmarks for each scenario
for (const scenario of scenarios) {
const blocks1 = createBlocks(scenario.count);
const blocks2 = [...blocks1]; // Same blocks
const blocks3 = [...blocks1.slice(0, -1), new BlockState("block-changed")]; // One changed

group(`${scenario.name} - No changes (equal)`, () => {
bench("Old method (isEqual + sort)", () => {
oldComparison(blocks1, blocks2);
});

bench("New method (Set-based)", () => {
newComparison(blocks1, blocks2);
});
});

group(`${scenario.name} - One block changed`, () => {
bench("Old method (isEqual + sort)", () => {
oldComparison(blocks1, blocks3);
});

bench("New method (Set-based)", () => {
newComparison(blocks1, blocks3);
});
});
}

// Run all benchmarks
await run({
units: false, // Don't show units in results
silent: false, // Show output
avg: true, // Show average time
json: false, // Don't output JSON
colors: true, // Use colors
min_max: true, // Show min/max
collect: false, // Don't collect gc
percentiles: true, // Show percentiles
});
88 changes: 88 additions & 0 deletions docs/system/scheduler-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,17 @@ classDiagram
class GlobalScheduler {
-schedulers: IScheduler[][]
-_cAFID: number
-visibilityChangeHandler: Function | null
+addScheduler(scheduler, index)
+removeScheduler(scheduler, index)
+start()
+stop()
+destroy()
+tick()
+performUpdate()
-setupVisibilityListener()
-handleVisibilityChange()
-cleanupVisibilityListener()
}

class Scheduler {
Expand Down Expand Up @@ -83,6 +88,78 @@ sequenceDiagram
Browser->>GlobalScheduler: requestAnimationFrame (next frame)
```

## Browser Background Behavior and Page Visibility

> ⚠️ **Important:** Browsers throttle or completely pause `requestAnimationFrame` execution when a tab is in the background. This is a critical consideration for the scheduler system.

### Background Tab Behavior

When a browser tab is not visible (e.g., opened in background, user switched to another tab), browsers implement the following optimizations:

| Browser | Behavior | Impact on Scheduler |
|---------|----------|---------------------|
| **Chrome** | Throttles rAF to 1 FPS | Severe slowdown, ~60x slower |
| **Firefox** | Pauses rAF completely | Complete halt until tab visible |
| **Safari** | Pauses rAF completely | Complete halt until tab visible |
| **Edge** | Throttles rAF to 1 FPS | Severe slowdown, ~60x slower |

### Why Browsers Do This

Browsers pause or throttle `requestAnimationFrame` in background tabs for several reasons:

1. **Battery Life** - Reduces CPU usage on mobile devices and laptops
2. **Performance** - Frees up resources for the active tab
3. **Fairness** - Prevents background tabs from consuming too many resources
4. **User Experience** - Prioritizes the visible tab

### Page Visibility API Integration

To handle this behavior, `GlobalScheduler` integrates with the [Page Visibility API](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API):

```typescript
// In GlobalScheduler constructor
private setupVisibilityListener(): void {
if (typeof document === "undefined") {
return; // Not in browser environment
}

this.visibilityChangeHandler = this.handleVisibilityChange;
document.addEventListener("visibilitychange", this.visibilityChangeHandler);
}

private handleVisibilityChange(): void {
// Only update if page becomes visible and scheduler is running
if (!document.hidden && this._cAFID) {
// Perform immediate update when tab becomes visible
this.performUpdate();
}
}
```

### Visibility Change Flow

```mermaid
sequenceDiagram
participant User
participant Browser
participant Document
participant GlobalScheduler
participant Components

User->>Browser: Switch to another tab
Browser->>Document: Set document.hidden = true
Browser->>GlobalScheduler: Pause requestAnimationFrame
Note over GlobalScheduler: rAF callbacks stop executing

User->>Browser: Switch back to graph tab
Browser->>Document: Set document.hidden = false
Document->>GlobalScheduler: Fire 'visibilitychange' event
GlobalScheduler->>GlobalScheduler: handleVisibilityChange()
GlobalScheduler->>GlobalScheduler: performUpdate() (immediate)
GlobalScheduler->>Components: Update all components
Browser->>GlobalScheduler: Resume requestAnimationFrame
```

## Update Scheduling

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

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

### Cleanup

In rare cases where you need to completely destroy the scheduler (e.g., testing, cleanup):

```typescript
// Stop scheduler and remove all event listeners
globalScheduler.destroy();
```

> **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.

## Debugging the Scheduler

Debugging issues with the scheduler can be challenging, but there are several techniques you can use to identify and resolve problems:
Expand Down
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"jest": "^30.0.5",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^30.0.5",
"mitata": "^1.0.34",
"monaco-editor": "^0.52.0",
"prettier": "^3.0.0",
"process": "^0.11.10",
Expand Down
48 changes: 48 additions & 0 deletions src/lib/Scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,50 @@ export class GlobalScheduler {
private schedulers: [IScheduler[], IScheduler[], IScheduler[], IScheduler[], IScheduler[]];
private _cAFID: number;
private toRemove: Array<[IScheduler, ESchedulerPriority]> = [];
private visibilityChangeHandler: (() => void) | null = null;

constructor() {
this.tick = this.tick.bind(this);
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);

this.schedulers = [[], [], [], [], []];
this.setupVisibilityListener();
}

/**
* Setup listener for page visibility changes.
* When tab becomes visible after being hidden, force immediate update.
* This fixes the issue where tabs opened in background don't render HTML until interaction.
*/
private setupVisibilityListener(): void {
if (typeof document === "undefined") {
return; // Not in browser environment
}

this.visibilityChangeHandler = this.handleVisibilityChange;
document.addEventListener("visibilitychange", this.visibilityChangeHandler);
}

/**
* Handle page visibility changes.
* When page becomes visible, perform immediate update if scheduler is running.
*/
private handleVisibilityChange(): void {
// Only update if page becomes visible and scheduler is running
if (!document.hidden && this._cAFID) {
// Perform immediate update when tab becomes visible
this.performUpdate();
}
}

/**
* Cleanup visibility listener
*/
private cleanupVisibilityListener(): void {
if (this.visibilityChangeHandler && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", this.visibilityChangeHandler);
this.visibilityChangeHandler = null;
}
}

public getSchedulers() {
Expand All @@ -51,6 +90,15 @@ export class GlobalScheduler {
this._cAFID = undefined;
}

/**
* Cleanup method to be called when GlobalScheduler is no longer needed.
* Stops the scheduler and removes event listeners.
*/
public destroy(): void {
this.stop();
this.cleanupVisibilityListener();
}

public tick() {
this.performUpdate();
this._cAFID = rAF(this.tick);
Expand Down
Loading
Loading