Page Controllers
Without a controller, the Svelte page owns everything: fetching, state, error handling, lifecycle. That works for simple pages, but as behavior grows — forms, retries, subscriptions, multi-step workflows — logic leaks into handlers and the page becomes untestable without mounting the UI.
A page controller is a plain class that owns that behavior instead. It receives the load() result, exposes a public input API, and holds observable data. The page becomes a rendering layer: it reads data, calls input.*, and listens to optional events.
This makes two things possible. The page is simple enough that a bug is almost always a rendering issue. And the controller is testable without Svelte — resolve it from a fresh IoC scope, swap collaborators with fakes, call input.*, and assert data.
Wiring a Controller to a Route
Add x-controller beside x-view. The controller receives load() data through initialize(), then owns what happens next.
# src/ui/openapi/pages/user.yaml
modules:
user:
basePath: /user
paths:
/:
get:
summary: User
x-view: pages/user/main.svelte
x-controller: controllers/user.controller.svelte.ts
x-layout:
- layout/app_layout.svelteexport async function load({ context }) {
return { user: await getUser(context.user.id) };
}import type { PageController, RouteContext } from "@noego/forge/client";
import { Component, Inject, LoadAs } from "@noego/ioc";
export abstract class UserClient {
abstract rename(id: string, name: string): Promise<void>;
}
@Component({ scope: LoadAs.Scoped })
export default class UserController implements PageController {
data = $state({ name: "" });
constructor(@Inject(UserClient) private api: UserClient) {}
input = {
rename: async (name: string) => {
await this.api.rename(this.data.id, name);
this.data.name = name;
}
};
initialize(loadData, _route: RouteContext) {
Object.assign(this.data, loadData);
}
}With behavior in the controller, the page is just props and markup. That makes UI testing straightforward — pass in data and input as props, assert what renders, done. No service mocks, no async setup, no container.
<script lang="ts">
let { data, input } = $props();
</script>
<h1>{data.name}</h1>
<button onclick={() => input.rename("Ada")}>Rename</button>Controller Contract
- data
- Reactive state. The page reads it; tests assert it.
- input
- Public behavior API. The page calls it; tests call it too.
- events
- Optional fire-and-forget streams for toasts, animations, and other momentary signals.
- initialize
- Receives the load function result and route context before the page renders.
- destroy
- Optional cleanup for subscriptions, timers, streams, or other resources.
Test the Behavior Boundary
Fresh container, fake collaborators, call input.*, assert data. No Svelte, no module mocks.
import createContainer, { LoadAs } from "@noego/ioc";
import UserController, { UserClient } from "./user.controller.svelte";
class FakeUserClient extends UserClient {
renamed = "";
async rename(id: string, name: string) { this.renamed = name; }
}
it("renames through the controller", async () => {
const container = createContainer();
const fake = new FakeUserClient();
container.registerFunction(UserClient, () => fake, { loadAs: LoadAs.Singleton });
const ctrl = await container.instance(UserController);
ctrl.initialize({ id: "1", name: "Ada" }, routeContext);
await ctrl.input.rename("Grace");
expect(ctrl.data.name).toBe("Grace");
expect(fake.renamed).toBe("Grace");
});When to Add One
Use raw load props
- Read-only pages
- Static marketing content
- Simple detail pages where bugs are mostly rendering or copy
Use a controller
- Forms and saves
- Async pipelines or retries
- State shared across several controls
- Behavior that should be tested without rendering Svelte