/** * ViewHistory — lightweight in-app navigation stack for rApps. * * Each rApp with hierarchical views instantiates one, calls push() * on forward navigation, and back() from the back button. Replaces * hardcoded data-back targets with a proper history stack. * * Browser history integration (opt-in): * Pass `moduleId` to the constructor to sync push/back with the * browser's history.pushState / popstate. TabCache's popstate handler * dispatches `rspace-view-popstate` events for same-module navigation, * which this class catches and calls `back()` automatically, then * re-dispatches as `rspace-view-restored` for the component to re-render. */ export interface ViewEntry { view: V; context?: Record; } const MAX_DEPTH = 20; export class ViewHistory { private stack: ViewEntry[] = []; private root: V; private moduleId: string | null; private _skipNextPush = false; /** * @param rootView Default view when stack is empty. * @param moduleId If provided, push/back sync with browser history. */ constructor(rootView: V, moduleId?: string) { this.root = rootView; this.moduleId = moduleId ?? null; if (this.moduleId) { window.addEventListener('rspace-view-popstate', this._onPopstate as EventListener); } } /** Record a forward navigation. Skips if top of stack is same view+context. */ push(view: V, context?: Record): void { const top = this.stack[this.stack.length - 1]; if (top && top.view === view) return; // skip duplicate this.stack.push({ view, context }); if (this.stack.length > MAX_DEPTH) this.stack.shift(); // Sync with browser history if (this.moduleId && !this._skipNextPush) { const existing = history.state || {}; history.pushState( { ...existing, moduleId: existing.moduleId, spaceSlug: existing.spaceSlug, _view: view, _viewCtx: context }, '', ); } this._skipNextPush = false; } /** Pop and return the previous entry, or null if at root. */ back(): ViewEntry | null { if (this.stack.length <= 1) { this.stack = []; return { view: this.root }; } this.stack.pop(); // remove current return this.stack[this.stack.length - 1] ?? { view: this.root }; } /** True when there's history to go back to. */ get canGoBack(): boolean { return this.stack.length > 1; } /** Peek at the previous entry without popping. */ peekBack(): ViewEntry | null { if (this.stack.length <= 1) return { view: this.root }; return this.stack[this.stack.length - 2] ?? null; } /** Clear the stack and reset to a root view (e.g. on space switch). */ reset(rootView?: V): void { if (rootView !== undefined) this.root = rootView; this.stack = []; } /** Clean up the popstate listener. Call when the component disconnects. */ destroy(): void { if (this.moduleId) { window.removeEventListener('rspace-view-popstate', this._onPopstate as EventListener); } } /** Handle popstate events forwarded by TabCache for same-module nav. */ private _onPopstate = (e: CustomEvent) => { if (e.detail?.moduleId !== this.moduleId) return; // Pop our stack without triggering another pushState const prev = this.back(); if (!prev) return; // Tell the component to re-render with the restored view window.dispatchEvent(new CustomEvent('rspace-view-restored', { detail: { moduleId: this.moduleId, view: prev.view, context: prev.context }, })); }; }