Skip to content

Comments

Fix turbo-refresh-scroll ignored on error responses#1462

Open
yujiteshima wants to merge 5 commits intohotwired:mainfrom
yujiteshima:fix-1449-turbo-refresh-scroll-errors
Open

Fix turbo-refresh-scroll ignored on error responses#1462
yujiteshima wants to merge 5 commits intohotwired:mainfrom
yujiteshima:fix-1449-turbo-refresh-scroll-errors

Conversation

@yujiteshima
Copy link
Contributor

Description

Fixes #1449

The turbo-refresh-scroll meta tag was being ignored for non-2XX HTTP responses. This PR ensures that both "reset" and "preserve" scroll modes work correctly with error responses (404, 500, etc.), not just successful responses.

Problem

Previously, scroll behavior was only applied to successful (2XX) responses. When navigating to error pages, the scroll position would remain unchanged regardless of the turbo-refresh-scroll setting:

Before:

  • Successful response: loadResponse()renderPageSnapshot()performScroll()
  • Error response: loadResponse()renderError() → (no scroll handling) ✗

This created inconsistent behavior where error pages would leave users scrolled down, potentially hiding the error message.

Solution

Added scroll handling to the error rendering path in src/core/drive/visit.js:

  1. Capture the turbo-refresh-scroll setting from the current snapshot before rendering
  2. After rendering the error page, apply the appropriate scroll behavior:
    • "preserve": Restore the previous scroll position
    • "reset" (or default): Scroll to top

After:

  • Error response: loadResponse()renderError() → apply scroll behavior ✓

Changes

  • src/core/drive/visit.js: Add scroll handling for error responses
  • src/tests/fixtures/navigation_scroll_reset.html: New test fixture for reset mode
  • src/tests/fixtures/navigation_scroll_preserve.html: New test fixture for preserve mode
  • src/tests/fixtures/404.html: Make scrollable for testing
  • src/tests/fixtures/500.html: Make scrollable for testing
  • src/tests/server.mjs: Add GET /error endpoint for test cases
  • src/tests/functional/navigation_tests.js: Add 4 comprehensive test cases

Test Coverage

Added tests covering:

  • ✅ Reset scroll on 404 error
  • ✅ Reset scroll on 500 error
  • ✅ Reset scroll on success (regression test)
  • ✅ Preserve scroll on 404 error

All tests pass in both Chrome and Firefox.

Related

Copy link
Contributor

@seanpdoyle seanpdoyle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The introduction of 404.html and 500.html as dedicated routes introduced in the src/tests/server.mjs changes deviate from how page refreshes behave in real world applications.

Since the <a> elements drive the page to a URL that differs from the one initiating the request, it does not reproduce the Page Refresh behavior outlined in the original issue (#1449).

Have you explored alternatives that drives to the same URL that's initiating the navigation?

Comment on lines 547 to 554
assert.notOk(await isScrolledToTop(page), "page is scrolled down")

await page.click("#link-404")
await nextBody(page)
await nextBeat()

assert.ok(await isScrolledToTop(page), "page is scrolled to the top after 404 error")
assert.equal(await page.locator("h1").textContent(), "Not Found")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If these assert calls were replaced with async expect-based expectations, and the h1 assertions were first, the nextBody and nextBeat calls might not be necessary:

Could these calls to assert be replaced with expect calls?

Suggested change
assert.notOk(await isScrolledToTop(page), "page is scrolled down")
await page.click("#link-404")
await nextBody(page)
await nextBeat()
assert.ok(await isScrolledToTop(page), "page is scrolled to the top after 404 error")
assert.equal(await page.locator("h1").textContent(), "Not Found")
expect(await isScrolledToTop(page), "page is scrolled down").toEqual(false)
await page.click("#link-404")
await expect(page.locator("h1")).toHaveText("Not Found")
expect(await isScrolledToTop(page), "page is scrolled to the top after 404 error").toEqual(true)
})

Comment on lines 560 to 566
assert.notOk(await isScrolledToTop(page), "page is scrolled down")

await page.click("#link-500")
await nextBody(page)

assert.ok(await isScrolledToTop(page), "page is scrolled to the top after 500 error")
assert.equal(await page.locator("h1").textContent(), "Internal Server Error")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
assert.notOk(await isScrolledToTop(page), "page is scrolled down")
await page.click("#link-500")
await nextBody(page)
assert.ok(await isScrolledToTop(page), "page is scrolled to the top after 500 error")
assert.equal(await page.locator("h1").textContent(), "Internal Server Error")
expect(await isScrolledToTop(page), "page is scrolled down").toEqual(false)
await page.click("#link-500")
await expect(page.locator("h1")).toHaveText("Internal Server Error")
expect(await isScrolledToTop(page), "page is scrolled to the top after 500 error").toEqual(true)

Comment on lines 572 to 578
assert.notOk(await isScrolledToTop(page), "page is scrolled down")

await page.click("#link-success")
await nextBody(page)

assert.ok(await isScrolledToTop(page), "page is scrolled to the top after success")
assert.equal(await page.locator("h1").textContent(), "One")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
assert.notOk(await isScrolledToTop(page), "page is scrolled down")
await page.click("#link-success")
await nextBody(page)
assert.ok(await isScrolledToTop(page), "page is scrolled to the top after success")
assert.equal(await page.locator("h1").textContent(), "One")
expect(await isScrolledToTop(page), "page is scrolled down").toEqual(false)
await page.click("#link-success")
await expect(page.locator("h1")).toHaveText("One")
expect(await isScrolledToTop(page), "page is scrolled to the top after success").toEqual(true)

Comment on lines 585 to 592
assert.notOk(await isScrolledToTop(page), "page is scrolled down")

await page.click("#link-404")
await nextBody(page)

const newScrollY = await page.evaluate(() => window.scrollY)
assert.equal(newScrollY, scrollY, "scroll position should be preserved after 404 error")
assert.equal(await page.locator("h1").textContent(), "Not Found")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
assert.notOk(await isScrolledToTop(page), "page is scrolled down")
await page.click("#link-404")
await nextBody(page)
const newScrollY = await page.evaluate(() => window.scrollY)
assert.equal(newScrollY, scrollY, "scroll position should be preserved after 404 error")
assert.equal(await page.locator("h1").textContent(), "Not Found")
expect(await isScrolledToTop(page), "page is scrolled down").toEqual(false)
await page.click("#link-404")
await expect(page.locator("h1")).toHaveText("Not Found")
const newScrollY = await page.evaluate(() => window.scrollY)
expect(newScrollY, "scroll position should be preserved after 404 error").toEqual(scrollY)

Comment on lines 222 to 235
const currentSnapshot = this.view.snapshot
const preserveScroll = currentSnapshot.refreshScroll === "preserve"
const scrollPosition = preserveScroll ? { x: window.scrollX, y: window.scrollY } : null

await this.view.renderError(PageSnapshot.fromHTMLString(responseHTML), this)

// Handle scroll position after error rendering
if (preserveScroll && scrollPosition) {
this.view.scrollToPosition(scrollPosition)
} else if (!this.scrollToAnchor()) {
this.view.scrollToTop()
}
this.scrolled = true

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than adding these lines to account for the scrolling behavior, have you explored invoking this.performScroll()?

Suggested change
const currentSnapshot = this.view.snapshot
const preserveScroll = currentSnapshot.refreshScroll === "preserve"
const scrollPosition = preserveScroll ? { x: window.scrollX, y: window.scrollY } : null
await this.view.renderError(PageSnapshot.fromHTMLString(responseHTML), this)
// Handle scroll position after error rendering
if (preserveScroll && scrollPosition) {
this.view.scrollToPosition(scrollPosition)
} else if (!this.scrollToAnchor()) {
this.view.scrollToTop()
}
this.scrolled = true
await this.view.renderError(PageSnapshot.fromHTMLString(responseHTML), this)
this.performScroll()

It would mirror this branch's counterpart (the this.renderPageSnapshot(snapshot, false))

async renderPageSnapshot(snapshot, isPreview) {
await this.viewTransitioner.renderChange(this.view.shouldTransitionTo(snapshot), async () => {
await this.view.renderPage(snapshot, isPreview, this.willRender, this)
this.performScroll()
})
}

yujiteshima added a commit to yujiteshima/hotwire-turbo-issue-1449-repro that referenced this pull request Nov 9, 2025
Update turbo-fixed.js with the latest build that includes
the performScroll() refactoring based on code review feedback.

Changes:
- Update public/turbo-fixed.js to latest build
- Update README.md to reflect current fix implementation
- Clarify that the fix uses performScroll() for consistency
- Add code example showing before/after comparison

The fix ensures scroll behavior is handled consistently for
all response types (2XX and non-2XX) by calling performScroll()
after rendering error responses.

Related: hotwired/turbo#1449, hotwired/turbo#1462
@yujiteshima
Copy link
Contributor Author

Thank you for the thorough review! I've addressed all the feedback points. Here's a summary of the changes:

Changes Made

1. Refactored scroll handling in visit.js

As suggested, I've replaced the manual scroll position handling with a call to the existing this.performScroll() method to maintain consistency with the successful response handling pattern.

Before:

const currentSnapshot = this.view.snapshot
const preserveScroll = currentSnapshot.refreshScroll === "preserve"
const scrollPosition = preserveScroll ? { x: window.scrollX, y: window.scrollY } : null

await this.view.renderError(PageSnapshot.fromHTMLString(responseHTML), this)

if (preserveScroll && scrollPosition) {
  this.view.scrollToPosition(scrollPosition)
} else if (!this.scrollToAnchor()) {
  this.view.scrollToTop()
}
this.scrolled = true

After:

await this.view.renderError(PageSnapshot.fromHTMLString(responseHTML), this)
this.performScroll()

2. Updated test assertions to use expect style

Converted all test assertions from assert.ok/notOk/equal to Playwright's expect().toEqual() pattern for consistency. Also:

  • Reordered assertions to verify page content (h1 text) before checking scroll position
  • Removed explicit nextBody() and nextBeat() timing helpers in favor of await expect(page.locator("h1")).toHaveText(...) which waits automatically
  • Used assertPageScroll(page, top, left) helper in page_refresh_tests.js for consistency with existing tests

Example:

// Before
assert.notOk(await isScrolledToTop(page), "page is scrolled down")
await page.click("#link-404")
await nextBody(page)
await nextBeat()
assert.ok(await isScrolledToTop(page), "page is scrolled to the top after 404 error")
assert.equal(await page.locator("h1").textContent(), "Not Found")

// After
expect(await isScrolledToTop(page), "page is scrolled down").toEqual(false)
await page.click("#link-404")
await expect(page.locator("h1")).toHaveText("Not Found")
expect(await isScrolledToTop(page)).toEqual(true)

3. Clarified test scope and naming

I've updated the tests to better reflect what's actually being tested:

  • Removed turbo-refresh-scroll references from test names (this meta tag only applies to same-URL page refreshes, not cross-page navigation)
  • Renamed fixture: navigation_scroll_reset.htmlnavigation_error.html
  • Removed the misleading <meta name="turbo-refresh-scroll" content="reset"> tag from the fixture
  • Deleted navigation_scroll_preserve.html (preserve mode was out of scope for Issue turbo-refresh-scroll = reset is only honored on HTTP status 2XX #1449)
  • Updated test names to be more accurate:
    • ❌ "resets scroll position on 404 error with turbo-refresh-scroll=reset"
    • ✅ "resets scroll position when navigating to a 404 error page"

4. Expanded test coverage to include form submissions

While Issue #1449 specifically mentions "clicking any link", the fix (performScroll()) applies to all error responses, including form submissions. To ensure comprehensive coverage and prevent future regressions, I've added tests for form submission error handling:

Added tests in page_refresh_tests.js:

  • preserves scroll position when form submission returns 422 error with turbo-refresh-scroll=preserve
  • resets scroll position when form submission returns 422 error with turbo-refresh-scroll=reset

Why form submission tests were added:

  • The performScroll() method handles scroll behavior for all visit types (link clicks, form submissions, page refreshes)
  • Form submissions returning 422 errors represent a common real-world scenario (validation failures)
  • These tests verify that turbo-refresh-scroll meta tags are correctly respected for same-URL error responses
  • This prevents regressions and ensures the fix works comprehensively across all error scenarios

Additional fixtures and infrastructure:

  • 422_morph_reset.html - Error page with turbo-refresh-scroll: reset
  • Modified 422_morph.html - Made scrollable for testing
  • Modified page_refresh.html - Added form for reset mode testing
  • Added /error endpoint in server.mjs for navigation error tests
  • Added /reject/morph/reset endpoint in server.mjs for form submission tests

Response to Test Approach Concern

Regarding your question about alternative test approaches:

"Have you explored alternatives that drive to the same URL that's initiating the navigation?"

Thank you for raising this important point. I've considered several alternative approaches:

Alternative Approaches Considered

Option 1: Same-URL with query parameters

GET /navigation_error.html?trigger_error=404 → 404 status

Option 2: Dynamic fixture behavior

router.get("/dynamic-fixture.html", (req, res) => {
  if (req.session.visitCount > 0) {
    res.status(404).sendFile('404.html')
  }
})

Option 3: Dedicated error page paths

<a href="/nonexistent-page.html">404</a>

Why I Chose the Current Approach

After investigation, I found the /__turbo/* endpoint pattern is the established convention throughout this codebase:

$ grep -r "/__turbo/" src/tests/fixtures/*.html | wc -l
98

Examples include:

  • /__turbo/redirect - testing redirects
  • /__turbo/refresh - testing page refreshes
  • /__turbo/messages - testing form submissions
  • /__turbo/delayed_response - testing async behavior

The server mounts these via app.use(/\/__turbo/, router), so /__turbo/error maps to router.get("/error"). This pattern provides:

✅ Consistency with 98 existing test usages
✅ Clarity of intent (endpoint name describes behavior)
✅ Maintainability (centralized test server logic)
✅ Reliability (deterministic responses)

Issue #1449 Scope Clarification

The original issue specifically describes link-based navigation:

"When performing a turbo visit by clicking any link, if the response HTTP status code returned is anything but a 2XX level..."

"if the current page has been scrolled, and contains a link to a controller returning 404..."

The core problem is: performScroll() wasn't being called for error responses, causing scroll position to remain unchanged. This affects all error scenarios uniformly, regardless of whether the URL changes. The fix ensures performScroll() is called, which:

  • For cross-page navigation (like the link tests): Always scrolls to top (or anchor if present)
  • For same-URL page refreshes (like the form tests): Respects the turbo-refresh-scroll meta tag

Going Forward

I'm open to implementing a more realistic test approach if you feel it's important. However, I'd suggest:

  1. For this PR: Keep the current approach for consistency with the 98 existing /__turbo/* usages
  2. Separately: If there's interest in modernizing test patterns, I'm happy to open an issue to discuss whether the test suite should move toward more realistic URL patterns instead of /__turbo/* endpoints

This would allow:

Does this sound reasonable? I'm happy to adjust the approach if you feel the alternative patterns would be significantly better for this specific case.

Summary of Current State

Files changed:

  • src/core/drive/visit.js - Replaced manual scroll handling with performScroll()
  • src/tests/functional/navigation_tests.js - Updated 3 tests with expect-style assertions, clarified naming
  • src/tests/functional/page_refresh_tests.js - Added 2 new tests for form submission errors
  • src/tests/fixtures/navigation_error.html - New fixture (renamed from navigation_scroll_reset.html, removed misleading meta tag)
  • src/tests/fixtures/navigation_scroll_preserve.html - Deleted (out of scope)
  • src/tests/fixtures/navigation_scroll_reset.html - Deleted (renamed to navigation_error.html)
  • src/tests/fixtures/422_morph.html - Made scrollable
  • src/tests/fixtures/422_morph_reset.html - New fixture for reset mode
  • src/tests/fixtures/page_refresh.html - Added form for reset mode testing
  • src/tests/server.mjs - Added /error and /reject/morph/reset endpoints

Tests coverage:

  • ✅ Link navigation to 404 error page
  • ✅ Link navigation to 500 error page
  • ✅ Link navigation to success page (regression test)
  • ✅ Form submission returning 422 with preserve mode
  • ✅ Form submission returning 422 with reset mode

All tests pass on Chrome. Please let me know if you'd like any adjustments!

@yujiteshima
Copy link
Contributor Author

@seanpdoyle Thank you for your patience. I now understand the issue correctly.

My Previous Misunderstanding

In my previous comment, I thought updating the test names and explaining the /__turbo/error endpoint pattern would address your concern. However, I missed the fundamental point: Navigation vs Page Refresh.

The Real Issue

You were right - those tests in navigation_tests.js were testing cross-page navigation (URL changes), not Page Refresh (same URL). The turbo-refresh-scroll meta tag only applies to Page Refresh, so those tests were completely out of scope for this PR.

What I've Done Now

Removed:

  • ❌ 3 navigation tests from navigation_tests.js (navigated to different URLs)
  • navigation_error.html fixture
  • /__turbo/error endpoint

Added:

  • ✅ Page Refresh tests for 404 and 500 errors in page_refresh_tests.js
  • 404_reset.html and 500_reset.html fixtures with turbo-refresh-scroll=reset
  • /__turbo/reject/reset endpoint

Test Coverage

All tests now correctly test same-URL Page Refresh behavior:

Status Scroll Mode Result
422 preserve ✓ Pass
422 reset ✓ Pass
404 reset ✓ Pass
500 reset ✓ Pass

Each test uses form submissions that return error responses on the same URL, correctly reproducing the scenario in #1449.

Thank you for helping me understand this critical distinction!

Add test coverage for verifying that turbo-refresh-scroll meta tag
behavior works with error responses (404, 500) in addition to
successful responses.

- Add test fixtures for reset and preserve scroll modes
- Make error page fixtures (404.html, 500.html) scrollable
- Add GET /error endpoint to test server for returning error responses
- Add 4 test cases covering both reset and preserve modes on errors
Honor the turbo-refresh-scroll meta tag for non-2XX status codes.
Previously, scroll behavior (reset/preserve) was only applied to
successful (2XX) responses. Error responses now correctly respect
the scroll mode configuration.

When rendering error responses, capture the current scroll mode
before rendering, then apply the appropriate scroll behavior:
- "preserve": restore the previous scroll position
- "reset" (or default): scroll to top

Fixes hotwired#1449
Replace manual scroll position handling with the existing
performScroll() method to maintain consistency with the
successful response handling pattern.

- Remove manual scroll capture and restoration logic
- Call this.performScroll() after renderError()
- Leverage existing scroll behavior handling

Addresses review feedback from @seanpdoyle
- Convert assertions from assert to expect() style
- Remove explicit timing helpers (nextBody/nextBeat) in favor of
  await expect(locator).toHaveText() automatic waiting
- Clarify test scope: remove turbo-refresh-scroll references from
  navigation tests (meta tag only applies to same-URL page refreshes)
- Rename fixture: navigation_scroll_reset.html → navigation_error.html
- Add form submission error tests for comprehensive coverage
- Use assertPageScroll() helper in page_refresh_tests.js

Test files:
- Update navigation_tests.js with expect style and clarified naming
- Add 422 error tests to page_refresh_tests.js

Fixtures:
- Create navigation_error.html (renamed, removed misleading meta tag)
- Delete navigation_scroll_preserve.html (out of scope)
- Make 422_morph.html scrollable
- Create 422_morph_reset.html for reset mode testing
- Update page_refresh.html with reset mode form

Server:
- Add /error endpoint for error response testing
- Add /reject/morph/reset endpoint for reset mode testing

Addresses review feedback from @seanpdoyle
Addresses @seanpdoyle's review feedback. The navigation tests were testing
cross-page navigation instead of Page Refresh behavior. The turbo-refresh-scroll
meta tag only applies to same-URL page refreshes, not navigation.

Changes:
- Remove 3 navigation tests that navigated to different URLs
- Remove unused navigation_error.html and /__turbo/error endpoint
- Add Page Refresh tests for 404/500 errors with turbo-refresh-scroll=reset
- Add 404_reset.html and 500_reset.html fixtures
- Add /__turbo/reject/reset endpoint and update page_refresh.html

All tests now correctly test same-URL Page Refresh behavior.

Related: hotwired#1449
@yujiteshima yujiteshima force-pushed the fix-1449-turbo-refresh-scroll-errors branch from 3f1cb51 to 423d697 Compare December 13, 2025 05:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

turbo-refresh-scroll = reset is only honored on HTTP status 2XX

2 participants