Focus Jumping in Inline-Editable Tables | LlamaPress Cookbook
LlamaPress
Cookbook
Tables Stable

Focus Jumping in Inline-Editable Tables

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.

Proven on rsb.llamapress.ai view · controller · stimulus_js
Live demo — try it Open full screen

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.

# 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:

// ❌ 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:

// ✅ 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.

<%# 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>
<%# 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.

# 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):

// 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:

// 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.

// 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.

// 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.

Related