ClojureScript ↔ JavaScript interop boundary for incremental migration without downtime
0 reputation · 27 May 2022, 07:37 UTC
0 reputation · 27 May 2022, 07:37 UTC
A small frontend application is being considered for incremental migration from plain JavaScript to ClojureScript with no downtime. The goal is to mount ClojureScript components into isolated DOM nodes and routes while the existing JavaScript remains active, using feature flags and separate asset loading.
ClojureScript compiles to plain JavaScript and can be delivered as ES modules or bundles that coexist with existing JavaScript on the same page. Interoperability relies on explicit interop syntax and externs declarations. Under advanced compilation Closure renames symbols and properties, which breaks external calls without externs. Build tool choice influences the output shape, with shadow-cljs providing npm integration and hot reload for development and the official CLI targeting Google Closure Compiler for production artifacts. Zero-downtime behavior depends on deployment practices such as feature flags and cache busting, not on ClojureScript itself.
An unresolved decision is the stability of the JavaScript API surface exported from ClojureScript. The trade-off is between exposing names via defonce and externs for external JavaScript callers versus keeping the boundary internal and recompiling on change.
What contract should define a stable JS API from ClojureScript when advanced optimizations are enabled? How should externs or export maps be maintained across incremental releases to avoid breaking existing JavaScript callers? Is it advisable to keep the interop boundary internal rather than publishing a versioned JS surface?
29775 reputation · 27 May 2022, 19:12 UTC
Publish a deliberately thin, versioned JavaScript facade from a single ClojureScript namespace, mark every function in it with ^:export, and keep the rest of the interop boundary internal. Externs are needed only for names the Closure Compiler must not rename that your own exports do not already cover — typically third-party globals or properties you read off JavaScript objects. Do not treat defonce as an export mechanism: it controls redefinition semantics during hot reload, not name preservation in :advanced output.
Well established: under :advanced optimizations, Closure renames local names and property accesses it believes are private. ^:export metadata (or goog.exportSymbol / goog.exportProperty) emits code that pins the name. Externs files tell the compiler a name is defined elsewhere and must be left alone. These are long-standing behaviors rather than version-specific quirks.
What varies by toolchain: the exact flag names for externs validation, whether the tool emits an export manifest, and how npm packages resolve. shadow-cljs and the official cljs.main CLI differ here, and flags move between releases. Verify against the documentation for the versions you pin.
myapp.api, is the only thing JavaScript may call. Everything else is private by convention.clj->js / js->clj), and exposes no ClojureScript-specific types in its signature.version string plus a mount(node, props) / unmount(node) pair. Mounting into an isolated DOM node is what makes per-route migration possible.| Situation | Mechanism |
|---|---|
| ClojureScript exports a function for external JavaScript | ^:export on the var |
| ClojureScript reads a property Closure would rename | externs entry |
| Third-party global used from ClojureScript | externs, or a library-supplied externs file |
| Hot-reload redefinition in development | defonce — unrelated to name preservation |
For incremental migration without downtime, a thin published surface is usually the better trade. If the boundary stays internal, every change to a component signature forces a rebuild and redeploy of the JavaScript callers, re-coupling the two codebases and undermining the point of migrating incrementally. A small facade with a version string lets old JavaScript keep calling the old shape while new ClojureScript loads alongside.
The cost is discipline: a public surface must be versioned, documented, and deprecated deliberately. If your JavaScript callers live in the same compilation unit as the ClojureScript — one Closure build — externs are largely unnecessary and ^:export alone suffices. That distinction is the single detail that most changes the recommendation.
Feature flags and cache busting are what actually deliver zero downtime. A flag that hides a new component does not help if a browser has cached JavaScript that calls a renamed export. Use content-hashed, immutable asset URLs and keep the previous bundle reachable during the rollout window.
grep -o 'myapp\.api\.[A-Za-z0-9_]*' out/main.js | sort -uFlag for review: exact flag names and manifest support differ between shadow-cljs and the official CLI and shift across versions. Confirm against the docs for your pinned versions before wiring CI.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.