Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/tough-cheetahs-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rocket.chat/fuselage-hooks": minor
---

feat(fuselage-hooks): Implement `useSafeRefCallback`
1 change: 1 addition & 0 deletions packages/fuselage-hooks/src/useSafeRefCallback/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useSafeRefCallback';
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { render } from '@testing-library/react';

import { useSafeRefCallback } from './useSafeRefCallback';

const TestComponent = ({
callback,
renderSpan,
}: {
callback: any;
renderSpan?: boolean;
}) => {
const cbRef = useSafeRefCallback(callback);

if (renderSpan) {
return <span ref={cbRef} />;
}
return <div ref={cbRef} />;
};

describe('useSafeRefCallback', () => {
it('should work as a regular callbackRef if cleanup is not provided', () => {
const callback = jest.fn();

const { rerender, unmount } = render(<TestComponent callback={callback} />);

expect(callback).toHaveBeenCalledTimes(1);
expect(callback.mock.lastCall[0]).toBeInstanceOf(HTMLDivElement);

rerender(<TestComponent callback={callback} renderSpan />);

expect(callback).toHaveBeenCalledTimes(3);
expect(callback.mock.calls[1][0]).toBe(null);
expect(callback.mock.calls[2][0]).toBeInstanceOf(HTMLSpanElement);

unmount();

expect(callback).toHaveBeenCalledTimes(4);
expect(callback.mock.calls[3][0]).toBe(null);
});

it('should run again when callback reference changes', () => {
const callback = jest.fn();

const { rerender, unmount } = render(<TestComponent callback={callback} />);

expect(callback).toHaveBeenCalledTimes(1);
expect(callback.mock.lastCall[0]).toBeInstanceOf(HTMLDivElement);

const callback2 = jest.fn();

rerender(<TestComponent callback={callback2} />);

// Ensure first callback has been properly unmounted
expect(callback).toHaveBeenCalledTimes(2);
expect(callback.mock.calls[1][0]).toBe(null);

expect(callback2).toHaveBeenCalledTimes(1);
expect(callback2.mock.lastCall[0]).toBeInstanceOf(HTMLDivElement);

rerender(<TestComponent callback={callback2} renderSpan />);

expect(callback2).toHaveBeenCalledTimes(3);
expect(callback2.mock.calls[1][0]).toBe(null);
expect(callback2.mock.calls[2][0]).toBeInstanceOf(HTMLSpanElement);

unmount();

expect(callback2).toHaveBeenCalledTimes(4);
expect(callback2.mock.calls[3][0]).toBe(null);
});

it('should call cleanup with previous value on rerender', () => {
const cleanup = jest.fn();
const callback = jest.fn<() => void, any>(() => cleanup);

const { rerender, unmount } = render(<TestComponent callback={callback} />);

expect(callback).toHaveBeenCalledTimes(1);
expect(callback.mock.lastCall[0]).toBeInstanceOf(HTMLDivElement);

expect(cleanup).not.toHaveBeenCalled();

rerender(<TestComponent callback={callback} renderSpan />);

expect(callback).toHaveBeenCalledTimes(3);
expect(callback.mock.calls[1][0]).toBe(null);
expect(callback.mock.calls[2][0]).toBeInstanceOf(HTMLSpanElement);

expect(cleanup).toHaveBeenCalledTimes(2);

const cleanup2 = jest.fn();
const callback2 = jest.fn<() => void, any>(() => cleanup2);

rerender(<TestComponent callback={callback2} renderSpan />);

// Ensure first callback has been properly unmounted
expect(callback).toHaveBeenCalledTimes(4);
expect(callback.mock.calls[3][0]).toBe(null);

console.log(cleanup.mock.calls);
expect(cleanup).toHaveBeenCalledTimes(3);

expect(callback2).toHaveBeenCalledTimes(1);
expect(callback2.mock.lastCall[0]).toBeInstanceOf(HTMLSpanElement);

expect(cleanup2).not.toHaveBeenCalled();

rerender(<TestComponent callback={callback2} />);

expect(callback2).toHaveBeenCalledTimes(3);
expect(callback2.mock.calls[1][0]).toBe(null);
expect(callback2.mock.calls[2][0]).toBeInstanceOf(HTMLDivElement);

expect(cleanup2).toHaveBeenCalledTimes(2);

unmount();

expect(callback2).toHaveBeenCalledTimes(4);
expect(callback2.mock.calls[3][0]).toBe(null);

expect(cleanup2).toHaveBeenCalledTimes(3);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useMemo } from 'react';

type CallbackRefWithCleanup<T> = (node: T) => () => void;
type CallbackRef<T> = (node: T) => void;

type SafeCallbackRef<T> = CallbackRefWithCleanup<T> | CallbackRef<T>;

/**
* useSafeRefCallback will call a cleanup function (returned from the passed callback)
* if the passed callback is called multiple times (similar to useEffect, but in a callbackRef)
*
* @example
* const callback = useSafeRefCallback(
* useCallback(
* (node: T) => {
* if (!node) {
* return;
* }
* node.addEventListener('click', listener);
* return () => {
* node.removeEventListener('click', listener);
* };
* },
* [listener],
* ),
* );
*
*/
export const useSafeRefCallback = <T extends HTMLElement | null>(
callback: SafeCallbackRef<T>,
) => {
const callbackRef = useMemo(() => {
let _cleanup: (() => void) | null;

return (node: T): void => {
if (typeof _cleanup === 'function') {
_cleanup();
}
const cleanup = callback(node);

_cleanup = cleanup || null;
};
}, [callback]);

return callbackRef;
};
3 changes: 2 additions & 1 deletion packages/fuselage-hooks/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"outDir": "./dist",
"moduleResolution": "node",
"esModuleInterop": true,
"resolveJsonModule": true
"resolveJsonModule": true,
"jsx": "react-jsx"
},
"include": ["src", "./jest.config.ts"],
"exclude": ["dist", "node_modules"]
Expand Down
Loading