turbo-hmr 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e6f12eb486a159355c025fa656c8c3e764379bb6e38c4af5cbc4e3cc3eeaa263
4
+ data.tar.gz: 6965f4bcb66437567a8a8cbd237de05db207f0f4f5e63fba2db330e28bb4a282
5
+ SHA512:
6
+ metadata.gz: 80061ae7f2144ad3e1c54c016c7567d9c8b6b998ff319c0d8d14f4271690bf0bc03c697dcebb59636f6d50d89fc2bd11b8504d44e5fa87924b83076a078fdff3
7
+ data.tar.gz: 023375f245a878be95e8a633db97eb711392825ae9eea5554970e65ab9de5ba2d5a0222147b23bc34aa55929c3ba84732299dd62d45d87307885cee153a1268a
data/.ruby-gemset ADDED
@@ -0,0 +1 @@
1
+ turbo-hmr
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ ruby-3.4.7
data/CLAUDE.md ADDED
@@ -0,0 +1,66 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ turbo-hmr is a Ruby gem that provides Hot Module Replacement (HMR) for Turbo with Importmap. It detects when pinned Stimulus controller modules change across Turbo-driven navigations and attempts to hot-swap them without triggering a full page reload.
8
+
9
+ ## Architecture
10
+
11
+ The gem has two main components that work together:
12
+
13
+ **Ruby/Rails side:**
14
+ - `lib/turbo/hmr/engine.rb` - Rails Engine that sets up asset paths and registers helpers
15
+ - `lib/turbo/hmr/importmap_helper.rb` - Overrides the default `javascript_inline_importmap_tag` to remove `data-turbo-track="reload"` attribute, which is essential for HMR to intercept importmap changes
16
+ - `lib/generators/turbo/hmr/install_generator.rb` - Generator that configures the host application
17
+
18
+ **JavaScript side:**
19
+ - `app/javascript/turbo-hmr.js` - Core HMR logic that:
20
+ - Listens to `turbo:before-fetch-response` to extract the incoming page's importmap
21
+ - Compares importmaps in `turbo:before-render` to detect changes
22
+ - Hot-swaps changed Stimulus controllers by calling `application.unload()` and `application.register()` with re-imported modules
23
+ - Falls back to full reload via `Turbo.visit()` if non-controller imports change or hot-swap fails
24
+ - Only hot-swaps modules matching `controllers/*` pattern (configurable via `isControllerImport()`)
25
+
26
+ **Key flow:**
27
+ 1. Importmap helper removes `data-turbo-track` attribute to prevent automatic reloads
28
+ 2. JavaScript intercepts Turbo navigation events to compare old vs new importmaps
29
+ 3. If only controllers changed: hot-swap them without page reload
30
+ 4. If anything else changed: trigger full reload
31
+
32
+ ## Development Commands
33
+
34
+ **Run tests:**
35
+ ```bash
36
+ bundle exec rspec
37
+ ```
38
+
39
+ **Run specific test:**
40
+ ```bash
41
+ bundle exec rspec spec/system/hotswap_system_spec.rb
42
+ ```
43
+
44
+ **Run single focused test:**
45
+ ```bash
46
+ bundle exec rspec spec/system/hotswap_system_spec.rb:<line_number>
47
+ ```
48
+
49
+ ## Testing
50
+
51
+ The gem uses RSpec with system tests powered by Capybara and Cuprite (headless Chrome driver). The test suite includes a dummy Rails app in `spec/dummy/` that simulates a real Rails application with Turbo and Stimulus.
52
+
53
+ System tests verify HMR behavior by:
54
+ - Modifying controller files at runtime (`write_version_controller`)
55
+ - Navigating between pages with Turbo
56
+ - Verifying controllers hot-swap without full page reloads
57
+ - Checking page load counts using custom `have_been_loaded` matcher
58
+
59
+ The dummy app is essential for testing - it's not just scaffolding but a functioning Rails app that exercises the actual HMR flow.
60
+
61
+ ## Important Constraints
62
+
63
+ - Hot-swapping only works for Stimulus controllers (modules under `controllers/` in importmap)
64
+ - Controllers must be safe to re-import (no problematic side-effects during module evaluation)
65
+ - Requires importmap entries to include cache-busting digests (Rails default in development/test)
66
+ - The `data-turbo-track="reload"` attribute MUST be removed from importmap script tags
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Micah Geisel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # turbo-hmr
2
+
3
+ Hot Module Replacement for Turbo with Importmap.
4
+
5
+ This gem detects when pinned Stimulus controller modules change across a Turbo-driven navigation (e.g., `Turbo.visit`). If the new page’s importmap updates any Stimulus controllers, the gem attempts to swap in the updated controllers without a full page reload by:
6
+
7
+ - Disconnecting existing controller instances for the changed identifiers
8
+ - Re-importing the updated modules
9
+ - Re-registering/reconnecting controllers so instances start using the new code
10
+ - Preventing the full page reload
11
+
12
+ If any changed importmap item can’t be safely hot-swapped, we fall back upon the default Turbo.visit behavior to ensure correctness.
13
+
14
+ ## Why
15
+
16
+ More seamless deployments for long-lived single-page sessions.
17
+
18
+ Turbo’s morphing normally keeps you on the page without a full reload, but the browser doesn’t automatically re-import modules whose specifiers are unchanged while their URLs in the importmap have changed, and instead just does a full page load. This gem bridges that gap by watching for importmap diffs and applying a targeted hot swap.
19
+
20
+ ## How It Works
21
+
22
+ This gem disables Turbo's built-in `data-turbo-track="reload"` behavior for importmaps and replaces it with smarter logic:
23
+
24
+ 1. During Turbo navigations, it intercepts `turbo:before-fetch-response` to extract the incoming page's importmap
25
+ 2. In `turbo:before-render`, it compares importmaps and detects what changed
26
+ 3. If only Stimulus controllers changed, it hot-swaps them without a full page reload
27
+ 4. Controllers are hot-swapped by calling `Stimulus.unload()` and `Stimulus.register()` with the new module
28
+ 5. If any non-controller imports changed, it triggers a full reload via `Turbo.visit()`
29
+
30
+ **Caveat emptor: This process assumes that all stimulus controllers can be safely unloaded and reloaded without problematic side-effects. Users of this gem need to ensure that their controllers are safe to re-import!**
31
+
32
+ ## Installation
33
+
34
+ 1. Add to your Gemfile:
35
+
36
+ ```ruby
37
+ gem "turbo-hmr"
38
+ ```
39
+
40
+ 2. Run the installer:
41
+
42
+ ```bash
43
+ bin/rails generate turbo:hmr:install
44
+ ```
45
+
46
+ This will:
47
+ - Pin the `turbo-hmr` JavaScript module in `config/importmap.rb`
48
+ - Set it up in `app/javascript/application.js`
49
+
50
+ ## Limitations
51
+
52
+ - Only swaps Stimulus controllers; other module changes will trigger a full page reload.
53
+ - Assumes that controllers do not have problematic side-effects during module evaluation. The onus is upon the user of this gem to ensure their controllers are safe to re-import.
54
+ - Assumes importmap entries include cache-busting digests when files change (Rails does this by default in development/test).
55
+ - Requires disabling `data-turbo-track="reload"` on the importmap script tag (this gem monkeypatches importmaps-rails to do this).
56
+
57
+ ## Roadmap
58
+
59
+ - Configurable module matching (allow/deny lists)
60
+ - Opt-in HMR for non-controller modules
61
+
62
+ ## License
63
+
64
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,149 @@
1
+ class TurboHmr {
2
+ constructor() {
3
+ this.currentImportmap = null
4
+ this.application = null
5
+ this.pendingImportmap = null
6
+ this.changes = { controllers: [], others: [] }
7
+ }
8
+
9
+ start(application) {
10
+ this.application = application
11
+ this.currentImportmap = this.extractImportmap(document)
12
+
13
+ document.addEventListener("turbo:before-fetch-response", this.handleBeforeFetchResponse.bind(this))
14
+ document.addEventListener("turbo:before-render", this.handleBeforeRender.bind(this))
15
+ }
16
+
17
+ async handleBeforeFetchResponse(event) {
18
+ const response = event.detail.fetchResponse
19
+ const html = await response.responseHTML
20
+ const parser = new DOMParser()
21
+ const doc = parser.parseFromString(html, "text/html")
22
+ this.pendingImportmap = this.extractImportmap(doc)
23
+ }
24
+
25
+ handleBeforeRender(event) {
26
+ if (!this.pendingImportmap || !this.currentImportmap) return
27
+
28
+ this.detectChanges(this.currentImportmap, this.pendingImportmap)
29
+
30
+ // console.log("turbo-hmr: changes:", this.changes)
31
+
32
+ if (this.changes.others.length > 0) {
33
+ // console.log("turbo-hmr: Non-controller imports changed, triggering reload")
34
+ event.preventDefault()
35
+ location.reload()
36
+
37
+ } else if (this.changes.controllers.length > 0) {
38
+ // console.log("turbo-hmr: Hot-swapping controllers:", this.changes.controllers)
39
+
40
+ // Unload removed controllers before render
41
+ const removedControllers = this.changes.controllers.filter(c => c.newUrl === null)
42
+ for (const change of removedControllers) {
43
+ this.unloadController(change.identifier)
44
+ }
45
+
46
+ // Reload added/changed controllers after render
47
+ requestAnimationFrame(() => {
48
+ const addedOrChangedControllers = this.changes.controllers.filter(c => c.newUrl !== null)
49
+ Promise.all(addedOrChangedControllers.map(change =>
50
+ this.reloadController(change.identifier, change.newUrl)
51
+ ))
52
+ .then(() => {
53
+ // console.log("turbo-hmr: successfully hot-swapped controllers")
54
+ this.currentImportmap = this.pendingImportmap
55
+ })
56
+ .catch(error => {
57
+ console.error("turbo-hmr: Failed to hot-swap controllers", error)
58
+ location.reload()
59
+ })
60
+ })
61
+ } else {
62
+ this.currentImportmap = this.pendingImportmap
63
+ }
64
+ }
65
+
66
+ extractImportmap(doc) {
67
+ const importmapScript = doc.querySelector('script[type="importmap"]')
68
+ if (!importmapScript) return null
69
+
70
+ try {
71
+ return JSON.parse(importmapScript.textContent)
72
+ } catch (e) {
73
+ console.error("turbo-hmr: Failed to parse importmap", e)
74
+ return null
75
+ }
76
+ }
77
+
78
+ detectChanges(oldMap, newMap) {
79
+ this.changes = { controllers: [], others: [] }
80
+
81
+ for (const [identifier, url] of Object.entries(newMap.imports)) {
82
+ const oldUrl = oldMap.imports[identifier]
83
+ const isController = this.isControllerImport(identifier)
84
+
85
+ if (!oldUrl) {
86
+ // Added
87
+ const change = { identifier, oldUrl: null, newUrl: url }
88
+ isController ? this.changes.controllers.push(change) : this.changes.others.push(change)
89
+ } else if (oldUrl !== url) {
90
+ // Changed
91
+ const change = { identifier, oldUrl, newUrl: url }
92
+ isController ? this.changes.controllers.push(change) : this.changes.others.push(change)
93
+ }
94
+ }
95
+
96
+ // Removed
97
+ for (const [identifier, url] of Object.entries(oldMap.imports)) {
98
+ if (!newMap.imports[identifier]) {
99
+ const isController = this.isControllerImport(identifier)
100
+ const change = { identifier, oldUrl: url, newUrl: null }
101
+ isController ? this.changes.controllers.push(change) : this.changes.others.push(change)
102
+ }
103
+ }
104
+ }
105
+
106
+ isControllerImport(identifier) {
107
+ return identifier.startsWith("controllers/")
108
+ }
109
+
110
+ async hotSwapControllers() {
111
+ for (const change of this.changes.controllers) {
112
+ if (change.newUrl) {
113
+ await this.reloadController(change.identifier, change.newUrl)
114
+ } else {
115
+ await this.unloadController(change.identifier)
116
+ }
117
+ }
118
+ }
119
+
120
+ async reloadController(identifier, url) {
121
+ const controllerName = this.identifierToControllerName(identifier)
122
+ const module = await import(url)
123
+ this.application.unload(controllerName)
124
+ this.application.register(controllerName, module.default)
125
+ // console.log(`turbo-hmr: Reloaded controller "${controllerName}" from ${url}`)
126
+ }
127
+
128
+ async unloadController(identifier) {
129
+ const controllerName = this.identifierToControllerName(identifier)
130
+ this.application.unload(controllerName)
131
+ // console.log(`turbo-hmr: Unloaded controller "${controllerName}"`)
132
+ }
133
+
134
+ identifierToControllerName(identifier) {
135
+ // "controllers/version_controller" => "version"
136
+ // "controllers/admin/users_controller" => "admin--users"
137
+ return identifier
138
+ .replace("controllers/", "")
139
+ .replace("_controller", "")
140
+ .replace(/\//g, "--")
141
+ .replace(/_/g, "-")
142
+ }
143
+ }
144
+
145
+ const hotswap = new TurboHmr()
146
+ export function start(application) {
147
+ hotswap.start(application)
148
+ }
149
+ export default { start }
@@ -0,0 +1,10 @@
1
+ Description:
2
+ Installs turbo-hmr into your Rails application.
3
+
4
+ This generator will:
5
+ - Create an initializer to disable data-turbo-track on the importmap
6
+ - Pin the turbo_hmr module in config/importmap.rb
7
+ - Add the import to app/javascript/application.js
8
+
9
+ Example:
10
+ bin/rails generate turbo:hmr:install
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Turbo
4
+ module Hmr
5
+ module Generators
6
+ class InstallGenerator < ::Rails::Generators::Base
7
+ source_root File.expand_path("templates", __dir__)
8
+
9
+ desc "Installs turbo-hmr"
10
+
11
+ def update_importmap
12
+ append_to_file "config/importmap.rb", %(pin "turbo-hmr", to: "turbo-hmr.js"\n)
13
+ end
14
+
15
+ def update_application_js
16
+ application_js_path = "app/javascript/application.js"
17
+
18
+ # Read the existing content to determine where to add the code
19
+ if File.exist?(application_js_path)
20
+ content = File.read(application_js_path)
21
+
22
+ # Add the import at the top if not already present
23
+ unless content.include?('from "turbo-hmr"')
24
+ prepend_to_file application_js_path, "import { start as startTurboHmr } from \"turbo-hmr\"\n"
25
+ end
26
+
27
+ # Add the start call after Stimulus is initialized
28
+ unless content.include?("startTurboHmr")
29
+ # Try to add after Application.start() or window.Stimulus assignment
30
+ if content.match(/(?:const|let|var)\s+(\w+)\s*=.*Application\.start\(\)/)
31
+ app_var = Regexp.last_match(1)
32
+ inject_into_file application_js_path, "\nstartTurboHmr(#{app_var})\n",
33
+ after: /#{app_var}\s*=.*Application\.start\(\)/
34
+ elsif content.match(/window\.Stimulus\s*=.*Application\.start\(\)/)
35
+ inject_into_file application_js_path, "\nstartTurboHmr(window.Stimulus)\n",
36
+ after: /window\.Stimulus\s*=.*Application\.start\(\)/
37
+ else
38
+ append_to_file application_js_path, "\n// Start turbo-hmr (adjust the application variable name as needed)\n// startTurboHmr(application)\n"
39
+ end
40
+ end
41
+ else
42
+ create_file application_js_path, <<~JS
43
+ import { start as startTurboHmr } from "turbo-hmr"
44
+ import { Application } from "@hotwired/stimulus"
45
+
46
+ const application = Application.start()
47
+ startTurboHmr(application)
48
+ JS
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Turbo
4
+ module Hmr
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace Turbo::Hmr
7
+
8
+ initializer "turbo_hmr.assets" do |app|
9
+ app.config.assets.paths << root.join("app/javascript")
10
+ app.config.assets.precompile += %w[turbo-hmr.js]
11
+ end
12
+
13
+ initializer "turbo_hmr.importmap_helper" do
14
+ ActiveSupport.on_load(:action_controller) do
15
+ helper Turbo::Hmr::ImportmapHelper
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Turbo
4
+ module Hmr
5
+ module ImportmapHelper
6
+ # Override importmap helper to not add data-turbo-track to the importmap script
7
+ # This allows turbo-hmr to handle importmap changes without full page reloads
8
+ def javascript_inline_importmap_tag(importmap_json = Rails.application.importmap.to_json(resolver: self))
9
+ tag.script importmap_json.html_safe,
10
+ type: "importmap", nonce: request&.content_security_policy_nonce
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Turbo
4
+ module Hmr
5
+ VERSION = "0.0.1"
6
+ end
7
+ end
data/lib/turbo/hmr.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hmr/version"
4
+ require_relative "hmr/engine"
5
+ require_relative "hmr/importmap_helper"
6
+
7
+ module Turbo
8
+ module Hmr
9
+ class Error < StandardError; end
10
+ end
11
+ end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: turbo-hmr
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Micah Geisel
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '6.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: importmap-rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec-rails
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '6.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '6.0'
54
+ description: Enables HMR (Hot Module Replacement) for ES modules during Turbo navigations,
55
+ with special support for Stimulus controllers.
56
+ email:
57
+ - micah@botandrose.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - ".ruby-gemset"
63
+ - ".ruby-version"
64
+ - CLAUDE.md
65
+ - LICENSE.txt
66
+ - README.md
67
+ - Rakefile
68
+ - app/javascript/turbo-hmr.js
69
+ - lib/generators/turbo/hmr/USAGE
70
+ - lib/generators/turbo/hmr/install_generator.rb
71
+ - lib/turbo/hmr.rb
72
+ - lib/turbo/hmr/engine.rb
73
+ - lib/turbo/hmr/importmap_helper.rb
74
+ - lib/turbo/hmr/version.rb
75
+ homepage: https://github.com/botandrose/turbo-hmr
76
+ licenses:
77
+ - MIT
78
+ metadata:
79
+ homepage_uri: https://github.com/botandrose/turbo-hmr
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 3.2.0
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.6.9
95
+ specification_version: 4
96
+ summary: Hot Module Replacement for Turbo
97
+ test_files: []