Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-09-19
skip_specs: true
51 changes: 51 additions & 0 deletions openspec/changes/migrate-translatr-root-standalone/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Design

## Context

This is the fourth and final layer of the `apps/translatr` standalone migration (after `libs/translatr-components`, `apps/translatr/src/app/modules/shared`, `apps/translatr/src/app/modules/pages`). Everything under `apps/translatr/src/app/modules/pages/**` now routes through `*RoutingModule`s with no declarations `NgModule`s left. The only `standalone: false` components remaining in `apps/translatr` are `AppComponent` and `SidenavComponent`, plus `AppModule`, `SidenavModule`, and `SidenavTestingModule`.

## Decisions

### Use native NgRx/Router provider functions where they don't break `forFeature` elsewhere in the app

NgRx 22 and Angular 22 (both in this workspace) ship first-class standalone provider functions: `provideStore`, `provideEffects`, `provideRouterStore`, `provideStoreDevtools`, `provideRouter`, `provideAnimations`, `provideHttpClient`. These are the officially recommended replacement for `StoreModule.forRoot`/`EffectsModule.forRoot`/`StoreRouterConnectingModule.forRoot`/`StoreDevtoolsModule.instrument`/`RouterModule.forRoot`/`BrowserAnimationsModule`/`HttpClientModule` respectively.

**Correction found during implementation**: `provideStore()`/`provideEffects()` are *not* a safe drop-in for `StoreModule.forRoot()`/`EffectsModule.forRoot()` in this app, because every lazy page module still registers its feature state via the NgModule form (`StoreModule.forFeature()`/`EffectsModule.forFeature()`, unchanged and out of scope for this change). `StoreFeatureModule`/`EffectsFeatureModule`'s factories inject the `StoreRootModule`/`EffectsRootModule` marker classes, which only the NgModule `forRoot()` calls provide - `provideStore()`/`provideEffects()` don't provide them, so every lazy route threw `NG0201` at runtime (a failure the unit test suite, which mocks facades and never exercises real NgRx wiring end-to-end, did not catch - only a full e2e run did). Root store/effects registration therefore stays as `importProvidersFrom(StoreModule.forRoot(...))` / `importProvidersFrom(EffectsModule.forRoot([AppEffects]))`. `provideRouterStore` and `provideStoreDevtools` have no `forFeature` counterpart anywhere else in the app, so they're unaffected and stayed as their native functional forms, as did `provideRouter`/`provideAnimations`/`provideHttpClient` (no forFeature-equivalent coupling exists for those either).

**Why not `importProvidersFrom` for these**: `importProvidersFrom` exists specifically for NgModules that aren't easily rewritten as provider functions (third-party libraries, or modules with non-trivial internal setup). NgRx and the Router ship the provider-function equivalents precisely so apps don't need it for their own root wiring.

### Keep `TranslocoRootModule` and `TranslatrSdkModule` as NgModules, use `importProvidersFrom`

Both encapsulate multiple related `@Injectable()` classes and provider configs that aren't simple 1:1 provider-function swaps, and `TranslatrSdkModule` lives in a separate lib (`libs/translatr-sdk`) that's out of scope for this app-only change. `importProvidersFrom(TranslocoRootModule)` / `importProvidersFrom(TranslatrSdkModule)` is the standard, supported way to consume an NgModule's providers from a standalone bootstrap without touching the module itself.

### `AppComponent`'s own `imports` are near-empty

`AppModule` only ever declared `AppComponent` (`declarations: [AppComponent]`) — every other `NgModule` in its `imports` array (`MatToolbarModule`, `MatButtonModule`, `MatSnackBarModule`, `MatDialogModule`, `LayoutModule`, `FeatureFlagDirective`, `FeatureFlagClassDirective`, `FontAwesomeModule`, `SvgIconComponent`) existed only to make those directives/components available to `AppComponent`'s template. `AppComponent`'s actual template is the single line `<router-outlet></router-outlet>` (declared inline, no `.html` file), so none of them are used. Standalone `AppComponent` needs only `RouterModule` (for `router-outlet`) in its own `imports` — the rest were vestigial, matching the "drop unused pooled imports" pattern established throughout the earlier three changes in this migration (NgModules historically pooled imports across every component they declared, not just the one importing them).

### `SidenavComponent` conversion mirrors the earlier `libs`/`shared` pattern

`SidenavModule` is a thin declarations-wrapper around a single component (`SidenavComponent`), identical in shape to modules already converted in the first two changes of this migration. Convert it the same way: `SidenavComponent` becomes `standalone: true` with explicit `imports` derived from its template (tag/directive scan), `SidenavModule` is deleted, and its 8 consumers switch from importing `SidenavModule` to importing `SidenavComponent` directly. `SidenavTestingModule`'s `MockSidenavComponent` converts the same way group 8 of the pages change converted its testing doubles: standalone, file renamed to `mock-sidenav.component.ts`, `SidenavTestingModule` deleted, consumer specs updated to import the class directly.

### `main.ts` provider order and dev-only `StoreDevtoolsModule`

`AppModule`'s `!environment.production ? StoreDevtoolsModule.instrument() : []` becomes `provideStoreDevtools()` gated the same way (`...(!environment.production ? [provideStoreDevtools()] : [])`), preserving prod builds shipping without devtools instrumentation.

### Routes stay defined in `app-routing.module.ts`

`AppRoutingModule`'s `Routes` array does not need to move into `main.ts` — `provideRouter(routes)` accepts the same `Routes` array already exported there. `AppRoutingModule` itself becomes unnecessary once nothing needs its `NgModule` wrapper (its `RouterModule.forRoot(routes, {...})` config becomes `provideRouter(routes, withRouterConfig(...))`-equivalent options passed to `provideRouter`), so it is deleted and `routes` is exported directly from `app-routing.module.ts` (kept as a plain `.ts` file, not renamed, to avoid an unrelated file-move) or inlined into `main.ts` — decided during implementation based on which keeps `main.ts` most readable.

## Risks / Trade-offs

- **Provider-function feature parity**: `provideRouter`'s options (`withRouterConfig`, `withComponentInputBinding`, etc.) are a different shape than `RouterModule.forRoot`'s second argument (`{ enableTracing: environment.routerTracing }`). `enableTracing` maps to `withDebugTracing()` conditionally included, verified against the Router's own provider-function API rather than assumed.
- **Bootstrap error visibility**: `bootstrapApplication(...).catch(err => console.error(err))` (unchanged) is the only bootstrap-failure signal; no change in behavior from `bootstrapModule`, but worth a manual smoke check after the rewrite since a provider-wiring mistake here breaks the entire app at boot, unlike a single-route wiring mistake in the pages change.

## Migration Plan

1. Convert `SidenavComponent` + update its 8 consumers + convert `SidenavTestingModule` (mirrors the pages change's component-conversion pattern, done first since it has no bootstrap risk).
2. Convert `AppComponent` to standalone.
3. Rewrite `main.ts` with `bootstrapApplication` and the full provider list; delete `app.module.ts` and `app-routing.module.ts`'s `NgModule` wrapper (or the whole file, per the decision above).
4. Verify `nx test translatr`, `nx build translatr`, and a manual smoke test (login flow, a lazy route, NgRx devtools presence in dev) since this is the one change in the whole migration where a mistake can break the entire app rather than one route.

## Open Questions

None — this is a direct architectural continuation of the pattern already validated three times in this migration.
22 changes: 22 additions & 0 deletions openspec/changes/migrate-translatr-root-standalone/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Proposal

## Why

`apps/translatr` has one piece left on `standalone: false`: the root bootstrap (`app.module.ts`, `main.ts`, `AppComponent`, `SidenavComponent`) plus `SidenavModule`/`SidenavTestingModule`. This is the fourth and final layer for `apps/translatr` after `libs/translatr-components`, `apps/translatr/src/app/modules/shared`, and `apps/translatr/src/app/modules/pages` — converting it removes `AppModule` entirely and switches the app to `bootstrapApplication`, the standalone-native bootstrap Angular has recommended since v14 (and the only bootstrap path Angular intends to keep long-term).

## What Changes

- Convert `AppComponent` to `standalone: true`. Its own template is just `<router-outlet></router-outlet>`, so its own `imports` need only `RouterModule` — every other import on the current `AppModule` (`MatToolbarModule`, `MatButtonModule`, `MatSnackBarModule`, `MatDialogModule`, `LayoutModule`, `FeatureFlagDirective`, `FeatureFlagClassDirective`, `FontAwesomeModule`, `SvgIconComponent`) is vestigial for `AppComponent` specifically (it was only ever needed by other components that `AppModule` used to declare, and none of those are declared here — `AppModule` only ever declared `AppComponent` itself).
- Convert `SidenavComponent` to `standalone: true` with explicit `imports` derived from its template. Delete `SidenavModule`; update its 8 consumers (`user-page`, `project-page`, `dashboard-page`, `projects-page`, `users-page`, `registration-page`, `editor-page/editor`, plus their specs) to import `SidenavComponent` directly.
- Convert `SidenavTestingModule`'s `MockSidenavComponent` to `standalone: true`; rename the file to `mock-sidenav.component.ts`; update every consumer spec; delete `sidenav-testing.module.ts`.
- Delete `app.module.ts`. Rewrite `main.ts` to call `bootstrapApplication(AppComponent, { providers: [...] })`, replacing `AppModule`'s `imports`/`providers` with the standalone-native provider functions available in this codebase's Angular 22 / NgRx 22: `provideRouter` (from `AppRoutingModule`'s existing `Routes` array), `provideAnimations`, `provideHttpClient(withXhr(), withInterceptorsFromDi())`, `provideStore`, `provideEffects([AppEffects])`, `provideRouterStore({ routerState: RouterState.Minimal })`, `provideStoreDevtools()` (dev only), plus `provideSvgIcons([])` and the existing non-NgModule providers (`AppFacade`, `FeatureFlagFacade`, `WINDOW`, `ENDPOINT_URL`, `LOGIN_URL`, `NotificationService`, `httpInterceptorProviders`, `HotkeysService`) carried over unchanged. `TranslocoRootModule` and `TranslatrSdkModule` (both lib/app NgModules with non-trivial provider setups, out of scope to rewrite) are kept as-is and pulled in via `importProvidersFrom`.

## Capabilities

No spec-level behavior changes — bootstrap, routing, NgRx root state, and Transloco behavior stay identical, only the underlying module/bootstrap architecture changes. Internal architecture change only (`skip_specs: true` set in `.openspec.yaml`).

## Impact

- **Code**: `apps/translatr/src/main.ts`, `apps/translatr/src/app/app.module.ts` (deleted), `apps/translatr/src/app/app.component.ts` (+ spec), `apps/translatr/src/app/modules/nav/sidenav/**` (component, deleted module, testing), and the 8 page components that compose `<app-sidenav>` (+ their specs).
- **Providers**: everything `AppModule` provided (NgRx root store/effects/router-store/devtools, HTTP client + interceptors, Transloco root, the SDK module, `AppFacade`, feature-flag facade, hotkeys, SVG icons) moves into `main.ts`'s `bootstrapApplication` providers array, unchanged in substance.
- **Out of scope**: `apps/translatr-admin` entirely — a separate, later change, if pursued at all (not explicitly planned beyond this point).
27 changes: 27 additions & 0 deletions openspec/changes/migrate-translatr-root-standalone/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Tasks

## 1. SidenavComponent and its consumers

- [x] 1.1 Convert `SidenavComponent` to `standalone: true` with explicit `imports` derived from its template (systematic tag/`*directive` extraction; dropped vestigial `MatSidenavModule`/`MatToolbarModule`/`MatListModule`/`FeatureFlagClassDirective` - not used in its own template, `ngProjectAs="mat-toolbar-row"` is just projection metadata for the caller's content, not a directive SidenavComponent itself needs; kept the `{ provide: LanguageSwicher, useClass: AppFacade }` component-level provider, matching the deleted module's scope)
- [x] 1.2 Delete `sidenav.module.ts`; update its 8 consumers (`user-page.component.ts`, `project-page.component.ts`, `dashboard-page.component.ts`, `projects-page.component.ts`, `users-page.component.ts`, `registration-page.component.ts`, `editor-page/editor/editor.component.ts`, plus specs) to import `SidenavComponent` directly instead of `SidenavModule`
- [x] 1.3 Convert `SidenavTestingModule`'s `MockSidenavComponent` to `standalone: true`; renamed file to `mock-sidenav.component.ts`; updated every consumer spec to import the class directly; deleted `sidenav-testing.module.ts`; `sidenav.component.spec.ts` itself needed a `TestBed.overrideComponent` swap (real→mock children) since it previously used `declarations:` against a non-standalone component
- [x] 1.4 Verify `nx test translatr` and `nx build translatr` succeed (159/159, clean build, `tsc --noEmit` clean)

## 2. AppComponent

- [x] 2.1 Convert `AppComponent` to `standalone: true` with `imports: [RouterModule]` (its template is only `<router-outlet>`); update `app.component.spec.ts` (dropped `declarations: [AppComponent]` from the Spectator `createComponentFactory` config - a standalone component can't be declared)
- [x] 2.2 Verify `nx test translatr` succeeds (deferred the actual run to task 3.4, since `AppComponent` becoming standalone and `app.module.ts` declaring it are mutually exclusive - these two tasks are atomic together, not independently buildable)

## 3. Root bootstrap

- [x] 3.1 Rewrote `main.ts` to call `bootstrapApplication(AppComponent, { providers: [...] })`, replacing every `AppModule` import/provider with its standalone-native equivalent: `provideRouter(routes, ...(environment.routerTracing ? [withDebugTracing()] : []))`, `provideAnimations()`, `provideHttpClient(withXhr(), withInterceptorsFromDi())`, `provideStore({ app: appReducer, router: routerReducer }, {...})` (kept the `router: routerReducer` root-reducer-map entry - `provideRouterStore()` connects the router to the store but does not itself register the reducer, matching its own usage docs), `provideEffects([AppEffects])`, `provideRouterStore({ routerState: RouterState.Minimal })`, `provideStoreDevtools()` gated the same `!environment.production` way as before, `importProvidersFrom(TranslocoRootModule)`, `importProvidersFrom(TranslatrSdkModule)`, plus the existing non-NgModule providers (`AppFacade`, `FeatureFlagFacade`, `WINDOW`, `ENDPOINT_URL`, `LOGIN_URL`, `NotificationService`, `httpInterceptorProviders`, `HotkeysService`, `provideSvgIcons([])`) carried over unchanged
- [x] 3.2 Deleted `app.module.ts`
- [x] 3.3 `app-routing.module.ts` kept as a plain file (not renamed) exporting `routes: Routes` directly; deleted its `NgModule`/`RouterModule.forRoot` wrapper
- [x] 3.4 Verified `nx test translatr` (159/159), `tsc --noEmit` (clean), and `nx build translatr` (clean) succeed. A manual e2e run then caught a real bug the unit suite couldn't: `provideStore()`/`provideEffects()` (the functional forms) don't provide the `StoreRootModule`/`EffectsRootModule` marker tokens that `StoreModule.forFeature()`/`EffectsModule.forFeature()` (used throughout every lazy page module, unchanged/out of scope here) inject in their factories - mixing functional root providers with NgModule-form feature registration threw NG0201 on every lazy route. Fixed by keeping the root store/effects registration as `importProvidersFrom(StoreModule.forRoot(...))` / `importProvidersFrom(EffectsModule.forRoot([AppEffects]))` instead of `provideStore`/`provideEffects`, while `provideRouterStore` and `provideStoreDevtools` (which have no `forFeature` counterpart elsewhere in the app) stayed as their native functional forms. Full local e2e suite: 187/187 passing after the fix

## 4. Cleanup and verification

- [x] 4.1 Grep `apps/translatr` for any remaining reference to `AppModule` or `SidenavModule` and confirm zero matches (confirmed clean)
- [x] 4.2 Run `nx build translatr` and verify it succeeds with no TypeScript errors (clean)
- [x] 4.3 Run `nx test translatr` and verify all suites pass (159/159)
- [x] 4.4 Smoke-tested via the browser pane (main page renders with zero console errors beyond expected unauthenticated 401s, `/login` route resolves) and via the full local e2e suite (187/187 passing), which exercises every lazy route, guard, and NgRx feature state end to end - a stronger signal here than manual devtools inspection, since the one real bug in this change (task 3.4's NG0201) was caught by the e2e run, not by manual browsing
16 changes: 2 additions & 14 deletions ui/apps/translatr/src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { environment } from '../environments/environment';
import { Routes } from '@angular/router';

const routes: Routes = [
export const routes: Routes = [
{
path: 'login',
loadComponent: () => import('./modules/pages/login-page').then(m => m.LoginPageComponent)
Expand Down Expand Up @@ -71,13 +69,3 @@ const routes: Routes = [
)
}
];

@NgModule({
imports: [
RouterModule.forRoot(routes, {
enableTracing: environment.routerTracing
})
],
exports: [RouterModule]
})
export class AppRoutingModule {}
1 change: 0 additions & 1 deletion ui/apps/translatr/src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ describe('AppComponent', () => {
const createComponent = createComponentFactory({
component: AppComponent,
imports: [RouterTestingModule, TranslocoTestingModule.forRoot({ langs: {}, translocoConfig: { availableLangs: ['en'] } }), MatDialogModule],
declarations: [AppComponent],
providers: [
mockProvider(AppFacade, {
me$: mockObservable()
Expand Down
6 changes: 4 additions & 2 deletions ui/apps/translatr/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { AfterViewInit, Component, ChangeDetectionStrategy } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { RouterModule } from '@angular/router';
import { ThemeService } from '@dev/translatr-components';
import { HotkeysHelpComponent, HotkeysService } from '@ngneat/hotkeys';
import { TranslocoService } from '@jsverse/transloco';
import { filter } from 'rxjs/operators';
import { AppFacade } from './+state/app.facade';

@Component({
standalone: false,
standalone: true,
selector: 'app-root',
changeDetection: ChangeDetectionStrategy.Eager,
template: '<router-outlet></router-outlet>'
template: '<router-outlet></router-outlet>',
imports: [RouterModule]
})
export class AppComponent implements AfterViewInit {
constructor(
Expand Down
Loading
Loading