mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 09:13:04 +02:00
Do an imitation of protecting our butts (race conditions in message loading, websocket defensiveness, optimistic UI update rollback handling
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Tests for API utilities.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAbortError } from '../api';
|
||||
|
||||
describe('isAbortError', () => {
|
||||
it('returns true for AbortError', () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
// Create an error that mimics fetch abort
|
||||
const error = new DOMException('The operation was aborted', 'AbortError');
|
||||
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for Error with name AbortError', () => {
|
||||
const error = new Error('Request cancelled');
|
||||
error.name = 'AbortError';
|
||||
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for regular Error', () => {
|
||||
const error = new Error('Something went wrong');
|
||||
|
||||
expect(isAbortError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for TypeError', () => {
|
||||
const error = new TypeError('Network failure');
|
||||
|
||||
expect(isAbortError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for null', () => {
|
||||
expect(isAbortError(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isAbortError(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-Error objects', () => {
|
||||
expect(isAbortError({ message: 'error' })).toBe(false);
|
||||
expect(isAbortError('error string')).toBe(false);
|
||||
expect(isAbortError(42)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for Error subclasses with different names', () => {
|
||||
class CustomError extends Error {
|
||||
constructor() {
|
||||
super('Custom error');
|
||||
this.name = 'CustomError';
|
||||
}
|
||||
}
|
||||
|
||||
expect(isAbortError(new CustomError())).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -193,3 +193,80 @@ describe('parseWebSocketMessage', () => {
|
||||
expect(onRawPacket).toHaveBeenCalledWith(packetData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useWebSocket ref-based handler pattern', () => {
|
||||
/**
|
||||
* These tests verify the pattern used in useWebSocket to avoid stale closures.
|
||||
* The hook stores handlers in a ref and accesses them through the ref in callbacks.
|
||||
* This ensures that when handlers are updated, the WebSocket still calls the latest version.
|
||||
*/
|
||||
|
||||
it('demonstrates ref pattern prevents stale closure', () => {
|
||||
// Simulate the ref pattern used in useWebSocket
|
||||
interface Handlers {
|
||||
onMessage?: (msg: string) => void;
|
||||
}
|
||||
|
||||
// This simulates what the hook does: store handlers in a ref
|
||||
const handlersRef: { current: Handlers } = { current: {} };
|
||||
|
||||
// First handler version
|
||||
const firstHandler = vi.fn();
|
||||
handlersRef.current = { onMessage: firstHandler };
|
||||
|
||||
// Simulate what onmessage does: access handlers through ref
|
||||
const processMessage = (data: string) => {
|
||||
// This is the pattern: access through ref.current, not closed-over variable
|
||||
handlersRef.current.onMessage?.(data);
|
||||
};
|
||||
|
||||
// Send first message
|
||||
processMessage('message1');
|
||||
expect(firstHandler).toHaveBeenCalledWith('message1');
|
||||
|
||||
// Update handler (simulates React re-render with new handler)
|
||||
const secondHandler = vi.fn();
|
||||
handlersRef.current = { onMessage: secondHandler };
|
||||
|
||||
// Send second message
|
||||
processMessage('message2');
|
||||
|
||||
// First handler should NOT be called again (would happen with stale closure)
|
||||
expect(firstHandler).toHaveBeenCalledTimes(1);
|
||||
// Second handler should be called (ref pattern works)
|
||||
expect(secondHandler).toHaveBeenCalledWith('message2');
|
||||
});
|
||||
|
||||
it('demonstrates stale closure problem without ref pattern', () => {
|
||||
// This demonstrates the bug we fixed - without refs, handlers become stale
|
||||
interface Handlers {
|
||||
onMessage?: (msg: string) => void;
|
||||
}
|
||||
|
||||
// First handler version
|
||||
const firstHandler = vi.fn();
|
||||
let handlers: Handlers = { onMessage: firstHandler };
|
||||
|
||||
// BAD PATTERN: capture handlers in closure (this is what we fixed)
|
||||
const capturedHandlers = handlers;
|
||||
const processMessageBad = (data: string) => {
|
||||
// This captures `capturedHandlers` at creation time - STALE!
|
||||
capturedHandlers.onMessage?.(data);
|
||||
};
|
||||
|
||||
// Send first message
|
||||
processMessageBad('message1');
|
||||
expect(firstHandler).toHaveBeenCalledWith('message1');
|
||||
|
||||
// Update handler
|
||||
const secondHandler = vi.fn();
|
||||
handlers = { onMessage: secondHandler };
|
||||
|
||||
// Send second message - BUG: still calls first handler!
|
||||
processMessageBad('message2');
|
||||
|
||||
// This demonstrates the stale closure bug
|
||||
expect(firstHandler).toHaveBeenCalledTimes(2); // Called twice - bug!
|
||||
expect(secondHandler).not.toHaveBeenCalled(); // Never called - bug!
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user