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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
!.yarn/releases
!.yarn/sdks
!.yarn/versions
llm-wiki/

*.pfx
*.publishsettings
Expand Down
1 change: 1 addition & 0 deletions packages/angular/ng-package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"@angular/common",
"@angular/core",
"@angular/router",
"unstorage",
"@ngx-translate/core"
]
}
3 changes: 2 additions & 1 deletion packages/angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@
"@ngx-translate/core": "^17.0.0",
"@sitecore-content-sdk/content": "^2.1.0",
"@sitecore-content-sdk/core": "^2.1.0",
"tslib": "^2.3.0"
"tslib": "^2.3.0",
"unstorage": "^1.17.5"
},
"devDependencies": {
"@angular/build": "^21.1.4",
Expand Down
28 changes: 27 additions & 1 deletion packages/angular/src/config/define-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ export interface AngularSitecoreConfigInput extends Omit<SitecoreConfigInput, 'r
* `defaultLanguage` is prepended automatically when absent.
*/
locales?: string[];
/**
* Configuration for the ISR-like cache. Both fields default when omitted
* (`enabled: true`, `revalidate: 300`).
*/
loadersCache?: {
/** Whether the cache is enabled. */
enabled?: boolean;
/** The global revalidate time in seconds. */
revalidate?: number;
};
};
}

Expand All @@ -47,9 +57,20 @@ export interface AngularSitecoreConfig extends Omit<SitecoreConfig, 'redirects'>
angular: {
/** Resolved locales for the Angular app. Always contains at least `defaultLanguage`. */
locales: string[];
/**
* Resolved configuration for the ISR-like cache. Defaults are applied by
* `defineConfig`: `enabled: true`, `revalidate: 300`.
*/
loadersCache: {
enabled: boolean;
revalidate: number;
};
};
}

/** Defaults applied to `angular.loadersCache` when input omits fields. */
const DEFAULT_ISR_CACHE = { enabled: true, revalidate: 300 } as const;

/**
* Ensures `defaultLanguage` is present in the locales list (prepended when missing) and
* returns an empty-input fallback of `[defaultLanguage]`.
Expand Down Expand Up @@ -96,8 +117,13 @@ export function defineConfig(

scConfig.redirects.locales = locales;

const loadersCache = {
enabled: angular?.loadersCache?.enabled ?? DEFAULT_ISR_CACHE.enabled,
revalidate: angular?.loadersCache?.revalidate ?? DEFAULT_ISR_CACHE.revalidate,
};

return {
...scConfig,
angular: { locales },
angular: { locales, loadersCache },
} as AngularSitecoreConfig;
}
14 changes: 4 additions & 10 deletions packages/angular/src/lib/sitecore-context.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* eslint-disable jsdoc/require-jsdoc */
/* eslint-disable */
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { Component, PLATFORM_ID, REQUEST } from '@angular/core';
Expand Down Expand Up @@ -100,9 +100,7 @@ describe('SitecoreContextService', () => {
TestBed.configureTestingModule({
imports: [RouterHostCmp, BlankCmp],
providers: [
provideRouter(
appLikeRoutes({ page: mockPage, dictionary: mockDictionary })
),
provideRouter(appLikeRoutes({ page: mockPage, dictionary: mockDictionary })),
{ provide: SITECORE_CONFIG_TOKEN, useValue: makeConfig([...TEST_LOCALES]) },
SitecoreContextService,
],
Expand Down Expand Up @@ -141,9 +139,7 @@ describe('SitecoreContextService', () => {
TestBed.configureTestingModule({
imports: [RouterHostCmp, BlankCmp],
providers: [
provideRouter(
appLikeRoutes({ page: editingPage })
),
provideRouter(appLikeRoutes({ page: editingPage })),
{ provide: SITECORE_CONFIG_TOKEN, useValue: makeConfig([...TEST_LOCALES]) },
SitecoreContextService,
],
Expand Down Expand Up @@ -325,9 +321,7 @@ describe('SitecoreContextService effectiveLocale', () => {
TestBed.configureTestingModule({
imports: [RouterHostCmp, BlankCmp],
providers: [
provideRouter(
appLikeRoutes({ page: makePage({ locale: 'fr' }) })
),
provideRouter(appLikeRoutes({ page: makePage({ locale: 'fr' }) })),
{
provide: SITECORE_CONFIG_TOKEN,
useValue: makeConfig([...TEST_LOCALES], 'en'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { PLATFORM_ID } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { LoaderDataService } from './loader-data.service';
import { ClientLoaderDataService } from './client-loader-data.service';
import { FETCH_DATA_ENDPOINT } from './loader-registry.token';
import { LOADER_DATA_ENDPOINT } from '../server/constants';
import * as sdkCore from '@sitecore-content-sdk/core';

describe('LoaderDataService', () => {
let service: LoaderDataService;
describe('ClientLoaderDataService', () => {
let service: ClientLoaderDataService;
let httpController: HttpTestingController;
let debugCommonSpy: ReturnType<typeof vi.spyOn>;

Expand All @@ -23,7 +23,7 @@ describe('LoaderDataService', () => {
const platformId = overrides.platformId ?? 'browser';
TestBed.configureTestingModule({
providers: [
LoaderDataService,
ClientLoaderDataService,
provideHttpClient(),
provideHttpClientTesting(),
{ provide: PLATFORM_ID, useValue: platformId },
Expand All @@ -32,7 +32,7 @@ describe('LoaderDataService', () => {
: []),
],
});
service = TestBed.inject(LoaderDataService);
service = TestBed.inject(ClientLoaderDataService);
httpController = TestBed.inject(HttpTestingController);
}

Expand All @@ -46,7 +46,7 @@ describe('LoaderDataService', () => {
});

describe('getData', () => {
it('should make new data request when no pending requests and data not in cache', async () => {
it('should make new data request when no pending requests and no staged prefetched response', async () => {
setupTestBed();
const request = { url: '/test', loaderId: 'page' };
const resultPromise = service.getData(request);
Expand Down Expand Up @@ -85,14 +85,14 @@ describe('LoaderDataService', () => {
expect(result).toEqual({
kind: 'error',
status: 500,
message: 'LoaderDataService only works in browser',
message: 'ClientLoaderDataService only works in browser',
});
httpController.expectNone(LOADER_DATA_ENDPOINT);
});
});

describe('prefetch', () => {
it('should populate cache without consuming so getData can read it without a new request', async () => {
it('should stage prefetched response without consuming so getData can read it without a new request', async () => {
setupTestBed();
const request = { url: '/prefetched', loaderId: 'page' };
service.prefetch(request);
Expand All @@ -111,12 +111,12 @@ describe('LoaderDataService', () => {
httpController.expectNone(LOADER_DATA_ENDPOINT);
});

it('should not make a new request when cache is already populated', async () => {
it('should not make a new request when prefetched response is already staged', async () => {
setupTestBed();
const request = { url: '/cached', loaderId: 'page' };
const request = { url: '/staged', loaderId: 'page' };
service.prefetch(request);
const req = httpController.expectOne(LOADER_DATA_ENDPOINT);
req.flush({ kind: 'data', data: { cached: true } });
req.flush({ kind: 'data', data: { staged: true } });
await new Promise((r) => setTimeout(r, 0));

service.prefetch(request);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ import { isPlatformBrowser } from '@angular/common';
import { firstValueFrom } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { Params } from '@angular/router';
import { LoaderApiRequest, LoaderApiResponse } from './models';
import { LoaderApiRequest, LoaderApiResponse, LoaderCacheConfig } from './models';
import { LOADER_DATA_ENDPOINT } from '../server/constants';
import { FETCH_DATA_ENDPOINT } from './loader-registry.token';

/**
* Cache key generator for loader data.
* Staging key for prefetched loader responses (browser-only, consume-once).
* @param {string} loaderId - Loader identifier
* @param {string} url - Request URL
* @returns Cache key string
* @returns Staging key string
*/
function cacheKey(loaderId: string, url: string): string {
function requestKey(loaderId: string, url: string): string {
return `loader:${loaderId}:${url}`;
}

Expand All @@ -26,65 +26,79 @@ export interface LoaderDataRequest {
loaderId: string;
params?: Params;
query?: Record<string, string | string[]>;
/**
* Per-route cache overrides from `loaderResolver(id, cacheOptions)`. Sent
* to the server in the POST body so server-side cache policy matches the
* route's intent on CSR navigations. Phase 5 of the refactor plan.
*/
cacheOptions?: LoaderCacheConfig;
}

/**
* Loader data client for browser loader data resolution. POSTs to the `/_data` endpoint and holds
* short-lived prefetched responses for parallel navigation prefetching.
* Not aware of the server-side {@link LoaderCache}.
* @public
*/
@Injectable({
providedIn: 'root',
})
export class LoaderDataService {
private readonly cache = new Map<string, LoaderApiResponse>();
export class ClientLoaderDataService {
private readonly prefetchedResponses = new Map<string, LoaderApiResponse>();
private readonly pending = new Map<string, Promise<LoaderApiResponse>>();
private readonly http = inject(HttpClient);
private readonly platformId = inject(PLATFORM_ID);
private readonly fetchDataEndpoint =
inject(FETCH_DATA_ENDPOINT, { optional: true }) ?? LOADER_DATA_ENDPOINT;

/**
* Prefetch loader data for the given request without consuming the cache.
* If data is already cached or a request is pending, does nothing.
* Otherwise starts a fetch and stores the result in cache for a later getData() call.
* Used by PreLoaderDataService to warm the cache for all loaders in a route in parallel.
* Prefetch loader data for the given request without consuming staged responses.
* If a response is already staged or a request is pending, does nothing.
* Otherwise starts a fetch and stores the result for a later getData() call.
* Used by PreLoaderDataService to warm responses for all loaders in a route in parallel.
* @param {LoaderDataRequest} loaderRequest - The loader data request
*/
prefetch(loaderRequest: LoaderDataRequest): void {
if (!isPlatformBrowser(this.platformId)) {
return;
}
const key = cacheKey(loaderRequest.loaderId, loaderRequest.url);
if (this.cache.has(key) || this.pending.has(key)) {
const key = requestKey(loaderRequest.loaderId, loaderRequest.url);
if (this.prefetchedResponses.has(key) || this.pending.has(key)) {
return;
}
const promise = this.fetchData(loaderRequest);
this.pending.set(key, promise);
promise.then(() => {
// Result is already stored in cache by fetchData; nothing to consume
// Result is already stored in prefetchedResponses by fetchData
});
}

/**
* Get data for the given request, using cache or fetching if needed.
* Get data for the given request, using staged prefetched responses or fetching if needed.
* If a request is already pending for this URL/loader combination,
* waits for it to complete instead of making a duplicate request.
* Consumes (removes) cached data after retrieval.
* Consumes (removes) staged responses after retrieval.
* @param {LoaderDataRequest} request - The loader data request
* @returns {Promise<LoaderApiResponse>} Promise resolving to the API response
*/
async getData(request: LoaderDataRequest): Promise<LoaderApiResponse> {
// Only fetch in browser
if (!isPlatformBrowser(this.platformId)) {
return { kind: 'error', status: 500, message: 'LoaderDataService only works in browser' };
return {
kind: 'error',
status: 500,
message: 'ClientLoaderDataService only works in browser',
};
}

const key = cacheKey(request.loaderId, request.url);
const key = requestKey(request.loaderId, request.url);

// Return cached response if available (consume on use); supports data and redirect
const cached = this.cache.get(key);
if (cached !== undefined) {
this.cache.delete(key);
return cached;
const staged = this.prefetchedResponses.get(key);
if (staged !== undefined) {
this.prefetchedResponses.delete(key);
return staged;
}

// Wait for pending request if one exists
// Wait for pending loader data request if one exists
const pendingRequest = this.pending.get(key);
if (pendingRequest) {
return pendingRequest;
Expand All @@ -104,34 +118,31 @@ export class LoaderDataService {
* @returns {Promise<LoaderApiResponse>} Promise resolving to the API response
*/
private async fetchData(request: LoaderDataRequest): Promise<LoaderApiResponse> {
const key = cacheKey(request.loaderId, request.url);
const key = requestKey(request.loaderId, request.url);
const endpoint = this.fetchDataEndpoint;
const reqBody: LoaderApiRequest = {
loaderId: request.loaderId,
url: request.url,
params: request.params ?? {},
query: request.query ?? {},
cacheOptions: request.cacheOptions,
};
console.log('DEBUG: LoaderDataService fetchData', endpoint, reqBody);

try {
const resp = await firstValueFrom(
this.http.post<LoaderApiResponse>(endpoint, reqBody, { cache: 'no-store' })
);
if (!resp) {
const message = `No response from ${endpoint}`;
console.log(`DEBUG: LoaderDataService fetchData: ${message}`);
return { kind: 'error', status: 500, message } as LoaderApiResponse;
}
if (resp.kind === 'data') {
console.log('DEBUG: LoaderDataService fetchData: data', resp.data);
this.cache.set(key, resp);
this.prefetchedResponses.set(key, resp);
} else if (resp.kind === 'redirect') {
this.cache.set(key, resp);
this.prefetchedResponses.set(key, resp);
}
return resp;
} catch (error) {
console.log('DEBUG: LoaderDataService fetchData: error', error);
const message = error instanceof Error ? error.message : 'Fetch failed';
return { kind: 'error', status: 500, message } as LoaderApiResponse;
} finally {
Expand Down
21 changes: 15 additions & 6 deletions packages/angular/src/loaders/loader-registry.token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,25 @@ export const FETCH_DATA_ENDPOINT = new InjectionToken<string | null | undefined>
'FETCH_DATA_ENDPOINT'
);

export const LOADER_REGISTRY = new InjectionToken<Record<string, LoaderFn>>('LOADER_REGISTRY');
/**
* Cross-boundary loader registry — maps loader IDs to loader functions.
* The same registry is used for SSR, CSR (`/_data`), and route resolvers.
* There is no separate server vs client loader set.
* @public
*/
export type LoaderRegistry = Record<string, LoaderFn>;

export const LOADER_REGISTRY = new InjectionToken<LoaderRegistry>('LOADER_REGISTRY');

/**
* Provides the loader registry for DI. Pass the loaders your app uses (e.g. page, '404', '500').
* The same loader set must be registered on the server in createLoaderDataServiceMiddleware so
* client-side navigation can fetch route data via the data endpoint.
* @param {Record<string, LoaderFn>} loaders - Map of loader id to loader function
* Registers the app's loader registry for DI. Pass the loaders your app uses
* (e.g. page, '404', '500'). Use the **same object** with
* {@link createLoaderDataServiceMiddleware} in `server.ts` so SSR and CSR
* navigations resolve the same loader functions.
* @param {LoaderRegistry} loaders - Map of loader id to loader function
* @public
*/
export const provideLoaderRegistry = (loaders: Record<string, LoaderFn>): Provider[] => {
export const provideLoaderRegistry = (loaders: LoaderRegistry): Provider[] => {
return [
{
provide: LOADER_REGISTRY,
Expand Down
Loading