From 7e2ca5fe268228c5684a4c718b0b4232b8861939 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 15 Sep 2026 14:40:58 -0700 Subject: [PATCH] fix(tui): retry installed plugin dependencies (#49187) --- packages/plugin/src/source.bun.ts | 11 ++++++++- packages/plugin/src/source.node.ts | 11 ++++++++- packages/plugin/src/source.package.ts | 13 +++++++++++ packages/tui/test/plugin-source.test.ts | 31 +++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 packages/plugin/src/source.package.ts diff --git a/packages/plugin/src/source.bun.ts b/packages/plugin/src/source.bun.ts index c84c5a79c34..618f51556b9 100644 --- a/packages/plugin/src/source.bun.ts +++ b/packages/plugin/src/source.bun.ts @@ -4,6 +4,7 @@ import path from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" import { Host } from "./host.js" import { localSource } from "./source.js" +import { missingPackageTarget } from "./source.package.js" let generation = Date.now() @@ -37,7 +38,15 @@ export async function prepareSource(entrypoint: string, track: (file: string, di item.path.startsWith("./") || item.path.startsWith("../") ? new URL(item.path, pathToFileURL(file)) : localSource(item.path, path.dirname(file)) - if (!local) continue + if (!local) { + try { + Bun.resolveSync(item.path, path.dirname(file)) + } catch { + const target = missingPackageTarget(item.path, file) + if (target) track(target, true) + } + continue + } const requested = fileURLToPath(local) // Resolving a workspace symlink can erase its node_modules boundary. if (requested.split(path.sep).includes("node_modules")) continue diff --git a/packages/plugin/src/source.node.ts b/packages/plugin/src/source.node.ts index ea5ed20b363..24c2b027e5a 100644 --- a/packages/plugin/src/source.node.ts +++ b/packages/plugin/src/source.node.ts @@ -2,6 +2,7 @@ import { registerHooks } from "node:module" import path from "node:path" import { fileURLToPath } from "node:url" import { localSource } from "./source.js" +import { missingPackageTarget } from "./source.package.js" import { Host } from "./host.js" let generation = Date.now() @@ -21,7 +22,15 @@ export async function prepareSource(entrypoint: string, track: (file: string, di specifier.startsWith("./") || specifier.startsWith("../") ? new URL(specifier, context.parentURL) : localSource(specifier, path.dirname(fileURLToPath(context.parentURL))) - if (!local) return nextResolve(specifier, context) + if (!local) { + try { + return nextResolve(specifier, context) + } catch (error) { + const target = missingPackageTarget(specifier, fileURLToPath(context.parentURL)) + if (target) track(target, true) + throw error + } + } if (fileURLToPath(local).split(path.sep).includes("node_modules")) return nextResolve(specifier, context) const resolved = (() => { try { diff --git a/packages/plugin/src/source.package.ts b/packages/plugin/src/source.package.ts new file mode 100644 index 00000000000..895fc1ed14b --- /dev/null +++ b/packages/plugin/src/source.package.ts @@ -0,0 +1,13 @@ +import { existsSync } from "node:fs" +import path from "node:path" + +export function missingPackageTarget(specifier: string, importer: string) { + if (specifier.startsWith("#")) return undefined + const parts = specifier.split("/") + const name = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0] + const start = path.dirname(importer) + for (let directory = start; ; directory = path.dirname(directory)) { + if (existsSync(path.join(directory, "package.json"))) return path.join(directory, "node_modules", name) + if (path.dirname(directory) === directory) return path.join(start, "node_modules", name) + } +} diff --git a/packages/tui/test/plugin-source.test.ts b/packages/tui/test/plugin-source.test.ts index 8dd30e14065..bffbd2807d3 100644 --- a/packages/tui/test/plugin-source.test.ts +++ b/packages/tui/test/plugin-source.test.ts @@ -89,6 +89,30 @@ test("renamed exports, failed loads, and new dependencies recover without cached expect((await sources.read(entry.href)).module).toMatchObject({ default: 3 }) }) +test("a missing package dependency reloads when it is installed", async () => { + let changes = 0 + const watcher = createSourceWatcher(() => { + changes++ + }) + using _watcher = { [Symbol.dispose]: watcher.dispose } + await using sources = await fixture(watcher.wait) + const entry = new URL("tui.ts", sources.url) + await Bun.write(entry, 'export { default } from "example"') + await expect(sources.read(entry.href)).rejects.toThrow("example") + + const count = changes + await Bun.write(new URL("node_modules/example/package.json", sources.url), '{"type":"module","main":"index.js"}') + await Bun.write(new URL("node_modules/example/index.js", sources.url), 'export default "installed"') + const deadline = Date.now() + 3000 + while (Date.now() < deadline) { + if (changes > count) break + await Bun.sleep(10) + } + + expect(changes).toBeGreaterThan(count) + expect((await sources.read(entry.href)).module).toMatchObject({ default: "installed" }) +}) + test("shared runtime and ordinary package identities survive plugin generations", async () => { await using sources = await fixture() const entry = new URL("tui.ts", sources.url) @@ -223,6 +247,13 @@ test.each(["", "?mode=plugin", "?mode=plugin#section"])( assert.equal(new URL(updated.source).pathname, helper.pathname) assert.equal(new URL(updated.source).searchParams.get("mode"), suffix ? "plugin" : null) assert.equal(new URL(updated.source).hash, suffix.includes("#") ? "#section" : "") + const missing = new URL("./missing.mjs", import.meta.url) + await writeFile(missing, 'export { default } from "later"') + await assert.rejects(sources.read(missing.href), /later/) + await mkdir(new URL("./node_modules/later", import.meta.url), { recursive: true }) + await writeFile(new URL("./node_modules/later/package.json", import.meta.url), '{"type":"module","main":"index.js"}') + await writeFile(new URL("./node_modules/later/index.js", import.meta.url), 'export default "installed"') + assert.equal((await sources.read(missing.href)).module.default, "installed") console.log("node graph reload passed") } finally { sources.dispose() } `,