Skip to content

Commit e9178a5

Browse files
committed
fix: yarn up now updates catalog entries instead of rewriting package.json
1 parent 888ca61 commit e9178a5

2 files changed

Lines changed: 156 additions & 11 deletions

File tree

  • packages
    • acceptance-tests/pkg-tests-specs/sources/commands
    • plugin-essentials/sources/commands

packages/acceptance-tests/pkg-tests-specs/sources/commands/up.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {Filename, ppath, xfs} from '@yarnpkg/fslib';
2+
import {yarn} from 'pkg-tests-core';
23

34
describe(`Commands`, () => {
45
describe(`up`, () => {
@@ -164,5 +165,79 @@ describe(`Commands`, () => {
164165
expect(stdout).not.toContain(`STDOUT preinstall out`);
165166
}),
166167
);
168+
169+
test(
170+
`it should update the default catalog entry instead of rewriting catalog: references in package.json`,
171+
makeTemporaryEnv(
172+
{
173+
dependencies: {
174+
[`no-deps`]: `catalog:`,
175+
},
176+
},
177+
async ({path, run, source}) => {
178+
await yarn.writeConfiguration(path, {
179+
catalog: {
180+
[`no-deps`]: `1.0.0`,
181+
},
182+
});
183+
184+
await run(`install`);
185+
await run(`up`, `no-deps@2.0.0`);
186+
187+
// package.json should still reference the catalog protocol
188+
await expect(xfs.readJsonPromise(ppath.join(path, Filename.manifest))).resolves.toMatchObject({
189+
dependencies: {
190+
[`no-deps`]: `catalog:`,
191+
},
192+
});
193+
194+
// .yarnrc.yml should have the updated version
195+
await expect(yarn.readConfiguration(path)).resolves.toMatchObject({
196+
catalog: {
197+
[`no-deps`]: `2.0.0`,
198+
},
199+
});
200+
},
201+
),
202+
);
203+
204+
test(
205+
`it should update a named catalog entry instead of rewriting catalog:<name> references in package.json`,
206+
makeTemporaryEnv(
207+
{
208+
dependencies: {
209+
[`no-deps`]: `catalog:react18`,
210+
},
211+
},
212+
async ({path, run, source}) => {
213+
await yarn.writeConfiguration(path, {
214+
catalogs: {
215+
react18: {
216+
[`no-deps`]: `1.0.0`,
217+
},
218+
},
219+
});
220+
221+
await run(`install`);
222+
await run(`up`, `no-deps@2.0.0`);
223+
224+
// package.json should still reference the named catalog protocol
225+
await expect(xfs.readJsonPromise(ppath.join(path, Filename.manifest))).resolves.toMatchObject({
226+
dependencies: {
227+
[`no-deps`]: `catalog:react18`,
228+
},
229+
});
230+
231+
// .yarnrc.yml should have the updated version in the named catalog
232+
await expect(yarn.readConfiguration(path)).resolves.toMatchObject({
233+
catalogs: {
234+
react18: {
235+
[`no-deps`]: `2.0.0`,
236+
},
237+
},
238+
});
239+
},
240+
),
241+
);
167242
});
168243
});

packages/plugin-essentials/sources/commands/up.ts

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,10 @@ export default class UpCommand extends BaseCommand {
276276
Descriptor,
277277
]> = [];
278278

279+
// Catalog entries that need to be updated in .yarnrc.yml, keyed by
280+
// `${catalogName ?? ''}\0${entryName}` to deduplicate across workspaces.
281+
const catalogUpdates = new Map<string, {catalogName: string | null, entryName: string, newRange: string}>();
282+
279283
for (const [workspace, target, /*existing*/, {suggestions}] of allSuggestions) {
280284
let selected: Descriptor;
281285

@@ -319,17 +323,25 @@ export default class UpCommand extends BaseCommand {
319323
throw new Error(`Assertion failed: This descriptor should have a matching entry`);
320324

321325
if (current.descriptorHash !== selected.descriptorHash) {
322-
workspace.manifest[target].set(
323-
selected.identHash,
324-
selected,
325-
);
326-
327-
afterWorkspaceDependencyReplacementList.push([
328-
workspace,
329-
target,
330-
current,
331-
selected,
332-
]);
326+
if (current.range.startsWith(`catalog:`)) {
327+
// When the dependency uses the catalog: protocol, update the catalog entry
328+
// in .yarnrc.yml rather than rewriting package.json with the resolved version.
329+
const catalogName = current.range.slice(`catalog:`.length) || null;
330+
const entryName = structUtils.stringifyIdent(current);
331+
catalogUpdates.set(`${catalogName ?? ``}\0${entryName}`, {catalogName, entryName, newRange: selected.range});
332+
} else {
333+
workspace.manifest[target].set(
334+
selected.identHash,
335+
selected,
336+
);
337+
338+
afterWorkspaceDependencyReplacementList.push([
339+
workspace,
340+
target,
341+
current,
342+
selected,
343+
]);
344+
}
333345
} else {
334346
const resolver = configuration.makeResolver();
335347
const resolveOptions: MinimalResolveOptions = {project, resolver};
@@ -341,6 +353,64 @@ export default class UpCommand extends BaseCommand {
341353
}
342354
}
343355

356+
// If there are any catalog entries to update, do them all at once in a single rc update to avoid
357+
// multiple filesystem writes, and to ensure that the in-memory configuration is updated only once
358+
if (catalogUpdates.size > 0) {
359+
type RcContent = {
360+
[key: string]: unknown,
361+
catalog?: Record<string, string>,
362+
catalogs?: Record<string, Record<string, string>>,
363+
}
364+
// `Configuration.updateConfiguration()` round-trips `.yarnrc.yml` through Yarn's own
365+
// `parseSyml` / `stringifySyml` serializer, which has two trade-offs:
366+
// - Comments are stripped: any `#` comments in `.yarnrc.yml` are lost on the first
367+
// `yarn up` that touches a catalog entry.
368+
// - Keys are reordered: `stringifySyml` sorts keys according to a fixed priority
369+
// list, so the order of entries in `catalog:` and `catalogs:` may change.
370+
await Configuration.updateConfiguration(project.cwd, (rcContent: RcContent) => {
371+
return Array.from(catalogUpdates.values()).reduce((updated, {catalogName, entryName, newRange}) => {
372+
// If catalogName is null, it means that the catalog entry is under the
373+
// top-level default catalog entry, so we should update the `catalog` field
374+
if (catalogName === null) {
375+
const existingCatalog = updated.catalog ?? {};
376+
return {
377+
...updated,
378+
catalog: {
379+
...existingCatalog,
380+
[entryName]: newRange
381+
}
382+
};
383+
}
384+
385+
// Otherwise, the catalog entry is under a named catalog, so we should update
386+
// that specific entry under the `catalogs` object
387+
const existingCatalogs = updated.catalogs ?? {};
388+
return {
389+
...updated,
390+
catalogs: {
391+
...existingCatalogs,
392+
[catalogName]: {
393+
...(existingCatalogs[catalogName] ?? {}),
394+
[entryName]: newRange,
395+
},
396+
},
397+
};
398+
}, rcContent);
399+
});
400+
401+
402+
// Update in-memory configuration so the subsequent install resolves the new ranges
403+
for (const {catalogName, entryName, newRange} of catalogUpdates.values()) {
404+
if (catalogName === null) {
405+
const catalog = configuration.values.get(`catalog`);
406+
catalog?.set(entryName, newRange);
407+
} else {
408+
const catalogs = configuration.values.get(`catalogs`);
409+
catalogs?.get(catalogName)?.set(entryName, newRange);
410+
}
411+
}
412+
}
413+
344414
await configuration.triggerMultipleHooks(
345415
(hooks: Hooks) => hooks.afterWorkspaceDependencyReplacement,
346416
afterWorkspaceDependencyReplacementList,

0 commit comments

Comments
 (0)