Skip to content

Regression: router-core scroll-restoration now uses bare browser-globals, breaking non-browser-global environments (Node / jsdom) #7472

@mkienenb

Description

@mkienenb

Which project does this relate to?

Router

Describe the bug

Regression: router-core scroll-restoration now uses bare browser-globals, breaking non-browser-global environments (Node / jsdom)

Summary

This is a regression. Creating a router under Node with a jsdom DOM shim — the standard unit/integration test setup — worked in earlier versions but now throws during router construction, breaking every test that builds a router.

The cause is in setupScrollRestoration (packages/router-core/src/scroll-restoration.ts). @tanstack/router-core is framework-agnostic core code, yet it now accesses several browser APIs as bare, unqualified globals — most importantly:

addEventListener('pagehide', () => {  })

This assumes the global object is the Window (globalThis === window), which is only true in a real browser. In a non-browser-global environment — notably Node with a jsdom DOM shim — addEventListener is not a bare global: it is inherited from EventTarget.prototype on window and is not present on globalThis, and Node provides no native top-level one. The call therefore throws.

Because setupScrollRestoration runs during router creation, the throw crashes router construction itself, breaking every test that builds a router under jsdom.

Affected version

What changed

The change landed in #7447 ("fix: fix scroll restoration issues", commit 0300f87, first released in @tanstack/router-core@1.171.4), which switched this file from window.-qualified access to bare global access in several places. As far as I can tell, neither #7447 nor #6595 states that dropping non-browser-global support was an intended change — the descriptions and discussion focus on scroll-restoration behavior, not on qualified-vs-bare global access. So this reads more like an unintended side effect than a deliberate decision, though I may be missing context.

Either way, it would help to confirm the intent: is router-core meant to support non-browser-global host environments (e.g. Node + jsdom)? If so, these bare accesses look like bugs; if not, documenting that limitation would help downstream consumers.

Scope: the bare global accesses introduced

Access Qualified? Runs when
addEventListener('pagehide', …) bare router creation (setupScrollRestoration) — this is the one that crashes construction
scrollX, scrollY bare inside the scroll handler (real scroll events)
scrollTo({ … }) (×2) bare scroll restoration after navigation
history.scrollRestoration global history setup
sessionStorage guarded via try/catch wrapper persist / read

pagehide is the urgent one because it fires at construction time and so breaks router creation unconditionally under jsdom. The scrollX / scrollY / scrollTo accesses only execute on actual scroll/navigation, so they don't crash a typical render-and-assert test — but they carry the same non-browser-global assumption and would throw if exercised. (Note sessionStorage already has a defensive wrapper in the same code, so a similar approach would fit the other accesses naturally.)

Steps to reproduce

  1. git clone https://github.com/JetBrains/kotlin-wrappers
  2. cd kotlin-wrappers/examples/
  3. ./gradlew tanstack-react-router-nodejs-test:jsNodeTest
  4. Observe the crash during construction.

Minimal reproduction of the failure mode:

require('global-jsdom/register')      // attaches a window, but not bare EventTarget methods
addEventListener('pagehide', () => {}) // throws: addEventListener is not defined / not a function

global-jsdom copies only the window's own enumerable properties onto globalThis; addEventListener is inherited from EventTarget.prototype, so it never becomes a bare global.

Expected behavior

Router creation succeeds under jsdom/Node, as it did in 1.171.3 and earlier.

Actual behavior

Router creation throws because the bare addEventListener cannot be resolved, breaking any test that constructs a router. The stack trace confirms the failure originates in setupScrollRestoration and propagates straight up through RouterCore construction (updatenew RouterCorenew RoutercreateRouter):

ReferenceError: addEventListener is not defined
    at <global>.setupScrollRestoration(file:///<MYPROJECT>/build/js/node_modules/@tanstack/router-core/src/scroll-restoration.ts:237)
    at RouterCore.update(file:///<MYPROJECT>/build/js/node_modules/@tanstack/router-core/src/router.ts:1141)
    at <global>.new RouterCore(file:///<MYPROJECT>/build/js/node_modules/@tanstack/router-core/src/router.ts:1004)
    at <global>.new Router(file:///<MYPROJECT>/build/js/node_modules/@tanstack/react-router/src/router.ts:117)
    at <global>.createRouter(file:///<MYPROJECT>/build/js/node_modules/@tanstack/react-router/src/router.ts:92)
    at <global>.createRouter(file:///<MYPROJECT>/build/js/packages/performance-appraisal-frontend-test/kotlin/src/jsTest/kotlin/com/gvea/par/common/ReactShouldSpecBase

Why this matters

  • router-core is the framework-agnostic layer. Node + jsdom is a mainstream environment for testing router-based apps, so it would be valuable for core to keep working there.
  • The qualified form is equivalent in the browser. window.addEventListener and the bare form behave identically in a real browser, while only the qualified (or guarded) form also resolves outside a browser-global — so re-qualifying would restore the non-browser-global case without changing browser behavior.
  • The module already guards one access this way. sessionStorage is wrapped defensively in the same file, so applying similar handling to the scroll/event accesses would be consistent with the existing code.

Verifying the one-line fix: it unblocks construction, but likely isn't sufficient

I applied the minimal window.addEventListener('pagehide', …) change to the 1.171.4 source and traced what remains by reading the code path. The construction half is backed by the original stack trace; the navigation half is a code-reading prediction that I have not run end-to-end. In short: the one-liner unblocks router construction, but the same class of non-browser-global access also appears on the navigation path, so it is unlikely to be a complete fix.

Why construction is fully unblocked. Inside setupScrollRestoration, only three global accesses run synchronously during setup:

  • history.scrollRestoration = 'manual' (line 197) — bare history;
  • document.addEventListener('scroll', …) (line 230) — already document-qualified;
  • the pagehide registration (line 237) — the bare access being fixed.

The original stack trace crashing at the pagehide line — after lines 197 and 230 — proves history and document already resolve in this environment (otherwise it would have thrown earlier). With pagehide qualified, no synchronous bare global remains in the setup body, so construction succeeds.

Why it is likely not enough. The other bare accesses live inside callbacks, not the setup body, so they only fire later:

  • scrollX / scrollY (line 205) inside the scroll handler — on a real scroll event;
  • scrollTo(…) (lines 333 / 356) inside the onRendered subscription — after any navigation.

A render-and-assert test passes with the one-line fix. A navigating test, by contrast, looks likely to hit the same failure: scrollTo (lines 333 / 356) is a bare, unqualified global in the onRendered handler, so on the first post-navigation render it would be expected to throw ReferenceError: scrollTo is not defined. I have not run a navigating test to confirm this — treat it as a prediction from reading the source, not an observed failure. But if it holds, a complete fix needs to cover all the bare sites, not only pagehide.

Suggested direction (deferring to maintainers)

Restore browser-global robustness in scroll-restoration.ts — either re-qualify with window. (the correct target; pagehide/scrollX/scrollY/scrollTo are all Window members) or guard the accesses the way sessionStorage already is. Fixing the pagehide registration alone unblocks router construction (see Verifying the one-line fix above), but it leaves the scrollX/scrollY/scrollTo accesses untouched — these would be expected to throw on scroll/navigation (see the caveat there) — so a complete fix should cover all the bare sites. The minimal construction-unblocking change is:

- addEventListener('pagehide', () => {
+ window.addEventListener('pagehide', () => {

Environment

  • @tanstack/router-core 1.171.4
  • Node + jsdom (via global-jsdom) test environment
  • Test runner: Mocha, with NODE_ENV=test (kotlin/gradle driven)

Note: while drafting this issue was AI-assisted, it is NOT an AI generated issue.

Complete minimal reproducer

https://github.com/JetBrains/kotlin-wrappers

Steps to Reproduce the Bug

Clone https://github.com/JetBrains/kotlin-wrappers
cd kotlin-wrappers/examples/
./gradlew tanstack-react-router-nodejs-test:jsNodeTest

Expected behavior

Router creation succeeds under jsdom/Node, as it did in 1.171.3 and earlier.

Screenshots or Videos

No response

Platform

  • Router / Start Version: 1.171.4
  • OS: Linux
  • Browser: n/a

@tanstack/router-core 1.171.4
Node 24.14.0 test environment
npm("jsdom", "26.1.0")
npm("global-jsdom", "26.0.0")
Test runner: Mocha, with NODE_ENV=test (kotlin/gradle driven)

Additional context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions