Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bugfix: fix circular dependency loop in dev #3562

Merged
merged 2 commits into from Aug 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/curvy-nails-rest.md
@@ -0,0 +1,5 @@
---
'snowpack': patch
---

Bugfix: dev server hanging on circular dependencies
74 changes: 46 additions & 28 deletions snowpack/src/sources/local.ts
Expand Up @@ -33,12 +33,12 @@ import {installPackages} from './local-install';

const CURRENT_META_FILE_CONTENTS = `.snowpack cache - Do not edit this directory!

The ".snowpack" cache directory is fully managed for you by Snowpack.
The ".snowpack" cache directory is fully managed for you by Snowpack.
Manual changes that you make to the files inside could break things.

Commit this directory to source control to speed up cold starts.

Found an issue? You can always delete the ".snowpack"
Found an issue? You can always delete the ".snowpack"
directory and Snowpack will recreate it on next run.

[.meta.version=2]`;
Expand Down Expand Up @@ -112,6 +112,7 @@ export class PackageSourceLocal implements PackageSource {
cacheDirectory: string;
packageSourceDirectory: string;
memoizedResolve: Record<string, string> = {};
memoizedImportMap: Record<string, ImportMap> = {};
allPackageImports: Record<string, PackageImportData> = {};
allSymlinkImports: Record<string, string> = {};
allKnownSpecs = new Set<string>();
Expand Down Expand Up @@ -140,6 +141,9 @@ export class PackageSourceLocal implements PackageSource {

private async setupCacheDirectory() {
const {config, packageSourceDirectory, cacheDirectory} = this;
const lockfileLoc = path.join(cacheDirectory, 'lock.json');
const manifestLoc = path.join(packageSourceDirectory, 'package.json');
const manifestLockLoc = path.join(packageSourceDirectory, 'package-lock.json');
await mkdirp(packageSourceDirectory);
if (config.dependencies) {
await fs.writeFile(
Expand All @@ -154,15 +158,15 @@ export class PackageSourceLocal implements PackageSource {
),
'utf8',
);
const lockfile = await fs.readFile(path.join(cacheDirectory, 'lock.json')).catch(() => null);
if (lockfile) {
await fs.writeFile(path.join(packageSourceDirectory, 'package-lock.json'), lockfile);
if (existsSync(lockfileLoc)) {
const lockfile = await fs.readFile(lockfileLoc);
await fs.writeFile(manifestLockLoc, lockfile);
} else {
await fs.unlink(path.join(packageSourceDirectory, 'package-lock.json')).catch(() => null);
if (existsSync(manifestLockLoc)) await fs.unlink(manifestLockLoc);
}
} else {
await fs.unlink(path.join(packageSourceDirectory, 'package.json')).catch(() => null);
await fs.unlink(path.join(packageSourceDirectory, 'package-lock.json')).catch(() => null);
if (existsSync(manifestLoc)) await fs.unlink(manifestLoc);
if (existsSync(manifestLockLoc)) await fs.unlink(manifestLockLoc);
}
}

Expand Down Expand Up @@ -202,9 +206,9 @@ export class PackageSourceLocal implements PackageSource {

async prepare() {
const installDirectoryHashLoc = path.join(this.cacheDirectory, '.meta');
const installDirectoryHash = await fs
.readFile(installDirectoryHashLoc, 'utf-8')
.catch(() => null);
const installDirectoryHash = existsSync(installDirectoryHashLoc)
? await fs.readFile(installDirectoryHashLoc, 'utf8')
: undefined;

if (installDirectoryHash === CURRENT_META_FILE_CONTENTS) {
logger.debug(`Install directory ".meta" file is up-to-date. Welcome back!`);
Expand Down Expand Up @@ -425,7 +429,7 @@ export class PackageSourceLocal implements PackageSource {
}

private async buildPackageImport(spec: string, _source?: string, logLine = false, depth = 0) {
const {config, memoizedResolve, allKnownSpecs, allPackageImports} = this;
const {config, memoizedResolve, memoizedImportMap, allKnownSpecs, allPackageImports} = this;
const source = _source || this.packageSourceDirectory;
const aliasEntry = findMatchingAliasEntry(config, spec);
if (aliasEntry && aliasEntry.type === 'package') {
Expand Down Expand Up @@ -466,6 +470,9 @@ export class PackageSourceLocal implements PackageSource {
],
});

// if this has already been memoized, exit
if (memoizedResolve[entrypoint]) return memoizedResolve[entrypoint];

let rootPackageDirectory = getRootPackageDirectory(entrypoint);
if (!rootPackageDirectory) {
const rootPackageManifestLoc = await findUp('package.json', {cwd: entrypoint});
Expand Down Expand Up @@ -494,13 +501,22 @@ export class PackageSourceLocal implements PackageSource {
const lineBullet = colors.dim(depth === 0 ? '+' : '└──'.padStart(depth * 2 + 1, ' '));
let packageFormatted = spec + colors.dim('@' + packageVersion);
const existingImportMapLoc = path.join(installDest, 'import-map.json');
const importMapHandle = await fs.open(existingImportMapLoc, 'r+').catch(() => null);

let existingImportMap: ImportMap | null = null;
if (importMapHandle) {
const importMapData = await importMapHandle.readFile('utf-8');
existingImportMap = importMapData ? JSON.parse(importMapData) : null;
await importMapHandle.close();
let existingImportMap: ImportMap | undefined = memoizedImportMap[packageName];
drwpow marked this conversation as resolved.
Show resolved Hide resolved
if (!existingImportMap) {
// note: this must happen BEFORE the check on disk to prevent a race condition.
// If two lookups occur at once from different sources, then we mark this as “taken” immediately and finish the lookup async
memoizedImportMap[packageName] = {imports: {}}; // TODO: this may not exist; should we throw an error?
try {
const importMapHandle = await fs.open(existingImportMapLoc, 'r+');
if (importMapHandle) {
const importMapData = await importMapHandle.readFile('utf8');
existingImportMap = importMapData ? JSON.parse(importMapData) : null;
memoizedImportMap[packageName] = existingImportMap as ImportMap;
await importMapHandle.close();
}
} catch (err) {
delete memoizedImportMap[packageName]; // if there was trouble reading this, free up memoization
}
}

// Kick off a build, if needed.
Expand Down Expand Up @@ -624,27 +640,29 @@ export class PackageSourceLocal implements PackageSource {
const dependencyFileLoc = path.join(installDest, importMap.imports[spec]);
const loadedFile = await fs.readFile(dependencyFileLoc!);
if (isJavaScript(dependencyFileLoc)) {
const packageImports = new Set<string>();
const newPackageImports = new Set<string>();
const code = loadedFile.toString('utf8');
for (const imp of await scanCodeImportsExports(code)) {
let spec = getWebModuleSpecifierFromCode(code, imp);
if (spec === null) {
continue;
}

// remove trailing slash from end of specifier (easier for Node to resolve)
spec = spec.replace(/(\/|\\)+$/, '');

if (isRemoteUrl(spec)) {
continue;
const scannedImport = code.substring(imp.s, imp.e).replace(/(\/|\\)+$/, ''); // remove trailing slash from end of specifier (easier for Node to resolve)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor change: don’t shadow spec in parent scope; it’s confusing.

if (isRemoteUrl(scannedImport)) {
continue; // ignore remote files
}
if (isPathImport(spec)) {
if (isPathImport(scannedImport)) {
continue;
}
packageImports.add(spec);
const [scannedPackageName] = parsePackageImportSpecifier(scannedImport);
if (scannedPackageName && memoizedImportMap[scannedPackageName]) {
Copy link
Collaborator Author

@drwpow drwpow Jul 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the actual fix here: using the in-memory import maps to prevent reinstalling duplicate packages (import map means install happened successfully). Otherwise, we would end up stuck in an infinite loop installing circular npm deps.

continue; // if we’ve already installed this, then don’t reinstall
}
newPackageImports.add(scannedImport);
}

for (const packageImport of packageImports) {
for (const packageImport of newPackageImports) {
await this.buildPackageImport(packageImport, entrypoint, logLine, depth + 1);
}
}
Expand Down
36 changes: 36 additions & 0 deletions test/snowpack/package/@nivo/index.test.js
@@ -0,0 +1,36 @@
const dedent = require('dedent');
const {testFixture, testRuntimeFixture} = require('../../../fixture-utils');
const {fmtjson} = require('../../../test-utils');

const pkg = {
'index.js': dedent`
import {NetworkCanvas} from '@nivo/network';
`,
'package.json': fmtjson({
dependencies: {
'@nivo/core': '^0.72.0',
'@nivo/network': '^0.72.0',
},
}),
};

/**
* Fixes #3466
* Main thing we’re testing for here is that dev server doesn’t hang on circular deps
* Though this test is slow, it’s important to test on real npm packages and not mocked ones
* as symlink behavior is really different here
*/
describe.skip('@nivo/core', () => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the comment said, added a test here which can be run manually. But skipping this in GitHub because for some reason this test keeps flaking in GitHub CI. But I’m able to run it on my machine in Node 12, 14, and 16 without fail every time. So this does fix the issue!

// note: skipped because test can be run locally, but not in GitHub for some reason
it('dev', async () => {
const server = await testRuntimeFixture(pkg);
const js = (await server.loadUrl('/index.js')).contents.toString('utf8');
expect(js).toBeTruthy(); // if this returned some response,
await server.cleanup(); // clean up
});

it('build', async () => {
const result = await testFixture(pkg);
expect(result['index.js']).toBeTruthy();
});
});
6 changes: 6 additions & 0 deletions test/test-utils.js
Expand Up @@ -156,3 +156,9 @@ function stripLockfile(output) {
return stripWhitespace(stripUrlHash(output));
}
exports.stripLockfile = stripLockfile;

/** Format JSON */
function fmtjson(json) {
return JSON.stringify(json, undefined, 2);
}
exports.fmtjson = fmtjson;