From f91ccf373a85e3a9f9c595cffe62b6401940b983 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Mon, 24 Aug 2026 03:48:32 +0000 Subject: [PATCH 1/3] fix(tools): resolve registry dependency cycles without deadlocking A node's spec was only returned after awaiting its full subtree expansion, so a cyclic edge back to an in-flight name@range made the resolution promises await each other forever (e.g. @nuxt/devtools@4.0.0-alpha.15 froze partway through). Defer subtree expansion to a queue drained to a fixpoint by the driver, so a node resolves to its spec as soon as its version is picked. --- .../src/registry/resolve.test.ts | 37 +++++++++++++++++++ .../src/registry/resolve.ts | 34 ++++++++++++++--- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/packages/node-modules-tools/src/registry/resolve.test.ts b/packages/node-modules-tools/src/registry/resolve.test.ts index 3c02f60a..10d3c3f3 100644 --- a/packages/node-modules-tools/src/registry/resolve.test.ts +++ b/packages/node-modules-tools/src/registry/resolve.test.ts @@ -254,6 +254,43 @@ describe('resolveRegistryDependencies', () => { expect(result.warnings.map(w => w.type).sort()).toEqual(['fetch-error', 'unresolved-version']) }) + it('resolves dependency cycles without deadlocking', async () => { + // A cycle where the same `name@range` recurs on the back-edge used to + // deadlock: a node's spec was only returned after its whole subtree + // expanded, so a cyclic edge back to the in-flight node awaited itself. + const { fetch } = createMockRegistry({ + a: { versions: { '1.0.0': { dependencies: { b: '^1.0.0' } } } }, + b: { versions: { '1.0.0': { dependencies: { c: '^1.0.0' } } } }, + c: { versions: { '1.0.0': { dependencies: { a: '^1.0.0' } } } }, + }) + + const result = await resolveRegistryDependencies({ + dependencies: { a: '^1.0.0' }, + fetch, + }) + + expect(specs(result)).toEqual(['a@1.0.0', 'b@1.0.0', 'c@1.0.0', ROOT_SPEC]) + expect([...result.packages.get('a@1.0.0')!.dependencies]).toEqual(['b@1.0.0']) + expect([...result.packages.get('b@1.0.0')!.dependencies]).toEqual(['c@1.0.0']) + expect([...result.packages.get('c@1.0.0')!.dependencies]).toEqual(['a@1.0.0']) + expect(result.warnings).toEqual([]) + }) + + it('resolves a self-referential dependency without deadlocking', async () => { + const { fetch } = createMockRegistry({ + a: { versions: { '1.0.0': { dependencies: { a: '^1.0.0', b: '^1.0.0' } } } }, + b: { versions: { '1.0.0': {} } }, + }) + + const result = await resolveRegistryDependencies({ + dependencies: { a: '^1.0.0' }, + fetch, + }) + + expect(specs(result)).toEqual(['a@1.0.0', 'b@1.0.0', ROOT_SPEC]) + expect([...result.packages.get('a@1.0.0')!.dependencies].sort()).toEqual(['a@1.0.0', 'b@1.0.0']) + }) + it('respects the depth limit', async () => { const { fetch } = createMockRegistry({ d1: { versions: { '1.0.0': { dependencies: { d2: '*' } } } }, diff --git a/packages/node-modules-tools/src/registry/resolve.ts b/packages/node-modules-tools/src/registry/resolve.ts index 067000c7..ee2c90cc 100644 --- a/packages/node-modules-tools/src/registry/resolve.ts +++ b/packages/node-modules-tools/src/registry/resolve.ts @@ -24,6 +24,12 @@ interface PeerTask { depth: number } +interface ExpandTask { + node: PackageNodeRaw + meta: RegistryAbbreviatedVersion + depth: number +} + /** * Resolve a dependency graph purely from npm-registry metadata — no package * manager, no filesystem. Produces the same result shape as @@ -61,6 +67,12 @@ export async function resolveRegistryDependencies( const warnings: RegistryResolveWarning[] = [] const resolutions = new Map>() const peerTasks: PeerTask[] = [] + // Expansion of a node's subtree is deferred and drained to a fixpoint by the + // driver below, rather than awaited inline while resolving the node. This is + // what keeps dependency cycles from deadlocking: a node's spec is returned as + // soon as its version is picked, so a cyclic edge back to an in-flight + // `name@range` resolves immediately instead of awaiting its own subtree. + const expandQueue: ExpandTask[] = [] function warn(warning: RegistryResolveWarning) { warnings.push(warning) @@ -178,8 +190,10 @@ export async function resolveRegistryDependencies( versionsByName.get(name)!.add(version) reportResolving() + // Defer expansion so this promise resolves to `spec` right away — + // see `expandQueue` above for why this avoids cycle deadlocks. if (depth < maxDepth) - await expandNode(node, meta, depth) + expandQueue.push({ node, meta, depth }) } return spec })()) @@ -249,10 +263,20 @@ export async function resolveRegistryDependencies( } })) - // Auto-installed peers may bring their own dependencies and peers — iterate to fixpoint - while (peerTasks.length) { - const batch = peerTasks.splice(0) - await Promise.all(batch.map(task => resolvePeer(task))) + // Drain the deferred subtree expansions, then any auto-installed peers. + // Both may enqueue further work (expansions bring more deps and peers; peers + // may auto-install packages with their own subtrees), so iterate to a + // fixpoint. Expansions are fully drained before peers so a peer is matched + // against the complete non-peer graph (npm 7+ behavior). + while (expandQueue.length || peerTasks.length) { + if (expandQueue.length) { + const batch = expandQueue.splice(0) + await Promise.all(batch.map(task => expandNode(task.node, task.meta, task.depth))) + } + else { + const batch = peerTasks.splice(0) + await Promise.all(batch.map(task => resolvePeer(task))) + } } // Synthetic workspace root holding the inputs (hidden by the default workspace filter) From f2898e295325aa63543223cf358ddb29627a2004 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Mon, 24 Aug 2026 03:48:40 +0000 Subject: [PATCH 2/3] fix(inspector): surface registry query failures and fix landing navigation When a top-level registry query resolves nothing, throw so the landing shows an error box instead of dropping into an empty graph. Navigate landing -> inspector with router.push (not the implicit replace) so the browser Back button returns to the landing; gate the landing on the reactive router.currentRoute since Landing renders outside . --- .../src/app/registry/index.ts | 18 +++++++++++++ .../src/app/web/Landing.vue | 25 ++++++++++++++++--- test/e2e/instant.spec.ts | 25 +++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/node-modules-inspector/src/app/registry/index.ts b/packages/node-modules-inspector/src/app/registry/index.ts index a0f5fc8b..db43d36f 100644 --- a/packages/node-modules-inspector/src/app/registry/index.ts +++ b/packages/node-modules-inspector/src/app/registry/index.ts @@ -68,6 +68,24 @@ export function createRegistryBackend( }) registryWarnings.value = warnings + // If the user asked for top-level packages but none of them could be + // resolved, treat it as a hard failure. Throwing here propagates to the + // landing page's error box (via `fetchData(..., propagateError)`), + // instead of dropping the user into an empty graph with only a toast. + const requestedCount = Object.keys(dependencies).length + const root = [...result.packages.values()].find(pkg => pkg.workspace) + const resolvedTopLevel = root?.dependencies.size ?? 0 + if (requestedCount && !resolvedTopLevel) { + const reasons = warnings + .filter(warning => !warning.dependent) + .map(warning => warning.message) + throw new Error( + reasons.length + ? reasons.join('\n') + : 'None of the requested packages could be resolved from the npm registry.', + ) + } + return { hash: getHash([...result.packages.keys()].sort()), timestamp: Date.now(), diff --git a/packages/node-modules-inspector/src/app/web/Landing.vue b/packages/node-modules-inspector/src/app/web/Landing.vue index f2125176..79452b34 100644 --- a/packages/node-modules-inspector/src/app/web/Landing.vue +++ b/packages/node-modules-inspector/src/app/web/Landing.vue @@ -2,13 +2,14 @@ import type { InstallExcludeSpec } from 'node-modules-tools/registry' import { parseInstallSpecs } from 'node-modules-tools/registry' import { computed, defineAsyncComponent, onMounted, ref, shallowRef } from 'vue' +import { useRouter } from '#app/composables/router' import { backend } from '../backends' import RegistryWarnings from '../components/registry/Warnings.vue' import UiCredits from '../components/ui/Credits.vue' import UiTitle from '../components/ui/Title.vue' import MainEntry from '../entries/main.vue' import { createRegistryBackend, registryProgress } from '../registry' -import { fetchData, rawPayload } from '../state/data' +import { fetchData } from '../state/data' import { query } from '../state/query' import { openTerminal, showTerminal } from '../state/terminal' @@ -18,6 +19,11 @@ const LazyPanelTerminal = defineAsyncComponent(() => import('../components/panel type WebMode = 'instant' | 'sandbox' +const router = useRouter() +// `Landing` renders outside ``, where `useRoute()` returns a stale +// snapshot — `router.currentRoute` is the reactive source that tracks pushes. +const isLanding = computed(() => router.currentRoute.value.path === '/') + // The WebContainer SDK (`@webcontainer/api`) is heavy and only needed for // "Sandbox Install" mode. Load `./container` lazily so the default Instant // (npm-registry) mode never pulls the SDK into the initial bundle. @@ -147,6 +153,12 @@ async function run() { await runSandbox(deps) else await runInstant(deps) + + // Navigate into the inspector with a real history push (not a replace) so + // the browser Back button returns here to the landing. The landing lives + // at `/`; once we're on an inspector route the payload renders MainEntry. + if (isLanding.value) + await router.push({ path: '/grid/depth', hash: location.hash }) } catch (e) { console.error(e) @@ -187,7 +199,12 @@ async function runSandbox(deps: Record) {