diff --git a/package-lock.json b/package-lock.json index 8f02caa..5a1221e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4956,10 +4956,9 @@ "license": "MIT" }, "node_modules/@tanstack/history": { - "version": "1.114.22", - "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.114.22.tgz", - "integrity": "sha512-CNwKraj/Xa8H7DUyzrFBQC3wL96JzIxT4i9CW0hxqFNNmLDyUcMJr8264iqqfxC0u1lFSG96URad08T2Qhadpw==", - "license": "MIT", + "version": "1.114.29", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.114.29.tgz", + "integrity": "sha512-OTRMhwidScQSA0xsc5OCtm3K/oAChe47jy1e4OY3VpXUnKrI7C8iwfQ9XDRdpdsRASH2xi6P5I0+7ksFBehaQQ==", "engines": { "node": ">=12" }, @@ -5050,12 +5049,11 @@ } }, "node_modules/@tanstack/router-core": { - "version": "1.114.25", - "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.114.25.tgz", - "integrity": "sha512-OyLCfs7r+0LEhmQGAdyJxfO+pqGBITlr4aUN0rdhXqDTpqBn0tyrO6Tu+U9B3LQF9Xnux3KqbjzRopTY9QZBog==", - "license": "MIT", + "version": "1.114.29", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.114.29.tgz", + "integrity": "sha512-CLf7HhHNiTz3cW1uB+DGyxiVwkEY+2YO36MXjtTLLtt5tQozDe3Kp7Dtb6B7aacMpvnLLwWtfmgptOce4Y8TQQ==", "dependencies": { - "@tanstack/history": "1.114.22", + "@tanstack/history": "1.114.29", "@tanstack/store": "^0.7.0", "tiny-invariant": "^1.3.3" }, @@ -5068,10 +5066,9 @@ } }, "node_modules/@tanstack/router-devtools-core": { - "version": "1.114.25", - "resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.114.25.tgz", - "integrity": "sha512-3KFAAytAV6nWcXLTe3nWNaiRPV8AyM3jx5aa2UpB+RLDgDbO+GkVMnv3C7fnGCM6j2nw2/1boAvTvHcoKKO5UA==", - "license": "MIT", + "version": "1.114.29", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.114.29.tgz", + "integrity": "sha512-QDtVcUGalFi9e5lFABOchGQI7gyxnk2z8cUET+DpZF8LWS0eJTv5+QWvLc7F7UHhz1MeFPbmIR2vQ4PDspWRfA==", "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" @@ -5084,7 +5081,7 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/router-core": "^1.114.25", + "@tanstack/router-core": "^1.114.29", "csstype": "^3.0.10", "solid-js": ">=1.9.5", "tiny-invariant": "^1.3.3" diff --git a/projects/router/src/lib/create-router.ts b/projects/router/src/lib/create-router.ts index 4b1abec..950d22e 100644 --- a/projects/router/src/lib/create-router.ts +++ b/projects/router/src/lib/create-router.ts @@ -1,22 +1,33 @@ +import { isPlatformBrowser } from '@angular/common'; import { + ApplicationRef, + computed, + effect, EnvironmentInjector, inject, + linkedSignal, + PLATFORM_ID, Provider, signal, Type, } from '@angular/core'; import type { RouterHistory } from '@tanstack/history'; -import type { +import { AnyRoute, CreateRouterFn, + getLocationChangeInfo, RouterConstructorOptions, + RouterCore, TrailingSlashOption, + trimPathRight, } from '@tanstack/router-core'; -import { RouterCore } from '@tanstack/router-core'; declare module '@tanstack/router-core' { export interface UpdatableRouteOptionsExtensions { component?: () => Type; + notFoundComponent?: () => Type; + pendingComponent?: () => Type; + errorComponent?: () => Type; providers?: Provider[]; } export interface RouterOptionsExtensions { @@ -34,14 +45,14 @@ declare module '@tanstack/router-core' { * @link [API Docs](https://tanstack.com/router/latest/docs/framework/solid/api/router/RouterOptionsType#defaulterrorcomponent-property) * @link [Guide](https://tanstack.com/router/latest/docs/framework/solid/guide/data-loading#handling-errors-with-routeoptionserrorcomponent) */ - // defaultErrorComponent?: ErrorRouteComponent + defaultErrorComponent?: () => Type; /** * The default `pendingComponent` a route should use if no pending component is provided. * * @link [API Docs](https://tanstack.com/router/latest/docs/framework/solid/api/router/RouterOptionsType#defaultpendingcomponent-property) * @link [Guide](https://tanstack.com/router/latest/docs/framework/solid/guide/data-loading#showing-a-pending-component) */ - // defaultPendingComponent?: RouteComponent + defaultPendingComponent?: () => Type; /** * The default `notFoundComponent` a route should use if no notFound component is provided. * @@ -49,24 +60,7 @@ declare module '@tanstack/router-core' { * @link [API Docs](https://tanstack.com/router/latest/docs/framework/solid/api/router/RouterOptionsType#defaultnotfoundcomponent-property) * @link [Guide](https://tanstack.com/router/latest/docs/framework/solid/guide/not-found-errors#default-router-wide-not-found-handling) */ - // defaultNotFoundComponent?: NotFoundRouteComponent - /** - * A component that will be used to wrap the entire router. - * - * This is useful for providing a context to the entire router. - * - * @link [API Docs](https://tanstack.com/router/latest/docs/framework/solid/api/router/RouterOptionsType#wrap-property) - */ - // Wrap?: (props: { children: any }) => Type - /** - * A component that will be used to wrap the inner contents of the router. - * - * This is useful for providing a context to the inner contents of the router where you also need access to the router context and hooks. - * - * @link [API Docs](https://tanstack.com/router/latest/docs/framework/solid/api/router/RouterOptionsType#innerwrap-property) - */ - // InnerWrap?: (props: { children: any }) => Type - + defaultNotFoundComponent?: () => Type; /** * The default `onCatch` handler for errors caught by the Router ErrorBoundary * @@ -94,8 +88,39 @@ export class NgRouter< TRouterHistory, TDehydrated > { - readonly routerState = signal(this.state); - readonly injector = inject(EnvironmentInjector); + injector = inject(EnvironmentInjector); + private platformId = inject(PLATFORM_ID); + private appRef = inject(ApplicationRef); + + historyState = linkedSignal(() => this.history); + routerState = linkedSignal(() => this.state); + isTransitioning = signal(false); + + private matches = computed(() => this.routerState().matches); + + hasPendingMatches = computed(() => + this.matches().some((match) => match.status === 'pending') + ); + + isLoading = computed(() => this.routerState().isLoading); + prevIsLoading = linkedSignal({ + source: this.isLoading, + computation: (src, prev) => prev?.source ?? src, + }); + + isAnyPending = computed( + () => this.isLoading() || this.isTransitioning() || this.hasPendingMatches() + ); + prevIsAnyPending = linkedSignal({ + source: this.isAnyPending, + computation: (src, prev) => prev?.source ?? src, + }); + + isPagePending = computed(() => this.isLoading() || this.hasPendingMatches()); + prevIsPagePending = linkedSignal({ + source: this.isPagePending, + computation: (src, prev) => prev?.source ?? src, + }); constructor( options: RouterConstructorOptions< @@ -108,9 +133,93 @@ export class NgRouter< ) { super(options); - void this.load({ sync: true }); - this.__store.subscribe(() => this.routerState.set(this.state)); - this.history.subscribe(() => this.load()); + if (isPlatformBrowser(this.platformId)) { + this.startTransition = (fn: () => void) => { + this.isTransitioning.set(true); + // NOTE: not quite the same as `React.startTransition` but close enough + queueMicrotask(() => { + fn(); + this.isTransitioning.set(false); + this.appRef.tick(); + }); + }; + } + + effect((onCleanup) => { + const unsub = this.__store.subscribe(() => { + this.routerState.set(this.state); + }); + onCleanup(() => unsub()); + }); + + effect((onCleanup) => { + const unsub = this.history.subscribe(() => { + this.historyState.set(this.history); + void this.load(); + }); + + // track history state + this.historyState(); + const nextLocation = this.buildLocation({ + to: this.latestLocation.pathname, + search: true, + params: true, + hash: true, + state: true, + _includeValidateSearch: true, + }); + + if ( + trimPathRight(this.latestLocation.href) !== + trimPathRight(nextLocation.href) + ) { + void this.commitLocation({ ...nextLocation, replace: true }); + } + + onCleanup(() => unsub()); + }); + + effect(() => { + const [prevIsLoading, isLoading] = [ + this.prevIsLoading(), + this.isLoading(), + ]; + if (prevIsLoading && !isLoading) { + this.emit({ + type: 'onLoad', // When the new URL has committed, when the new matches have been loaded into state.matches + ...getLocationChangeInfo(this.state), + }); + } + }); + + effect(() => { + const [prevIsPagePending, isPagePending] = [ + this.prevIsPagePending(), + this.isPagePending(), + ]; + if (prevIsPagePending && !isPagePending) { + this.emit({ + type: 'onBeforeRouteMount', + ...getLocationChangeInfo(this.state), + }); + } + }); + + effect(() => { + const [prevIsAnyPending, isAnyPending] = [ + this.prevIsAnyPending(), + this.isAnyPending(), + ]; + // The router was pending and now it's not + if (prevIsAnyPending && !isAnyPending) { + this.emit({ type: 'onResolved', ...getLocationChangeInfo(this.state) }); + this.__store.setState((s) => ({ + ...s, + status: 'idle', + resolvedLocation: s.location, + })); + } + }); } getRouteById(routeId: string) { diff --git a/projects/router/src/lib/not-found.ts b/projects/router/src/lib/not-found.ts new file mode 100644 index 0000000..2777533 --- /dev/null +++ b/projects/router/src/lib/not-found.ts @@ -0,0 +1,11 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'default-not-found', + template: ` +

Page not found

+ `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { style: 'display: contents;' }, +}) +export class DefaultNotFound {} diff --git a/projects/router/src/lib/outlet.ts b/projects/router/src/lib/outlet.ts index 3396336..b6837ba 100644 --- a/projects/router/src/lib/outlet.ts +++ b/projects/router/src/lib/outlet.ts @@ -1,90 +1,168 @@ import { ComponentRef, + computed, DestroyRef, Directive, effect, + EnvironmentInjector, inject, + Injector, Type, ViewContainerRef, } from '@angular/core'; -import { - AnyRoute, - getLocationChangeInfo, - RouterState, -} from '@tanstack/router-core'; +import { AnyRoute, RouterState } from '@tanstack/router-core'; +import invariant from 'tiny-invariant'; -import { context } from './context'; +import { DefaultNotFound } from './not-found'; import { injectRouteContext, injectRouter } from './router'; +import { injectRouterContext } from './router-context'; @Directive({ selector: 'outlet', exportAs: 'outlet' }) export class Outlet { - private context? = injectRouteContext(); private router = injectRouter(); + private routerContext = injectRouterContext(); + private routeContext = injectRouteContext(); private vcr = inject(ViewContainerRef); + private injector = inject(Injector); + private environmentInjector = inject(EnvironmentInjector); - private cmp: Type | null = null; - private cmpRef: ComponentRef | null = null; + private fullMatches = computed(() => this.router.routerState().matches); + private matchId = computed( + () => this.routeContext?.id || this.fullMatches()[0]?.id + ); - constructor() { - effect(() => { - const routerState = this.router.routerState(); - const hasMatches = routerState.matches.length > 0; + /** + * NOTE: we slice off the first match because we let Angular renders the root route + */ + private matches = computed(() => this.fullMatches().slice(1)); + private pendingMatches = computed(() => + this.router.routerState().pendingMatches?.slice(1) + ); + private match = computed(() => { + const matches = this.matches(); + const index = matches.findIndex((d) => d.id === this.routeContext?.id); + return matches[index + 1]; + }); + private pendingMatch = computed(() => { + const pendingMatches = this.pendingMatches(); + if (!pendingMatches) return null; + const index = pendingMatches.findIndex( + (d) => d.id === this.routeContext?.id + ); + return pendingMatches[index + 1]; + }); + private routeMatch = computed(() => this.pendingMatch() || this.match()); - if (!hasMatches) { - return; - } + private route = computed(() => { + const match = this.routeMatch(); + if (!match) return null; + return this.router.routesById[match.routeId]; + }); + private parentGlobalNotFound = computed(() => { + const matchId = this.matchId(); + if (!matchId) return null; - const matchesToRender = this.getMatch(routerState.matches.slice(1)); + const match = this.match(); + if (match) return null; - if (!matchesToRender) { - return; - } + const matches = this.fullMatches(); + const parentMatch = matches.find((d) => d.id === matchId); + invariant( + parentMatch, + `Could not find parent match for matchId "${this.routeContext?.id}"` + ); + const route = this.router.routesById[parentMatch.routeId]; + return { globalNotFound: parentMatch.globalNotFound, route }; + }); - const route: AnyRoute = this.router.getRouteById(matchesToRender.routeId); - const currentCmp = ( - route && route.options.component ? route.options.component() : undefined - ) as Type; + private cmp?: Type; + private cmpRef?: ComponentRef; - if (!currentCmp) { - return; + constructor() { + effect(() => { + const parentGlobalNotFound = this.parentGlobalNotFound(); + if (parentGlobalNotFound && parentGlobalNotFound.globalNotFound) { + return this.renderRouteNotFound(parentGlobalNotFound.route); } - const injector = context.getContext( - matchesToRender.routeId, - matchesToRender, + const match = this.routeMatch(); + if (!match) return; + + const route = this.route(); + if (!route) return; + + const injector = this.routerContext.getContext( + match.routeId, + match, this.vcr.injector ); - const environmentInjector = context.getEnvContext( - matchesToRender.routeId, + const environmentInjector = this.routerContext.getEnvContext( + match.routeId, route.options.providers || [], this.router.injector ); - if (this.cmp !== currentCmp) { - this.vcr.clear(); - this.cmpRef = this.vcr.createComponent(currentCmp, { - injector, - environmentInjector, - }); - this.cmp = currentCmp; - this.router.emit({ - type: 'onResolved', - ...getLocationChangeInfo(routerState), - }); - } else { - this.cmpRef?.changeDetectorRef.markForCheck(); - } + this.renderMatch(route, match, injector, environmentInjector); }); inject(DestroyRef).onDestroy(() => { this.vcr.clear(); - this.cmp = null; - this.cmpRef = null; + this.cmpRef = undefined; + this.cmp = undefined; + }); + } + + private renderRouteNotFound(route: AnyRoute) { + this.vcr.clear(); + + this.cmp = + route.options.notFoundComponent?.() || + this.router.options.defaultNotFoundComponent?.() || + DefaultNotFound; + + if (!this.cmp) return; + + this.cmpRef = this.vcr.createComponent(this.cmp, { + injector: this.injector, + environmentInjector: this.environmentInjector, }); } - getMatch(matches: RouterState['matches']) { - const idx = matches.findIndex((match) => match.id === this.context?.id); - return matches[idx + 1]; + private renderMatch( + route: AnyRoute, + match: RouterState['matches'][number], + injector: Injector, + environmentInjector: EnvironmentInjector + ) { + let cmp: Type | undefined = undefined; + + switch (match.status) { + case 'pending': { + cmp = + route.options.pendingComponent?.() || + this.router.options.defaultPendingComponent?.() || + undefined; + break; + } + + case 'success': + cmp = route.options.component?.(); + break; + } + + if (!cmp) return; + + if (this.cmp !== cmp) { + this.vcr.clear(); + + this.cmpRef = this.vcr.createComponent(cmp, { + injector, + environmentInjector, + }); + + this.cmp = cmp; + } else { + this.cmpRef?.changeDetectorRef.markForCheck(); + } } } diff --git a/projects/router/src/lib/route-context.ts b/projects/router/src/lib/route-context.ts deleted file mode 100644 index a6e70cc..0000000 --- a/projects/router/src/lib/route-context.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { runInInjectionContext } from '@angular/core'; -import { LoaderFnContext } from '@tanstack/router-core'; - -export function runLoaderInRouteContext(cb: Function): any { - return ({ context, route }: LoaderFnContext) => { - const routeInjector = (context as any).getRouteInjector(route.id); - - return runInInjectionContext(routeInjector, cb()); - }; -} diff --git a/projects/router/src/lib/route.ts b/projects/router/src/lib/route.ts index 862f334..0a96f9f 100644 --- a/projects/router/src/lib/route.ts +++ b/projects/router/src/lib/route.ts @@ -21,6 +21,7 @@ import { ResolveParams, ResolveUseLoaderData, ResolveUseParams, + ResolveUseSearch, RootRouteOptions, RouteById, RouteConstraints, @@ -58,6 +59,35 @@ function loaderData({ }); } +function routeSearch({ + id, + injector, +}: { id?: string; injector?: Injector } = {}) { + !injector && assertInInjectionContext(routeSearch); + + if (!injector) { + injector = inject(Injector); + } + + return runInInjectionContext(injector, () => { + const router = injectRouter(); + const routeId = id ?? injectRouteContext()?.id; + + if (!routeId) { + throw new Error('routeId is required'); + } + + return computed(() => { + const routerState = router.routerState(); + const route = routerState.matches.find( + (match) => match.routeId === routeId + ); + + return (route && route.search) || ({} as Record); + }); + }); +} + function routeParams({ id, injector, @@ -99,6 +129,7 @@ export function routeApi< }): BaseRouteApi & { loaderData: () => Signal>; routeParams: () => Signal>; + routeSearch: () => Signal>; } { !injector && assertInInjectionContext(routeApi); @@ -108,6 +139,7 @@ export function routeApi< const _loaderData = loaderData.bind(null, { id, injector }); const _routeParams = routeParams.bind(null, { id, injector }); + const _routeSearch = routeSearch.bind(null, { id, injector }); return runInInjectionContext(injector, () => { const routeApi = new BaseRouteApi({ id }); @@ -115,6 +147,7 @@ export function routeApi< return Object.assign(routeApi, { loaderData: _loaderData, routeParams: _routeParams, + routeSearch: _routeSearch, }); }); } @@ -194,6 +227,16 @@ class Route< > { return routeParams({ id: this.id, injector }); } + + routeSearch< + TRouter extends AnyRouter = RegisteredRouter, + const TFrom extends string | undefined = undefined, + TStrict extends boolean = false, + >({ injector }: { injector?: Injector } = {}): Signal< + ResolveUseSearch + > { + return routeSearch({ id: this.id, injector }); + } } export function createRoute< @@ -296,6 +339,14 @@ export class LazyRoute { > { return routeParams({ id: this.options.id, injector }); } + + routeSearch({ + injector, + }: { injector?: Injector } = {}): Signal< + ResolveUseSearch + > { + return routeSearch({ id: this.options.id, injector }); + } } export function createLazyRoute< @@ -463,8 +514,8 @@ export class NotFoundRoute< function runFnInInjectionContext any>(fn: TFn) { const originalFn = fn; return (...args: Parameters) => { - const { context, route } = args[0]; - const routeInjector = context.getRouteInjector(route.id); + const { context, location } = args[0]; + const routeInjector = context.getRouteInjector(location.href); return runInInjectionContext(routeInjector, originalFn.bind(null, ...args)); }; } diff --git a/projects/router/src/lib/context.ts b/projects/router/src/lib/router-context.ts similarity index 72% rename from projects/router/src/lib/context.ts rename to projects/router/src/lib/router-context.ts index c8eaf62..dbf0a1a 100644 --- a/projects/router/src/lib/context.ts +++ b/projects/router/src/lib/router-context.ts @@ -1,12 +1,14 @@ import { createEnvironmentInjector, EnvironmentInjector, + inject, + InjectionToken, Injector, Provider, } from '@angular/core'; import { ROUTE_CONTEXT } from './router'; -export class ContextService { +class RouterContext { private readonly injectors = new Map(); private readonly envInjectors = new Map(); @@ -14,7 +16,7 @@ export class ContextService { this.injectors.set(routeId, injector); } - getContext(routeId: string, context: any, parent: Injector) { + getContext(routeId: string, context: Record, parent: Injector) { const injector = this.injectors.get(routeId); if (injector) { @@ -44,12 +46,16 @@ export class ContextService { return newInjector; } - private getInjector(routeId: string, context: any, parentInjector: Injector) { + private getInjector( + routeId: string, + context: Record, + parentInjector: Injector + ) { return Injector.create({ providers: [ { provide: ROUTE_CONTEXT, - useValue: { id: context.routeId, params: context.params }, + useValue: { id: context['routeId'], params: context['params'] }, }, ], parent: parentInjector, @@ -66,4 +72,11 @@ export class ContextService { } } -export const context = new ContextService(); +export const ROUTER_CONTEXT = new InjectionToken( + 'ROUTER_CONTEXT', + { factory: () => new RouterContext() } +); + +export function injectRouterContext() { + return inject(ROUTER_CONTEXT); +} diff --git a/projects/router/src/lib/router-devtools.ts b/projects/router/src/lib/router-devtools.ts index e10f54e..9ff18d7 100644 --- a/projects/router/src/lib/router-devtools.ts +++ b/projects/router/src/lib/router-devtools.ts @@ -1,212 +1,118 @@ import { - AfterViewInit, - ChangeDetectorRef, - Component, + afterNextRender, + booleanAttribute, + computed, + Directive, + effect, ElementRef, - Input, + inject, + input, NgZone, - OnChanges, - OnDestroy, - OnInit, - SimpleChanges, - ViewChild, + signal, + untracked, } from '@angular/core'; -import type { AnyRouter } from '@tanstack/router-core'; import { TanStackRouterDevtoolsCore } from '@tanstack/router-devtools-core'; -import type { JSX } from 'solid-js'; - -@Component({ - selector: 'tan-stack-router-devtools', - template: ` -
- `, -}) -export class TanStackRouterDevtoolsComponent - implements OnInit, OnChanges, AfterViewInit, OnDestroy -{ - @Input() initialIsOpen?: boolean; - @Input() panelProps?: JSX.HTMLAttributes; - @Input() closeButtonProps?: JSX.ButtonHTMLAttributes; - @Input() toggleButtonProps?: JSX.ButtonHTMLAttributes; - @Input() position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; - @Input() containerElement?: string | any; - @Input() router?: AnyRouter; - @Input() shadowDOMTarget?: ShadowRoot; - - @ViewChild('devToolsContainer', { static: true }) - devToolsContainer!: ElementRef; - - private devtools?: TanStackRouterDevtoolsCore; - private cleanup?: () => void; - private isDevtoolsMounted = false; - - constructor( - private ngZone: NgZone, - private cdr: ChangeDetectorRef - ) {} - - ngOnInit(): void { - if (!this.router) { - console.warn('No router provided to TanStackRouterDevtools'); - return; - } - - // Delay initialization to ensure proper state - setTimeout(() => this.initializeDevtools(), 0); - } - - ngAfterViewInit(): void { - // If not initialized in ngOnInit, try again here - if (!this.isDevtoolsMounted && this.router) { - this.initializeDevtools(); - } - } - - ngOnChanges(changes: SimpleChanges): void { - if (!this.devtools) { - if (this.router) { - this.initializeDevtools(); - } - return; - } - - if (changes['router'] && this.router) { - this.updateRouter(); - } - - // Update options if any of them changed - if ( - changes['initialIsOpen'] || - changes['panelProps'] || - changes['closeButtonProps'] || - changes['toggleButtonProps'] || - changes['position'] || - changes['containerElement'] || - changes['shadowDOMTarget'] - ) { - this.updateOptions(); - } - } - - private initializeDevtools(): void { - if (!this.router || !this.devToolsContainer || this.isDevtoolsMounted) - return; - - this.ngZone.runOutsideAngular(() => { - try { - // Create a clean initial options object - const options = { - router: this.router as AnyRouter, - routerState: this.router!.state, - initialIsOpen: this.initialIsOpen, - panelProps: this.panelProps, - closeButtonProps: this.closeButtonProps, - toggleButtonProps: this.toggleButtonProps, - position: this.position, - containerElement: this.containerElement, - shadowDOMTarget: this.shadowDOMTarget, - }; - - // Initialize with all options at once - this.devtools = new TanStackRouterDevtoolsCore(options); - - // Set up manual router state tracking - this.setupStateTracking(); - - // Mount the devtools to the DOM - this.devtools.mount(this.devToolsContainer.nativeElement); - this.isDevtoolsMounted = true; - } catch (err) { - console.error('Failed to initialize TanStack Router DevTools:', err); - } +import { Router } from './router'; + +@Directive({ selector: 'router-devtools', host: { style: 'display: block;' } }) +export class RouterDevtools { + private injectedRouter = inject(Router); + private host = inject>(ElementRef); + private ngZone = inject(NgZone); + + router = input(this.injectedRouter); + initialIsOpen = input(undefined, { transform: booleanAttribute }); + panelOptions = input>({}); + closeButtonOptions = input>({}); + toggleButtonOptions = input>({}); + shadowDOMTarget = input(); + containerElement = input(); + position = input<'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'>(); + + private activeRouterState = computed(() => this.router().routerState()); + + private devtools = signal(null); + + constructor() { + afterNextRender(() => { + const [ + router, + initialIsOpen, + panelOptions, + closeButtonOptions, + toggleButtonOptions, + shadowDOMTarget, + containerElement, + position, + activeRouterState, + ] = [ + untracked(this.router), + untracked(this.initialIsOpen), + untracked(this.panelOptions), + untracked(this.closeButtonOptions), + untracked(this.toggleButtonOptions), + untracked(this.shadowDOMTarget), + untracked(this.containerElement), + untracked(this.position), + untracked(this.activeRouterState), + ]; + + // initial devTools + this.devtools.set( + new TanStackRouterDevtoolsCore({ + router, + routerState: activeRouterState, + initialIsOpen, + position, + panelProps: panelOptions, + closeButtonProps: closeButtonOptions, + toggleButtonProps: toggleButtonOptions, + shadowDOMTarget, + containerElement, + }) + ); }); - } - - private setupStateTracking(): void { - if (!this.router || !this.devtools) return; - - // Set up a polling mechanism to check for router state changes - // since Angular's router state doesn't have a subscribe method - const checkInterval = 100; // ms - let previousState = this.router.state; - - const intervalId = setInterval(() => { - if (this.router && this.devtools) { - const currentState = this.router.state; - - // If state reference has changed - if (currentState !== previousState) { - previousState = currentState; - - this.ngZone.runOutsideAngular(() => { - this.devtools?.setRouterState(currentState); - }); - } - } - }, checkInterval); - - // Store cleanup function - this.cleanup = () => clearInterval(intervalId); - } - - private updateRouter(): void { - if (!this.devtools || !this.router) return; - this.ngZone.runOutsideAngular(() => { - try { - // Update router reference - this.devtools?.setRouter(this.router as AnyRouter); - - // Update router state - this.devtools?.setRouterState(this.router!.state); + effect(() => { + const devtools = this.devtools(); + if (!devtools) return; + this.ngZone.runOutsideAngular(() => devtools.setRouter(this.router())); + }); - // Reset state tracking - if (this.cleanup) { - this.cleanup(); - } - this.setupStateTracking(); - } catch (err) { - console.error('Error updating TanStack Router DevTools router:', err); - } + effect(() => { + const devtools = this.devtools(); + if (!devtools) return; + this.ngZone.runOutsideAngular(() => + devtools.setRouterState(this.activeRouterState()) + ); }); - } - private updateOptions(): void { - if (!this.devtools) return; + effect(() => { + const devtools = this.devtools(); + if (!devtools) return; - this.ngZone.runOutsideAngular(() => { - try { - this.devtools?.setOptions({ - initialIsOpen: this.initialIsOpen, - panelProps: this.panelProps, - closeButtonProps: this.closeButtonProps, - toggleButtonProps: this.toggleButtonProps, - position: this.position, - containerElement: this.containerElement, - shadowDOMTarget: this.shadowDOMTarget, + this.ngZone.runOutsideAngular(() => { + devtools.setOptions({ + initialIsOpen: this.initialIsOpen(), + panelProps: this.panelOptions(), + closeButtonProps: this.closeButtonOptions(), + toggleButtonProps: this.toggleButtonOptions(), + position: this.position(), + containerElement: this.containerElement(), + shadowDOMTarget: this.shadowDOMTarget(), }); - } catch (err) { - console.error('Error updating TanStack Router DevTools options:', err); - } + }); }); - } - - ngOnDestroy(): void { - // Clean up interval - if (this.cleanup) { - this.cleanup(); - } - // Unmount devtools - if (this.devtools) { - this.ngZone.runOutsideAngular(() => { - try { - this.devtools?.unmount(); - this.isDevtoolsMounted = false; - } catch (err) { - console.error('Error unmounting TanStack Router DevTools:', err); - } + effect((onCleanup) => { + const devtools = this.devtools(); + if (!devtools) return; + this.ngZone.runOutsideAngular(() => + devtools.mount(this.host.nativeElement) + ); + onCleanup(() => { + this.ngZone.runOutsideAngular(() => devtools.unmount()); }); - } + }); } } diff --git a/projects/router/src/lib/router.ts b/projects/router/src/lib/router.ts index 91ade9e..030ff4c 100644 --- a/projects/router/src/lib/router.ts +++ b/projects/router/src/lib/router.ts @@ -4,6 +4,7 @@ import { InjectionToken, Injector, makeEnvironmentProviders, + provideAppInitializer, Provider, Type, } from '@angular/core'; @@ -13,8 +14,8 @@ import { RouterConstructorOptions, RouterCore, } from '@tanstack/router-core'; -import { context } from './context'; import { createRouter, NgRouter } from './create-router'; +import { injectRouterContext } from './router-context'; export type RouteObject = { element: Type; @@ -48,17 +49,25 @@ export function provideRouter( provide: Router, useFactory: () => { const injector = inject(EnvironmentInjector); + const routerContext = injectRouterContext(); return createRouter({ ...options, context: { ...options.context, getRouteInjector(routeId: string, providers: Provider[] = []) { - return context.getEnvContext(routeId, providers, injector); + return routerContext.getEnvContext(routeId, providers, injector); }, }, }); }, }, + provideAppInitializer(() => { + const router = injectRouter(); + router.load().then(() => { + console.log('initial router load'); + }); + return Promise.resolve(); + }), ]); } diff --git a/projects/router/src/public-api.ts b/projects/router/src/public-api.ts index 4077723..8daea39 100644 --- a/projects/router/src/public-api.ts +++ b/projects/router/src/public-api.ts @@ -234,10 +234,11 @@ export { SearchParamError, componentTypes, getInitialRouterState, + isNotFound, lazyFn, + notFound, + redirect, } from '@tanstack/router-core'; - -export { isNotFound, notFound } from '@tanstack/router-core'; export type { NotFoundError } from '@tanstack/router-core'; export type { @@ -261,11 +262,10 @@ export type { ValidateUseSearchResult, } from '@tanstack/router-core'; -export * from './lib/context'; export * from './lib/file-route'; export * from './lib/link'; export * from './lib/outlet'; export * from './lib/route'; -export * from './lib/route-context'; export * from './lib/router'; +export * from './lib/router-context'; export * from './lib/router-devtools'; diff --git a/src/app/about/about.component.ts b/src/app/about/about.component.ts deleted file mode 100644 index cf356ad..0000000 --- a/src/app/about/about.component.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { JsonPipe } from '@angular/common'; -import { Component } from '@angular/core'; -import { - createLazyRoute, - routeApi, -} from 'tanstack-angular-router-experimental'; - -export const LazyAboutRoute = createLazyRoute('/about')({ - component: () => AboutComponent, -}); - -@Component({ - selector: 'about', - standalone: true, - imports: [JsonPipe], - template: ` - TanStack Routing in Angular - -
- Loader Data: {{ loaderData() | json }} - -
- `, -}) -export class AboutComponent { - routeApi = routeApi({ id: '/about' }); - loaderData = this.routeApi.loaderData(); -} diff --git a/src/app/about/about.route.ts b/src/app/about/about.route.ts index 6438da9..c7afad3 100644 --- a/src/app/about/about.route.ts +++ b/src/app/about/about.route.ts @@ -3,14 +3,17 @@ import { createRoute } from 'tanstack-angular-router-experimental'; import { inject } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { Route as RootRoute } from '../root.route'; -import { TodosService } from '../todos.service'; +import { Spinner } from '../spinner'; +import { TodosClient } from '../todos-client'; export const AboutRoute = createRoute({ getParentRoute: () => RootRoute, path: 'about', + pendingComponent: () => Spinner, loader: async () => { - const todosService = inject(TodosService); + const todosService = inject(TodosClient); + await new Promise((resolve) => setTimeout(resolve, 5_000)); const todos = await firstValueFrom(todosService.getTodo(1)); return { todos }; }, -}).lazy(() => import('./about.component').then((m) => m.LazyAboutRoute)); +}).lazy(() => import('./about').then((m) => m.LazyAboutRoute)); diff --git a/src/app/about/about.ts b/src/app/about/about.ts new file mode 100644 index 0000000..26d21e7 --- /dev/null +++ b/src/app/about/about.ts @@ -0,0 +1,29 @@ +import { JsonPipe } from '@angular/common'; +import { Component } from '@angular/core'; +import { createLazyRoute } from 'tanstack-angular-router-experimental'; + +export const LazyAboutRoute = createLazyRoute('/about')({ + component: () => About, +}); + +@Component({ + selector: 'about', + imports: [JsonPipe], + template: ` + TanStack Routing in Angular + +
+ +

Loader Data

+ @if (loaderData()?.todos; as todos) { +
{{ todos | json }}
+ } @else { +

Loading...

+ } + +
+ `, +}) +export class About { + loaderData = LazyAboutRoute.loaderData(); +} diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 47551ba..299604a 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -8,6 +8,7 @@ import { routeTree } from './router'; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), + // provideExperimentalZonelessChangeDetection(), provideRouter({ routeTree }), provideHttpClient(withFetch()), ], diff --git a/src/app/app.component.spec.ts b/src/app/app.spec.ts similarity index 70% rename from src/app/app.component.spec.ts rename to src/app/app.spec.ts index 84dfe6e..b2a27ff 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.spec.ts @@ -1,27 +1,27 @@ import { TestBed } from '@angular/core/testing'; -import { AppComponent } from './app.component'; +import { App } from './app'; -describe('AppComponent', () => { +describe('App', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [AppComponent], + imports: [App], }).compileComponents(); }); it('should create the app', () => { - const fixture = TestBed.createComponent(AppComponent); + const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; expect(app).toBeTruthy(); }); it(`should have the 'tanstack-router-angular' title`, () => { - const fixture = TestBed.createComponent(AppComponent); + const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; expect(app.title).toEqual('tanstack-router-angular'); }); it('should render title', () => { - const fixture = TestBed.createComponent(AppComponent); + const fixture = TestBed.createComponent(App); fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; expect(compiled.querySelector('h1')?.textContent).toContain( diff --git a/src/app/app.component.ts b/src/app/app.ts similarity index 64% rename from src/app/app.component.ts rename to src/app/app.ts index 4a04cb2..20dff3b 100644 --- a/src/app/app.component.ts +++ b/src/app/app.ts @@ -1,14 +1,13 @@ import { Component } from '@angular/core'; import { - injectRouter, Link, Outlet, - TanStackRouterDevtoolsComponent, + RouterDevtools, } from 'tanstack-angular-router-experimental'; @Component({ selector: 'app-root', - imports: [Outlet, TanStackRouterDevtoolsComponent, Link], + imports: [Outlet, Link, RouterDevtools], template: `

Welcome to {{ title }}!

Home @@ -16,17 +15,15 @@ import { About | Parent 1 + | + Protected + | + Login
- @if (router) { - - } + `, styles: [ ` @@ -38,7 +35,6 @@ import { `, ], }) -export class AppComponent { +export class App { title = 'tanstack-router-angular'; - router = injectRouter(); } diff --git a/src/app/auth-state.ts b/src/app/auth-state.ts new file mode 100644 index 0000000..4f83be3 --- /dev/null +++ b/src/app/auth-state.ts @@ -0,0 +1,7 @@ +import { computed, Injectable, signal } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class AuthState { + username = signal(''); + isAuthenticated = computed(() => this.username() !== ''); +} diff --git a/src/app/child.component.ts b/src/app/child.ts similarity index 72% rename from src/app/child.component.ts rename to src/app/child.ts index 2d032c6..e997f5b 100644 --- a/src/app/child.component.ts +++ b/src/app/child.ts @@ -2,12 +2,12 @@ import { Component } from '@angular/core'; import { createRoute } from 'tanstack-angular-router-experimental'; -import { Route as ParentRoute } from './parent.component'; +import { Route as ParentRoute } from './parent'; export const Route = createRoute({ getParentRoute: () => ParentRoute, path: '$id', - component: () => ChildComponent, + component: () => Child, }); @Component({ @@ -16,6 +16,6 @@ export const Route = createRoute({ Child {{ params().id }} `, }) -export class ChildComponent { +export class Child { params = Route.routeParams(); } diff --git a/src/app/home.component.ts b/src/app/home.ts similarity index 80% rename from src/app/home.component.ts rename to src/app/home.ts index 4901bf2..a6c521d 100644 --- a/src/app/home.component.ts +++ b/src/app/home.ts @@ -7,14 +7,13 @@ import { Route as RootRoute } from './root.route'; export const Route = createRoute({ getParentRoute: () => RootRoute, path: '/', - component: () => HomeComponent, + component: () => Home, }); @Component({ selector: 'home', - standalone: true, template: ` Hello from TanStack Router `, }) -export class HomeComponent {} +export class Home {} diff --git a/src/app/login.ts b/src/app/login.ts new file mode 100644 index 0000000..2c5e2e2 --- /dev/null +++ b/src/app/login.ts @@ -0,0 +1,56 @@ +import { Component, inject } from '@angular/core'; +import { + createRoute, + injectRouter, + redirect, +} from 'tanstack-angular-router-experimental'; +import { AuthState } from './auth-state'; + +import { Route as RootRoute } from './root.route'; + +export const Route = createRoute({ + getParentRoute: () => RootRoute, + path: 'login', + component: () => Login, + validateSearch: (search) => ({ redirect: search['redirect'] as string }), + beforeLoad: ({ search }) => { + const authState = inject(AuthState); + if (authState.isAuthenticated()) { + console.log('already logged in'); + throw redirect({ to: search.redirect || '/' }); + } + }, +}); + +@Component({ + selector: 'login', + template: ` +

Login

+ +
+ + +
+ `, +}) +export class Login { + authState = inject(AuthState); + router = injectRouter(); + search = Route.routeSearch(); + + onSubmit(event: SubmitEvent) { + event.preventDefault(); + const form = event.target as HTMLFormElement; + const formData = new FormData(form); + const username = formData.get('username'); + if (!username || typeof username !== 'string') return; + + this.authState.username.set(username); + this.router.navigate({ to: this.search().redirect || '/' }); + } +} diff --git a/src/app/parent.component.ts b/src/app/parent.ts similarity index 92% rename from src/app/parent.component.ts rename to src/app/parent.ts index 7bf559e..6b9409b 100644 --- a/src/app/parent.component.ts +++ b/src/app/parent.ts @@ -12,7 +12,7 @@ import { Route as RootRoute } from './root.route'; export const Route = createRoute({ getParentRoute: () => RootRoute, path: 'parent', - component: () => ParentComponent, + component: () => Parent, }); @Component({ @@ -43,6 +43,6 @@ export const Route = createRoute({ `, ], }) -export class ParentComponent { +export class Parent { router = injectRouter(); } diff --git a/src/app/protected.ts b/src/app/protected.ts new file mode 100644 index 0000000..4195c30 --- /dev/null +++ b/src/app/protected.ts @@ -0,0 +1,29 @@ +import { Component, inject } from '@angular/core'; +import { createRoute, redirect } from 'tanstack-angular-router-experimental'; +import { AuthState } from './auth-state'; +import { Route as RootRoute } from './root.route'; + +export const Route = createRoute({ + getParentRoute: () => RootRoute, + path: 'protected', + component: () => Protected, + beforeLoad: ({ location }) => { + const authState = inject(AuthState); + if (!authState.isAuthenticated()) { + throw redirect({ + to: '/login', + search: { + redirect: location.href, + }, + }); + } + }, +}); + +@Component({ + selector: 'protected', + template: ` +

This is protected route

+ `, +}) +export class Protected {} diff --git a/src/app/root.route.ts b/src/app/root.route.ts index 61bf3d8..04a8e61 100644 --- a/src/app/root.route.ts +++ b/src/app/root.route.ts @@ -1,5 +1,5 @@ import { createRootRoute } from 'tanstack-angular-router-experimental'; -import { AppComponent } from './app.component'; +import { App } from './app'; -export const Route = createRootRoute({ component: () => AppComponent }); +export const Route = createRootRoute({ component: () => App }); diff --git a/src/app/router.ts b/src/app/router.ts index 468eda6..0d45281 100644 --- a/src/app/router.ts +++ b/src/app/router.ts @@ -1,15 +1,19 @@ import { TypedRouter } from 'tanstack-angular-router-experimental'; import { AboutRoute } from './about/about.route'; -import { Route as ChildRoute } from './child.component'; -import { Route as HomeRoute } from './home.component'; -import { Route as ParentRoute } from './parent.component'; +import { Route as ChildRoute } from './child'; +import { Route as HomeRoute } from './home'; +import { Route as LoginRoute } from './login'; +import { Route as ParentRoute } from './parent'; +import { Route as ProtectedRoute } from './protected'; import { Route as RootRoute } from './root.route'; export const routeTree = RootRoute.addChildren([ HomeRoute, AboutRoute, ParentRoute.addChildren([ChildRoute]), + ProtectedRoute, + LoginRoute, ]); export type router = TypedRouter; diff --git a/src/app/spinner.ts b/src/app/spinner.ts new file mode 100644 index 0000000..487ae15 --- /dev/null +++ b/src/app/spinner.ts @@ -0,0 +1,47 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'spinner', + template: ` + + + + + + + + + + + + `, +}) +export class Spinner {} diff --git a/src/app/todos.service.ts b/src/app/todos-client.ts similarity index 80% rename from src/app/todos.service.ts rename to src/app/todos-client.ts index 7e987e3..39c8632 100644 --- a/src/app/todos.service.ts +++ b/src/app/todos-client.ts @@ -1,10 +1,8 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -@Injectable({ - providedIn: 'root', -}) -export class TodosService { +@Injectable({ providedIn: 'root' }) +export class TodosClient { private http = inject(HttpClient); getTodo(id: number) { diff --git a/src/main.ts b/src/main.ts index a0dbb2c..2108cd9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,5 @@ import { bootstrapApplication } from '@angular/platform-browser'; -import { AppComponent } from './app/app.component'; +import { App } from './app/app'; import { appConfig } from './app/app.config'; -bootstrapApplication(AppComponent, appConfig).catch((err) => - console.error(err) -); +bootstrapApplication(App, appConfig).catch((err) => console.error(err));