hotwired / stimulus

A modest JavaScript framework for the HTML you already have
https://stimulus.hotwired.dev/
MIT License
12.53k stars 418 forks source link

Stimulus controllers imported twice, Initialize and clickEvents firing twice #777

Open EdgeCaseLord opened 5 days ago

EdgeCaseLord commented 5 days ago

Similar problem to this issue, but my Stimulus controllers even fire Initialize twice with just one controller instance. I'm using ruby "3.2.2", "rails", "~> 7.1.3", ">= 7.1.3.4" with Importmaps, es-module-shims@1.10.0, TailwindCSS and ViewComponents. My Stimulus controllers get pinned in the importmap:

pin_all_from 'app/javascript/controllers', under: 'controllers', preload: true
pin_all_from 'app/frontend/components', under: 'components', to: 'components', preload: true

then in talwind.config.js:

module.exports = {
  content: [
  ...
    './app/javascript/controllers/*.js',
    './app/views/**/*.{erb,haml,html,slim}',
    './app/frontend/components/**/*',
  ...
  ],

(Please also take note of the fact that I had to put the components into a subdir like frontend for the Stimulus controllers to get loaded at all, but that's another issue)

Then my app/javascript/application.js looks like this:

  import "@hotwired/turbo-rails"
import "controllers"
// import "components"

import * as ActiveStorage from "@rails/activestorage"
ActiveStorage.start()

import "trix"
import "@rails/actiontext"

import 'flowbite';
...

app/javascript/controllers/index.js:

import { application } from "controllers/application"

// Eager load all controllers defined in the import map under controllers/**/*_controller
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
eagerLoadControllersFrom("components", application);

app/javascript/controllers/application.js:

import { Application } from "@hotwired/stimulus"

const application = Application.start()

// Configure Stimulus development experience
application.debug = true
window.Stimulus   = application

export { application }

(default, I haven't made any changes here)

And, finally, my audio_player_component.html.erb, which has only one instance that' included directly in the application layout:

<div id="playerBar" data-turbo-permanent >
    <audio id="audioPlayer" data-controller="audio-player-component" data-audio-player-component-target="audioPlayer"
    data-audio-player-component-station-value="<%= @station.to_json %>"
    data-audio-player-component-stream-url-value="<%= @stream_url %>"
    data-audio-player-component-volume-value="<%= @volume %>"
    data-audio-player-component-state-value="<%= @state %>"
    class="audioPlayer" crossorigin="anonymous" playsinline src="<%= @station.stream_url %>"  type="audio/mp3" >Your browser does not support the <code>audio</code> element.
    </audio>
    <div id="play">
        <%= render AudioPlayerPlayButtonComponent.new(station: @station, state: @state, stream_url: @station.stream_url ) %>
    </div>
    <div id="nowPlaying" class="row">
        <div class="col-2 d-flex align-items-center"> <!-- Added alignment for the album art -->
            <% if @station %>
                <div id="player-bar-art" style="width: 80px; height: 80px;">
                    <img id="album-art" src="" alt="Album Art">
                </div>
            <% end %>
        </div>
        <div id="marquee">
            <%
=begin%>
 <marquee direction="left" behavior="scroll" scrollamount="10">
<%
=end%>

                    <% if @station %>Station:&nbsp;<% end %><span id="player-bar-station"><strong>Bitte Station wählen!</strong></span>

                <% if @station %>
                    Artist:&nbsp;<span id="player-bar-artist"></span>
                    Title:&nbsp;<span id="player-bar-title"></span>
                    Album:&nbsp;<span id="player-bar-album"></span>
                <% end %>
            <%
=begin%>
 </marquee>
<%
=end%>
        </div>
    </div>

    <div id="playerVolume" data-controller="volume" class="d-flex justify-content-center">
        <div id="slider" data-volume-target="slider"></div>
    </div>
</div>

(take note of the data-turbo-permanent attribute) and its Stimulus controller

// Import the Controller class from Stimulus
import { Controller } from "@hotwired/stimulus";
console.log('audioPlayerComponentController imported');
// Define and export the controller class
export default class extends Controller {
  // Declare the targets this controller will interact with
  static targets = ["audioPlayer"];
  static values = {
    station: String,
    streamUrl: String,
    playing: Boolean,
    volume: Number,
    state: String
  }

  initialize() {
      if (!sessionStorage.getItem("audioPlayerInitialized")) { // Check if already initialized
      this.setupEventListeners();
      sessionStorage.setItem("station", this.stationValue);
      sessionStorage.setItem("streamUrl", this.streamUrlValue);
      sessionStorage.setItem("playing", this.playingValue);
      sessionStorage.setItem("volume", this.volumeValue);
      sessionStorage.setItem("state", this.stateValue);
      sessionStorage.setItem("audioPlayerInitialized", "true");

      // Set the flag to prevent re-initialization
      sessionStorage.setItem("audioPlayerInitialized", "true");
      console.log("AudioPlayerComponentController initialized", this.element);
    } else {
      console.log("AudioPlayerComponentController already initialized.");
    }
  }

  setupEventListeners() {
    this.audioPlayerTarget.addEventListener("audioSourceChange", this.handleAudioSourceChange.bind(this));
  }

  // Handle the custom "audioSourceChanged" event
  handleAudioSourceChange(streamUrl) {
    console.log("New audio source: ", streamUrl);

    let sourceElement = this.audioPlayerTarget.querySelector('source');
    console.log('sourceElement: ', sourceElement);

    // let newSrc = streamUrl + "?cache=" + new Date().getTime();
    // console.log('newSrc: ', newSrc);

    sourceElement.setAttribute("src", streamUrl);
    window.audioState.streamUrl = streamUrl;
    this.streamUrlValue = streamUrl;
    // this.audioPlayerTarget.querySelector('source').src = event.detail.streamUrl;
    // this.audioPlayerTarget.load();
    this.pause();
    this.play();
  }

  // Define the play action
  async play() {
    // Play the audio element
    console.log("AudioPlayerComponentController play");

    let station   = sessionStorage.getItem("station");
    let streamUrl = sessionStorage.getItem("streamUrl");
    let playing   = sessionStorage.getItem("playing");
    let volume    = sessionStorage.getItem("volume");
    let state     = sessionStorage.getItem("state");

    // Check if the sourceElement exists and get its src attribute
    // var currentSrc = this.audioPlayerTarget.getAttribute('src');
    var sourceForEvent = streamUrl;
    // Append a unique query parameter (e.g., the current timestamp) to bypass cache
    var newSrc = streamUrl + "?cache=" + new Date().getTime();
    console.log('newSrc: ', newSrc);

    // Clear the current source
    this.audioPlayerTarget.src = "";

    // Set the new source
    this.audioPlayerTarget.src = newSrc;

    // Load the new source
    this.audioPlayerTarget.load();

    // Use an arrow function to maintain the 'this' context
    this.audioPlayerTarget.onloadeddata = async () => {
      // Play the audio when it has finished loading
      try {
        await this.audioPlayerTarget.play();
        console.log('onloadeddata event fired');
        // Update the global audioState object
        sessionStorage.setItem("playing", true);
        this.stateValue = "playing";
        sessionStorage.setItem("state", "playing");
        this.toggleButtons();
      } catch (err) {
        // Autoplay was prevented. Handle this if needed.
        console.log(err);
      }
    }
  }

  toggleButtons() {
    let station   = sessionStorage.getItem("station");
    let streamUrl = sessionStorage.getItem("streamUrl");
    let playing   = sessionStorage.getItem("playing");
    let volume    = sessionStorage.getItem("volume");
    let state     = sessionStorage.getItem("state");
    // Dispatch a custom event to update other buttons
    this.dispatch("toggleButtons", { detail: { station: station, streamUrl: sourceForEvent, playing: playing, state: state } })
  }

  // Define the pause action
  pause() {
    // Pause the audio element
    console.log("AudioPlayerComponentController pause");
    this.audioPlayerTarget.pause();
    // Update the global audioState object
    sessionStorage.setItem("playing", false);
    sessionStorage.setItem("state", "stopped");
    // Dispatch a custom event to update other buttons
    // this.dispatch("audioStopped");
  }
}

(the latter two reside under app/frontend/components)

I expect that my component and its controller are a bit buggy, this is a WIP version. The point is that this component is only existent once in the DOM, in an audio player comopnent that's fixed to the bottom of the page and has to be persistent over page navigation. Just as all the other component controllers, it gets initialized twice:

audioPlayerComponentController imported [audio_player_component_controller-b42d2cf0495d4d49a65e9f7ff5abe5a5355b0238736b110cb31c3c43304027aa.js:3:9](http://127.0.0.1:3000/assets/components/audio_player_component_controller-b42d2cf0495d4d49a65e9f7ff5abe5a5355b0238736b110cb31c3c43304027aa.js)
AudioPlayerComponentController initialized 
<audio id="audioPlayer" class="audioPlayer" data-controller="audio-player-component" data-audio-player-component-target="audioPlayer" data-audio-player-component-station-value='{"id":1,"title":"MF Radi…24-06-21T09:26:17.412Z"}' data-audio-player-component-stream-url-value="https://myStreamURL" data-audio-player-component-volume-value="75" data-audio-player-component-state-value="stopped" crossorigin="anonymous" playsinline="" src="myStreamURL" type="audio/mp3">
[audio_player_component_controller-b42d2cf0495d4d49a65e9f7ff5abe5a5355b0238736b110cb31c3c43304027aa.js:29:15](http://127.0.0.1:3000/assets/components/audio_player_component_controller-b42d2cf0495d4d49a65e9f7ff5abe5a5355b0238736b110cb31c3c43304027aa.js)
audioPlayerComponentController imported [audio_player_component_controller-b42d2cf0495d4d49a65e9f7ff5abe5a5355b0238736b110cb31c3c43304027aa.js:3:9](http://127.0.0.1:3000/assets/components/audio_player_component_controller-b42d2cf0495d4d49a65e9f7ff5abe5a5355b0238736b110cb31c3c43304027aa.js)
AudioPlayerComponentController already initialized.

For comparison, this is another non-ViewComponent-controller's log output, which also gets initialised twice:

darkmode #initialize [stimulus.min-dd364f16ec9504dfb72672295637a1c8838773b01c0b441bd41008124c407894.js:4:39610](http://127.0.0.1:3000/assets/stimulus.min-dd364f16ec9504dfb72672295637a1c8838773b01c0b441bd41008124c407894.js)
darkmode_controller connected [darkmode_controller-dcb01cb7421370bf4cb778bb0001a99fb5ef72c0f8a101309492780f11667e29.js:9:13](http://127.0.0.1:3000/assets/controllers/darkmode_controller-dcb01cb7421370bf4cb778bb0001a99fb5ef72c0f8a101309492780f11667e29.js)
darkmode #connect [stimulus.min-dd364f16ec9504dfb72672295637a1c8838773b01c0b441bd41008124c407894.js:4:39610](http://127.0.0.1:3000/assets/stimulus.min-dd364f16ec9504dfb72672295637a1c8838773b01c0b441bd41008124c407894.js)
darkmode #initialize [stimulus.min-dd364f16ec9504dfb72672295637a1c8838773b01c0b441bd41008124c407894.js:4:39610](http://127.0.0.1:3000/assets/stimulus.min-dd364f16ec9504dfb72672295637a1c8838773b01c0b441bd41008124c407894.js)
darkmode_controller connected [darkmode_controller-dcb01cb7421370bf4cb778bb0001a99fb5ef72c0f8a101309492780f11667e29.js:9:13](http://127.0.0.1:3000/assets/controllers/darkmode_controller-dcb01cb7421370bf4cb778bb0001a99fb5ef72c0f8a101309492780f11667e29.js)
darkmode #connect

Resulting behaviour is that all eventListeners get attached twice, resulting in button clicks not being performed once as expected, but twice, resulting in faulty behaviour of especially toggle-buttons. The way I've set it up now, the eventListeners defined in the Initialize() function get called only once, but those that aren't defined here, like those for Stimulus' data-actions, still fire twice.

Expected behaviour: fire once

Steps to reproduce:

Based on my research, this is most likely due to Turbo caching issues. Since Stimulus and Turbo are promoted as working together perfectly but obviously they don't, I'd suggest adding caching excemptions for Stimulus controllers, or at least some sort of switch to easily turn this behaviour, which seems to be intended, off for certain controllers or functions.

Please let me know if I'm wrong or if such a solution does already exist. I'm pretty new to Hotwire and I didn't find anything about this issue in the docs.

EdgeCaseLord commented 5 days ago

Here's also my manifest.js file:

//= link_tree ../images
//= link_directory ../stylesheets .css
//= link_tree ../../javascript .js
//= link_tree ../../javascript/controllers .js
//= link_tree ../../frontend/components .js
//= link_tree ../../../vendor/javascript .js
//= link_tree ../builds
EdgeCaseLord commented 5 days ago

It looks like if (canRegisterController(name, application)) in stimulus-loading.js calls .then(module => registerController(name, module, application)) twice on all controllers, no idea why. I don't think that this is a Turbo issue. Possibly es-module-shims-related?

EdgeCaseLord commented 5 days ago

This is my call stack: grafik

EdgeCaseLord commented 5 days ago

I found out that the problem gets solved when commenting out the es-module-shims import. Closing this issue here. If you think that you can propose a workaround to this esm shims issue, feel free to re-open it.

EdgeCaseLord commented 5 days ago

Sorry, my mistake. I've removed the esm-shims but the controllers still get loaded and initialised twice. I also had the importmaps twice in the application layout, no idea why and how, but removing the second one also didn't change anything. And I deactivated rails_live_reload because I thouthg that might interfere, with no effect.