diff --git a/react/components/LazyRender.test.tsx b/react/components/LazyRender.test.tsx new file mode 100644 index 00000000..376f5afa --- /dev/null +++ b/react/components/LazyRender.test.tsx @@ -0,0 +1,253 @@ +import React from 'react' +import { act, cleanup, render, wait } from '@vtex/test-tools/react' +import 'jest-dom/extend-expect' + +import LazyRender from './LazyRender' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Fires an IntersectionObserver callback simulating the element coming into view. + * Always uses the most recently created observer instance. + */ +const triggerIntersection = (isIntersecting: boolean) => { + const instances = (window.IntersectionObserver as any).mock.instances + const calls = (window.IntersectionObserver as any).mock.calls + const lastIndex = instances.length - 1 + const observer = instances[lastIndex] + const callback = calls[lastIndex][0] + callback( + [{ isIntersecting, intersectionRatio: isIntersecting ? 1 : 0 }], + observer + ) +} + +/** Dispatches the asyncScriptsReady custom event on window. */ +const dispatchAsyncScriptsReady = () => { + window.__ASYNC_SCRIPTS_READY__ = true + window.dispatchEvent(new CustomEvent('asyncScriptsReady')) +} + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockObserve = jest.fn() +const mockUnobserve = jest.fn() +const mockDisconnect = jest.fn() + +const IntersectionObserverMock = jest.fn().mockImplementation((cb) => ({ + observe: mockObserve, + unobserve: mockUnobserve, + disconnect: mockDisconnect, + _cb: cb, +})) + +beforeAll(() => { + ;(window as any).IntersectionObserver = IntersectionObserverMock + // Simulate scroll > 0 so initializeOnInteraction triggers immediately + Object.defineProperty(window, 'scrollY', { writable: true, value: 100 }) +}) + +afterEach(() => { + cleanup() + jest.clearAllMocks() + delete (window as any).__ASYNC_SCRIPTS_READY__ + // Reset scrollY + ;(window as any).scrollY = 100 +}) + +// --------------------------------------------------------------------------- +// Tests: async mode OFF (__ASYNC_SCRIPTS_READY__ === undefined) +// --------------------------------------------------------------------------- + +describe('when async scripts mode is OFF (__ASYNC_SCRIPTS_READY__ is undefined)', () => { + beforeEach(() => { + // Simulate a store without enableAsyncScripts: the global is never set + delete (window as any).__ASYNC_SCRIPTS_READY__ + }) + + it('registers the IntersectionObserver immediately (no waiting)', () => { + render( + +
content
+
+ ) + + expect(mockObserve).toHaveBeenCalledTimes(1) + }) + + it('renders children once the element enters the viewport', () => { + const { queryByTestId } = render( + +
content
+
+ ) + + expect(queryByTestId('child')).toBeNull() + + act(() => { + triggerIntersection(true) + }) + + expect(queryByTestId('child')).toBeInTheDocument() + }) +}) + +// --------------------------------------------------------------------------- +// Tests: async mode ON, bundles already done (__ASYNC_SCRIPTS_READY__ === true) +// --------------------------------------------------------------------------- + +describe('when async scripts mode is ON and all bundles already ran', () => { + beforeEach(() => { + ;(window as any).__ASYNC_SCRIPTS_READY__ = true + }) + + it('registers the IntersectionObserver immediately (no waiting needed)', () => { + render( + +
content
+
+ ) + + expect(mockObserve).toHaveBeenCalledTimes(1) + }) + + it('renders children once the element enters the viewport', () => { + const { queryByTestId } = render( + +
content
+
+ ) + + expect(queryByTestId('child')).toBeNull() + + act(() => { + triggerIntersection(true) + }) + + expect(queryByTestId('child')).toBeInTheDocument() + }) +}) + +// --------------------------------------------------------------------------- +// Tests: async mode ON, bundles still pending (__ASYNC_SCRIPTS_READY__ === false) +// --------------------------------------------------------------------------- + +describe('when async scripts mode is ON and bundles are still pending', () => { + beforeEach(() => { + ;(window as any).__ASYNC_SCRIPTS_READY__ = false + }) + + it('does NOT register the IntersectionObserver while bundles are pending', () => { + render( + +
content
+
+ ) + + expect(mockObserve).not.toHaveBeenCalled() + }) + + it('does NOT render children even if intersection fires while bundles are pending', () => { + const { queryByTestId } = render( + +
content
+
+ ) + + // No observer registered, so intersection cannot fire — children stay hidden + expect(mockObserve).not.toHaveBeenCalled() + expect(queryByTestId('child')).toBeNull() + }) + + it('registers the IntersectionObserver after asyncScriptsReady fires', async () => { + render( + +
content
+
+ ) + + expect(mockObserve).not.toHaveBeenCalled() + + act(() => { + dispatchAsyncScriptsReady() + }) + + // wait() polls until the assertion passes, handling deferred React effects + // triggered by native DOM events that act() may not flush synchronously. + await wait(() => expect(mockObserve).toHaveBeenCalledTimes(1)) + }) + + it('renders children after asyncScriptsReady fires AND element enters viewport', async () => { + const { queryByTestId } = render( + +
content
+
+ ) + + expect(queryByTestId('child')).toBeNull() + + // Step 1: bundles finish — observer registered once effects flush + act(() => { + dispatchAsyncScriptsReady() + }) + await wait(() => expect(mockObserve).toHaveBeenCalledTimes(1)) + + expect(queryByTestId('child')).toBeNull() + + // Step 2: element enters viewport + act(() => { + triggerIntersection(true) + }) + + expect(queryByTestId('child')).toBeInTheDocument() + }) + + it('does not crash if asyncScriptsReady fires multiple times', async () => { + const { queryByTestId } = render( + +
content
+
+ ) + + // First event: sets asyncReady=true, listener then removed by cleanup + act(() => { + dispatchAsyncScriptsReady() + }) + await wait(() => expect(mockObserve).toHaveBeenCalledTimes(1)) + + // Second event: listener is already gone, no new observer is created + act(() => { + dispatchAsyncScriptsReady() + }) + + act(() => { + triggerIntersection(true) + }) + + expect(queryByTestId('child')).toBeInTheDocument() + // Observer registered exactly once despite multiple events + expect(mockObserve).toHaveBeenCalledTimes(1) + }) + + it('cleans up the asyncScriptsReady listener on unmount', () => { + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener') + + const { unmount } = render( + +
content
+
+ ) + + unmount() + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'asyncScriptsReady', + expect.any(Function) + ) + + removeEventListenerSpy.mockRestore() + }) +}) diff --git a/react/components/LazyRender.tsx b/react/components/LazyRender.tsx index da30ec29..af47266d 100644 --- a/react/components/LazyRender.tsx +++ b/react/components/LazyRender.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent, useRef, useState } from 'react' +import React, { FunctionComponent, useEffect, useRef, useState } from 'react' import { useOnView } from '../hooks/viewDetection' interface Props { @@ -15,6 +15,33 @@ const LazyRender: FunctionComponent = ({ const ref = useRef(null) const [hasBeenViewed, setHasBeenViewed] = useState(false) + // When enableAsyncScripts is active, window.__ASYNC_SCRIPTS_READY__ starts as + // false and flips to true (dispatching "asyncScriptsReady") only after every + // bundle in the async queue has run. If we let the IntersectionObserver fire + // while bundles are still executing, lazy components may try to require() + // modules whose webpack chunk hasn't been registered yet, causing + // "Object(...) is not a function" / React error #130. + const [asyncReady, setAsyncReady] = useState(() => { + if (typeof window === 'undefined') return true + if (typeof window.__ASYNC_SCRIPTS_READY__ === 'undefined') return true + return window.__ASYNC_SCRIPTS_READY__ === true + }) + + useEffect(() => { + if (asyncReady) return + + const onReady = () => setAsyncReady(true) + + window.addEventListener('asyncScriptsReady', onReady) + + // Guard against the event having fired between the render and this effect + if (window.__ASYNC_SCRIPTS_READY__) { + setAsyncReady(true) + } + + return () => window.removeEventListener('asyncScriptsReady', onReady) + }, [asyncReady]) + useOnView({ ref, onView: () => { @@ -22,6 +49,7 @@ const LazyRender: FunctionComponent = ({ }, once: true, initializeOnInteraction: true, + bailOut: !asyncReady, }) if (hasBeenViewed) {