fix(tabs): strip duplicate shell elements from canvas tab injection

The canvas.html body contained <rstack-tab-bar>, <rstack-space-settings>,
and <rstack-history-panel> elements that weren't being stripped by
extractCanvasContent (the tab-row regex failed due to extra children).
When injected via TabCache, these duplicate elements interfered with the
shell's tab management, causing tabs to appear wiped.

Fixes:
- Server: robust div-counting strip for rstack-tab-row + explicit strips
  for space-settings and history-panel
- Client: DOM-based safety strip in TabCache.extractContent()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 17:21:52 -07:00
parent 13a7e44e24
commit e8379541cc
2 changed files with 33 additions and 2 deletions

View File

@ -36,6 +36,29 @@ routes.get("/api/meta", async (c) => {
* Strips the shell chrome (header, tab-bar, welcome overlay) that renderShell provides,
* and returns just the canvas-specific DOM + inline styles + module scripts.
*/
/** Strip a <div class="className"> and all its nested children by counting open/close tags. */
function stripNestedDiv(html: string, className: string): string {
const re = new RegExp(`<div\\s[^>]*class="[^"]*${className}[^"]*"[^>]*>`);
const match = html.match(re);
if (!match || match.index === undefined) return html;
let depth = 1;
let pos = match.index + match[0].length;
while (depth > 0 && pos < html.length) {
const nextOpen = html.indexOf("<div", pos);
const nextClose = html.indexOf("</div>", pos);
if (nextClose === -1) break;
if (nextOpen !== -1 && nextOpen < nextClose) {
depth++;
pos = nextOpen + 4;
} else {
depth--;
pos = nextClose + 6;
}
}
return html.substring(0, match.index) + html.substring(pos);
}
function extractCanvasContent(html: string): { body: string; styles: string; scripts: string } {
// Extract inline <style> blocks from <head> (canvas CSS)
const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
@ -58,11 +81,15 @@ function extractCanvasContent(html: string): { body: string; styles: string; scr
let bodyContent = bodyMatch?.[1] || "";
// Strip shell chrome that renderShell already provides:
// header, tab-bar (contains only a <rstack-tab-bar>), and welcome overlay.
// header, tab-row (including all children like people badge/panel),
// space-settings, history-panel, and welcome overlay.
bodyContent = bodyContent
.replace(/<header[\s\S]*?<\/header>/i, "")
.replace(/<div class="rstack-tab-row"[^>]*>\s*<rstack-tab-bar[^>]*><\/rstack-tab-bar>\s*<\/div>/i, "")
.replace(/<rstack-space-settings[^>]*><\/rstack-space-settings>/gi, "")
.replace(/<rstack-history-panel[^>]*><\/rstack-history-panel>/gi, "")
.replace(/<!--\s*Welcome overlay[\s\S]*?<\/div>\s*<\/div>\s*<\/div>/i, "");
// Strip the rstack-tab-row div (nested divs make simple regex unreliable)
bodyContent = stripNestedDiv(bodyContent, "rstack-tab-row");
const styles = styleBlocks.join("\n");
const scripts = scriptSrcs.map(s => `<script type="module" crossorigin src="${s}"></script>`).join("\n");

View File

@ -272,6 +272,10 @@ export class TabCache {
let body: string;
if (appEl) {
// Remove duplicate shell chrome that shouldn't be in tab panes
appEl.querySelectorAll(
".rstack-tab-row, rstack-tab-bar, rstack-space-settings, rstack-history-panel",
).forEach((el) => el.remove());
body = appEl.innerHTML;
} else if (iframeWrap) {
body = iframeWrap.outerHTML;