From 9b4b281c5223084834019664fbe5248521caadae 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 (#41882) 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 PR Close #41882 --- 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 | 16 +++---- 3 files changed, 57 insertions(+), 22 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 b0648ad5c2536..e7d53802f7bfe 100644 --- a/packages/core/test/acceptance/i18n_spec.ts +++ b/packages/core/test/acceptance/i18n_spec.ts @@ -1624,17 +1624,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 2577ffa2883e3..572c5248a15dc 100644 --- a/packages/core/test/render3/i18n/i18n_spec.ts +++ b/packages/core/test/render3/i18n/i18n_spec.ts @@ -458,9 +458,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'); @@ -469,11 +470,10 @@ describe('Runtime i18n', () => { }, undefined, nbConsts, HEADER_OFFSET + index); expect(opCodes).toEqual(matchDebug([ - `if (mask & 0b1) { (lView[${ - HEADER_OFFSET + 0}] as Element).setAttribute('title', \`Hello \$\{lView[i-1]}!\`); }`, - `if (mask & 0b1) { (lView[${ - HEADER_OFFSET + - 0}] 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]}!`); }', ])); }); });