Data-turbo-permanent is recreated on updating form with turbo-stream

Hi, I’ve been recently struggling with typical scenario. Imagine turbo-frame with form inside and the stimulus controller for the form which handles input events for inputs inside form. When user types inside any of inputs, the form is submitted and updated with small timeout.
The problem is the input which has input focus. As long as it is recreated (morphed I suppose), it looses caret position as well as changes which were made after submitting the form.
To solve all of that, data-turbo-permanent would be handy, but it just doesn’t work. The input is recreated along with form. Even for latest Rails 8, importmap and all other 8’s defaults.
Some context is given below.
Layout

doctype html
html
  head
    title = content_for(:title) || "MySite"
    meta[name='viewport' content='width=device-width,initial-scale=1']
    meta[name='apple-mobile-web-app-capable' content='yes']
    meta[name='mobile-web-app-capable' content='yes']
    = turbo_refreshes_with method: :morph, scroll: :preserve
    = csrf_meta_tags
    = csp_meta_tag

    = yield :head

    link[rel='icon' href='/icon.png' type='image/png']
    link[rel='icon' href='/icon.svg' type='image/svg+xml']
    link[rel='apple-touch-icon' href='/icon.png']

    = stylesheet_link_tag 'tailwind', 'inter-font', data: { turbo_track: 'reload' }
    = stylesheet_link_tag :application, data: { turbo_track: 'reload' }
    = javascript_importmap_tags

  body.min-h-screen.relative
    ...

form partial:

  = form_with id: dom_id(myobj.payment_details, :form_payment_details), model: myobj.payment_details,
    url: account_myobj_payment_detailses_path, method: :patch, class: :contents,
    data: { controller: "autosubmit-form", autosubmit_form_focus_value: focus } do |f|
    .w-full.bg-white.rounded-lg.p-4.flex.flex-col.gap-y-4
            .flex.flex-col
              = f.label :legal_name, "Legal name", class: "w-fit bg-white text-xs text-ab-darkgray ml-1 px-1 -mb-2 z-10 font-light"
              = f.text_field :legal_name, class: "border rounded p-1.5", data: { turbo_permanent: true }, placeholder: "Buzz Ltd"
    ...

I ran into a similar issue with a similar use case. Although in my case the form was inside a turbo frame, the cause is the same I think: data-turbo-permanent only works if it is present in both versions of the HTML, the one being displayed and the one replacing it. I’ve solved it for me with a hack: copy the data-turbo-permanent before the content is replaced, described in detail here: Form that changes on user input without losing focus

Do you have multiple inputs on the same page? I wonder if the id on the input isn’t unique. What does the auto submit stimulus controller do?

Til, thank you very much for the link. I’ve tried to use your stimulus controller for my form but data-turbo-permanent inputs are still being recreated. So I suppose something’s wrong with my form, inputs or with setup.

jch, I’ve checked, and it looks like ids are unique.
Autosubmit controller submits form on user input (with small timeout to gather multiple quick keystrokes), submitted form updates form’s turbo-frame with the same partial as show action.

Here’s the partial with form (actually its two of them) updating dynamic form:

/ user, brewery, percentage, [focus]
- focus ||= nil
- percentage = 60 if !defined?(percentage) || percentage.nil?

.flex-1.flex.flex-col.gap-y-6
  .w-full.h-32.bg-white.rounded-lg.p-4.grid.place-content-between.gap-3 class="grid-cols-[1fr_10fr_222px]"
    .flex.text-lg.font-semibold.items-center About the Brewery
    .min-w-full
    .text-sm.p-0.button-ab-gray.h-fit Restore Data

    .flex.gap-x-3.items-center
      .text-ab-yellow Filled
      .text-ab-yellow.bg-ab-lightyellow.text-sm.py-1.px-2.rounded
        = "#{percentage}%"
    .min-w-full.min-w-0.flex.items-center
      .flex-1.min-w-full.overflow-hidden.rounded-full.h-2.bg-ab-lightyellow.relative
        .absolute.top-0.left-0.bottom-0.rounded-full.h-2.bg-ab-yellow style="width: #{percentage}%"
    .text-sm.p-0.button-ab-yellow.h-fit Submit for Moderation

  = form_with id: dom_id(brewery, :form), model: brewery, url: account_brewery_profile_path, method: :patch, class: :contents,
    data: { controller: "autosubmit-form" } do |f|
    .w-full.bg-white.rounded-lg.p-4.flex.flex-col.gap-y-10
      .font-semibold Basic Information
      .grid.grid-cols-2.text-sm.gap-x-10
        .flex.flex-col.gap-y-4
          .flex.flex-col.gap-y-1
            = f.label :name, "Name"
            = f.text_field :name, class: "border rounded p-1.5",
              placeholder: "For example, Brewery No. 1"
          .flex.flex-col.gap-y-1
            = f.label :town, "City"
            .relative.w-full id="town_input_permanent" data-controller="autocomplete" data-turbo-permanent="true"
              = f.text_field :town, class: "w-full border rounded p-1.5 peer",
                placeholder: "Enter or select",
                data: { controller: "input-change-get",
                  input_change_get_url_value: account_brewery_town_suggestions_path,
                  autocomplete_target: :input }
              .absolute id=(dom_id(brewery, :town_suggestions))
          .flex.flex-col.gap-y-1
            = f.label :description, "Description"
            = f.text_area :description, rows: 7, class: "border rounded p-1.5",
              placeholder: "Tell us about your brewery. A good description will help promote it."

        .flex.flex-col.gap-y-4
          .flex.flex-col.gap-y-1
            = f.label :logo, "Logo"
            = f.label :logo
              - if brewery.logo.attached?
                = render partial: "account/editable_image_attachment",
                  locals: { attachment: brewery.logo, padding: true, form: f }
              - else
                .bg-ab-lightgray.rounded.flex.flex-col.justify-center.items-center.text-xs.cursor-pointer.p-10.gap-y-2 class="h-[120px]"
                  = image_tag "account/add-logo.svg", class: "w-6"
                  .text-xs.font-semibold.text-center Add Logo
            = f.file_field :logo, multiple: false, class: :hidden
          .flex-1.flex.flex-col.gap-y-1
            / goes ahead of everything to make label clicks work
            = f.file_field :photos, multiple: true, class: :hidden
            = f.label :photos, "Photos"
            - if brewery.photos.attached?
              .flex.flex-wrap.overflow-y-auto.gap-4.justify-start.items-start
                = render collection: brewery.photos, partial: "account/editable_image_attachment",
                  as: :attachment, locals: { multiple: :photos, padding: false, form: f }
                = f.label :photos, class: "border rounded bg-ab-lightgray flex flex-col justify-center items-center text-xs cursor-pointer p-10 gap-y-2 w-[100px] h-[100px]"
                  = image_tag "account/add-photo.svg", class: "w-6"
                  .text-xs.font-semibold.text-center Add Images
            - else
              = f.label :photos, class: "flex flex-wrap overflow-y-auto gap-4 justify-start items-start"
                .w-full.h-full.flex-1.bg-ab-lightgray.rounded.flex.flex-col.justify-center.items-center.text-xs.cursor-pointer.p-10.gap-y-2
                  = image_tag "account/add-photo.svg", class: "w-6"
                  .text-xs.font-semibold.text-center Add Images
    
  = form_with id: dom_id(brewery.payment_details, :form_payment_details), model: brewery.payment_details,
    url: account_brewery_payment_detailses_path, method: :patch, class: :contents,
    data: { controller: "autosubmit-form" } do |f|
    .w-full.bg-white.rounded-lg.p-4.flex.flex-col.gap-y-4
      .font-semibold Payment Details
      .grid.grid-cols-3.text-sm.gap-x-10
        .flex.flex-col.gap-y-4
          .flex.flex-col.gap-y-2
            .text-md.font-semibold Legal Information
            .relative.w-full.flex.flex-col id="inn_permanent" data-controller="autocomplete"
              = f.label :inn, "TIN", class: "w-fit bg-white text-xs text-ab-darkgray ml-1 px-1 -mb-2 z-10 font-light"
              = f.text_field :inn, class: "w-full border rounded p-1.5 peer",
                placeholder: "2934879129",
                data: { controller: "input-change-get",
                  input_change_get_url_value: account_brewery_inn_suggestions_path,
                  autocomplete_target: :input }
              .absolute id=dom_id(brewery.payment_details, :inn_suggestions)
            .flex.flex-col
              = f.label :legal_name, "Legal Name", class: "w-fit bg-white text-xs text-ab-darkgray ml-1 px-1 -mb-2 z-10 font-light"
              = f.text_field :legal_name, class: "border rounded p-1.5",
                placeholder: "OOO BREWERY NUMBER ONE"
            .flex.flex-col
              = f.label :kpp, "KPP", class: "w-fit bg-white text-xs text-ab-darkgray ml-1 px-1 -mb-2 z-10 font-light"
              = f.text_field :kpp, class: "border rounded p-1.5",
                placeholder: "0293901212"
            .flex.flex-col
              = f.label :ogrn, "OGRN", class: "w-fit bg-white text-xs text-ab-darkgray ml-1 px-1 -mb-2 z-10 font-light"
              = f.text_field :ogrn, class: "border rounded p-1.5",
                placeholder: "10965738293901"
            .flex.flex-col
              = f.label :legal_address, "Legal Address", class: "w-fit bg-white text-xs text-ab-darkgray ml-1 px-1 -mb-2 z-10 font-light"
              = f.text_field :legal_address, class: "border rounded p-1.5",
                placeholder: "191002, St. Petersburg, Marata St., 35"
            .flex.flex-col
              = f.label :address, "Actual Address", class: "w-fit bg-white text-xs text-ab-darkgray ml-1 px-1 -mb-2 z-10 font-light"
              = f.text_field :address, class: "border rounded p-1.5",
                placeholder: "191002, St. Petersburg, Marata St., 35"
          .flex.flex-col.gap-y-2
            .text-md.font-semibold Additional Information
            .flex.flex-col
              = f.label :okved, "OKVED", class: "w-fit bg-white text-xs text-ab-darkgray ml-1 px-1 -mb-2 z-10 font-light"
              = f.text_field :okved, class: "border rounded p-1.5 peer",
                placeholder: "2934879129"
            .flex.flex-col
              = f.label :egais_id, "EGAIS ID", class: "w-fit bg-white text-xs text-ab-darkgray ml-1 px-1 -mb-2 z-10 font-light"
              = f.text_field :egais_id, class: "border rounded p-1.5",
                placeholder: "34901234098"

        .flex.flex-col.gap-y-4
          .flex.flex-col.gap-y-2
            .text-md.font-semibold Bank Account
            .flex.flex-col
              = f.label :current_account, "Current Account, No.", class: "w-fit bg-white text-xs text-ab-darkgray ml-1 px-1 -mb-2 z-10 font-light"
              = f.text_field :current_account, class: "border rounded p-1.5 peer",
                placeholder: "1234214214"
            .relative.w-full.flex.flex-col id="bik_permanent" data-controller="autocomplete" data-turbo-permanent="true"
              = f.label :bik, "BIC", class: "w-fit bg-white text-xs text-ab-darkgray ml-1 px-1 -mb-2 z-10 font-light"
              = f.text_field :bik, class: "border rounded p-1.5 peer",
                placeholder: "1234214214",
                data: { controller: "input-change-get",
                  input_change_get_url_value: account_brewery_bik_suggestions_path,
                  autocomplete_target: :input }
              .absolute id=dom_id(brewery.payment_details, :bik_suggestions)
            .flex.flex-col
              = f.label :bank_name, "Bank Name", class: "w-fit bg-white text-xs text-ab-darkgray ml-1 px-1 -mb-2 z-10 font-light"
              = f.text_field :bank_name, class: "border rounded p-1.5 peer",
                placeholder: "OOO SBER"
            .flex.flex-col
              = f.label :correspondent_account, "Correspondent Account", class: "w-fit bg-white text-xs text-ab-darkgray ml-1 px-1 -mb-2 z-10 font-light"
              = f.text_field :correspondent_account, class: "border rounded p-1.5 peer",
                placeholder: "1234214214"

          .flex.flex-col.gap-y-2
            .text-md.font-semibold Declaration
            = f.label :declaration
              - if brewery.payment_details.declaration.attached?
                = render partial: "account/editable_image_attachment",
                  locals: { attachment: brewery.payment_details.declaration, padding: true, form: f }
              - else
                .flex-1.h-full.bg-ab-lightgray.rounded.flex.flex-col.justify-center.items-center.text-xs.cursor-pointer.p-10.gap-y-2
                  = image_tag "account/add-logo.svg", class: "w-6"
                  .text-xs.font-semibold.text-center Add Photo/Scan of Declaration
            = f.file_field :declaration, multiple: false, class: :hidden

        .flex.flex-col.gap-y-4
          .flex.flex-col.gap-y-2
            .text-md.font-semibold Manager
            .flex.flex-col
              = f.label :manager_full_name, "Full Name", class: "w-fit bg-white text-xs text-ab-darkgray ml-1 px-1 -mb-2 z-10 font-light"
              = f.text_field :manager_full_name, class: "border rounded p-1.5 peer",
                placeholder: "Volkov Igor Vitalievich"
            .flex.flex-col
              = f.label :manager_position, "Position", class: "w-fit bg-white text-xs text-ab-darkgray ml-1 px-1 -mb-2 z-10 font-light"
              = f.text_field :manager_position, class: "border rounded p-1.5 peer",
                placeholder: "Director"

          .flex.flex-col.gap-y-2
            .text-md.font-semibold Contact Information
            .flex.flex-col
              = f.label :manager_email_address, "Email", class: "w-fit bg-white text-xs text-ab-darkgray ml-1 px-1 -mb-2 z-10 font-light"
              = f.text_field :manager_email_address, class: "border rounded p-1.5 peer",
                placeholder: "volkov@somemail.ru"
            .flex.flex-col
              = f.label :manager_phone_number, "Phone", class: "w-fit bg-white text-xs text-ab-darkgray ml-1 px-1 -mb-2 z-10 font-light"
              = f.text_field :manager_phone_number, class: "border rounded p-1.5 peer",
                placeholder: "+7 (___) ___ __ __"

          .flex.flex-col.gap-y-2
            .flex.flex-col
              .text-md.mr-1.font-semibold Document
              .text-xs.text-ab-darkgray on the basis of which the manager acts
            = f.label :manager_legal_document
              - if brewery.payment_details.manager_legal_document.attached?
                = render partial: "account/editable_image_attachment",
                  locals: { attachment: brewery.payment_details.manager_legal_document, padding: true, form: f }
              - else
                .flex-1.h-full.bg-ab-lightgray.rounded.flex.flex-col.justify-center.items-center.text-xs.cursor-pointer.p-10.gap-y-2
                  = image_tag "account/add-logo.svg", class: "w-6"
                  .text-xs.font-semibold.text-center Add Photo/Scan of Document
            = f.file_field :manager_legal_document, multiple: false, class: :hidden

And here’s stimulus controller:

import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
    connect() {
    this.changed = false
    this.timer = null
    this.element.addEventListener('change', () => {
      if (this.changed) this.onChanged()
    })
    this.element.addEventListener('input', (event) => {
      this.changed = true
      clearTimeout(this.timer)
      this.timer = setTimeout(() => {
        if (this.changed) this.onChanged()
      }, 200)
    })
    this.element.addEventListener("focusin", this.#setPermanent)
    this.element.addEventListener("focusout", this.#unsetPermanent)
    this.element.addEventListener("turbo:before-frame-render", this.#copyPermanence)
  }

  disconnect() {
    if (this.changed)
      this.onChanged()
    this.element.removeEventListener("focusin", this.#setPermanent)
    this.element.removeEventListener("focusout", this.#unsetPermanent)
    this.element.removeEventListener("turbo:before-frame-render", this.#copyPermanence)
  }

  onChanged() {
    this.changed = false
    this.submitWithFocusParam()
  }

  submitWithFocusParam() {
    const form = this.element
    form.requestSubmit();
  }

  #setPermanent(e) {
    const el = e.target.closest(".input")
    if (el) el.setAttribute("data-turbo-permanent", true)
  }

  #unsetPermanent(e) {
    const el = e.target.closest(".input")
    if (el) el.removeAttribute("data-turbo-permanent")
  }

  #copyPermanence(e) {
    let needResubmit = false
    for(const currentPermanentElement of e.target.querySelectorAll("[id][data-turbo-permanent]")) {
      const newPermanentElement = e.detail.newFrame.querySelector(`#${currentPermanentElement.id}`)
      if (newPermanentElement) {
        newPermanentElement.dataset.turboPermanent = true
        needResubmit ||= newPermanentElement.value != currentPermanentElement.value
        newPermanentElement.value = currentPermanentElement.value
        newPermanentElement.selectionStart = currentPermanentElement.selectionStart
        console.log("set permanent, value:", newPermanentElement.value, "caret:", newPermanentElement.selectionStart)
      }
    }
    if (needResubmit) {
      this.changed = false
      this.submitWithFocusParam()
    }
  }
}

Can you check if the first step, setting data-turbo-permanent on focus, works as expected? E.g. look at an input element in the browser’s web inspector and then focus it, see if the attribute gets set.

I suspect it doesn’t, because the controller expects a wrapping element with the class input in e.target.closest(".input") and I don’t see that in the markup of the partial. Maybe try changing that part in the controller to just e.target, then just the input element will be targeted.

I’ve checked data-turbo-permanent is set on focus succesfully. But the problem is “turbo:before-frame-render” event is never called, but turbo seems to work fine and no console error messages are shown. I’m using default rails 8 setup and importmap. I’ve checked if morph event “turbo:before-morph-element” might be called, but it wasn’t either.

UPD. It looks like I’ve found cause of problem. I’ll update later