From c343f008069b6624d02f3b515d5d1f478bd89f0b Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Thu, 29 Apr 2021 16:29:14 +0100 Subject: [PATCH] fix(core): handle multiple i18n attributes with expression bindings When there are multiple attributes that are marked for i18n translation, which contain expression bindings, we were generating i18n update op-codes that did not accurately map to the correct value to be bound in the lView. Each attribute's bindings were relative to the start of the lView first attributes values rather than to their own. This fix passes the current binding index to `generateBindingUpdateOpCodes()` when processing i18n attributes to account for this. Fixes #41869 --- packages/core/src/render3/i18n/i18n_parse.ts | 49 ++++++++++++++++---- packages/core/test/acceptance/i18n_spec.ts | 14 ++++-- packages/core/test/render3/i18n/i18n_spec.ts | 13 ++++-- 3 files changed, 57 insertions(+), 19 deletions(-) diff --git a/packages/core/src/render3/i18n/i18n_parse.ts b/packages/core/src/render3/i18n/i18n_parse.ts index 9c6bc08e90507..16edade3139ac 100644 --- a/packages/core/src/render3/i18n/i18n_parse.ts +++ b/packages/core/src/render3/i18n/i18n_parse.ts @@ -219,7 +219,7 @@ function i18nStartFirstCreatePassProcessTextNode( const tNode = createTNodeAndAddOpCode( tView, rootTNode, existingTNodes, lView, createOpCodes, hasBinding ? null : text, false); if (hasBinding) { - generateBindingUpdateOpCodes(updateOpCodes, text, tNode.index); + generateBindingUpdateOpCodes(updateOpCodes, text, tNode.index, null, 0, null); } } @@ -251,7 +251,11 @@ export function i18nAttributesFirstPass(tView: TView, index: number, values: str // i18n attributes that hit this code path are guaranteed to have bindings, because // the compiler treats static i18n attributes as regular attribute bindings. - generateBindingUpdateOpCodes(updateOpCodes, message, previousElementIndex, attrName); + // Since this may not be the first i18n attribute on this element we need to pass in how + // many previous bindings there have already been. + generateBindingUpdateOpCodes( + updateOpCodes, message, previousElementIndex, attrName, countBindings(updateOpCodes), + null); } } tView.data[index] = updateOpCodes; @@ -267,10 +271,12 @@ export function i18nAttributesFirstPass(tView: TView, index: number, values: str * @param destinationNode Index of the destination node which will receive the binding. * @param attrName Name of the attribute, if the string belongs to an attribute. * @param sanitizeFn Sanitization function used to sanitize the string after update, if necessary. + * @param bindingStart The lView index of the next expression that can be bound via an opCode. + * @returns The mask value for these bindings */ -export function generateBindingUpdateOpCodes( - updateOpCodes: I18nUpdateOpCodes, str: string, destinationNode: number, attrName?: string, - sanitizeFn: SanitizerFn|null = null): number { +function generateBindingUpdateOpCodes( + updateOpCodes: I18nUpdateOpCodes, str: string, destinationNode: number, attrName: string|null, + bindingStart: number, sanitizeFn: SanitizerFn|null): number { ngDevMode && assertGreaterThanOrEqual( destinationNode, HEADER_OFFSET, 'Index must be in absolute LView offset'); @@ -289,7 +295,7 @@ export function generateBindingUpdateOpCodes( if (j & 1) { // Odd indexes are bindings - const bindingIndex = parseInt(textValue, 10); + const bindingIndex = bindingStart + parseInt(textValue, 10); updateOpCodes.push(-1 - bindingIndex); mask = mask | toMaskBit(bindingIndex); } else if (textValue !== '') { @@ -309,6 +315,28 @@ export function generateBindingUpdateOpCodes( return mask; } +/** + * Count the number of bindings in the given `opCodes`. + * + * It could be possible to speed this up, by passing the number of bindings found back from + * `generateBindingUpdateOpCodes()` to `i18nAttributesFirstPass()` but this would then require more + * complexity in the code and/or transient objects to be created. + * + * Since this function is only called once when the template is instantiated, is trivial in the + * first instance (since `opCodes` will be an empty array), and it is not common for elements to + * contain multiple i18n bound attributes, it seems like this is a reasonable compromise. + */ +function countBindings(opCodes: I18nUpdateOpCodes): number { + let count = 0; + for (let i = 0; i < opCodes.length; i++) { + const opCode = opCodes[i]; + // Bindings are negative numbers. + if (typeof opCode === 'number' && opCode < 0) { + count++; + } + } + return count; +} /** * Convert binding index to mask bit. @@ -595,12 +623,12 @@ function walkIcuTree( if (VALID_ATTRS.hasOwnProperty(lowerAttrName)) { if (URI_ATTRS[lowerAttrName]) { generateBindingUpdateOpCodes( - update, attr.value, newIndex, attr.name, _sanitizeUrl); + update, attr.value, newIndex, attr.name, 0, _sanitizeUrl); } else if (SRCSET_ATTRS[lowerAttrName]) { generateBindingUpdateOpCodes( - update, attr.value, newIndex, attr.name, sanitizeSrcset); + update, attr.value, newIndex, attr.name, 0, sanitizeSrcset); } else { - generateBindingUpdateOpCodes(update, attr.value, newIndex, attr.name); + generateBindingUpdateOpCodes(update, attr.value, newIndex, attr.name, 0, null); } } else { ngDevMode && @@ -627,7 +655,8 @@ function walkIcuTree( addCreateNodeAndAppend(create, null, hasBinding ? '' : value, parentIdx, newIndex); addRemoveNode(remove, newIndex, depth); if (hasBinding) { - bindingMask = generateBindingUpdateOpCodes(update, value, newIndex) | bindingMask; + bindingMask = + generateBindingUpdateOpCodes(update, value, newIndex, null, 0, null) | bindingMask; } break; case Node.COMMENT_NODE: diff --git a/packages/core/test/acceptance/i18n_spec.ts b/packages/core/test/acceptance/i18n_spec.ts index cc7fb156ce2c0..39b0d844a12be 100644 --- a/packages/core/test/acceptance/i18n_spec.ts +++ b/packages/core/test/acceptance/i18n_spec.ts @@ -1604,17 +1604,23 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { }); it('multiple attributes', () => { - loadTranslations({[computeMsgId('hello {$INTERPOLATION}')]: 'bonjour {$INTERPOLATION}'}); + loadTranslations({ + [computeMsgId('hello {$INTERPOLATION} - {$INTERPOLATION_1}')]: + 'bonjour {$INTERPOLATION} - {$INTERPOLATION_1}', + [computeMsgId('bye {$INTERPOLATION} - {$INTERPOLATION_1}')]: + 'au revoir {$INTERPOLATION} - {$INTERPOLATION_1}', + }); const fixture = initWithTemplate( AppComp, - ``); + ``); expect(fixture.nativeElement.innerHTML) - .toEqual(``); + .toEqual(``); fixture.componentRef.instance.name = 'John'; + fixture.componentRef.instance.count = 5; fixture.detectChanges(); expect(fixture.nativeElement.innerHTML) - .toEqual(``); + .toEqual(``); }); it('on removed elements', () => { diff --git a/packages/core/test/render3/i18n/i18n_spec.ts b/packages/core/test/render3/i18n/i18n_spec.ts index 26c2be6c2ab64..000606664be66 100644 --- a/packages/core/test/render3/i18n/i18n_spec.ts +++ b/packages/core/test/render3/i18n/i18n_spec.ts @@ -449,9 +449,10 @@ describe('Runtime i18n', () => { }); it('for multiple attributes', () => { - const message = `Hello �0�!`; - const attrs = ['title', message, 'aria-label', message]; - const nbConsts = 2; + const message1 = `Hello �0� - �1�!`; + const message2 = `Bye �0� - �1�!`; + const attrs = ['title', message1, 'aria-label', message2]; + const nbConsts = 4; const index = 1; const opCodes = getOpCodes(attrs, () => { ɵɵelementStart(0, 'div'); @@ -460,8 +461,10 @@ describe('Runtime i18n', () => { }, undefined, nbConsts, HEADER_OFFSET + index); expect(opCodes).toEqual(matchDebug([ - 'if (mask & 0b1) { (lView[20] as Element).setAttribute(\'title\', `Hello ${lView[i-1]}!`); }', - 'if (mask & 0b1) { (lView[20] as Element).setAttribute(\'aria-label\', `Hello ${lView[i-1]}!`); }', + `if (mask & 0b11) { (lView[${HEADER_OFFSET + 0}] as Element).` + + 'setAttribute(\'title\', `Hello ${lView[i-1]} - ${lView[i-2]}!`); }', + `if (mask & 0b1100) { (lView[${HEADER_OFFSET + 0}] as Element).` + + 'setAttribute(\'aria-label\', `Bye ${lView[i-3]} - ${lView[i-4]}!`); }', ])); }); });