Skip to content

Conversation

MitjaBezensek
Copy link
Contributor

@MitjaBezensek MitjaBezensek commented Oct 2, 2025

Browser will throttle us on screens that can't support 120hz (raf won't run more often than the device supports).

Change type

  • improvement

Release notes

  • Support high refresh devices (up to 120hz)

API changes

  • fpsThrottle no accepts an optional callback which returns the target fps for the throttled function to run at. If no function is provided we will use the default fps of 120.

Copy link

vercel bot commented Oct 2, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Updated (UTC)
analytics Ready Ready Preview Oct 3, 2025 11:00am
examples Ready Ready Preview Oct 3, 2025 11:00am
3 Skipped Deployments
Project Deployment Preview Updated (UTC)
chat-template Ignored Ignored Preview Oct 3, 2025 11:00am
tldraw-docs Ignored Ignored Preview Oct 3, 2025 11:00am
workflow-template Ignored Ignored Preview Oct 3, 2025 11:00am

@huppy-bot
Copy link
Contributor

huppy-bot bot commented Oct 2, 2025

API Changes Check Passed

Great! The PR description now includes the required "### API changes" section. This helps reviewers and SDK users understand the impact of your changes.

Copy link

claude bot commented Oct 2, 2025

🤖 Claude Code Review

Summary

This PR increases the target FPS from 60 to 120 and adds dynamic FPS throttling based on presence mode ('solo' vs collaborative). The browser will naturally throttle via requestAnimationFrame based on display capabilities.

Code Quality ✅

Strengths:

  • Clean implementation of optional custom FPS getter parameter
  • Proper use of WeakMap for storing per-function state
  • Good backward compatibility - existing calls work without changes
  • Comments updated to reflect new behavior

Areas for Improvement:

1. Logic Issue: Duplicate Timing Checks ⚠️

In packages/utils/src/lib/throttle.ts:92-112, there's a potential issue with the throttling logic:

const throttledFn = () => {
    // Custom FPS check here (lines 94-104)
    if (getTargetFps) {
        const lastRun = customFpsLastRunTime.get(fn) ?? -Infinity
        const customTimePerFrame = Math.floor(1000 / getTargetFps()) * 0.9
        const elapsed = Date.now() - lastRun
        
        if (elapsed < customTimePerFrame) {
            return  // Early return - skips adding to queue
        }
        
        customFpsLastRunTime.set(fn, Date.now())
    }
    
    // Then adds to global fpsQueue (lines 107-111)
    if (fpsQueue.includes(fn)) {
        return
    }
    fpsQueue.push(fn)
    tick()
}

Problem: Functions with custom FPS throttling are being checked twice:

  1. First by the custom timing logic (Date.now() based)
  2. Then by the global queue/RAF system

This means scheduleRebase and flushPendingPushRequests will:

  • Check if 1000/1 or 1000/30 ms have elapsed (custom timing)
  • Still get added to the shared fpsQueue
  • Still wait for the next RAF callback based on the global 120fps timing

Impact: In 'solo' mode (1 fps target), the custom check expects ~900ms between calls, but the global queue still processes at 120fps cadence (~7ms). The custom timing might not work as intended.

Suggestion: Consider one of these approaches:

  • Option A: Custom FPS functions bypass the global queue entirely and schedule their own RAF
  • Option B: Make the global targetFps configurable per function instead of adding a separate timing layer
  • Option C: Document that custom FPS is a "minimum delay" on top of the global throttling

2. Unused WeakMap 📝

customFpsGetter is stored but never read (line 89). Either use it or remove it:

const customFpsGetter = new WeakMap<Function, () => number>()
// ...
if (getTargetFps) {
    customFpsGetter.set(fn, getTargetFps)  // Stored but never retrieved
}

3. Magic Numbers in TLSyncClient 📝

The FPS values 1 and 30 in packages/sync-core/src/lib/TLSyncClient.ts:585,695 should be constants:

const SOLO_MODE_FPS = 1
const COLLABORATIVE_FPS = 30

() => (this.presenceMode?.get() === 'solo' ? SOLO_MODE_FPS : COLLABORATIVE_FPS)

This improves readability and makes it easier to tune these values.

4. API Design Consideration 💭

The getTargetFps callback is called on every throttled invocation to get the current FPS. For presenceMode?.get(), this creates a reactive dependency each time. Consider if this is intentional or if FPS should be determined once at throttle creation time.

Performance Considerations ⚡

Positive:

  • 120fps target allows smoother animations on high-refresh displays
  • Dynamic throttling in solo mode (1fps) reduces unnecessary work
  • Browser naturally caps at display refresh rate

Concerns:

  • More frequent RAF callbacks (7ms vs 15ms) means more tick() invocations when the queue is active
  • Date.now() calls on every throttled invocation add minor overhead
  • The solo mode 1fps throttling may not work as designed (see logic issue above)

Security Concerns 🔒

No security issues identified. This is internal performance tuning code.

Test Coverage 📋

Missing:

  • No tests for the new getTargetFps parameter
  • No tests verifying custom FPS timing works correctly
  • No tests for solo vs collaborative mode behavior
  • The PR description has incomplete test plan checkboxes

Recommendations:

  • Add unit tests for fpsThrottle with custom FPS getter
  • Test that functions with 1fps target truly throttle to ~1 call/second
  • Add integration test in TLSyncClient testing solo vs collaborative presence modes
  • Consider adding a test with __FORCE_RAF_IN_TESTS__ to verify RAF behavior

API Changes ✅

API report updated correctly. The change is backward compatible (optional parameter).

Recommendations

  1. High Priority: Fix or clarify the dual-throttling behavior (custom timing + global queue)
  2. Medium: Add test coverage for new functionality
  3. Low: Extract magic numbers to constants
  4. Low: Remove unused customFpsGetter WeakMap

Verdict

The intent is good - smoother animations and smarter throttling. However, the implementation needs clarification on how custom FPS interacts with the global queue system. I'd recommend addressing the logic issue before merging.

Browser will throttle us on screens that can suport it (raf won't run
more often than the device supports).
We double checked whether they should be called.
Copy link

claude bot commented Oct 2, 2025

🤖 Claude Code Review

Summary

This PR increases the target frame rate from 60fps to 120fps and adds custom FPS throttling capabilities to fpsThrottle(). The changes allow high-refresh-rate devices to benefit from smoother updates while maintaining backwards compatibility.

Code Quality and Best Practices

Positive Aspects

  • Good API design: The optional getTargetFps callback parameter maintains backward compatibility
  • Clear documentation: JSDoc comments properly updated to reflect the 120fps target
  • Appropriate use of WeakMap for tracking per-function timing (memory-efficient, prevents leaks)
  • Smart optimization in TLSyncClient: Different FPS targets for solo mode (1fps) vs collaborative mode (30fps)

Issues and Concerns

1. Logic Bug: Premature timing update
Location: packages/utils/src/lib/throttle.ts:99

The custom FPS timing is updated BEFORE the function is queued, not when it actually runs. If the function gets queued but doesn't execute immediately, the timing will be inaccurate. The next call could be rejected even though the function hasn't actually run yet.

Recommendation: Update the timestamp AFTER the function executes, within the flush() function.

2. Mixed throttling strategies
The implementation now has TWO separate throttling mechanisms:

  • Global queue with RAF-based throttling (targetTimePerFrame)
  • Per-function timing for custom FPS (customFpsLastRunTime)

Functions with custom FPS are checked twice. Is this intentional? A function with getTargetFps() = 1 could still run up to 120 times per second if called frequently enough.

3. Performance consideration
Every call to a custom-FPS-throttled function now calls getTargetFps(), does Date.now() twice, and performs WeakMap lookups. For high-frequency calls, consider caching the getTargetFps() result.

Test Coverage

No tests found for the fpsThrottle function or these changes.

Recommendations:

  • Add unit tests for fpsThrottle with custom FPS
  • Test interaction between global and custom timing
  • Test with presenceMode switching in TLSyncClient
  • Add integration tests verifying high-refresh-rate drawing is smoother

Conclusion

The change addresses a real UX improvement for high-refresh-rate displays. However, there is a timing logic bug that should be fixed before merging. The implementation also introduces complexity with dual throttling that deserves clearer documentation.

Recommendation: Request changes to fix timing update logic and add test coverage.

Copy link

claude bot commented Oct 2, 2025

🤖 Claude Code Review

Summary

This PR increases the target FPS from 60 to 120 and adds custom per-function FPS throttling capabilities. The changes are well-structured and the implementation appears sound for supporting high refresh rate displays.

Code Quality ✅

Strengths:

  • Clean API design with backward compatibility (optional getTargetFps parameter)
  • Proper use of WeakMap to avoid memory leaks when tracking per-function state
  • Good inline comments explaining the purpose of new code
  • API documentation updated correctly

Minor concerns:

  1. Magic number in TLSyncClient (lines 585, 694): The values 1 and 30 are hardcoded. Consider extracting these as named constants:

    const SOLO_MODE_TARGET_FPS = 1
    const MULTI_MODE_TARGET_FPS = 30
  2. Inconsistent naming: customFpsLastRunTime uses "custom" prefix while customFpsGetters does too, but the variable names could be more descriptive (e.g., functionLastRunTime, functionFpsGetters)

Performance Considerations ⚠️

Potential issue - Race condition with custom FPS:

In throttle.ts:98-109, there's a timing check that happens BEFORE queuing:

if (getTargetFps) {
    const lastRun = customFpsLastRunTime.get(fn) ?? -Infinity
    const customTimePerFrame = Math.floor(1000 / getTargetFps()) * 0.9
    const elapsed = Date.now() - lastRun
    
    if (elapsed < customTimePerFrame) {
        return // Not ready yet, don't queue
    }
}

However, the timestamp is only updated in flush() (line 25). This creates a potential issue:

  • Function is called and queued (passes timing check)
  • Before flush executes, function is called again
  • Second call also passes timing check (timestamp not updated yet)
  • Function gets queued twice due to line 110 check: if (fpsQueue.includes(fn))

Wait, actually line 110 prevents duplicate queuing, so this is handled correctly. ✅

Performance benefit:

  • Early return in throttledFn (line 106-108) avoids unnecessary queue operations when throttling at custom FPS
  • This is good for the sync client use case where solo mode wants very low FPS (1 fps)

Security Concerns ✅

No security issues identified. The use of WeakMap is appropriate and prevents memory leaks.

Test Coverage ⚠️

Concerns:

  1. No direct tests for new fpsThrottle behavior: The custom FPS functionality isn't tested directly. The existing test in presenceMode.test.ts mocks fpsThrottle entirely (line 12), so it doesn't validate the new behavior.

  2. Missing test cases:

    • Custom FPS with different values
    • Behavior when getTargetFps returns varying values over time
    • Edge case: getTargetFps returning 0 or negative values (could cause division issues)

Recommendation: Add tests like:

test('fpsThrottle respects custom FPS rates', () => {
  let callCount = 0
  const fn = vi.fn(() => callCount++)
  const throttled = fpsThrottle(fn, () => 2) // 2 FPS = 500ms per frame
  
  throttled()
  expect(callCount).toBe(1)
  
  // Call immediately - should be throttled
  throttled()
  expect(callCount).toBe(1)
  
  // Wait 500ms and call - should execute
  vi.advanceTimersByTime(500)
  throttled()
  expect(callCount).toBe(2)
})

Potential Bugs 🐛

Issue 1: Division by zero risk
In throttle.ts:102, if getTargetFps() returns 0 or a very small number:

const customTimePerFrame = Math.floor(1000 / getTargetFps()) * 0.9

This could result in Infinity or unexpectedly large values. Consider adding validation.

Issue 2: Global targetTimePerFrame vs custom FPS
The global targetTimePerFrame (line 9) is now ~7ms for 120fps, but the global flush timing in tick() (line 37) still uses this global value. This means:

  • A function with custom FPS=1 will still be affected by the global 120fps flush timing
  • The custom FPS check happens BEFORE queuing, so rapid calls at 1fps will just return early
  • This is probably intended behavior, but worth documenting

API Changes 📝

The API change is documented in the PR description:

  • fpsThrottle now accepts an optional callback which returns the target fps for the throttled function to run at. If no function is provided we will use the default fps of 120.

Suggestion: Update the JSDoc in throttle.ts:68 to include an example:

* @example
* // Custom FPS based on mode
* const throttled = fpsThrottle(myFn, () => isHighPerformanceMode ? 60 : 30)

Recommendations

  1. Add input validation for getTargetFps:

    if (getTargetFps) {
        const targetFps = Math.max(1, getTargetFps()) // Ensure minimum 1 FPS
        const customTimePerFrame = Math.floor(1000 / targetFps) * 0.9
        // ...
    }
  2. Extract magic numbers in TLSyncClient to constants

  3. Add unit tests for custom FPS behavior

  4. Consider renaming WeakMap variables for clarity

Overall Assessment

Verdict: Approve with minor suggestions

The implementation is solid and achieves the goal of supporting 120hz displays. The custom FPS feature is a good addition for the sync client's solo mode optimization. The main concerns are around test coverage and input validation, which are relatively minor.

The browser will naturally throttle requestAnimationFrame on devices that don't support 120hz, so the change is safe and backward compatible.

@vercel vercel bot temporarily deployed to Preview – chat-template October 2, 2025 09:33 Inactive
@vercel vercel bot temporarily deployed to Preview – workflow-template October 2, 2025 09:33 Inactive
Copy link

claude bot commented Oct 2, 2025

🤖 Claude Code Review

Summary

This PR bumps the target frame rate from 60fps to 120fps and adds adaptive throttling for network sync operations. The changes are well-structured, but there are a few concerns to address.

Code Quality & Architecture

✅ Strengths

  • Clear separation of concerns: The custom FPS tracking is cleanly separated using WeakMaps
  • Backward compatible: The optional getTargetFps parameter maintains existing API
  • Well-documented: Constants and functions have clear documentation
  • Smart optimization: Using WeakMaps prevents memory leaks by allowing garbage collection

⚠️ Issues & Concerns

1. Potential Race Condition in Custom FPS Tracking

Location: packages/utils/src/lib/throttle.ts:98-108

The custom FPS check happens BEFORE queuing, but the timestamp update happens during flush. This creates a potential issue:

const throttledFn = () => {
    if (getTargetFps) {
        const lastRun = customFpsLastRunTime.get(fn) ?? -Infinity
        const customTimePerFrame = Math.floor(1000 / getTargetFps()) * 0.9
        const elapsed = Date.now() - lastRun
        
        if (elapsed < customTimePerFrame) {
            return  // Don't queue
        }
    }
    // ... queue the function
}

Problem: If throttledFn() is called multiple times between frames, it could queue the same function multiple times before the first execution updates the timestamp. The fpsQueue.includes(fn) check at line 110 mitigates this, but the timing check at line 105 becomes ineffective.

Recommendation: Update the timestamp when queuing, not when executing:

if (getTargetFps) {
    const lastRun = customFpsLastRunTime.get(fn) ?? -Infinity
    const customTimePerFrame = Math.floor(1000 / getTargetFps()) * 0.9
    const elapsed = Date.now() - lastRun
    
    if (elapsed < customTimePerFrame) {
        return
    }
    customFpsLastRunTime.set(fn, Date.now())  // Update here
}

And remove the timestamp update from the flush function.

2. API Inconsistency

Location: packages/utils/src/lib/throttle.ts:72-74

The API accepts () => number but the description says it "returns the current target FPS rate". Consider renaming to make it clearer:

  • getTargetFpsgetTargetFpsRate or targetFpsGetter

Or update the documentation to be more explicit about what the callback should return.

3. Memory Leak Risk with Bound Methods

Location: packages/sync-core/src/lib/TLSyncClient.ts:594, 702

private flushPendingPushRequests = fpsThrottle(() => {
    // ...
}, this.getSyncFps.bind(this))

Problem: this.getSyncFps.bind(this) creates a new function reference each time. However, since this is a class field initialization (only happens once), this is actually fine. But it's worth documenting.

Note: This is NOT a leak in the current implementation, but future maintainers should be aware not to recreate the throttled functions.

4. Missing Cleanup in Test Mode

Location: packages/utils/src/lib/throttle.ts:93-96

When getTargetFps is provided in test mode, the WeakMap entry is still created even though the throttling is bypassed:

if (isTest()) {
    // ... return unthrottled fn
    return fn
}

// This happens even in test mode
if (getTargetFps) {
    customFpsGetters.set(fn, getTargetFps)
}

Recommendation: Move the WeakMap setup before the test mode check, or skip it in test mode.

Performance Considerations

✅ Good Decisions

  • 120fps cap is reasonable: Browsers throttle RAF to display refresh rate anyway
  • Adaptive network sync: 1fps solo mode vs 30fps collaborative is a smart optimization
  • WeakMap usage: No manual cleanup needed, prevents memory leaks

⚠️ Potential Issues

  1. Double Date.now() calls: In the custom FPS path, Date.now() is called twice - once in the check and once in flush. Consider storing the timestamp once.

  2. Global vs Per-Function Throttling: The global targetTimePerFrame (line 9) is still 120fps, but individual functions can have custom rates. This means:

    • A 1fps function will still be flushed up to 120fps if other functions are queued
    • This is probably fine, but worth documenting the behavior

Security Concerns

✅ No security issues identified. The changes don't introduce any attack vectors.

Test Coverage

⚠️ Missing Tests: No test files for throttle functionality found. The PR should include tests for:

  1. Custom FPS throttling behavior
  2. Adaptive FPS in TLSyncClient
  3. Edge cases (rapid calls, FPS changes, etc.)

Recommendation: Add tests in packages/utils/src/lib/throttle.test.ts

API Changes

The API change is documented in the api-report:

export function fpsThrottle(fn: {
    (): void;
    cancel?(): void;
}, getTargetFps?: () => number): {
    (): void;
    cancel?(): void;
};

✅ Backward compatible (optional parameter)
✅ Well-typed

Recommendations

High Priority

  1. Fix the timestamp update location to prevent the race condition
  2. Add tests for the new functionality

Medium Priority

  1. Consider renaming getTargetFps for clarity
  2. Document the global vs per-function throttling interaction
  3. Move WeakMap setup to avoid dead code in test mode

Low Priority

  1. Optimize double Date.now() calls
  2. Add inline comments explaining the solo vs collaborative FPS rationale

Conclusion

The core idea is solid and the implementation is mostly clean. The main concerns are the timestamp update timing and lack of tests. Once those are addressed, this should be a good improvement for high refresh rate displays.

Overall Assessment: ⚠️ Approve with changes requested

Copy link
Member

@mimecuvalo mimecuvalo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice! i'm down with this change.

  • nit: can we also pull the change from #6470 that updates DefaultDebugPanel.tsx‎?
  • does this work as expected on iOS?


/** Get the target FPS for network operations based on presence mode */
private getSyncFps(): number {
return this.presenceMode?.get() === 'solo' ? SOLO_MODE_FPS : COLLABORATIVE_MODE_FPS
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, much clearer than my version!

const targetFps = 60
const targetTimePerFrame = Math.floor(1000 / targetFps) * 0.9 // ~15ms - we allow for some variance as browsers aren't that precise.
const targetFps = 120
const targetTimePerFrame = Math.floor(1000 / targetFps) * 0.9 // ~7ms - we allow for some variance as browsers aren't that precise.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually, is the 0.9 still necessary? i'm forgetting a bit on why this was needed... let's expand the comment if we still need this, we're gonna forget again in the future why we did this 🙃

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment here.

@MitjaBezensek
Copy link
Contributor Author

MitjaBezensek commented Oct 3, 2025

nice! i'm down with this change.

Go to settings Apps
Safari
Advanced
Feature flags
Turn off prefer page rendering near 60hz
This was introduced in iOS 18 but a lot of people do not know about it!

@mimecuvalo
Copy link
Member

  • does this work as expected on iOS?

awesome, thanks for the info. actually to be crisper with my question: does this cause any issues on iOS with the browser trying to go too fast than it can go? (less of a question of whether it can go to 120hz but is it "overclocking" and causing it to freak out? :P)

@MitjaBezensek
Copy link
Contributor Author

  • does this work as expected on iOS?

awesome, thanks for the info. actually to be crisper with my question: does this cause any issues on iOS with the browser trying to go too fast than it can go? (less of a question of whether it can go to 120hz but is it "overclocking" and causing it to freak out? :P)

I didn't see any issues while testing 🤷‍♂️

I guess it would work pretty much the same as it did till now: we were setting the target to 60 but some actions were too slow so the fps dropped. Now we set a higher default so more actions will fall into that category. But from my tests the rendering stabilizes at the same fps as before.

With the default being 60fps in Safari I'd expect many people on iOS won't even know the difference. Would be great if we could play with some older devices, though. Maybe some old androids with high refresh screens.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants