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
82 changes: 81 additions & 1 deletion projects/core/router/url/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Component } from '@angular/core';
import { Component, REQUEST } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NavigationEnd, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { IS_BROWSER, IS_SERVER } from '@signality/core/internal';
import { url } from './index';

describe(url.name, () => {
Expand Down Expand Up @@ -118,4 +119,83 @@ describe(url.name, () => {
expect(component.absoluteUrl()).toBe('http://localhost:4200/dashboard');
});
});

describe('server rendering', () => {
@Component({ template: '{{ relativeUrl() }}|{{ absoluteUrl() }}' })
class TestComponent {
readonly relativeUrl = url();
readonly absoluteUrl = url({ absolute: true });
}

const configureServer = (request: Request | null) => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: mockRouter },
{ provide: IS_SERVER, useValue: true },
{ provide: IS_BROWSER, useValue: false },
{ provide: REQUEST, useValue: request },
],
});
};

const createComponent = () => {
const fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
return fixture.componentInstance;
};

it('should prepend the request origin to an absolute URL', () => {
configureServer(new Request('https://example.com/home'));

const component = createComponent();

expect(component.relativeUrl()).toBe('/home');
expect(component.absoluteUrl()).toBe('https://example.com/home');
});

it('should take only the origin from the request and the path from the router', () => {
mockRouter.url = '/products/123?sort=name';
configureServer(new Request('https://example.com:8443/redirected'));

const component = createComponent();

expect(component.absoluteUrl()).toBe('https://example.com:8443/products/123?sort=name');
});

it('should fall back to a relative URL and warn when there is no request', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
configureServer(null);

const component = createComponent();

expect(component.absoluteUrl()).toBe('/home');
expect(warn).toHaveBeenCalledWith(expect.stringContaining('[url]'));
});

it('should not warn when `absolute` is not requested', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);

TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: mockRouter },
{ provide: IS_SERVER, useValue: true },
{ provide: IS_BROWSER, useValue: false },
{ provide: REQUEST, useValue: null },
],
});

@Component({ template: '{{ relativeUrl() }}' })
class RelativeOnly {
readonly relativeUrl = url();
}

const fixture = TestBed.createComponent(RelativeOnly);
fixture.detectChanges();

expect(fixture.componentInstance.relativeUrl()).toBe('/home');
expect(warn).not.toHaveBeenCalled();
});
});
});
25 changes: 23 additions & 2 deletions projects/core/router/url/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type CreateSignalOptions, inject, signal, type Signal } from '@angular/core';
import { type CreateSignalOptions, inject, REQUEST, signal, type Signal } from '@angular/core';
import { Router } from '@angular/router';
import { constSignal, setupContext } from '@signality/core/internal';
import type { WithInjector } from '@signality/core/types';
Expand All @@ -7,6 +7,10 @@ import { routerListener } from '@signality/core/router/router-listener';
export interface UrlOptions extends CreateSignalOptions<string>, WithInjector {
/**
* Include origin (protocol + host) for absolute URL.
*
* On the server the origin comes from the `REQUEST` token. While prerendering there is no
* request, so the origin is unknown and the signal falls back to a relative URL.
*
* @default false
*/
readonly absolute?: boolean;
Expand Down Expand Up @@ -41,7 +45,24 @@ export function url(options?: UrlOptions): Signal<string> {
const router = inject(Router);

if (isServer) {
return constSignal(router.url);
const relativeUrl = router.url;

if (!options?.absolute) {
return constSignal(relativeUrl);
}

const request = inject(REQUEST, { optional: true });
if (!request) {
if (ngDevMode) {
console.warn(
'[url] `absolute` is ignored while prerendering, because the deployment origin is unknown at build time. Falling back to a relative URL.'
);
}

return constSignal(relativeUrl);
}

return constSignal(new URL(request.url).origin + relativeUrl);
}

const getUrl = () => {
Expand Down
4 changes: 4 additions & 0 deletions projects/docs/router/url.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ export class ShareButtons {

On the server, the signal initializes with the current URL from the router's [`url`](https://angular.dev/api/router/Router#url) property.

With `absolute: true`, the origin is taken from the [`REQUEST`](https://angular.dev/api/core/REQUEST) token, so the rendered value matches the origin the visitor actually requested — including behind a reverse proxy that terminates TLS (`X-Forwarded-Host` and `X-Forwarded-Proto` are honoured).

During **prerendering** there is no request, and therefore no origin: the deployment host is not knowable at build time. The signal falls back to a relative URL and logs a warning in development builds. Keep this in mind when using `absolute: true` for crawler-visible metadata such as `og:url` — a prerendered page will carry a relative value until it is hydrated.

## Type Definitions

```typescript
Expand Down
Loading