@isa/core/tabs
A sophisticated tab management system for Angular applications providing browser-like navigation with intelligent history management, persistence, and configurable pruning strategies.
Overview
The Core Tabs library provides a comprehensive solution for managing multiple tabs with navigation history in Angular applications. Built on NgRx Signals, it offers persistent state management, intelligent history pruning, and seamless integration with Angular Router. The system supports unlimited tabs with configurable history limits, automatic browser navigation synchronization, and flexible metadata storage for domain-specific extensions.
Table of Contents
- Features
- Quick Start
- Core Concepts
- API Reference
- Usage Examples
- History Pruning
- Configuration
- Router Integration
- Testing
- Architecture Notes
Features
- NgRx Signals state management - Modern functional state with reactive patterns
- Persistent tab storage - Automatic state persistence with UserStorageProvider
- Browser navigation sync - Automatic Router event synchronization
- Intelligent history pruning - Three strategies: oldest, balanced, smart
- Configurable limits - Per-tab or global history size configuration
- Zod validation - Runtime schema validation for all operations
- Index validation - Automatic history index integrity checking
- Forward/backward navigation - Browser-style back/forward with pruning awareness
- Metadata system - Flexible per-tab metadata storage
- Angular DevTools integration - Debug tab state with Redux DevTools
- Router resolver - Automatic tab creation from route parameters
- Injection helpers - Convenient signal-based injection functions
- Navigate back component - Ready-to-use back button component
Quick Start
1. Import and Inject
import { Component, inject } from '@angular/core';
import { TabService, injectTab, injectTabId } from '@isa/core/tabs';
@Component({
selector: 'app-feature',
template: '...'
})
export class FeatureComponent {
// Option 1: Inject full service
#tabService = inject(TabService);
// Option 2: Inject current tab as signal
currentTab = injectTab();
// Option 3: Inject current tab ID as signal
currentTabId = injectTabId();
}
2. Create and Activate a Tab
createNewTab(): void {
const tab = this.#tabService.addTab({
name: 'Customer Details',
tags: ['customer', 'active'],
metadata: { customerId: 12345 }
});
this.#tabService.activateTab(tab.id);
console.log(`Created tab ${tab.id} at ${new Date(tab.createdAt)}`);
}
3. Navigate to a Location
navigateToCustomer(customerId: number): void {
const tabId = this.#tabService.activatedTabId();
if (!tabId) return;
this.#tabService.navigateToLocation(tabId, {
timestamp: Date.now(),
title: `Customer ${customerId}`,
url: `/customer/${customerId}`
});
}
4. Navigate Back/Forward
goBack(): void {
const tabId = this.#tabService.activatedTabId();
if (!tabId) return;
const previousLocation = this.#tabService.navigateBack(tabId);
if (previousLocation) {
this.router.navigateByUrl(previousLocation.url);
}
}
goForward(): void {
const tabId = this.#tabService.activatedTabId();
if (!tabId) return;
const nextLocation = this.#tabService.navigateForward(tabId);
if (nextLocation) {
this.router.navigateByUrl(nextLocation.url);
}
}
5. Setup Router Integration
import { ApplicationConfig } from '@angular/core';
import { TabNavigationService } from '@isa/core/tabs';
export const appConfig: ApplicationConfig = {
providers: [
// ... other providers
{
provide: APP_INITIALIZER,
useFactory: (tabNav: TabNavigationService) => () => tabNav.init(),
deps: [TabNavigationService],
multi: true
}
]
};
Core Concepts
Tab Entity Structure
Each tab is stored as an NgRx entity with the following structure:
interface Tab {
id: number; // Unique identifier
name: string; // Display name
createdAt: number; // Creation timestamp (ms)
activatedAt?: number; // Last activation timestamp
metadata: Record<string, unknown>; // Flexible metadata storage
location: TabLocationHistory; // Navigation history
tags: string[]; // Organization tags
}
Location History
Navigation history is tracked using a cursor-based system:
interface TabLocationHistory {
current: number; // Index of current location (-1 for empty)
locations: TabLocation[]; // Array of visited locations
}
interface TabLocation {
timestamp: number; // Visit timestamp (ms)
title: string; // Page title
url: string; // Full URL
}
Example Navigation Flow:
// Initial state
{ current: -1, locations: [] }
// After navigating to /home
{ current: 0, locations: [{ timestamp: ..., title: 'Home', url: '/home' }] }
// After navigating to /products
{
current: 1,
locations: [
{ timestamp: ..., title: 'Home', url: '/home' },
{ timestamp: ..., title: 'Products', url: '/products' }
]
}
// After navigating back
{ current: 0, locations: [...] } // Points back to /home
Tab Metadata
Metadata provides flexible per-tab storage for domain-specific data:
// Store customer context
this.#tabService.patchTabMetadata(tabId, {
customerId: 12345,
customerName: 'John Doe',
viewMode: 'details'
});
// Retrieve typed metadata
import { getMetadataHelper } from '@isa/core/tabs';
const customerId = getMetadataHelper(
tabId,
'customerId',
z.number(),
this.#tabService.entityMap()
);
History Configuration Overrides:
// Override global history limits per tab
this.#tabService.patchTabMetadata(tabId, {
maxHistorySize: 25, // Override global limit
maxForwardHistory: 5 // Custom forward history depth
});
Tab ID Generation
Tab IDs are automatically generated using timestamp-based generation:
// Automatic ID generation
const tab = this.#tabService.addTab({
name: 'New Tab'
// ID automatically generated: Date.now()
});
// Manual ID specification
const tab = this.#tabService.addTab({
id: 42,
name: 'Fixed ID Tab'
});
API Reference
TabService
NgRx SignalStore providing complete tab management functionality.
State Properties
entities(): Tab[]
Returns array of all tabs in the store.
Example:
const allTabs = this.#tabService.entities();
console.log(`Total tabs: ${allTabs.length}`);
entityMap(): { [id: number]: Tab }
Returns dictionary mapping tab IDs to tab objects.
Example:
const tabMap = this.#tabService.entityMap();
const tab42 = tabMap[42];
activatedTabId(): number | null
Returns ID of currently activated tab, or null.
Example:
const activeId = this.#tabService.activatedTabId();
if (activeId !== null) {
console.log(`Active tab: ${activeId}`);
}
activatedTab(): Tab | null
Computed signal returning currently activated tab entity.
Example:
const activeTab = this.#tabService.activatedTab();
if (activeTab) {
console.log(`Active: ${activeTab.name}`);
}
Tab Management Methods
addTab(params): Tab
Creates and adds a new tab to the store.
Parameters:
params: AddTabInput- Tab creation parameters (validated with Zod)name?: string- Display name (default: 'Neuer Vorgang')tags?: string[]- Initial tags (default: [])metadata?: Record<string, unknown>- Initial metadata (default: {})id?: number- Optional ID (auto-generated if omitted)activatedAt?: number- Optional activation timestamp
Returns: Created Tab entity with generated ID and timestamps
Example:
const tab = this.#tabService.addTab({
name: 'Customer Order #1234',
tags: ['order', 'customer'],
metadata: { orderId: 1234, status: 'pending' }
});
console.log(`Created tab ${tab.id}`);
activateTab(id: number): void
Activates a tab by ID and updates its activation timestamp.
Parameters:
id: number- Tab ID to activate
Side Effects:
- Updates
activatedTabIdstate - Sets tab's
activatedAtto current timestamp
Example:
this.#tabService.activateTab(42);
patchTab(id: number, changes: PatchTabInput): void
Partially updates a tab's properties.
Parameters:
id: number- Tab ID to updatechanges: PatchTabInput- Partial tab updatesname?: string- Updated display nametags?: string[]- Updated tags arraymetadata?: Record<string, unknown>- Metadata to mergelocation?: TabLocationHistory- Updated location history
Example:
this.#tabService.patchTab(42, {
name: 'Updated Name',
tags: ['new-tag'],
metadata: { additionalField: 'value' }
});
patchTabMetadata(id: number, metadata: Record<string, unknown>): void
Updates tab metadata by merging with existing metadata.
Parameters:
id: number- Tab ID to updatemetadata: Record<string, unknown>- Metadata to merge
Example:
// Existing metadata: { customerId: 123 }
this.#tabService.patchTabMetadata(42, {
orderCount: 5
});
// Result: { customerId: 123, orderCount: 5 }
removeTab(id: number): void
Removes a tab from the store.
Parameters:
id: number- Tab ID to remove
Example:
this.#tabService.removeTab(42);
Navigation Methods
navigateToLocation(id: number, location: TabLocationInput): TabLocation | null
Navigates to a new location in tab history with intelligent pruning.
Parameters:
id: number- Tab ID to navigatelocation: TabLocationInput- Location to addtimestamp: number- Visit timestamp (ms)title: string- Page titleurl: string- Full URL
Returns: Validated TabLocation, or null if tab not found
Behavior:
- Prunes forward history based on
maxForwardHistory - Adds new location after current position
- Applies history size pruning if needed
- Validates and corrects history index
Example:
this.#tabService.navigateToLocation(42, {
timestamp: Date.now(),
title: 'Product Details',
url: '/products/123'
});
navigateBack(id: number): TabLocation | null
Navigates backward in tab history.
Parameters:
id: number- Tab ID to navigate
Returns: Previous location, or null if at start or tab not found
Example:
const previousLocation = this.#tabService.navigateBack(42);
if (previousLocation) {
this.router.navigateByUrl(previousLocation.url);
}
navigateForward(id: number): TabLocation | null
Navigates forward in tab history.
Parameters:
id: number- Tab ID to navigate
Returns: Next location, or null if at end or tab not found
Example:
const nextLocation = this.#tabService.navigateForward(42);
if (nextLocation) {
this.router.navigateByUrl(nextLocation.url);
}
getCurrentLocation(id: number): TabLocation | null
Gets current location with automatic index validation.
Parameters:
id: number- Tab ID to query
Returns: Current location, or null if none or invalid
Side Effects:
- Automatically corrects invalid history indices
- Logs warnings if index correction occurs (when enabled)
Example:
const currentLoc = this.#tabService.getCurrentLocation(42);
if (currentLoc) {
console.log(`Current page: ${currentLoc.title}`);
}
updateCurrentLocation(id: number, updates: Partial<TabLocation>): TabLocation | null
Updates the current location in-place.
Parameters:
id: number- Tab ID to updateupdates: Partial<TabLocation>- Properties to update
Returns: Updated location, or null if invalid
Example:
// Update current location's title
this.#tabService.updateCurrentLocation(42, {
title: 'Updated Title'
});
clearLocationHistory(id: number): void
Clears all navigation history for a tab.
Parameters:
id: number- Tab ID to clear
Example:
this.#tabService.clearLocationHistory(42);
// Result: { current: -1, locations: [] }
TabNavigationService
Service that automatically synchronizes Angular Router navigation with tab history.
init(): void
Initializes automatic navigation synchronization.
Behavior:
- Subscribes to Router NavigationEnd events
- Updates tab location history on each navigation
- Handles browser back/forward with pruning awareness
- Respects URL blacklist configuration
Usage:
// In app config
{
provide: APP_INITIALIZER,
useFactory: (tabNav: TabNavigationService) => () => tabNav.init(),
deps: [TabNavigationService],
multi: true
}
syncCurrentRoute(): void
Manually syncs current route to active tab's history.
Use Cases:
- Initial page loads
- New tab creation
- Manual synchronization after route changes
- Recovery scenarios
Example:
// After creating a new tab
const tab = this.#tabService.addTab({ name: 'New Tab' });
this.#tabService.activateTab(tab.id);
this.#tabNavigationService.syncCurrentRoute();
Helper Functions
injectTab(): Signal<Tab | null>
Injects current activated tab as a signal.
Returns: Signal emitting active tab or null
Example:
export class MyComponent {
currentTab = injectTab();
ngOnInit() {
effect(() => {
const tab = this.currentTab();
console.log(`Active tab: ${tab?.name}`);
});
}
}
injectTabId(): Signal<number | null>
Injects current tab ID as a signal.
Returns: Signal emitting active tab ID or null
Example:
export class MyComponent {
currentTabId = injectTabId();
isTabActive = computed(() => this.currentTabId() !== null);
}
getTabHelper(tabId: number, entities: Record<number, Tab>): Tab | undefined
Retrieves a tab from entity map.
Parameters:
tabId: number- Tab ID to retrieveentities: Record<number, Tab>- Entity map from TabService
Returns: Tab entity or undefined
Example:
const tab = getTabHelper(42, this.#tabService.entityMap());
getMetadataHelper<T>(tabId, key, schema, entities): T | undefined
Retrieves typed metadata value with Zod validation.
Parameters:
tabId: number- Tab ID to querykey: string- Metadata key to retrieveschema: ZodSchema<T>- Zod schema for validationentities: EntityMap<Tab>- Entity map from TabService
Returns: Validated metadata value or undefined
Example:
import { z } from 'zod';
const customerId = getMetadataHelper(
42,
'customerId',
z.number(),
this.#tabService.entityMap()
);
if (customerId !== undefined) {
console.log(`Customer ID: ${customerId}`);
}
Router Resolver
tabResolverFn: ResolveFn<Tab>
Angular Router resolver that ensures tab exists before route activation.
Behavior:
- Extracts tabId from route parameter
- Creates tab if it doesn't exist
- Activates the tab
- Returns tab entity for route data
Route Configuration:
import { Routes } from '@angular/router';
import { tabResolverFn } from '@isa/core/tabs';
export const routes: Routes = [
{
path: ':tabId/customer/:customerId',
component: CustomerComponent,
resolve: { tab: tabResolverFn }
}
];
Component Usage:
import { Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-customer',
template: '...'
})
export class CustomerComponent {
constructor(private route: ActivatedRoute) {
const tab = this.route.snapshot.data['tab'];
console.log(`Resolved tab: ${tab.name}`);
}
}
NavigateBackButtonComponent
Ready-to-use back button component with automatic state management.
Selector: tabs-navigate-back-button
Features:
- Automatically disables when no back history
- Uses current tab from TabService
- Integrates with Angular Router
- E2E testable with data-what attribute
Usage:
import { NavigateBackButtonComponent } from '@isa/core/tabs';
@Component({
selector: 'app-feature',
imports: [NavigateBackButtonComponent],
template: `
<tabs-navigate-back-button />
`
})
export class FeatureComponent {}
Usage Examples
Basic Tab Creation and Navigation
import { Component, inject } from '@angular/core';
import { TabService } from '@isa/core/tabs';
import { Router } from '@angular/router';
@Component({
selector: 'app-customer-search',
template: '...'
})
export class CustomerSearchComponent {
#tabService = inject(TabService);
#router = inject(Router);
openCustomerInNewTab(customerId: number): void {
// Create new tab with customer context
const tab = this.#tabService.addTab({
name: `Customer ${customerId}`,
tags: ['customer'],
metadata: { customerId }
});
// Activate the new tab
this.#tabService.activateTab(tab.id);
// Navigate to customer details
this.#router.navigate([tab.id, 'customer', customerId]);
}
}
Working with Tab Metadata
import { Component, inject, OnInit } from '@angular/core';
import { TabService, injectTabId, getMetadataHelper } from '@isa/core/tabs';
import { z } from 'zod';
const CustomerMetadataSchema = z.object({
customerId: z.number(),
customerName: z.string().optional(),
viewMode: z.enum(['details', 'orders', 'history']).default('details')
});
type CustomerMetadata = z.infer<typeof CustomerMetadataSchema>;
@Component({
selector: 'app-customer-details',
template: '...'
})
export class CustomerDetailsComponent implements OnInit {
#tabService = inject(TabService);
tabId = injectTabId();
ngOnInit() {
const id = this.tabId();
if (!id) return;
// Set initial metadata
this.#tabService.patchTabMetadata(id, {
customerId: 12345,
customerName: 'John Doe',
viewMode: 'details'
});
}
getCustomerMetadata(): CustomerMetadata | undefined {
const id = this.tabId();
if (!id) return undefined;
return getMetadataHelper(
id,
'customerMetadata',
CustomerMetadataSchema,
this.#tabService.entityMap()
);
}
switchViewMode(mode: 'details' | 'orders' | 'history'): void {
const id = this.tabId();
if (!id) return;
this.#tabService.patchTabMetadata(id, { viewMode: mode });
}
}
Custom History Configuration
import { Component, inject } from '@angular/core';
import { TabService } from '@isa/core/tabs';
@Component({
selector: 'app-wizard',
template: '...'
})
export class WizardComponent {
#tabService = inject(TabService);
createWizardTab(): void {
const tab = this.#tabService.addTab({
name: 'Order Wizard',
tags: ['wizard'],
metadata: {
// Override global history limits for wizard
maxHistorySize: 10, // Keep only last 10 steps
maxForwardHistory: 0 // Disable forward navigation (wizard-like)
}
});
this.#tabService.activateTab(tab.id);
}
// Forward navigation disabled by maxForwardHistory: 0
// User must complete wizard steps linearly
}
Browser-Style Navigation
import { Component, inject, computed } from '@angular/core';
import { TabService } from '@isa/core/tabs';
import { Router } from '@angular/router';
@Component({
selector: 'app-navigation-controls',
template: `
<button
[disabled]="!canGoBack()"
(click)="goBack()"
data-what="back-button">
Back
</button>
<button
[disabled]="!canGoForward()"
(click)="goForward()"
data-what="forward-button">
Forward
</button>
`
})
export class NavigationControlsComponent {
#tabService = inject(TabService);
#router = inject(Router);
canGoBack = computed(() => {
const tabId = this.#tabService.activatedTabId();
if (!tabId) return false;
const tab = this.#tabService.entityMap()[tabId];
return tab ? tab.location.current > 0 : false;
});
canGoForward = computed(() => {
const tabId = this.#tabService.activatedTabId();
if (!tabId) return false;
const tab = this.#tabService.entityMap()[tabId];
if (!tab) return false;
return tab.location.current < tab.location.locations.length - 1;
});
goBack(): void {
const tabId = this.#tabService.activatedTabId();
if (!tabId) return;
const previousLocation = this.#tabService.navigateBack(tabId);
if (previousLocation) {
this.#router.navigateByUrl(previousLocation.url);
}
}
goForward(): void {
const tabId = this.#tabService.activatedTabId();
if (!tabId) return;
const nextLocation = this.#tabService.navigateForward(tabId);
if (nextLocation) {
this.#router.navigateByUrl(nextLocation.url);
}
}
}
Tab Filtering and Organization
import { Component, inject, computed } from '@angular/core';
import { TabService } from '@isa/core/tabs';
@Component({
selector: 'app-tab-manager',
template: '...'
})
export class TabManagerComponent {
#tabService = inject(TabService);
// Filter tabs by tag
customerTabs = computed(() => {
return this.#tabService.entities().filter(tab =>
tab.tags.includes('customer')
);
});
orderTabs = computed(() => {
return this.#tabService.entities().filter(tab =>
tab.tags.includes('order')
);
});
// Sort tabs by last activation
recentTabs = computed(() => {
return [...this.#tabService.entities()]
.filter(tab => tab.activatedAt !== undefined)
.sort((a, b) => (b.activatedAt ?? 0) - (a.activatedAt ?? 0))
.slice(0, 5);
});
// Close all tabs with specific tag
closeCustomerTabs(): void {
this.customerTabs().forEach(tab => {
this.#tabService.removeTab(tab.id);
});
}
}
History Pruning
Pruning Strategies
The library provides three intelligent pruning strategies to manage memory usage while preserving navigation functionality.
1. Oldest First Strategy
Removes oldest history entries when size limit exceeded.
Use Case: Simple applications with linear navigation
Behavior:
- FIFO (First In, First Out) removal
- Preserves recent navigation
- Maintains current position
Example:
// With locations [A, B, C, D, E], current=2 (C), maxHistorySize=3
// Result: [C, D, E] with current=0 (still pointing to C)
Configuration:
import { TAB_CONFIG, createTabConfig } from '@isa/core/tabs';
{
provide: TAB_CONFIG,
useValue: createTabConfig({
pruningStrategy: 'oldest',
maxHistorySize: 50
})
}
2. Balanced Strategy (Default)
Maintains balanced window around current position.
Use Case: Most applications with forward/backward navigation
Behavior:
- Preserves 70% of entries before current position
- Preserves 30% of entries after current position
- Maintains navigation context around current location
Example:
// With current=5 in 20 locations, maxHistorySize=10
// Keeps ~7 entries before current, current entry, ~2 entries after
// Result preserves useful navigation context
Configuration:
{
provide: TAB_CONFIG,
useValue: createTabConfig({
pruningStrategy: 'balanced', // Default
maxHistorySize: 50
})
}
3. Smart Strategy
Intelligent pruning based on recency and proximity.
Use Case: Complex applications with varied navigation patterns
Behavior:
- Scores each location by recency and proximity to current
- Recent locations (< 1 hour): 100 points base
- Medium age (< 1 day): 60 points base
- Old locations (> 1 day): 20 points base
- Proximity bonus: 100 - (distance_from_current * 10)
- Keeps highest-scoring locations
Example:
// Preserves:
// - Recently visited pages (high recency score)
// - Pages near current position (high proximity score)
// - Removes old, distant entries first
Configuration:
{
provide: TAB_CONFIG,
useValue: createTabConfig({
pruningStrategy: 'smart',
maxHistorySize: 50
})
}
Forward History Limiting
Controls maximum forward history depth when adding new locations.
Purpose: Prevents unlimited "redo" depth while maintaining reasonable navigation
Default: 10 forward entries
Behavior:
// With current=2, maxForwardHistory=2
// Before: [A, B, C, D, E, F]
// ^
// After: [A, B, C, D, E]
// ^
// Preserved 2 forward entries (D, E), removed F
Configuration:
// Global configuration
{
provide: TAB_CONFIG,
useValue: createTabConfig({
maxForwardHistory: 5 // Limit forward history
})
}
// Per-tab override
this.#tabService.patchTabMetadata(tabId, {
maxForwardHistory: 0 // Disable forward history (wizard-like)
});
Index Validation
Automatic validation and correction of history indices.
Purpose: Maintains data integrity after history modifications
Behavior:
- Validates indices on all navigation operations
- Corrects invalid indices automatically
- Logs warnings when corrections occur (configurable)
Example:
// Invalid index: current=10 with only 5 locations
// Automatically corrected to: current=4 (last valid index)
// Empty history: locations=[], current=5
// Automatically corrected to: current=-1
Configuration:
{
provide: TAB_CONFIG,
useValue: createTabConfig({
enableIndexValidation: true, // Default: true
logPruning: true // Log correction warnings
})
}
Configuration
TabConfig Interface
interface TabConfig {
maxHistorySize: number; // Max history entries (default: 50)
maxForwardHistory: number; // Max forward entries (default: 10)
pruningStrategy: 'oldest' | 'balanced' | 'smart'; // Default: 'balanced'
enableIndexValidation: boolean; // Default: true
logPruning: boolean; // Default: false
}
Global Configuration
import { ApplicationConfig } from '@angular/core';
import { TAB_CONFIG, createTabConfig } from '@isa/core/tabs';
export const appConfig: ApplicationConfig = {
providers: [
{
provide: TAB_CONFIG,
useValue: createTabConfig({
maxHistorySize: 100,
maxForwardHistory: 20,
pruningStrategy: 'smart',
enableIndexValidation: true,
logPruning: true
})
}
]
};
Per-Tab Configuration Override
// Override global limits for specific tab
this.#tabService.addTab({
name: 'High History Tab',
metadata: {
maxHistorySize: 200, // Override global limit
maxForwardHistory: 50 // Override global limit
}
});
// Runtime override
this.#tabService.patchTabMetadata(tabId, {
maxHistorySize: 25,
maxForwardHistory: 5
});
Default Configuration
const DEFAULT_TAB_CONFIG: TabConfig = {
maxHistorySize: 50,
maxForwardHistory: 10,
pruningStrategy: 'balanced',
enableIndexValidation: true,
logPruning: false
};
Router Integration
Automatic Navigation Synchronization
The TabNavigationService automatically syncs Router events to tab history.
Setup:
import { ApplicationConfig, APP_INITIALIZER } from '@angular/core';
import { TabNavigationService } from '@isa/core/tabs';
export const appConfig: ApplicationConfig = {
providers: [
{
provide: APP_INITIALIZER,
useFactory: (tabNav: TabNavigationService) => () => tabNav.init(),
deps: [TabNavigationService],
multi: true
}
]
};
Behavior:
- Listens to NavigationEnd events
- Extracts tabId from URL patterns (/:tabId/... or /kunde/:processId/...)
- Creates location entries automatically
- Handles browser back/forward with pruning awareness
- Respects URL blacklist for excluded routes
URL Patterns
The service recognizes two URL patterns:
Modern Pattern:
// URL: /42/customer/123
// Extracted tabId: 42
Legacy Pattern:
// URL: /kunde/42/details
// Extracted processId (maps to tabId): 42
Fallback:
// URL: /dashboard (no ID in URL)
// Uses currently activated tab ID
URL Blacklist
Certain URLs are excluded from history tracking:
export const HISTORY_BLACKLIST_URLS = ['/kunde/dashboard'];
Router Resolver Integration
import { Routes } from '@angular/router';
import { tabResolverFn } from '@isa/core/tabs';
export const routes: Routes = [
{
path: ':tabId',
resolve: { tab: tabResolverFn },
children: [
{
path: 'customer/:customerId',
component: CustomerComponent
},
{
path: 'order/:orderId',
component: OrderComponent
}
]
}
];
Resolver Behavior:
- Extracts tabId from route parameter
- Validates tabId is positive integer
- Creates tab if it doesn't exist (name: 'Neuer Vorgang')
- Activates the tab
- Returns tab entity for route data access
Testing
The library uses Jest for testing.
Running Tests
# Run tests for this library
npx nx test core-tabs --skip-nx-cache
# Run tests with coverage
npx nx test core-tabs --code-coverage --skip-nx-cache
# Run tests in watch mode
npx nx test core-tabs --watch
Test Structure
The library would benefit from comprehensive unit tests covering:
- Tab creation - Add, activate, remove operations
- Navigation - Forward, backward, location management
- History pruning - All three strategies with various scenarios
- Index validation - Correction logic and edge cases
- Metadata management - Patch, retrieve, type validation
- Router integration - Navigation sync, blacklist, resolver
- State persistence - Storage integration
- Configuration - Global and per-tab overrides
Example Test (Recommended)
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from '@jest/globals';
import { TabService } from './tab';
describe('TabService', () => {
let service: TabService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(TabService);
});
it('should create tab with auto-generated ID', () => {
const tab = service.addTab({ name: 'Test Tab' });
expect(tab.id).toBeGreaterThan(0);
expect(tab.name).toBe('Test Tab');
expect(tab.location.current).toBe(-1);
expect(tab.location.locations).toEqual([]);
});
it('should navigate to location', () => {
const tab = service.addTab({ name: 'Test' });
const location = service.navigateToLocation(tab.id, {
timestamp: Date.now(),
title: 'Home',
url: '/home'
});
expect(location).not.toBeNull();
expect(location?.url).toBe('/home');
const updatedTab = service.entityMap()[tab.id];
expect(updatedTab.location.current).toBe(0);
expect(updatedTab.location.locations).toHaveLength(1);
});
it('should navigate back', () => {
const tab = service.addTab({ name: 'Test' });
service.navigateToLocation(tab.id, {
timestamp: Date.now(),
title: 'Home',
url: '/home'
});
service.navigateToLocation(tab.id, {
timestamp: Date.now(),
title: 'Products',
url: '/products'
});
const previousLocation = service.navigateBack(tab.id);
expect(previousLocation?.url).toBe('/home');
const updatedTab = service.entityMap()[tab.id];
expect(updatedTab.location.current).toBe(0);
});
});
Architecture Notes
Current Architecture
The library follows a layered architecture with NgRx Signals at its core:
Components/Features
↓
Injection Helpers (injectTab, injectTabId)
↓
TabNavigationService (Router sync)
↓
TabService (NgRx SignalStore)
↓
├─→ TabHistoryPruner (history management)
├─→ Storage Integration (UserStorageProvider)
├─→ DevTools Integration (Redux DevTools)
└─→ Zod Validation (schemas)
Design Patterns
1. Signal Store Pattern
Uses NgRx Signals with functional composition:
signalStore(
withDevtools('TabService'),
withStorage('tabs', UserStorageProvider, { autosave: true }),
withState({ activatedTabId: null }),
withEntities<Tab>(),
withProps(),
withComputed(),
withMethods()
)
Benefits:
- Reactive state management
- Automatic persistence
- DevTools integration
- Type-safe operations
2. Strategy Pattern (Pruning)
Three interchangeable pruning strategies:
switch (config.pruningStrategy) {
case 'oldest': return TabHistoryPruner.pruneOldestFirst(...);
case 'balanced': return TabHistoryPruner.pruneBalanced(...);
case 'smart': return TabHistoryPruner.pruneSmart(...);
}
Benefits:
- Configurable behavior
- Easy testing
- New strategies can be added
3. Injection Token Pattern
Dependency injection for configuration and ID generation:
export const TAB_CONFIG = new InjectionToken<TabConfig>('TAB_CONFIG', {
providedIn: 'root',
factory: () => DEFAULT_TAB_CONFIG
});
export const CORE_TAB_ID_GENERATOR = new InjectionToken<() => number>(
'CORE_TAB_ID_GENERATOR',
{ providedIn: 'root', factory: () => generateTabId }
);
Benefits:
- Easy testing with mocks
- Runtime configuration
- No circular dependencies
State Persistence
Automatic state persistence using withStorage():
withStorage('tabs', UserStorageProvider, { autosave: true })
Features:
- Saves to user-scoped storage
- Automatic save on state changes
- Loads on service initialization
- Validates loaded state with Zod
Storage Key: tabs (namespace for user storage)
Index Integrity
The system maintains history index integrity through:
- Validation on read -
getCurrentLocation()validates indices - Validation on navigation - Back/forward check bounds
- Correction on errors - Invalid indices auto-corrected
- Logging - Optional warnings for debugging
Example Correction:
// Corrupted state: current=10, locations.length=5
const { index, wasInvalid } = TabHistoryPruner.validateLocationIndex(
locations,
current
);
// index=4 (corrected to last valid), wasInvalid=true
Performance Considerations
- Computed Signals -
activatedTabis computed, not stored - Immutable Updates - Spread operators ensure immutability
- Pruning Efficiency - All strategies are O(n) or better
- Index Validation - O(1) validation, only corrects when needed
- Storage Optimization - Autosave debounced by library
Known Limitations
1. Tab ID Collision Risk (Low Priority)
Current State:
- Uses
Date.now()for ID generation - Collision possible with rapid tab creation
Proposed Solution:
- Add sequence counter:
Date.now() + (counter++) - Or use UUID/nanoid for guaranteed uniqueness
Impact: Low risk in practice, rapid creation uncommon
2. Storage Size Limits (Medium Priority)
Current State:
- No limit on number of tabs
- Storage could grow unbounded
Proposed Solutions:
- Maximum tab limit (configurable)
- Automatic cleanup of old inactive tabs
- Storage quota monitoring
Impact: Medium risk for long-running sessions
3. URL Blacklist Not Configurable (Low Priority)
Current State:
- Hardcoded blacklist in constants
- Cannot customize at runtime
Proposed Solution:
- Injection token for blacklist
- Merge with default blacklist
Impact: Low, blacklist rarely needs customization
Future Enhancements
Potential improvements identified:
- Tab Groups - Organize tabs into collapsible groups
- Tab Pinning - Pin important tabs to prevent closure
- Tab Duplication - Clone existing tabs with history
- History Export - Export/import tab state for debugging
- Analytics - Track tab usage patterns
- Lazy History - Only load history when needed
- Tab Sorting - Custom sort orders (recent, alphabetical, manual)
- Search - Search across tab history locations
Dependencies
Required Libraries
@angular/core- Angular framework@angular/router- Router integration@angular/platform-browser- Title service@ngrx/signals- State management@ngrx/signals/entities- Entity management@angular-architects/ngrx-toolkit- DevTools integration@isa/core/storage- Storage providers@isa/core/logging- Logging service@isa/ui/buttons- Button component (for NavigateBackButtonComponent)@isa/icons- Icon library (for NavigateBackButtonComponent)@ng-icons/core- Icon framework (for NavigateBackButtonComponent)@angular/cdk/coercion- Array coercion utilitieszod- Schema validation
Path Alias
Import from: @isa/core/tabs
License
Internal ISA Frontend library - not for external distribution.