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:
- A page contains a
<turbo-frame id="row_1"> with a link that lazy‑loads an inline edit form into the frame.
- The loaded form contains an
<input id="title" name="title"> (the id is auto‑generated from the name).
- Focus the input and press Enter to submit the form.
- The server responds with a Turbo Stream:
<turbo-stream action="refresh" request-id="row_1"></turbo-stream>
- 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):
- 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.
- 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>
Summary
When a page refresh uses
morph(<meta name="turbo-refresh-method" content="morph">) and the originally focused element is an<input>whoseidno longer matches an input‑like element in the new DOM, Idiomorph'ssaveAndRestoreFocusresolves the saved id to a non‑input element viaquerySelectorand then callssetSelectionRangeon it, throwing: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/turbo8.0.23morph(set via<meta name="turbo-refresh-method" content="morph">)<turbo-stream action="refresh">responseSteps to reproduce
Minimal scenario:
<turbo-frame id="row_1">with a link that lazy‑loads an inline edit form into the frame.<input id="title" name="title">(theidis auto‑generated from thename).morph. The new page no longer contains an element withid="title".Observed:
TypeErrorabove.Note: clicking a submit
<button>does NOT trigger the error, because clicking moves focus to the button before the morph runs, so the earlyinstanceof HTMLInputElementcheck insaveAndRestoreFocusreturns false and the function exits early.Root cause
Source:
node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js, functionsaveAndRestoreFocus(≈ lines 2215–2248 of the ESM build, which mirrors the Idiomorph source):Two issues compound at (1) and (2):
querySelector('[id="<id>"]')is typed/assumed to return anHTMLInputElement/HTMLTextAreaElement, but it actually returnsElement | null. Any element in the post‑morph DOM that happens to share the id will match — including<div>,<span>,<button>, etc. The originalinstanceofguard is performed only on the pre‑morphactiveElementand is not repeated after the re‑query.setSelectionRangeis called without checking whether the (possibly re‑assigned)activeElementactually supports it.selectionEndbeingundefinedmakes!activeElement.selectionEndtruthy, so the guard does not protect against non‑input elements; it only protects against the cursor already being at a non‑zero position.Additionally,
setSelectionRangeis 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:
Index page:
Response from
/items/1/edit(extracted by the frame):Response from form submit:
Trigger: focus
#title, press Enter. The console logs theTypeError.Workaround
For affected applications, replace the page‑level
refreshstream with a targetedreplace/updateof the frame so the morph path is avoided: