Repository navigation
hydrateOnVisible: Cannot read properties of null (reading 'nodeType') when the SSR node is detached before the IntersectionObserver callback fires #15091
Description
Vue version
3.5.33
Link to minimal reproduction
https://stackblitz.com/edit/vitejs-vite-ktsycrrc?file=repro.mjs
Steps to reproduce
- SSR-render an async component that uses hydrate: hydrateOnVisible().
- On the client, hydration is deferred and the strategy registers an IntersectionObserver on the component's SSR node.
- The node gets detached from the document before the observer's callback executes (in production this is a route change / re-render while the intersection callback is already queued).
- The observer callback runs and invokes hydrate() against the now-detached node.
What is expected?
A node that is no longer connected to the document should simply not be hydrated (there's nothing to hydrate), no exception thrown.
What is actually happening?
hydrate() reaches hydrateNode, which computes the container type from the node's parent:
// packages/runtime-core/src/hydration.ts — inside hydrateNode, async-component branch
nextNode = vnode.type.hydrate(
node, vnode, parentComponent, parentSuspense,
getContainerType(parentNode(node)), // parentNode(node) === null for a detached node
slotScopeIds, optimized, rendererInternals, hydrateNode,
)
// packages/runtime-core/src/hydration.ts
const getContainerType = (container) => {
if (container.nodeType !== 1) return undefined // <-- container is null -> throws
...
}
For a detached node parentNode(node) is null, so getContainerType(null) throws TypeError: Cannot read properties of null (reading 'nodeType').
The async-component hydration wrapper only guards against the patched case, never against disconnection:
// packages/runtime-core/src/apiAsyncComponent.ts — __asyncHydrate
const performHydrate = () => {
if (patched) { /* warn + */ return } // only guards `patched`
hydrate() // no isConnected check
}
Because the strategy runs inside load().then(...), the throw surfaces as an unhandledrejection, so app-level try/catch around the component can't catch it.
System Info
vue: 3.5.33
@vue/server-renderer: 3.5.33
Environment where it triggers: Android WebView (Chrome), production SSR + client hydrationAny additional comments?
We see this almost exclusively on Android Chrome WebView (~43k occurrences / ~12.8k users over a rollout window) on a page that lazy-hydrates several below-the-fold sections via hydrateOnVisible. The IntersectionObserver callback is dispatched asynchronously; when the user navigates/re-renders in the WebView, the observed node is detached between the intersection being recorded and the callback running, exactly the sequence above. Desktop rarely hits the race.
Skip hydration when the anchor node is no longer connected. the semantically correct place is the async performHydrate (or each built-in strategy's trigger), e.g. bail when no observed/anchor element isConnected:
const performHydrate = () => {
if (patched) { return }
if (el && !el.isConnected) return // detached before hydration → nothing to hydrate
hydrate()
}
As defense-in-depth, getContainerType could also tolerate a null container (if (!container || container.nodeType !== 1) return undefined), since it's called with parentNode(node) at several sites.
We're currently shipping a userland wrapper around hydrateOnVisible that applies exactly the isConnected guard, which fully stops the crash.
Activity
fix(runtime-core): skip lazy hydration for detached roots (#15092)