Skip to content

Commit

Permalink
Merge pull request #44 from hotwired/jsbundling
Browse files Browse the repository at this point in the history
Support jsbundling
  • Loading branch information
jorgemanrubia authored Dec 25, 2024
2 parents f2033df + 23fedd7 commit ef803e8
Show file tree
Hide file tree
Showing 17 changed files with 226 additions and 163 deletions.
38 changes: 27 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,25 @@ That's it!

The system will listen for three kinds of changes and will take action depending on each:

* **HTML change:** it fetches the new document body and updates the current body with morphing. It uses [`idiomorph`](https://github.com/bigskysoftware/idiomorph) under the hood.
* HTML contents
* CSS
* Stimulus controllers

Depending on your setup, the default behavior will be different.

### Importmaps

Importmaps is the setup that allows for the smoother updates:

* **HTML change:** it fetches the new document body and updates the current body with morphing, then it reloads the Stimulus controllers in the page. It uses [`idiomorph`](https://github.com/bigskysoftware/idiomorph) under the hood.
* **CSS change:** it fetches and reloads the stylesheet that changed.
* **Stimulus controller change:** it fetches the Stimulus controller that changed and reloads all the controllers in the page.

> [!NOTE]
> Hotwire Spark currently does not support `jsbundling`, only import maps.
### JavaScript Bundling

* **HTML change:** it reloads the page with a Turbo visit.
* **CSS change:** it fetches and reloads the stylesheet that changed.
* **Stimulus controller change:** it reloads the page with a Turbo visit.

## Configuration

Expand All @@ -39,14 +52,17 @@ You can set configuration options on your `development.rb`. For example:
config.hotwire.spark.html_paths += %w[ lib ]
```

| Name | Description |
|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `html_paths` | Paths where file changes trigger a content refresh. By default: `app/controllers`, `app/helpers`, `app/models`, `app/views`. |
| `css_paths` | Paths where file changes trigger a CSS refresh. By default: `app/assets/stylesheets` or `app/assets/builds` if exists. |
| `stimulus_paths` | Paths where file changes trigger a Stimulus controller refresh. By default: `app/javascript/controllers`. |
| `enabled` | Enable or disable live reloading. By default, it's only enabled in `development`. |
| `logging` | Show logs in the browser console when reloading happens. It's false by default. |
| `html_reload_method` | How to perform reloads when HTML contents changes: `:morph` or `:replace`. By default, it is `:morph` and it will morph the `<body>` of the page. Set to `:replace` to reload the page with a regular Turbo navigation that will replace the `<body>`. |
| Name | Description |
|-----------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `html_paths` | Paths where file changes trigger a content refresh. By default: `app/controllers`, `app/helpers`, `app/models`, `app/views`. |
| `css_paths` | Paths where file changes trigger a CSS refresh. By default: `app/assets/stylesheets` or `app/assets/builds` if exists. |
| `stimulus_paths` | Paths where file changes trigger a Stimulus controller refresh. By default: `app/javascript/controllers`. |
| `enabled` | Enable or disable live reloading. By default, it's only enabled in `development`. |
| `logging` | Show logs in the browser console when reloading happens. It's false by default. |
| `html_reload_method` | How to perform reloads when HTML content changes: `:morph` or `:replace`. By default, it is `:morph` and it will morph the `<body>` of the page and reload all the stimulus controllers. Set to `:replace` to reload the page with a regular Turbo navigation that will replace the `<body>`. |
| `html_extensions` | The extension to monitor for HTML content changes. By default: `rb`, `erb`. | |
| `css_extensions` | The extension to monitor for CSS changes. By default: `css`. | |
| `stimulus_extensions` | The extension to monitor for CSS changes. By default: `js`. | |

## License

Expand Down
116 changes: 55 additions & 61 deletions app/assets/javascripts/hotwire_spark.js
Original file line number Diff line number Diff line change
Expand Up @@ -1396,71 +1396,64 @@ var HotwireSpark = (function () {
}

class StimulusReloader {
static async reload(filePattern) {
const document = await reloadHtmlDocument();
return new StimulusReloader(document, filePattern).reload();
static async reload(path) {
return new StimulusReloader(path).reload();
}
constructor(document) {
let filePattern = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : /./;
this.document = document;
this.filePattern = filePattern;
static async reloadAll() {
Stimulus.controllers.forEach(controller => {
Stimulus.unload(controller.identifier);
Stimulus.register(controller.identifier, controller.constructor);
});
return Promise.resolve();
}
constructor(changedPath) {
this.changedPath = changedPath;
this.application = window.Stimulus;
}
async reload() {
log("Reload Stimulus controllers...");
this.application.stop();
await this.#reloadChangedStimulusControllers();
this.#unloadDeletedStimulusControllers();
try {
await this.#reloadChangedController();
} catch (error) {
if (error instanceof SourceFileNotFound) {
this.#deregisterChangedController();
} else {
console.error("Error reloading controller", error);
}
}
this.application.start();
}
async #reloadChangedStimulusControllers() {
await Promise.all(this.#stimulusControllerPathsToReload.map(async moduleName => this.#reloadStimulusController(moduleName)));
}
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.filePattern.test(path);
async #reloadChangedController() {
const module = await this.#importControllerFromSource(this.changedPath);
await this.#registerController(this.#changedControllerIdentifier, module);
}
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.filePattern.test(`${controller.identifier}_controller`));
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 #didChangeTriggerAReload() {
return this.#stimulusControllerPathsToReload.length > 0;
}
#pathForModuleName(moduleName) {
return this.#stimulusPathsByModule[moduleName];
get #changedControllerIdentifier() {
this.changedControllerIdentifier = this.changedControllerIdentifier || this.#extractControllerName(this.changedPath);
return this.changedControllerIdentifier;
}
#extractControllerName(path) {
return path.replace(/^.*\//, "").replace("_controller", "").replace(/\//g, "--").replace(/_/g, "-");
return path.replace(/^.*\//, "").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 @@ -1469,14 +1462,15 @@ var HotwireSpark = (function () {
this.application.unload(name);
}
}
class SourceFileNotFound extends Error {}

class MorphHtmlReloader {
static async reload() {
return new MorphHtmlReloader().reload();
}
async reload() {
const reloadedDocument = await this.#reloadHtml();
await this.#reloadStimulus(reloadedDocument);
await this.#reloadHtml();
await this.#reloadStimulus();
}
async #reloadHtml() {
log("Reload html with morph...");
Expand All @@ -1487,8 +1481,8 @@ var HotwireSpark = (function () {
#updateBody(newBody) {
Idiomorph.morph(document.body, newBody);
}
async #reloadStimulus(reloadedDocument) {
return new StimulusReloader(reloadedDocument).reload();
async #reloadStimulus() {
await StimulusReloader.reloadAll();
}
}

Expand Down Expand Up @@ -1561,7 +1555,7 @@ var HotwireSpark = (function () {
await this.#visitCurrentPage();
}
#maintainScrollPosition() {
document.addEventListener("turbo:render", () => {
document.addEventListener("turbo:before-render", () => {
Turbo.navigator.currentVisit.scrolled = true;
}, {
once: true
Expand Down Expand Up @@ -1595,14 +1589,13 @@ var HotwireSpark = (function () {
action,
path
} = _ref;
const fileName = assetNameFromPath(path);
switch (action) {
case "reload_html":
return this.reloadHtml();
case "reload_css":
return this.reloadCss(fileName);
return this.reloadCss(path);
case "reload_stimulus":
return this.reloadStimulus(fileName);
return this.reloadStimulus(path);
default:
throw new Error(`Unknown action: ${action}`);
}
Expand All @@ -1611,11 +1604,12 @@ var HotwireSpark = (function () {
const htmlReloader = HotwireSpark.config.htmlReloadMethod == "morph" ? MorphHtmlReloader : ReplaceHtmlReloader;
return htmlReloader.reload();
},
reloadCss(fileName) {
reloadCss(path) {
const fileName = assetNameFromPath(path);
return CssReloader.reload(new RegExp(fileName));
},
reloadStimulus(fileName) {
return StimulusReloader.reload(new RegExp(fileName));
reloadStimulus(path) {
return StimulusReloader.reload(path);
}
});

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: 14 additions & 0 deletions app/controllers/hotwire/spark/source_files_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class Hotwire::Spark::SourceFilesController < ActionController::Base
def show
if File.exist?(path_param)
render plain: File.read(path_param)
else
head :not_found
end
end

private
def path_param
Rails.root.join params[:path]
end
end
13 changes: 6 additions & 7 deletions app/javascript/hotwire/spark/channels/monitoring_channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,13 @@ consumer.subscriptions.create({ channel: "Hotwire::Spark::Channel" }, {
},

dispatch({ action, path }) {
const fileName = assetNameFromPath(path)

switch(action) {
case "reload_html":
return this.reloadHtml()
case "reload_css":
return this.reloadCss(fileName)
return this.reloadCss(path)
case "reload_stimulus":
return this.reloadStimulus(fileName)
return this.reloadStimulus(path)
default:
throw new Error(`Unknown action: ${action}`)
}
Expand All @@ -38,12 +36,13 @@ consumer.subscriptions.create({ channel: "Hotwire::Spark::Channel" }, {
return htmlReloader.reload()
},

reloadCss(fileName) {
reloadCss(path) {
const fileName = assetNameFromPath(path)
return CssReloader.reload(new RegExp(fileName))
},

reloadStimulus(fileName) {
return StimulusReloader.reload(new RegExp(fileName))
reloadStimulus(path) {
return StimulusReloader.reload(path)
}
})

8 changes: 4 additions & 4 deletions app/javascript/hotwire/spark/reloaders/morph_html_reloader.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export class MorphHtmlReloader {
}

async reload() {
const reloadedDocument = await this.#reloadHtml()
await this.#reloadStimulus(reloadedDocument)
await this.#reloadHtml()
await this.#reloadStimulus()
}

async #reloadHtml() {
Expand All @@ -25,7 +25,7 @@ export class MorphHtmlReloader {
Idiomorph.morph(document.body, newBody)
}

async #reloadStimulus(reloadedDocument) {
return new StimulusReloader(reloadedDocument).reload()
async #reloadStimulus() {
await StimulusReloader.reloadAll()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export class ReplaceHtmlReloader {
}

#maintainScrollPosition() {
document.addEventListener("turbo:render", () => {
document.addEventListener("turbo:before-render", () => {
Turbo.navigator.currentVisit.scrolled = true
}, { once: true })
}
Expand Down
Loading

0 comments on commit ef803e8

Please sign in to comment.