Skip to content

Conversation

@KyleAMathews
Copy link
Contributor

When a shape receives multiple updates within the HTTP cache window (60s), each update ends with an up-to-date control message that gets cached. On page refresh, these cached responses replay rapidly, causing multiple renders. This change implements cursor-based detection to suppress cached up-to-date notifications until a fresh response (with a new cursor) arrives from the server, ensuring only one render occurs.

Addresses issue where multiple up-to-date control messages from HTTP cache
trigger excessive renders on page refresh.

When a shape receives multiple updates within the 60-second cache window
(s-maxage), each update cycle ends with an up-to-date control message.
These responses are cached by HTTP proxies/browser. On page refresh, the
client retrieves multiple cached responses in quick succession, with each
up-to-date message triggering a render, resulting in sloppy UI updates.

Solution: Hybrid approach with localStorage persistence and time-window
deduplication:

1. **UpToDateTracker class**: Tracks up-to-date messages per shape using:
   - localStorage persistence (60s TTL matching HTTP cache)
   - In-memory suppression window (1s for rapid messages)
   - LRU eviction for cache size management

2. **ShapeStream integration**: Before notifying subscribers of an
   up-to-date message, checks the tracker to determine if notification
   should be suppressed. Always allows live (SSE) messages through.

3. **State preservation**: Internal state (#isUpToDate, #lastSyncedAt)
   is updated even when notifications are suppressed, ensuring correct
   stream behavior.

Benefits:
- Solves page refresh problem with localStorage persistence
- Adds defense-in-depth with rapid-message deduplication
- Gracefully degrades when localStorage unavailable
- Follows existing codebase patterns (ExpiredShapesCache)
- Maintains backward compatibility

Testing: Added comprehensive unit tests covering TTL expiration,
localStorage persistence, LRU eviction, and edge cases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Implements a simpler "replay mode" approach to prevent multiple renders
from cached up-to-date messages on page refresh.

## Problem
When a shape receives multiple updates within the 60-second HTTP cache
window, each ends with an up-to-date control message. On page refresh,
these cached responses replay rapidly, causing multiple renders:

Response 1 (cached): [data] + up-to-date → RENDER
Response 2 (cached): [data] + up-to-date → RENDER
Response 3 (cached): [data] + up-to-date → RENDER
Result: 3 renders in ~50ms = sloppy UI

## Solution: Binary Replay Mode Flag

Instead of counting up-to-dates (error-prone), we use a simple flag:

### On receiving up-to-date (normal operation):
- Record timestamp in localStorage (60s TTL)

### On page load (offset=-1):
- Check if recent up-to-date exists in localStorage
- If yes → enter "replay mode"

### During replay mode:
- Suppress ALL cached up-to-dates
- Exit mode when receiving live/SSE message
- Notify subscribers only on live up-to-date

### Result:
Response 1 (cached): [data] + up-to-date → suppress
Response 2 (cached): [data] + up-to-date → suppress
Response 3 (live):   [data] + up-to-date → RENDER ONCE

## Changes

1. **UpToDateTracker** (simplified):
   - `recordUpToDate(shapeKey)`: Record timestamp
   - `shouldEnterReplayMode(shapeKey)`: Check for recent timestamp
   - No counting, no complex logic

2. **ShapeStream**:
   - Add `#replayMode` flag
   - Check for replay mode on startup (in #fetchShape)
   - Suppress cached up-to-dates in #onMessages
   - Exit replay mode on first live/SSE message
   - Record up-to-date when allowing notification

3. **Tests**: Updated to reflect replay mode behavior

## Benefits
- No off-by-one errors (no counting)
- Natural termination (exits on live message)
- Guarantees fresh data (always does live round-trip)
- Simple binary flag logic
- Follows existing patterns (ExpiredShapesCache)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Fixes critical bug where replay mode never exits for long-polling users.

## Bug
Previous logic only exited replay mode for SSE messages:
```typescript
if (this.#replayMode && !isSseMessage) {
  return  // Suppress
}
this.#replayMode = false  // Only reached if SSE
```

For long-polling users:
- isSseMessage is always false
- All up-to-dates suppressed, even fresh ones
- Replay mode never exits
- No notifications ever sent! 😱

## Fix
Check if request is in live mode (has `live=true` param):
```typescript
const isLiveRequest =
  this.#currentFetchUrl?.searchParams.has(LIVE_QUERY_PARAM) ?? false

if (this.#replayMode && !isSseMessage && !isLiveRequest) {
  return  // Suppress cached responses during initial sync
}

this.#replayMode = false  // Exit on live request OR SSE
```

Now works for both:
- SSE users: exits on first SSE message
- Long-poll users: exits when switching to live mode

## Also Fixed
- Clarified s-maxage comment (CDN cache, not browser cache)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Implements cursor-based detection to properly exit replay mode and prevent
multiple renders from cached up-to-date messages.

## Problem with Previous Approach
Previous fix checked for `live=true` param, but live requests are also cached!

Scenario:
```
Session 1:
- Request live=true&cursor=100 → up-to-date (cached)
- Request live=true&cursor=101 → up-to-date (cached)
- Request live=true&cursor=102 → up-to-date (cached)

Session 2 (page refresh):
- Request live=true&cursor=100 → CACHED, but has live=true!
- ❌ Exit replay mode, notify subscribers (WRONG!)
- Request live=true&cursor=101 → CACHED
- ❌ Not in replay mode, notify again (WRONG!)
- Result: Still getting multiple renders!
```

## Solution: Cursor-Based Detection

Cursors are time-based intervals that always increment (server code analysis):
```elixir
next_interval = ceil(time_since_epoch / timeout) * timeout
if next_interval == prev_interval:
  next_interval + random(1..3600)  # Always moves forward
```

**Key insight:** Fresh responses have a DIFFERENT cursor than cached ones.

### Implementation

1. **Store cursor in localStorage** (not just timestamp):
   ```typescript
   recordUpToDate(shapeKey, cursor: string)
   ```

2. **Enter replay mode with last cursor**:
   ```typescript
   const lastSeenCursor = upToDateTracker.shouldEnterReplayMode(shapeKey)
   if (lastSeenCursor) {
     this.#replayMode = lastSeenCursor  // Store cursor, not just boolean
   }
   ```

3. **Exit on cursor change**:
   ```typescript
   if (currentCursor === lastSeenCursor) {
     return  // Still replaying cached, same cursor
   }
   // Different cursor = fresh from server, exit replay mode!
   ```

### Why This Works

- Multiple cached responses: **same cursor** → suppress all
- First fresh response: **different cursor** → exit replay mode, notify once!
- Works for both long-polling and SSE
- No complex timing logic needed

## Changes

- **UpToDateTracker**: Store cursor with timestamp
- **ShapeStream**: Store cursor in `#replayMode`, compare on up-to-date
- **Type change**: `#replayMode: false | string` (cursor when active)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Adds throttling to avoid excessive localStorage writes as every incremental
message includes an up-to-date control message.

## Why Throttle?

Up-to-date messages arrive frequently:
- Every incremental sync cycle ends with up-to-date
- High-activity shapes can trigger 10-60+ writes/minute
- localStorage writes are synchronous and block main thread (1-10ms typical)

## Implementation

- **In-memory always current**: Data updates immediately
- **localStorage throttled**: Writes max once per 60 seconds
- **Smart scheduling**: If within throttle window, schedules write for later
- **No data loss risk**: Only affects persistence, not current session

## Trade-off

Worst case: User refreshes page within 60s of last write before a crash.
They might see a brief flash of slightly older cached data.
This is acceptable as it's rare and non-critical.

## Performance Benefit

Before: Up to 60 localStorage writes/minute = potential jank
After: Max 1 localStorage write/minute = smooth

60s throttle matches the cache TTL, making it a natural boundary.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
@codecov
Copy link

codecov bot commented Oct 30, 2025

Codecov Report

❌ Patch coverage is 93.90244% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.08%. Comparing base (6f9bba2) to head (b6adc5d).

Files with missing lines Patch % Lines
...ckages/typescript-client/src/up-to-date-tracker.ts 93.93% 4 Missing ⚠️
packages/typescript-client/src/client.ts 93.75% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3358      +/-   ##
==========================================
- Coverage   70.14%   70.08%   -0.06%     
==========================================
  Files         178      182       +4     
  Lines        9606     9893     +287     
  Branches      290      363      +73     
==========================================
+ Hits         6738     6934     +196     
- Misses       2866     2957      +91     
  Partials        2        2              
Flag Coverage Δ
elixir 66.97% <ø> (+0.07%) ⬆️
elixir-client 73.94% <ø> (-0.53%) ⬇️
packages/experimental 87.73% <ø> (ø)
packages/react-hooks 86.48% <ø> (ø)
packages/typescript-client 94.16% <93.90%> (-0.03%) ⬇️
packages/y-electric 55.12% <ø> (?)
postgres-140000 66.00% <ø> (+0.03%) ⬆️
postgres-170000 66.04% <ø> (+0.05%) ⬆️
postgres-180000 66.04% <ø> (?)
sync-service 66.28% <ø> (+0.13%) ⬆️
typescript 87.70% <93.90%> (-5.14%) ⬇️
unit-tests 70.08% <93.90%> (-0.06%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

claude and others added 2 commits October 30, 2025 13:18
Updated all test cases to use the new recordUpToDate signature that requires
a cursor argument. Also updated assertions to expect cursor strings instead
of booleans, and fixed localStorage mock data structures to match the new
UpToDateEntry interface with timestamp and cursor fields.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Copy link
Contributor

@kevin-dp kevin-dp left a comment

Choose a reason for hiding this comment

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

LGTM. Left a few nits.

#activeSnapshotRequests = 0 // counter for concurrent snapshot requests
#midStreamPromise?: Promise<void>
#midStreamPromiseResolver?: () => void
#replayMode: false | string = false // False or last seen cursor when replaying cached responses
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: this variable serves 2 purposes: storing the last seen cursor and indicating whether we're in replay mode or not. I'd go for #lastSeenCursor: string | undefined and then we can derive replay mode, e.g. get #replayMode() = #lastSeenCursor !== undefined.

// Check if the cursor has changed - cursors are time-based and always
// increment, so a new cursor means fresh data from the server.
const currentCursor = this.#liveCacheBuster
const lastSeenCursor =
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we need this logic? The if test on L843 if (this.#replayMode && ...) checks the replay mode, so if we're here, the #replayMode must be a string. So the ternary seems unnecessary and the lastSeenCursor check in in the if test on L851 seems unnecessary too.

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.

4 participants