Skip to content

Commit 71aec45

Browse files
committed
feat: add setCompiledTranslation bypass method (#1605)
Adds TranslateService.setCompiledTranslation(lang, translations, shouldMerge?) which stores already-compiled translations directly via the store, bypassing the configured TranslateCompiler. Use this when translations are already in their final, interpolator-ready form (e.g. interpolation functions produced at build time). For raw translations that still need compilation, continue using setTranslation. Fixes a v17 regression where passing function-valued objects to setTranslation no longer typechecked. The v17 raw-vs-compiled type split (TranslationObject vs InterpolatableTranslationObject) is preserved — this is the missing public entry point for the compiled side, not a change to the type hierarchy. Zero breaking changes. No existing signatures, type definitions, or internal behavior modified. setTranslation is unchanged and still runs the compiler; the new method delegates directly to TranslateStore.setTranslations. Bidirectional JSDoc cross-references link the two methods so users hitting the #1605 type error in setTranslation are pointed at the correct alternative. Covered by nine new specs: - Seven behavior tests: function leaves, params interpolation, mixed string and function leaves, nested dotted-key lookup, shouldMerge=true additive, shouldMerge=false replacing, and onTranslationChange event payload. - Two compiler-bypass spy tests using a local SpyCompiler: a negative assertion proves setCompiledTranslation does NOT invoke compileTranslations, and a positive control proves setTranslation DOES invoke it exactly once on the same compiler instance. Closes #1605
1 parent 96ac044 commit 71aec45

3 files changed

Lines changed: 177 additions & 2 deletions

File tree

projects/ngx-translate/src/lib/translate.service.interface.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ export abstract class ITranslateService {
113113
shouldMerge?: boolean,
114114
): void;
115115

116+
public abstract setCompiledTranslation(
117+
lang: Language,
118+
translations: InterpolatableTranslationObject,
119+
shouldMerge?: boolean,
120+
): void;
121+
116122
public abstract getParsedResult(
117123
key: string | string[],
118124
interpolateParams?: InterpolationParameters,

projects/ngx-translate/src/lib/translate.service.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,8 +347,13 @@ export class TranslateService implements ITranslateService {
347347
}
348348

349349
/**
350-
* Manually sets an object of translations for a given language
351-
* after passing it through the compiler
350+
* Manually sets an object of translations for a given language,
351+
* passing it through the configured {@link TranslateCompiler} first.
352+
*
353+
* If you already have translations in their final compiled form
354+
* (e.g. interpolation functions produced at build time), use
355+
* {@link setCompiledTranslation} instead — it stores the data
356+
* directly and skips the compiler.
352357
*/
353358
public setTranslation(
354359
lang: Language,
@@ -360,6 +365,23 @@ export class TranslateService implements ITranslateService {
360365
this.store.setTranslations(lang, interpolatableTranslations, shouldMerge);
361366
}
362367

368+
/**
369+
* Stores an already-compiled translation object for the given language,
370+
* bypassing the configured {@link TranslateCompiler}.
371+
*
372+
* Use this when you have translations in their final, interpolator-ready
373+
* form — e.g. interpolation functions produced at build time. For raw
374+
* translations that still need to go through the compiler, use
375+
* {@link setTranslation} instead.
376+
*/
377+
public setCompiledTranslation(
378+
lang: Language,
379+
translations: InterpolatableTranslationObject,
380+
shouldMerge = false,
381+
): void {
382+
this.store.setTranslations(lang, translations, shouldMerge);
383+
}
384+
363385
public getLangs(): readonly Language[] {
364386
return this.store.getLanguages();
365387
}

projects/ngx-translate/src/tests/translate.service.spec.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ import { fakeAsync, TestBed, tick } from "@angular/core/testing";
33
import { defer, EMPTY, Observable, of, throwError, timer, zip } from "rxjs";
44
import { first, map, take, toArray } from "rxjs/operators";
55
import {
6+
InterpolatableTranslationObject,
67
InterpolationParameters,
78
LangChangeEvent,
89
provideChildTranslateService,
10+
provideTranslateCompiler,
911
provideTranslateLoader,
1012
provideTranslateService,
13+
TranslateCompiler,
1114
TranslateLoader,
1215
TranslatePipe,
1316
TranslateService,
@@ -722,6 +725,150 @@ describe("TranslateService", () => {
722725
});
723726
});
724727

728+
describe("setCompiledTranslation()", () => {
729+
it("stores a function-valued translation and resolves it via instant()", () => {
730+
translate.setCompiledTranslation("en", {
731+
GREETING: () => "hello",
732+
});
733+
translate.use("en");
734+
735+
expect(translate.instant("GREETING")).toBe("hello");
736+
});
737+
738+
it("passes interpolation params to function leaves", () => {
739+
translate.setCompiledTranslation("en", {
740+
GREETING: (params) => `hello ${params?.["name"] ?? ""}`.trim(),
741+
});
742+
translate.use("en");
743+
744+
expect(translate.instant("GREETING", { name: "Andreas" })).toBe("hello Andreas");
745+
});
746+
747+
it("supports mixed string and function leaves in the same object", () => {
748+
translate.setCompiledTranslation("en", {
749+
STATIC: "plain text",
750+
DYNAMIC: (params) => `value: ${params?.["v"]}`,
751+
});
752+
translate.use("en");
753+
754+
expect(translate.instant("STATIC")).toBe("plain text");
755+
expect(translate.instant("DYNAMIC", { v: 42 })).toBe("value: 42");
756+
});
757+
758+
it("supports nested objects with function leaves reached by dotted keys", () => {
759+
translate.setCompiledTranslation("en", {
760+
user: {
761+
profile: {
762+
greeting: (params) => `hi ${params?.["name"]}`,
763+
},
764+
},
765+
});
766+
translate.use("en");
767+
768+
expect(translate.instant("user.profile.greeting", { name: "Rook" })).toBe("hi Rook");
769+
});
770+
771+
it("merges into existing translations when shouldMerge is true", () => {
772+
translate.setTranslation("en", { KEEP: "kept string" });
773+
translate.setCompiledTranslation("en", { ADDED: () => "added via compiled" }, true);
774+
translate.use("en");
775+
776+
expect(translate.instant("KEEP")).toBe("kept string");
777+
expect(translate.instant("ADDED")).toBe("added via compiled");
778+
});
779+
780+
it("replaces existing translations when shouldMerge is false (default)", () => {
781+
translate.setTranslation("en", { GONE: "will be replaced" });
782+
translate.setCompiledTranslation("en", {
783+
REPLACED: () => "new content",
784+
});
785+
translate.use("en");
786+
787+
expect(translate.instant("REPLACED")).toBe("new content");
788+
// The original key is gone after replacement
789+
expect(translate.instant("GONE")).toBe("GONE");
790+
});
791+
792+
it("emits onTranslationChange when storing compiled translations", () => {
793+
translate.use("en");
794+
const events: TranslationChangeEvent[] = [];
795+
const sub = translate.onTranslationChange.subscribe((event) => {
796+
events.push(event);
797+
});
798+
799+
translate.setCompiledTranslation("en", {
800+
GREETING: () => "hi",
801+
});
802+
803+
sub.unsubscribe();
804+
expect(events.length).toBe(1);
805+
expect(events[0].lang).toBe("en");
806+
expect(typeof (events[0].translations as Record<string, unknown>)["GREETING"]).toBe(
807+
"function",
808+
);
809+
});
810+
});
811+
812+
describe("setCompiledTranslation() compiler bypass", () => {
813+
let spyTranslate: TestableTranslateService;
814+
let compileTranslationsSpy: jasmine.Spy;
815+
816+
class SpyCompiler extends TranslateCompiler {
817+
compile(value: string, lang: string): string {
818+
void lang;
819+
return value;
820+
}
821+
compileTranslations(
822+
translations: InterpolatableTranslationObject,
823+
lang: string,
824+
): InterpolatableTranslationObject {
825+
void lang;
826+
return translations;
827+
}
828+
}
829+
830+
beforeEach(() => {
831+
TestBed.resetTestingModule();
832+
TestBed.configureTestingModule({
833+
providers: [
834+
provideTestableTranslateService({
835+
loader: provideTranslateLoader(FakeLoader),
836+
compiler: provideTranslateCompiler(SpyCompiler),
837+
}),
838+
],
839+
});
840+
spyTranslate = TestBed.inject(TranslateService) as TestableTranslateService;
841+
compileTranslationsSpy = spyOn(
842+
spyTranslate.getCompiler() as SpyCompiler,
843+
"compileTranslations",
844+
).and.callThrough();
845+
});
846+
847+
it("does NOT invoke the compiler when storing compiled translations", () => {
848+
spyTranslate.setCompiledTranslation("en", {
849+
GREETING: () => "hi",
850+
});
851+
// use() short-circuits via store.hasTranslationFor, so it does NOT
852+
// trigger the loader/compiler path here — we're only exercising
853+
// setCompiledTranslation / setTranslation.
854+
spyTranslate.use("en");
855+
856+
expect(compileTranslationsSpy).not.toHaveBeenCalled();
857+
expect(spyTranslate.instant("GREETING")).toBe("hi");
858+
});
859+
860+
it("DOES invoke the compiler when setTranslation is called, as a control", () => {
861+
spyTranslate.setTranslation("en", { GREETING: "hi" });
862+
// use() short-circuits via store.hasTranslationFor, so it does NOT
863+
// trigger the loader/compiler path here — we're only exercising
864+
// setCompiledTranslation / setTranslation.
865+
spyTranslate.use("en");
866+
867+
expect(compileTranslationsSpy).toHaveBeenCalledTimes(1);
868+
expect(spyTranslate.instant("GREETING")).toBe("hi");
869+
});
870+
});
871+
725872
it("should trigger an event when the lang changes", () => {
726873
const tr: TranslationObject = { TEST: "This is a test" };
727874
translate.setTranslation("en", tr);

0 commit comments

Comments
 (0)