Skip to content

Experiment: stream-level detection for element protection#5

Open
raghubetina wants to merge 1 commit into
mainfrom
simplify-stream-permanent
Open

Experiment: stream-level detection for element protection#5
raghubetina wants to merge 1 commit into
mainfrom
simplify-stream-permanent

Conversation

@raghubetina
Copy link
Copy Markdown
Contributor

Summary

This branch replaces the per-element initiator tracking mechanism for data-turbo-refresh-stream-permanent with stream-level detection. It was an experiment to simplify the protection logic. After thorough testing, we concluded this approach is not a clear improvement over the original and should not be merged as-is. This PR is preserved for reference.

Motivation

The original approach felt complex: 6 state variables, URL correlation with a 2-second timeout, and event listeners across turbo:submit-start, turbo:submit-end, turbo:click, and turbo:visit to track which specific element initiated each navigation. The question was: can we replace all of that with a single check — "was this morph triggered by a stream refresh?"

What changed

Old model (main): Track which specific element initiated the navigation. During morph, protect all data-turbo-refresh-stream-permanent elements except the initiator.

New model (this branch): Detect whether the morph was stream-initiated via turbo:before-stream-render. Stream-initiated morphs → protect everything. User-initiated morphs → protect nothing.

Key implementation details

  • Listen to turbo:before-stream-render for action="refresh" to set a streamRefreshPending flag
  • Gate the flag with userNavigationInProgress to ignore the user's own broadcast (which arrives via turbo:before-stream-render before Turbo's request-id deduping)
  • Clear userNavigationInProgress on turbo:render (page navigations), turbo:submit-end (Turbo Stream form responses), and skip setting it for data-turbo-stream link clicks
  • In turbo:before-render, snapshot morphIsFromStream = streamRefreshPending && shouldAnimateAfterRender
  • In turbo:before-morph-element, use morphIsFromStream instead of the old initiator check

What we learned

The approach is not simpler in practice

The conceptual model is simpler ("stream = protect, user = don't"), but the implementation ended up comparably complex:

Old (main) New (this branch)
State variables 6 4
Event listeners for protection 6 7
Edge case handling 2-second URL correlation timeout userNavigationInProgress gate with Turbo Stream response handling

The userNavigationInProgress flag is essentially doing the same job as the old initiator tracking — preventing the user's own broadcast from triggering protection — just less granularly.

Lost per-element granularity

The old approach allowed per-element decisions: if you have two open edit forms (A and B) and submit form A, the old code protects B while allowing A to morph. The new approach protects nothing during user-initiated morphs, so B also gets morphed back to its server-rendered state.

Requires changes to how users structure their Rails controllers

We discovered that if the redirect target has a .turbo_stream.erb template (e.g., show.turbo_stream.erb for an inline edit flow), fetch() preserves the Accept: text/vnd.turbo-stream.html header across the 303 redirect. Rails renders the Turbo Stream template instead of the HTML page, no page morph ever happens, and the form never clears.

The old approach didn't have this problem because it tracked the initiating element during the morph — it didn't depend on the response being a full HTML page.

This is a usability regression: we'd be asking library users to restructure their controller actions to avoid Turbo Stream templates on redirect targets.

turbo:before-stream-render fires before Turbo's deduping

This was the root cause of the most subtle bug. When a user submits a form:

  1. turbo:submit-start fires
  2. Server saves, broadcasts a refresh stream
  3. The broadcast arrives via WebSocket → turbo:before-stream-render fires
  4. Turbo would normally dedupe this stream (matching request-id), but our flag is already set
  5. The redirect morph runs with morphIsFromStream = true → form is incorrectly protected

We had to add userNavigationInProgress to gate the flag, and then discovered that flag gets stuck after Turbo Stream responses (which don't trigger turbo:render). This required two additional fixes: clearing on turbo:submit-end for Turbo Stream content types, and skipping the flag for data-turbo-stream link clicks.

What's worth keeping

Test infrastructure

This branch adds vitest + jsdom tests for the protection behavior. The test helpers simulate Turbo event sequences (stream-render → visit → before-render → before-morph-element → render) and assert on preventDefault behavior. These tests should be ported to main regardless of which protection approach we use.

README improvements

  • Updated documentation for how protection works
  • Added "Important: Avoid Turbo Stream templates on redirect targets" caveat (relevant for both approaches, though more critical for this branch's approach)
  • The caveat about redirect targets rendering Turbo Stream templates is a real gotcha that should be documented regardless

View Transitions API research

Separately from this branch, we researched whether the View Transitions API could replace our CSS class-based animation approach. Conclusion: View Transitions cannot do clean exit animations (the "ghost element" problem — exiting elements render as screenshots in an overlay while siblings have already shifted). Documented in #3.

Files changed

  • turbo-refresh-animations.js — replaced protection mechanism (net -23 lines from protection code, but added userNavigationInProgress handling)
  • README.md — updated protection documentation
  • test/ — new test infrastructure (helpers.js, protection.test.js)
  • vitest.config.js, package.json, package-lock.json — test dependencies
  • .gitignore — ignore node_modules and tmp/

Replace the protection mechanism for data-turbo-refresh-stream-permanent
elements. The old approach tracked which specific element (form or link
wrapper) initiated each navigation. The new approach detects whether the
current morph was triggered by a stream refresh vs a user action.

- Add turbo:before-stream-render listener to flag stream-initiated morphs
- Add userNavigationInProgress gate to ignore own broadcasts
- Add turbo:submit-end handler to clear flag for Turbo Stream responses
- Skip userNavigationInProgress for data-turbo-stream link clicks
- Add vitest + jsdom test infrastructure with 16 protection tests
- Update README: document new detection model, add turbo_stream caveat
- Add .gitignore for node_modules and tmp/

Trade-off: loses per-element granularity (submitting form A no longer
keeps form B protected during the same morph). See PR for full analysis.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f9e3198c58

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +77 to +79
if (!link?.hasAttribute("data-turbo-stream")) {
userNavigationInProgress = true
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Set navigation flag only after a visit is confirmed

userNavigationInProgress is flipped to true for every turbo:click before we know whether Turbo will actually navigate. In this same handler, some clicks are explicitly canceled (for example the same-page anchor path that calls event.preventDefault()), which means no turbo:render/turbo:submit-end follows to reset the flag. After that, turbo:before-stream-render ignores genuine external refresh streams, so data-turbo-refresh-stream-permanent elements stop being protected until another render happens.

Useful? React with 👍 / 👎.

raghubetina added a commit that referenced this pull request Apr 2, 2026
Remove dispatchTurboBeforeStreamRender and
simulateSamePageRefreshMorph — leftover from the
stream-level detection experiment branch (#5),
not used by any tests on main.
@raghubetina
Copy link
Copy Markdown
Contributor Author

Post-merge notes (April 2026)

This PR was not merged, but several things discovered during this experiment were ported to main:

  1. Test infrastructure — vitest + jsdom tests were ported and expanded (26 tests on main)
  2. README: Turbo Stream template gotcha — documented that .turbo_stream.erb templates on redirect targets intercept form submissions, preventing morphs. This is a browser-level limitation (fetch() preserves the Accept header across redirects). See hotwired/turbo#1018.
  3. README: Duplicate ID scroll jump — documented that Rails form helpers generate duplicate IDs in loops, which causes Turbo's morph engine to lose scroll position. See hotwired/turbo#1226.
  4. Attribute renamedata-turbo-refresh-stream-permanent was renamed to data-turbo-refresh-preserve on main (version 0.1.0). This PR still references the old name.

The core conclusion stands: stream-level detection is conceptually simpler but not practically simpler. The per-element initiator tracking on main remains the better approach.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant