I'm working on a Rails application that utilizes Turbo Streams for a real-time voting feature within a game room. Each game room progresses through multiple voting rounds, with a "lock votes" feature to conclude each round based on majority vote. However, I'm encountering an issue where the application behaves unexpectedly starting from the second voting round, specifically with the round-ending functionality not triggering correctly.
Here is the frontend diagram in more detail of what I'm trying to achieve, for visual context
Problem Description
- The application works as expected for the first voting round: participants vote, and the room owner can lock votes to end the round, which then correctly initiates the next round without a page refresh for all participants.
- In the second voting round, when the "lock votes" button is clicked (especially after a veto majority vote), the expected Turbo Stream update to proceed to round three does not occur. Instead, clicking "lock votes" a second time causes a page refresh and advances to round three, but other participants' views are no longer updated in real-time.
- Subsequent rounds continue with this disjointed behavior, significantly impacting the real-time aspect of the application.
Context
Rails 7.1.1 with turbo streams enabled
Voting rounds and the "lock votes" functionality are integral, with each round being dynamically updated through Turbo Streams partials.
The "lock votes" functionality is intended to broadcast the end of a round and the start of a new round to all participants when veto is chosen.
I am also making updates from the model (e.g., broadcasting from model callbacks) which could potentially be influencing the behavior observed.
relevant schema info:
Rooms have multiple rounds and participants, with an owner (creator).
Rounds have multiple round choices (including vetoes) and track if voting is locked.
Votes link participants to round choices.
Participants can switch there vote as much as they want before round end (updating vote)
Relevant code
rooms_controller.rb
class RoomsController < ApplicationController
before_action :set_room, only: [:show, :join, :lock_votes]
before_action :set_room_participant_association, only: [:show, :join]
before_action :set_current_round, only: [:show, :lock_votes]
# Other actions...
def lock_votes
# Ensuring only the room owner can lock votes
return unless @room.owner == current_participant && @round.update(voting_locked: true)
results = tally_votes_and_determine_result(@round)
if results[:is_veto]
handle_veto_case(@round)
broadcast_new_round
elsif results[:is_tie]
# Handle tie case
else
# Handle normal voting result
end
end
private
def broadcast_new_round
Turbo::StreamsChannel.broadcast_replace_to(
"room_#{@room.id}",
target: "round_status",
partial: "rooms/active_round",
locals: { room: @room, round: @round, room_participant: @room_participant, participant: current_participant }
)
end
def broadcast_winning_choice
Turbo::StreamsChannel.broadcast_replace_to(
"room_#{@room.id}",
target: "round_status",
partial: "rooms/winner_display",
locals: { room: @room, round: @round, room_participant: @room_participant, participant: current_participant }
)
Turbo::StreamsChannel.broadcast_remove_to(
"room_#{@room.id}",
target: "end_round_button"
)
end
def initiate_new_round(room)
@round = room.rounds.create(round_number: room.rounds.count + 1)
RestaurantAssignmentService.new(@room).assign_restaurants_to_round(@round)
# Create a veto choice for the round
@round.round_choices.create(is_veto: true)
end
# More Helper methods, etc.
end
models/vote.rb
class Vote < ApplicationRecord
belongs_to :participant
belongs_to :round_choice
# Turbo stream hooks
after_create_commit { broadcast_vote_updates }
after_update_commit { broadcast_vote_updates }
def broadcast_vote_updates
round_choice.round.round_choices.each do |choice|
Turbo::StreamsChannel.broadcast_update_to(
"round_#{choice.round_id}",
target: "choice_#{choice.id}",
partial: "rooms/choice_card",
locals: { choice: choice, participant: participant }
)
end
end
end
rooms/show.html.erb
<%# --------------------- Winner display (turbo stream) > -------------------- %>
<%= turbo_stream_from "room_#{@room.id}" %> <!-- broadcasting from room_participant model-->
<% Rails.logger.debug "Owner: #{@room.owner}, Current Participant: #{current_participant}" %>
<div id="round_status">
<% if @room.final_winning_choice %>
<%= render partial: 'rooms/winner_display', locals: { room: @room } %>
<% elsif @round %>
<%= render partial: 'rooms/active_round', locals: { room: @room, round: @round, room_participant: @room_participant, participant: current_participant }%>
<% else %>
<p class="text-center text-gray-500">No active rounds at the moment.</p>
<% end %>
</div>
<!-- ROOM OWNER END ROUND BUTTON -->
<div id="end_round_button">
<% unless @room.final_winning_choice %>
<% if current_participant == @room.owner %>
<p class="text-center" >Once everyone has voted, lock in votes and end round:</p>
<%= button_to 'Lock Votes and End Round', lock_votes_room_path(@room), method: :post, class:"w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"%>
<% end %>
<% end %>
</div>
rooms/_active_round.html.erb
<!-- ...ROUND NUMBER & TIMER STUFF... -->
<!-- ROUND CHOICES -->
<%= turbo_stream_from "round_#{round.id}" %> <!-- broadcasting from vote_model-->
<% round.round_choices.each do |choice| %>
<%= turbo_frame_tag "choice_#{choice.id}" do %>
<%= render partial:'rooms/choice_card', locals: { choice: choice, participant: participant} %>
<% end %>
<% end %>
rooms/_choice_card.html.erb
<!-- assigning local variables derived from 'choice' -->
<!-- card body -->
<div>
<!-- ...round choice info... -->
<!-- Votes count -->
<div>
<p class="text-sm text-gray-500 mb-2">Votes: <%= votes %></p>
</div>
<!-- Vote Button -->
<div class="flex items-center ml-2">
<%= form_with(model: Vote.new, url: votes_path, method: :post) do |form| %>
<%= form.hidden_field :round_choice_id, value: choice.id %>
<%= form.submit "Vote (#{choice.votes.where(participant: participant).count})", class: "text-white bg-primary-600 hover:bg-primary-700 py-2 px-4 rounded focus:outline-none focus:shadow-outline sm:text-sm" %>
<%end%>
</div>
</div>
Attempts to Resolve:
- Tried reorganizing partials and ensuring correct IDs and targets for Turbo Streams.
- Experimented with different methods of broadcasting from the model and controller.
- I suspect there is something I am overlooking here with regards to turbo streams? This is all pretty new to me, and I can't get passed this...
Expected Outcome:
I anticipated seamless real-time transitions between voting rounds without the need for manual page refreshes, maintaining a dynamic and interactive experience for all participants.
Questions:
- How to ensure reliable Turbo Stream updates across multiple voting rounds?
- How would you structure the real-time turbo-stream partial updates for an application like this? Is there some best practices?
- Is there any known Turbo Streams issues or limitations in scenarios like this?
- bonus: What's the best approach to apply custom CSS dynamically for highlighting the user selections?
I suspect I might be missing some nuances of Turbo Streams that are causing these issues. Any insights or suggestions on how to resolve these problems would be greatly appreciated. Thank you!