Modules

@Module groups providers, controllers, and declared components into a feature. The module's injector — and the transitive closure of its imports — is the scope everything inside it sees.

todo.module.ts
import { Module } from '@preact-mvc/core';
import { SharedModule } from '../shared/shared.module';
import { TodoStore } from './todo.store';
import { TodoController } from './todo.controller';
import { TodoView } from './todo.view';

@Module({
  imports: [SharedModule],        // its providers/components join our scope
  providers: [TodoStore],         // one TodoStore per TodoModule injector
  controllers: [TodoController],  // shared down the module's subtree
  components: [TodoView],         // declares scope ownership (see below)
  bootstrap: [TodoView],          // what a route renders for this module
})
export class TodoModule {}

What each field does

FieldEffect
providersRegistered in the module's injector — one instance per module scope, shared by everything mounted under it.
importsThe imported module's providers and declared components become visible in this scope. The closure is transitive: importing a module that imports SharedModule brings shared services along.
controllersRegistered in the module injector — the same controller instance serves every view in the module's subtree (see Controllers).
componentsScope ownership, not a lookup table: a declared view may only mount inside this module's injector ancestry. Undeclared views mount anywhere. See below.
bootstrapThe view rendered when a route points at this module ({ path: '/todos', module: TodoModule }) or when it's the app root.

Module injectors are cached per parent

getModuleInjector(Mod, parent) creates the injector once per parent scope — two routes into the same module share one injector, so module-scoped services survive navigation inside the feature.

Declaration = scope ownership, not resolution

In JSX, <TodoView/> is already the class — there's no selector registry for a declaration to feed. So components answers a different question: what scope does this view live in?

// TodoView is declared by TodoModule. Mounting it outside that
// module's injector ancestry fails at mount — before construction:
//
//   TodoView is declared by TodoModule but mounted outside its
//   injector scope. Mount it under a route/module that includes
//   TodoModule.
//
// The view's error boundary catches it locally: the rest of the app
// keeps rendering, the failed subtree shows a retryable fallback.

Undeclared views are free agents — <HelloView/> works anywhere its imports resolve, Angular's standalone model before standalone existed. Declaring buys you the discipline: the framework guarantees the view's dependency graph is satisfiable by construction, and fails fast with a clear message when it isn't.

exports exists in the metadata for forward-compat but is not yet enforced — any component in the import closure is mountable in scope.

A feature module under a route

app.routes.ts
export const routes: Route[] = [
  { path: '/', component: HomeView },
  { path: '/todos', module: TodoModule },   // renders TodoView inside TodoModule's injector
  { path: '/user/:name', component: HelloView },
  { path: '*', component: NotFoundView },
];

Module routes are how feature scoping composes with the router — see Router.