From fa9665a270eb3ac6ca3f7217c2f43f5b32c5cb86 Mon Sep 17 00:00:00 2001 From: Victor Moura Cortez Date: Fri, 27 Mar 2026 17:22:15 -0300 Subject: [PATCH 1/2] fix: gate LazyRender IntersectionObserver on asyncScriptsReady When enableAsyncScripts is active, every bundle is wrapped in enqueueScripts() and executed via setImmediate in order. The LazyRender IntersectionObserver fires after initial hydration (which correctly waits for asyncScriptsReady), but if the user scrolls quickly, the observer can trigger a lazy component render while some bundles are still pending in the async queue. This causes the lazy component to require() a webpack module whose chunk has not been registered yet, resulting in: TypeError: Object(...) is not a function Error: requiring vtex.structured-data@0.17.0/ProductList failed React error 130 (element type is invalid) Fix: add asyncReady state to LazyRender that mirrors window.__ASYNC_SCRIPTS_READY__. Pass bailOut to useOnView so the IntersectionObserver is only created once all async bundles have finished executing. When async mode is off the behavior is identical to before. --- react/components/LazyRender.tsx | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) 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) { From 6d8713aad84b36ef48dc1f80aa832319c43bd25a Mon Sep 17 00:00:00 2001 From: Victor Moura Cortez Date: Fri, 27 Mar 2026 17:34:47 -0300 Subject: [PATCH 2/2] test: add LazyRender tests for asyncScriptsReady gating Covers the fix for the race condition between LazyRender's IntersectionObserver and the async bundle queue: - async mode OFF: observer registered immediately, no gating - async mode ON, bundles done: observer registered immediately - async mode ON, bundles pending: observer blocked until asyncScriptsReady fires, then registered on next scroll - double dispatch of asyncScriptsReady: observer created exactly once - cleanup: asyncScriptsReady listener removed on unmount --- react/components/LazyRender.test.tsx | 253 +++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 react/components/LazyRender.test.tsx 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() + }) +})