Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/all-candies-reply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"lingo.dev": patch
---

fix racing condition where concurrent processing could use data from the wrong locale
26 changes: 20 additions & 6 deletions packages/cli/src/cli/loaders/_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ export function createLoader<I, O, C>(
const state = {
defaultLocale: undefined as string | undefined,
originalInput: undefined as I | undefined | null,
pullInput: undefined as I | undefined | null,
pullOutput: undefined as O | undefined | null,
// Store pullInput and pullOutput per-locale to avoid race conditions
// when multiple locales are processed concurrently
pullInputByLocale: new Map<string, I | null>(),
pullOutputByLocale: new Map<string, O | null>(),
initCtx: undefined as C | undefined,
};
return {
Expand Down Expand Up @@ -81,15 +83,15 @@ export function createLoader<I, O, C>(
state.originalInput = input || null;
}

state.pullInput = input;
state.pullInputByLocale.set(locale, input || null);
const result = await lDefinition.pull(
locale,
input,
state.initCtx!,
state.defaultLocale,
state.originalInput!,
);
state.pullOutput = result;
state.pullOutputByLocale.set(locale, result);

return result;
},
Expand All @@ -101,13 +103,25 @@ export function createLoader<I, O, C>(
throw new Error("Cannot push data without pulling first");
}

// Use locale-specific pullInput/pullOutput if available,
// otherwise fall back to the default locale's values for backward compatibility
// (some loaders push for locales that were never explicitly pulled)
const pullInput =
state.pullInputByLocale.get(locale) ??
state.pullInputByLocale.get(state.defaultLocale) ??
null;
const pullOutput =
state.pullOutputByLocale.get(locale) ??
state.pullOutputByLocale.get(state.defaultLocale) ??
null;

const pushResult = await lDefinition.push(
locale,
data,
state.originalInput,
state.defaultLocale,
state.pullInput!,
state.pullOutput!,
pullInput!,
pullOutput!,
);
return pushResult;
},
Expand Down
58 changes: 58 additions & 0 deletions packages/cli/src/cli/loaders/po/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,64 @@ msgstr ""`;
expect(result).not.toContain('"Language: en\\n"');
expect(result).toContain('msgstr "Hola mundo"');
});

it("should preserve Language header for each locale when multiple target locales are pulled before push", async () => {
// This test verifies the fix for a bug where pulling multiple target locales
// before pushing would cause the Language header to be overwritten with the
// wrong locale's value (e.g., es.po would get "Language: en" instead of "Language: es")
const loader = createLoader();

const sourceInput = `msgid ""
msgstr ""
"Language: en\\n"
"Content-Type: text/plain; charset=utf-8\\n"

#: hello.py:1
msgid "Hello"
msgstr "Hello"`;

const spanishInput = `msgid ""
msgstr ""
"Language: es\\n"
"Content-Type: text/plain; charset=utf-8\\n"

#: hello.py:1
msgid "Hello"
msgstr ""`;

const portugueseInput = `msgid ""
msgstr ""
"Language: pt\\n"
"Content-Type: text/plain; charset=utf-8\\n"

#: hello.py:1
msgid "Hello"
msgstr ""`;

await loader.pull("en", sourceInput);

// Pull multiple target locales (simulates concurrent processing)
await loader.pull("es", spanishInput);
await loader.pull("pt", portugueseInput);

const spanishResult = await loader.push("es", {
Hello: { singular: "Hola", plural: null },
});

const portugueseResult = await loader.push("pt", {
Hello: { singular: "Olá", plural: null },
});

expect(spanishResult).toContain('"Language: es\\n"');
expect(spanishResult).not.toContain('"Language: pt\\n"');
expect(spanishResult).not.toContain('"Language: en\\n"');
expect(spanishResult).toContain('msgstr "Hola"');

expect(portugueseResult).toContain('"Language: pt\\n"');
expect(portugueseResult).not.toContain('"Language: es\\n"');
expect(portugueseResult).not.toContain('"Language: en\\n"');
expect(portugueseResult).toContain('msgstr "Olá"');
});
});

function createLoader(params: PoLoaderParams = { multiline: false }) {
Expand Down
Loading