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 { spaces, createSpace, resolveCallerRole, roleAtLeast } 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 { renderMainLanding, renderSpaceDashboard } from "./landing";
|
||||
import { syncServer } from "./sync-instance";
|
||||
|
|
@ -2595,6 +2595,22 @@ for (const mod of getAllModules()) {
|
|||
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);
|
||||
// Auto-mount browsable output list pages
|
||||
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 {
|
||||
/** Page <title> */
|
||||
title: string;
|
||||
|
|
@ -112,6 +160,8 @@ export interface ShellOptions {
|
|||
enabledModules?: string[] | null;
|
||||
/** Whether this space has client-side encryption enabled */
|
||||
spaceEncrypted?: boolean;
|
||||
/** If true, return lightweight JSON fragment instead of full shell HTML */
|
||||
fragment?: boolean;
|
||||
/** Optional tab bar rendered below the subnav. */
|
||||
tabs?: Array<{ id: string; label: string; icon?: string }>;
|
||||
/** 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}`;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
// 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 {
|
||||
title,
|
||||
moduleId,
|
||||
|
|
|
|||
|
|
@ -415,6 +415,20 @@ export class RStackAppSwitcher extends HTMLElement {
|
|||
};
|
||||
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
|
||||
// Only intercept when the shell tab system is active (window.__rspaceTabBar).
|
||||
// On landing pages (rspace.online/, rspace.online/{moduleId}), let links
|
||||
|
|
|
|||
|
|
@ -185,7 +185,6 @@ export class TabCache {
|
|||
try {
|
||||
const resolved = new URL(url, window.location.href);
|
||||
if (resolved.origin !== window.location.origin) {
|
||||
// Rewrite subdomain URL to path format: /{space}/{moduleId}
|
||||
fetchUrl = `/${space}/${moduleId}`;
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -196,7 +195,9 @@ export class TabCache {
|
|||
const app = document.getElementById("app");
|
||||
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
|
||||
const loadingPane = document.createElement("div");
|
||||
|
|
@ -212,23 +213,33 @@ export class TabCache {
|
|||
const timeoutId = setTimeout(() => controller.abort(), 15_000);
|
||||
|
||||
try {
|
||||
const resp = await fetch(fetchUrl, {
|
||||
headers: { "Accept": "text/html" },
|
||||
const resp = await fetch(fragmentUrl, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
clearTimeout(timeoutId);
|
||||
console.warn("[TabCache] fetchAndInject: HTTP", resp.status, "for", fetchUrl);
|
||||
console.warn("[TabCache] fetchAndInject: HTTP", resp.status, "for", fragmentUrl);
|
||||
loadingPane.remove();
|
||||
return false;
|
||||
}
|
||||
|
||||
const html = await resp.text();
|
||||
clearTimeout(timeoutId);
|
||||
console.log("[TabCache] fetchAndInject: got", html.length, "bytes for", moduleId);
|
||||
const content = this.extractContent(html);
|
||||
const ct = resp.headers.get("content-type") || "";
|
||||
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) {
|
||||
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();
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue