Migrating to module output
The Curiosity front-end is now compiled as JavaScript modules rather than one bundle per project. A custom front-end has to move with it: the referenced packages publish their code as modules, and a project still configured for a single bundle never imports their chunks.
This page is the migration checklist. Work through it in order — the first two steps are what make the application load at all.
1. Set module output in tps.json
Every front-end project needs outputBy set to Module:
{
"output": "$(OutDir)/tps/",
"fileName": "app.js",
"outputBy": "Module",
"combineScripts": false
}
outputFormatting no longer exists. Remove it — it is silently ignored. The tps.Release.json overlays that existed only to force outputFormatting: Both can go with it.
A tps.json should no longer re-declare its own project's compiled output. The { "name": "MyApp.js", "files": [ "$(OutDir)tps/…" ] } resource groups were a workaround for a consumer that could not choose which variant of a package's code to take; a package now ships all three variants (the formatted bundle, the minified bundle, and the module entry with its chunks) and the referencing application keeps the set its own configuration calls for. To keep a referenced package out of index.html entirely, say "loadCompiledOutput": false.
2. Update the toolchain packages
Module output needs the newer compiler and runtime. Nothing below is optional — an older runtime drops a nested type whose chunk evaluates before its container's, and the application dies at boot.
| Package | Minimum | Why |
|---|---|---|
Transpose.Compiler (the tps tool) |
26.8.4101 | Emits the module entry and the chunk map. |
Transpose.BCL |
26.8.4102 | Keeps a nested type alive across chunk evaluation order, makes reflection metadata's attribute classes eager, and keeps a namespace resolvable once its stubs register. Also declares LoadsTypeArgumentsAttribute, which step 5 needs. |
Transpose.Newtonsoft.Json |
26.8.4104 | Its DeserializeObject<T> carries [ConstructsTypeArguments], without which deserializing a DTO graph hits stubs no chunk imported. |
Tesserae |
2026.8 or newer | Ships the same way, and brings the layout change in step 7. |
The workspace's own Download template pins a matching set — take those versions rather than picking your own.
3. Debug and Release are structurally different
A Release build emits one ES module per chunk (chunks/<assembly>/cN.mjs) plus an entry module per project, and index.html loads each entry with <script type="module">. A chunk is a strongly-connected component of the reference graph.
A Debug build ignores that and emits one readable bundle per project, with no chunks at all. That is the compiler's decision, not a setting: stepping through one file is what a Debug build is for.
Two things follow:
- A chunking bug reproduces in Release only. Test the change you are shipping in the configuration you are shipping it in.
- A Debug build is the fastest way to rule chunking out of a symptom. If it happens in both, it is not chunking.
The failure mode of a chunking problem is a runtime error naming the module — ... lives in module './chunks/...', which has not been loaded — never a build error.
4. A deferred type cannot be constructed synchronously
A type whose chunk has not been fetched is a stub: reflection still sees its name, its interfaces and its attributes, but Activator.CreateInstance throws and names the module.
So code that discovers implementations by reflection has to fetch them first, and keep the type it gets back — a stub is retired in place, so the type object reflection handed you is not the real class:
// Load first, then construct. Load them in one parallel pass so the
// factories you register stay plain synchronous constructions.
var loaded = await Transpose.Modules.LoadAsync(discoveredType);
Register(() => (IMyView)Activator.CreateInstance(loaded));
5. Route handlers activate their views, they do not new them
A route handler that writes new SomeView(state) welds every view into one chunk with the shell that reaches back into them. Activate through the routing helper instead, which loads the view's chunk and then constructs it — which is why the handlers become async:
// Before — the constructor pins the view's chunk to the shell's.
Router.Register(DefaultRoutes.Things, state => ShowDefault(new ThingsView(), title: "Things".t()));
// After — the chunk is fetched when the route is opened.
Router.Register(DefaultRoutes.Things, async state => ShowDefault(await AppRouting.ActivateAsync<ThingsView>(), title: "Things".t()));
AppRouting.ActivateAsync<T>() comes from the Curiosity.FrontEnd package, with a params object[] overload for a constructor that takes arguments — the common case being the route's Parameters. If your shell has its own activation helper, it must carry [Transpose.LoadsTypeArguments]: that attribute is the whole mechanism, and without it the emitted type argument is an ordinary reference and the chunker gives the caller the same hard edge the constructor did — a slower new, not a lazier one. Calling Activator.CreateInstanceAsync<T>() directly from the route handler does not help, because the type argument is then written down at the handler.
Two limits to respect:
- The constructor is picked at run time by arity and argument type, the way
Activator.CreateInstancealways has. A view whose only constructor takes optional parameters has no parameterless constructor to find, and an enum argument is a bare number in JavaScript. Pass an optional parameter explicitly (ActivateAsync<ThingsView>(false)) when that is enough; otherwise leave those asnew. - A static factory cannot be activated.
SomeView.OpenFor(...)names the type for a static call, which is a hard reference whatever the caller does.
6. A generic method that deserializes must be attributed
[Transpose.ConstructsTypeArguments] records its edge at the call site where the type argument is written down. The one inside JsonConvert.DeserializeObject<T> writes that method's own type parameter, so nothing concrete reaches it — which means the attribute has to be repeated on every generic method of yours that forwards a type argument into a deserializer:
[Transpose.ConstructsTypeArguments]
internal static async Task<T> GetAsync<T>(string path) => Deserialize<T>(await FetchAsync(path));
Without it, a module build keeps the DTO itself loadable but not the graph below it. The symptom is silent: a boot that hangs, with Cannot create an instance of '...' synchronously logged as an object nobody reads. If your application hangs at boot after a chunking change, unwrap the console objects first.
7. Other changes that land with this move
- Admin-only server calls moved out of the shared API package. Management, configuration and diagnostics calls now live in the admin package, which the application fetches on demand, so a regular user's session no longer downloads them. A custom front-end calling one of those directly has to reference the admin package.
- Stack and grid children are the layout item. Following the Tesserae update, a child of a stack or grid is now itself the layout item rather than being wrapped in one. Custom styles or code that reached through that wrapper have to be pointed one level in.
- Runtime script loading goes through the Transpose loader. A script, module or stylesheet fetched at run time goes through
Transpose.Require.RequireAsyncrather than a hand-rolled<script>element. It picks the element from the URL (.css→ a stylesheet link,.mjs→ a module, anything else → a classic script; passRequireKind.Modulefor a module that keeps a.jsname), loads several URLs in order so a plugin arrives after the library it extends, shares one fetch between callers, falls back between the.jsand.min.jsspellings, and no longer treats a failed load as successful. This needs the newerTranspose.BCLfrom step 2. - The node-preview CSS class setting is removed. A custom front end now runs a callback over every node preview sheet and every full-page node view before they are drawn, with the row being rendered passed in so one node type can be treated differently from the rest. The full-page node view had no customization point before. A front end that set the old CSS class has to move to the hook. File rows also carry stable CSS classes for their path, size, author and footer entries.
- The charting package is no longer referenced. See Curiosity Components → Charts.
Tuning which chunks group together
Once the application loads, grouping is what is left to tune. The chunker's grouping is a static guess, and a tps.chunks.json beside a project's tps.json replaces part of that guess with a measurement of the running application: one group of type names per screen, captured by driving the app while the server records which chunks each screen fetched.
Parsing it can never fail a build, so a stale or renamed entry costs a hint and nothing else.
Two things are worth knowing before you try:
- A static facade wants
[Transpose.SkipTypeClustering]. A static class whose members construct components, and which those same components call back into, is one strongly-connected component with half the application — i.e. one chunk — unless its member dependencies are attributed to the call sites instead. Apply it to static facades only, not to a class that owns a nested type: a nested type shares its container's global slot, so dropping the container's edges leaves nothing importing it and everything through it dies on "has not been loaded". Re-test in Release and click through the routes. - Re-capturing requires an uncoalesced package, not just an uncoalesced app. A referenced library's chunks are decided when that library is packed, so turning coalescing off for the application build does not re-split the library. Pack the library uncoalesced first, then capture.
Related
- Development workflow — building, serving and uploading the
tps/folder. - Migrating to OmniResult — the other breaking change in the same window.
- Curiosity Components — the components a custom front-end builds on.