Skip to content

Conversation

@Dadzemovic
Copy link

@Dadzemovic Dadzemovic commented Jan 21, 2026

Bug Report: Socket Reference Shape Mismatch in Event Queue

Summary

A type inconsistency in Reflex's frontend event processing causes storage-related events to fail and fall back to a slower recovery path. This adds unnecessary latency to cookie removal and localStorage/sessionStorage operations. The proposed fix eliminates silent failures, improves latency for these operations, and makes the event queue self-sufficient rather than dependent on implicit recovery.


Background

The Event Queue System

Reflex processes events one at a time to maintain order and consistency:

let event_processing = false;      // Lock flag
const event_queue = [];            // Pending events

export const processEvent = async (socket, ...) => {
    // Only one event at a time
    if (event_queue.length === 0 || event_processing) {
        return;
    }

    event_processing = true;       // Acquire lock
    const event = event_queue.shift();

    // ... send event via socket ...

    event_processing = false;      // Release lock
};

Socket References in React

React uses "refs" to hold mutable values that persist across renders:

// A ref is an object with a .current property
const socket = useRef(null);

// Access the actual socket:
socket.current.emit("event", data);

// The ref object itself:
socket          // { current: Socket }
socket.current  // Socket

This distinction between socket (the ref) and socket.current (the actual socket) is where the bug occurs.

Why Use a Ref for the Socket?

In React, component functions re-run on every render. If you stored the socket in a regular variable, it would be recreated each time:

// BAD: Socket would be recreated on every render
function App() {
    const socket = io.connect("...");  // New socket every render!
    return <div>...</div>;
}

Refs solve this by providing a stable container that persists across renders:

// GOOD: Socket is created once and persists
function App() {
    const socket = useRef(null);

    useEffect(() => {
        socket.current = io.connect("...");  // Created once
    }, []);

    return <div>...</div>;
}

The ref object { current: Socket } stays the same across renders, while socket.current holds the actual WebSocket connection.

Why Is the Socket Sometimes a Ref and Sometimes Raw?

This inconsistency arises from how event processing is structured:

Path 1: Initial Event Processing (Uses Ref)

When React's event loop processes events, it passes the ref:

// In the main useEffect hook:
await processEvent(socket.current, navigate, params);
//                 ^^^^^^^^^^^^^^
//                 Extracts raw socket from ref, passes it directly

Path 2: Event Handler Chain (Passes Raw Socket Along)

When processEvent calls applyEvent, it passes the raw socket it received:

// processEvent receives raw socket, passes it to applyEvent:
eventSent = await applyEvent(event, socket, navigate, params);
//                                  ^^^^^^
//                                  This is the RAW socket (already extracted)

Path 3: Client-Side Events Queue More Events (Expects Ref)

When handling events like _clear_local_storage, the code queues follow-up events:

// In applyEvent:
if (event.name == "_clear_local_storage") {
    localStorage.clear();
    queueEventIfSocketExists(initialEvents(), socket, navigate, params);
    //                                        ^^^^^^
    //                                        Still the RAW socket!
}

Path 4: queueEvents Assumes Ref (BUG!)

Finally, queueEvents assumes it received a ref:

// In queueEvents:
await processEvent(socket.current, navigate, params);
//                 ^^^^^^^^^^^^^^
//                 Tries to access .current on a raw socket
//                 socket.current is UNDEFINED!

Visual Summary of the Problem

React Component
    │
    │ socket = useRef(null)      ← socket is a REF: { current: Socket }
    │
    ▼
processEvent(socket.current)     ← Extracts raw Socket, passes it
    │
    │ socket = Socket            ← socket is now RAW (no .current)
    │
    ▼
applyEvent(event, socket)        ← Receives raw Socket, passes it along
    │
    │ socket = Socket            ← still RAW
    │
    ▼
queueEventIfSocketExists(..., socket)
    │
    │ socket = Socket            ← still RAW
    │
    ▼
queueEvents(..., socket)
    │
    │ socket = Socket            ← still RAW
    │
    ▼
processEvent(socket.current)     ← BUG! socket.current is undefined
                                    because socket is already the raw Socket

The bug is a shape mismatch: the code at the end of the chain expects a ref, but receives a raw socket because the socket was "unwrapped" earlier in the chain and never re-wrapped.


The Bug

Problem Statement

The queueEvents function sometimes receives a socket ref (an object with .current) and sometimes receives the raw socket object directly. The code assumes it always receives a ref:

// In queueEvents:
await processEvent(socket.current, navigate, params);
//                 ^^^^^^^^^^^^^^
//                 Assumes socket is a ref with .current
//                 But sometimes socket IS the raw socket!

When socket is already the raw socket object:

  • socket.current is undefined
  • processEvent(undefined, ...) is called
  • The socket validity check fails
  • The function returns early without processing events

Code Location

File: reflex/.templates/web/utils/state.js

Buggy Code (lines 470-471):

export const queueEvents = async (events, socket, prepend, navigate, params) => {
    // ... queue management ...
    event_queue.push(...events);
    await processEvent(socket.current, navigate, params);  // BUG HERE
};

When This Happens

The bug triggers when handling any of these 5 client-side storage/cookie events:

Event Name Reflex API What It Does
_remove_cookie rx.remove_cookies() Removes a browser cookie
_clear_local_storage rx.clear_local_storage() Clears all localStorage
_remove_local_storage rx.remove_local_storage() Removes a localStorage key
_clear_session_storage rx.clear_session_storage() Clears all sessionStorage
_remove_session_storage rx.remove_session_storage() Removes a sessionStorage key

All of these events need to trigger a rehydration after clearing data (so the UI reflects the cleared state). They do this by queuing initialEvents():

// In applyEvent function:
if (event.name == "_clear_local_storage") {
    localStorage.clear();
    queueEventIfSocketExists(initialEvents(), socket, navigate, params);
    //                                        ^^^^^^
    //                                        This socket is already raw!
    return false;
}

// Same pattern for all 5 events:
if (event.name == "_remove_cookie") { ... queueEventIfSocketExists(..., socket, ...); }
if (event.name == "_clear_local_storage") { ... queueEventIfSocketExists(..., socket, ...); }
if (event.name == "_remove_local_storage") { ... queueEventIfSocketExists(..., socket, ...); }
if (event.name == "_clear_session_storage") { ... queueEventIfSocketExists(..., socket, ...); }
if (event.name == "_remove_session_storage") { ... queueEventIfSocketExists(..., socket, ...); }

Regular events (like button clicks, form submits, etc.) do NOT trigger this bug because they don't call queueEventIfSocketExists. They go directly to the backend via WebSocket without queuing follow-up events.

The call chain is:

  1. processEvent(socket, ...) — socket is raw (already resolved)
  2. applyEvent(event, socket, ...) — socket is still raw
  3. queueEventIfSocketExists(..., socket, ...) — socket is still raw
  4. queueEvents(..., socket, ...) — socket is still raw
  5. processEvent(socket.current, ...)BUG! socket.current is undefined

The Recovery Mechanism

A useEffect hook runs on every React render and drains the event queue:

useEffect(() => {
    while (event_queue.length > 0 && !event_processing) {
        await processEvent(socket.current, navigate, params);
        //                 ^^^^^^^^^^^^^^
        //                 Here socket IS a ref, so .current works
    }
});

This recovery mechanism masks the bug by eventually processing the stuck events.


Evidence

Reproduction Steps

  1. Create a Reflex app with localStorage functionality
  2. Add instrumentation to track when processEvent bails out
  3. Click "Clear LocalStorage"
  4. Observe the bail-out in console logs

Test Application

"""Reproduction: Event queue stall after clear_local_storage."""
import reflex as rx


class State(rx.State):
    stored: str = rx.LocalStorage("", name="repro_stored")
    seen_value: str = ""

    def set_local(self):
        self.stored = "set_via_state"
        self.seen_value = self.stored

    def on_load(self):
        self.seen_value = self.stored


def index() -> rx.Component:
    return rx.vstack(
        rx.heading("Repro: LocalStorage + clear_local_storage"),
        rx.text("stored (LocalStorage): ", State.stored),
        rx.text("seen_value (from on_load): ", State.seen_value),
        rx.hstack(
            rx.button("Set LocalStorage", on_click=State.set_local),
            rx.button("Clear LocalStorage", on_click=rx.clear_local_storage()),
        ),
        rx.text("Expected: after Clear, values should become empty via rehydrate."),
        spacing="4",
        padding="8",
    )


app = rx.App()
app.add_page(index, on_load=State.on_load)

Instrumentation Patch

To observe the bug, add this instrumentation to state.js:

// In processEvent, before the socket validity check:
if (isStateful() && !(socket && socket.connected)) {
    console.log('%c[SOCKET-BUG] processEvent BAILED', 'color: red; font-weight: bold',
        'socket:', socket,
        'queue length:', event_queue.length);
    return;
}

Observed Console Output

[SOCKET-BUG] queueEvents calling processEvent with socket.current: undefined raw socket: Socket {connected: true, ...}
[SOCKET-BUG] processEvent BAILED - socket: undefined, queue length: 2
[SOCKET-BUG] Recovery useEffect triggered, queue length: 2

This proves:

  1. socket is the raw Socket object (not a ref)
  2. socket.current is undefined
  3. processEvent bails out
  4. Events are stuck until recovery runs

Impact Analysis

What Happens When the Bug Triggers

Step What Occurs
1 User clicks "Clear LocalStorage"
2 _clear_local_storage event is handled
3 initialEvents() (rehydration) is queued
4 processEvent(undefined, ...) is called
5 Function bails out early (socket invalid)
6 Events stuck in queue
7 Recovery useEffect runs on next React render
8 Events are finally processed

Practical User Impact

The recovery mechanism runs on the next React render, which typically happens within:

  • Fast devices: 5-20ms
  • Slow devices: 50-100ms
  • Heavy applications: 100-200ms

This delay is generally not perceptible to users (human perception threshold ~100-200ms).

When Impact Could Be Higher

Scenario Risk
Very slow device with heavy React app Delay could reach 200-500ms
Recovery useEffect is removed in future refactor Events would be permanently stuck
Error occurs in recovery path Events would be permanently stuck
Component unmounts before recovery Events would be lost

Why Fix Now?

  1. The code is objectively wrong — It calls .current on a non-ref object
  2. Silent failures are bad — The code fails and relies on implicit recovery
  3. Future-proofing — If recovery is removed, bug becomes critical
  4. Debugging difficulty — "Why did this take an extra render?" is hard to trace
  5. Consistency — Socket handling should be predictable throughout the codebase

The Fix

Solution

Add a helper function that handles both socket shapes:

/**
 * Resolve a socket reference to the actual socket object.
 * Handles both ref objects ({ current: Socket }) and raw sockets.
 *
 * @param socket - Either a ref object or raw socket
 * @returns The actual socket object
 */
const resolveSocket = (socket) => {
    return socket?.current ?? socket;
};

Then use it in queueEvents:

export const queueEvents = async (events, socket, prepend, navigate, params) => {
    // ... queue management ...
    event_queue.push(...events);
    await processEvent(resolveSocket(socket), navigate, params);  // FIXED
};

Diff

+const resolveSocket = (socket) => {
+  return socket?.current ?? socket;
+};
+
 export const queueEvents = async (
   events,
   socket,
   prepend,
   navigate,
   params,
 ) => {
   // ... existing code ...
   event_queue.push(...events.filter((e) => e !== undefined && e !== null));
-  await processEvent(socket.current, navigate, params);
+  await processEvent(resolveSocket(socket), navigate, params);
 };

Why This Fix is Safe

Concern Mitigation
Could break existing behavior? No — handles both cases explicitly
Could introduce new bugs? No — pure function with no side effects
Tested? Yes — both socket shapes work correctly
Backwards compatible? Yes — existing code paths unchanged

Report generated from deterministic simulation testing.

Add resolveSocket() helper to handle both ref objects ({ current: Socket })
and raw sockets, fixing a bug where queueEvents would call socket.current
on an already-unwrapped socket, causing processEvent to bail out.
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Jan 21, 2026

Greptile Summary

This PR fixes a type inconsistency in the queueEvents function where it incorrectly assumed the socket parameter was always a React ref object with a .current property. The bug occurred when storage-related events (_clear_local_storage, _remove_cookie, etc.) queued follow-up rehydration events - the socket was passed as a raw object rather than a ref, causing socket.current to be undefined and processEvent to bail out early.

The fix introduces a resolveSocket helper that safely handles both socket shapes:

  • If socket is a ref object: returns socket.current (the actual Socket)
  • If socket is already raw: returns socket itself
  • Uses optional chaining (?.) and nullish coalescing (??) for safe access

Key Changes:

  • Added resolveSocket helper function at state.js:454-456
  • Updated queueEvents to call resolveSocket(socket) instead of socket.current at state.js:475

Impact:
The bug was masked by a recovery mechanism in useEventLoop that eventually processed stuck events on the next render. This fix eliminates the silent failure path, removes unnecessary render-dependent latency for storage operations, and ensures the event queue is self-sufficient.

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk - it fixes a real bug with a defensive, backwards-compatible solution
  • The fix is well-designed and uses a defensive programming approach. The resolveSocket helper is a pure function with no side effects that handles both socket shapes correctly. The change is minimal (4 lines added, 1 line modified), backwards-compatible, and doesn't alter existing behavior for correctly-typed sockets. The PR description provides excellent documentation of the problem, evidence, and impact analysis.
  • No files require special attention

Important Files Changed

Filename Overview
reflex/.templates/web/utils/state.js Adds resolveSocket helper to handle both ref and raw socket objects, fixing type inconsistency in queueEvents

Sequence Diagram

sequenceDiagram
    participant React as React Component
    participant useEventLoop as useEventLoop Hook
    participant queueEvents as queueEvents
    participant processEvent as processEvent
    participant applyEvent as applyEvent
    participant queueEventIfSocketExists as queueEventIfSocketExists
    participant resolveSocket as resolveSocket (NEW)

    Note over React: socket = useRef(null)
    Note over React: socket.current = Socket instance

    React->>queueEvents: queueEvents(events, socket, ...)
    Note over queueEvents: socket is REF { current: Socket }
    queueEvents->>resolveSocket: resolveSocket(socket)
    resolveSocket-->>queueEvents: Returns socket.current (raw Socket)
    queueEvents->>processEvent: processEvent(raw Socket, ...)
    
    processEvent->>applyEvent: applyEvent(event, raw Socket, ...)
    Note over applyEvent: Handles _clear_local_storage event
    applyEvent->>queueEventIfSocketExists: queueEventIfSocketExists(initialEvents(), raw Socket, ...)
    Note over queueEventIfSocketExists: socket is now RAW Socket
    queueEventIfSocketExists->>queueEvents: queueEvents(events, raw Socket, ...)
    Note over queueEvents: socket is RAW Socket { connected: true, ... }
    queueEvents->>resolveSocket: resolveSocket(socket)
    resolveSocket-->>queueEvents: Returns socket (already raw)
    queueEvents->>processEvent: processEvent(raw Socket, ...)
    
    Note over queueEvents,processEvent: FIX: resolveSocket handles both shapes<br/>Without fix: socket.current would be undefined
Loading

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Jan 21, 2026

Greptile found no issues!

From now on, if a review finishes and we haven't found any issues, we will not post anything, but you can confirm that we reviewed your changes in the status check section.

This feature can be toggled off in your Code Review Settings by deselecting "Create a status check for each PR".

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.

1 participant