{"slug":"focus-restoration-after-row-replace","meta":{"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 \u0026 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."}]},"body":"# Focus Jumping in Inline-Editable Tables\n\n\u003e ⚠️ **Cookbook example — not live code.** Every code block below is an **example\n\u003e snippet**, **not part of the llamapress.ai codebase**, and **not running on this\n\u003e server**. This is a reference recipe for a **Leo instance (an AI coding agent) to\n\u003e implement in its own app** — read it to understand the pattern, then recreate it there.\n\nA user changes a dropdown in row 14 of an editable table. The row saves correctly. Then\nthe cursor lands on the **first dropdown of the first row**, forty rows away. They scroll\nback, find their place, change the next cell, and get thrown to the top again.\n\nNothing errors. The data is right. Only the caret is wrong — which is why this bug ships:\nit never shows up in a test that asserts on the database.\n\nThis guide is about that class of bug: **restoring focus when a Turbo Stream replaces the\nelement the user was focused on.**\n\n\u003e **When to use:** any table where changing a field triggers a save that re-renders the\n\u003e whole row (recalculated totals, derived columns, conditional cells).\n\u003e **When not to:** if you only need to update a couple of display values, don't replace the\n\u003e row at all — see the first section. That is the real fix most of the time.\n\n---\n\n## Step 0 — The cheapest fix is not to replace the row\n\nThe browser has one rule here: **when a focused element is removed from the DOM, focus\nfalls to `document.body`.** Nothing you do afterwards is free. So the first question is\nwhether the row has to be replaced at all.\n\n```ruby\n# app/controllers/line_items_controller.rb\n\n# ❌ Replaces the whole \u003ctr\u003e, including the \u003cselect\u003e the user is standing on.\nrender turbo_stream: turbo_stream.replace(\n  @line_item, partial: \"line_items/line_item\", locals: { line_item: @line_item }\n)\n\n# ✅ Updates only the derived cells. The focused element is never touched,\n#    so focus stays put with zero JavaScript.\nrender turbo_stream: [\n  turbo_stream.update(\"rate_display_#{@line_item.id}\",  @line_item.rate_display),\n  turbo_stream.update(\"total_display_#{@line_item.id}\", @line_item.total_display)\n]\n```\n\nTargeted updates are faster, produce less DOM churn, and cannot break focus. Use full-row\nreplacement only when the save genuinely changes the row's **structure** — a cell appears,\na column becomes read-only, a select's option list changes.\n\nEverything below is for when you actually need `turbo_stream.replace`.\n\n---\n\n## The 80/20 in one breath\n\n1. **Snapshot before the submit**: write the focused field's `name` and its row id onto a\n   node that will *survive* the replacement — the `\u003ctbody\u003e`, not the `\u003ctr\u003e`.\n2. **Submit.** Turbo replaces the row. The old Stimulus controller instance dies; a new one\n   connects on the new row.\n3. **Catch the end of the submit** on `document`, filtered by form identity — not with a\n   `data-action` on the row, which is gone by then.\n4. **Restore inside `requestAnimationFrame`**, with a selector scoped to the **row id**:\n   `#row_id [name=\"...\"]`. The row scope is the whole ballgame — see the next section.\n5. **Clear the snapshot** so a later save can't restore stale coordinates.\n\n---\n\n## The root cause of \"it jumps to the first row\"\n\nThis is the single highest-value paragraph in the guide.\n\nField names in a Rails table are **identical across every row**. Row 1 and row 40 both\ncontain `name=\"line_item[status]\"`. So a restore that searches by name alone:\n\n```javascript\n// ❌ Matches the FIRST element in the document with that name.\n//    Every save sends the user to row 1. This IS the \"focus jump\".\nconst target = document.querySelector(`[name=\"${name}\"]`)\n```\n\n...always finds row 1. The fix is to scope the query to the row, and to **refuse to restore\nat all** if you don't have a row id:\n\n```javascript\n// ✅ Row-scoped. Requires BOTH halves of the coordinate.\nif (!fieldName || !rowId) return          // no guess-restoring\nconst selector = `#${CSS.escape(rowId)} [name=\"${CSS.escape(fieldName)}\"]`\nconst target = document.querySelector(selector)\nif (target) target.focus()\n```\n\n`CSS.escape` is not optional. Rails field names contain square brackets\n(`line_item[status]`) and ids contain nothing you control; unescaped, the selector either\nthrows or silently matches the wrong thing.\n\n**A missing row id must mean \"do nothing\", never \"fall back to name only.\"** A fallback\nhere is precisely the bug: it restores focus successfully, to the wrong row, every time.\n\n---\n\n## Layer 1 — The view\n\nThree things matter in the markup: a **stable row id**, a **persistent container** above\nthe rows, and the Stimulus wiring.\n\n```erb\n\u003c%# app/views/line_items/index.html.erb %\u003e\n\u003cdiv class=\"overflow-auto max-h-[70vh]\"\u003e\n  \u003ctable class=\"min-w-full\"\u003e\n    \u003cthead\u003e...\u003c/thead\u003e\n\n    \u003c%# This \u003ctbody\u003e is the persistence anchor. It is NOT replaced by any stream,\n        so its dataset survives row replacement. Everything we need to remember\n        between \"before submit\" and \"after replace\" lives here. %\u003e\n    \u003ctbody id=\"line_items_body\"\u003e\n      \u003c%= render @line_items %\u003e\n    \u003c/tbody\u003e\n  \u003c/table\u003e\n\u003c/div\u003e\n```\n\n```erb\n\u003c%# app/views/line_items/_line_item.html.erb %\u003e\n\u003c%# dom_id gives a stable id that the replacement re-uses — this is the row half\n    of the focus coordinate. %\u003e\n\u003ctr id=\"\u003c%= dom_id(line_item) %\u003e\"\n    data-controller=\"dirty-form\"\n    data-action=\"change-\u003edirty-form#save\n                 turbo:submit-start-\u003edirty-form#handleSubmitStart\"\u003e\n  \u003c%= form_with model: line_item, class: \"contents\" do |f| %\u003e\n    \u003ctd\u003e\u003c%= f.select :status, LineItem::STATUSES, {}, class: \"select select-sm\" %\u003e\u003c/td\u003e\n    \u003ctd\u003e\u003c%= f.select :assignee_id, @assignee_options, {}, class: \"select select-sm\" %\u003e\u003c/td\u003e\n    \u003ctd\u003e\u003c%= f.text_field :quantity, class: \"input input-sm\" %\u003e\u003c/td\u003e\n  \u003c% end %\u003e\n  \u003ctd id=\"\u003c%= dom_id(line_item, :total) %\u003e\"\u003e\u003c%= line_item.total_display %\u003e\u003c/td\u003e\n\u003c/tr\u003e\n```\n\nNote what is **not** there: no `turbo:submit-end-\u003edirty-form#handleSubmitEnd`. That\nlistener has to live somewhere that outlives the row. Layer 3 explains why.\n\n---\n\n## Layer 2 — The controller\n\nNothing exotic. Respond with a stream that replaces the row, and keep the partial's root\nid stable so the new row has the same id as the old one.\n\n```ruby\n# app/controllers/line_items_controller.rb\ndef update\n  @line_item = current_account.line_items.find(params[:id])\n\n  if @line_item.update(line_item_params)\n    respond_to do |format|\n      format.turbo_stream do\n        render turbo_stream: turbo_stream.replace(\n          @line_item, partial: \"line_items/line_item\", locals: { line_item: @line_item }\n        )\n      end\n    end\n  else\n    render turbo_stream: turbo_stream.replace(\n      @line_item, partial: \"line_items/line_item\", locals: { line_item: @line_item }\n    ), status: :unprocessable_entity\n  end\nend\n```\n\nThe status code matters to the client: **do not restore focus when the save failed.** The\nStimulus side checks `event.detail.success` before touching focus, so a rejected save\nleaves the user's cursor exactly where they were arguing with the validation.\n\n---\n\n## Layer 3 — Making sure you are still listening\n\nHere is the part that costs people an afternoon.\n\n`turbo:submit-end` is the natural place to restore focus — it fires after Turbo has\nrendered the stream. The obvious wiring is a `data-action` on the row. It does not work,\nfor two independent reasons.\n\n**Reason one: Stimulus has already unbound the row.** Stimulus detects DOM changes with a\n`MutationObserver`, and observer callbacks run as **microtasks**. Turbo's stream rendering\nis `async`, so it yields at `await` boundaries — and the observer fires in those gaps. By\nthe time the render finishes, Stimulus has called `disconnect()` on the old controller and\nremoved every `data-action` listener from the old row.\n\n**Reason two: Turbo redirects the event when the form is detached.** This is the dispatch\nhelper, verbatim from `turbo.min.js` (turbo-rails 2.0.16):\n\n```javascript\n// node_modules/@hotwired/turbo — dispatch()\nfunction dispatch(eventName, { target, cancelable, detail } = {}) {\n  const event = new CustomEvent(eventName, { cancelable, bubbles: true, composed: true, detail })\n  target \u0026\u0026 target.isConnected\n    ? target.dispatchEvent(event)\n    : document.documentElement.dispatchEvent(event)   // ← the trapdoor\n  return event\n}\n```\n\nAnd the call site:\n\n```javascript\n// FormSubmission#requestFinished\ndispatch(\"turbo:submit-end\", {\n  target: this.formElement,\n  detail: { formSubmission: this, ...this.result }\n})\n```\n\nBy the time `requestFinished` runs, the stream has already been rendered and the form has\nbeen ripped out with its row. `formElement.isConnected === false`, so Turbo dispatches the\nevent on **`document.documentElement`** instead. It bubbles up to `document` and `window`\nand **never reaches the detached form** — so a listener you attached directly to the form\nis bypassed too.\n\nThe fix that survives both problems: **listen on `document`, and identify your submission\nby object identity.**\n\n```javascript\n// app/javascript/controllers/dirty_form_controller.js\nconnect() {\n  this.form = this.element.querySelector(\"form\")\n  if (!this.form) return\n  this.isSubmitting = false\n\n  // Cache the persistent ancestors NOW. After the row is replaced,\n  // this.element is detached and closest() returns null.\n  this.container  = this.element.closest(\"tbody\")\n  this.overflowEl = this.element.closest(\".overflow-auto\")\n\n  // Document-level, because the event may be dispatched on \u003chtml\u003e.\n  // Identity filter, because every row in the table hears it.\n  this._onSubmitEnd = (event) =\u003e {\n    if (event.detail?.formSubmission?.formElement === this.form) {\n      this.handleSubmitEnd(event)\n    }\n  }\n  document.addEventListener(\"turbo:submit-end\", this._onSubmitEnd)\n}\n\ndisconnect() {\n  // Keep the listener alive across the replacement that our OWN submit caused —\n  // otherwise we unsubscribe milliseconds before the event we are waiting for.\n  if (!this.isSubmitting) this.teardown()\n}\n\nteardown() {\n  if (this._onSubmitEnd) document.removeEventListener(\"turbo:submit-end\", this._onSubmitEnd)\n  this._onSubmitEnd = null\n}\n```\n\nWhy `===` is safe: `event.detail.formSubmission.formElement` is the exact same JavaScript\nobject that was submitted. Turbo holds it on the `FormSubmission` instance and spreads it\ninto the event detail. Attached or detached, object identity is stable — which is the one\nthing about the element that the DOM cannot take away from you.\n\nThe `isSubmitting` guard in `disconnect()` is what makes the listener outlive its own row.\nSet it in `handleSubmitStart`, clear it in `handleSubmitEnd`, and call `teardown()` there\nso the listener does not leak once it has done its job.\n\n---\n\n## Layer 4 — Snapshot and restore\n\nState lives on the **container**, never on the controller instance. The instance is\ndestroyed by the replacement; the `\u003ctbody\u003e` is not.\n\n```javascript\n// app/javascript/controllers/dirty_form_controller.js\n\n// --- Before the submit -------------------------------------------------------\nsave(event) {\n  if (this.isSubmitting) return\n\n  const activeEl = document.activeElement\n\n  // 1. Snapshot BEFORE blurring. The blur below moves focus to \u003cbody\u003e;\n  //    snapshot after it and there is nothing left to record.\n  if (activeEl \u0026\u0026 this.element.contains(activeEl) \u0026\u0026 this.container) {\n    this.storeFocusData(activeEl, this.element.id)\n  }\n\n  // 2. Blur deliberately, so the browser does not hunt for a new focus target\n  //    (and scroll the page) when Turbo removes the element.\n  if (activeEl \u0026\u0026 this.element.contains(activeEl)) activeEl.blur()\n\n  // 3. Save scroll for both scrollers — the window and the table wrapper.\n  if (this.container)  this.container.dataset.savedScrollY   = window.scrollY\n  if (this.overflowEl) this.overflowEl.dataset.savedScrollTop = this.overflowEl.scrollTop\n\n  this.form.requestSubmit()\n}\n\nstoreFocusData(activeEl, rowId) {\n  this.container.dataset.focusedField = activeEl.getAttribute(\"name\") || \"\"\n  this.container.dataset.focusedRowId = rowId || \"\"\n  // Text inputs have a caret; \u003cselect\u003e does not. Guard on undefined.\n  if (activeEl.selectionStart !== undefined \u0026\u0026 activeEl.selectionStart !== null) {\n    this.container.dataset.focusedSelectionStart = activeEl.selectionStart\n    this.container.dataset.focusedSelectionEnd   = activeEl.selectionEnd\n  }\n}\n\nclearFocusData() {\n  if (!this.container) return\n  delete this.container.dataset.focusedField\n  delete this.container.dataset.focusedRowId\n  delete this.container.dataset.focusedSelectionStart\n  delete this.container.dataset.focusedSelectionEnd\n}\n\nhandleSubmitStart() {\n  this.isSubmitting = true\n  const activeEl = document.activeElement\n  const inThisRow = activeEl \u0026\u0026 this.element.contains(activeEl)\n\n  // A focused element in a DIFFERENT row means the user genuinely moved on —\n  // drop the snapshot so we never yank them back.\n  // activeEl === document.body is OUR OWN blur from save(), not a move. Keep it.\n  const storedRowId = this.container?.dataset.focusedRowId\n  const userMovedAway = activeEl \u0026\u0026 activeEl !== document.body \u0026\u0026 !inThisRow \u0026\u0026\n                        storedRowId \u0026\u0026 storedRowId !== this.element.id\n\n  if (userMovedAway)   this.clearFocusData()\n  else if (inThisRow)  this.storeFocusData(activeEl, this.element.id)  // direct-submit paths\n}\n\n// --- After the row has been replaced -----------------------------------------\nhandleSubmitEnd(event) {\n  this.isSubmitting = false\n  this.teardown()\n\n  const c = this.container\n  const fieldName = c?.dataset.focusedField\n  const rowId     = c?.dataset.focusedRowId\n  const selStart  = c?.dataset.focusedSelectionStart\n  const selEnd    = c?.dataset.focusedSelectionEnd\n  const savedY    = c?.dataset.savedScrollY\n  const savedTop  = this.overflowEl?.dataset.savedScrollTop\n\n  this.clearFocusData()                                  // read once, then burn it\n  if (c) delete c.dataset.savedScrollY\n  if (this.overflowEl) delete this.overflowEl.dataset.savedScrollTop\n\n  requestAnimationFrame(() =\u003e {\n    // Restore scroll first — focus() may scroll, and we correct it afterwards.\n    if (savedTop !== undefined) this.overflowEl.scrollTop = parseFloat(savedTop)\n    if (savedY   !== undefined) window.scrollTo(0, parseFloat(savedY))\n\n    const failed = event?.detail?.success === false\n    if (fieldName \u0026\u0026 rowId \u0026\u0026 !failed) {\n      // Did the user move somewhere real while the save was in flight?\n      const active = document.activeElement\n      const row    = document.getElementById(rowId)\n      const movedOn = active \u0026\u0026 active !== document.body \u0026\u0026 (!row || !row.contains(active))\n\n      if (!movedOn) {\n        const target = document.querySelector(\n          `#${CSS.escape(rowId)} [name=\"${CSS.escape(fieldName)}\"]`\n        )\n        if (target \u0026\u0026 typeof target.focus === \"function\") {\n          target.focus()\n          if (selStart !== undefined \u0026\u0026 target.setSelectionRange) {\n            target.setSelectionRange(parseInt(selStart), parseInt(selEnd))\n          }\n        }\n      }\n    }\n\n    // focus() scrolls the element into view. Undo that.\n    if (savedTop !== undefined) this.overflowEl.scrollTop = parseFloat(savedTop)\n    if (savedY   !== undefined) window.scrollTo(0, parseFloat(savedY))\n  })\n}\n```\n\n---\n\n## How to debug this yourself\n\nLogic alone will not find the break, because there are four places the chain can snap and\nthey all look identical from the outside (the cursor is in the wrong place). Log at the\n**handoff points** — the boundary between stages — not at random lines:\n\n```\nsave() / storeFocusData()      → is the right field + rowId being written?\n        ↓\ndocument listener fires        → does it fire at all? does the identity filter match?\n        ↓\ndisconnect()                   → is the listener kept (isSubmitting) or dropped?\n        ↓\nhandleSubmitEnd → RAF          → what are fieldName/rowId? what does querySelector return?\n```\n\nThen read the output as a table. The **first missing or wrong log is the bug**:\n\n| What you see | What it means |\n|---|---|\n| `storeFocusData` never logs | The save took a different code path (a silent `fetch`, a direct submit) that skips the snapshot |\n| `submit-end fired, matches? false` | `this.form` is stale or null — usually the form was looked up before it existed |\n| `disconnect: removing listener` | `isSubmitting` was false — `handleSubmitStart` did not fire, so the guard never engaged |\n| RAF logs with an empty `rowId` | Something cleared the dataset between the snapshot and the read — look for a second save path |\n| Correct data, but `target` is the wrong element | The selector is not row-scoped. This is the classic first-row jump |\n| Everything correct, focus still wrong | Something after you is stealing focus — a modal, an autofocus attribute, or a second controller on the same row |\n\nDo this in one pass. Adding logs one at a time turns a 20-minute job into an afternoon,\nbecause each stage's failure is invisible until you can see the stage before it succeeded.\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **An unscoped selector is the bug.** `[name=\"...\"]` matches row 1. If the symptom is\n  \"focus jumps to the top of the table\", stop reading and go check the selector for a row\n  scope. Everything else in this guide is the plumbing that gets a row id to that line.\n- **Never fall back to a name-only lookup.** No row id means *do not restore*. A fallback\n  silently reintroduces the exact bug you are fixing.\n- **Controller instance state does not survive a row replace.** `this.savedFocus = ...` is\n  gone the moment Turbo swaps the `\u003ctr\u003e`. Persist to a `dataset` on an ancestor that no\n  stream targets — usually the `\u003ctbody\u003e`.\n- **`closest()` returns null on a detached element.** After the replacement,\n  `this.element.closest(\"tbody\")` is `null` and every `?.` quietly skips your restore.\n  Cache the ancestors in `connect()`.\n- **Turbo redirects `turbo:submit-end` to `\u003chtml\u003e` when the form is detached.** A listener\n  on the row *or on the form itself* is bypassed. Listen on `document` and filter with\n  `event.detail.formSubmission.formElement === this.form`.\n- **Stimulus unbinds `data-action` listeners mid-render.** MutationObserver callbacks are\n  microtasks and fire inside Turbo's `await` gaps, so `disconnect()` runs *before*\n  `turbo:submit-end`. Do not put post-replacement work on a `data-action` attached to the\n  element being replaced.\n- **Guard `disconnect()` with `isSubmitting`.** Otherwise the controller unsubscribes from\n  the event it exists to wait for, milliseconds before it arrives.\n- **Your own `blur()` is not the user moving away.** Blurring before submit is correct — it\n  stops the browser from scrolling while it hunts for a new focus target — but it leaves\n  `document.activeElement === document.body`. Code that treats \"not in this row\" as\n  \"user moved on\" will throw away its own snapshot. Test for `!== document.body`\n  explicitly.\n- **Snapshot before you blur, not after.** Order matters more than anything else in\n  `save()`.\n- **A second save path will use stale coordinates.** If some edits save via a silent\n  `fetch()` (bypassing Turbo's lifecycle), nothing clears the dataset — and the *next*\n  Turbo save restores focus from those leftovers, landing the user on a row they edited a\n  minute ago. Clear the focus keys in every save path, not just the Turbo one.\n- **Read the snapshot once, then delete it.** Treat it like a message queue, not a cache.\n- **Do not restore on a failed save.** Check `event.detail.success` — on a validation error\n  the user is probably already interacting with the error.\n- **Do not yank the user back.** Saves take 200–800ms; people keep typing. If focus is\n  already on a real element outside the saved row, skip the restore entirely.\n- **`focus()` scrolls.** Restore scroll, focus, then restore scroll again. And restore\n  *both* scrollers — the window and the `.overflow-auto` wrapper, whose `scrollTop` resets\n  when its children are replaced.\n- **`CSS.escape` both halves.** `line_item[status]` is not a valid selector fragment.\n- **`\u003cselect\u003e` has no `selectionStart`.** Guard the caret restore or you will throw on\n  every dropdown.\n- **`requestAnimationFrame`, not `setTimeout(0)`.** You need the new row painted before you\n  query for it; a microtask or a zero timeout can land too early.\n- **This bug is invisible to your test suite.** Assertions on the database all pass. Add a\n  system test that asserts on `page.evaluate_script(\"document.activeElement.name\")` and its\n  row, or you will ship the regression again.\n\n---\n\n## Files this pattern touches\n\n```\napp/views/\u003cplural\u003e/index.html.erb                   # the persistent \u003ctbody id=\"...\"\u003e anchor\napp/views/\u003cplural\u003e/_\u003csingular\u003e.html.erb             # stable row id + Stimulus wiring\napp/controllers/\u003cplural\u003e_controller.rb              # turbo_stream.replace (or targeted update)\napp/javascript/controllers/dirty_form_controller.js # snapshot + document listener + restore\ntest/system/\u003cplural\u003e_inline_edit_test.rb            # asserts WHERE focus landed\n```\n\n## How to adapt to your schema\n\n1. **Try to delete the problem first.** If the save only changes derived display values,\n   swap `turbo_stream.replace` for one `turbo_stream.update` per display cell and stop\n   here. No JavaScript needed.\n2. Give the row a stable id (`dom_id(record)`) and make sure the replacement partial\n   renders the **same** id.\n3. Pick the persistence anchor: the nearest ancestor no stream ever targets. `\u003ctbody\u003e` for\n   a table; for a card list or a nested breakdown it may be a wrapper `div` — the rule is\n   only that it must survive the replacement.\n4. Point `this.container` at that anchor in `connect()`, and cache the scroll wrapper too.\n5. Keep both guards. The move-away check in `handleSubmitStart` and the moved-on check in\n   the RAF are not redundant: the first covers \"moved before the request started\", the\n   second covers \"moved while it was in flight\".\n6. If your app has more than one save path (Turbo submit, silent fetch, keyboard shortcut),\n   every one of them must clear the focus keys. Centralize it in `clearFocusData()` and\n   call it from each.\n7. The controller is generic — the only app-specific line is the `closest()` selector for\n   the persistence anchor.\n"}