Skip to content
Open
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
3 changes: 3 additions & 0 deletions next-cloudinary/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@tsconfig/recommended": "^1.0.7",
"@types/node": "^22.0.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"dotenv": "^16.4.5",
"happy-dom": "^17.6.3",
"mkdirp": "^3.0.1",
"tsup": "^8.2.3",
"typescript": "^5.5.4",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
CloudinaryUploadWidgetError
} from '@cloudinary-util/types';

import { triggerOnIdle } from '../../lib/util';
import { useTriggerOnIdle } from '../../lib/util';

import {
CldUploadEventAction,
Expand All @@ -36,6 +36,7 @@ const CldUploadWidget = ({
}: CldUploadWidgetProps) => {
const cloudinary: CldUploadWidgetCloudinaryInstance = useRef();
const widget: CldUploadWidgetWidgetInstance = useRef();
const triggerOnIdle = useTriggerOnIdle();

const [error, setError] = useState<CloudinaryUploadWidgetError | undefined>(undefined);
const [results, setResults] = useState<CloudinaryUploadWidgetResults | undefined>(undefined);
Expand Down
67 changes: 65 additions & 2 deletions next-cloudinary/src/lib/util.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,74 @@
import { useRef, useEffect, useCallback } from 'react';

/**
* triggerOnIdle
* @see MDN Polyfill https://github.com/behnammodi/polyfill/blob/master/window.polyfill.js#L7-L24
*/

export function triggerOnIdle(callback: any) {
if ( window && 'requestIdleCallback' in window ) {
/**
* Check if requestIdleCallback is supported in the current environment
*/
function isRequestIdleCallbackSupported(): boolean {
return typeof window !== 'undefined' && 'requestIdleCallback' in window;
}

/**
* Cancel an idle callback safely
*/
function cleanupIdleCallback(callbackId: number | NodeJS.Timeout | undefined): void {
if (callbackId === undefined) return;

if (isRequestIdleCallbackSupported()) {
cancelIdleCallback(callbackId as number);
} else {
clearTimeout(callbackId as NodeJS.Timeout);
}
}

/**
* Trigger a callback when the browser is idle
*/
function triggerOnIdle(callback: any) {
if (isRequestIdleCallbackSupported()) {
return requestIdleCallback(callback);
}
return setTimeout(() => callback(), 1);
}

/**
* Custom hook for managing a single idle callback with automatic cleanup
* Returns a function to trigger idle callbacks
*/
export function useTriggerOnIdle() {
const callbackId = useRef<number | NodeJS.Timeout | undefined>(undefined);

const triggerOnIdleWithCleanup = useCallback((callback: any) => {
// Clean up any existing callback first
if (callbackId.current !== undefined) {
cleanupIdleCallback(callbackId.current);
}

// Trigger new callback
const currentId = triggerOnIdle(() => {
// Only execute if this callback ID is still current (not cancelled)
if (callbackId.current === currentId) {
callbackId.current = undefined; // Clear after execution
callback();
}
});

callbackId.current = currentId;
}, []);

// Cleanup on unmount
useEffect(() => {
return () => {
if (callbackId.current !== undefined) {
cleanupIdleCallback(callbackId.current);
callbackId.current = undefined;
}
};
}, []);

return triggerOnIdleWithCleanup;
}
132 changes: 132 additions & 0 deletions next-cloudinary/tests/lib/util.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { vi, describe, it, beforeEach, afterEach, expect } from 'vitest';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { useTriggerOnIdle } from '../../src/lib/util';

/**
* Tests for useTriggerOnIdle hook - Race Condition Prevention
*
* These tests validate that the useTriggerOnIdle hook properly prevents
* race conditions between requestIdleCallback and component cleanup.
*
* The original issue: widgets could be created after component unmount
* when requestIdleCallback executed after the cleanup function ran.
*/
describe('useTriggerOnIdle Hook', () => {
let mockRequestIdleCallback;
let mockCancelIdleCallback;
let originalRequestIdleCallback;
let originalCancelIdleCallback;

beforeEach(() => {
// Store originals
originalRequestIdleCallback = global.requestIdleCallback;
originalCancelIdleCallback = global.cancelIdleCallback;

// Setup mocks
mockRequestIdleCallback = vi.fn();
mockCancelIdleCallback = vi.fn();

// Mock the browser APIs
global.requestIdleCallback = mockRequestIdleCallback;
global.cancelIdleCallback = mockCancelIdleCallback;
});

afterEach(() => {
vi.clearAllMocks();
// Restore originals
global.requestIdleCallback = originalRequestIdleCallback;
global.cancelIdleCallback = originalCancelIdleCallback;
});

it('should cancel pending callback when component unmounts quickly (preventing race condition)', () => {
const callbackId = 123;
let callbackExecuted = false;

// Mock requestIdleCallback to return an ID and store the callback
let storedCallback = null;
mockRequestIdleCallback.mockImplementation((callback) => {
storedCallback = callback;
return callbackId;
});

// Test the hook directly using renderHook
const { result, unmount } = renderHook(() => useTriggerOnIdle(), {
// Provide a minimal wrapper that doesn't require document
wrapper: ({ children }) => children,
});

// Trigger an idle callback (like CldUploadWidget does in handleOnLoad)
act(() => {
result.current(() => {
callbackExecuted = true;
});
});

// Verify requestIdleCallback was called
expect(mockRequestIdleCallback).toHaveBeenCalledTimes(1);
expect(storedCallback).toBeTruthy();

// Unmount the hook (simulates component cleanup)
unmount();

// Verify cancelIdleCallback was called during cleanup
expect(mockCancelIdleCallback).toHaveBeenCalledWith(callbackId);

// Execute the stored callback (simulating browser calling it after cancel)
// This demonstrates the race condition scenario we're preventing
if (storedCallback) {
storedCallback();
}

// The callback should not have executed because it was cancelled
expect(callbackExecuted).toBe(false);
});

it('should handle rapid mount/unmount preventing memory leaks', () => {
const callbackIds = [111, 222];
let executionCount = 0;

mockRequestIdleCallback
.mockReturnValueOnce(callbackIds[0])
.mockReturnValueOnce(callbackIds[1]);

// First mount/unmount cycle
const { result: result1, unmount: unmount1 } = renderHook(() => useTriggerOnIdle(), {
wrapper: ({ children }) => children,
});

act(() => {
result1.current(() => {
executionCount++;
});
});

expect(mockRequestIdleCallback).toHaveBeenCalledTimes(1);

// Quick unmount (like navigating away)
unmount1();
expect(mockCancelIdleCallback).toHaveBeenCalledWith(callbackIds[0]);

// Second mount/unmount cycle
const { result: result2, unmount: unmount2 } = renderHook(() => useTriggerOnIdle(), {
wrapper: ({ children }) => children,
});

act(() => {
result2.current(() => {
executionCount++;
});
});

expect(mockRequestIdleCallback).toHaveBeenCalledTimes(2);

// Quick unmount again
unmount2();
expect(mockCancelIdleCallback).toHaveBeenCalledWith(callbackIds[1]);

// No callbacks should have executed due to quick unmounts
expect(executionCount).toBe(0);
expect(mockCancelIdleCallback).toHaveBeenCalledTimes(2);
});
});
7 changes: 7 additions & 0 deletions next-cloudinary/vitest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
environment: 'happy-dom',
},
})
Loading