diff --git a/src/shared/lib/react/react.hoc.tsx b/src/shared/lib/react/react.hoc.tsx index cf5408b..4da95ac 100644 --- a/src/shared/lib/react/react.hoc.tsx +++ b/src/shared/lib/react/react.hoc.tsx @@ -3,32 +3,55 @@ import { Suspense, forwardRef, ComponentType, - ForwardedRef, createElement, + ReactNode, + ComponentRef, + ComponentProps, + ForwardRefExoticComponent, + PropsWithoutRef, + RefAttributes, } from 'react' -export function withSuspense( - component: ComponentType, - suspenseProps: SuspenseProps & { - FallbackComponent?: ComponentType - }, -) { - const Wrapped = forwardRef, Props>( - (props: Props, ref: ForwardedRef>) => - createElement( +type SuspenseSharedProps = Omit + +type SuspensePropsWithComponent = SuspenseSharedProps & { + fallback?: never + FallbackComponent: ComponentType +} + +type SuspensePropsWithFallback = SuspenseSharedProps & { + fallback: ReactNode + FallbackComponent?: never +} + +type WithSuspenseProps = SuspensePropsWithComponent | SuspensePropsWithFallback + +export function withSuspense>( + WrappedComponent: T, + suspenseProps: WithSuspenseProps, +): ForwardRefExoticComponent< + PropsWithoutRef> & RefAttributes> +> { + const Wrapped = forwardRef, ComponentProps>( + (props, ref) => { + const { fallback, FallbackComponent, ...otherSuspenseProps } = + suspenseProps + + const suspenseFallback = + fallback ?? + (FallbackComponent ? createElement(FallbackComponent) : null) + + return createElement( Suspense, - { - fallback: - suspenseProps.fallback || - (suspenseProps.FallbackComponent && - createElement(suspenseProps.FallbackComponent)), - }, - createElement(component, { ...props, ref }), - ), + { ...otherSuspenseProps, fallback: suspenseFallback }, + createElement(WrappedComponent, { ...props, ref }), + ) + }, ) - const name = component.displayName || component.name || 'Unknown' - Wrapped.displayName = `withSuspense(${name})` + const wrappedComponentName = + WrappedComponent.displayName || WrappedComponent.name || 'Unknown' + Wrapped.displayName = `withSuspense(${wrappedComponentName})` return Wrapped } diff --git a/src/shared/lib/react/react.test.tsx b/src/shared/lib/react/react.test.tsx new file mode 100644 index 0000000..3f6fb72 --- /dev/null +++ b/src/shared/lib/react/react.test.tsx @@ -0,0 +1,86 @@ +import React, { + ComponentPropsWithoutRef, + ForwardedRef, + forwardRef, + lazy, +} from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { withSuspense } from './react.hoc' + +describe('withSuspense HOC', () => { + it('renders the synchronous component correctly without fallback', async () => { + render() + + await waitFor(() => + expect(screen.getByTestId(MOCK_COMPONENT_TEST_ID)).toBeInTheDocument(), + ) + }) + + it('renders the fallback when the lazy component is suspended (JSX fallback)', async () => { + render() + + await waitFor(() => + expect( + screen.getByTestId(FALLBACK_COMPONENT_TEST_ID), + ).toBeInTheDocument(), + ) + }) + + it('renders the FallbackComponent when the lazy component is suspended (Component fallback)', async () => { + render() + + await waitFor(() => + expect( + screen.getByTestId(FALLBACK_COMPONENT_TEST_ID), + ).toBeInTheDocument(), + ) + }) + + it('forwards the ref to the wrapped component', async () => { + const ref = React.createRef() + + render() + + await waitFor(() => + expect(screen.getByTestId(MOCK_COMPONENT_TEST_ID)).toBeInTheDocument(), + ) + + expect(ref.current).toBeInTheDocument() + }) +}) + +const MOCK_COMPONENT_TEST_ID = 'mock-component' +const FALLBACK_COMPONENT_TEST_ID = 'fallback-component' + +const FallbackComponent = () =>
+ +const MockComponent = forwardRef( + ( + props: ComponentPropsWithoutRef<'div'>, + ref?: ForwardedRef, + ) => ( +
+ ), +) + +const LazyMockComponent = lazy( + () => + new Promise<{ default: React.ComponentType }>((resolve) => { + setTimeout(() => resolve({ default: MockComponent }), 500) + }), +) + +const SyncComponentWithSuspense = withSuspense(MockComponent, { + FallbackComponent, +}) +const LazyComponentWithFallbackComponent = withSuspense(LazyMockComponent, { + FallbackComponent, +}) +const LazyComponentWithJSXFallback = withSuspense(LazyMockComponent, { + fallback: , +})