From 3970f81dd92e382f0d8b1601c1cf5c12748b4979 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 21 Aug 2026 10:13:55 -0700 Subject: [PATCH] Declare every runtime-shimmed package for types in plugin scaffolds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bb plugin build` swaps sonner, vaul, @pierre/diffs, the portal radix families and the host-resident clsx/tailwind-merge/cva for runtime shims, but a plugin's tsc resolves those imports through node_modules, and the scaffold only declared the four packages its starter components happened to import. The documented `import { toast } from "sonner"` therefore failed to typecheck in a fresh `bb plugin new --app` (#2072). - Move the shim table into packages/plugin-build/src/runtime-shims.mjs, plain ESM read by the builder, the export-manifest generator and the plugin-scaffold generator, so the three hand-copied lists cannot drift. - Scaffold every shimmed npm package as a type-only devDependency at the host's version (PLUGIN_SHIMMED_TYPE_DEPENDENCIES, mirrored from apps/app/package.json). - `bb plugin types` repins those devDependencies alongside the SDK pin (adding missing ones for app plugins, moving any out of dependencies) and `--check` reports the drift. - Document the rule in the plugin-authoring skill, the plugin guide, the bb-cli skill and the scaffold README; guard it with a CLI test (scaffold devDependencies ⊇ shim list) and a templates test that runs the scaffold's tsc over every shimmed specifier. Co-Authored-By: Claude --- .../plugin-scaffold-dependencies.test.ts | 42 ++++- apps/cli/src/commands/plugin.ts | 32 +++- .../skills/builtin-skills/bb-cli/SKILL.md | 6 +- .../bb-plugin-authoring/SKILL.md | 21 ++- .../generate-runtime-export-manifest.mjs | 37 +--- packages/plugin-build/src/build-plugin-app.ts | 89 ++-------- packages/plugin-build/src/index.ts | 1 + packages/plugin-build/src/runtime-shims.d.mts | 9 + packages/plugin-build/src/runtime-shims.mjs | 137 +++++++++++++++ .../scripts/generate-plugin-scaffold.mjs | 69 ++++---- packages/templates/src/plugin-scaffold.ts | 144 +++++++++++++-- .../src/templates/bb-guide-plugins.md | 16 +- .../test/plugin-migrate-layout.test.ts | 125 ++++++++++++- .../test/plugin-scaffold-shim-types.test.ts | 164 ++++++++++++++++++ turbo.json | 2 + 15 files changed, 712 insertions(+), 182 deletions(-) create mode 100644 packages/plugin-build/src/runtime-shims.d.mts create mode 100644 packages/plugin-build/src/runtime-shims.mjs create mode 100644 packages/templates/test/plugin-scaffold-shim-types.test.ts diff --git a/apps/cli/src/__tests__/plugin-scaffold-dependencies.test.ts b/apps/cli/src/__tests__/plugin-scaffold-dependencies.test.ts index 66e70be76a..165b7848a7 100644 --- a/apps/cli/src/__tests__/plugin-scaffold-dependencies.test.ts +++ b/apps/cli/src/__tests__/plugin-scaffold-dependencies.test.ts @@ -4,6 +4,7 @@ import { join, relative } from "node:path"; import { PLUGIN_SERVER_EXTERNALS, RUNTIME_SLOT_BY_SPECIFIER, + SHIMMED_TYPE_PACKAGES, } from "@bb/plugin-build"; import { scaffoldPlugin } from "@bb/templates/plugin-scaffold"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -79,7 +80,11 @@ function packageNameOf(specifier: string): string { async function scaffoldWithDependencies(args: { workDir: string; app: boolean; -}): Promise<{ targetDir: string; dependencies: string[] }> { +}): Promise<{ + targetDir: string; + dependencies: string[]; + devDependencies: string[]; +}> { const packageName = `bb-plugin-${args.app ? "app" : "headless"}`; const targetDir = join(args.workDir, packageName); await scaffoldPlugin({ @@ -88,10 +93,15 @@ async function scaffoldWithDependencies(args: { bbVersion: "0.9.0", app: args.app, }); - const manifest: { dependencies?: Record } = JSON.parse( - await readFile(join(targetDir, "package.json"), "utf8"), - ); - return { targetDir, dependencies: Object.keys(manifest.dependencies ?? {}) }; + const manifest: { + dependencies?: Record; + devDependencies?: Record; + } = JSON.parse(await readFile(join(targetDir, "package.json"), "utf8")); + return { + targetDir, + dependencies: Object.keys(manifest.dependencies ?? {}), + devDependencies: Object.keys(manifest.devDependencies ?? {}), + }; } describe("scaffold dependency classification", () => { @@ -134,6 +144,28 @@ describe("scaffold dependency classification", () => { }, ); + /** + * The flip side of the shim (#2072): esbuild never reads a shimmed package + * from node_modules, but tsc does, so every shimmed npm package has to be + * installed for types — as a devDependency — or the documented + * `import { toast } from "sonner"` fails to typecheck in a fresh scaffold. + * Derived from the build's shim table, so adding a slot without declaring + * its types fails here. + */ + it("declares every runtime-shimmed package as a type-only devDependency of an app scaffold", async () => { + const { dependencies, devDependencies } = await scaffoldWithDependencies({ + workDir, + app: true, + }); + + expect( + SHIMMED_TYPE_PACKAGES.filter((name) => !devDependencies.includes(name)), + ).toEqual([]); + expect( + SHIMMED_TYPE_PACKAGES.filter((name) => dependencies.includes(name)), + ).toEqual([]); + }); + it("keeps host-provided packages out of dependencies", async () => { const { dependencies } = await scaffoldWithDependencies({ workDir, diff --git a/apps/cli/src/commands/plugin.ts b/apps/cli/src/commands/plugin.ts index b445c93356..5ecb9bdbe7 100644 --- a/apps/cli/src/commands/plugin.ts +++ b/apps/cli/src/commands/plugin.ts @@ -1333,7 +1333,7 @@ export function registerPluginCommands( plugin .command("types [path]") .description( - "Sync a plugin's @get-bb/plugin-sdk surface to the running bb (default: cwd): repin the npm devDependency for plugins that depend on the package, or rewrite the vendored types/ declarations for plugins that still carry them", + "Sync a plugin's @get-bb/plugin-sdk surface to the running bb (default: cwd): repin the npm devDependency and the type-only devDependencies of the packages bb shims at runtime (sonner, vaul, the portal radix families, ...) for plugins that depend on the package, or rewrite the vendored types/ declarations for plugins that still carry them", ) .option( "--check", @@ -1359,6 +1359,7 @@ export function registerPluginCommands( const pending = await setPluginSdkPin({ rootDir, sdkVersion: PLUGIN_SDK_VERSION, + app: hasApp, dryRun: true, }); if (pending === null) { @@ -1367,20 +1368,30 @@ export function registerPluginCommands( ); return; } - console.error( - pending.pin === null - ? 'Move "@get-bb/plugin-sdk" from dependencies to devDependencies — bb provides its runtime (`bb plugin types` does it for you).' - : `Set "@get-bb/plugin-sdk" to ${PLUGIN_SDK_VERSION} in devDependencies and re-run npm install (\`bb plugin types\` does it for you).`, - ); + if (pending.pin !== null || pending.movedFromDependencies) { + console.error( + pending.pin === null + ? 'Move "@get-bb/plugin-sdk" from dependencies to devDependencies — bb provides its runtime (`bb plugin types` does it for you).' + : `Set "@get-bb/plugin-sdk" to ${PLUGIN_SDK_VERSION} in devDependencies and re-run npm install (\`bb plugin types\` does it for you).`, + ); + } + for (const shim of pending.shimmedTypePins) { + console.error( + shim.movedFromDependencies + ? `Move "${shim.name}" from dependencies to devDependencies at ${shim.to} — bb shims it at runtime and never bundles it (\`bb plugin types\` does it for you).` + : `Set "${shim.name}" to ${shim.to} in devDependencies — the version this bb shims at runtime (\`bb plugin types\` does it for you).`, + ); + } process.exit(1); } const changed = await setPluginSdkPin({ rootDir, sdkVersion: PLUGIN_SDK_VERSION, + app: hasApp, }); if (changed === null) { console.log( - `@get-bb/plugin-sdk is already pinned to ${PLUGIN_SDK_VERSION} — this bb's SDK version.`, + `@get-bb/plugin-sdk is already pinned to ${PLUGIN_SDK_VERSION} — this bb's SDK version${hasApp ? ", and the runtime-shimmed packages are at this bb's versions" : ""}.`, ); console.log( "The declarations are in node_modules/@get-bb/plugin-sdk/bundled-types/ — read them for exact signatures.", @@ -1399,6 +1410,13 @@ export function registerPluginCommands( "Moved @get-bb/plugin-sdk from dependencies to devDependencies.", ); } + for (const shim of changed.shimmedTypePins) { + // Same reasoning as the SDK: bb shims these at runtime, so they + // are declared for types only, at the versions bb itself ships. + console.log( + `${shim.name}: ${shim.from ?? "(not declared)"} → ${shim.to} in devDependencies${shim.movedFromDependencies ? " (moved from dependencies)" : ""}.`, + ); + } // The new pin has to resolve for the declarations to land, so the // same unpublished-version warning the scaffold prints applies. await warnIfSdkVersionUnpublished(); diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index 45c87d9e28..e9d1e8a176 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md @@ -888,7 +888,11 @@ them by mixing ink into canvas), the `--primary` accent, the secondary text tier - `bb plugin types [path]` — sync the plugin's `@get-bb/plugin-sdk` surface to the running bb (default: cwd). For a plugin that depends on the npm package it rewrites the exact `devDependencies` pin to this bb's SDK - version (reporting old → new, and reminding you to `npm install`); for a + version and brings the type-only devDependencies of the packages bb shims + at runtime (sonner, vaul, the portal radix families, @pierre/diffs, clsx, + tailwind-merge, class-variance-authority) to this bb's versions — adding + any an app plugin is missing and moving one out of `dependencies` + (reporting old → new, and reminding you to `npm install`); for a plugin that still vendors declarations it rewrites `types/*.d.ts`, creating `types/` when absent. Run it in a cloned or older plugin: the SDK surface grows every release. `--check` writes nothing and exits non-zero on a diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index 91392d49b8..f2237c199e 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -58,7 +58,13 @@ The manifest is `package.json`: `devDependencies` makes the plugin uninstallable from git, and unbuildable after any install that omits dev deps — including the packaged CLI's own, which runs npm under `NODE_ENV=production`. `devDependencies` is for types - and tooling only. + and tooling only — including every package bb shims at runtime (sonner, + vaul, the portal radix families, @pierre/diffs, clsx, tailwind-merge, + class-variance-authority): the build never bundles them, but `tsc` still + resolves their declarations through node_modules, so each one you import + needs a `devDependencies` entry at the host's version (`bb plugin new` + writes all of them; `bb plugin types` repins them). Never put one in + `dependencies` — that bundles a second copy beside the host's. - `bb.host` (optional, singular) — full-trust Node 22 ESM entry bundled into `dist/host.js` + source map + `host.meta.json`. Its owning server entry calls it through typed host RPC. The daemon downloads it lazily, verifies its @@ -159,7 +165,10 @@ does not cover: 1. **`bb plugin types`**, run in the plugin directory (or given its path), syncs that plugin's SDK surface to the running bb — no server needed. For a plugin that depends on the npm package it repins the exact - `@get-bb/plugin-sdk` devDependency to this bb's SDK version (run + `@get-bb/plugin-sdk` devDependency to this bb's SDK version and brings the + runtime-shimmed packages' type-only devDependencies (sonner, vaul, the + portal radix families, ...) to the versions this bb ships — adding any an + app plugin is missing and moving one out of `dependencies` (run `npm install` after); for an older plugin that still vendors `types/*.d.ts` it rewrites those declarations. Either way a cloned or older plugin can be thousands of lines behind. `--check` reports a mismatch without writing; @@ -2170,7 +2179,13 @@ only `definePluginApp` + the hooks): `-tooltip`, `-navigation-menu`), `sonner`, `vaul`, `@pierre/diffs` (+ `/react`). Your vendored overlays therefore share the host's dismissable-layer/focus/scroll-lock world — stacking against host - overlays behaves correctly. + overlays behaves correctly. "Import freely" is about the bundle: `tsc` + still needs each one's declarations in `node_modules`, so every shimmed + package is a **type-only `devDependencies` entry at the host's version** + (the scaffold declares all of them; `bb plugin types` repins them; `bb + plugin types --check` reports drift). Never list one in `dependencies` — + the build would not read it, and a git install would bundle a second + copy of a singleton. - Also never bundled, for size rather than singleton reasons: `clsx`, `tailwind-merge`, and `class-variance-authority`. Your app bundle uses the host's installed copies (tailwind-merge ^3, clsx ^2, cva ^0.7), so keep diff --git a/packages/plugin-build/scripts/generate-runtime-export-manifest.mjs b/packages/plugin-build/scripts/generate-runtime-export-manifest.mjs index 4114f65ad7..29558c376a 100644 --- a/packages/plugin-build/scripts/generate-runtime-export-manifest.mjs +++ b/packages/plugin-build/scripts/generate-runtime-export-manifest.mjs @@ -12,6 +12,7 @@ import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { build } from "esbuild"; +import { RUNTIME_SHIM_NPM_SPECIFIERS } from "../src/runtime-shims.mjs"; // Node 20 does not expose the browser-compatible Navigator global added in // later Node releases. Some shared browser runtimes (currently @pierre/diffs) @@ -31,39 +32,9 @@ const appRequire = createRequire( path.join(scriptDir, "..", "..", "..", "apps", "app", "package.json"), ); -const RUNTIME_MODULE_IDS = [ - "react", - "react-dom", - "react-dom/client", - "react/jsx-runtime", - "react/jsx-dev-runtime", - // Portaling radix families (plugin design §5.5): shimmed so vendored - // components share the host's dismissable-layer/focus/scroll-lock world. - // Non-portal radix has no singleton semantics and bundles per plugin. - "@radix-ui/react-alert-dialog", - "@radix-ui/react-context-menu", - "@radix-ui/react-dialog", - "@radix-ui/react-dropdown-menu", - "@radix-ui/react-hover-card", - "@radix-ui/react-menubar", - "@radix-ui/react-navigation-menu", - "@radix-ui/react-popover", - "@radix-ui/react-select", - "@radix-ui/react-tooltip", - // toast() must reach the host toaster; vaul mutates document.body styles. - "sonner", - "vaul", - // Diff rendering: FileDiff reads the host's WorkerPoolContextProvider - // (React context identity requires one module copy) and sharing keeps - // shiki's grammars out of plugin bundles. - "@pierre/diffs", - "@pierre/diffs/react", - // Host-resident libraries (RUNTIME_SLOT_BY_SPECIFIER rule 2): no singleton - // semantics, shimmed so plugin bundles stop duplicating them. - "clsx", - "tailwind-merge", - "class-variance-authority", -]; +// The shimmed npm modules, from the same list `bb plugin build` shims +// (src/runtime-shims.mjs) so the manifest can never miss a slot. +const RUNTIME_MODULE_IDS = RUNTIME_SHIM_NPM_SPECIFIERS; /** * Workspace TypeScript modules exposed as slots. Not requireable, so their diff --git a/packages/plugin-build/src/build-plugin-app.ts b/packages/plugin-build/src/build-plugin-app.ts index 9fad3abd19..32a101b6ca 100644 --- a/packages/plugin-build/src/build-plugin-app.ts +++ b/packages/plugin-build/src/build-plugin-app.ts @@ -20,6 +20,12 @@ import { RUNTIME_EXPORT_MANIFEST } from "./generated/runtime-export-manifest.gen import { type PluginBuildToolchain } from "./toolchain.js"; import { createPluginArtifactMeta } from "./plugin-artifact-meta.js"; import { isRecord, validatePluginBuildManifest } from "./plugin-manifest.js"; +import { + LEGACY_PLUGIN_SDK_APP_SPECIFIER, + PLUGIN_SDK_APP_SPECIFIER, + RUNTIME_SLOT_BY_SPECIFIER, + SHARED_UI_ICON_SPECIFIER, +} from "./runtime-shims.mjs"; import { pluginScopeRoots, scopePluginUtilities, @@ -46,82 +52,15 @@ import { */ /** - * Runtime slot on `globalThis.__bbPluginRuntime` per shimmed specifier. - * Shim policy (plugin design §5.5), two admission rules: - * - * 1. Singleton/global behavior — one React, the portaling radix families - * (shared dismissable-layer/focus/scroll-lock/aria-hidden world), sonner - * (`toast()` must reach the host toaster), vaul (mutates document.body - * styles), @pierre/diffs (its react FileDiff reads the host's - * WorkerPoolContextProvider — context identity requires one module copy — - * and sharing keeps shiki's grammars out of every plugin bundle) — plus - * the SDK surface itself. - * 2. Host-resident libraries every plugin app would otherwise duplicate — - * tailwind-merge + clsx (the `cn()` pair every vendored component pulls - * in), class-variance-authority, and the shared-ui `Icon` (its hugeicons - * map is ~110 KB raw per copy). These have no singleton semantics; they - * are shimmed so a phone does not parse a dozen copies of the same code. - * A plugin gets the host's installed version, so its declared range must - * stay within the host's major (tailwind-merge ^3, clsx ^2, cva ^0.7). - * Rule 2 has a cost on the host side: exposing a namespace on the - * runtime object stops the app's bundler from tree-shaking that library - * out of the boot chunk, so it only admits libraries whose slot leaves - * the boot budget (apps/app/bundle-budget.json) intact. zod does not — - * the app uses a fraction of its exports and slotting the namespace - * added +193 KB raw / +33 KB brotli to the payload every phone downloads - * before first paint — so zod stays bundled per plugin. - * - * Everything else (non-portal radix, lucide-react, zod, form/calendar/chart - * libs, hugeicons imported directly) bundles from the plugin's own - * node_modules. Adding a slot here requires the matching host slot in - * apps/app/src/lib/plugin-frontend.ts (installPluginRuntime) and an - * export-manifest entry (scripts/generate-runtime-export-manifest.mjs). - */ -/** The SDK app subpath plugin sources import. */ -const PLUGIN_SDK_APP_SPECIFIER = "@get-bb/plugin-sdk/app"; - -/** - * Legacy alias for {@link PLUGIN_SDK_APP_SPECIFIER}, kept so pre-rename plugin - * sources still build. It resolves to the same runtime slot and the same - * export list; a later change removes it. + * The shim specifier → runtime-slot map lives in runtime-shims.mjs (plain + * ESM, so the export-manifest and plugin-scaffold generators can read the + * same list under bare `node`); the shim admission policy is documented + * there. Re-exported for the package's public surface. */ -const LEGACY_PLUGIN_SDK_APP_SPECIFIER = "@bb/plugin-sdk/app"; - -/** - * The shared-ui icon module. Builtin plugins import it by package specifier; - * shared-ui's own components import it relatively (`./icon`), and - * {@link runtimeShimPlugin} routes both to the same host slot so no plugin - * bundle carries a second hugeicons map. - */ -const SHARED_UI_ICON_SPECIFIER = "@bb/shared-ui/icon"; - -export const RUNTIME_SLOT_BY_SPECIFIER: Record = { - react: "react", - "react-dom": "reactDom", - "react-dom/client": "reactDomClient", - "react/jsx-runtime": "jsxRuntime", - "react/jsx-dev-runtime": "jsxDevRuntime", - [PLUGIN_SDK_APP_SPECIFIER]: "pluginSdkApp", - [LEGACY_PLUGIN_SDK_APP_SPECIFIER]: "pluginSdkApp", - "@pierre/diffs": "pierreDiffs", - "@pierre/diffs/react": "pierreDiffsReact", - "@radix-ui/react-alert-dialog": "radixAlertDialog", - "@radix-ui/react-context-menu": "radixContextMenu", - "@radix-ui/react-dialog": "radixDialog", - "@radix-ui/react-dropdown-menu": "radixDropdownMenu", - "@radix-ui/react-hover-card": "radixHoverCard", - "@radix-ui/react-menubar": "radixMenubar", - "@radix-ui/react-navigation-menu": "radixNavigationMenu", - "@radix-ui/react-popover": "radixPopover", - "@radix-ui/react-select": "radixSelect", - "@radix-ui/react-tooltip": "radixTooltip", - sonner: "sonner", - vaul: "vaul", - clsx: "clsx", - "tailwind-merge": "tailwindMerge", - "class-variance-authority": "classVarianceAuthority", - [SHARED_UI_ICON_SPECIFIER]: "sharedUiIcon", -}; +export { + RUNTIME_SLOT_BY_SPECIFIER, + SHIMMED_TYPE_PACKAGES, +} from "./runtime-shims.mjs"; /** * Real-path suffix of shared-ui's icon module (extension stripped). esbuild diff --git a/packages/plugin-build/src/index.ts b/packages/plugin-build/src/index.ts index 69291e82b8..113e48cd82 100644 --- a/packages/plugin-build/src/index.ts +++ b/packages/plugin-build/src/index.ts @@ -17,6 +17,7 @@ export { buildPluginApp, RUNTIME_SLOT_BY_SPECIFIER, + SHIMMED_TYPE_PACKAGES, } from "./build-plugin-app.js"; export { buildPluginServer, diff --git a/packages/plugin-build/src/runtime-shims.d.mts b/packages/plugin-build/src/runtime-shims.d.mts new file mode 100644 index 0000000000..198ba10810 --- /dev/null +++ b/packages/plugin-build/src/runtime-shims.d.mts @@ -0,0 +1,9 @@ +// Type surface of runtime-shims.mjs (the data lives there so bare-`node` +// generator scripts can read it). Keep the two in step. + +export const PLUGIN_SDK_APP_SPECIFIER: "@get-bb/plugin-sdk/app"; +export const LEGACY_PLUGIN_SDK_APP_SPECIFIER: "@bb/plugin-sdk/app"; +export const SHARED_UI_ICON_SPECIFIER: "@bb/shared-ui/icon"; +export const RUNTIME_SLOT_BY_SPECIFIER: Readonly>; +export const RUNTIME_SHIM_NPM_SPECIFIERS: readonly string[]; +export const SHIMMED_TYPE_PACKAGES: readonly string[]; diff --git a/packages/plugin-build/src/runtime-shims.mjs b/packages/plugin-build/src/runtime-shims.mjs new file mode 100644 index 0000000000..ee5daad585 --- /dev/null +++ b/packages/plugin-build/src/runtime-shims.mjs @@ -0,0 +1,137 @@ +// The one list of modules `bb plugin build` swaps for host-runtime shims. +// +// Plain ESM on purpose: the build engine (build-plugin-app.ts) imports it as +// a module, and two generator scripts that run under bare `node` before any +// TypeScript is compiled read it by file path — +// packages/plugin-build/scripts/generate-runtime-export-manifest.mjs (the +// shims' static export lists) and +// packages/templates/scripts/generate-plugin-scaffold.mjs (the scaffold's +// type-only devDependencies). Keeping all three on this module is what makes +// "shimmed at runtime" and "declared for types" impossible to drift apart +// (#2072). The sibling runtime-shims.d.mts declares its shape for tsc. + +/** The SDK app subpath plugin sources import. */ +export const PLUGIN_SDK_APP_SPECIFIER = "@get-bb/plugin-sdk/app"; + +/** + * Legacy alias for {@link PLUGIN_SDK_APP_SPECIFIER}, kept so pre-rename plugin + * sources still build. It resolves to the same runtime slot and the same + * export list; a later change removes it. + */ +export const LEGACY_PLUGIN_SDK_APP_SPECIFIER = "@bb/plugin-sdk/app"; + +/** + * The shared-ui icon module. Builtin plugins import it by package specifier; + * shared-ui's own components import it relatively (`./icon`), and the build's + * runtime shim plugin routes both to the same host slot so no plugin bundle + * carries a second hugeicons map. + */ +export const SHARED_UI_ICON_SPECIFIER = "@bb/shared-ui/icon"; + +/** + * Runtime slot on `globalThis.__bbPluginRuntime` per shimmed specifier. + * Shim policy (plugin design §5.5), two admission rules: + * + * 1. Singleton/global behavior — one React, the portaling radix families + * (shared dismissable-layer/focus/scroll-lock/aria-hidden world), sonner + * (`toast()` must reach the host toaster), vaul (mutates document.body + * styles), @pierre/diffs (its react FileDiff reads the host's + * WorkerPoolContextProvider — context identity requires one module copy — + * and sharing keeps shiki's grammars out of every plugin bundle) — plus + * the SDK surface itself. + * 2. Host-resident libraries every plugin app would otherwise duplicate — + * tailwind-merge + clsx (the `cn()` pair every vendored component pulls + * in), class-variance-authority, and the shared-ui `Icon` (its hugeicons + * map is ~110 KB raw per copy). These have no singleton semantics; they + * are shimmed so a phone does not parse a dozen copies of the same code. + * A plugin gets the host's installed version, so its declared range must + * stay within the host's major (tailwind-merge ^3, clsx ^2, cva ^0.7). + * Rule 2 has a cost on the host side: exposing a namespace on the + * runtime object stops the app's bundler from tree-shaking that library + * out of the boot chunk, so it only admits libraries whose slot leaves + * the boot budget (apps/app/bundle-budget.json) intact. zod does not — + * the app uses a fraction of its exports and slotting the namespace + * added +193 KB raw / +33 KB brotli to the payload every phone downloads + * before first paint — so zod stays bundled per plugin. + * + * Everything else (non-portal radix, lucide-react, zod, form/calendar/chart + * libs, hugeicons imported directly) bundles from the plugin's own + * node_modules. Adding a slot here requires the matching host slot in + * apps/app/src/lib/plugin-frontend.ts (installPluginRuntime); the export + * manifest and the scaffold's type-only devDependencies follow automatically. + */ +export const RUNTIME_SLOT_BY_SPECIFIER = Object.freeze({ + react: "react", + "react-dom": "reactDom", + "react-dom/client": "reactDomClient", + "react/jsx-runtime": "jsxRuntime", + "react/jsx-dev-runtime": "jsxDevRuntime", + [PLUGIN_SDK_APP_SPECIFIER]: "pluginSdkApp", + [LEGACY_PLUGIN_SDK_APP_SPECIFIER]: "pluginSdkApp", + "@pierre/diffs": "pierreDiffs", + "@pierre/diffs/react": "pierreDiffsReact", + "@radix-ui/react-alert-dialog": "radixAlertDialog", + "@radix-ui/react-context-menu": "radixContextMenu", + "@radix-ui/react-dialog": "radixDialog", + "@radix-ui/react-dropdown-menu": "radixDropdownMenu", + "@radix-ui/react-hover-card": "radixHoverCard", + "@radix-ui/react-menubar": "radixMenubar", + "@radix-ui/react-navigation-menu": "radixNavigationMenu", + "@radix-ui/react-popover": "radixPopover", + "@radix-ui/react-select": "radixSelect", + "@radix-ui/react-tooltip": "radixTooltip", + sonner: "sonner", + vaul: "vaul", + clsx: "clsx", + "tailwind-merge": "tailwindMerge", + "class-variance-authority": "classVarianceAuthority", + [SHARED_UI_ICON_SPECIFIER]: "sharedUiIcon", +}); + +/** The npm package owning a specifier: `react/jsx-runtime` → `react`. */ +function packageNameOf(specifier) { + const segments = specifier.split("/"); + return specifier.startsWith("@") + ? segments.slice(0, 2).join("/") + : segments[0]; +} + +/** + * Shimmed modules that are not npm packages a plugin would install: the SDK + * facade (pinned separately, as `@get-bb/plugin-sdk`) and the workspace-only + * shared-ui icon module. + */ +const NON_NPM_SHIM_PACKAGES = new Set([ + packageNameOf(PLUGIN_SDK_APP_SPECIFIER), + packageNameOf(LEGACY_PLUGIN_SDK_APP_SPECIFIER), + packageNameOf(SHARED_UI_ICON_SPECIFIER), +]); + +/** + * Shimmed npm specifiers whose named exports the build introspects from the + * host app's installed copies — every slot except the workspace source + * modules (the SDK facade and the shared-ui icon), whose export lists come + * from esbuild metadata instead. + */ +export const RUNTIME_SHIM_NPM_SPECIFIERS = Object.freeze( + Object.keys(RUNTIME_SLOT_BY_SPECIFIER).filter( + (specifier) => !NON_NPM_SHIM_PACKAGES.has(packageNameOf(specifier)), + ), +); + +/** + * The npm packages a plugin must declare as type-only devDependencies (at the + * host's version) for its shimmed imports to typecheck: every shimmed npm + * package except React, whose declarations ship separately as `@types/react` + * and `@types/react-dom` and which the scaffold pins on its own. `bb plugin + * build` never bundles any of these, so none belongs in `dependencies`. + */ +export const SHIMMED_TYPE_PACKAGES = Object.freeze( + [ + ...new Set( + RUNTIME_SHIM_NPM_SPECIFIERS.map(packageNameOf).filter( + (name) => name !== "react" && name !== "react-dom", + ), + ), + ].sort(), +); diff --git a/packages/templates/scripts/generate-plugin-scaffold.mjs b/packages/templates/scripts/generate-plugin-scaffold.mjs index 85aaee761f..dd50692ab0 100644 --- a/packages/templates/scripts/generate-plugin-scaffold.mjs +++ b/packages/templates/scripts/generate-plugin-scaffold.mjs @@ -11,6 +11,10 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { writeGeneratedFile } from "./write-generated.mjs"; +import { + RUNTIME_SLOT_BY_SPECIFIER, + SHIMMED_TYPE_PACKAGES, +} from "../../plugin-build/src/runtime-shims.mjs"; const packageRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -20,36 +24,19 @@ const packageRoot = path.resolve( // Embed the `bb plugin new --app` starter component set from the plugin // component registry (plugin design §5.5): the transitive closure of the // starter items, as {target, content} pairs, plus the npm deps a scaffold -// needs to build (dependencies) and typecheck (devDependencies) them — -// versions mirrored from apps/app so vendored source matches what the app -// ships. Read by file path — NOT a package import — same as the plugin-sdk -// dts embed above. Regenerate the registry FIRST +// needs to build (dependencies) and typecheck (devDependencies) them, and +// the full shimmed-package set for types — versions mirrored from apps/app +// so vendored source and declarations match what the app ships. Read by +// file path — NOT a package import — same as the plugin-sdk dts embed above. +// Regenerate the registry FIRST // (node packages/plugin-registry/scripts/build-registry.mjs), then this. const STARTER_ITEMS = ["button", "card", "input", "dialog"]; -// Keep in sync with RUNTIME_SLOT_BY_SPECIFIER in -// packages/plugin-build/src/build-plugin-app.ts: shimmed packages are -// runtime-provided (devDependencies for types only); everything else must be -// a real dependency for esbuild to bundle. -const SHIMMED_SPECIFIERS = new Set([ - "@radix-ui/react-alert-dialog", - "@radix-ui/react-context-menu", - "@radix-ui/react-dialog", - "@radix-ui/react-dropdown-menu", - "@radix-ui/react-hover-card", - "@radix-ui/react-menubar", - "@radix-ui/react-navigation-menu", - "@radix-ui/react-popover", - "@radix-ui/react-select", - "@radix-ui/react-tooltip", - "sonner", - "vaul", - // Host-resident libraries: shimmed for bundle size, not singleton - // semantics. zod is not a slot (its namespace would bloat the host's boot - // chunk), so the scaffold keeps it in dependencies. - "clsx", - "tailwind-merge", - "class-variance-authority", -]); +// Shimmed packages are runtime-provided (devDependencies for types only); +// everything else a starter component imports must be a real dependency for +// esbuild to bundle. Both lists come from the build's own shim table, +// read by file path like the registry (@bb/templates cannot depend on +// @bb/plugin-build without a workspace cycle). +const SHIMMED_SPECIFIERS = new Set(Object.keys(RUNTIME_SLOT_BY_SPECIFIER)); const registryDir = path.join(packageRoot, "..", "plugin-registry", "r"); const appPackageJson = JSON.parse( await readFile( @@ -59,7 +46,11 @@ const appPackageJson = JSON.parse( ); const starterFiles = []; const starterBundledDeps = new Set(); -const starterTypeOnlyDeps = new Set(); +// Every shimmed package, not only those the starter components import: the +// plugin guide tells authors to import any of them freely, and `bb plugin +// build` shims them all — but tsc resolves through node_modules, so each one +// needs its declarations installed for the import to typecheck (#2072). +const shimmedTypeDeps = new Set(SHIMMED_TYPE_PACKAGES); { const seenItems = new Set(); const itemQueue = [...STARTER_ITEMS]; @@ -74,10 +65,15 @@ const starterTypeOnlyDeps = new Set(); starterFiles.push({ target: file.target, content: file.content }); } for (const dep of item.dependencies ?? []) { - (SHIMMED_SPECIFIERS.has(dep) - ? starterTypeOnlyDeps - : starterBundledDeps - ).add(dep); + if (SHIMMED_SPECIFIERS.has(dep)) { + if (!shimmedTypeDeps.has(dep)) { + throw new Error( + `starter dep "${dep}" is shimmed but missing from SHIMMED_TYPE_PACKAGES`, + ); + } + continue; + } + starterBundledDeps.add(dep); } itemQueue.push( ...(item.registryDependencies ?? []).map((name) => @@ -126,8 +122,11 @@ export const PLUGIN_STARTER_FILES: readonly PluginStarterFile[] = ${JSON.stringi /** npm deps \`bb plugin build\` bundles — must be installed to build. */ export const PLUGIN_STARTER_DEPENDENCIES: Readonly> = ${JSON.stringify(versionedDeps(starterBundledDeps), null, 2)}; -/** Runtime-shimmed packages — installed for editor/tsc types only. */ -export const PLUGIN_STARTER_TYPE_DEPENDENCIES: Readonly> = ${JSON.stringify(versionedDeps(starterTypeOnlyDeps), null, 2)}; +/** + * Every package \`bb plugin build\` shims to the host runtime, at the host's + * version — installed for editor/tsc types only, never bundled. + */ +export const PLUGIN_SHIMMED_TYPE_DEPENDENCIES: Readonly> = ${JSON.stringify(versionedDeps(shimmedTypeDeps), null, 2)}; `; await writeGeneratedFile(starterOutputPath, starterOutput); diff --git a/packages/templates/src/plugin-scaffold.ts b/packages/templates/src/plugin-scaffold.ts index 9607b7b995..22d1ecc806 100644 --- a/packages/templates/src/plugin-scaffold.ts +++ b/packages/templates/src/plugin-scaffold.ts @@ -14,9 +14,9 @@ import { dirname, isAbsolute, join, relative } from "node:path"; import { derivePluginId, PLUGIN_SDK_VERSION } from "@bb/domain"; import { loadPluginSdkDeclarations } from "./plugin-sdk-dts.js"; import { + PLUGIN_SHIMMED_TYPE_DEPENDENCIES, PLUGIN_STARTER_DEPENDENCIES, PLUGIN_STARTER_FILES, - PLUGIN_STARTER_TYPE_DEPENDENCIES, } from "./generated/plugin-starter-files.generated.js"; /** @@ -311,6 +311,9 @@ export async function migratePluginToPackageLayout( const { rootDir, sdkVersion, dryRun = false } = args; const manifestPlan = await planManifest(rootDir, sdkVersion, { raiseFloor: true, + // The migration is the SDK layout switch; `bb plugin types` owns the + // shimmed-package pins once the plugin is on the package layout. + shimmedTypePins: "none", }); const typesPlan = await planVendoredDeletions(rootDir); // The `types` include entries only stop being the author's once the @@ -506,10 +509,27 @@ interface SetPluginSdkPinArgs { rootDir: string; /** Exact version to pin `@get-bb/plugin-sdk` to. */ sdkVersion: string; + /** + * Whether the manifest declares a `bb.app` frontend. An app plugin gets + * every runtime-shimmed package declared for types (see + * {@link PLUGIN_SHIMMED_TYPE_DEPENDENCIES}); a headless one only has the + * shimmed packages it already declares repinned. + */ + app: boolean; /** Report the change without writing (`bb plugin types --check`). */ dryRun?: boolean; } +/** One shimmed package {@link setPluginSdkPin} brought to the host's version. */ +export interface ShimmedTypePinChange { + name: string; + /** Previously declared range, or null when the package was not declared. */ + from: string | null; + to: string; + /** Whether the declaration was moved out of `dependencies`. */ + movedFromDependencies: boolean; +} + /** What {@link setPluginSdkPin} changed. */ interface PluginSdkPinChange { /** @@ -522,11 +542,14 @@ interface PluginSdkPinChange { * `devDependencies`, collapsing a manifest that declared it in both. */ movedFromDependencies: boolean; + /** Runtime-shimmed packages repinned, moved, or added (types only). */ + shimmedTypePins: ShimmedTypePinChange[]; } /** * Point a package-layout plugin's `@get-bb/plugin-sdk` devDependency at - * `sdkVersion` exactly, leaving `engines.bbPluginSdk` alone. + * `sdkVersion` exactly, leaving `engines.bbPluginSdk` alone, and bring the + * runtime-shimmed packages' type-only devDependencies to the host's versions. * * `bb plugin types` is the command that keeps a plugin's declarations matched * to the bb actually running it. Under the vendored layout it rewrote @@ -535,29 +558,50 @@ interface PluginSdkPinChange { * deliberately untouched: it states what the plugin's *source* requires, and * merely reading newer declarations does not raise that. * - * Returns null when the manifest already pins this version in the right - * section. + * The shimmed packages (sonner, vaul, the portaling radix families, ...) are + * the other half of the host-provided surface: `bb plugin build` swaps their + * imports for the host's copies, so the declarations a plugin typechecks + * against must be the host's versions too, and they belong in + * `devDependencies` for the same reason the SDK does (#2072). + * + * Returns null when the manifest already matches. */ export async function setPluginSdkPin( args: SetPluginSdkPinArgs, ): Promise { - const { rootDir, sdkVersion, dryRun = false } = args; - const plan = await planManifest(rootDir, sdkVersion, { raiseFloor: false }); + const { rootDir, sdkVersion, app, dryRun = false } = args; + const plan = await planManifest(rootDir, sdkVersion, { + raiseFloor: false, + shimmedTypePins: app ? "all" : "declared", + }); if (plan.text === null) return null; if (!dryRun) { await writeJsonFileAtomically(rootDir, "package.json", plan.text); } - return { pin: plan.pin, movedFromDependencies: plan.movedFromDependencies }; + return { + pin: plan.pin, + movedFromDependencies: plan.movedFromDependencies, + shimmedTypePins: plan.shimmedTypePins, + }; } interface ManifestPlan { pin: { from: string | null; to: string } | null; movedFromDependencies: boolean; + shimmedTypePins: ShimmedTypePinChange[]; enginesFloor: { from: string | null; to: string } | null; /** Replacement file text, or null when the manifest already matches. */ text: string | null; } +/** + * Which runtime-shimmed packages {@link planManifest} brings to the host's + * version: none (`bb plugin migrate`, whose plan is the SDK switch alone), + * the ones the manifest already declares, or every one (an app plugin, whose + * source may import any of them). + */ +type ShimmedTypePinPolicy = "none" | "declared" | "all"; + /** * Compute the package.json rewrite. Parsing failures throw here rather than * being swallowed: unlike layout detection, a migration that silently skipped @@ -566,7 +610,7 @@ interface ManifestPlan { async function planManifest( rootDir: string, sdkVersion: string, - options: { raiseFloor: boolean }, + options: { raiseFloor: boolean; shimmedTypePins: ShimmedTypePinPolicy }, ): Promise { const path = join(rootDir, "package.json"); await statNoFollow(path, "package.json"); @@ -619,6 +663,11 @@ async function planManifest( ); } + const shimmedTypePins = applyShimmedTypePins( + manifest, + options.shimmedTypePins, + ); + let enginesFloor: ManifestPlan["enginesFloor"] = null; if (options.raiseFloor) { const engines = asRecord(manifest.engines); @@ -630,10 +679,16 @@ async function planManifest( } } - if (pin === null && !movedFromDependencies && enginesFloor === null) { + if ( + pin === null && + !movedFromDependencies && + shimmedTypePins.length === 0 && + enginesFloor === null + ) { return { pin: null, movedFromDependencies: false, + shimmedTypePins: [], enginesFloor: null, text: null, }; @@ -641,11 +696,68 @@ async function planManifest( return { pin, movedFromDependencies, + shimmedTypePins, enginesFloor, text: reserialize(raw, manifest), }; } +/** + * Bring the runtime-shimmed packages' declarations to the host's versions, + * in place: each one ends up in `devDependencies` at the range the host app + * declares, and a copy in `dependencies` is removed — `bb plugin build` + * never reads it from node_modules, so a runtime declaration only installs a + * second copy of a singleton. Same rules as the SDK pin: the host's range in + * the right section is left alone; a drifted range, a wrong section, or + * (under `"all"`) a missing declaration is a change. + */ +function applyShimmedTypePins( + manifest: Record, + policy: ShimmedTypePinPolicy, +): ShimmedTypePinChange[] { + if (policy === "none") return []; + const changes: ShimmedTypePinChange[] = []; + const deps = asRecord(manifest.dependencies); + let devDeps = asRecord(manifest.devDependencies); + let depsChanged = false; + for (const [name, hostVersion] of Object.entries( + PLUGIN_SHIMMED_TYPE_DEPENDENCIES, + )) { + const runtimeDeclared = deps[name]; + const devDeclared = devDeps[name]; + const inDependencies = typeof runtimeDeclared === "string"; + const declared = + typeof devDeclared === "string" + ? devDeclared + : inDependencies + ? runtimeDeclared + : null; + if (declared === null && policy === "declared") continue; + if (declared === hostVersion && !inDependencies) continue; + changes.push({ + name, + from: declared, + to: hostVersion, + movedFromDependencies: inDependencies, + }); + if (inDependencies) { + delete deps[name]; + depsChanged = true; + } + devDeps = insertDependency(devDeps, name, hostVersion); + } + if (changes.length === 0) return []; + if (depsChanged) { + if (Object.keys(deps).length === 0) { + delete manifest.dependencies; + } else { + manifest.dependencies = deps; + } + } + manifest.devDependencies = devDeps; + return changes; +} + /** * Where the manifest declares `@get-bb/plugin-sdk`, and at what version. A * manifest that declares it in both sections reports the devDependencies @@ -1299,8 +1411,11 @@ Run \`npm install\` once before \`bb plugin build\` — the vendored components' npm deps bundle into your dist. React, and BB-shimmed packages like the radix portal primitives and \`sonner\` (\`import { toast } from "sonner"\` reaches BB's own toaster), are provided by the BB app at runtime and never -bundled. Ship \`dist/\` (npm tarball or committed for git installs) so -people installing your plugin never need npm. +bundled. Every shimmed package is declared in \`devDependencies\` at the +host's version so those imports typecheck; keep them there (never in +\`dependencies\`, which would bundle a second copy), and \`bb plugin types\` +repins them alongside the SDK. Ship \`dist/\` (npm tarball or committed for +git installs) so people installing your plugin never need npm. ` : ""; return `# ${packageName} @@ -1457,8 +1572,11 @@ export async function scaffoldPlugin(args: ScaffoldPluginArgs): Promise { "better-sqlite3": "^12.0.0", hono: "^4.11.9", typescript: "^5.7.0", - // Runtime-shimmed by BB (never bundled) — types only. - ...(app ? PLUGIN_STARTER_TYPE_DEPENDENCIES : {}), + // Every package BB shims to its own runtime (never bundled), at + // the host's version — types only, so each documented "import + // freely" specifier resolves for tsc and the editor. `bb plugin + // types` keeps these matched to the BB you run, like the SDK pin. + ...(app ? PLUGIN_SHIMMED_TYPE_DEPENDENCIES : {}), }, }, null, diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index 075a1ca4d1..f337278e0e 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -237,7 +237,10 @@ added/updated/unchanged counts. app.tsx, plus a typecheck-only tsconfig.json) bb plugin types [path] Sync a plugin's @get-bb/plugin-sdk surface to this bb (default: cwd): repin the npm - devDependency to this bb's SDK version, or + devDependency to this bb's SDK version and + the type-only devDependencies of the packages + bb shims at runtime (sonner, vaul, the portal + radix families, ...) to this bb's versions, or rewrite the vendored types/ of a plugin that still carries them; --check writes nothing and exits non-zero on a mismatch @@ -575,7 +578,11 @@ not. A plugin can address only its own eligible tab on the current nav panel. `import { toast } from "sonner"` reaches the host toaster; react, the portaling radix families, sonner, vaul, @pierre/diffs, and the host-resident clsx, tailwind-merge, and -class-variance-authority libraries are runtime-shimmed (never bundled) — +class-variance-authority libraries are runtime-shimmed (never bundled). Shimmed +does not mean undeclared: tsc resolves their declarations through node_modules, +so each shimmed package a plugin imports is a type-only devDependency at the +host's version — the scaffold declares all of them and `bb plugin types` +repins them; never list one in dependencies, which would bundle a second copy — though source and diffs should go through the host's own experimental_SourceCode / experimental_Diff components rather than @pierre/diffs directly, so bb owns patch normalization, syntax @@ -662,8 +669,9 @@ works for existing entries. Run `bb plugin migrate` before adding `bb.host` so the `/host` and `/testing/host` declaration subpaths are available; migration shows every change and asks first. The SDK surface grows every release, so `bb plugin types` syncs a plugin to -the running bb — repinning the devDependency, or rewriting types/ for a -plugin that still vendors them. Run it in a cloned or older plugin, and `bb +the running bb — repinning the SDK devDependency and the shimmed packages' +type-only devDependencies, or rewriting types/ for a plugin that still +vendors them. Run it in a cloned or older plugin, and `bb plugin types --check` in CI. `bb plugin build` and `bb plugin dev` keep a vendored plugin in step for you. Need a symbol the types don't explain? Clone the repo: https://github.com/get-bb/bb. The API in diff --git a/packages/templates/test/plugin-migrate-layout.test.ts b/packages/templates/test/plugin-migrate-layout.test.ts index 62102350a2..d7ed32f7a3 100644 --- a/packages/templates/test/plugin-migrate-layout.test.ts +++ b/packages/templates/test/plugin-migrate-layout.test.ts @@ -15,6 +15,7 @@ import { resolvePluginSdkLayout, setPluginSdkPin, } from "../src/plugin-scaffold.js"; +import { PLUGIN_SHIMMED_TYPE_DEPENDENCIES } from "../src/generated/plugin-starter-files.generated.js"; const SDK_VERSION = "0.4.3"; @@ -595,11 +596,12 @@ describe("setPluginSdkPin", () => { )}\n`, ); - const result = await setPluginSdkPin({ rootDir, sdkVersion: SDK_VERSION }); + const result = await setPluginSdkPin({ rootDir, sdkVersion: SDK_VERSION, app: false }); expect(result).toEqual({ pin: { from: "0.2.0", to: SDK_VERSION }, movedFromDependencies: false, + shimmedTypePins: [], }); const manifest = await readJson(join(rootDir, "package.json")); expect( @@ -611,7 +613,7 @@ describe("setPluginSdkPin", () => { expect((manifest.engines as Record).bbPluginSdk).toBe( ">=0.2.0", ); - expect(await setPluginSdkPin({ rootDir, sdkVersion: SDK_VERSION })).toBeNull(); + expect(await setPluginSdkPin({ rootDir, sdkVersion: SDK_VERSION, app: false })).toBeNull(); }); it("moves a runtime-declared SDK into devDependencies rather than duplicating it", async () => { @@ -628,7 +630,7 @@ describe("setPluginSdkPin", () => { )}\n`, ); - const result = await setPluginSdkPin({ rootDir, sdkVersion: SDK_VERSION }); + const result = await setPluginSdkPin({ rootDir, sdkVersion: SDK_VERSION, app: false }); expect(result?.movedFromDependencies).toBe(true); const manifest = await readJson(join(rootDir, "package.json")); @@ -658,15 +660,126 @@ describe("setPluginSdkPin", () => { )}\n`, ); - const result = await setPluginSdkPin({ rootDir, sdkVersion: SDK_VERSION }); + const result = await setPluginSdkPin({ rootDir, sdkVersion: SDK_VERSION, app: false }); - expect(result).toEqual({ pin: null, movedFromDependencies: true }); + expect(result).toEqual({ + pin: null, + movedFromDependencies: true, + shimmedTypePins: [], + }); const manifest = await readJson(join(rootDir, "package.json")); expect(manifest.dependencies).toBeUndefined(); expect(manifest.devDependencies).toEqual({ "@get-bb/plugin-sdk": SDK_VERSION, }); // And now it really is a no-op. - expect(await setPluginSdkPin({ rootDir, sdkVersion: SDK_VERSION })).toBeNull(); + expect(await setPluginSdkPin({ rootDir, sdkVersion: SDK_VERSION, app: false })).toBeNull(); + }); + + /** + * #2072: the shimmed packages are host-provided exactly like the SDK, so an + * app plugin's type-only declarations for them track the running bb — a + * drifted range, a copy in `dependencies` (which would bundle a second + * sonner beside the host's toaster), or a missing one is brought to the + * host's version in `devDependencies`. + */ + it("brings an app plugin's shimmed packages to the host's versions in devDependencies", async () => { + const hostSonner = PLUGIN_SHIMMED_TYPE_DEPENDENCIES.sonner!; + const hostVaul = PLUGIN_SHIMMED_TYPE_DEPENDENCIES.vaul!; + await writeFile( + join(rootDir, "package.json"), + `${JSON.stringify( + { + name: "bb-plugin-toasty", + bb: { server: "./server.ts", app: "./app.tsx" }, + dependencies: { vaul: "^0.9.0", zod: "^4.3.6" }, + devDependencies: { + "@get-bb/plugin-sdk": SDK_VERSION, + sonner: "^0.3.0", + typescript: "^5.7.0", + }, + }, + null, + 2, + )}\n`, + ); + + const result = await setPluginSdkPin({ + rootDir, + sdkVersion: SDK_VERSION, + app: true, + }); + + expect(result?.pin).toBeNull(); + expect(result?.shimmedTypePins).toContainEqual({ + name: "sonner", + from: "^0.3.0", + to: hostSonner, + movedFromDependencies: false, + }); + expect(result?.shimmedTypePins).toContainEqual({ + name: "vaul", + from: "^0.9.0", + to: hostVaul, + movedFromDependencies: true, + }); + expect(result?.shimmedTypePins).toContainEqual({ + name: "@radix-ui/react-popover", + from: null, + to: PLUGIN_SHIMMED_TYPE_DEPENDENCIES["@radix-ui/react-popover"], + movedFromDependencies: false, + }); + const manifest = await readJson(join(rootDir, "package.json")); + expect(manifest.dependencies).toEqual({ zod: "^4.3.6" }); + const devDependencies = manifest.devDependencies as Record; + for (const [name, version] of Object.entries( + PLUGIN_SHIMMED_TYPE_DEPENDENCIES, + )) { + expect(devDependencies[name], name).toBe(version); + } + expect(devDependencies["@get-bb/plugin-sdk"]).toBe(SDK_VERSION); + expect(devDependencies.typescript).toBe("^5.7.0"); + // Idempotent once synced — `bb plugin types --check` reads this as current. + expect( + await setPluginSdkPin({ rootDir, sdkVersion: SDK_VERSION, app: true }), + ).toBeNull(); + }); + + it("only repins the shimmed packages a headless plugin already declares", async () => { + await writeFile( + join(rootDir, "package.json"), + `${JSON.stringify( + { + name: "bb-plugin-headless", + bb: { server: "./server.ts" }, + devDependencies: { + "@get-bb/plugin-sdk": SDK_VERSION, + clsx: "^1.0.0", + }, + }, + null, + 2, + )}\n`, + ); + + const result = await setPluginSdkPin({ + rootDir, + sdkVersion: SDK_VERSION, + app: false, + }); + + expect(result?.shimmedTypePins).toEqual([ + { + name: "clsx", + from: "^1.0.0", + to: PLUGIN_SHIMMED_TYPE_DEPENDENCIES.clsx, + movedFromDependencies: false, + }, + ]); + const manifest = await readJson(join(rootDir, "package.json")); + expect(manifest.devDependencies).toEqual({ + "@get-bb/plugin-sdk": SDK_VERSION, + clsx: PLUGIN_SHIMMED_TYPE_DEPENDENCIES.clsx, + }); }); }); diff --git a/packages/templates/test/plugin-scaffold-shim-types.test.ts b/packages/templates/test/plugin-scaffold-shim-types.test.ts new file mode 100644 index 0000000000..89b960f681 --- /dev/null +++ b/packages/templates/test/plugin-scaffold-shim-types.test.ts @@ -0,0 +1,164 @@ +import { execFile } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { scaffoldPlugin } from "../src/plugin-scaffold.js"; +import { PLUGIN_SHIMMED_TYPE_DEPENDENCIES } from "../src/generated/plugin-starter-files.generated.js"; + +const execFileAsync = promisify(execFile); +const repoRoot = resolve(import.meta.dirname, "..", "..", ".."); +const appRoot = join(repoRoot, "apps", "app"); +const pluginSdkRoot = join(repoRoot, "packages", "plugin-sdk"); + +/** + * #2072: `bb plugin build` resolves the shimmed packages (sonner, vaul, the + * portal radix families, @pierre/diffs, ...) through an esbuild shim, but a + * plugin's `tsc` resolves them through node_modules like any other import — + * so the scaffold must declare every one of them for types, not just the ones + * its starter components happen to use. This scaffolds a plugin, materialises + * node_modules the way `npm install --include=dev` would (exactly the packages + * the manifest declares, linked from this workspace so no network is needed), + * and runs the scaffold's own tsc over an app that imports each shimmed + * specifier. + */ + +/** + * Where the workspace keeps an installed package. pnpm lays each dependency + * out as `node_modules/` (a link into the store) under the package that + * declares it; `require.resolve` is no use for ESM-only packages with a strict + * exports map (@pierre/diffs). + */ +function workspacePackageRoot(name: string): string { + for (const base of [appRoot, repoRoot, pluginSdkRoot]) { + const candidate = join(base, "node_modules", name); + try { + readFileSync(join(candidate, "package.json"), "utf8"); + return candidate; + } catch { + // not here + } + } + throw new Error(`package not installed in the workspace: ${name}`); +} + +async function installDeclaredDependencies(targetDir: string): Promise { + const manifest: { + dependencies?: Record; + devDependencies?: Record; + } = JSON.parse(await readFile(join(targetDir, "package.json"), "utf8")); + const names = new Set([ + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.devDependencies ?? {}), + ]); + for (const name of names) { + const target = join(targetDir, "node_modules", name); + await mkdir(dirname(target), { recursive: true }); + // The SDK links to the workspace package (bundled-types/ is built by the + // turbo dependency of this test task). + const source = + name === "@get-bb/plugin-sdk" + ? pluginSdkRoot + : workspacePackageRoot(name); + await symlink(source, target, "dir"); + } +} + +async function runTsc( + targetDir: string, +): Promise<{ ok: boolean; output: string }> { + const tsc = join(workspacePackageRoot("typescript"), "lib", "tsc.js"); + try { + const { stdout, stderr } = await execFileAsync( + process.execPath, + [tsc, "--project", "tsconfig.json"], + { cwd: targetDir }, + ); + return { ok: true, output: `${stdout}${stderr}` }; + } catch (error) { + const failed = error as { stdout?: string; stderr?: string }; + return { + ok: false, + output: `${failed.stdout ?? ""}${failed.stderr ?? ""}`, + }; + } +} + +/** + * Every shimmed specifier a plugin may import, including subpath exports — + * the build's table is keyed by specifier, the manifest by package, and a + * package can declare types for its root but not a subpath. + */ +const SHIMMED_SPECIFIERS = [ + "react", + "react-dom", + "react-dom/client", + "react/jsx-runtime", + ...Object.keys(PLUGIN_SHIMMED_TYPE_DEPENDENCIES), + "@pierre/diffs/react", +]; + +describe("scaffold typechecks the runtime-shimmed imports (#2072)", () => { + let workDir: string; + let targetDir: string; + + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), "bb-scaffold-shims-")); + targetDir = join(workDir, "bb-plugin-toasty"); + await scaffoldPlugin({ + targetDir, + packageName: "bb-plugin-toasty", + bbVersion: "0.39.0", + app: true, + }); + await installDeclaredDependencies(targetDir); + }); + + afterEach(async () => { + await rm(workDir, { recursive: true, force: true }); + }); + + it('the documented `import { toast } from "sonner"` and every other shimmed specifier resolve', async () => { + const appPath = join(targetDir, "app.tsx"); + const app = await readFile(appPath, "utf8"); + expect(app).toContain('import { useState } from "react";'); + await writeFile( + appPath, + app.replace( + 'import { useState } from "react";', + 'import { useState } from "react";\nimport { toast } from "sonner";\ntoast.success("hi");', + ), + ); + const lines = SHIMMED_SPECIFIERS.map( + (specifier, i) => `import * as m${i} from "${specifier}";`, + ); + lines.push( + `export const all = [${SHIMMED_SPECIFIERS.map((_, i) => `m${i}`).join(", ")}];`, + ); + await writeFile( + join(targetDir, "components", "all-shims.ts"), + `${lines.join("\n")}\n`, + ); + + const result = await runTsc(targetDir); + + // Before the fix: "error TS2307: Cannot find module 'sonner' or its + // corresponding type declarations." for sonner, vaul, @pierre/diffs and + // nine radix families — every shim the starter components did not import. + const missing = [ + ...result.output.matchAll(/Cannot find module '([^']+)'/g), + ].map((m) => m[1]); + expect(missing, result.output).toEqual([]); + expect(result.output).toBe(""); + expect(result.ok).toBe(true); + }, 120_000); +}); diff --git a/turbo.json b/turbo.json index 04f216a6df..a96118aee3 100644 --- a/turbo.json +++ b/turbo.json @@ -300,6 +300,7 @@ "scripts/generate-plugin-scaffold.mjs", "scripts/write-generated.mjs", "$TURBO_ROOT$/apps/app/package.json", + "$TURBO_ROOT$/packages/plugin-build/src/runtime-shims.mjs", "$TURBO_ROOT$/packages/plugin-registry/r/**" ], "outputs": ["src/generated/plugin-starter-files.generated.ts"] @@ -311,6 +312,7 @@ "inputs": [ "scripts/generate-plugin-theme.mjs", "scripts/generate-runtime-export-manifest.mjs", + "src/runtime-shims.mjs", "$TURBO_ROOT$/apps/app/package.json", "$TURBO_ROOT$/apps/app/src/components/ui/theme.css", "$TURBO_ROOT$/packages/plugin-sdk/src/**",