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) {