Migrating to 0.5
0.5 fixes bugs that changed observable behaviour, so some of these fixes break 0.4 code. This page lists every breaking change by package, with the code to change. Upgrade every tuvix.js and @tuvix.js/* package to ^0.5.0 together; mixing 0.4 and 0.5 adapters on one page is not supported.
pnpm up --latest "tuvix.js" "@tuvix.js/*"Projects created with create-tuvix-app before 0.5 depend on ^0.1.0, which never resolves to 0.4 or 0.5. Edit those ranges by hand.
Core
The orchestrator uses the global bus
0.4 gave every orchestrator a private bus, separate from getGlobalBus(). In 0.5 the orchestrator's bus is getGlobalBus() unless you pass eventBus options, and destroy() leaves the global bus alive.
// 0.4: two buses; apps listening on getGlobalBus() missed orchestrator events
const orchestrator = createOrchestrator();
orchestrator.getEventBus() === getGlobalBus(); // false// 0.5
import { createOrchestrator, getGlobalBus } from 'tuvix.js';
const shared = createOrchestrator();
console.log(shared.getEventBus() === getGlobalBus()); // true
const isolated = createOrchestrator({ eventBus: {} }); // private bus, as in 0.4
console.log(isolated.getEventBus() === getGlobalBus()); // falseIf you exposed the orchestrator's bus on window so apps could find it, you can drop that and use the bus from the mount context or getGlobalBus().
Route changes leave manual apps alone
0.4 unmounted an app on the next route change when it had neither activeWhen nor a router route, and mountWhenVisible apps as well. In 0.5, reconciles only touch apps that activeWhen or a route controls. If you relied on a navigation to remove a manually mounted app, unmount it yourself:
import { createOrchestrator } from 'tuvix.js';
const orchestrator = createOrchestrator();
await orchestrator.unmountApp('cookie-banner');sandbox: true renders in a shadow root
In 0.4 sandbox on register did nothing. In 0.5 sandbox: true mounts the app inside a shadow root and loads its entry styles there. Page CSS stops reaching the app, and the app's entry styles stop reaching the page. Remove sandbox: true from apps that depend on page styles. See Sandboxing.
prefetch: 'hover'
0.4 prefetched every app on the first mouse movement anywhere on the page. 0.5 prefetches an app when the pointer enters, or focus lands on, a same-origin link whose path activates it. Use 'idle' or 'immediate' to fetch every app up front.
OrchestratorConfig.shared is gone
The orchestrator never read shared. Delete it, and share dependencies with @tuvix.js/module-federation or import maps.
activeWhen patterns
activeWhen strings now use matchPath. A query string or hash no longer unmounts an app, :param segments work, and characters such as . and ( match literally.
// 0.4: `.` was a regex wildcard and matched '/v1x0'
activeWhen: '/v1.0/*'
// 0.4: query strings broke matching, so people wrote functions
activeWhen: (path) => path.split('?')[0].startsWith('/dashboard')import { createOrchestrator } from 'tuvix.js';
const orchestrator = createOrchestrator();
orchestrator.register({
name: 'dashboard',
entry: () => import('./apps/dashboard'),
container: '#app',
activeWhen: '/dashboard/*', // also active on /dashboard?tab=1
});Router
The '/' route and RouteConfig.exact
Route paths match the whole path everywhere. A '/' route now activates only on /; in 0.4, getActiveApps() treated it as every path while match() treated it as exact. RouteConfig.exact is gone, because it never changed anything.
// 0.4
routes: [{ path: '/', app: 'shell-nav' }, { path: '/settings', app: 'settings', exact: true }]// 0.5
import { createRouter } from 'tuvix.js';
const router = createRouter({
routes: [
{ path: '/*', app: 'shell-nav' }, // every path
{ path: '/settings', app: 'settings' }, // /settings only
],
});
void router;activeWhen: '/' on register still matches every path, because activeWhen strings use prefix matching.
push and replace add the base
// 0.4 with base: '/app'
router.push('/app/settings'); // you had to add the base yourself// 0.5
import { createRouter } from 'tuvix.js';
const router = createRouter({ base: '/app', routes: [] });
await router.push('/settings'); // URL: /app/settingsPassing /app/settings in 0.5 writes /app/app/settings. A guard that cancels back/forward navigation also restores the URL with the base.
Other router changes
basestrips only on a segment boundary. Withbase: '/app',/applicationstays/application.pathToRegex()returns a cachedRegExp; do not mutate it. Its secondexactargument is gone.- Regex characters in patterns match literally. A
*in the middle of a pattern matches one segment. matchRoute()andisPathActive()ignore a#hashin the path.
Loader
Removed options
LoaderOptions.fetch and EntryConfig.html are gone. The loader never read them; delete them from your config.
// 0.4
new ModuleLoader({ fetch: myFetch });
orchestrator.register({ name: 'x', entry: { scripts: ['/x.js'], html: '<div></div>' }, container: '#x' });// 0.5
import { ModuleLoader } from '@tuvix.js/loader';
const loader = new ModuleLoader({ timeout: 10000 });
void loader;Module scripts run once per URL
After unload(name), an app whose type: 'module' script registered itself does not load again from the same URL, because browsers run a module script once per URL. Use a loader-function entry for apps you unload and reload:
import { createOrchestrator } from 'tuvix.js';
const orchestrator = createOrchestrator();
orchestrator.register({
name: 'editor',
entry: () => import('./apps/editor'),
container: '#editor',
});load() for a cached module now reports its real duration instead of 0.
Module Federation
federatedEntry and createFederatedEntry return a loader function instead of { scripts: [remoteEntry.js] }. The 0.4 entry loaded the container script and then failed with "no exports found".
// 0.4
orchestrator.register({
name: 'dashboard',
entry: federatedEntry({ name: 'dashboard', url: 'https://cdn.example.com/dashboard/remoteEntry.js' }),
container: '#main',
});// 0.5
import { createOrchestrator } from '@tuvix.js/core';
import { createFederatedLoader, federatedEntry } from '@tuvix.js/module-federation';
const orchestrator = createOrchestrator();
orchestrator.register({
name: 'dashboard',
entry: federatedEntry(
{ name: 'dashboard', url: 'https://cdn.example.com/dashboard/remoteEntry.js' },
'./App', // exposed module path
),
container: '#main',
activeWhen: '/dashboard/*',
});
const remotes = createFederatedLoader({
remotes: [{ name: 'settings', url: 'https://cdn.example.com/settings/remoteEntry.js' }],
shareScope: {}, // replaces 0.4 `shared`
});
orchestrator.register({
name: 'settings',
entry: remotes.createFederatedEntry('settings', './App'),
container: '#main',
activeWhen: '/settings/*',
});FederatedLoaderConfig.sharedis replaced byshareScope. 0.4 never readshared.loadRemoteContainerreturnsFederatedContainerinstead ofany.
React
TuvixReactAppserver HTML now contains Suspense comment markers (<!--$-->...<!--/$-->). Render and hydrate with@tuvix.js/react0.5 on both sides.mountrejects when the first render throws. In 0.4 the error reached React's root error handler andmountresolved. The orchestrator now shows the app'sfallbackand callsonError.unmounton a container the module never mounted does nothing. 0.4 cleared it.
Vue
update() no longer writes app.config.globalProperties.$tuvixProps. Props are reactive now:
<!-- 0.4 -->
<template>{{ $tuvixProps.theme }}</template><!-- 0.5 -->
<script setup lang="ts">
defineProps<{ theme?: string }>();
</script>
<template>{{ theme }}</template>Descendants can inject('tuvixProps'), which returns the same reactive object.
VueSsrMicroAppConfigno longer acceptsssr.createSsrVueMicroAppsets it.unmounton a container the module never mounted does nothing.
Svelte
- Without
ssr,createSvelteMicroAppempties the container before it renders. 0.4 appended Svelte 3/4 components after existing markup. UsecreateSsrSvelteMicroAppto keep and hydrate server markup. unmounton a container the module never mounted, or whose mount failed, does nothing. 0.4 emptied it. All four adapters now behave this way.renderSvelteToStringno longer falls back toApp.renderwhensvelte/serverfails to load. Withrenderpassed it uses Svelte 5; otherwise it takes the Svelte 5 path whensvelteexportsmount, and callsApp.renderonly for Svelte 3/4. A Svelte 5 import or render error reaches you.
Svelte 5 components work in 0.5; in 0.4 createSvelteMicroApp failed on them. A Svelte 5 update() remounts the component unless you pass SvelteMap from svelte/reactivity in the config, which updates props in place:
import { SvelteMap } from 'svelte/reactivity';
import { createSvelteMicroApp } from '@tuvix.js/svelte';
import Profile from './Profile.svelte';
export default createSvelteMicroApp({ name: 'profile', App: Profile, SvelteMap });Angular
New config shape
// 0.4
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
export default createAngularMicroApp({
name: 'admin',
module: AppModule,
platform: platformBrowserDynamic,
selector: 'app-root',
});// 0.5
import { createAngularMicroApp } from '@tuvix.js/angular';
import { AppComponent } from './app.component';
import { AppModule } from './app.module';
export default createAngularMicroApp({
name: 'admin',
component: AppComponent, // required
module: AppModule, // only when AppComponent is declared in an NgModule
});platform and compilerOptions are gone. Remove import '@angular/compiler' from your entry; the adapter loads the compiler for JIT components.
Zoneless by default
Apps run without zone.js unless you pass zone: true. On Angular 15-17, DOM events no longer trigger change detection; call ChangeDetectorRef.detectChanges() in handlers, or import zone.js in your entry and pass zone: true. Angular 18+ schedules change detection without zone.js. See Angular.
Props are inputs
update() no longer writes window.__TUVIX_ANGULAR_PROPS__. Declare @Input() properties instead; the adapter sets them with setInput.
// 0.4
ngOnInit() {
this.userId = (window as any).__TUVIX_ANGULAR_PROPS__?.admin?.userId;
}// 0.5
import { Component, Input } from '@angular/core';
@Component({ selector: 'app-admin', standalone: true, template: '{{ userId }}' })
export class AdminComponent {
@Input() userId = '';
}Other Angular changes
createSsrAngularMicroAppno longer addsprovideClientHydration(). It re-renders into the server host element.unmounton a container the module never mounted does nothing.
Sandbox
Sandbox({ css: false }).activate(container)returnsnulland attaches no shadow root.ISandbox.activatereturnsShadowRoot | null; check fornullbefore callingaddStyle.JsSandboxstrict mode throws aTypeErroron a blocked write. 0.4 logged a warning and stored the value anyway. Add globals your code writes toallowedGlobals.
import { createSandbox } from '@tuvix.js/sandbox';
declare const container: HTMLElement;
const sandbox = createSandbox({ css: true, js: false });
const shadow = sandbox.activate(container);
if (shadow) sandbox.css.addStyle(shadow, ':host { display: block; }');Server
createServerRenderer().render()returnsstatusCode: 502when at least one app matched the path and every fragment fetch failed. It still returns 200 when no app matched or one fragment succeeded.createMiddlewaresends that status. The streaming renderer always sends 200.createMiddlewareandcreateStreamingMiddlewarecallnext(error)instead ofnext()when rendering throws, so Express routes the request to your error handler.
Non-breaking fixes worth knowing
require('@tuvix.js/core')and the other CommonJS entry points work. In 0.4 five packages pointedrequireat files the build never wrote.getGlobalBus()keeps its instance onwindow, so separately bundled micro apps share one bus.- Adapters and
defineMicroAppdispatchtuvix:mounted,tuvix:unmountedandtuvix:error. See Lifecycle Events. - A
bootstrap()that throws runs again on the next mount. 0.4 skipped it from then on. register()afterstart()mounts the app right away when the current path activates it, and starts observing amountWhenVisibleapp.- Every lifecycle failure emits
tuvix:app:erroron the bus with{ name, error, phase }. 0.4 emitted it only for load and mount failures. maxListenerswarns withconsole.warnonce per event, with or withoutdebug.orchestrator.connectRouter()replacessetTimeout+reconcile()bridges. See External Router.