Advice Request: Stimulus + Webpack - Best practices bundle-splitting for multi-page, SSR site

Hi!

Love Stimulus and have been working on a high-traffic Stimulus-powered site for some time.

The site is multi-page SSR ( not a rails project, we use Stimulus with Shopify ).

Looking to optimize things a bit with a refactor and was curious about best practices here.

For example, we have some controllers that load on every page ( like header, cart, etc ) and others that only run on a few or single pages ( like lookbooks, contact pages, etc ).

To-date, we have been using the webpack loader helper and all our controllers get bundled up to one javascript file. This is fine, but that single file has grown with the site and our initial page loads are getting slower.

What we are trying to do, is have some global controllers and some entrypoint-specific controllers, but run into an issue across multiple ES6 imports when it comes time to .start();

We could import each controller needed on each page entry point, but that leaves so much room for error ( and lots of repetition with our global controllers ).

Would love to hear if there are some ways to say have a main.js that starts stimulus then register other controllers to it based on the entrypoint ( Dynamic import and register? ).

I went through through all the github issues and posts here, but nothing really fit the bill. Closest was:

and

We have about 100 or so controllers, so wanted to kinda talk it out a bit before going in too deep.

Thanks in advance for the help!

In simpler terms, Iā€™d like to do something like this:

// global_controller.js
import { Application } from "stimulus";
import { definitionsFromContext } from "stimulus/webpack-helpers";

const application = Application.start();
const context = require.context("../controllers/global", true, /\.js$/);
application.load(definitionsFromContext(context));

And then at a page / template level do something like this:

// template_controllers.js
**import 'global_controller'**
import HelloController from "./controllers/hello_controller"
import ClipboardController from "./controllers/clipboard_controller"

application.register("hello", HelloController)
application.register("clipboard", ClipboardController)

Looking for help on how to create a ā€˜globalā€™ set of controllers that can be added to each entrypoint.

Just curious what best practice bundle-splitting practices are.

Thank you in advance for your time and help :slight_smile:

If I understand correctly, I think I would generate different packs, a global pack and optionals packs (optional1, optional2, ā€¦)

My global controller entry point

// app/javascript/packs/global_controller.js
import { Application } from "stimulus";
import { definitionsFromContext } from "stimulus/webpack-helpers";

const application = Application.start();
const context = require.context("../controllers/global", true, /\.js$/);
application.load(definitionsFromContext(context));

My optional1 entry point

// app/javascript/packs/optional1_controller.js
import { Application } from "stimulus";
import { definitionsFromContext } from "stimulus/webpack-helpers";

const application = Application.start();
const context = require.context("../controllers/optional1", true, /\.js$/);
application.load(definitionsFromContext(context));

and then in my layout

  ...
  <%= javascript_pack_tag 'global', defer: true %>
  <%= javascript_pack_tag 'optional1', defer: true %>
  ...

Of course the layout would have some logic to toggle the optional packs. Maybe with a content_for

I have not tested it nor implemented it like this in the pastā€¦ but I suppose it should work, not sure if it is exactly what you are looking for

3 Likes

Thanks @adrienpoly this is a good start.

I guess my next question is ( one I maybe should have asked first :slight_smile: ) is - is it ok to have 2 Application.start() calls?

I donā€™t use Rails for this project, using Shopify ( and BTW - Stimulus works AMAZING with SSR Shopify! ) but I can split out my bundles and load them dynamically in liquid just like the erb sample you posted.

Will test and see how it goes - but if you have knowledge of Stimulus under the hood and know if its cool or not to have the 2 start()'s please enlighten me!

Thanks so much for taking the time to comment back!

Iā€™ve been following the pattern @adrienpoly describes, expect I called the global namespace
application, trying to follow rails conventions.

Then I load specific packs on a per-view basis, only when needed, nested in a content_for helper call.
Itā€™s been working perfectly fine so far! :slight_smile:

My javascript folders & files organisation :point_down:

3 Likes

Very interesting ideas presented here.

Iā€™m wondering how to deal with controllers that are named the same thing using this arrangement?

For example, say you have the Webpack config as described:

// app/javascript/packs/global_controller.js
import { Application } from "stimulus";
import { definitionsFromContext } from "stimulus/webpack-helpers";

const application = Application.start();
const context = require.context("../controllers/global", true, /\.js$/);
application.load(definitionsFromContext(context));

// app/javascript/packs/optional1_controller.js
import { Application } from "stimulus";
import { definitionsFromContext } from "stimulus/webpack-helpers";

const application = Application.start();
const context = require.context("../controllers/optional1", true, /\.js$/);
application.load(definitionsFromContext(context));

and letā€™s say you have a folder structure such as

controllers/
  global/
    test_controller.js
  optional1
    test_controller.js

Now you have some HTML which resembles

<div data-controller="test">

How does stimulus know in this case which controller js file to use?

Interesting question indeed.
I havenā€™t run into such a collision situation for now, as Iā€™m using specific packs on each page.

Pretty sure you could still namespace then in separate folders, thus effectively being able to attach them both on the same page, following the baked-in conventions you can find here.

What do you think?

How about using app/javascript/packs/application.js for storing global code ?

This ( I think ) would work well in a rails project but I am using Stimulus with Shopify ( and I canā€™t reiterate how well Shopify components and sections work with Stimulus )

Needless to say Iā€™m a happy camper. Always looking to improve.

What I am in the middle of doing now is namespacing with folders. Think is naming things is hard. Because I was dealing with what @fizznol posted really quick.

I think a another week or two of practical implementation and then Iā€™d love to create an abstracted repro of the design pattern so we can discuss something more tangible.

I canā€™t thank you all enough for your feedback. It led to many thoughts that I solved my initial issue - but Iā€™d also like to share the pattern for a broader look.

4 Likes

I have a similar issue, but I have the headache that these packs still contain all of stimulus, and thus they are pretty big in size. What I want is for the packs to be small in size, and only contain the required components to work with stimulus, so I can reduce the amount of time my browser spends parsing and loading javascript.
Is there a way to bundle stimulus itself in one script, and have all the controllers in packs register onto the global stimulus?

Basically I dont want to have to import this into every pack:
import { Application } from ā€œstimulusā€

1 Like

This seems to be the top result from Google and I still donā€™t know the right way to do this.

To answer @justinmetros question, you donā€™t want to call application.start() multiple times.

Iā€™m using something like this:

const application = Application.start();
// require controllers
window.Stimulus = application;
1 Like

I want to do this too. I import a big library in my bundle thatā€™s only used on some subpages, not the frontpage. Splitting the bundle would make the users that arenā€™t going to the subpages in question download less javascript, and it would also probably influence the Lighthouse score for the frontpage since the initial bundle is smaller.

Basically I want to have a few controllers in the main entrypoint (letā€™s call it globals.js) and then viewcomponent specific entrypoints with one or maybe two controllers attached in sidecar files. Like this.

Right now I can only think of one way to do this without calling Application.start() dozens of times, and itā€™s to import Application and Controller and then force them into the global scope and then register controllers on a component (or partial if you prefer that) basis. Itā€™s extremely ugly, but works:

// app/frontend/entrypoints/globals.js
import { Application, Controller } from "@hotwired/stimulus";
window.Controller = Controller;
window.Stimulus = Application.start();

Then you can do this in a view (Iā€™m using ViteJS, but Webpacker would be similar):

<%# app/views/employees/show.html.erb %>
<% content_for :template_javascript { vite_javascript_tag "employees" } %>
<%# app/views/layouts/application.html.erb %>
<head>
  <%= vite_javascript_tag "globals" %>
  <%= yield :template_javascript %>
</head>

ā€¦and finally write the template-specific controller like this:

// app/frontend/entrypoints/employees.js
Stimulus.register("hello", class extends Controller {
  static targets = [ "name" ];

  connect() {
    console.log("Template-specific controller connected!");
  }
});

It would be messy to have the controller in a file in the entrypoints folder, it should be in a viewcomponent sidecar file or whatever. But this works without setting up different directories.

I like ESM and donā€™t really enjoy polluting the global scope this way, so if thereā€™s a better way, Iā€™m very interested to know about it!

1 Like

In theory, I think we should be able to put this in a globals.js:

import { Application } from "@hotwired/stimulus"
import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers"

window.Stimulus = Application.start()

And then in each smaller bundle, just put:

import FoobarDataController from "controllers/foobar"
application.register("foobar", FoobarController)

I think that would work. However, inside the controller, we also have to do

import { Controller } from '@hotwired/stimulus'

and I think thatā€™s the part that includes the whole Stimulus module. Digging into the dist folder, it looks like everything is contained in one big stimulus.js. Maybe a separate controller.js could be compiled for this purpose?

Iā€™m not sure who on the Stimulus team to ask about making that happen.

I requested this here: Request: New dist/controller.min.js that only includes Controller. Ā· Issue #638 Ā· hotwired/stimulus Ā· GitHub

On a similar note I think I noticed a similar question on SO: Reduce Javascript size in Rails app and improve performance score. Maybe issue with ESBuild? - Stack Overflow

(without Webpack though)