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
git clone https://github.com/JetBrains/kotlin-wrappers
cd kotlin-wrappers/examples/
./gradlew tanstack-react-router-nodejs-test:jsNodeTest
- 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 (update → new RouterCore → new Router → createRouter):
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
Which project does this relate to?
Router
Describe the bug
Regression:
router-corescroll-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-coreis framework-agnostic core code, yet it now accesses several browser APIs as bare, unqualified globals — most importantly: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 —addEventListeneris not a bare global: it is inherited fromEventTarget.prototypeonwindowand is not present onglobalThis, and Node provides no native top-level one. The call therefore throws.Because
setupScrollRestorationruns during router creation, the throw crashes router construction itself, breaking every test that builds a router under jsdom.Affected version
@tanstack/router-core@1.171.4(published 2026-05-20), via fix: fix scroll restoration issues #7447 (0300f87) as a fix for Scroll position should be remembered for intermediate pages when navigating with resetScroll=false #6595.1.171.3and earlier are unaffected — they usedwindow.-qualified access.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 fromwindow.-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-coremeant 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
addEventListener('pagehide', …)setupScrollRestoration) — this is the one that crashes constructionscrollX,scrollYscrollhandler (real scroll events)scrollTo({ … })(×2)history.scrollRestorationhistorysessionStoragepagehideis the urgent one because it fires at construction time and so breaks router creation unconditionally under jsdom. ThescrollX/scrollY/scrollToaccesses 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. (NotesessionStoragealready has a defensive wrapper in the same code, so a similar approach would fit the other accesses naturally.)Steps to reproduce
git clone https://github.com/JetBrains/kotlin-wrapperscd kotlin-wrappers/examples/./gradlew tanstack-react-router-nodejs-test:jsNodeTestMinimal reproduction of the failure mode:
global-jsdomcopies only the window's own enumerable properties ontoglobalThis;addEventListeneris inherited fromEventTarget.prototype, so it never becomes a bare global.Expected behavior
Router creation succeeds under jsdom/Node, as it did in
1.171.3and earlier.Actual behavior
Router creation throws because the bare
addEventListenercannot be resolved, breaking any test that constructs a router. The stack trace confirms the failure originates insetupScrollRestorationand propagates straight up throughRouterCoreconstruction (update→new RouterCore→new Router→createRouter):Why this matters
router-coreis 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.window.addEventListenerand 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.sessionStorageis 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 the1.171.4source 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) — barehistory;document.addEventListener('scroll', …)(line 230) — alreadydocument-qualified;pagehideregistration (line 237) — the bare access being fixed.The original stack trace crashing at the
pagehideline — after lines 197 and 230 — proveshistoryanddocumentalready resolve in this environment (otherwise it would have thrown earlier). Withpagehidequalified, 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 thescrollhandler — on a real scroll event;scrollTo(…)(lines 333 / 356) inside theonRenderedsubscription — 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 theonRenderedhandler, so on the first post-navigation render it would be expected to throwReferenceError: 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 onlypagehide.Suggested direction (deferring to maintainers)
Restore browser-global robustness in
scroll-restoration.ts— either re-qualify withwindow.(the correct target;pagehide/scrollX/scrollY/scrollToare allWindowmembers) or guard the accesses the waysessionStoragealready is. Fixing thepagehideregistration alone unblocks router construction (see Verifying the one-line fix above), but it leaves thescrollX/scrollY/scrollToaccesses 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:Environment
@tanstack/router-core1.171.4global-jsdom) test environmentNODE_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
@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