Filtering a turbo-frame src and persist some query string parameters

I created some time ago a Stimulus controller that allows me to persist some query string parameters on a turbo-frame. This is particularly useful when I have some filtering in action that is persisted in the query string, and I want to follow links but keep this filtering in place. It can be of use to remove a parameter on next frame load.

Unfortunately, for this I tapped into the Turbo internals using a Proxy object to wrap a delegate. With Turbo 8 and private properties, this causes errors as the proxy object is used in place of the delegate and cannot access private properties.

Could there be an interest from the Turbo side to add an event when the src of a turbo-frame changes, and allow modifying it from the event handler?

This is the controller I used:

/**
 * data-controller="frame-persist-query"
 * =====================================
 *
 * Attaches to a turbo-frame element and allows navigation applying to this
 * frame to keep query string parameters. Used for example in the design
 * overview to keep the tracking parameter.
 *
 * For example, the frame is showing URL `/document?a=1&b=2` and this controller
 * is configured to persist parameter `a`. When clicking on a link that
 * navigates on this frame to `/document2` then the final URL that is going to
 * be shown in the frame will be `/document2?a=1` (to keep parameter `a`).
 *
 * - `data-frame-persist-query-params-value=`: the list of parameters (space
 *   separated) to persist on the URL
 *
 * - `data-frame-persist-query-url-value=`: the initial URL of the frame, if
 *   the `src` attribute is not present.
 *
 * - `data-frame-persist-query-add-params-value=`: List of parameters to add on
 *   the next frame load, separated by `&`.
 *
 * - `data-frame-persist-query-remove-params-value=`: List of parameters to drop
 *   on the next frame load, separated by `&`.
 *
 * - `data-frame-persist-query-replace-params-value=`: List of parameters to
 *   replace on the next frame load, separated by `&`.
 *
 * This works by defining a custom delegate in the turbo-frame element that
 * changes the URL if necessary and forwards to the original delegate.
 *
 */

const debug = window.lcas_debug?.includes('frame-persist') // ?debug[frame-persist]=1

export default class FramePersistQueryController extends ApplicationController {

  static values = {
    params: String,
    url: String,
    addParams: String,
    replaceParams: String,
    removeParams: String
  }

  connect(){
    super.connect()
    this.delegate = this.element.delegate
    this.src = this.element.src || this.urlValue
    let controller = this
    this.element.delegate = new Proxy(this.delegate, {
      get(target, prop, receiver) {
        if (prop == 'sourceURLChanged') {
          return controller.sourceURLChanged.bind(controller)
        }
        return Reflect.get(...arguments)
      }
    })

    // Save current changes in the src before page refresh
    this.addAction('beforeunload@window', 'sourceURLChanged')
  }

  disconnect(){
    this.element.delegate = this.delegate
    super.disconnect()
  }

  _extractSearch(url) {
    return url.replace(/^[^\?]*\?/, '') // eslint-disable-line no-useless-escape
  }

  _replaceSearch(url, newSearch) {
    const bare = url.replace(/\?.*$/, '')
    if (newSearch && newSearch[0] == '?') {
      return bare + newSearch
    } else if (newSearch) {
      return bare + '?' + newSearch
    } else {
      return bare
    }
  }

  sourceURLChanged(){
    if (debug) console.log('sourceURLChanged old: %s', this.src)
    if (debug) console.log('sourceURLChanged new: %s', this.element.src)

    let updated = false

    if (this.src) {
      const oldParams = new URLSearchParams(this._extractSearch(this.src))
      let newParams = new URLSearchParams(this._extractSearch(this.element.src || ''))

      for (let param of this.paramsValue.split(/\s+/)) {
        if (!oldParams.has(param) || newParams.has(param)) continue

        for (let val of oldParams.getAll(param)) {
          newParams.append(param, val)
          updated = true
        }
      }

      for (let entry of new URLSearchParams(this.addParamsValue)) {
        newParams.append(entry[0], entry[1])
        updated = true
      }

      const replacedParams = new URLSearchParams(this.replaceParamsValue)
      let candidateNewParams = new URLSearchParams()
      let removedParams = new URLSearchParams(this.removeParamsValue)
      let removed = false
      for (let newEntry of newParams.entries()) {
        let foundInRemoveList = false
        for (let removeEntry of removedParams.entries()) {
          if (newEntry[0] == removeEntry[0] && newEntry[1] == removeEntry[1]) {
            foundInRemoveList = true
            removed = true
            updated = true
            break
          }
        }
        if (foundInRemoveList) continue
        for (let replaceEntry of replacedParams.entries()) {
          if (newEntry[0] == replaceEntry[0]) {
            foundInRemoveList = true
            removed = true
            updated = true
            break
          }
        }
        if (!foundInRemoveList) {
          candidateNewParams.append(newEntry[0], newEntry[1])
        }
      }
      if (removed) {
        newParams = candidateNewParams
      }

      for (let entry of replacedParams) {
        newParams.append(entry[0], entry[1])
        updated = true
      }

      if (updated) {
        this.src = this._replaceSearch(this.element.src, newParams.toString())
        if (this.src != this.element.src) {
          console.log('[frame-persist-query] #%s change url %s -> %s', this.element.id, this.element.src, this.src)
          this.element.src = this.src
          if (debug) console.log('sourceURLChanged changed: %s', this.src)
        }
        return this.delegate.sourceURLChanged()
      }
    }

    this.src = this.element.src
    return this.delegate.sourceURLChanged()
  }

  _addInHTMLList(list, element){
    return (list || '').split(' ').filter(x => x != element).concat(element).join(' ').trim()
  }

  addAction(eventName, callback, elements){
    elements = elements || this.element
    if (! (elements instanceof Array) && ! (elements instanceof NodeList)) {
      elements = [elements]
    }
    for (let element of elements) {
      if (!element) continue
      if (!callback.match(/^(.*)#(.*)$/)) {
        callback = `${this.identifier}#${callback}`
      }
      element.dataset.action = this._addInHTMLList(element.dataset.action, `${eventName}->${callback}`)
    }
  }

}