Repository navigation
Interactivity API fails to re-hydrate on iOS Safari after bfcache restoration #75778
Description
Description
The Interactivity API fails to re-hydrate interactive elements (e.g., Mini Cart, custom buttons) on iOS Safari when a page is restored from the Back-Forward Cache (bfcache).
When a user navigates to a page using the @wordpress/interactivity-router, closes the tab, and then reopens it (or uses the "Back" button after the page has been evicted from memory and restored), the interactive elements become unresponsive. The JavaScript engine restores the state from the cache, but the Interactivity API's internal state and event listeners appear to be disconnected from the restored DOM.
This issue is specific to iOS Safari. Chrome and Firefox handle bfcache restoration more gracefully, sometimes allowing manual re-initialization via the pageshow event, but Safari's restoration mechanism causes a permanent "frozen" state where calls to getContext() or interactions fail silently or throw errors because the context stack is invalid.
Step-by-step reproduction instructions
- Setup: Use a hybrid theme setup:
- A classic PHP theme header containing a WooCommerce Mini-Cart block (rendered via
do_blocks). - A content area wrapped in
data-wp-interactiveanddata-wp-router-region. - A custom script module importing
@wordpress/interactivityand@wordpress/interactivity-routerto handle AJAX navigation.
- A classic PHP theme header containing a WooCommerce Mini-Cart block (rendered via
- Pre-condition: Ensure the site is loaded on an iPhone or iPad (Safari, iOS 17+).
- Action:
- Open the site. Verify the Mini-Cart button works (opens drawer).
- Navigate to a different page using a link handled by the Interactivity Router.
- Verify interactive elements on the new page work.
- Close the Safari tab.
- Reopen the tab (Safari restores it from bfcache).
- Alternative: Put the tab in background, open other heavy apps to force memory pressure, then return to the tab.
- Test: Try clicking the Mini-Cart button or any
data-wp-on--clickelement.
Expected behaviour
Interactive elements should remain functional after the page is restored from bfcache. The Interactivity API should either automatically re-hydrate the DOM or expose a method to manually trigger re-hydration on pageshow event (persisted: true).
Actual behaviour
Interactive elements are completely dead. Clicking on Mini-Cart or Add to Cart buttons does nothing. The DOM is present, but the JS event listeners attached by the Interactivity API are detached or pointing to a stale/undefined context. No errors may appear in the console if the failure is silent, or TypeError regarding undefined context may appear if hydration is forced.
WordPress information
- WordPress version: 6.9.1 (trunk/dev)
- Gutenberg version: Latest (as integrated in WP core / Interactivity API packages)
- Are all plugins except Gutenberg deactivated? Yes (except WooCommerce for Block testing).
- Are you using a default theme? No, custom hybrid theme (Classic Header + Block Content).
Device information
- Device: iPhone / iPad
- Operating system: iOS 18.x / 17.x
- Browser: Safari Mobile
Technical Analysis
The issue seems to stem from how the internal at() function (used by getContext) and the proxy-based state management interact with Safari's freezing mechanism.
When Safari restores from bfcache, it restores the JS heap "as is". However, the Interactivity API relies on a specific initialization order. If the script tries to access context or state after restoration, it encounters a race condition where the DOM is ready, but the internal iAPI context stack is empty or corrupted.
A simplified reproduction script (attached below) shows that attempting to re-trigger init() via pageshow fails to restore functionality on Safari, whereas it works on Firefox.
References to #76053
Activity
DAreRodz commentedon Feb 24, 2026
Hey, @markusfoo, thanks for opening this issue. I've been trying to reproduce it without success; maybe you can help. 🙂
Before using the code snippets you shared, I followed these steps in pewresearch.org - which is a page we know is using the Interactivity API - on Safari 17, 18 and 26 (iOS):
This is what I did:
As mentioned, I wasn't able to neither break the app nor see any errors logged in the console.
However, regarding the code you shared, I see at least two things that would need to be fixed-specifically in the JavaScript module, under the
3. The Critical bfcache Handlerpart:You cannot use
window.wp.interactivity.initto re-initialize the Interactivity API. The Interactivity API is a module that should be imported, and it's not populating any properties inwindow, sowindow.wp.interactivity.initwill always beundefined. Moreover, theinitfunction should be called only once; additional call could break interactive blocks (I don't know if that's what you're seeing). For that reason, theinitfunction isn't even exposed. You can't import that function, because you don't need to use it.You're calling
getContext()outside of a directive. This will fail becausegetContext(), when called, depends on the "scope" of the element where the directive callinggetContext()(either a getter/action/callback) is being evaluated. The "scope" is computed per interactive element, when the vDOM is processed, and it is inside the "scope" where the "context" is being stored.So, at the time
getContext()is called in your code snippet, there is no "scope", and so there is no "context" either.If you need to access to
getContext()in such a situation, you would have to define a callback inside an Interactivity store, and usedata-wp-on-window--pageshow, or still usewindow.addEventListenerbut wrapping the event callback withwithScope(). Another option would be not usinggetContext()at all;getState()should work fine as long as you don't access any properties that depend ongetContext().The key concept here is the "scope". You cannot call
getContext()where the "scope" doesn't exist.Let us know if the problems you faced are related to these things I pointed out, or if you need help with anything else. 🙂
markusfoo commentedon Feb 24, 2026
Hi @DAreRodz, thanks for the corrections on the test snippet.
You are absolutely right — my snippet was attempting to use internal APIs incorrectly. However, I want to pivot the discussion back to the production code where the actual bug occurs.
1. The valid usage:
In my production site, I use the Interactivity Router exactly as intended. I have a class that imports the router and calls
actions.navigate(url)for seamless page transitions. This works perfectly on Chrome, Firefox, and Opera.2. The bfcache crash on Safari:
On iOS Safari, when a tab is restored from bfcache (tab reopen), the router instance becomes "frozen" or corrupted.
router.actions.navigate()stops working or fails silently. The DOM is restored, but the router is dead.3. The "Trap":
You mentioned that
init()is not exposed and I shouldn't call it.That is exactly the root of the problem.
If the Interactivity API does not expose a public method to re-initialize or "wake up" after a bfcache restoration, then the Router is permanently broken on Safari for persistent SPAs.
4. Why pewresearch.org didn't show the bug:
On
pewresearch.org, navigation likely triggers a more traditional page load or context reset. In my setup (Hybrid Theme + Router), the JS instance persists in bfcache, exposing this state corruption.Summary:
Since I cannot call
init()(as you correctly noted), and the router does not recover automatically on Safaripageshow, there is currently no way to fix this from a developer's perspective. The router remains dead in memory.How do you suggest handling bfcache restoration for the Interactivity Router if the internal state becomes stale?
markusfoo commentedon Feb 25, 2026
Hi @DAreRodz,
I have prepared a complete reproduction environment for the bfcache issue (#75778). Since my previous code snippets may have caused confusion due to invalid
getContextusage, I have created a clean, standalone integration that mirrors a Hybrid Theme architecture.This setup proves that the issue lies within the router's lifecycle on iOS Safari, independent of complex store logic.
🧪 Live Test Site
I have deployed a test site for you to verify the behavior immediately:
URL:
https://markuss.cu.ma/shop/📦 The Patch (Storefront Integration)
I created a patch for the standard Storefront theme (latest version) + WooCommerce 10.6 Beta 1.
The patch consists of 4 files that convert a standard Storefront theme into a Single Page Application using the Interactivity Router.
Modified Files:
header.php— Addeddata-wp-interactiveanddata-wp-router-region="content"to the main content wrapper.functions.php— Enqueued the router and cart scripts as ES modules viawp_enqueue_script_module(crucial for correct import map handling) and replaced standard WooCommerce buttons with interactive ones.google-assets/router.js— The Router App class. It uses@wordpress/interactivity-routerfor navigation with a fetch fallback.google-assets/cart.js— A sanitized, robust "Add to Cart" store implementation.storefront-google-patch.zip
📋 Reproduction Steps on iOS Safari
Note: On Chrome and Opera, the same test works flawlessly. The router recovers, and navigation continues to work after restoring the tab. This isolates the issue specifically to iOS Safari's bfcache implementation + Firefox now interacting with the Interactivity API.
🎥 Video Evidence / Browser Behavior
I have tested the patch across multiple browsers:
This confirms that the bfcache restoration logic is fragile across different browser implementations, with iOS Safari being the most critical failure point for mobile users.
Video_260225044444.mp4
IMG_1512.MP4
On iOS, everything dies dead and the touchscreen is blocked because the mini-basket scripts continue to work but the action to open/closed (Display blocked) it visually is no longer performed.
💡 Why this matters
I intentionally avoided "hacks" or heavy workarounds in this patch to expose the raw behavior of the router. This minimal setup confirms that the Interactivity Router does not survive the bfcache restoration cycle on iOS, leading to a broken user experience in any SPA-like implementation.
If there are specific architectural requirements to make the router bfcache-safe that I missed, please let me know. I am ready to integrate any fixes into my production scripts. However, it currently seems like a core issue.
Thanks for investigating!
Markus
DAreRodz commentedon Feb 25, 2026
This case is interesting. The Interactivity API should work the same, regardless of the approach used - in the end, it only uses the final HTML as the source of truth to set up multiple Preact apps. So, it shouldn't matter if the HTML is generated through a block theme, a classic theme, or a hybrid theme.
You mention that
pewresearch.orgcould be using a more traditional approach that avoids the issue you mentioned. It would be great if you could share some of your code so we can better understand your use case, how it differs from the traditional one, and how the bug could be fixed. 🤔markusfoo commentedon Feb 25, 2026
Hi @DAreRodz,
I believe there might be a misunderstanding.
In my previous comment (9 hours ago), I already attached a complete patch (
storefront-google-patch.zip) and provided a live test site (https://markuss.cu.ma).The zip file contains the full source code for the Hybrid Theme setup:
header.phpfunctions.phpgoogle-assets/router.jsgoogle-assets/cart.jsThis implementation uses valid Interactivity API patterns (context is only accessed inside directives/actions). It reproduces the bug 100% reliably on iOS Safari + Firefox but excellent work via Opera/Chrome.
Please review the attachment in the comment above. All the code and evidence you requested is already there.
Thanks.
markusfoo commentedon Feb 25, 2026
@DAreRodz, I have updated the test patch to v1.0.1. This update fixes the "Dead DOM" issue on the initial page load in Firefox, which helps isolate the true bfcache bug more clearly.
File Updated:
storefront-google-patch.zip(containsrouter.jsv1.0.1).🔧 What was fixed in v1.0.1?
The previous script (v1.0.0) failed to navigate on Firefox even on a fresh load due to History API Desynchronization.
history.pushState()optimistically before callingrouter.actions.navigate().history.pushStateif the Router is available. We let the Router manage the history entirely. This aligns with the logic found in working production implementations.🧪 Current Behavior & Observations
With v1.0.1, the behavior is now distinct and reproducible across browsers:
Initial Load (Clean Cache):
bfcache Restoration (Tab Reopen):
Video_260225164936.mp4
💡 Why this matters
This proves that the issue is not with the initialization logic or dependency loading. The issue is specifically with state persistence across bfcache restoration.
The internal proxies or state references within the Interactivity API do not survive the freeze/restore cycle on WebKit/Gecko engines. When the page is restored, the Router "wakes up" with a stale reference to a DOM that is no longer active, causing it to silently fail on subsequent interactions.
Testing Instructions:
Please test the "Tab Reopen" scenario on iOS Safari or Firefox Desktop to see the "Dead State" immediately after restoration.
I have attached the console logs which definitively prove the issue is an internal state desynchronization in the Interactivity Router.
Log Analysis:
Fresh Load (Working):
Attempting Router...->Complete.[GoogleCart] Adding product ID...(Click events work)Tab Reopen from bfcache (Broken):
Initialized.(Script runs, references restored).Attempting Router...->Complete.[GoogleCart]logs are missing. Clicking "Add to Cart" does nothing. Thedata-wp-on--clickbindings are dead.Conclusion:
This is not an initialization issue. The code calls the API correctly. The Router executes and reports success, but its internal Preact instance is detached from the restored DOM.
This is a critical bug for any Hybrid Theme or SPA implementation using the Router. Users on iOS Safari or Firefox who reopen a tab will encounter a "frozen" site where navigation silently fails.
The internal state of the Router needs a mechanism to detect bfcache restoration and rebuild/reconcile its vDOM, or at least throw an error instead of failing silently.
Thanks!
DAreRodz commentedon Feb 25, 2026
Hi again, @markusfoo. Sorry for the previous misunderstanding; GitHub inexplicably hid your message from me. 😄
I've been debugging the test site you shared,
https://markuss.cu.ma, and I found what could be the issue.I was able to reproduce it in Safari iOS 26, as you explained, also in macOS. I haven't tested in Firefox, but I did in Chrome, and it worked without issues on that browser.
This is what I think is the problem:
@wordpress/interactivity-routeris evaluated before@wordpress/interactivitycompletes the hydration phase.Why is this a problem? Because the router depends on the
@wordpress/interactivitymodule being initialized. The code below processes the initial page to add it to the router's internal cache. As you can see, it uses aninitialVdomproperty imported from@wordpress/interactivity.gutenberg/packages/interactivity-router/src/index.ts
Lines 360 to 371 in 626ea14
That
initialVdomis a WeakMap, populated during the initial hydration:gutenberg/packages/interactivity/src/hydration.ts
Line 41 in 626ea14
The issue here is maybe a bit complex to explain: since the router runs before, there is no initial vDOM, and the router parses the entire HTML with
toVdom()to generate it. That step has side effects that mark the parsed regions as hydrated:gutenberg/packages/interactivity/src/vdom.ts
Line 188 in 626ea14
This is "catastrophic", as it makes the actual hydration phase ignore the interactive regions:
gutenberg/packages/interactivity/src/hydration.ts
Line 37 in 626ea14
You may be wondering why this happens in Safari and not in Chrome. Me too. 😅 I guess it's because Chrome is fast enough to run the hydration phase before the router module has the opportunity to be evaluated.
The fix would require importing the router after hydration, for example, inside a
data-wp-initcallback, similar to what we do in thenavigateaction of the Query block.Let me know if this helps. 🙂
@luisherranz, maybe we should rethink this part so it works independently of which module runs first. I believe we discussed this already. 🤔
Interactivity API: Fix hydratedIslands race condition and bfcache rec…
Interactivity API: Fix hydratedIslands race condition and bfcache rec…
markusfoo commentedon Feb 25, 2026
@DAreRodz, @luisherranz,
I have tested the proposed fix locally, and it confirms the diagnosis. The issue is indeed a side effect in
toVdom.The problem is that
toVdommarks islands as hydrated (hydratedIslands.add), but it can be called by the Router before the main hydration phase runs. When the main hydration runs, it sees the islands are already marked and skips them, resulting in a dead DOM.The Fix
Move the
hydratedIslands.add()call fromtoVdom()tohydrateRegions(), specifically right before thehydrate()call.In
packages/interactivity/src/vdom.ts:Remove the side effect from the parser.
In
packages/interactivity/src/hydration.ts:Add the marking inside the hydration loop.
This ensures that:
Video_260225210231.mp4
I cannot open a separate PR against the
.tsfiles directly, but I have validated both changes locally and they resolve 100% of the reproduction cases described in this issue on Safari and Firefox.@DAreRodz please test: https://markuss.cu.ma/shop/
The patched JS is attached for reference:
debug.js
Whether this warrants a core patch or should first go through CI regression tests is up to the team — happy to assist with any additional verification needed. 🙂
markusfoo commentedon Feb 25, 2026
This fix is implemented across two commits:
3ceeed7 — removes the
hydratedIslands.addside effect fromtoVdominvdom.ts49dc12a — moves the
hydratedIslands.addcall to the correct place inhydration.ts, right beforehydrate()is called@DAreRodz @luisherranz both changes are minimal and self-contained. Happy to adjust if the team prefers a different approach before merging.
DAreRodz commentedon Feb 26, 2026
Hi again, @markusfoo.
I think the fix you proposed (moving
hydratedIslands.add()fromtoVdom()tohydrateRegions()) isn't quite right, for a couple of reasons:The purpose of
hydratedIslands.add()insidetoVdom()is to identify interactive regions nested inside other interactive regions. These nested regions should not be hydrated independently, because they already belong to a parent interactive region that will handle them. If you removehydratedIslands.add()fromtoVdom(), those nested islands would no longer be detected, andhydrateRegions()would try to hydrate them separately, which would break things. To make your approach work, you'd also need to ensure that all nested nodes insidenodewith thedata-wp-interactivedirective are marked as hydrated, not just the top-level ones processed in thehydrateRegions()loop.This change doesn't address the underlying issue with the
@wordpress/interactivity-routermodule being imported before hydration completes. Even with your fix, the router would still run its initialization code (at module scope) beforehydrateRegions()has had a chance to execute. This means the router would still calltoVdom()to parse the entire document and generate the initial vDOM when it shouldn't. It's true that, in this case, the hydration phase itself would work (since the islands are no longer prematurely marked), but it comes at the cost of parsing and creating a vDOM again for each interactive region, duplicating work the router already did.The real fix needs to ensure that
@wordpress/interactivity-routeris imported after the hydration phase completes, so that theinitialVdomWeakMap is already populated when the router tries to access it. That way, the router can use the vDOM that was already generated during hydration instead of re-parsing the DOM from scratch. Or, if not, modify how@wordpress/interactivity-routeraccesses theinitialVdom. We could makeinitialVdoma Promise that resolves once the hydration phase ends, returning the WeakMap with the regions' vDOM trees, and waiting for that promise to process the initial page in the Interactivity Router.I hope this makes sense. Let me know if you have any questions!
markusfoo commentedon Feb 26, 2026
Observation: Partial Hydration Failure in Nested Interactive Regions (Mini-Cart)
IMG_1526.1.mp4
Status: The
reset()+pageshowfix successfully restores Navigation (Router actions).New Issue: It fails to restore Visual State for complex, state-driven components like the Mini-Cart list.
Reproduction Steps:
Technical Analysis:
This highlights a limitation of using
hydrate()for bfcache recovery.wooState(Redux-like store) persists in memory, so the counter updates.hydrate()again, Preact tries to attach listeners to these existing nodes.Conclusion:
The bug exhibits hierarchical properties:
hydratedIslands.reset().bfcacherecovery handler that forces a re-render (vDOM replacement) rather than just a re-hydration (attachment) for dynamic lists. Hydration assumes the DOM is fresh from the server; bfcache provides a stale DOM.markusfoo commentedon Feb 26, 2026
@DAreRodz @luisherranz
Thank you for the detailed architectural feedback. I am retracting the
vdom.tschange — you are correct thathydratedIslands.add(elementNode)insidetoVdom()is intentional, not a side effect. It marks nested islands during the parent walk sohydrateRegions()does not try to hydrate them independently. Removing it would break any site with nested interactive regions. That commit should not be merged.I want to close my involvement with a consolidated observation, since I spent significant time testing across browsers, setups, and theme architectures — and since at the start of this issue the problem was not confirmed at all.
Two separate problems that must not be conflated
All proposed fixes so far — including my own — have failed because they solve one layer while silently leaving the other intact. The two layers need to be understood and addressed independently.
Layer 1 — Initial load race condition
The router module evaluates before
hydrateRegions()completes on slower JS engines. Your diagnosis is correct: the router callstoVdom()on the full document, which marks islands as hydrated, and the actual hydration phase skips them all. A Promise-basedinitialVdomthat resolves only afterhydrateRegions()completes — or enforced import order — would fix this. This is an engine-speed-dependent implicit ordering requirement, and Chrome masks it because V8 evaluates modules faster than JavaScriptCore or SpiderMonkey.Layer 2 — bfcache restoration lifecycle
This is entirely independent of Layer 1. When Safari or Firefox restores a page from bfcache, the entire JS heap is thawed from a frozen snapshot. Async continuations suspended at
awaitpoints are permanently discarded. Modules are not re-evaluated.import()does not re-run. TheinitialVdomPromise has already resolved long before the tab was closed. Fixing the import order changes nothing here.The result: Preact's internal vdom trees are partially built or pointing to stale references. Event listeners are dead. The router logs "Complete" but produces no DOM changes — a silent failure with no errors in the console.
My tests after forcing
hydrateRegions()onpageshowconfirmed this split clearly:navigate()works.data-wp-on--clickhandlers fire.hydrate()assumes structurally fresh server-rendered DOM. After bfcache the nodes carry stale Preact references, and reconciliation silently produces empty output.The WeakMap problem
There is a structural issue worth flagging explicitly:
initialVdomis aWeakMap. AWeakMapholds weak references — entries can be collected by the garbage collector if no other strong reference to the key exists. More critically for bfcache: when Safari freezes the page, the GC may run before or during the freeze cycle, clearing entries whose keys have no other strong holders. On restoration,initialVdomcan come back partially or fully empty.This means even a correct
render()call onpageshowhas no source of truth to render from — the vdom it needs may no longer exist.The fix for Layer 2 requires two things together: store the vdom in a regular
Mapwith explicit lifecycle cleanup, and callrender()rather thanhydrate()onevent.persisted.render()replaces DOM nodes from the stored vdom instead of trying to attach to frozen ones.hydrate()is simply not designed for this scenario.Where the architectural mistake was made
iAPI was designed for server-rendered Gutenberg blocks where each navigation produces a fresh server response and a structurally clean DOM. In that model bfcache is not a real concern. The problem emerged when developers started building SPA navigation, hybrid themes, and WooCommerce integrations on top of iAPI — pushing it beyond its original scope before the architecture accounted for it.
The initialization chain has implicit ordering requirements that are not enforced anywhere in the code. The router depends on
initialVdombeing populated.initialVdomdepends onhydrateRegions()having completed.hydrateRegions()depends on the DOM being ready. None of these are explicitly declared or awaited. Correctness depends on engine evaluation speed, which is not a stable guarantee across browsers or hardware.This is not a Safari bug. Apple's bfcache is the strictest and most spec-compliant implementation among all major browsers — the W3C specification explicitly permits discarding async continuations on freeze. Apple will not change this behavior for any library. The adaptation has to happen entirely on the iAPI side, and it requires more than a timing fix.
What would actually address both layers
Layer 1:
initialVdombecomes a Promise that resolves afterhydrateRegions()completes. The router awaits it before processing the initial page. Explicit dependency, engine-speed-independent.Layer 2: On
pageshowwithevent.persisted === true, the API callsrender()using the stored vdom — but only ifinitialVdomis migrated fromWeakMapto a regularMapso the vdom survives the freeze/thaw cycle. Without theMapmigration, evenrender()has nothing to work from.For developers in the meantime: expose a public bfcache lifecycle hook or a
rehydrate()API. Right now there is no supported path for third-party code to safely recover afterevent.persisted. Developers are left choosing between broken behavior or depending on private internals that will break on the next release.The two fixes are independent and can ship separately — Layer 1 is the smaller, lower-risk change.
Why this matters beyond my setup
This bug is reproducible on a standard Storefront theme with no custom code. The live test environment at https://markuss.cu.ma/shop/ demonstrates it on a clean Storefront + WooCommerce install. If it breaks there, it breaks on any iOS Safari session where a user reopens a tab — which is the default behavior for the most common mobile browser in most markets.
The issue was not confirmed when I first opened it. I hope the analysis across these comments is useful to the team regardless of which implementation path is chosen. 🙂
DAreRodz commentedon Feb 27, 2026
@markusfoo
I tested your live test environment in Chrome 145 to more thoroughly check the bfcache issue. Chrome also implements bfcache since version 96, and as we discussed earlier, this browser seems to avoid the initial-load race condition, so I believe using Chrome would at least discard that problem during my testing. In addition, Chrome's DevTools include a way to test the bfcache cache (Application > Background Services > Back/forward cache), very handy for our case.
Well, what I see is that Interactivity is fully preserved after bfcache restoration. You can take a look at the video below.
bfcache-restoration.mov
In theory, when a page is restored from back/forward cache, the browser restores the entire JS heap — Preact's component tree, event listeners, signals, the router's page cache, everything. Nothing is destroyed, so there's nothing to re-render. I'm sure Safari has its own implementation of the back/forward cache, but I don't think its behavior differs from Chrome's.
The concern about discarded async continuations is valid per spec, but in practice,
hydrateRegions(), while being async, completes almost right after DOM ready, well before a user could navigate away. The router initialization is effectively synchronous. By the time bfcache kicks in, there's no in-flight async work to lose.On the
WeakMappoint —initialVdomuses DOM elements as keys, and those elements stay attached to the document (which is also frozen and restored).WeakMaponly drops entries when the key is garbage-collected, and elements in the live DOM aren't eligible for GC.My current read is that the symptoms are more likely due to the initialization race condition surfacing, which just looks like a bfcache problem because restoring a page that was already broken still shows the broken state. That said, if you have a reliable reproduction where interactivity breaks specifically on
pageshowwithevent.persisted === trueon a page that was fully working before navigating away, we'd love to see it. That would change the picture.Thanks again for raising this and for the thorough investigation — even when concerns don't lead to code changes, the analysis helps us understand the system better. 🙂
markusfoo commentedon Feb 27, 2026
@DAreRodz
Thank you for the Chrome test. The new logs I collected confirm your race condition diagnosis — but they also reveal something important that changes the scope of the problem.
Test environment note
The site at https://markuss.cu.ma/shop/ has been running without any patches for over 24 hours. The commits I previously referenced (
3ceeed7and49dc12a) have been fully reverted. You were testing a clean WordPress 7.0 Beta 2 with zero modifications to the Interactivity API. Thepageshowlogger I added is the only addition.The test script is live on the site. You can open DevTools on any browser right now and reproduce the results yourself.
What the logs actually show
Fresh load — Firefox Desktop
Video_260227224834_Slice.mp4
Navigation works. Everything fine. Then I opened a new tab to the same URL:
Router reports Complete. DOM is dead. No tab was closed. No bfcache involved.
event.persistedisfalse.Back button navigation — same session — works
Navigating forward and backward within the same session works correctly. Events fire. Cart updates. Everything responds.
Chrome — same fresh load
__preactattr_isfalseon Chrome too. Yet everything works on Chrome.What the logs prove
__preactattr_is not a valid signal for hydration state. Modern Preact (the version WordPress uses) does not write to__preactattr_as a direct DOM property — it uses internal WeakMap structures. The property isfalseon every browser including Chrome on a fully working page. Using it as a health check produces a false negative everywhere.The failure is not bfcache. Opening a new tab produces
persisted: falseand a dead DOM. No cache restoration involved. This is a clean, repeatable initialization failure on every new context.The failure is browser-engine-dependent. The same code, the same server, the same HTML:
hydrateRegions()skips themThis is exactly the race condition you described. But it is not occasional or edge-case. On Firefox and Safari it happens on every single new page load because their engines consistently evaluate the router module before hydration completes.
Additional observation — cross-tab reproduction (no bfcache involved):
Tab 1 (cold HTTP cache): page loads, all interactive elements
work correctly. Navigation works. Cart works. Back/forward no
history works within the session.
Without closing Tab 1, I open Tab 2 to the same URL.
Tab 2 (warm HTTP cache):
router.jsloads instantly frombrowser cache — zero network latency. DOM is dead immediately.
event.persisted: false. No bfcache. No tab close.This cross-tab behavior isolates the race condition directly:
when
router.jsloads faster (cached), it wins the raceagainst
hydrateRegions()every time. When it loads slower(cold network), hydration sometimes wins.
Chrome avoids this because V8 executes hydration fast enough
to win regardless of network speed. Firefox and Safari lose
the race when the module cache removes the natural network
delay that was accidentally protecting them.
On Safari: the page is dead on every load — first tab,
second tab, cold cache, warm cache — because JavaScriptCore
executes hydration slow enough that the router always wins
the race regardless of HTTP cache state.
The fix cannot rely on network timing or engine speed. The
dependency must be explicit.
What this means
The symptoms I was attributing to bfcache are the race condition surfacing in a different lifecycle context. A page that was never properly hydrated comes back from bfcache still broken. The root cause is always the initialization ordering.
Your proposed fix — making
initialVdoma Promise that resolves afterhydrateRegions()completes, with the router awaiting it before processing the initial page — directly addresses this. It removes the engine-speed dependency from the initialization chain.I am attaching a new video recorded on Firefox showing the same
persisted: false+ dead DOM sequence. The test environment is open for you to verify on any browser directly.The fix path is clear. What is the timeline for moving forward on the Promise-based
initialVdomapproach?DAreRodz commentedon Mar 2, 2026
👋 @markusfoo
There's no timeline for this yet. For now, our approach was to recommend developers to load the
@wordpress/interactivity-routermodule asynchronously only when a page needs to be prefetched or a visitor wants to navigate to another page, which is not always the case. That saves some bytes from being unnecessarily downloaded.That said, you are welcome to contribute by opening a PR to enable the Interactivity Router to be loaded before hydration. 😄
I would recommend, though, closing this issue and creating a new one specifically related to the Promise-based
initialVdomapproach.markusfoo commentedon Mar 2, 2026
Hi @DAreRodz,
I agree that the 'side effect fix' has risks with nested islands as you noted. The Promise-based approach is solid. However, I propose a specific implementation: Lifecycle Synchronization Hook
Thanks for the clear confirmation and the direction. Rather than opening a new issue, I'll go ahead and open a PR directly targeting the Promise-based initialVdom approach — changes in hydration.ts and index.ts only, no vdom.ts modifications. Will tag you for review once it's up. Keeping this issue open as the tracking reference. #76053
Add e2e test for router hydration race condition
Interactivity API: Fix router initialization race condition on Safari…
Interactivity API: Fix router initialization race condition on Safari…
Interactivity API: Fix router initialization race condition on Safari…