transloco icon indicating copy to clipboard operation
transloco copied to clipboard

Bug(transloco): translateSignal prepends DI scope to key when none is provided

Open enriquelaffranconi opened this issue 6 months ago β€’ 9 comments

Is there an existing issue for this?

  • [x] I have searched the existing issues

Which Transloco package(s) are the source of the bug?

Transloco

Is this a regression?

No

Current behavior

When using translateSignal('some.key') or translateObjectSignal('some.key') without explicitly providing a scope, Transloco automatically fetches the scope from the dependency injection context (inject(TRANSLOCO_SCOPE)). If a scope (e.g., validation) is present in the DI context, it prepends this scope to the translation key, resulting in lookups like validation.some.key instead of just some.key. This is unexpected and can cause incorrect translations to be used, especially when the developer intends to use a root-level key.

Expected behavior

If no scope is provided to translateSignal or translateObjectSignal, Transloco should use the translation key as-is, without inferring or prepending any scope from the DI context. The behavior should be consistent with selectTranslate, which does not infer a scope unless one is explicitly provided.

Please provide a link to a minimal reproduction of the bug, if you won't provide a link the issue won't be handled.

https://codesandbox.io/p/devbox/quiet-snowflake-4zn2tf?workspaceId=ws_MMfF28NiXcF9q6oq2kP3fe

Transloco Config


Please provide the environment you discovered this bug in

Angular CLI: 18.2.11
Node: 22.14.0
Package Manager: yarn 4.9.1
OS: darwin x64

Angular: 18.2.9

Browser


Additional context

Please check the lazy component in Sandbox.

I would like to make a pull request for this bug

Yes πŸš€

enriquelaffranconi avatar Jun 13 '25 06:06 enriquelaffranconi

I'm also having this issue. Translations work fine with RxJS but the signal methods don't return values at all.

lztetreault-dev avatar Jul 04 '25 18:07 lztetreault-dev

The problem is that as a developer you would expect translateSignal to mirror translate/selectTranslate. However if you look closely you will see that the former uses TRANSLOCO_SCOPE for its scope parameter's default value. https://github.com/jsverse/transloco/blob/4fc2651ede154fae2e2a5a1e719a14b9eceb6582/libs/transloco/src/lib/transloco.signal.ts#L154-L157 Meanwhile the latter use getActiveLang for their scope parameter's default value. https://github.com/jsverse/transloco/blob/4fc2651ede154fae2e2a5a1e719a14b9eceb6582/libs/transloco/src/lib/transloco.service.ts#L282-L286 https://github.com/jsverse/transloco/blob/4fc2651ede154fae2e2a5a1e719a14b9eceb6582/libs/transloco/src/lib/transloco.service.ts#L358-L360 selectTranslate is more difficult to follow but langChanges$ does reflect the active lang. Ideally translateSignal should be brought in line with the existing APIs.

austinw-fineart avatar Jul 25 '25 08:07 austinw-fineart

Mayby move current imlementation to new scopedTranslate signal and mirror algorithm from selectTranslate to translateSignal. Bdw, it's not recomended to postfix with signal.

kbrilla avatar Aug 19 '25 09:08 kbrilla

From my investigation, to have consistent behavior between template translations (Directive and Pipes) and service translations (selectTranslate or translateSignal), it is due to logic that prefixes the scope to the key here:

https://github.com/jsverse/transloco/blob/4fc2651ede154fae2e2a5a1e719a14b9eceb6582/libs/transloco/src/lib/transloco.service.ts#L297

This causes what is explained in the documentation about how the scope works to not be usable when using any function that relies on this, which is very unfortunate because it is an incredible inheritance logic with the scopes.

https://github.com/jsverse/transloco/blob/4fc2651ede154fae2e2a5a1e719a14b9eceb6582/libs/transloco/src/lib/transloco.service.ts#L271-L282

For me, it would be better to have the same logic as the template translations to ensure consistency and fix the regression regarding scope inheritance or at least propose an option to have it.

FU856BMO avatar Sep 11 '25 13:09 FU856BMO

Upon re-examining the merged solution, I don't think the approach is actually to resolve the root cause, which is the inconsistency between the template, signal, and service translations.

Would love to hear your thoughts about this. None should prefix right? I think this was done to mimic the template translation's auto scope loading behavior

shaharkazaz avatar Nov 15 '25 18:11 shaharkazaz

I'd like to contribute some thoughts on the scope inference inconsistency issue and hear the community's feedback on potential solutions.

I'm not entirely sure if this is the best place to present this proposalβ€”perhaps opening an RFC (similar to Angular's RFC process) would be more appropriate for a change of this nature. Regardless of which path we take, I'm happy to implement a pull request for whichever solution the team decides to pursue.


Current Behavior - The Problem

There's a critical inconsistency in how scope resolution works across Transloco APIs:

πŸ”΄ Service methods DON'T auto-infer scope from DI

@Component({
  selector: 'app-my-component',
  template: `<div>{{ text }}</div>`,
  providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'validation' }]
})
export class MyComponent {
  private transloco = inject(TranslocoService);
  
  // ❌ Service methods ignore DI scope - uses key as-is
  text = this.transloco.translate('required');  // β†’ 'required' (root key, not 'validation.required')
  
  getText() {
    // Observable version also ignores DI scope
    return this.transloco.selectTranslate('required');  // β†’ Observable<'required'>
  }
  
  getTextWithScope() {
    // Must explicitly provide scope:
    return this.transloco.translate('required', {}, 'validation'); // β†’ 'validation.required'
  }
}

βœ… Directive, Pipe, and Signal correctly auto-infer scope from DI

@Component({
  selector: 'app-my-component',
  template: `
    <div transloco="required"></div>
    <div>{{ 'required' | transloco }}</div>
  `,
  providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'validation' }]
})
export class MyComponent {
  // βœ… These correctly auto-infer 'validation' scope from DI
  text1 = translateSignal('required');           // β†’ 'validation.required'
  text2 = translateObjectSignal('errors');       // β†’ 'validation.errors'
}
<!-- βœ… These also correctly auto-infer 'validation' scope -->
<div transloco="required"></div>              <!-- β†’ 'validation.required' -->
<div>{{ 'required' | transloco }}</div>       <!-- β†’ 'validation.required' -->

🚨 The Inconsistency Problem

Method Auto-infers from DI? Current Behavior
service.translate() ❌ No Uses key as-is (inconsistent)
service.selectTranslate() ❌ No Uses key as-is (inconsistent)
translateSignal() βœ… Yes Auto-prefixes with DI scope
translateObjectSignal() βœ… Yes Auto-prefixes with DI scope
transloco directive βœ… Yes Auto-prefixes with DI scope
transloco pipe βœ… Yes Auto-prefixes with DI scope

Real-world confusion:

@Component({
  selector: 'app-my-component',
  template: `<div>{{ displayText() }}</div>`,
  providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'validation' }]
})
export class MyComponent {
  private transloco = inject(TranslocoService);
  
  // Same key, different results - WHY?
  text1 = this.transloco.translate('required');        // β†’ 'required' ❌
  text2 = translateSignal('required');                 // β†’ 'validation.required' βœ…
  text3 = this.transloco.selectTranslate('required');  // β†’ Observable<'required'> ❌
  
  displayText = this.text2;
}
@Component({
  selector: 'app-my-component',
  template: `
    <!-- Template correctly uses scope, but service doesn't! -->
    <div transloco="required"></div>  <!-- β†’ 'validation.required' βœ… -->

    <button (click)="doSomething()">
      {{ transloco.translate('required') }}  <!-- β†’ 'required' ❌ Wrong! -->
    </button>
  `
})
export class MyComponent {
  transloco = inject(TranslocoService);
  
  doSomething() {
    console.log(this.transloco.translate('required')); // β†’ 'required' ❌
  }
}

When accessing root keys from a scoped component:

@Component({
  selector: 'app-admin',
  template: `
    <!-- Accessing root keys in template is confusing -->
    <h1 transloco="app.title"></h1>                    <!-- ❌ β†’ 'admin.app.title' (doesn't exist) -->
    <h1 transloco="app.title" translocoScope=""></h1>  <!-- βœ… β†’ 'app.title' (unintuitive) -->
  `,
  providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'admin' }]
})
export class AdminComponent {
  private transloco = inject(TranslocoService);
  
  // Accessing root translation keys:
  
  // Service works without explicit scope
  appTitle1 = this.transloco.translate('app.title');  // βœ… Works β†’ 'app.title'
  
  // Signals/directives/pipes require workaround with explicit empty scope
  appTitle2 = translateSignal('app.title');           // ❌ Fails β†’ 'admin.app.title' (doesn't exist)
  appTitle3 = translateSignal('app.title', {}, '');   // βœ… Works but unintuitive
}

Proposed Solution: New inferScopeFromDI Config

Add a new configuration option to make service methods consistent with directives, pipes, and signals:

export interface TranslocoConfig {
  // ... existing config
  scopes?: {
    autoPrefixKeys?: boolean;  // existing - controls service prefixing
    inferScopeFromDI?: boolean; // NEW - controls DI scope inference (default: true)
  };
}

Why Default to true?

  1. Consistency: Makes service methods behave like directives/pipes/signals
  2. Least Surprise: Most users expect scoped components to use their scope
  3. Backward Compatible: Doesn't break existing directive/pipe/signal usage
  4. Intuitive: TRANSLOCO_SCOPE provider should affect all translation methods

Resulting Behavior

With inferScopeFromDI: true (Recommended Default) βœ…

Consistent behavior across ALL APIs - service now matches directives/pipes/signals:

// transloco.config.ts
export const translocoConfig = {
  scopes: {
    inferScopeFromDI: true // Default - all methods respect DI scope
  }
};
@Component({
  selector: 'app-my-component',
  template: `
    <div>{{ text1 }}</div>
    <div>{{ text2() }}</div>
  `,
  providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'validation' }]
})
export class MyComponent {
  private transloco = inject(TranslocoService);
  
  // βœ… ALL methods now consistently use DI scope
  text1 = this.transloco.translate('required');       // β†’ 'validation.required' βœ…
  text2 = translateSignal('required');                // β†’ 'validation.required' βœ…
  text3 = this.transloco.selectTranslate('required'); // β†’ Observable<'validation.required'> βœ…
}
<!-- βœ… All use DI scope consistently -->
<div transloco="required"></div>              <!-- β†’ 'validation.required' -->
<div>{{ 'required' | transloco }}</div>       <!-- β†’ 'validation.required' -->

When accessing root keys, use empty scope explicitly:

@Component({
  selector: 'app-admin',
  template: `
    <!-- Access root keys with empty scope -->
    <h1 transloco="app.title" translocoScope=""></h1>  <!-- β†’ 'app.title' -->
  `,
  providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'admin' }]
})
export class AdminComponent {
  private transloco = inject(TranslocoService);
  
  // Access root keys by passing empty scope
  appTitle1 = this.transloco.translate('app.title', {}, '');  // β†’ 'app.title'
  appTitle2 = translateSignal('app.title', {}, '');           // β†’ 'app.title'
}

With inferScopeFromDI: false (Explicit Opt-Out)

All methods ignore DI scope - must be explicit everywhere:

export const translocoConfig = {
  scopes: {
    inferScopeFromDI: false // Opt-out: explicit scope required everywhere
  }
};
@Component({
  selector: 'app-my-component',
  template: `<div>{{ text1 }}</div>`,
  providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'validation' }]
})
export class MyComponent {
  private transloco = inject(TranslocoService);
  
  // All use keys as-is - DI scope is ignored
  text1 = this.transloco.translate('required');      // β†’ 'required'
  text2 = translateSignal('required');               // β†’ 'required'
  text3 = this.transloco.selectTranslate('required'); // β†’ Observable<'required'>
  
  // Must explicitly provide scope when needed
  text4 = this.transloco.translate('required', {}, 'validation');  // β†’ 'validation.required'
  text5 = translateSignal('required', {}, 'validation');           // β†’ 'validation.required'
}
<!-- Must explicitly provide scope -->
<div transloco="required"></div>                              <!-- β†’ 'required' -->
<div transloco="required" translocoScope="validation"></div>  <!-- β†’ 'validation.required' -->
<div>{{ 'required' | transloco:{}:'validation' }}</div>       <!-- β†’ 'validation.required' -->

Benefits

βœ… Consistency: Service methods now behave the same as directives/pipes/signals
βœ… Predictability: All methods respect TRANSLOCO_SCOPE provider by default
βœ… Intuitive: Providing a scope actually works everywhere (principle of least surprise)
βœ… Backward Compatible: Directives/pipes/signals continue working as before
βœ… Flexible: Can opt-out with inferScopeFromDI: false when needed
βœ… Better DX: No more confusion about why service methods ignore scope providers
βœ… Cleaner Code: Less need for explicit scope parameters in every call (DRY principle)



Questions and Alternative Approach

πŸ€” Do autoPrefixKeys and inferScopeFromDI solve the same problem?

Both configs control whether scopes should be automatically applied to translation keys:

  • autoPrefixKeys: Controls if the service should prefix keys with scope when scope is explicitly provided
  • inferScopeFromDI: Controls if methods should infer scope from DI context when not explicitly provided

Question: Are these really two separate concerns, or just different aspects of the same problem?

Perhaps a single, clearer config would be better:

scopes?: {
  autoResolve?: 'never' | 'explicit' | 'infer'; // Default: 'infer'
}

Where:

  • 'never': Never auto-prefix, always use keys as-is
  • 'explicit': Only prefix when scope is explicitly passed as parameter
  • 'infer': Prefix with scope from DI context (current directive/pipe/signal behavior)

πŸ’‘ Better Solution: Fallback to Root Keys

The ideal behavior when TRANSLOCO_SCOPE is provided should be:

  1. Try scoped key first: validation.required
  2. If not found, fallback to root key: required

This would solve the root key access problem naturally:

@Component({
  selector: 'app-admin',
  template: `
    <!-- No need for empty scope workaround -->
    <h1 transloco="app.title"></h1>  <!-- β†’ Tries 'admin.app.title', falls back to 'app.title' βœ… -->
    <div transloco="required"></div>  <!-- β†’ Finds 'admin.required' directly βœ… -->
  `,
  providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'admin' }]
})
export class AdminComponent {
  private transloco = inject(TranslocoService);
  
  // Natural behavior - no workarounds needed
  appTitle = this.transloco.translate('app.title');   // β†’ Tries 'admin.app.title', falls back to 'app.title' βœ…
  required = this.transloco.translate('required');    // β†’ Finds 'admin.required' βœ…
}

Benefits of fallback approach:

βœ… No workarounds needed: Access root keys naturally from scoped components
βœ… Scope isolation: Scoped translations take precedence when they exist
βœ… Graceful degradation: Falls back to root when scoped translation doesn't exist
βœ… Simpler mental model: "Try scoped, then global" is intuitive
βœ… No config complexity: Single behavior that works for all cases
βœ… DRY: Reuse common translations without duplicating in every scope

Implementation suggestion:

// Translation resolution order with TRANSLOCO_SCOPE='admin'
translate('title') {
  1. Look for 'admin.title'     // βœ… Found? Return it
  2. Look for 'title'            // βœ… Found? Return it
  3. Return missing key handler  // ❌ Not found in either
}

This approach eliminates the need for both autoPrefixKeys and inferScopeFromDI configs while providing the most intuitive behavior.


Summary

The proposed inferScopeFromDI config solves the inconsistency by:

  1. Default true: Makes service methods consistent with directives/pipes/signals (KISS)
  2. Fixes the bug: Service methods now respect TRANSLOCO_SCOPE provider
  3. Backward compatible: Existing directive/pipe/signal code continues working unchanged
  4. Optional opt-out: Set false for explicit scope control when needed (DRY)
  5. Clear mental model: TRANSLOCO_SCOPE provider works everywhere or nowhere

The Core Problem Being Solved

Before: Why does TRANSLOCO_SCOPE work in templates but not in service calls?
After: TRANSLOCO_SCOPE consistently affects all translation methods

However, the fallback approach may be even better: automatically trying root keys when scoped keys don't exist would eliminate the need for workarounds entirely while maintaining scope isolation where it matters.

This aligns Transloco's behavior with the principle of least surprise: when you provide a scope, all translation methods within that scope use it by default.

Rodrigo54 avatar Nov 18 '25 21:11 Rodrigo54

I'm of the mindset that auto scope isn't intelligent enough to infer which scope I actually mean, especially for keys that exist simultaneously between multiple scopes and components that use multiple scopes, so I just end up explicitly defining the scope anyway. Therefore I would vote to have all of the auto scoping disabled by default across the APIs.

austinw-fineart avatar Nov 19 '25 02:11 austinw-fineart

Personally, I think automatically inferring the scope is a big usability plus when using the signal, directive or pipe API. I do see you are recommending the fallback option to use the non-scoped key, but this could end up being a breaking change for some users as well that rely on a translation not being found in their scoped translation context.

Secondly, I don't believe this functionality is actually possible in Angular because the TranslocoService is injected at the root of the application and does not have access to TRANSLOCO_SCOPE instances provided at lower levels of the DI tree.

  text1 = this.transloco.translate('required');       // β†’ 'required' 
  text2 = translateSignal('required');                // β†’ 'validation.required'
  text3 = this.transloco.selectTranslate('required'); // β†’ Observable<'required'> 

Using the TranslocoService directly is more of an advanced use case in my opinion. If you want to always get automatic DI scoping, users should only use the signal, directive, or pipe APIs.

maleetz avatar Dec 01 '25 14:12 maleetz

Thank you guys for sharing. I think I'll turn this into an actual RFC about this topic to get the community's thoughts on this before making a decision

shaharkazaz avatar Dec 02 '25 06:12 shaharkazaz