From 50792aae482f4575cd3b4700cf652060763f2683 Mon Sep 17 00:00:00 2001 From: fforres Date: Sun, 18 Oct 2026 00:11:16 -0700 Subject: [PATCH 1/4] perf(mcp-apps-shell): drop the shell document's own Tailwind browser compiler The shell document imported @tailwindcss/browser although generated code now renders in the inner frame, which has its own compiler. The shell chrome is styled by the build-time sheet, so the second compiler only duplicated rules against the default theme (font-mono fell back to the system monospace) and shipped the 282 KB bundle twice in the shell HTML. Claude-Session: https://claude.ai/code/session_019ZE3j9YzRKxT4LaiDCBv6S --- .../src/shell/artifact-renderer.tsx | 9 ++-- .../src/shell/mcp-app.browser.test.ts | 50 +++++++++++++++++++ .../mcp-apps-shell/src/shell/shell-app.tsx | 1 - packages/react/src/api/artifact-renderer.tsx | 6 +-- 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx index be0ddbf8e9..789b5d8c97 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx +++ b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx @@ -40,11 +40,10 @@ import { FILL_DISPLAY_MODE, INLINE_DISPLAY_MODE } from "./display-mode"; * That is not architectural purity for its own sake; mounting `McpAppsShell` * inline was actively broken: * - * - It imports `@tailwindcss/browser` and its own `globals.css` at IMPORT - * scope, and it themes by toggling `document.documentElement`. Inline, all - * of that landed on the CONSOLE document — the shell's palette (a teal - * `--primary`) overwrote the console's strictly-grayscale tokens for the - * rest of the session, and its runtime Tailwind JIT kept mutating the page. + * - It imports its own `globals.css` at IMPORT scope, and it themes by + * toggling `document.documentElement`. Inline, all of that landed on the + * CONSOLE document — the shell's palette (a teal `--primary`) overwrote the + * console's strictly-grayscale tokens for the rest of the session. * - Its inner renderer reports content height over the MCP-Apps resize * protocol, which expects a host frame to consume it. With no host, nothing * did, so tall artifacts clipped mid-viewport. diff --git a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts index 96adc1b083..5e65f96268 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts +++ b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts @@ -75,6 +75,7 @@ type HostState = { type BrowserHostWindow = Window & { __mcpHostState: HostState; __sendGeneratedUi: (code: string, artifactId: string) => void; + __sendDataResult: (structuredContent: Record) => void; __setHostContext: (context: Record) => void; }; @@ -808,6 +809,18 @@ const createHostHtml = (shellUrl: string) => ` }); }; + // A tool result with no generated UI: the shell shows it as a data view. + window.__sendDataResult = (structuredContent) => { + sendToApp({ + jsonrpc: "2.0", + method: "ui/notifications/tool-result", + params: { + content: [{ type: "text", text: "" }], + structuredContent, + }, + }); + }; + // The host changing its mind after the handshake — how the console // announces a display mode and a measured viewport once it has laid out. // A real host sends only the CHANGED fields, so this does too. @@ -1772,6 +1785,43 @@ describe("MCP app generated UI browser isolation", () => { } }, 30_000); + it("runs no Tailwind compiler in the shell document", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + await renderGeneratedUi(page, shellFrame, 'function App() { return
; }'); + + const shellCompilerMeasures = await shellFrame.evaluate(() => + performance + .getEntriesByType("measure") + .filter((entry) => entry.name.startsWith("Create compiler")) + .map((entry) => entry.name), + ); + expect(shellCompilerMeasures, "only the generated frame compiles Tailwind").toEqual([]); + } finally { + await page.close(); + } + }, 30_000); + + it("sets shell chrome monospace text in Geist Mono", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + await page.evaluate(() => + (window as unknown as BrowserHostWindow).__sendDataResult({ answer: 42 }), + ); + const pre = shellFrame.locator("pre"); + await pre.waitFor({ timeout: 10_000 }); + + const fontFamily = await pre.evaluate((element) => getComputedStyle(element).fontFamily); + expect(fontFamily, "font-mono resolves to the design system's mono").toContain("Geist Mono"); + } finally { + await page.close(); + } + }, 30_000); + it("blocks popup, form, and nested-frame escape attempts from generated UI", async () => { if (!browser || !hostServer) throw new Error("Browser harness did not start."); const { page, shellFrame } = await openHarness(browser, hostServer.url); diff --git a/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx index 363b6972ab..638b5a596d 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx +++ b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx @@ -5,7 +5,6 @@ // a consumer's tsconfig `include` covers only its own sources. /// import "./globals.css"; -import "@tailwindcss/browser"; import React, { useState, useEffect, useRef, useCallback, type ReactNode } from "react"; import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; diff --git a/packages/react/src/api/artifact-renderer.tsx b/packages/react/src/api/artifact-renderer.tsx index c5da707d52..a13cbfb723 100644 --- a/packages/react/src/api/artifact-renderer.tsx +++ b/packages/react/src/api/artifact-renderer.tsx @@ -17,9 +17,9 @@ import * as React from "react"; // for any host-specific capability. // // The seam carries a LOADER, not a component. The shell is browser-only by -// nature: it imports `@tailwindcss/browser`, which builds a ` + ` + : ``; + const frameCompiler = + plan.js === null ? `\n ` : ""; return ` @@ -241,12 +284,11 @@ const buildRendererSrcDoc = (token: string): string => { - - + ${artifactStyles}
- + ${frameCompiler} `; }; @@ -556,6 +598,7 @@ export function McpAppsShell({ postToRenderer({ type: "executor.render", code: current.code, + ...(current.compiledJs === null ? {} : { compiledJs: current.compiledJs }), theme: hostContext?.theme, // The inner frame is the last link in the height chain, and it cannot // see the host context — so the mode is told to it, the same way the @@ -700,13 +743,15 @@ export function McpAppsShell({ }, [fill, postToRenderer, renderer]); /** Render a JSX code string in the sandboxed inner iframe. */ - const renderCode = useCallback((code: string) => { + const renderCode = useCallback((code: string, compiled?: unknown) => { try { const token = createRendererToken(); + const plan = readRenderPlan(compiled); const nextRenderer = { token, code, - srcDoc: buildRendererSrcDoc(token), + compiledJs: plan.js, + srcDoc: buildRendererSrcDoc(token, plan), config: {}, height: 240, }; @@ -739,7 +784,7 @@ export function McpAppsShell({ if (typeof artifactId === "string") artifactIdRef.current = artifactId; const code = params.arguments?.code; if (code && typeof code === "string") { - renderCode(code); + renderCode(code, params.arguments?.compiled); } }; @@ -752,7 +797,7 @@ export function McpAppsShell({ if (typeof artifactId === "string") artifactIdRef.current = artifactId; if (code && typeof code === "string") { - renderCode(code); + renderCode(code, structured?.compiled); return; } diff --git a/packages/hosts/mcp-apps-shell/src/shell/smoke-harness.tsx b/packages/hosts/mcp-apps-shell/src/shell/smoke-harness.tsx index df15e0a523..fe354f803c 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/smoke-harness.tsx +++ b/packages/hosts/mcp-apps-shell/src/shell/smoke-harness.tsx @@ -29,7 +29,8 @@ import { StrictMode, createElement } from "react"; import { renderToReadableStream } from "react-dom/server.browser"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { compileJsx, evaluateComponent } from "./component-runtime"; +import { compileJsx } from "./compile-jsx"; +import { evaluateComponent } from "./component-runtime"; import * as Components from "./components"; import { createToolsProxy } from "./tools-proxy"; import type { SmokeRenderResult } from "./smoke-render-result"; diff --git a/packages/hosts/mcp-apps-shell/src/vite.test.ts b/packages/hosts/mcp-apps-shell/src/vite.test.ts index 6dcbea0069..556f5efc58 100644 --- a/packages/hosts/mcp-apps-shell/src/vite.test.ts +++ b/packages/hosts/mcp-apps-shell/src/vite.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { mcpAppsShellAsset } from "./vite"; +import { innerRendererPlugin, mcpAppsShellAsset } from "./vite"; describe("mcpAppsShellAsset", () => { it("resolves the dev asset URL without building the shell during server startup", async () => { @@ -26,3 +26,19 @@ describe("mcpAppsShellAsset", () => { ); }); }); + +describe("innerRendererPlugin", () => { + it("serves the JSX compiler as its own program, apart from the renderer", async () => { + const plugin = innerRendererPlugin(); + const compilerId = plugin.resolveId("virtual:executor-inner-compiler"); + const rendererId = plugin.resolveId("virtual:executor-inner-renderer"); + expect(compilerId).toBeDefined(); + expect(rendererId).toBeDefined(); + + const compiler = await plugin.load(compilerId ?? ""); + const renderer = await plugin.load(rendererId ?? ""); + expect(compiler).toContain("__executorCompileJsx"); + expect(compiler).toContain("jsxRuntime"); + expect(renderer).not.toContain("jsxRuntime"); + }); +}); diff --git a/packages/hosts/mcp-apps-shell/src/vite.ts b/packages/hosts/mcp-apps-shell/src/vite.ts index d67f31fbd8..8b34086605 100644 --- a/packages/hosts/mcp-apps-shell/src/vite.ts +++ b/packages/hosts/mcp-apps-shell/src/vite.ts @@ -7,6 +7,10 @@ * by esbuild — it cannot be a normal import, because it must exist as *source * text* to be embedded in the sandboxed frame. * + * `virtual:executor-inner-compiler` is the Sucrase half of that frame, bundled + * the same way but kept out of the renderer: a frame whose artifact was + * precompiled on the server never loads it. + * * `mcpAppsShellAsset` supplies `virtual:executor-mcp-apps-shell-html`: the URL * of the BUILT, self-contained shell document, emitted into the app's client * assets. The console's artifact page loads that URL into a sandboxed iframe @@ -41,22 +45,18 @@ export interface InnerRendererVitePlugin { const VIRTUAL_ID = "virtual:executor-inner-renderer"; const RESOLVED_ID = `\0${VIRTUAL_ID}`; +const COMPILER_VIRTUAL_ID = "virtual:executor-inner-compiler"; +const COMPILER_RESOLVED_ID = `\0${COMPILER_VIRTUAL_ID}`; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); export const innerRendererEntry = (): string => path.resolve(packageRoot, "src/shell/inner-renderer.tsx"); -/** - * Bundle the inner renderer to a self-contained IIFE string. - * - * Exported on its own so a build script or test can produce the same bytes the - * plugin serves without standing up Vite. - */ -export const bundleInnerRenderer = async (): Promise => { +const bundleInnerProgram = async (entry: string, failure: string): Promise => { const { build } = await import("esbuild"); const result = await build({ - entryPoints: [innerRendererEntry()], + entryPoints: [entry], absWorkingDir: packageRoot, bundle: true, write: false, @@ -75,11 +75,27 @@ export const bundleInnerRenderer = async (): Promise => { const js = result.outputFiles[0]; if (!js) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: Vite plugin hooks report build failures by throwing - throw new Error("Failed to bundle the MCP-Apps inner renderer."); + throw new Error(failure); } return js.text; }; +/** + * Bundle the inner renderer to a self-contained IIFE string. + * + * Exported on its own so a build script or test can produce the same bytes the + * plugin serves without standing up Vite. + */ +export const bundleInnerRenderer = (): Promise => + bundleInnerProgram(innerRendererEntry(), "Failed to bundle the MCP-Apps inner renderer."); + +export const innerCompilerEntry = (): string => + path.resolve(packageRoot, "src/shell/inner-compiler.ts"); + +/** Bundle the in-frame JSX compiler to a self-contained IIFE string. */ +export const bundleInnerCompiler = (): Promise => + bundleInnerProgram(innerCompilerEntry(), "Failed to bundle the MCP-Apps inner compiler."); + export const smokeHarnessEntry = (): string => path.resolve(packageRoot, "src/shell/smoke-harness.tsx"); @@ -132,10 +148,17 @@ export const innerRendererPlugin = (): InnerRendererVitePlugin => ({ name: "executor-inner-renderer-source", // `pre` so the virtual id resolves before any generic resolver claims it. enforce: "pre", - resolveId: (id) => (id === VIRTUAL_ID ? RESOLVED_ID : undefined), + resolveId: (id) => { + if (id === VIRTUAL_ID) return RESOLVED_ID; + if (id === COMPILER_VIRTUAL_ID) return COMPILER_RESOLVED_ID; + return undefined; + }, load: async (id) => { - if (id !== RESOLVED_ID) return undefined; - return `export default ${JSON.stringify(await bundleInnerRenderer())};`; + if (id === RESOLVED_ID) return `export default ${JSON.stringify(await bundleInnerRenderer())};`; + if (id === COMPILER_RESOLVED_ID) { + return `export default ${JSON.stringify(await bundleInnerCompiler())};`; + } + return undefined; }, }); diff --git a/packages/react/src/api/artifact-renderer.tsx b/packages/react/src/api/artifact-renderer.tsx index a13cbfb723..d0ef2e74ba 100644 --- a/packages/react/src/api/artifact-renderer.tsx +++ b/packages/react/src/api/artifact-renderer.tsx @@ -31,6 +31,19 @@ import * as React from "react"; export interface ArtifactRendererProps { /** The artifact's stored JSX source. */ readonly code: string; + /** + * The server's precompile of `code` (JS and Tailwind CSS), when the host has + * one. The shell trusts it only if its fingerprint matches the shell's own + * compiler, and otherwise compiles `code` itself. + */ + readonly compiled?: + | { + readonly fingerprint: string; + readonly js: string | null; + readonly css: string | null; + } + | null + | undefined; /** Which artifact this is. The source addresses integrations by role, so the * server needs the row to resolve a role to a connection; this is how the id * reaches `execute-action`. */ diff --git a/packages/react/src/pages/artifact-detail.tsx b/packages/react/src/pages/artifact-detail.tsx index 7ce115a8a1..0b3f45023e 100644 --- a/packages/react/src/pages/artifact-detail.tsx +++ b/packages/react/src/pages/artifact-detail.tsx @@ -7,7 +7,11 @@ import { toast } from "sonner"; import type { ArtifactId } from "@executor-js/sdk/shared"; import { trackEvent } from "../api/analytics"; -import { useArtifactRenderer, usePreloadArtifactRenderer } from "../api/artifact-renderer"; +import { + useArtifactRenderer, + usePreloadArtifactRenderer, + type ArtifactRendererProps, +} from "../api/artifact-renderer"; import { artifactAtom, artifactsOptimisticAtom, @@ -219,7 +223,9 @@ export function ArtifactDetailPage(props: { readonly artifactId: ArtifactId }) { />
), - onSuccess: ({ value }) => , + onSuccess: ({ value }) => ( + + ), }) )} @@ -241,7 +247,11 @@ export function ArtifactDetailPage(props: { readonly artifactId: ArtifactId }) { * A host that registers no renderer still gets a page that explains itself * instead of crashing. */ -function ArtifactStage(props: { readonly code: string; readonly artifactId: string }) { +function ArtifactStage(props: { + readonly code: string; + readonly compiled: ArtifactRendererProps["compiled"]; + readonly artifactId: string; +}) { const Renderer = useArtifactRenderer(); // One host per mount: it holds no state, but a new identity each render would // retrigger the shell's host effects. @@ -314,6 +324,7 @@ function ArtifactStage(props: { readonly code: string; readonly artifactId: stri