diff --git a/packages/interactivity/src/init.ts b/packages/interactivity/src/init.ts index fa1eec51c3e27e..39b6ec508cf439 100644 --- a/packages/interactivity/src/init.ts +++ b/packages/interactivity/src/init.ts @@ -29,6 +29,38 @@ export const initialVdom = new WeakMap< Element, ComponentChild[] >(); // Initialize the router with the initial DOM. export const init = async () => { + const pendingNodes = new Set(); + + const intersectionObserver = new window.IntersectionObserver( + async ( entries ) => { + for ( const entry of entries ) { + if ( ! entry.isIntersecting ) { + continue; + } + + const node = entry.target; + intersectionObserver.unobserve( node ); + pendingNodes.delete( node ); + if ( pendingNodes.size === 0 ) { + intersectionObserver.disconnect(); + } + + if ( ! hydratedIslands.has( node ) ) { + const fragment = getRegionRootFragment( node ); + const vdom = toVdom( node ); + await splitTask(); + hydrate( vdom, fragment ); + await splitTask(); + } + } + }, + { + root: null, // To watch for intersection relative to the device's viewport. + rootMargin: '100% 0% 100% 0%', // Intersect when within 1 viewport approaching from top or bottom. + threshold: 0.0, // As soon as even one pixel is visible. + } + ); + const nodes = document.querySelectorAll( `[data-${ directivePrefix }-interactive]` ); @@ -45,13 +77,7 @@ export const init = async () => { } ); for ( const node of nodes ) { - if ( ! hydratedIslands.has( node ) ) { - await splitTask(); - const fragment = getRegionRootFragment( node ); - const vdom = toVdom( node ); - initialVdom.set( node, vdom ); - await splitTask(); - hydrate( vdom, fragment ); - } + pendingNodes.add( node ); + intersectionObserver.observe( node ); } }; diff --git a/test/e2e/specs/interactivity/directive-each.spec.ts b/test/e2e/specs/interactivity/directive-each.spec.ts index 3c015e63fe4bc1..af49f3ff774990 100644 --- a/test/e2e/specs/interactivity/directive-each.spec.ts +++ b/test/e2e/specs/interactivity/directive-each.spec.ts @@ -11,6 +11,15 @@ test.describe( 'data-wp-each', () => { test.beforeEach( async ( { interactivityUtils: utils, page } ) => { await page.goto( utils.getLink( 'test/directive-each' ) ); + + // Scroll to page bottom to trigger hydration of out-of-viewport interactive regions. + await page.evaluate( + `window.scrollTo( { + top: document.body.scrollHeight, + left: 0, + behavior: 'instant', + } );` + ); } ); test.afterAll( async ( { interactivityUtils: utils } ) => { @@ -509,7 +518,8 @@ test.describe( 'data-wp-each', () => { test( `does not error with non-iterable values: ${ testId }`, async ( { page, } ) => { - await expect( page.getByTestId( testId ) ).toBeEmpty(); + const element = page.getByTestId( testId ); + await expect( element ).toBeEmpty(); } ); }