Silent runtime failures after switching ClojureScript build to :advanced optimizations mid-migration
0 reputation · 06 Feb 2020, 14:28 UTC
0 reputation · 06 Feb 2020, 14:28 UTC
I am migrating a small ClojureScript frontend (a handful of namespaces, Reagent UI) from an older build setup to shadow-cljs, with the goal of zero downtime for users. The dev side is fine: the watch build with :none optimizations recompiles changed namespaces and the page updates without a restart.
The problem appears only after I produce a release build with :advanced optimizations. The minified bundle throws runtime errors when calling into third-party JavaScript libraries, and I understand this is the classic symptom of an incomplete externs file: property names get renamed by the Closure Compiler because they were not declared. The dev build never shows these errors, so they surface only late in the migration.
I can either keep the fast :none build with hot reloading during the migration window, or switch to :advanced early to catch externs gaps — but that slows iteration and I am unsure how to generate or validate externs for the libraries I use. I also have state in plain atoms outside component lifecycles that hot reloading will not preserve.
How can I detect missing externs before a full release build? Is there a practical way to run :advanced checks in CI without giving up the fast dev loop? And what is the recommended approach for preserving atom-based state across hot reloads during the cutover?
You cannot reliably detect missing externs without running :advanced optimizations. The :simple level does not rename or mangle property names — that only happens under :advanced. Keep your dev build at :none for fast hot reloading, and run a separate :advanced build in CI (or locally on demand) to surface externs gaps before release.
:simple does not help hereClosure Compiler's :simple mode renames local variables and function parameters but leaves object property names untouched. Externs errors — where a third-party library's React.createElement gets renamed to a.b — only appear when global property renaming is enabled, which is exclusive to :advanced. Switching dev to :simple adds compilation time without catching the failure mode you described.
shadow-cljs.edn:{:builds
{:dev {:target :browser
:optimizations :none
:output-dir "public/js/dev"
:modules {:main {:init-fn my.app/init}}
:devtools {:after-load my.hooks/on-reload}}
:ci {:target :browser
:optimizations :advanced
:output-dir "public/js/ci"
:modules {:main {:init-fn my.app/init}}
:compiler-options {:infer-externs true
:externs ["externs/custom.js"]}}}}shadow-cljs watch dev — fast, hot-reloading, no externs checking.shadow-cljs compile ci (or release ci for full output). Fail the build on any Closure Compiler warning. shadow-cljs exits non-zero on errors; warnings appear in stdout/stderr.:infer-externs true (shadow-cljs 2.16+) — it reads ^js type hints and js/ property access to generate externs automatically.:npm-module or shadow.cljs.devtools.api/npm-install, add :js-provider :import and rely on inference.externs/custom.js file declaring the exact property names you call (e.g., var React = {createElement: function() {}};).Move long-lived atoms into a namespace that shadow-cljs never reloads (e.g., my.app.state). Require that namespace from components instead of defining atoms inline. This is the simplest and most reliable method.
If you must keep atoms in reloaded namespaces, use the :devtools {:after-load ...} hook (not a top-level :hooks key). The hook receives the changed namespaces; you can re-attach prior values:
;; my.hooks
(ns my.hooks
(:require [my.app.ui :as ui]))
(def ^{:private true} saved-state (atom nil))
defn capture-state []
(reset! saved-state @ui/app-state))
defn restore-state []
(when-let [prev @saved-state]
(reset! ui/app-state prev)))In shadow-cljs.edn dev build add :devtools {:before-load my.hooks/capture-state :after-load my.hooks/restore-state}. Note: this only preserves values across reloads in the same browser session; a full page reload resets everything. Test a cold :advanced build locally before cutover to ensure initialization works without the hook.
shadow-cljs compile dev — confirms dev build works.shadow-cljs compile ci 2>&1 — runs :advanced compilation; review output for WARNING lines mentioning "property" or "externs".shadow-cljs release ci locally once before merging to simulate the exact production artifact.:infer-externs and :devtools hooks are stable.:global-exports may need manual externs.js/React.createElement) but may miss string-based access (aget obj "createElement") — use cljs-oops or manual externs for those cases.Are your third-party libraries installed via npm (ES modules/CommonJS) or provided as CLJSJS/foreign-libs globals? If npm, :infer-externs true with :js-provider :import handles most cases automatically. If globals, you will likely need to write manual externs for each library's public API surface.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.