Skip to content

TypeError: activeElement.setSelectionRange is not a function #1538

Description

@emaia

Summary

When a page refresh uses morph (<meta name="turbo-refresh-method" content="morph">) and the originally focused element is an <input> whose id no longer matches an input‑like element in the new DOM, Idiomorph's saveAndRestoreFocus resolves the saved id to a non‑input element via querySelector and then calls setSelectionRange on it, throwing:

Uncaught (in promise) TypeError: activeElement.setSelectionRange is not a function
    at saveAndRestoreFocus
    at Object.morph
    at morphElements
    at MorphingPageRenderer.renderElement
    at MorphingPageRenderer.assignNewBody
    at MorphingPageRenderer.preservingPermanentElements
    at MorphingPageRenderer.replaceBody
    at MorphingPageRenderer.render
    at PageView.renderSnapshot

The error is silent in terms of user‑visible behavior (the morph still completes), but it is reported as an unhandled Promise rejection and pollutes the console.

Environment

  • @hotwired/turbo 8.0.23
  • Refresh method: morph (set via <meta name="turbo-refresh-method" content="morph">)
  • Triggered by a Turbo Stream <turbo-stream action="refresh"> response

Steps to reproduce

Minimal scenario:

  1. A page contains a <turbo-frame id="row_1"> with a link that lazy‑loads an inline edit form into the frame.
  2. The loaded form contains an <input id="title" name="title"> (the id is auto‑generated from the name).
  3. Focus the input and press Enter to submit the form.
  4. The server responds with a Turbo Stream:
    <turbo-stream action="refresh" request-id="row_1"></turbo-stream>
  5. Turbo re‑visits the current URL using morph. The new page no longer contains an element with id="title".

Observed:

  • The page is refreshed correctly (data saved, list re‑rendered).
  • The browser console logs the TypeError above.

Note: clicking a submit <button> does NOT trigger the error, because clicking moves focus to the button before the morph runs, so the early instanceof HTMLInputElement check in saveAndRestoreFocus returns false and the function exits early.

Root cause

Source: node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js, function saveAndRestoreFocus (≈ lines 2215–2248 of the ESM build, which mirrors the Idiomorph source):

function saveAndRestoreFocus(ctx, fn) {
  if (!ctx.config.restoreFocus) return fn();
  let activeElement = /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ (
    document.activeElement
  );

  // don't bother if the active element is not an input or textarea
  if (
    !(
      activeElement instanceof HTMLInputElement ||
      activeElement instanceof HTMLTextAreaElement
    )
  ) {
    return fn();
  }

  const { id: activeElementId, selectionStart, selectionEnd } = activeElement;

  const results = fn();

  if (
    activeElementId &&
    activeElementId !== document.activeElement?.getAttribute("id")
  ) {
    activeElement = ctx.target.querySelector(`[id="${activeElementId}"]`); // ← (1)
    activeElement?.focus();
  }
  if (activeElement && !activeElement.selectionEnd && selectionEnd) {
    activeElement.setSelectionRange(selectionStart, selectionEnd);          // ← (2)
  }

  return results;
}

Two issues compound at (1) and (2):

  1. At (1), querySelector('[id="<id>"]') is typed/assumed to return an HTMLInputElement/HTMLTextAreaElement, but it actually returns Element | null. Any element in the post‑morph DOM that happens to share the id will match — including <div>, <span>, <button>, etc. The original instanceof guard is performed only on the pre‑morph activeElement and is not repeated after the re‑query.
  2. At (2), setSelectionRange is called without checking whether the (possibly re‑assigned) activeElement actually supports it. selectionEnd being undefined makes !activeElement.selectionEnd truthy, so the guard does not protect against non‑input elements; it only protects against the cursor already being at a non‑zero position.

Additionally, setSelectionRange is unsupported even on some real <input> types (e.g. type="number", type="email", type="date"), so the same crash can occur when the post‑morph element is a valid input of an unsupported type.

Suggested fix

bigskysoftware/idiomorph#150

Minimal reproduction

Layout:

<meta name="turbo-refresh-method" content="morph">

Index page:

<turbo-frame id="row_1">
  <a href="/items/1/edit">edit</a>
</turbo-frame>

Response from /items/1/edit (extracted by the frame):

<turbo-frame id="row_1">
  <form action="/items/1" method="post">
    <input id="title" name="title" value="Hello">
    <button>Save</button>
  </form>
</turbo-frame>

Response from form submit:

Content-Type: text/vnd.turbo-stream.html

<turbo-stream action="refresh" request-id="row_1"></turbo-stream>

Trigger: focus #title, press Enter. The console logs the TypeError.

Workaround

For affected applications, replace the page‑level refresh stream with a targeted replace/update of the frame so the morph path is avoided:

<turbo-stream action="replace" target="row_1">
  <template>
    <turbo-frame id="row_1"> ...new content... </turbo-frame>
  </template>
</turbo-stream>

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions