feat: client-side tab caching for instant tab switching
Previously loaded tabs stay in the DOM and are shown/hidden via CSS. New tabs are fetched via fetch() + DOMParser on first visit, then cached. Switching back is instant with no network request. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d9bd7557fa
commit
abbfb552cc
|
|
@ -54,7 +54,7 @@ export function renderShell(opts: ShellOptions): string {
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<link rel="stylesheet" href="/shell.css?v=5">
|
||||
<link rel="stylesheet" href="/shell.css?v=6">
|
||||
<style>
|
||||
/* When loaded inside an iframe (e.g. standalone domain redirecting back),
|
||||
hide the shell chrome — the parent rSpace page already provides it. */
|
||||
|
|
@ -94,7 +94,7 @@ export function renderShell(opts: ShellOptions): string {
|
|||
${renderWelcomeOverlay()}
|
||||
|
||||
<script type="module">
|
||||
import '/shell.js?v=5';
|
||||
import '/shell.js?v=6';
|
||||
// Provide module list to app switcher
|
||||
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
|
||||
|
||||
|
|
@ -206,31 +206,62 @@ export function renderShell(opts: ShellOptions): string {
|
|||
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
|
||||
}
|
||||
|
||||
// ── Tab cache: instant switching via show/hide DOM panes ──
|
||||
let tabCache = null;
|
||||
try {
|
||||
const TC = window.__RSpaceTabCache;
|
||||
if (TC) {
|
||||
tabCache = new TC(spaceSlug, currentModuleId);
|
||||
if (!tabCache.init()) tabCache = null;
|
||||
}
|
||||
} catch(e) { tabCache = null; }
|
||||
|
||||
// ── Tab events ──
|
||||
tabBar.addEventListener('layer-switch', (e) => {
|
||||
const { moduleId } = e.detail;
|
||||
saveTabs();
|
||||
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
|
||||
if (tabCache) {
|
||||
tabCache.switchTo(moduleId).then(ok => {
|
||||
if (!ok) window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
|
||||
});
|
||||
} else {
|
||||
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
|
||||
}
|
||||
});
|
||||
|
||||
tabBar.addEventListener('layer-add', (e) => {
|
||||
const { moduleId } = e.detail;
|
||||
// Add the new module before navigating so the next page sees it
|
||||
if (!layers.find(l => l.moduleId === moduleId)) {
|
||||
layers.push(makeLayer(moduleId, layers.length));
|
||||
}
|
||||
saveTabs();
|
||||
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
|
||||
if (tabCache) {
|
||||
tabCache.switchTo(moduleId).then(ok => {
|
||||
if (!ok) window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
|
||||
});
|
||||
} else {
|
||||
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
|
||||
}
|
||||
});
|
||||
|
||||
tabBar.addEventListener('layer-close', (e) => {
|
||||
const { layerId } = e.detail;
|
||||
const closedModuleId = layerId.replace('layer-', '');
|
||||
tabBar.removeLayer(layerId);
|
||||
layers = layers.filter(l => l.id !== layerId);
|
||||
saveTabs();
|
||||
// Remove cached pane from DOM
|
||||
if (tabCache) tabCache.removePane(closedModuleId);
|
||||
// If we closed the active tab, switch to the first remaining
|
||||
if (layerId === 'layer-' + currentModuleId && layers.length > 0) {
|
||||
window.location.href = window.__rspaceNavUrl(spaceSlug, layers[0].moduleId);
|
||||
const nextModuleId = layers[0].moduleId;
|
||||
if (tabCache) {
|
||||
tabCache.switchTo(nextModuleId).then(ok => {
|
||||
if (!ok) window.location.href = window.__rspaceNavUrl(spaceSlug, nextModuleId);
|
||||
});
|
||||
} else {
|
||||
window.location.href = window.__rspaceNavUrl(spaceSlug, nextModuleId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -355,7 +386,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<link rel="stylesheet" href="/shell.css?v=5">
|
||||
<link rel="stylesheet" href="/shell.css?v=6">
|
||||
<style>
|
||||
html.rspace-embedded .rstack-header { display: none !important; }
|
||||
html.rspace-embedded .rstack-tab-row { display: none !important; }
|
||||
|
|
@ -398,7 +429,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
|
|||
</div>
|
||||
|
||||
<script type="module">
|
||||
import '/shell.js?v=5';
|
||||
import '/shell.js?v=6';
|
||||
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
|
||||
|
||||
const tabBar = document.querySelector('rstack-tab-bar');
|
||||
|
|
@ -581,7 +612,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>${mod.icon}</text></svg>">
|
||||
<title>${escapeHtml(mod.name)} — rSpace</title>
|
||||
<link rel="stylesheet" href="/shell.css?v=5">
|
||||
<link rel="stylesheet" href="/shell.css?v=6">
|
||||
${cssBlock}
|
||||
<script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script>
|
||||
</head>
|
||||
|
|
@ -601,7 +632,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
|
|||
</header>
|
||||
${bodyContent}
|
||||
<script type="module">
|
||||
import '/shell.js?v=5';
|
||||
import '/shell.js?v=6';
|
||||
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
|
||||
try {
|
||||
var raw = localStorage.getItem('encryptid_session');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
/**
|
||||
* Client-side tab cache for instant tab switching.
|
||||
*
|
||||
* Instead of full page navigations, previously loaded tabs are kept in the DOM
|
||||
* and shown/hidden via CSS. New tabs are fetched via fetch() + DOMParser on
|
||||
* first visit, then stay in the DOM forever.
|
||||
*
|
||||
* Hiding preserves ALL component state: Shadow DOM, event listeners,
|
||||
* WebSocket connections, timers.
|
||||
*/
|
||||
|
||||
export class TabCache {
|
||||
private currentModuleId: string;
|
||||
private spaceSlug: string;
|
||||
private panes = new Map<string, HTMLElement>();
|
||||
|
||||
constructor(spaceSlug: string, moduleId: string) {
|
||||
this.spaceSlug = spaceSlug;
|
||||
this.currentModuleId = moduleId;
|
||||
}
|
||||
|
||||
/** Wrap the initial #app content in a pane div and set up popstate */
|
||||
init(): boolean {
|
||||
const app = document.getElementById("app");
|
||||
if (!app) return false;
|
||||
|
||||
// Wrap existing content in a pane
|
||||
const pane = document.createElement("div");
|
||||
pane.className = "rspace-tab-pane rspace-tab-pane--active";
|
||||
pane.dataset.moduleId = this.currentModuleId;
|
||||
pane.dataset.pageTitle = document.title;
|
||||
|
||||
while (app.firstChild) {
|
||||
pane.appendChild(app.firstChild);
|
||||
}
|
||||
app.appendChild(pane);
|
||||
this.panes.set(this.currentModuleId, pane);
|
||||
|
||||
// Push initial state into history
|
||||
history.replaceState(
|
||||
{ moduleId: this.currentModuleId, spaceSlug: this.spaceSlug },
|
||||
"",
|
||||
window.location.href,
|
||||
);
|
||||
|
||||
// Handle browser back/forward
|
||||
window.addEventListener("popstate", (e) => {
|
||||
const state = e.state;
|
||||
if (state?.moduleId && this.panes.has(state.moduleId)) {
|
||||
this.showPane(state.moduleId);
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Switch to a module tab. Returns true if handled client-side. */
|
||||
async switchTo(moduleId: string): Promise<boolean> {
|
||||
if (moduleId === this.currentModuleId) return true;
|
||||
|
||||
if (this.panes.has(moduleId)) {
|
||||
this.showPane(moduleId);
|
||||
this.updateUrl(moduleId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.fetchAndInject(moduleId);
|
||||
}
|
||||
|
||||
/** Check if a pane exists in the DOM cache */
|
||||
has(moduleId: string): boolean {
|
||||
return this.panes.has(moduleId);
|
||||
}
|
||||
|
||||
/** Remove a cached pane from the DOM */
|
||||
removePane(moduleId: string): void {
|
||||
const pane = this.panes.get(moduleId);
|
||||
if (pane) {
|
||||
pane.remove();
|
||||
this.panes.delete(moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch a module page, extract content, and inject into a new pane */
|
||||
private async fetchAndInject(moduleId: string): Promise<boolean> {
|
||||
const navUrl = (window as any).__rspaceNavUrl;
|
||||
if (!navUrl) return false;
|
||||
const url: string = navUrl(this.spaceSlug, moduleId);
|
||||
|
||||
// Cross-origin URLs → fall back to full navigation
|
||||
try {
|
||||
const resolved = new URL(url, window.location.href);
|
||||
if (resolved.origin !== window.location.origin) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const app = document.getElementById("app");
|
||||
if (!app) return false;
|
||||
|
||||
// Show loading spinner
|
||||
const loadingPane = document.createElement("div");
|
||||
loadingPane.className =
|
||||
"rspace-tab-pane rspace-tab-pane--active rspace-tab-loading";
|
||||
loadingPane.innerHTML = '<div class="rspace-tab-spinner"></div>';
|
||||
this.hideAllPanes();
|
||||
app.appendChild(loadingPane);
|
||||
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
loadingPane.remove();
|
||||
return false;
|
||||
}
|
||||
|
||||
const html = await resp.text();
|
||||
const content = this.extractContent(html);
|
||||
if (!content) {
|
||||
loadingPane.remove();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove loading spinner
|
||||
loadingPane.remove();
|
||||
|
||||
// Create the real pane
|
||||
const pane = document.createElement("div");
|
||||
pane.className = "rspace-tab-pane rspace-tab-pane--active";
|
||||
pane.dataset.moduleId = moduleId;
|
||||
pane.dataset.pageTitle = content.title;
|
||||
pane.innerHTML = content.body;
|
||||
app.appendChild(pane);
|
||||
this.panes.set(moduleId, pane);
|
||||
|
||||
// Load module-specific assets
|
||||
this.loadAssets(content.scripts, content.styles);
|
||||
|
||||
// Update shell state
|
||||
this.currentModuleId = moduleId;
|
||||
document.title = content.title;
|
||||
this.updateShellState(moduleId);
|
||||
this.updateUrl(moduleId);
|
||||
this.updateCanvasLayout(moduleId);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
loadingPane.remove();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse full HTML response and extract module content */
|
||||
private extractContent(
|
||||
html: string,
|
||||
): {
|
||||
body: string;
|
||||
title: string;
|
||||
scripts: string[];
|
||||
styles: string[];
|
||||
} | null {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
// Extract #app content or .rspace-iframe-wrap (for external app modules)
|
||||
const appEl = doc.getElementById("app");
|
||||
const iframeWrap = doc.querySelector(".rspace-iframe-wrap");
|
||||
|
||||
let body: string;
|
||||
if (appEl) {
|
||||
body = appEl.innerHTML;
|
||||
} else if (iframeWrap) {
|
||||
body = iframeWrap.outerHTML;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = doc.title || "";
|
||||
|
||||
// Extract module-specific scripts (exclude shell.js and inline shell bootstrap)
|
||||
const scripts: string[] = [];
|
||||
doc.querySelectorAll('script[type="module"]').forEach((s) => {
|
||||
const src = s.getAttribute("src") || "";
|
||||
if (src.includes("shell.js")) return;
|
||||
const text = s.textContent || "";
|
||||
if (text.includes("import '/shell.js")) return;
|
||||
if (text.includes('import "/shell.js')) return;
|
||||
if (src) scripts.push(src);
|
||||
});
|
||||
|
||||
// Extract module-specific styles (exclude shell.css)
|
||||
const styles: string[] = [];
|
||||
doc.querySelectorAll('link[rel="stylesheet"]').forEach((l) => {
|
||||
const href = l.getAttribute("href") || "";
|
||||
if (href.includes("shell.css")) return;
|
||||
styles.push(href);
|
||||
});
|
||||
|
||||
return { body, title, scripts, styles };
|
||||
}
|
||||
|
||||
/** Load script and style assets (idempotent — browsers deduplicate module scripts) */
|
||||
private loadAssets(scripts: string[], styles: string[]): void {
|
||||
for (const src of scripts) {
|
||||
const el = document.createElement("script");
|
||||
el.type = "module";
|
||||
el.src = src;
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
|
||||
for (const href of styles) {
|
||||
if (document.querySelector(`link[href="${CSS.escape(href)}"]`))
|
||||
continue;
|
||||
const el = document.createElement("link");
|
||||
el.rel = "stylesheet";
|
||||
el.href = href;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
/** Show a specific pane and update shell state */
|
||||
private showPane(moduleId: string): void {
|
||||
this.hideAllPanes();
|
||||
const pane = this.panes.get(moduleId);
|
||||
if (pane) {
|
||||
pane.classList.add("rspace-tab-pane--active");
|
||||
const storedTitle = pane.dataset.pageTitle;
|
||||
if (storedTitle) document.title = storedTitle;
|
||||
}
|
||||
this.currentModuleId = moduleId;
|
||||
this.updateShellState(moduleId);
|
||||
this.updateCanvasLayout(moduleId);
|
||||
}
|
||||
|
||||
/** Hide all panes */
|
||||
private hideAllPanes(): void {
|
||||
const app = document.getElementById("app");
|
||||
if (!app) return;
|
||||
app.querySelectorAll(".rspace-tab-pane").forEach((p) => {
|
||||
p.classList.remove("rspace-tab-pane--active");
|
||||
});
|
||||
}
|
||||
|
||||
/** Update app-switcher and tab-bar to reflect current module */
|
||||
private updateShellState(moduleId: string): void {
|
||||
const appSwitcher = document.querySelector("rstack-app-switcher");
|
||||
if (appSwitcher) appSwitcher.setAttribute("current", moduleId);
|
||||
|
||||
const tabBar = (window as any).__rspaceTabBar;
|
||||
if (tabBar) tabBar.setAttribute("active", "layer-" + moduleId);
|
||||
}
|
||||
|
||||
/** Toggle canvas-layout class on #app */
|
||||
private updateCanvasLayout(moduleId: string): void {
|
||||
const app = document.getElementById("app");
|
||||
if (!app) return;
|
||||
if (moduleId === "canvas") {
|
||||
app.classList.add("canvas-layout");
|
||||
} else {
|
||||
app.classList.remove("canvas-layout");
|
||||
}
|
||||
}
|
||||
|
||||
/** Push new URL to browser history */
|
||||
private updateUrl(moduleId: string): void {
|
||||
const navUrl = (window as any).__rspaceNavUrl;
|
||||
if (!navUrl) return;
|
||||
const url: string = navUrl(this.spaceSlug, moduleId);
|
||||
history.pushState(
|
||||
{ moduleId, spaceSlug: this.spaceSlug },
|
||||
"",
|
||||
url,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -381,3 +381,29 @@ body[data-theme="light"] {
|
|||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tab pane caching (show/hide) ── */
|
||||
|
||||
.rspace-tab-pane {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rspace-tab-pane--active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rspace-tab-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 92px);
|
||||
}
|
||||
|
||||
.rspace-tab-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid rgba(255,255,255,0.1);
|
||||
border-top-color: #14b8a6;
|
||||
border-radius: 50%;
|
||||
animation: rspace-spin 0.8s linear infinite;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,14 @@ import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher"
|
|||
import { RStackTabBar } from "../shared/components/rstack-tab-bar";
|
||||
import { RStackMi } from "../shared/components/rstack-mi";
|
||||
import { rspaceNavUrl } from "../shared/url-helpers";
|
||||
import { TabCache } from "../shared/tab-cache";
|
||||
|
||||
// Expose URL helper globally (used by shell inline scripts + components)
|
||||
(window as any).__rspaceNavUrl = rspaceNavUrl;
|
||||
|
||||
// Expose TabCache for inline shell script to instantiate
|
||||
(window as any).__RSpaceTabCache = TabCache;
|
||||
|
||||
// Register all header components
|
||||
RStackIdentity.define();
|
||||
RStackAppSwitcher.define();
|
||||
|
|
|
|||
Loading…
Reference in New Issue