Skip to content

Load the assets as ES modules through an import map - #2737

Merged
simon04 merged 4 commits into
mainfrom
esm-modules
Sep 14, 2026
Merged

simon04 merged 4 commits into
mainfrom
esm-modules

Conversation

@simon04

@simon04 simon04 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Sprockets has no notion of import/export — its whole JS pipeline is
concat_javascript_sources, which appends each file and pokes a ;
between them. The single global scope that produced is what the app
object was a registry for. Serving the modules individually instead
costs ~33 KB of cross-file compression and six levels of import depth,
which modulepreload flattens to about one round trip.

Every module is served at a content-digested URL, immutable and
cacheable forever, and the import map that points at those URLs ships
inside the HTML, which is never cached. A client therefore reads one
build's map and fetches that build's modules, and can't end up running
half of one build and half of another. Old digests are kept for a couple
of builds so a deploy doesn't strand a client mid-load.

The modules import each other by relative path rather than by a bare
specifier so that tsc, editors and Node resolve them with no extra
configuration; the digests still apply because an import map may key on
a URL as well as on a bare name.

app keeps only the state the rest of the app reads off it. The four
class registries are gone, and with them the reason for most of
globals.d.ts. Two things had to move rather than be translated:

  • config becomes its own leaf module. Five classes read it from a
    static field, which runs at class-definition time and would find
    app still uninitialised inside the app/views import cycle. The
    same hazard made static model = Doc in the collections throw, so
    the model is resolved from a method now.

  • The injection-error check compared window.$ against the app's own
    copy, to notice extensions replacing the globals. Module scope makes
    that impossible, which also makes the comparison always true — it
    would have reported every error as an injection error and shown an
    alert. Removed along with the fields that existed to feed it.

The vendored libraries assign globals rather than exporting, so they
stay one concatenated classic script loaded ahead of the graph. docs.js
and debug.js stay separate entries: single-doc pages skip the catalog,
and debug has to run before the boot it wraps.

The tests import the modules directly instead of concatenating files
into a vm context, with a loader hook standing in for the modules ERB
generates at build time. A new test evaluates the whole graph in the
order the browser does, which is the only thing that catches the
initialisation-cycle bugs above.

Sprockets has no notion of `import`/`export` — its whole JS pipeline is
`concat_javascript_sources`, which appends each file and pokes a `;`
between them. The single global scope that produced is what the `app`
object was a registry for. Serving the modules individually instead
costs ~33 KB of cross-file compression and six levels of import depth,
which `modulepreload` flattens to about one round trip.

Every module is served at a content-digested URL, `immutable` and
cacheable forever, and the import map that points at those URLs ships
inside the HTML, which is never cached. A client therefore reads one
build's map and fetches that build's modules, and can't end up running
half of one build and half of another. Old digests are kept for a couple
of builds so a deploy doesn't strand a client mid-load.

The modules import each other by relative path rather than by a bare
specifier so that tsc, editors and Node resolve them with no extra
configuration; the digests still apply because an import map may key on
a URL as well as on a bare name.

`app` keeps only the state the rest of the app reads off it. The four
class registries are gone, and with them the reason for most of
globals.d.ts. Two things had to move rather than be translated:

  - `config` becomes its own leaf module. Five classes read it from a
    `static` field, which runs at class-definition time and would find
    `app` still uninitialised inside the app/views import cycle. The
    same hazard made `static model = Doc` in the collections throw, so
    the model is resolved from a method now.

  - The injection-error check compared `window.$` against the app's own
    copy, to notice extensions replacing the globals. Module scope makes
    that impossible, which also makes the comparison always true — it
    would have reported every error as an injection error and shown an
    alert. Removed along with the fields that existed to feed it.

The vendored libraries assign globals rather than exporting, so they
stay one concatenated classic script loaded ahead of the graph. docs.js
and debug.js stay separate entries: single-doc pages skip the catalog,
and debug has to run before the boot it wraps.

The tests import the modules directly instead of concatenating files
into a `vm` context, with a loader hook standing in for the modules ERB
generates at build time. A new test evaluates the whole graph in the
order the browser does, which is the only thing that catches the
initialisation-cycle bugs above.
@simon04
simon04 requested a review from a team as a code owner September 14, 2026 10:24
@simon04 simon04 self-assigned this Sep 14, 2026
@simon04
simon04 requested a balanced review from Copilot September 14, 2026 10:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The generated news module has an initialization-cycle failure, and the import-map requirement breaks currently advertised browsers.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Migrates DevDocs’ JavaScript assets from a Sprockets-concatenated global bundle to individually cached ES modules resolved through an import map.

Changes:

  • Replaces global registries with explicit module imports and exports.
  • Adds digested module mapping, preloading, vendor bundling, and asset retention.
  • Updates asset tests to load modules directly and verify graph initialization.
File summaries
File Description
views/other.erb Loads single-doc pages through modules.
views/index.erb Loads the full module graph and catalog.
tsconfig.json Enables unused-local checking.
test/assets/tsconfig.json Configures Node and DOM test types.
test/assets/setup.js Provides browser globals and generated-module hooks.
test/assets/search_ranking_test.js Converts ranking tests to module imports.
test/assets/search_hash_test.js Converts hash tests to module imports.
test/assets/module_graph_test.js Tests module graph evaluation.
test/assets/fixtures/root_tmpl.js Stubs generated landing templates.
test/assets/fixtures/news_tmpl.js Stubs generated news templates.
test/assets/fixtures/docs.js Stubs the generated catalog module.
test/assets/fixtures/config.js Stubs generated application configuration.
test/assets/doc_version_test.js Converts migration tests to modules.
package.json Enables ESM and test setup hooks.
lib/tasks/assets.thor Retains previous asset generations.
lib/app.rb Builds maps, preloads, and module asset lists.
assets/javascripts/views/view.js Exports the base view and renderer dependency.
assets/javascripts/views/sidebar/type_list.js Modularizes type-list dependencies.
assets/javascripts/views/sidebar/sidebar.js Modularizes the sidebar composition.
assets/javascripts/views/sidebar/sidebar_hover.js Exports the hover view.
assets/javascripts/views/sidebar/results.js Modularizes search results.
assets/javascripts/views/sidebar/entry_list.js Exports the entry list.
assets/javascripts/views/sidebar/doc_picker.js Modularizes the documentation picker.
assets/javascripts/views/sidebar/doc_list.js Modularizes the documentation list.
assets/javascripts/views/search/search.js Modularizes the main search view.
assets/javascripts/views/search/search_scope.js Modularizes scoped search.
assets/javascripts/views/pages/support_tables.js Exports support-table behavior.
assets/javascripts/views/pages/sqlite.js Exports SQLite page behavior.
assets/javascripts/views/pages/rdoc.js Exports RDoc page behavior.
assets/javascripts/views/pages/jquery.js Exports jQuery page behavior.
assets/javascripts/views/pages/hidden.js Modularizes hidden-document pages.
assets/javascripts/views/pages/base.js Exports the base documentation page.
assets/javascripts/views/misc/updates.js Modularizes update notifications.
assets/javascripts/views/misc/tip.js Exports tip notifications.
assets/javascripts/views/misc/notif.js Exports the notification base.
assets/javascripts/views/misc/notice.js Exports persistent notices.
assets/javascripts/views/misc/news.js Modularizes news notifications.
assets/javascripts/views/list/paginated_list.js Modularizes paginated lists.
assets/javascripts/views/list/list_select.js Exports list selection behavior.
assets/javascripts/views/list/list_fold.js Modularizes list folding.
assets/javascripts/views/list/list_focus.js Modularizes list focus behavior.
assets/javascripts/views/layout/settings.js Modularizes the settings panel.
assets/javascripts/views/layout/resizer.js Exports the layout resizer.
assets/javascripts/views/layout/path.js Exports breadcrumb behavior.
assets/javascripts/views/layout/mobile.js Modularizes mobile layout behavior.
assets/javascripts/views/layout/menu.js Exports menu behavior.
assets/javascripts/views/layout/document.js Composes the root view through imports.
assets/javascripts/views/content/type_page.js Modularizes type pages.
assets/javascripts/views/content/static_page.js Exports static-page rendering.
assets/javascripts/views/content/settings_page.js Modularizes settings-page behavior.
assets/javascripts/views/content/root_page.js Exports landing-page behavior.
assets/javascripts/views/content/offline_page.js Modularizes offline management.
assets/javascripts/views/content/entry_page.js Maps document-specific page views.
assets/javascripts/views/content/content.js Composes content views through imports.
assets/javascripts/vendor.js Creates the classic vendor bundle.
assets/javascripts/tracking.js Imports analytics dependencies explicitly.
assets/javascripts/templates/tip_tmpl.js Exports tip templates.
assets/javascripts/templates/sidebar_tmpl.js Exports sidebar templates.
assets/javascripts/templates/path_tmpl.js Exports breadcrumb templates.
assets/javascripts/templates/pages/type_tmpl.js Exports type-page templates.
assets/javascripts/templates/pages/settings_tmpl.js Exports settings templates.
assets/javascripts/templates/pages/root_tmpl.js.erb Generates exported landing templates.
assets/javascripts/templates/pages/root_tmpl.d.ts Declares generated landing exports.
assets/javascripts/templates/pages/offline_tmpl.js Modularizes offline templates.
assets/javascripts/templates/pages/news_tmpl.js.erb Generates news module exports and data.
assets/javascripts/templates/pages/news_tmpl.d.ts Declares generated news exports.
assets/javascripts/templates/pages/help_tmpl.js Exports help templates.
assets/javascripts/templates/pages/about_tmpl.js Exports the about template.
assets/javascripts/templates/notif_tmpl.js Exports notification templates.
assets/javascripts/templates/notice_tmpl.js Exports persistent-notice templates.
assets/javascripts/templates/error_tmpl.js Exports error templates.
assets/javascripts/templates/base.js Builds the module-based template registry.
assets/javascripts/models/type.js Exports the type model.
assets/javascripts/models/model.js Exports the base model.
assets/javascripts/models/entry.js Modularizes entry dependencies.
assets/javascripts/models/doc.js Modularizes documentation models.
assets/javascripts/lib/util.js Exports DOM utility functions.
assets/javascripts/lib/page.js Exports router utilities and context.
assets/javascripts/lib/local_storage_store.js Exports local-storage support.
assets/javascripts/lib/license.js Removes the standalone license module.
assets/javascripts/lib/favicon.js Exports favicon helpers.
assets/javascripts/lib/events.js Exports the event base class.
assets/javascripts/lib/cookies_store.js Exports cookie storage.
assets/javascripts/lib/ajax.js Exports the AJAX helper.
assets/javascripts/globals.d.ts Reworks ambient declarations for modules.
assets/javascripts/docs.js.erb Imports the app before setting its catalog.
assets/javascripts/docs.d.ts Declares the generated catalog module.
assets/javascripts/debug.js Patches imported classes for instrumentation.
assets/javascripts/collections/types.js Exports typed type collections.
assets/javascripts/collections/entries.js Exports typed entry collections.
assets/javascripts/collections/docs.js Exports documentation collections.
assets/javascripts/collections/collection.js Exports the generic collection base.
assets/javascripts/application.js Becomes the module boot entry point.
assets/javascripts/app/update_checker.js Exports update checking.
assets/javascripts/app/shortcuts.js Exports keyboard shortcuts.
assets/javascripts/app/settings.js Exports application settings.
assets/javascripts/app/serviceworker.js Exports service-worker management.
assets/javascripts/app/searcher.js Exports asynchronous and synchronous searchers.
assets/javascripts/app/router.js Exports the application router.
assets/javascripts/app/offline_backup.js Exports offline backup support.
assets/javascripts/app/db.js Exports database management.
assets/javascripts/app/config.js.erb Generates exported configuration.
assets/javascripts/app/config.d.ts Declares generated configuration types.
assets/javascripts/app/app.js Replaces registries with direct imports.
Review details
  • Files reviewed: 98/104 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

//= depend_on news.json

app.templates.newsPage = () => ` <h1 class="_lined-heading">Changelog</h1>
import { app } from "../../app/app.js";
Comment thread views/index.erb
ahead of the module graph. The import map rewrites each module's relative
imports onto its digested URL; it has to come before any module loads. %>
<%= javascript_tag 'vendor' %>
<script type="importmap" nonce="devdocs"><%= import_map_json %></script>
Comment thread views/other.erb
<%# Single-doc pages read their one doc off the body, so they skip docs.js
and the whole catalog with it. %>
<%= javascript_tag 'vendor' %>
<script type="importmap" nonce="devdocs"><%= import_map_json %></script>
news_tmpl.js is a dependency of the app singleton, so it evaluated
before `app` was initialised and threw on every load. Export the data
from the leaf module instead.

The graph test swaps this module for a side-effect-free fixture and so
could never have failed. Assert the property directly.
Such a browser resolves every relative import against the undigested
path, 404s on all of them, and sits on the loading screen. The in-app
check cannot report that from inside the graph that failed to load, so
test for import maps from a classic script that runs first.

The documented baseline was already wrong: the assets target ES2022.
Concatenated scripts run sloppy, so a plain function's `this` was the
global object. page.js cached the canonical link element there and
favicon.js used it to reach resetFavicon. Modules are strict, and the
first of those threw on the boot path.

noImplicitThis catches the favicon shape. It does not catch page.js,
where a cast asserting `this` had a type silenced the check.
@simon04
simon04 merged commit e62b809 into main Sep 14, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants