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. * external template and removes all sub-templates.
*/ */
export function getTranslationForTemplate(message: string, subTemplateIndex?: number) { export function getTranslationForTemplate(message: string, subTemplateIndex?: number) {
if (typeof subTemplateIndex !== 'number') { if (isRootTemplateMessage(subTemplateIndex)) {
// We want the root template message, ignore all sub-templates // We want the root template message, ignore all sub-templates
return removeInnerTemplateTranslation(message); return removeInnerTemplateTranslation(message);
} else { } else {
@ -376,6 +376,10 @@ export function ɵɵi18nStart(index: number, message: string, subTemplateIndex?:
// This is reset to 0 when `i18nStartFirstPass` is called. // This is reset to 0 when `i18nStartFirstPass` is called.
let i18nVarsCount: number; let i18nVarsCount: number;
function allocNodeIndex(startIndex: number): number {
return startIndex + i18nVarsCount++;
}
/** /**
* See `i18nStart` above. * See `i18nStart` above.
*/ */
@ -409,81 +413,90 @@ function i18nStartFirstPass(
const updateOpCodes: I18nUpdateOpCodes = []; const updateOpCodes: I18nUpdateOpCodes = [];
const icuExpressions: TIcu[] = []; const icuExpressions: TIcu[] = [];
const templateTranslation = getTranslationForTemplate(message, subTemplateIndex); if (message === '' && isRootTemplateMessage(subTemplateIndex)) {
const msgParts = replaceNgsp(templateTranslation).split(PH_REGEXP); // If top level translation is an empty string, do not invoke additional processing
for (let i = 0; i < msgParts.length; i++) { // and just create op codes for empty text node instead.
let value = msgParts[i]; createOpCodes.push(
if (i & 1) { message, allocNodeIndex(startIndex),
// Odd indexes are placeholders (elements and sub-templates) parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild);
if (value.charAt(0) === '/') { } else {
// It is a closing tag const templateTranslation = getTranslationForTemplate(message, subTemplateIndex);
if (value.charAt(1) === TagType.ELEMENT) { const msgParts = replaceNgsp(templateTranslation).split(PH_REGEXP);
const phIndex = parseInt(value.substr(2), 10); for (let i = 0; i < msgParts.length; i++) {
parentIndex = parentIndexStack[--parentIndexPointer]; let value = msgParts[i];
createOpCodes.push(phIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.ElementEnd); if (i & 1) {
// Odd indexes are placeholders (elements and sub-templates)
if (value.charAt(0) === '/') {
// It is a closing tag
if (value.charAt(1) === TagType.ELEMENT) {
const phIndex = parseInt(value.substr(2), 10);
parentIndex = parentIndexStack[--parentIndexPointer];
createOpCodes.push(phIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.ElementEnd);
}
} else {
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.
createOpCodes.push(
(isElement ? phIndex : ~phIndex) << I18nMutateOpCode.SHIFT_REF |
I18nMutateOpCode.Select,
parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild);
if (isElement) {
parentIndexStack[++parentIndexPointer] = parentIndex = phIndex;
}
} }
} else { } else {
const phIndex = parseInt(value.substr(1), 10); // Even indexes are text (including bindings & ICU expressions)
const isElement = value.charAt(0) === TagType.ELEMENT; const parts = extractParts(value);
// The value represents a placeholder that we move to the designated index. for (let j = 0; j < parts.length; j++) {
// Note: positive indicies indicate that a TNode with a given index should also be marked as if (j & 1) {
// parent while executing `Select` instruction. // Odd indexes are ICU expressions
createOpCodes.push( const icuExpression = parts[j] as IcuExpression;
(isElement ? phIndex : ~phIndex) << I18nMutateOpCode.SHIFT_REF |
I18nMutateOpCode.Select,
parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild);
if (isElement) { // Verify that ICU expression has the right shape. Translations might contain invalid
parentIndexStack[++parentIndexPointer] = parentIndex = phIndex; // constructions (while original messages were correct), so ICU parsing at runtime may
} // not succeed (thus `icuExpression` remains a string).
} if (typeof icuExpression !== 'object') {
} else { throw new Error(
// Even indexes are text (including bindings & ICU expressions) `Unable to parse ICU expression in "${templateTranslation}" message.`);
const parts = extractParts(value); }
for (let j = 0; j < parts.length; j++) {
if (j & 1) {
// Odd indexes are ICU expressions
const icuExpression = parts[j] as IcuExpression;
// Verify that ICU expression has the right shape. Translations might contain invalid // Create the comment node that will anchor the ICU expression
// constructions (while original messages were correct), so ICU parsing at runtime may not const icuNodeIndex = allocNodeIndex(startIndex);
// succeed (thus `icuExpression` remains a string). createOpCodes.push(
if (typeof icuExpression !== 'object') { COMMENT_MARKER, ngDevMode ? `ICU ${icuNodeIndex}` : '', icuNodeIndex,
throw new Error(`Unable to parse ICU expression in "${templateTranslation}" message.`); parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild);
}
// Create the comment node that will anchor the ICU expression // Update codes for the ICU expression
const icuNodeIndex = startIndex + i18nVarsCount++; const mask = getBindingMask(icuExpression);
createOpCodes.push( icuStart(icuExpressions, icuExpression, icuNodeIndex, icuNodeIndex);
COMMENT_MARKER, ngDevMode ? `ICU ${icuNodeIndex}` : '', icuNodeIndex, // Since this is recursive, the last TIcu that was pushed is the one we want
parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild); const tIcuIndex = icuExpressions.length - 1;
updateOpCodes.push(
toMaskBit(icuExpression.mainBinding), // mask of the main binding
3, // skip 3 opCodes if not changed
-1 - icuExpression.mainBinding,
icuNodeIndex << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuSwitch, tIcuIndex,
mask, // mask of all the bindings of this ICU expression
2, // skip 2 opCodes if not changed
icuNodeIndex << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuUpdate, tIcuIndex);
} else if (parts[j] !== '') {
const text = parts[j] as string;
// Even indexes are text (including bindings)
const hasBinding = text.match(BINDING_REGEXP);
// Create text nodes
const textNodeIndex = allocNodeIndex(startIndex);
createOpCodes.push(
// If there is a binding, the value will be set during update
hasBinding ? '' : text, textNodeIndex,
parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild);
// Update codes for the ICU expression if (hasBinding) {
const mask = getBindingMask(icuExpression); addAllToArray(generateBindingUpdateOpCodes(text, textNodeIndex), updateOpCodes);
icuStart(icuExpressions, icuExpression, icuNodeIndex, icuNodeIndex); }
// Since this is recursive, the last TIcu that was pushed is the one we want
const tIcuIndex = icuExpressions.length - 1;
updateOpCodes.push(
toMaskBit(icuExpression.mainBinding), // mask of the main binding
3, // skip 3 opCodes if not changed
-1 - icuExpression.mainBinding,
icuNodeIndex << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuSwitch, tIcuIndex,
mask, // mask of all the bindings of this ICU expression
2, // skip 2 opCodes if not changed
icuNodeIndex << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuUpdate, tIcuIndex);
} else if (parts[j] !== '') {
const text = parts[j] as string;
// Even indexes are text (including bindings)
const hasBinding = text.match(BINDING_REGEXP);
// Create text nodes
const textNodeIndex = startIndex + i18nVarsCount++;
createOpCodes.push(
// If there is a binding, the value will be set during update
hasBinding ? '' : text, textNodeIndex,
parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild);
if (hasBinding) {
addAllToArray(generateBindingUpdateOpCodes(text, textNodeIndex), updateOpCodes);
} }
} }
} }
@ -558,6 +571,10 @@ function appendI18nNode(
return tNode; return tNode;
} }
function isRootTemplateMessage(subTemplateIndex: number|undefined): subTemplateIndex is undefined {
return subTemplateIndex === undefined;
}
/** /**
* Handles message string post-processing for internationalization. * 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', () => { it('should work with directives and host bindings', () => {
let directiveInstances: ClsDir[] = []; let directiveInstances: ClsDir[] = [];