Skip to content

Interactivity API: Lazy hydration of interactive islands, with an idle-time fallback - #81249

Open
nickchomey wants to merge 13 commits into
WordPress:trunkfrom
nickchomey:try/iapi-lazy-hydration
Open

nickchomey wants to merge 13 commits into
WordPress:trunkfrom
nickchomey:try/iapi-lazy-hydration

Conversation

@nickchomey

@nickchomey nickchomey commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Closes #58225

Relates to: PR #58284 — "Interactivity API: Defer hydration until node is scrolled near the viewport" (@westonruter ). This is an updated, rebased-on-trunk version of that work, incorporating the maintainer discussion (notably @luisherranz's request for an idle-CPU fallback) and fixes for regressions found along the way.

I was not able to edit #58284 directly, so just ported those changes to trunk and added idle hydration and tests. I dont know how to, so feel free to add attribution to whoever should have it (I see @westonruter, @felixarntz, @luisherranz. @sirreal). I removed @sirreal's scroll-to-bottom edits to the wp-each directive test, as they were no longer necessary after my improvements.


The problem

Today, @wordpress/interactivity hydrates every interactive island at DOMContentLoaded. On pages with many blocks below the fold, that means running hydration JS (and firing data-wp-init / data-wp-watch callbacks) for content the user may never scroll to — contributing to slow initial load on long pages (feeds, archives, long-form posts).

What this does

Defers hydration of each interactive island until it is near the viewport, and guarantees the rest are hydrated when the CPU is idle:

  • An IntersectionObserver (rootMargin: one viewport height, top and bottom) hydrates each island as it approaches the viewport.
  • A one-shot idle-time sweep (requestIdleCallback, bounded by a timeout: 2000) hydrates everything remaining within a bounded time, guaranteeing that islands the user never scrolls to — and the router regions the router needs to diff — are hydrated even on a busy page. This also keeps the router's initialVdomPromise caching reliable (the original hang that plagued early iterations of Interactivity API: Defer hydration until node is scrolled near the viewport #58284 is gone). Implemented via the requestidlecallback npm package, already used by @wordpress/priority-queue, which provides a comprehensive polyfill for Safari.
  • Router integration: the router force-hydrates all remaining islands before rendering a navigation, so its regions are always available for diffing regardless of scroll position or idle timing. Router-rendered regions are preact-rendered (fully interactive) and are never re-hydrated.

Once hydration has completed (idle sweep ran, or the observer finished all islands), hydrateAllRemaining() is a permanent no-op:

  • Islands inserted into the DOM later (e.g. raw HTML) are not auto-hydrated — developers hydrate injected markup explicitly (see the companion renderElement() / renderHTML() PR Interactivity API: Add renderElement() and renderHTML() to hydrate HTML inserted into the live DOM #81190).
  • The observer's "all done" detection uses a Set of observed nodes (idempotent delete) instead of a counter. We hit a flake during integration where duplicate intersection entries double-decremented the counter and prematurely disarmed the sweep; the Set makes it idempotent.
  • The router caches the initial page from initialVdomPromise rather than re-running toVdom on the live DOM at module-eval time. Under lazy hydration, islands may not be hydrated yet when the router initializes, so toVdom could mark them as hydrated before hydration actually ran — a regression we hit when integrating the idle sweep with the router (the static-import race-condition failures).

Testing

  • New lazy-hydration e2e spec: 5 tests, all passing, run 10+ times with zero flakes.
  • Full interactivity e2e suite: 234/234 passing.
  • Unit tests unaffected (this is e2e-tested behavior; the observer/sweep are browser-timing features).

Screenshots

Here's a Performance Profile for a site with 978 data-wp- attributes in the document. 6x Slowdown. Filtered/highlighted for interactivity.

Before:
Screenshot_2026-08-05_19-48-47

After:
Screenshot_2026-08-05_19-48-37

The bulk moved to after the Load event. Interestingly, but perhaps unsurprisingly, the processing looks a lot more fragmented and takes longer.

I commented-out splitTask and the bars get much tighter. All test pass like this - is splitTask truly needed?

Screenshot_2026-08-05_20-05-43

Decisions for reviewers

  1. timeout: 2000 — bounds how long we wait for a quiet CPU. On an idle page the sweep fires at the first idle moment (typically well under 2s); the timeout only caps the worst case (busy main thread). Do you want to increase the limit, or get rid of it altogether (waiting until whenever the main thread frees up, if ever)? I'm actually preferential to not forcing the hydration at all - hydrating elements/directives that are out of the viewport is just wasted battery life on mobile phones. The hydration will happen if they scroll and will ultimately be forced before a router navigation.

  2. requestidlecallback polyfill — reused from @wordpress/priority-queue rather than a new polyfill; no-op on modern browsers. Similar to the previous point, this adds over 1KB min.gz to the bundle, which is a lot for something that we might not even need...

  3. One-shot semantics — Concerns were raised in in Interactivity API: Defer hydration until node is scrolled near the viewport #58284 about how to handle html that might have been
    added outside of the iAPI. @luisherranz decided "I don’t think we should consider that scenario. If someone wants to modify the DOM and have the Interactivity API recognize those changes, they should do it through the methods provided by the Interactivity API."
    As it turns out, there's currently no such mechanism in the iAPI for doing this. As such, PR Interactivity API: renderHTML function to insert and hydrate html strings #81462 could be considered a companion to this PR, as it brings precisely these capabilities via its renderHTML() function. Of course anything there is open to discussion and change.

  4. Are the two splitTask() calls that wrap toVdom() necessary? It seems to perform much better without them, and all tests pass.

Use of AI Tools

Deepseek V4 Flash via vscode

@github-actions github-actions Bot added [Package] Interactivity /packages/interactivity [Package] Interactivity Router /packages/interactivity-router labels Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Warning: Type of PR label mismatch

To merge this PR, it requires exactly 1 label indicating the type of PR. Other labels are optional and not being checked here.

  • Required label: Any label starting with [Type].
  • Labels found: [Package] Interactivity, [Package] Interactivity Router.

Read more about Type labels in Gutenberg. Don't worry if you don't have the required permissions to add labels; the PR reviewer should be able to help with the task.

@nickchomey nickchomey changed the title Try/iapi lazy hydration Interactivity API: Defer hydration of interactive islands until they approach the viewport, with an idle-time fallback Aug 5, 2026
@nickchomey nickchomey changed the title Interactivity API: Defer hydration of interactive islands until they approach the viewport, with an idle-time fallback Interactivity API: Lazy hydration of interactive islands, with an idle-time fallback Aug 5, 2026
@nickchomey
nickchomey force-pushed the try/iapi-lazy-hydration branch from e786b6c to 531e984 Compare August 6, 2026 01:37
@nickchomey
nickchomey force-pushed the try/iapi-lazy-hydration branch from 531e984 to e51da7a Compare August 12, 2026 03:04
…ches don't block. Was accidentally moved during porting
@nickchomey

nickchomey commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor Author

One other thought: it seems to me that the various mechanisms are sort of running at cross-purposes here. Some ideas:

  1. IntersectionObserver delays the hydration until it is actually needed. Good. But then requestIdleCallback( hydrateAllRemaining, { timeout: 2000 } ); just goes ahead and hydrates everything anyway once it reaches its timeout.

The question raised in the OP was what timeout is appropriate (2000ms was selected arbitrarily)? On one hand, no/infinite timeout is ideal so as to not hydrate things that are not needed. On the other hand, i think the idea behind requestIdleCallback was to make sure everything was hydrated before someone uses navigate(). But I also implemented it such that navigate() itself calls hydrateAllRemaining in case requestIdleCallback has not yet run (and it no-ops if it has).

What if we just move requestIdleCallback( hydrateAllRemaining, { timeout: 2000 } ); to the router, such that it is only loaded/used when someone is using the router - there's no need for it otherwise. The only counterargument to this that I can think of is if we can notice/measure jank on slow devices when scrolling triggers a cascade of hydration. That seems doubtful though (can someone really scroll into many islands on a slow (likely small) device, faster than it can hydrate them in a reasonable time?

  1. A separate, but related, optimization might be to see if navigate() can do its fetch and hydrateAllRemaining async, and if the fetch arrives sooner, dont proceed until hydrateAllRemaining is done)? This might get rid of the need for requestIdleCallback altogether, since the fetch will probably take more time than hydration.

  2. splitTask()'s window.scheduler.yield.bind (or setTimeout(0) fallback) was implemented in Interactivity API: Break up long hydration task in interactivity init #58227 in order to split up the various tovdom/hydration tasks so that it doesnt happen in one big, blocking synchronous block. An alternative might be to use requestIdleCallback instead, such that it happens not at the next convenient moment, but when the browser next has idle time? I do suppose that, contrary to my question related to the 3rd trace in the OP, one of these is ultimately needed because there could, in theory, be many regions with many elements in the initial viewport - without splitting up their hydration tasks, we'd be back to the situation that splitTask was implemented to resolve.

I'll try to explore these ideas and provide some traces when I have a chance.

@nickchomey

nickchomey commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Just stumbled on this comment while looking into other preact stuff... preactjs/preact#409 (comment)

import { options } from 'preact';

options.debounceRendering = requestIdleCallback

They rejected a PR to implement requestIdleCallback within preact (v8), and said that it was now possible in userland in v10 with this option/hook.

I see that the same option was mentioned here #70874 (comment)

I'll try to test it out soon.

@nickchomey

nickchomey commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Another idea to try out: lazy hydrate within an island. If you have, for example, a feed of posts, the island's start will be within the initial viewport, but the whole island (and all of its posts) wont be. Would be great if possible to defer hydration of posts until they come close to being in the viewport (and, as proposed in previous comments, not lazy load them at all until a client-side navigation forces it).

This would be directly useful for my own application

a couple good articles:

https://philipwalton.com/articles/idle-until-urgent/
https://kciter.so/posts/the-expensive-main-thread/en/

@nickchomey nickchomey mentioned this pull request Sep 22, 2026
5 of 10 tasks
@nickchomey

Copy link
Copy Markdown
Contributor Author

Another thought that just occurred to me...

Without having dug into the code again, i think we loop the top-level regions/islands and hydrate them. But, since islands are distinct, i dont see why we couldn't hydrate all the regions in parallel with something like Promise.all...

Something to try and test when i pick this back up

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

🤖 PR meta 🤖

🎉 Props

Unlinked Accounts

The following contributors have not linked their GitHub and WordPress.org accounts: @awss1i.

Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Unlinked contributors: awss1i.

Co-authored-by: nickchomey <nickchomey@git.wordpress.org>
Co-authored-by: luisherranz <luisherranz@git.wordpress.org>
Co-authored-by: westonruter <westonruter@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

Updated as activity occurs, without notifying anyone named here. Add the props-bot label to refresh.

🏷️ Labels

This pull request needs exactly one label indicating its type, and has 0.

  • Required: any label starting with [Type].
  • Found: none.

Read more about Type labels in Gutenberg. If you cannot add labels yourself, a reviewer can do it for you.

@github-actions github-actions Bot added [Package] Base styles /packages/base-styles [Package] Boot /packages/boot [Package] Admin UI /packages/admin-ui [Package] Abilities /packages/abilities labels Oct 17, 2026
@nickchomey

nickchomey commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor Author

Oh god. somehow merging trunk added 1800 commits, lots of tags, and notified a lot of maintainers. Apologies. I will fix.

edit: if anyone is curious, i think it is a peculiarity of jj vcs (or probably just underlying git, or github...). if you do jj new -r trunk -r prbranch it adds all the commits from trunk, but you dont see it until you push to github. if you do jj new -r prbranch -r trunk, it works as expected.

@awss1i

awss1i commented Oct 17, 2026

Copy link
Copy Markdown
Contributor

Oh god. somehow merging trunk added 1800 commits, lots of tags, and notified a lot of maintainers. Apologies. I will fix.

Haha thats odd, no worries.

Fold trunk a51f2e3 into the lazy-hydration branch, keeping PR behavior on top of trunk refactors:
- packages/interactivity: keep trunk vitest devDependency alongside PR requestidlecallback types; opt into requestidlecallback types in tsconfig.build.json.
- hydration.ts: keep trunk top-level island filter and JSDoc; keep PR observer, idle sweep, hydrateAllRemaining, and the no-Observer fallback.
- vdom.ts: restore hydratedIslands WeakSet trunk removed; the PR double-hydrate guard needs it.
- router index.ts: keep trunk lifecycle-token rewrite; splice the PR hydrateAllRemaining calls into popstate and navigate.
@nickchomey
nickchomey force-pushed the try/iapi-lazy-hydration branch from d4a3b50 to e5b11bc Compare October 17, 2026 20:54
@github-actions github-actions Bot added [Package] E2E Tests /packages/e2e-tests [Package] Interactivity /packages/interactivity [Package] Interactivity Router /packages/interactivity-router and removed [Package] A11y /packages/a11y [Package] Autop /packages/autop [Tool] Babel preset /packages/babel-preset-default [Tool] Babel plugin makepot /packages/babel-plugin-makepot [Tool] Babel plugin import JSX pragma /packages/babel-plugin-import-jsx-pragma [Package] Blob /packages/blob [Package] API fetch /packages/api-fetch [Package] Blocks /packages/blocks [Package] Block library /packages/block-library [Package] Block editor /packages/block-editor [Package] Base styles /packages/base-styles [Package] Boot /packages/boot [Package] Admin UI /packages/admin-ui [Package] Abilities /packages/abilities labels Oct 17, 2026
@nickchomey
nickchomey force-pushed the try/iapi-lazy-hydration branch from 59d0051 to 8565be6 Compare October 17, 2026 22:43

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] E2E Tests /packages/e2e-tests [Package] Interactivity Router /packages/interactivity-router [Package] Interactivity /packages/interactivity

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Interactivity API: Hydration causes long task

2 participants