From 782d4f8bf57a08f040bcea50aa91e4ef6f18d6e1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:09:37 +0200 Subject: [PATCH] perf: stop loading the app's stores in every plugin frame --- components/ui/button.tsx | 2 +- components/ui/input.tsx | 2 +- .../plugin-sandbox-runtime-imports.test.ts | 31 +++++++++++++++++++ lib/__tests__/plugin-ui-kit.test.tsx | 4 ++- lib/cn.ts | 9 ++++++ lib/plugin-sandbox/ui-kit/components.tsx | 2 +- lib/utils.ts | 6 +--- 7 files changed, 47 insertions(+), 9 deletions(-) create mode 100644 lib/__tests__/plugin-sandbox-runtime-imports.test.ts create mode 100644 lib/cn.ts diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 8aaba79e6..81e17a7e3 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; export interface ButtonProps extends React.ButtonHTMLAttributes { diff --git a/components/ui/input.tsx b/components/ui/input.tsx index fd8132fdf..72757eb4f 100644 --- a/components/ui/input.tsx +++ b/components/ui/input.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; export interface InputProps extends React.InputHTMLAttributes {} diff --git a/lib/__tests__/plugin-sandbox-runtime-imports.test.ts b/lib/__tests__/plugin-sandbox-runtime-imports.test.ts new file mode 100644 index 000000000..2b5724fc4 --- /dev/null +++ b/lib/__tests__/plugin-sandbox-runtime-imports.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment node +import { describe, it, expect } from 'vitest'; +import path from 'node:path'; +import { build } from 'esbuild'; + +// Every plugin iframe parses and runs all the code the sandbox page imports, +// once per frame: sandboxed frames have opaque origins, so the browser's code +// cache never helps. The app's stores came in through the UI kit's Button +// (lib/utils -> settings store -> ten more stores), which made each frame +// boot ~100 ms slower. +describe('plugin sandbox runtime', () => { + it('does not pull the app stores into plugin frames', async () => { + const root = path.resolve(__dirname, '../..'); + const result = await build({ + entryPoints: [path.join(root, 'lib/plugin-sandbox/runtime.tsx')], + bundle: true, + write: false, + metafile: true, + format: 'esm', + platform: 'browser', + jsx: 'automatic', + alias: { '@': root }, + external: ['@tabler/icons-react'], + loader: { '.css': 'empty' }, + logLevel: 'silent', + }); + const inputs = Object.keys(result.metafile.inputs).map((f) => f.replace(/\\/g, '/')); + expect(inputs.filter((f) => /(^|\/)stores\//.test(f))).toEqual([]); + expect(inputs.filter((f) => /node_modules\/zustand\//.test(f))).toEqual([]); + }, 30_000); +}); diff --git a/lib/__tests__/plugin-ui-kit.test.tsx b/lib/__tests__/plugin-ui-kit.test.tsx index d8fffdfcc..9423f1623 100644 --- a/lib/__tests__/plugin-ui-kit.test.tsx +++ b/lib/__tests__/plugin-ui-kit.test.tsx @@ -221,7 +221,9 @@ describe('plugin dialog footer', () => { await act(async () => {}); expect(result).toBeNull(); vi.doUnmock('@/components/plugins/plugin-iframe-slot'); - }); + // The dialog host is the first import here that loads the app's stores + // (the kit no longer does), and transforming them takes a while. + }, 30_000); }); describe('Combobox', () => { diff --git a/lib/cn.ts b/lib/cn.ts new file mode 100644 index 000000000..6c8616e8a --- /dev/null +++ b/lib/cn.ts @@ -0,0 +1,9 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +// Kept apart from lib/utils, which imports the settings store: the plugin +// sandbox uses the app's Button and Input, and through lib/utils every +// sandbox frame loaded and ran ten app stores it never uses. +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/lib/plugin-sandbox/ui-kit/components.tsx b/lib/plugin-sandbox/ui-kit/components.tsx index dc9b20a8f..4622479b5 100644 --- a/lib/plugin-sandbox/ui-kit/components.tsx +++ b/lib/plugin-sandbox/ui-kit/components.tsx @@ -15,7 +15,7 @@ // against. import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cn } from '@/lib/cn'; import { Button as AppButton } from '@/components/ui/button'; import { Input as AppInput } from '@/components/ui/input'; import { getFileIconByName, getFolderIcon } from '@/components/files/file-icons'; diff --git a/lib/utils.ts b/lib/utils.ts index a8e85329b..27e9c60ad 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,5 +1,3 @@ -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; import { Mailbox } from "./jmap/types"; import { debug } from "./debug"; import { getEffectiveLocale } from '@/i18n/detect-locale'; @@ -7,9 +5,7 @@ import { useSettingsStore } from "@/stores/settings-store"; import type { DateFormat, DateLocale } from "@/stores/settings-store"; import { getEffectiveTimeZone, getWallClock } from "./timezone"; -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} +export { cn } from "./cn"; /** * Strip embedded Basic-auth credentials from a URL for display.