perf(shell): add fragment mode for fast rApp tab switching
When switching rApps from the dropdown, TabCache now requests ?fragment=1 which returns a lightweight JSON payload (~200 bytes) instead of re-rendering the full 2000-line shell HTML template. This eliminates server-side shell rendering and client-side DOMParser overhead. Also prefetches fragments on hover. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
12499d62b5
commit
f9457a0e11
|
|
@ -84,7 +84,7 @@ import { vnbModule } from "../modules/rvnb/mod";
|
||||||
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
||||||
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
||||||
import type { SpaceRoleString } from "./spaces";
|
import type { SpaceRoleString } from "./spaces";
|
||||||
import { renderShell, renderSubPageInfo, renderOnboarding } from "./shell";
|
import { renderShell, renderSubPageInfo, renderOnboarding, setFragmentMode } from "./shell";
|
||||||
import { renderOutputListPage } from "./output-list";
|
import { renderOutputListPage } from "./output-list";
|
||||||
import { renderMainLanding, renderSpaceDashboard } from "./landing";
|
import { renderMainLanding, renderSpaceDashboard } from "./landing";
|
||||||
import { syncServer } from "./sync-instance";
|
import { syncServer } from "./sync-instance";
|
||||||
|
|
@ -2595,6 +2595,22 @@ for (const mod of getAllModules()) {
|
||||||
return next();
|
return next();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Fragment mode: signal renderShell to return lightweight JSON instead of full shell HTML.
|
||||||
|
// Used by TabCache for fast tab switching — skips the entire 2000-line shell template.
|
||||||
|
app.use(`/:space/${mod.id}`, async (c, next) => {
|
||||||
|
if (c.req.method !== "GET" || !c.req.query("fragment")) return next();
|
||||||
|
// Set the global flag so renderShell returns JSON fragment
|
||||||
|
setFragmentMode(true);
|
||||||
|
await next();
|
||||||
|
// renderShell already returned JSON — fix the content-type header
|
||||||
|
if (c.res.headers.get("content-type")?.includes("text/html")) {
|
||||||
|
const body = await c.res.text();
|
||||||
|
c.res = new Response(body, {
|
||||||
|
status: c.res.status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
app.route(`/:space/${mod.id}`, mod.routes);
|
app.route(`/:space/${mod.id}`, mod.routes);
|
||||||
// Auto-mount browsable output list pages
|
// Auto-mount browsable output list pages
|
||||||
if (mod.outputPaths) {
|
if (mod.outputPaths) {
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,54 @@ export function getSpaceShellMeta(spaceSlug: string): { enabledModules: string[]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a lightweight JSON fragment for tab-cache.
|
||||||
|
* Returns only the body HTML, title, scripts, and styles — no shell chrome.
|
||||||
|
* Used by TabCache's ?fragment=1 requests to avoid re-rendering the full 2000-line shell.
|
||||||
|
*/
|
||||||
|
export function renderFragment(opts: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
scripts?: string;
|
||||||
|
styles?: string;
|
||||||
|
}): string {
|
||||||
|
// Parse script srcs from the scripts string
|
||||||
|
const scriptSrcs: string[] = [];
|
||||||
|
const scriptRe = /src="([^"]+)"/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
if (opts.scripts) {
|
||||||
|
while ((m = scriptRe.exec(opts.scripts)) !== null) {
|
||||||
|
scriptSrcs.push(m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse stylesheet hrefs from the styles string
|
||||||
|
const styleSrcs: string[] = [];
|
||||||
|
const styleRe = /href="([^"]+)"/g;
|
||||||
|
if (opts.styles) {
|
||||||
|
while ((m = styleRe.exec(opts.styles)) !== null) {
|
||||||
|
styleSrcs.push(m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract inline <style> content
|
||||||
|
const inlineStyles: string[] = [];
|
||||||
|
const inlineRe = /<style[^>]*>([\s\S]*?)<\/style>/g;
|
||||||
|
if (opts.styles) {
|
||||||
|
while ((m = inlineRe.exec(opts.styles)) !== null) {
|
||||||
|
if (m[1].trim()) inlineStyles.push(m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
title: opts.title,
|
||||||
|
body: opts.body,
|
||||||
|
scripts: scriptSrcs,
|
||||||
|
styles: styleSrcs,
|
||||||
|
inlineStyles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShellOptions {
|
export interface ShellOptions {
|
||||||
/** Page <title> */
|
/** Page <title> */
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -112,6 +160,8 @@ export interface ShellOptions {
|
||||||
enabledModules?: string[] | null;
|
enabledModules?: string[] | null;
|
||||||
/** Whether this space has client-side encryption enabled */
|
/** Whether this space has client-side encryption enabled */
|
||||||
spaceEncrypted?: boolean;
|
spaceEncrypted?: boolean;
|
||||||
|
/** If true, return lightweight JSON fragment instead of full shell HTML */
|
||||||
|
fragment?: boolean;
|
||||||
/** Optional tab bar rendered below the subnav. */
|
/** Optional tab bar rendered below the subnav. */
|
||||||
tabs?: Array<{ id: string; label: string; icon?: string }>;
|
tabs?: Array<{ id: string; label: string; icon?: string }>;
|
||||||
/** Active tab ID (matched from URL path by server). First tab if omitted. */
|
/** Active tab ID (matched from URL path by server). First tab if omitted. */
|
||||||
|
|
@ -138,7 +188,23 @@ export function buildSpaceUrl(space: string, path: string, host?: string): strin
|
||||||
return `http://${h}/${space}${path}`;
|
return `http://${h}/${space}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Global flag set by middleware to signal fragment mode to renderShell. */
|
||||||
|
let _fragmentMode = false;
|
||||||
|
export function setFragmentMode(on: boolean) { _fragmentMode = on; }
|
||||||
|
|
||||||
export function renderShell(opts: ShellOptions): string {
|
export function renderShell(opts: ShellOptions): string {
|
||||||
|
// Fragment mode: return lightweight JSON instead of full shell HTML.
|
||||||
|
// Triggered by middleware setting the flag, or by opts.fragment.
|
||||||
|
if (opts.fragment || _fragmentMode) {
|
||||||
|
_fragmentMode = false; // Reset after use
|
||||||
|
return renderFragment({
|
||||||
|
title: opts.title,
|
||||||
|
body: opts.body,
|
||||||
|
scripts: opts.scripts,
|
||||||
|
styles: opts.styles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
moduleId,
|
moduleId,
|
||||||
|
|
|
||||||
|
|
@ -415,6 +415,20 @@ export class RStackAppSwitcher extends HTMLElement {
|
||||||
};
|
};
|
||||||
document.addEventListener("click", this.#outsideClickHandler);
|
document.addEventListener("click", this.#outsideClickHandler);
|
||||||
|
|
||||||
|
// Prefetch module fragments on hover for faster tab switching.
|
||||||
|
// Uses low-priority fetch so it doesn't compete with user-initiated requests.
|
||||||
|
if ((window as any).__rspaceTabBar) {
|
||||||
|
this.#shadow.querySelectorAll("a.item").forEach((el) => {
|
||||||
|
el.addEventListener("mouseenter", () => {
|
||||||
|
const moduleId = (el as HTMLElement).dataset.id;
|
||||||
|
if (!moduleId || moduleId === current) return;
|
||||||
|
const space = this.#getSpaceSlug();
|
||||||
|
const href = `/${space}/${moduleId}?fragment=1`;
|
||||||
|
fetch(href, { priority: "low" } as RequestInit).catch(() => {});
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Intercept same-origin module links → dispatch event for tab system
|
// Intercept same-origin module links → dispatch event for tab system
|
||||||
// Only intercept when the shell tab system is active (window.__rspaceTabBar).
|
// Only intercept when the shell tab system is active (window.__rspaceTabBar).
|
||||||
// On landing pages (rspace.online/, rspace.online/{moduleId}), let links
|
// On landing pages (rspace.online/, rspace.online/{moduleId}), let links
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,6 @@ export class TabCache {
|
||||||
try {
|
try {
|
||||||
const resolved = new URL(url, window.location.href);
|
const resolved = new URL(url, window.location.href);
|
||||||
if (resolved.origin !== window.location.origin) {
|
if (resolved.origin !== window.location.origin) {
|
||||||
// Rewrite subdomain URL to path format: /{space}/{moduleId}
|
|
||||||
fetchUrl = `/${space}/${moduleId}`;
|
fetchUrl = `/${space}/${moduleId}`;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -196,7 +195,9 @@ export class TabCache {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
if (!app) { console.warn("[TabCache] fetchAndInject: no #app"); return false; }
|
if (!app) { console.warn("[TabCache] fetchAndInject: no #app"); return false; }
|
||||||
|
|
||||||
console.log("[TabCache] fetchAndInject:", fetchUrl, "for", moduleId);
|
// Append ?fragment=1 for lightweight JSON response (no full shell re-render)
|
||||||
|
const fragmentUrl = fetchUrl + (fetchUrl.includes("?") ? "&" : "?") + "fragment=1";
|
||||||
|
console.log("[TabCache] fetchAndInject:", fragmentUrl, "for", moduleId);
|
||||||
|
|
||||||
// Show loading spinner
|
// Show loading spinner
|
||||||
const loadingPane = document.createElement("div");
|
const loadingPane = document.createElement("div");
|
||||||
|
|
@ -212,23 +213,33 @@ export class TabCache {
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 15_000);
|
const timeoutId = setTimeout(() => controller.abort(), 15_000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(fetchUrl, {
|
const resp = await fetch(fragmentUrl, {
|
||||||
headers: { "Accept": "text/html" },
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
console.warn("[TabCache] fetchAndInject: HTTP", resp.status, "for", fetchUrl);
|
console.warn("[TabCache] fetchAndInject: HTTP", resp.status, "for", fragmentUrl);
|
||||||
loadingPane.remove();
|
loadingPane.remove();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = await resp.text();
|
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
console.log("[TabCache] fetchAndInject: got", html.length, "bytes for", moduleId);
|
const ct = resp.headers.get("content-type") || "";
|
||||||
const content = this.extractContent(html);
|
let content: { body: string; title: string; scripts: string[]; styles: string[]; inlineStyles: string[] } | null;
|
||||||
|
|
||||||
|
if (ct.includes("application/json")) {
|
||||||
|
// Fast path: server returned pre-extracted JSON fragment
|
||||||
|
content = await resp.json();
|
||||||
|
console.log("[TabCache] fetchAndInject: got JSON fragment for", moduleId);
|
||||||
|
} else {
|
||||||
|
// Fallback: server returned full HTML (fragment middleware didn't match)
|
||||||
|
const html = await resp.text();
|
||||||
|
console.log("[TabCache] fetchAndInject: got", html.length, "bytes HTML (fallback) for", moduleId);
|
||||||
|
content = this.extractContent(html);
|
||||||
|
}
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
console.warn("[TabCache] fetchAndInject: extractContent returned null for", moduleId, "— HTML has #app:", html.includes('id="app"'));
|
console.warn("[TabCache] fetchAndInject: no content extracted for", moduleId);
|
||||||
loadingPane.remove();
|
loadingPane.remove();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue