fix(core): handle empty translations correctly (#36499)

In certain use-cases it's useful to have an ability to use empty strings as translations. Currently Ivy fails at runtime if empty string is used as a translation, since some parts of internal data structures are not created properly. This commit updates runtime i18n logic to handle empty translations and avoid unnecessary extra processing for such cases.

Fixes #36476.

PR Close #36499
This commit is contained in:
Andrew Kushnir 2020-04-07 17:59:36 -07:00 committed by atscott
parent 03ff380e13
commit b1f1d3ffd2
2 changed files with 122 additions and 70 deletions

View File

@ -243,7 +243,7 @@ function removeInnerTemplateTranslation(message: string): string {
* external template and removes all sub-templates.
*/
export function getTranslationForTemplate(message: string, subTemplateIndex?: number) {
if (typeof subTemplateIndex !== 'number') {
if (isRootTemplateMessage(subTemplateIndex)) {
// We want the root template message, ignore all sub-templates
return removeInnerTemplateTranslation(message);
} else {
@ -376,6 +376,10 @@ export function ɵɵi18nStart(index: number, message: string, subTemplateIndex?:
// This is reset to 0 when `i18nStartFirstPass` is called.
let i18nVarsCount: number;
function allocNodeIndex(startIndex: number): number {
return startIndex + i18nVarsCount++;
}
/**
* See `i18nStart` above.
*/
@ -409,6 +413,13 @@ function i18nStartFirstPass(
const updateOpCodes: I18nUpdateOpCodes = [];
const icuExpressions: TIcu[] = [];
if (message === '' && isRootTemplateMessage(subTemplateIndex)) {
// If top level translation is an empty string, do not invoke additional processing
// and just create op codes for empty text node instead.
createOpCodes.push(
message, allocNodeIndex(startIndex),
parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild);
} else {
const templateTranslation = getTranslationForTemplate(message, subTemplateIndex);
const msgParts = replaceNgsp(templateTranslation).split(PH_REGEXP);
for (let i = 0; i < msgParts.length; i++) {
@ -426,8 +437,8 @@ function i18nStartFirstPass(
const phIndex = parseInt(value.substr(1), 10);
const isElement = value.charAt(0) === TagType.ELEMENT;
// The value represents a placeholder that we move to the designated index.
// Note: positive indicies indicate that a TNode with a given index should also be marked as
// parent while executing `Select` instruction.
// Note: positive indicies indicate that a TNode with a given index should also be marked
// as parent while executing `Select` instruction.
createOpCodes.push(
(isElement ? phIndex : ~phIndex) << I18nMutateOpCode.SHIFT_REF |
I18nMutateOpCode.Select,
@ -446,14 +457,15 @@ function i18nStartFirstPass(
const icuExpression = parts[j] as IcuExpression;
// Verify that ICU expression has the right shape. Translations might contain invalid
// constructions (while original messages were correct), so ICU parsing at runtime may not
// succeed (thus `icuExpression` remains a string).
// constructions (while original messages were correct), so ICU parsing at runtime may
// not succeed (thus `icuExpression` remains a string).
if (typeof icuExpression !== 'object') {
throw new Error(`Unable to parse ICU expression in "${templateTranslation}" message.`);
throw new Error(
`Unable to parse ICU expression in "${templateTranslation}" message.`);
}
// Create the comment node that will anchor the ICU expression
const icuNodeIndex = startIndex + i18nVarsCount++;
const icuNodeIndex = allocNodeIndex(startIndex);
createOpCodes.push(
COMMENT_MARKER, ngDevMode ? `ICU ${icuNodeIndex}` : '', icuNodeIndex,
parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild);
@ -476,7 +488,7 @@ function i18nStartFirstPass(
// Even indexes are text (including bindings)
const hasBinding = text.match(BINDING_REGEXP);
// Create text nodes
const textNodeIndex = startIndex + i18nVarsCount++;
const textNodeIndex = allocNodeIndex(startIndex);
createOpCodes.push(
// If there is a binding, the value will be set during update
hasBinding ? '' : text, textNodeIndex,
@ -489,6 +501,7 @@ function i18nStartFirstPass(
}
}
}
}
if (i18nVarsCount > 0) {
allocExpando(tView, lView, i18nVarsCount);
@ -558,6 +571,10 @@ function appendI18nNode(
return tNode;
}
function isRootTemplateMessage(subTemplateIndex: number|undefined): subTemplateIndex is undefined {
return subTemplateIndex === undefined;
}
/**
* Handles message string post-processing for internationalization.
*

View File

@ -1611,6 +1611,41 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => {
});
});
describe('empty translations', () => {
it('should replace existing text content with empty translation', () => {
loadTranslations({[computeMsgId('Some Text')]: ''});
const fixture = initWithTemplate(AppComp, '<div i18n>Some Text</div>');
expect(fixture.nativeElement.textContent).toBe('');
});
it('should replace existing DOM elements with empty translation', () => {
loadTranslations({
[computeMsgId(
' Start {$START_TAG_DIV}DIV{$CLOSE_TAG_DIV}' +
'{$START_TAG_SPAN}SPAN{$CLOSE_TAG_SPAN} End ')]: '',
});
const fixture = initWithTemplate(AppComp, `
<div i18n>
Start
<div>DIV</div>
<span>SPAN</span>
End
</div>
`);
expect(fixture.nativeElement.textContent).toBe('');
});
it('should replace existing ICU content with empty translation', () => {
loadTranslations({
[computeMsgId('{VAR_PLURAL, plural, =0 {zero} other {more than zero}}')]: '',
});
const fixture = initWithTemplate(AppComp, `
<div i18n>{count, plural, =0 {zero} other {more than zero}}</div>
`);
expect(fixture.nativeElement.textContent).toBe('');
});
});
it('should work with directives and host bindings', () => {
let directiveInstances: ClsDir[] = [];