quarc/router/components/router-outlet/router-outlet.component.ts

332 lines
12 KiB
TypeScript

import { Component, IComponent, ComponentType, WebComponent, WebComponentFactory } from "../../../core";
import { Subject } from "../../../rxjs";
import { Router, RouterOutletRef, NavigationEvent } from "../../angular/router";
import { Route, ActivatedRoute } from "../../angular/types";
import { RouteMatcher } from "../../utils/route-matcher";
import "../../../core/global";
@Component({
selector: 'router-outlet',
style: "router-outlet{ display: contents; }",
template: '',
})
export class RouterOutlet implements RouterOutletRef {
public urlSegments: string[] = [];
public parentUrlSegments: string[] = [];
public parentRoutes: Route[] = [];
public activatedRoute?: ActivatedRoute;
public parentRouterOutlet?: RouterOutlet;
public parentRoute?: ActivatedRoute;
public readonly navigationChange$ = new Subject<NavigationEvent>();
private childOutlets: Set<RouterOutlet> = new Set();
private isRootOutlet = false;
constructor(
private router: Router,
public element: HTMLElement,
) {
this.initialize();
}
private async initialize() {
this.element.innerHTML = '';
const parentRouterOutlet = this.getParentRouterOutlet();
if (parentRouterOutlet) {
this.parentRouterOutlet = parentRouterOutlet;
this.parentUrlSegments = parentRouterOutlet.urlSegments;
parentRouterOutlet.registerChildOutlet(this);
if (!parentRouterOutlet.activatedRoute) {
throw Error('Parent ActivatedRoute not set!');
}
// Set parentRoute to be the same as parent's activatedRoute
this.parentRoute = parentRouterOutlet.activatedRoute;
this.parentRoutes = await this.loadRoutes(parentRouterOutlet.activatedRoute);
} else {
this.isRootOutlet = true;
this.router.registerRootOutlet(this);
this.parentUrlSegments = location.pathname.split('/').filter((segment) => segment.length > 0);
this.parentRoutes = this.router.config;
// Root outlet has no parent route
this.parentRoute = undefined;
}
const matchedRoutes = await this.getMatchedRoutes();
await this.updateContent(matchedRoutes);
}
public onNavigationChange(event: NavigationEvent): void {
this.handleNavigationChange(event);
}
private async handleNavigationChange(event: NavigationEvent): Promise<void> {
const urlWithoutQueryAndFragment = event.url.split('?')[0].split('#')[0];
const newUrlSegments = urlWithoutQueryAndFragment.split('/').filter((segment) => segment.length > 0);
const queryParams = this.parseQueryParams(event.url);
const fragment = this.parseFragment(event.url);
if (this.parentRouterOutlet) {
this.parentUrlSegments = this.parentRouterOutlet.urlSegments;
if (this.parentRouterOutlet.activatedRoute) {
// Update parentRoute to match parent's activatedRoute
this.parentRoute = this.parentRouterOutlet.activatedRoute;
this.parentRoutes = await this.loadRoutes(this.parentRouterOutlet.activatedRoute);
}
} else {
this.parentUrlSegments = newUrlSegments;
this.parentRoutes = this.router.config;
// Root outlet has no parent route
this.parentRoute = undefined;
}
const matchedRoutes = await this.getMatchedRoutes();
const newRoute = matchedRoutes[0];
const newParams = newRoute?.snapshot.params ?? {};
const componentChanged = this.hasComponentChanged(this.activatedRoute, newRoute);
if (componentChanged || !this.activatedRoute) {
await this.updateContent(matchedRoutes);
} else if (this.activatedRoute && newRoute) {
// IMPORTANT: Use newRoute's URL segments, not newUrlSegments
// newUrlSegments contains the full URL, but newRoute.url contains only the consumed segments
const routeUrlSegments = newRoute.url.getValue();
this.activatedRoute.updateSnapshot(
newRoute.path ?? '',
newParams,
queryParams,
fragment || null,
routeUrlSegments,
newRoute.routeConfig ?? undefined,
);
// IMPORTANT: Always update urlSegments for proper child outlet routing
this.urlSegments = this.calculateUrlSegments();
}
this.navigationChange$.next(event);
this.notifyChildOutlets(event);
}
private hasComponentChanged(current?: ActivatedRoute, next?: ActivatedRoute): boolean {
if (!current && !next) return false;
if (!current || !next) return true;
const currentComponent = current.component ?? current.loadComponent;
const nextComponent = next.component ?? next.loadComponent;
if (currentComponent !== nextComponent) return true;
const currentParentPath = this.getFullParentPath(current);
const nextParentPath = this.getFullParentPath(next);
return currentParentPath !== nextParentPath;
}
private getFullParentPath(route: ActivatedRoute): string {
const paths: string[] = [];
let current: ActivatedRoute | null | undefined = route.parent;
while (current) {
if (current.path) {
paths.unshift(current.path);
}
current = current.parent;
}
return paths.join('/');
}
private parseQueryParams(url: string): Record<string, string> {
const queryString = url.split('?')[1]?.split('#')[0] ?? '';
const params: Record<string, string> = {};
if (!queryString) return params;
for (const pair of queryString.split('&')) {
const [key, value] = pair.split('=');
if (key) {
params[decodeURIComponent(key)] = decodeURIComponent(value ?? '');
}
}
return params;
}
private parseFragment(url: string): string {
return url.split('#')[1] ?? '';
}
private areParamsEqual(
params1?: Record<string, string>,
params2?: Record<string, string>,
): boolean {
if (!params1 && !params2) return true;
if (!params1 || !params2) return false;
const keys1 = Object.keys(params1);
const keys2 = Object.keys(params2);
if (keys1.length !== keys2.length) return false;
return keys1.every(key => params1[key] === params2[key]);
}
private notifyChildOutlets(event: NavigationEvent): void {
for (const child of this.childOutlets) {
child.onNavigationChange(event);
}
}
public registerChildOutlet(outlet: RouterOutlet): void {
this.childOutlets.add(outlet);
}
public unregisterChildOutlet(outlet: RouterOutlet): void {
this.childOutlets.delete(outlet);
}
private async updateContent(matchedRoutes: ActivatedRoute[]): Promise<void> {
this.childOutlets.clear();
if (this.activatedRoute) {
this.router.unregisterActiveRoute(this.activatedRoute);
this.popActivatedRouteFromStack(this.activatedRoute);
this.activatedRoute = undefined;
}
if (matchedRoutes.length > 0) {
this.activatedRoute = matchedRoutes[0];
this.urlSegments = this.calculateUrlSegments();
this.router.registerActiveRoute(this.activatedRoute);
this.pushActivatedRouteToStack(this.activatedRoute);
} else {
this.urlSegments = this.parentUrlSegments;
}
await this.renderComponents(matchedRoutes);
}
private pushActivatedRouteToStack(route: ActivatedRoute): void {
window.__quarc.activatedRouteStack ??= [];
window.__quarc.activatedRouteStack.push(route);
}
private popActivatedRouteFromStack(route: ActivatedRoute): void {
if (!window.__quarc.activatedRouteStack) return;
const index = window.__quarc.activatedRouteStack.indexOf(route);
if (index !== -1) {
window.__quarc.activatedRouteStack.splice(index, 1);
}
}
private calculateUrlSegments(): string[] {
if (!this.activatedRoute?.path) {
return this.parentUrlSegments;
}
// Use actual URL segments from activated route, not path segments
const routeUrlSegments = this.activatedRoute.url.getValue();
const consumedSegments = routeUrlSegments.length;
const remainingSegments = this.parentUrlSegments.slice(consumedSegments);
return remainingSegments;
}
private async loadRoutes(route: ActivatedRoute): Promise<Route[]> {
let routes: Route[] = [];
if (route.children) {
routes = route.children as Route[];
} else if (route.loadChildren) {
routes = await route.loadChildren();
}
for (const r of routes) {
r.parent = route;
}
return routes;
}
private getParentRouterOutlet(): RouterOutlet | null {
let parent = this.element.parentElement;
while (parent) {
if (parent.tagName.toLowerCase() === 'router-outlet') {
return (parent as WebComponent).componentInstance as IComponent as RouterOutlet;
}
parent = parent.parentElement;
}
return null;
}
public async getMatchedRoutes(): Promise<ActivatedRoute[]> {
const result = await RouteMatcher.findMatchingRouteAsync(
this.parentRoutes,
this.parentUrlSegments,
0,
this.parentRouterOutlet?.activatedRoute ?? null,
{},
{},
);
if (result) {
return [result.route];
}
return [];
}
private async renderComponents(matchedRoutes: ActivatedRoute[]): Promise<void> {
const tags: string[] = [];
for (const route of matchedRoutes) {
const selector = await this.resolveComponentSelector(route);
if (selector) {
tags.push(`<${selector}></${selector}>`);
}
}
this.element.innerHTML = tags.join('');
}
private async resolveComponentSelector(route: ActivatedRoute): Promise<string | null> {
if (typeof route.component === 'string') {
return route.component;
}
if (typeof route.component === 'function' && !this.isComponentType(route.component)) {
const selector = await (route.component as () => Promise<string>)();
return selector;
}
let componentType: ComponentType<IComponent> | undefined;
if (route.component && this.isComponentType(route.component)) {
componentType = route.component as ComponentType<IComponent>;
} else if (route.loadComponent) {
componentType = await route.loadComponent() as ComponentType<IComponent>;
}
if (componentType) {
WebComponentFactory.registerWithDependencies(componentType);
return componentType._quarcComponent[0].selector;
}
return null;
}
private isComponentType(component: unknown): component is ComponentType<IComponent> {
return typeof component === 'function' && '_quarcComponent' in component;
}
public destroy(): void {
if (this.activatedRoute) {
this.router.unregisterActiveRoute(this.activatedRoute);
this.popActivatedRouteFromStack(this.activatedRoute);
}
if (this.isRootOutlet) {
this.router.unregisterRootOutlet(this);
} else if (this.parentRouterOutlet) {
this.parentRouterOutlet.unregisterChildOutlet(this);
}
this.navigationChange$.complete();
this.childOutlets.clear();
}
}