fix(tui): retry installed plugin dependencies (#49187)

This commit is contained in:
Kit Langton
2026-09-15 14:40:58 -07:00
committed by GitHub
parent aaed6eaa45
commit 7e2ca5fe26
4 changed files with 64 additions and 2 deletions
+10 -1
View File
@@ -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
+10 -1
View File
@@ -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 {
+13
View File
@@ -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)
}
}
+31
View File
@@ -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() }
`,