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
- Snapshot before the submit: write the focused field's
nameand its row id onto a node that will survive the replacement — the<tbody>, not the<tr>. - Submit. Turbo replaces the row. The old Stimulus controller instance dies; a new one connects on the new row.
- Catch the end of the submit on
document, filtered by form identity — not with adata-actionon the row, which is gone by then. - 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. - 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 adataseton an ancestor that no stream targets — usually the<tbody>. closest()returns null on a detached element. After the replacement,this.element.closest("tbody")isnulland every?.quietly skips your restore. Cache the ancestors inconnect().- Turbo redirects
turbo:submit-endto<html>when the form is detached. A listener on the row or on the form itself is bypassed. Listen ondocumentand filter withevent.detail.formSubmission.formElement === this.form. - Stimulus unbinds
data-actionlisteners mid-render. MutationObserver callbacks are microtasks and fire inside Turbo'sawaitgaps, sodisconnect()runs beforeturbo:submit-end. Do not put post-replacement work on adata-actionattached to the element being replaced. - Guard
disconnect()withisSubmitting. 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 leavesdocument.activeElement === document.body. Code that treats "not in this row" as "user moved on" will throw away its own snapshot. Test for!== document.bodyexplicitly. - 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-autowrapper, whosescrollTopresets when its children are replaced.CSS.escapeboth halves.line_item[status]is not a valid selector fragment.<select>has noselectionStart. Guard the caret restore or you will throw on every dropdown.requestAnimationFrame, notsetTimeout(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
- Try to delete the problem first. If the save only changes derived display values,
swap
turbo_stream.replacefor oneturbo_stream.updateper display cell and stop here. No JavaScript needed. - Give the row a stable id (
dom_id(record)) and make sure the replacement partial renders the same id. - 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 wrapperdiv— the rule is only that it must survive the replacement. - Point
this.containerat that anchor inconnect(), and cache the scroll wrapper too. - Keep both guards. The move-away check in
handleSubmitStartand 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". - 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. - The controller is generic — the only app-specific line is the
closest()selector for the persistence anchor.