Experiment: stream-level detection for element protection#5
Conversation
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.
There was a problem hiding this comment.
💡 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".
| if (!link?.hasAttribute("data-turbo-stream")) { | ||
| userNavigationInProgress = true | ||
| } |
There was a problem hiding this comment.
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 👍 / 👎.
Remove dispatchTurboBeforeStreamRender and simulateSamePageRefreshMorph — leftover from the stream-level detection experiment branch (#5), not used by any tests on main.
Post-merge notes (April 2026)This PR was not merged, but several things discovered during this experiment were ported to main:
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. |
Summary
This branch replaces the per-element initiator tracking mechanism for
data-turbo-refresh-stream-permanentwith 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, andturbo:visitto 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-permanentelements 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
turbo:before-stream-renderforaction="refresh"to set astreamRefreshPendingflaguserNavigationInProgressto ignore the user's own broadcast (which arrives viaturbo:before-stream-renderbefore Turbo's request-id deduping)userNavigationInProgressonturbo:render(page navigations),turbo:submit-end(Turbo Stream form responses), and skip setting it fordata-turbo-streamlink clicksturbo:before-render, snapshotmorphIsFromStream = streamRefreshPending && shouldAnimateAfterRenderturbo:before-morph-element, usemorphIsFromStreaminstead of the old initiator checkWhat 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:
userNavigationInProgressgate with Turbo Stream response handlingThe
userNavigationInProgressflag 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.erbtemplate (e.g.,show.turbo_stream.erbfor an inline edit flow),fetch()preserves theAccept: text/vnd.turbo-stream.htmlheader 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-renderfires before Turbo's dedupingThis was the root cause of the most subtle bug. When a user submits a form:
turbo:submit-startfiresturbo:before-stream-renderfiresmorphIsFromStream = true→ form is incorrectly protectedWe had to add
userNavigationInProgressto gate the flag, and then discovered that flag gets stuck after Turbo Stream responses (which don't triggerturbo:render). This required two additional fixes: clearing onturbo:submit-endfor Turbo Stream content types, and skipping the flag fordata-turbo-streamlink 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
preventDefaultbehavior. These tests should be ported to main regardless of which protection approach we use.README improvements
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 addeduserNavigationInProgresshandling)README.md— updated protection documentationtest/— new test infrastructure (helpers.js, protection.test.js)vitest.config.js,package.json,package-lock.json— test dependencies.gitignore— ignore node_modules and tmp/