PR 10830: standalone routes review

PR 10830 moves the 15 one-component public pages (about, pricing, privacy-policy, terms-condition, contact-us, contact-v1, help-faq, calendar-connect, shopify-connect, imported-opt-out, unsubscribe-newsletter, unsubscribe-instruction, not-found, shortend-url, access-user-account) from loadChildren to loadComponent, deletes their 15 module files, and removes the unrouted advertise and comparison modules. One commit, 42 files, +154 / -3016. I read every changed file, built base and head, and diffed the server output for 22 URLs.

Verdict

Merge it. Of the three PRs in this series, 15 pages at once is the one I expected to find something in. I did not. Base and head both build to 12.30 MB, ESLint has no errors, and once I ignore chunk hashes the server sends the same bytes for every URL I tried. The one change that goes beyond mechanical conversion, adding RouterLink, fixes a bug the compiler would have let through. Everything I have to say below is about code this PR touches but did not write.

reviewed 2026-09-23 base ee0c53c5b2 head aaa15ec916 Angular 21.2.23

What changed

Every one of the 15 modules declared a single component and wrapped it in a one-entry RouterModule.forChild with an empty path. That is a lot of ceremony for a component and a route. The PR removes the middle layer. The app route points at the component with loadComponent, and the component's imports array lists what its template uses. Six of those inline routes carried a title and description; the PR moves the strings into src/app/constants/public-page-route-data.ts and attaches them to the app routes. link-in-bio-fan-preview.routes.ts gets the same treatment for its wildcard. The PR also deletes the advertise and comparison directories under modules/public, 2,564 lines that no route loaded.

Findings

correct

The RouterLink import fixes a silent break

Five templates (about, pricing, privacy, terms, help-faq) use routerLink for the Home breadcrumb and a couple of in-page links. I read SharedModule to see where that directive came from. It imports RouterModule twice and exports it zero times. So the only thing that ever put routerLink in scope for these pages was the RouterModule.forChild call inside the module this PR deletes. Without the new import, Angular would treat routerLink on an <a> as an ordinary attribute. The build passes, the page renders, the link has no href. Nothing red anywhere. This is the kind of regression that reaches production, and the PR caught it. In the head build every Home breadcrumb renders as <a routerlink="/" href="/">.

correct

The imports cover the templates

I grepped each template for what it uses and checked it against the component's imports plus SharedModule's exports. ng-select in contact-us comes from NgSelectModule. app-dynamic-form-builder in contact-v1 comes from DynamicFormBuilderModule. help-faq imports FaqComponent directly for app-faq. [formGroup] and formControlName come from ReactiveFormsModule, which SharedModule exports. Privacy and terms skip CommonModule. Their templates have no structural directives or pipes, so nothing is missing. The AOT build agrees, and this time there is no CUSTOM_ELEMENTS_SCHEMA anywhere to hide a typo.

correct

The dropped imports were unused

help-faq loses CarouselModule. There is no owl-carousel in its template, and SharedModule exports the module anyway. contact-v1 loses NgSelectModule. The dynamic form builder renders its form, so there is no ng-select to match. imported-opt-out loses AlertModalComponent. No app-alert-modal in the template, and again SharedModule exports it. pricing, calendar-connect, and shopify-connect lose CUSTOM_ELEMENTS_SCHEMA. The build passes without it, which means every element in those templates resolves to something real. I would guess someone added the schema years ago to silence an error and nobody removed it once the error went away.

correct

advertise and comparison were dead

At the base commit, nothing under src, scripts, or the root .mjs files referenced either directory, and no route loaded either module. Only the deleted component imported the 1,097-line comparison/constant.ts. The live pages are advertisement-home and comparison-page, separate directories that this PR does not touch. Two full page implementations sat in the tree that no build ever compiled and no user could ever reach. Deleting them is the part of this PR I like most.

correct

The 404 path still sets a 404

NotFoundComponent injects RESPONSE and calls status(404) on the server. That works the same under loadComponent. /not-found, a made-up deep URL, and the link-in-bio wildcard all returned 404 with app-not-found in the body, on both base and head.

correct

No stale references

I searched the head for the 17 deleted class names and all deleted file paths. Nothing left. No spec files existed for any of them.

suggest

The moved route data is dead, and now it ships in main

The PR says nothing reads data.title or data.description, and that is true. CommonService.getTitle and getDescription have no callers outside their own recursion. Every one of these pages sets its own title and meta in ngOnInit through seoService.setPublicPageSeo(), reading from SEO_TITLE_DESC. The route-level title on the two terms routes loses the same race. The server output says Terms of Service - TopFan, not TopFan - Terms & Conditions, because SeoService writes last. So public-page-route-data.ts preserves strings that never reach a user, and moving them out of lazy chunks and onto the app routes puts them in the initial bundle. I measured main at 285,810 bytes on base and 287,523 on head, +1,713 raw. That number is small. The file still has no reason to exist. I would delete it along with the data and title properties, in this PR or the next. If someone wants route-level SEO later, SEO_TITLE_DESC already holds the real strings, and these are stale copies of them.

pre-existing

AboutComponent has the wrong selector

Its decorator says selector: 'app-privacy-policy', so /about renders an <app-privacy-policy> element. Someone copied the privacy component to start the about page and never renamed the selector. This predates the PR, but the PR edits that exact decorator, and the fix is one word. It also matters a little more now. Two standalone components sharing a selector is harmless until someone imports both into the same template, and standalone makes that kind of import a lot more likely.

pre-existing

Dead ViewChild in imported-opt-out

The component keeps @ViewChild('emailModal') emailModal: AlertModalComponent, but there is no #emailModal in the template. The field is always undefined. The AlertModalComponent import at the top of the file exists only to give it a type. Delete both lines.

note

Redundant and oversized imports

contact-v1 imports DynamicFormBuilderModule directly even though SharedModule already exports it. Harmless. The bigger version of the same thing is that 13 of the 15 components import SharedModule, about a hundred exports, to reach app-button, app-loader, the forms modules, and translate. I said this about 10826 and 10829 and the answer has not changed. Leave it until more of the app is standalone, then narrow each import list to what the template names.

note

Lint is clean, with the usual warnings

ESLint on the 18 changed TypeScript files reports 0 errors and 4 warnings. All four are use-lifecycle-interface on methods that existed before this PR, ngOnDestroy in both contact components and ngOnInit plus ngOnDestroy in shopify-connect.

What I ran

I made two detached worktrees, one at the base ee0c53c5b2 and one at the head aaa15ec916, sharing one node_modules, and ran ng build --configuration=qa --prerender=false in each. Both passed in about a minute with the same budget and non-ESM warnings the project always prints.

MeasureBaseHeadChange
Initial total12.30 MB12.30 MB0
main raw bytes285,810287,523+1,713
All browser JS raw bytes23,619,92423,616,571−3,353
Browser output files1291290

Then I started server.mjs from each build on its own port and fetched 22 URLs from both. I stripped chunk hashes, SRI hashes, and Angular's per-component style ids before diffing. After that, the only difference left in any page was one hydration component id inside ng-state, and that id changes whenever a component's metadata changes, so it is expected here. Status codes and rendered components:

  • /about200app-privacy-policy (see above), Home href="/"
  • /pricing200app-pricing, Home href="/"
  • /privacy-policy200app-privacy-policy, Home href="/"
  • /terms-conditions200app-terms-condition, Home href="/"
  • /terms-of-service200app-terms-condition, Home href="/"
  • /contact-us200app-contact-us with reactive form
  • /request-demo200app-contact-v1, dynamic form rendered
  • /schedule-a-demo200app-contact-v1
  • /help-faq200app-help-faq, Home href="/"
  • /calendar-connect200app-calendar-connect
  • /shopify-connect200app-shopify-connect
  • /imported/opt-out200app-imported-opt-out
  • /unsubscribe/newsletter200app-unsubscribe-newsletter
  • /unsubscribe-instruction200app-unsubscribe-instruction
  • /share/abc123200app-shortend-url, empty template
  • /access-user200app-access-user-account, empty template
  • /not-found404app-not-found
  • /this-page-does-not-exist-xyz/a/b404app-not-found via wildcard
  • /bio/someuser/extra/segment404app-not-found via link-in-bio wildcard
  • /About, /PRICING404same as base; case folding only covers marketing routes
  • /about/extra-segment200app-sub-page-view, same as base

Not run: a browser, form submissions, the OAuth and email callbacks with real query strings, prerender, white-label builds.

QA after merge

  1. Click the Home breadcrumb on about, pricing, privacy, terms, and help-faq. It should navigate to / without a full reload. On help-faq, also click "Still have Questions?".
  2. On /contact-us, open both dropdowns, then submit the form with and without errors. On /request-demo, check the dynamic form renders every field and submits.
  3. Open a made-up URL and confirm the not-found page with a 404 in the network tab.
  4. Complete a Google Calendar connect and a Shopify connect from the creator settings and confirm each callback page lands on the connected state.
  5. Follow a real imported-fan opt-out link and a newsletter unsubscribe link from an email.