Skip to content

Commit

Permalink
Restore mechanism based on parsing import maps
Browse files Browse the repository at this point in the history
This won't cause trouble with CSP policies.

Fixes #50

Closes #49

It incorporates a fix for the problem that Andrey Maslov detected in #49: it wills support namespaced stimulus controllers.

Co-authored-by: Andrey Maslov <[email protected]>
  • Loading branch information
jorgemanrubia and andryusha committed Dec 26, 2024
1 parent c82dfd1 commit 1434832
Show file tree
Hide file tree
Showing 7 changed files with 158 additions and 109 deletions.
88 changes: 52 additions & 36 deletions app/assets/javascripts/hotwire_spark.js
Original file line number Diff line number Diff line change
Expand Up @@ -1396,8 +1396,9 @@ var HotwireSpark = (function () {
}

class StimulusReloader {
static async reload(path) {
return new StimulusReloader(path).reload();
static async reload(changedFilePath) {
const document = await reloadHtmlDocument();
return new StimulusReloader(document, changedFilePath).reload();
}
static async reloadAll() {
Stimulus.controllers.forEach(controller => {
Expand All @@ -1406,54 +1407,70 @@ var HotwireSpark = (function () {
});
return Promise.resolve();
}
constructor(changedPath) {
this.changedPath = changedPath;
constructor(document, changedFilePath) {
this.document = document;
this.changedFilePath = changedFilePath;
this.application = window.Stimulus;
}
async reload() {
log("Reload Stimulus controllers...");
this.application.stop();
try {
await this.#reloadChangedController();
} catch (error) {
if (error instanceof SourceFileNotFound) {
this.#deregisterChangedController();
} else {
console.error("Error reloading controller", error);
}
}
await this.#reloadChangedStimulusControllers();
this.#unloadDeletedStimulusControllers();
this.application.start();
}
async #reloadChangedController() {
const module = await this.#importControllerFromSource(this.changedPath);
await this.#registerController(this.#changedControllerIdentifier, module);
async #reloadChangedStimulusControllers() {
await Promise.all(this.#stimulusControllerPathsToReload.map(async moduleName => this.#reloadStimulusController(moduleName)));
}
async #importControllerFromSource(path) {
const response = await fetch(`/spark/source_files/?path=${path}`);
if (response.status === 404) {
throw new SourceFileNotFound(`Source file not found: ${path}`);
}
const sourceCode = await response.text();
const blob = new Blob([sourceCode], {
type: "application/javascript"
});
const moduleUrl = URL.createObjectURL(blob);
const module = await import(moduleUrl);
URL.revokeObjectURL(moduleUrl);
return module;
get #stimulusControllerPathsToReload() {
this.controllerPathsToReload = this.controllerPathsToReload || this.#stimulusControllerPaths.filter(path => this.#shouldReloadController(path));
return this.controllerPathsToReload;
}
get #stimulusControllerPaths() {
return Object.keys(this.#stimulusPathsByModule).filter(path => path.endsWith("_controller"));
}
#shouldReloadController(path) {
return this.#extractControllerName(path) === this.#changedControllerIdentifier;
}
get #changedControllerIdentifier() {
this.changedControllerIdentifier = this.changedControllerIdentifier || this.#extractControllerName(this.changedPath);
this.changedControllerIdentifier = this.changedControllerIdentifier || this.#extractControllerName(this.changedFilePath);
return this.changedControllerIdentifier;
}
#extractControllerName(path) {
return path.replace(/^.*\//, "").replace("_controller", "").replace(/\//g, "--").replace(/_/g, "-").replace(/\.js$/, "");
get #stimulusPathsByModule() {
this.pathsByModule = this.pathsByModule || this.#parseImportmapJson();
return this.pathsByModule;
}
#deregisterChangedController() {
this.#deregisterController(this.#changedControllerIdentifier);
#parseImportmapJson() {
const importmapScript = this.document.querySelector("script[type=importmap]");
return JSON.parse(importmapScript.text).imports;
}
async #reloadStimulusController(moduleName) {
log(`\t${moduleName}`);
const controllerName = this.#extractControllerName(moduleName);
const path = cacheBustedUrl(this.#pathForModuleName(moduleName));
const module = await import(path);
this.#registerController(controllerName, module);
}
#unloadDeletedStimulusControllers() {
this.#controllersToUnload.forEach(controller => this.#deregisterController(controller.identifier));
}
get #controllersToUnload() {
if (this.#didChangeTriggerAReload) {
return [];
} else {
return this.application.controllers.filter(controller => this.#changedControllerIdentifier === controller.identifier);
}
}
get #didChangeTriggerAReload() {
return this.#stimulusControllerPathsToReload.length > 0;
}
#pathForModuleName(moduleName) {
return this.#stimulusPathsByModule[moduleName];
}
#extractControllerName(path) {
return path.replace(/^\/+/, "").replace(/^controllers\//, "").replace("_controller", "").replace(/\//g, "--").replace(/_/g, "-").replace(/\.js$/, "");
}
#registerController(name, module) {
log("\tReloading controller", name);
this.application.unload(name);
this.application.register(name, module.default);
}
Expand All @@ -1462,7 +1479,6 @@ var HotwireSpark = (function () {
this.application.unload(name);
}
}
class SourceFileNotFound extends Error {}

class MorphHtmlReloader {
static async reload() {
Expand Down
2 changes: 1 addition & 1 deletion app/assets/javascripts/hotwire_spark.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/assets/javascripts/hotwire_spark.min.js.map

Large diffs are not rendered by default.

14 changes: 0 additions & 14 deletions app/controllers/hotwire/spark/source_files_controller.rb

This file was deleted.

106 changes: 66 additions & 40 deletions app/javascript/hotwire/spark/reloaders/stimulus_reloader.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { log } from "../logger.js"
import { cacheBustedUrl, reloadHtmlDocument } from "../helpers.js"

export class StimulusReloader {
static async reload(path) {
return new StimulusReloader(path).reload()
static async reload(changedFilePath) {
const document = await reloadHtmlDocument()
return new StimulusReloader(document, changedFilePath).reload()
}

static async reloadAll() {
Expand All @@ -14,8 +16,9 @@ export class StimulusReloader {
return Promise.resolve()
}

constructor(changedPath) {
this.changedPath = changedPath
constructor(document, changedFilePath) {
this.document = document
this.changedFilePath = changedFilePath
this.application = window.Stimulus
}

Expand All @@ -24,63 +27,88 @@ export class StimulusReloader {

this.application.stop()

try {
await this.#reloadChangedController()
}
catch(error) {
if (error instanceof SourceFileNotFound) {
this.#deregisterChangedController()
} else {
console.error("Error reloading controller", error)
}
}
await this.#reloadChangedStimulusControllers()
this.#unloadDeletedStimulusControllers()

this.application.start()
}

async #reloadChangedController() {
const module = await this.#importControllerFromSource(this.changedPath)
await this.#registerController(this.#changedControllerIdentifier, module)
async #reloadChangedStimulusControllers() {
await Promise.all(
this.#stimulusControllerPathsToReload.map(async moduleName => this.#reloadStimulusController(moduleName))
)
}

async #importControllerFromSource(path) {
const response = await fetch(`/spark/source_files/?path=${path}`)

if (response.status === 404) {
throw new SourceFileNotFound(`Source file not found: ${path}`)
}

const sourceCode = await response.text()
get #stimulusControllerPathsToReload() {
this.controllerPathsToReload = this.controllerPathsToReload || this.#stimulusControllerPaths.filter(path => this.#shouldReloadController(path))
return this.controllerPathsToReload
}

const blob = new Blob([sourceCode], { type: "application/javascript" })
const moduleUrl = URL.createObjectURL(blob)
const module = await import(moduleUrl)
URL.revokeObjectURL(moduleUrl)
get #stimulusControllerPaths() {
return Object.keys(this.#stimulusPathsByModule).filter(path => path.endsWith("_controller"))
}

return module
#shouldReloadController(path) {
return this.#extractControllerName(path) === this.#changedControllerIdentifier
}

get #changedControllerIdentifier() {
this.changedControllerIdentifier = this.changedControllerIdentifier || this.#extractControllerName(this.changedPath)
this.changedControllerIdentifier = this.changedControllerIdentifier || this.#extractControllerName(this.changedFilePath)
return this.changedControllerIdentifier
}

get #stimulusPathsByModule() {
this.pathsByModule = this.pathsByModule || this.#parseImportmapJson()
return this.pathsByModule
}

#parseImportmapJson() {
const importmapScript = this.document.querySelector("script[type=importmap]")
return JSON.parse(importmapScript.text).imports
}

async #reloadStimulusController(moduleName) {
log(`\t${moduleName}`)

const controllerName = this.#extractControllerName(moduleName)
const path = cacheBustedUrl(this.#pathForModuleName(moduleName))

const module = await import(path)

this.#registerController(controllerName, module)
}

#unloadDeletedStimulusControllers() {
this.#controllersToUnload.forEach(controller => this.#deregisterController(controller.identifier))
}

get #controllersToUnload() {
if (this.#didChangeTriggerAReload) {
return []
} else {
return this.application.controllers.filter(controller => this.#changedControllerIdentifier === controller.identifier)
}
}

get #didChangeTriggerAReload() {
return this.#stimulusControllerPathsToReload.length > 0
}

#pathForModuleName(moduleName) {
return this.#stimulusPathsByModule[moduleName]
}

#extractControllerName(path) {
return path
.replace(/^.*\//, "")
.replace(/^\/+/, "")
.replace(/^controllers\//, "")
.replace("_controller", "")
.replace(/\//g, "--")
.replace(/_/g, "-")
.replace(/\.js$/, "")
}

#deregisterChangedController() {
this.#deregisterController(this.#changedControllerIdentifier)
}

#registerController(name, module) {
log("\tReloading controller", name)

this.application.unload(name)
this.application.register(name, module.default)
}
Expand All @@ -90,5 +118,3 @@ export class StimulusReloader {
this.application.unload(name)
}
}

class SourceFileNotFound extends Error { }
37 changes: 37 additions & 0 deletions lib/hotwire/spark/change.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
class Hotwire::Spark::Change
attr_reader :paths, :extensions, :changed_path, :action

def initialize(paths, extensions, changed_path, action)
@paths = paths
@extensions = extensions
@changed_path = changed_path
@action = action
end

def broadcast
broadcast_reload_action if should_broadcast?
end

private
def broadcast_reload_action
Hotwire::Spark.cable_server.broadcast "hotwire_spark", reload_message
end

def reload_message
{ action: action, path: canonical_changed_path }
end

def canonical_changed_path
canonical_changed_path = changed_path
paths.each { |path| canonical_changed_path = changed_path.to_s.gsub(/^#{path}/, "") }
canonical_changed_path
end

def should_broadcast?
changed_path.to_s =~ extension_regexp
end

def extension_regexp
/#{extensions.map { |ext| "\\." + ext }.join("|")}$/
end
end
18 changes: 1 addition & 17 deletions lib/hotwire/spark/installer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ def initialize(application)

def install
configure_cable_server
configure_routes
configure_middleware
monitor_paths
end
Expand All @@ -20,12 +19,6 @@ def configure_cable_server
end
end

def configure_routes
application.routes.prepend do
mount Hotwire::Spark::Engine => "/spark", as: "hotwire_spark"
end
end

def configure_middleware
middleware.use Hotwire::Spark::Middleware
end
Expand All @@ -45,20 +38,11 @@ def monitor(paths_name, action:, extensions:)
paths = Hotwire::Spark.public_send(paths_name)
if paths.present?
file_watcher.monitor paths do |file_path|
pattern = /#{extensions.map { |ext| "\\." + ext }.join("|")}$/
broadcast_reload_action(action, file_path) if file_path.to_s =~ pattern
Hotwire::Spark::Change.new(paths, extensions, file_path, action).broadcast
end
end
end

def broadcast_reload_action(action, file_path)
Hotwire::Spark.cable_server.broadcast "hotwire_spark", reload_message_for(action, file_path)
end

def reload_message_for(action, file_path)
{ action: action, path: file_path }
end

def file_watcher
@file_watches ||= Hotwire::Spark::FileWatcher.new
end
Expand Down

0 comments on commit 1434832

Please sign in to comment.