Skip to content

hydrateOnVisible: Cannot read properties of null (reading 'nodeType') when the SSR node is detached before the IntersectionObserver callback fires #15091

Closed

Description

@landrade

Vue version

3.5.33

Link to minimal reproduction

https://stackblitz.com/edit/vitejs-vite-ktsycrrc?file=repro.mjs

Steps to reproduce

  1. SSR-render an async component that uses hydrate: hydrateOnVisible().
  2. On the client, hydration is deferred and the strategy registers an IntersectionObserver on the component's SSR node.
  3. 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).
  4. 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 hydration

Any 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

  1. added a commit that references this issue on Jul 16, 2026
    97f3525
  2. locked and limited conversation to collaborators on Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    🔨 p3-minor-bugPriority 3: this fixes a bug, but is an edge case that only affects very specific usage.scope:hydration

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions