---
title: Focus Jumping in Inline-Editable Tables
slug: focus-restoration-after-row-replace
category: Tables
summary: Why focus flies to the first row of the table after an inline edit saves, and the snapshot-restore-verify pattern that puts the cursor back where the user left it when Turbo replaces the whole row.
tags: [stimulus, turbo, turbo-streams, focus, tables, forms, ux-default]
status: stable
visibility: public
source_project: rsb.llamapress.ai
layers: [view, controller, stimulus_js]
related:
  - title: High-Quality Inline-Editable Table
    url: /cookbook/inline-editable-table
    summary: The base pattern. It edits one cell with a fetch and never replaces the row, so it never hits this bug — read it first if you are building a grid from scratch.
  - title: Multi-Select, Bulk Edit & Mass Reassignment
    url: /cookbook/bulk-edit-and-mass-reassignment
    summary: The other thing that goes wrong when a table row save gets clever. Read before adding multi-select.
  - title: Async Action Feedback
    url: /cookbook/async-action-feedback
    summary: The saving/saved indicator that sits next to this pattern.
---

# Focus Jumping in Inline-Editable Tables

> ⚠️ **Cookbook example — not live code.** Every code block below is an **example
> snippet**, **not part of the llamapress.ai codebase**, and **not running on this
> server**. This is a reference recipe for a **Leo instance (an AI coding agent) to
> implement in its own app** — read it to understand the pattern, then recreate it there.

A user changes a dropdown in row 14 of an editable table. The row saves correctly. Then
the cursor lands on the **first dropdown of the first row**, forty rows away. They scroll
back, find their place, change the next cell, and get thrown to the top again.

Nothing errors. The data is right. Only the caret is wrong — which is why this bug ships:
it never shows up in a test that asserts on the database.

This guide is about that class of bug: **restoring focus when a Turbo Stream replaces the
element the user was focused on.**

> **When to use:** any table where changing a field triggers a save that re-renders the
> whole row (recalculated totals, derived columns, conditional cells).
> **When not to:** if you only need to update a couple of display values, don't replace the
> row at all — see the first section. That is the real fix most of the time.

---

## Step 0 — The cheapest fix is not to replace the row

The browser has one rule here: **when a focused element is removed from the DOM, focus
falls to `document.body`.** Nothing you do afterwards is free. So the first question is
whether the row has to be replaced at all.

```ruby
# app/controllers/line_items_controller.rb

# ❌ Replaces the whole <tr>, including the <select> the user is standing on.
render turbo_stream: turbo_stream.replace(
  @line_item, partial: "line_items/line_item", locals: { line_item: @line_item }
)

# ✅ Updates only the derived cells. The focused element is never touched,
#    so focus stays put with zero JavaScript.
render turbo_stream: [
  turbo_stream.update("rate_display_#{@line_item.id}",  @line_item.rate_display),
  turbo_stream.update("total_display_#{@line_item.id}", @line_item.total_display)
]
```

Targeted updates are faster, produce less DOM churn, and cannot break focus. Use full-row
replacement only when the save genuinely changes the row's **structure** — a cell appears,
a column becomes read-only, a select's option list changes.

Everything below is for when you actually need `turbo_stream.replace`.

---

## The 80/20 in one breath

1. **Snapshot before the submit**: write the focused field's `name` and its row id onto a
   node that will *survive* the replacement — the `<tbody>`, not the `<tr>`.
2. **Submit.** Turbo replaces the row. The old Stimulus controller instance dies; a new one
   connects on the new row.
3. **Catch the end of the submit** on `document`, filtered by form identity — not with a
   `data-action` on the row, which is gone by then.
4. **Restore inside `requestAnimationFrame`**, with a selector scoped to the **row id**:
   `#row_id [name="..."]`. The row scope is the whole ballgame — see the next section.
5. **Clear the snapshot** so a later save can't restore stale coordinates.

---

## The root cause of "it jumps to the first row"

This is the single highest-value paragraph in the guide.

Field names in a Rails table are **identical across every row**. Row 1 and row 40 both
contain `name="line_item[status]"`. So a restore that searches by name alone:

```javascript
// ❌ Matches the FIRST element in the document with that name.
//    Every save sends the user to row 1. This IS the "focus jump".
const target = document.querySelector(`[name="${name}"]`)
```

...always finds row 1. The fix is to scope the query to the row, and to **refuse to restore
at all** if you don't have a row id:

```javascript
// ✅ Row-scoped. Requires BOTH halves of the coordinate.
if (!fieldName || !rowId) return          // no guess-restoring
const selector = `#${CSS.escape(rowId)} [name="${CSS.escape(fieldName)}"]`
const target = document.querySelector(selector)
if (target) target.focus()
```

`CSS.escape` is not optional. Rails field names contain square brackets
(`line_item[status]`) and ids contain nothing you control; unescaped, the selector either
throws or silently matches the wrong thing.

**A missing row id must mean "do nothing", never "fall back to name only."** A fallback
here is precisely the bug: it restores focus successfully, to the wrong row, every time.

---

## Layer 1 — The view

Three things matter in the markup: a **stable row id**, a **persistent container** above
the rows, and the Stimulus wiring.

```erb
<%# app/views/line_items/index.html.erb %>
<div class="overflow-auto max-h-[70vh]">
  <table class="min-w-full">
    <thead>...</thead>

    <%# This <tbody> is the persistence anchor. It is NOT replaced by any stream,
        so its dataset survives row replacement. Everything we need to remember
        between "before submit" and "after replace" lives here. %>
    <tbody id="line_items_body">
      <%= render @line_items %>
    </tbody>
  </table>
</div>
```

```erb
<%# app/views/line_items/_line_item.html.erb %>
<%# dom_id gives a stable id that the replacement re-uses — this is the row half
    of the focus coordinate. %>
<tr id="<%= dom_id(line_item) %>"
    data-controller="dirty-form"
    data-action="change->dirty-form#save
                 turbo:submit-start->dirty-form#handleSubmitStart">
  <%= form_with model: line_item, class: "contents" do |f| %>
    <td><%= f.select :status, LineItem::STATUSES, {}, class: "select select-sm" %></td>
    <td><%= f.select :assignee_id, @assignee_options, {}, class: "select select-sm" %></td>
    <td><%= f.text_field :quantity, class: "input input-sm" %></td>
  <% end %>
  <td id="<%= dom_id(line_item, :total) %>"><%= line_item.total_display %></td>
</tr>
```

Note what is **not** there: no `turbo:submit-end->dirty-form#handleSubmitEnd`. That
listener has to live somewhere that outlives the row. Layer 3 explains why.

---

## Layer 2 — The controller

Nothing exotic. Respond with a stream that replaces the row, and keep the partial's root
id stable so the new row has the same id as the old one.

```ruby
# app/controllers/line_items_controller.rb
def update
  @line_item = current_account.line_items.find(params[:id])

  if @line_item.update(line_item_params)
    respond_to do |format|
      format.turbo_stream do
        render turbo_stream: turbo_stream.replace(
          @line_item, partial: "line_items/line_item", locals: { line_item: @line_item }
        )
      end
    end
  else
    render turbo_stream: turbo_stream.replace(
      @line_item, partial: "line_items/line_item", locals: { line_item: @line_item }
    ), status: :unprocessable_entity
  end
end
```

The status code matters to the client: **do not restore focus when the save failed.** The
Stimulus side checks `event.detail.success` before touching focus, so a rejected save
leaves the user's cursor exactly where they were arguing with the validation.

---

## Layer 3 — Making sure you are still listening

Here is the part that costs people an afternoon.

`turbo:submit-end` is the natural place to restore focus — it fires after Turbo has
rendered the stream. The obvious wiring is a `data-action` on the row. It does not work,
for two independent reasons.

**Reason one: Stimulus has already unbound the row.** Stimulus detects DOM changes with a
`MutationObserver`, and observer callbacks run as **microtasks**. Turbo's stream rendering
is `async`, so it yields at `await` boundaries — and the observer fires in those gaps. By
the time the render finishes, Stimulus has called `disconnect()` on the old controller and
removed every `data-action` listener from the old row.

**Reason two: Turbo redirects the event when the form is detached.** This is the dispatch
helper, verbatim from `turbo.min.js` (turbo-rails 2.0.16):

```javascript
// node_modules/@hotwired/turbo — dispatch()
function dispatch(eventName, { target, cancelable, detail } = {}) {
  const event = new CustomEvent(eventName, { cancelable, bubbles: true, composed: true, detail })
  target && target.isConnected
    ? target.dispatchEvent(event)
    : document.documentElement.dispatchEvent(event)   // ← the trapdoor
  return event
}
```

And the call site:

```javascript
// FormSubmission#requestFinished
dispatch("turbo:submit-end", {
  target: this.formElement,
  detail: { formSubmission: this, ...this.result }
})
```

By the time `requestFinished` runs, the stream has already been rendered and the form has
been ripped out with its row. `formElement.isConnected === false`, so Turbo dispatches the
event on **`document.documentElement`** instead. It bubbles up to `document` and `window`
and **never reaches the detached form** — so a listener you attached directly to the form
is bypassed too.

The fix that survives both problems: **listen on `document`, and identify your submission
by object identity.**

```javascript
// app/javascript/controllers/dirty_form_controller.js
connect() {
  this.form = this.element.querySelector("form")
  if (!this.form) return
  this.isSubmitting = false

  // Cache the persistent ancestors NOW. After the row is replaced,
  // this.element is detached and closest() returns null.
  this.container  = this.element.closest("tbody")
  this.overflowEl = this.element.closest(".overflow-auto")

  // Document-level, because the event may be dispatched on <html>.
  // Identity filter, because every row in the table hears it.
  this._onSubmitEnd = (event) => {
    if (event.detail?.formSubmission?.formElement === this.form) {
      this.handleSubmitEnd(event)
    }
  }
  document.addEventListener("turbo:submit-end", this._onSubmitEnd)
}

disconnect() {
  // Keep the listener alive across the replacement that our OWN submit caused —
  // otherwise we unsubscribe milliseconds before the event we are waiting for.
  if (!this.isSubmitting) this.teardown()
}

teardown() {
  if (this._onSubmitEnd) document.removeEventListener("turbo:submit-end", this._onSubmitEnd)
  this._onSubmitEnd = null
}
```

Why `===` is safe: `event.detail.formSubmission.formElement` is the exact same JavaScript
object that was submitted. Turbo holds it on the `FormSubmission` instance and spreads it
into the event detail. Attached or detached, object identity is stable — which is the one
thing about the element that the DOM cannot take away from you.

The `isSubmitting` guard in `disconnect()` is what makes the listener outlive its own row.
Set it in `handleSubmitStart`, clear it in `handleSubmitEnd`, and call `teardown()` there
so the listener does not leak once it has done its job.

---

## Layer 4 — Snapshot and restore

State lives on the **container**, never on the controller instance. The instance is
destroyed by the replacement; the `<tbody>` is not.

```javascript
// app/javascript/controllers/dirty_form_controller.js

// --- Before the submit -------------------------------------------------------
save(event) {
  if (this.isSubmitting) return

  const activeEl = document.activeElement

  // 1. Snapshot BEFORE blurring. The blur below moves focus to <body>;
  //    snapshot after it and there is nothing left to record.
  if (activeEl && this.element.contains(activeEl) && this.container) {
    this.storeFocusData(activeEl, this.element.id)
  }

  // 2. Blur deliberately, so the browser does not hunt for a new focus target
  //    (and scroll the page) when Turbo removes the element.
  if (activeEl && this.element.contains(activeEl)) activeEl.blur()

  // 3. Save scroll for both scrollers — the window and the table wrapper.
  if (this.container)  this.container.dataset.savedScrollY   = window.scrollY
  if (this.overflowEl) this.overflowEl.dataset.savedScrollTop = this.overflowEl.scrollTop

  this.form.requestSubmit()
}

storeFocusData(activeEl, rowId) {
  this.container.dataset.focusedField = activeEl.getAttribute("name") || ""
  this.container.dataset.focusedRowId = rowId || ""
  // Text inputs have a caret; <select> does not. Guard on undefined.
  if (activeEl.selectionStart !== undefined && activeEl.selectionStart !== null) {
    this.container.dataset.focusedSelectionStart = activeEl.selectionStart
    this.container.dataset.focusedSelectionEnd   = activeEl.selectionEnd
  }
}

clearFocusData() {
  if (!this.container) return
  delete this.container.dataset.focusedField
  delete this.container.dataset.focusedRowId
  delete this.container.dataset.focusedSelectionStart
  delete this.container.dataset.focusedSelectionEnd
}

handleSubmitStart() {
  this.isSubmitting = true
  const activeEl = document.activeElement
  const inThisRow = activeEl && this.element.contains(activeEl)

  // A focused element in a DIFFERENT row means the user genuinely moved on —
  // drop the snapshot so we never yank them back.
  // activeEl === document.body is OUR OWN blur from save(), not a move. Keep it.
  const storedRowId = this.container?.dataset.focusedRowId
  const userMovedAway = activeEl && activeEl !== document.body && !inThisRow &&
                        storedRowId && storedRowId !== this.element.id

  if (userMovedAway)   this.clearFocusData()
  else if (inThisRow)  this.storeFocusData(activeEl, this.element.id)  // direct-submit paths
}

// --- After the row has been replaced -----------------------------------------
handleSubmitEnd(event) {
  this.isSubmitting = false
  this.teardown()

  const c = this.container
  const fieldName = c?.dataset.focusedField
  const rowId     = c?.dataset.focusedRowId
  const selStart  = c?.dataset.focusedSelectionStart
  const selEnd    = c?.dataset.focusedSelectionEnd
  const savedY    = c?.dataset.savedScrollY
  const savedTop  = this.overflowEl?.dataset.savedScrollTop

  this.clearFocusData()                                  // read once, then burn it
  if (c) delete c.dataset.savedScrollY
  if (this.overflowEl) delete this.overflowEl.dataset.savedScrollTop

  requestAnimationFrame(() => {
    // Restore scroll first — focus() may scroll, and we correct it afterwards.
    if (savedTop !== undefined) this.overflowEl.scrollTop = parseFloat(savedTop)
    if (savedY   !== undefined) window.scrollTo(0, parseFloat(savedY))

    const failed = event?.detail?.success === false
    if (fieldName && rowId && !failed) {
      // Did the user move somewhere real while the save was in flight?
      const active = document.activeElement
      const row    = document.getElementById(rowId)
      const movedOn = active && active !== document.body && (!row || !row.contains(active))

      if (!movedOn) {
        const target = document.querySelector(
          `#${CSS.escape(rowId)} [name="${CSS.escape(fieldName)}"]`
        )
        if (target && typeof target.focus === "function") {
          target.focus()
          if (selStart !== undefined && target.setSelectionRange) {
            target.setSelectionRange(parseInt(selStart), parseInt(selEnd))
          }
        }
      }
    }

    // focus() scrolls the element into view. Undo that.
    if (savedTop !== undefined) this.overflowEl.scrollTop = parseFloat(savedTop)
    if (savedY   !== undefined) window.scrollTo(0, parseFloat(savedY))
  })
}
```

---

## How to debug this yourself

Logic alone will not find the break, because there are four places the chain can snap and
they all look identical from the outside (the cursor is in the wrong place). Log at the
**handoff points** — the boundary between stages — not at random lines:

```
save() / storeFocusData()      → is the right field + rowId being written?
        ↓
document listener fires        → does it fire at all? does the identity filter match?
        ↓
disconnect()                   → is the listener kept (isSubmitting) or dropped?
        ↓
handleSubmitEnd → RAF          → what are fieldName/rowId? what does querySelector return?
```

Then read the output as a table. The **first missing or wrong log is the bug**:

| What you see | What it means |
|---|---|
| `storeFocusData` never logs | The save took a different code path (a silent `fetch`, a direct submit) that skips the snapshot |
| `submit-end fired, matches? false` | `this.form` is stale or null — usually the form was looked up before it existed |
| `disconnect: removing listener` | `isSubmitting` was false — `handleSubmitStart` did not fire, so the guard never engaged |
| RAF logs with an empty `rowId` | Something cleared the dataset between the snapshot and the read — look for a second save path |
| Correct data, but `target` is the wrong element | The selector is not row-scoped. This is the classic first-row jump |
| Everything correct, focus still wrong | Something after you is stealing focus — a modal, an autofocus attribute, or a second controller on the same row |

Do this in one pass. Adding logs one at a time turns a 20-minute job into an afternoon,
because each stage's failure is invisible until you can see the stage before it succeeded.

---

## Gotchas (the hard-won stuff)

- **An unscoped selector is the bug.** `[name="..."]` matches row 1. If the symptom is
  "focus jumps to the top of the table", stop reading and go check the selector for a row
  scope. Everything else in this guide is the plumbing that gets a row id to that line.
- **Never fall back to a name-only lookup.** No row id means *do not restore*. A fallback
  silently reintroduces the exact bug you are fixing.
- **Controller instance state does not survive a row replace.** `this.savedFocus = ...` is
  gone the moment Turbo swaps the `<tr>`. Persist to a `dataset` on an ancestor that no
  stream targets — usually the `<tbody>`.
- **`closest()` returns null on a detached element.** After the replacement,
  `this.element.closest("tbody")` is `null` and every `?.` quietly skips your restore.
  Cache the ancestors in `connect()`.
- **Turbo redirects `turbo:submit-end` to `<html>` when the form is detached.** A listener
  on the row *or on the form itself* is bypassed. Listen on `document` and filter with
  `event.detail.formSubmission.formElement === this.form`.
- **Stimulus unbinds `data-action` listeners mid-render.** MutationObserver callbacks are
  microtasks and fire inside Turbo's `await` gaps, so `disconnect()` runs *before*
  `turbo:submit-end`. Do not put post-replacement work on a `data-action` attached to the
  element being replaced.
- **Guard `disconnect()` with `isSubmitting`.** Otherwise the controller unsubscribes from
  the event it exists to wait for, milliseconds before it arrives.
- **Your own `blur()` is not the user moving away.** Blurring before submit is correct — it
  stops the browser from scrolling while it hunts for a new focus target — but it leaves
  `document.activeElement === document.body`. Code that treats "not in this row" as
  "user moved on" will throw away its own snapshot. Test for `!== document.body`
  explicitly.
- **Snapshot before you blur, not after.** Order matters more than anything else in
  `save()`.
- **A second save path will use stale coordinates.** If some edits save via a silent
  `fetch()` (bypassing Turbo's lifecycle), nothing clears the dataset — and the *next*
  Turbo save restores focus from those leftovers, landing the user on a row they edited a
  minute ago. Clear the focus keys in every save path, not just the Turbo one.
- **Read the snapshot once, then delete it.** Treat it like a message queue, not a cache.
- **Do not restore on a failed save.** Check `event.detail.success` — on a validation error
  the user is probably already interacting with the error.
- **Do not yank the user back.** Saves take 200–800ms; people keep typing. If focus is
  already on a real element outside the saved row, skip the restore entirely.
- **`focus()` scrolls.** Restore scroll, focus, then restore scroll again. And restore
  *both* scrollers — the window and the `.overflow-auto` wrapper, whose `scrollTop` resets
  when its children are replaced.
- **`CSS.escape` both halves.** `line_item[status]` is not a valid selector fragment.
- **`<select>` has no `selectionStart`.** Guard the caret restore or you will throw on
  every dropdown.
- **`requestAnimationFrame`, not `setTimeout(0)`.** You need the new row painted before you
  query for it; a microtask or a zero timeout can land too early.
- **This bug is invisible to your test suite.** Assertions on the database all pass. Add a
  system test that asserts on `page.evaluate_script("document.activeElement.name")` and its
  row, or you will ship the regression again.

---

## Files this pattern touches

```
app/views/<plural>/index.html.erb                   # the persistent <tbody id="..."> anchor
app/views/<plural>/_<singular>.html.erb             # stable row id + Stimulus wiring
app/controllers/<plural>_controller.rb              # turbo_stream.replace (or targeted update)
app/javascript/controllers/dirty_form_controller.js # snapshot + document listener + restore
test/system/<plural>_inline_edit_test.rb            # asserts WHERE focus landed
```

## How to adapt to your schema

1. **Try to delete the problem first.** If the save only changes derived display values,
   swap `turbo_stream.replace` for one `turbo_stream.update` per display cell and stop
   here. No JavaScript needed.
2. Give the row a stable id (`dom_id(record)`) and make sure the replacement partial
   renders the **same** id.
3. Pick the persistence anchor: the nearest ancestor no stream ever targets. `<tbody>` for
   a table; for a card list or a nested breakdown it may be a wrapper `div` — the rule is
   only that it must survive the replacement.
4. Point `this.container` at that anchor in `connect()`, and cache the scroll wrapper too.
5. Keep both guards. The move-away check in `handleSubmitStart` and the moved-on check in
   the RAF are not redundant: the first covers "moved before the request started", the
   second covers "moved while it was in flight".
6. If your app has more than one save path (Turbo submit, silent fetch, keyboard shortcut),
   every one of them must clear the focus keys. Centralize it in `clearFocusData()` and
   call it from each.
7. The controller is generic — the only app-specific line is the `closest()` selector for
   the persistence anchor.
