From 5c346d2e4e4687210eb76e5af02f455d82e8f29f Mon Sep 17 00:00:00 2001 From: Milen Karmidzhanov Date: Wed, 22 Jul 2026 17:32:05 +0300 Subject: [PATCH 1/5] fix(ui5-tokenizer): fix rare case when n-more button cause loop error --- .../main/cypress/specs/MultiComboBox.cy.tsx | 84 ++++++++++++++++++- packages/main/cypress/specs/MultiInput.cy.tsx | 4 +- packages/main/cypress/specs/Tokenizer.cy.tsx | 2 +- packages/main/src/Tokenizer.ts | 26 ++++-- 4 files changed, 104 insertions(+), 12 deletions(-) diff --git a/packages/main/cypress/specs/MultiComboBox.cy.tsx b/packages/main/cypress/specs/MultiComboBox.cy.tsx index 273b523ca49c8..f700bc9d06bd8 100644 --- a/packages/main/cypress/specs/MultiComboBox.cy.tsx +++ b/packages/main/cypress/specs/MultiComboBox.cy.tsx @@ -1,3 +1,4 @@ +import "../../src/Assets.js"; import MultiComboBox from "../../src/MultiComboBox.js"; import MultiComboBoxItem from "../../src/MultiComboBoxItem.js"; import MultiComboBoxItemCustom from "../../src/MultiComboBoxItemCustom.js"; @@ -6,6 +7,7 @@ import ResponsivePopover from "../../src/ResponsivePopover.js"; import Button from "../../src/Button.js"; import Link from "../../src/Link.js"; import Input from "../../src/Input.js"; +import { setLanguage } from "@ui5/webcomponents-base/dist/config/Language.js"; import { MULTIINPUT_SHOW_MORE_TOKENS, TOKENIZER_ARIA_CONTAIN_ONE_TOKEN, TOKENIZER_ARIA_CONTAIN_SEVERAL_TOKENS, TOKENIZER_ARIA_CONTAIN_TOKEN, TOKENIZER_SHOW_ALL_ITEMS, VALUE_STATE_ERROR, VALUE_STATE_TYPE_ERROR, VALUE_STATE_TYPE_SUCCESS, VALUE_STATE_TYPE_WARNING, VALUE_STATE_WARNING } from "../../src/generated/i18n/i18n-defaults.js"; describe("Security", () => { @@ -372,7 +374,7 @@ describe("General", () => { it("Should delete token after focus change when tokenizer collapses", () => { cy.mount( - + @@ -5503,3 +5505,83 @@ describe("MultiComboBoxItemCustom - Mixed Selection", () => { cy.get("@multiCombobox").shadow().find("[ui5-token]").should("have.length", 2); }); }); + +describe("Tokenizer overflow calculation", () => { + it("should not cause render loop at specific widths with medium-length tokens", () => { + // This test verifies the fix for a render loop that occurred when: + // - MultiComboBox has a specific width (208px) + // - First token is medium-length (3-4 characters) + // - The "n more" indicator in Korean (wider text) causes oscillation + // Korean "1 more" = "1개 더" which is significantly wider than English + // The loop is reproducable in english as well but we need to change the english + // translation to be 1 character longer (instead of "more" use "moree") + + // Set language to Korean and wait for it to load + cy.wrap({ setLanguage }) + .then(api => api.setLanguage("ko")); + + cy.mount( + + + + + + ); + + cy.get("[ui5-multi-combobox]") + .as("mcb"); + + // Get the tokenizer element + cy.get("@mcb") + .shadow() + .find("[ui5-tokenizer]") + .as("tokenizer"); + + // Get the "n more" text element + cy.get("@tokenizer") + .shadow() + .find(".ui5-tokenizer-more-text") + .as("nMoreText"); + + // Wait a bit for language to fully apply + cy.wait(200); + + // Verify "n more" indicator is shown (some tokens should overflow) + cy.get("@nMoreText") + .should("be.visible"); + + // Verify Korean text is shown (contains Korean characters) + // Korean shows "2개 항목" (wider than English "2 more") + cy.get("@nMoreText") + .invoke("text") + .should("match", /개|항목/); // Korean text contains these characters + + // Capture the initial text of the "n more" indicator + cy.get("@nMoreText") + .invoke("text") + .as("initialText"); + + // Wait a bit to ensure no render loop is happening + cy.wait(500); + + // Verify the "n more" text hasn't changed (would change continuously in a render loop) + cy.get("@nMoreText") + .invoke("text") + .then(currentText => { + cy.get("@initialText").should("equal", currentText); + }); + + // Verify the tokenizer state is stable + cy.get("@tokenizer") + .should("exist"); + + // Verify tokens exist in the tokenizer (tokens are created from selected items) + cy.get("@tokenizer") + .find("[ui5-token]") + .should("have.length.at.least", 1); + + // Reset language + cy.wrap({ setLanguage }) + .then(api => api.setLanguage("en")); + }); +}); diff --git a/packages/main/cypress/specs/MultiInput.cy.tsx b/packages/main/cypress/specs/MultiInput.cy.tsx index f3f603373d6df..e05902fa83665 100644 --- a/packages/main/cypress/specs/MultiInput.cy.tsx +++ b/packages/main/cypress/specs/MultiInput.cy.tsx @@ -355,7 +355,7 @@ describe("MultiInput tokens", () => { cy.get("[ui5-token]") .eq(1) - .should("not.have.attr", "overflows"); + .should("have.attr", "overflows"); cy.get("[ui5-token]") .eq(2) @@ -571,7 +571,7 @@ describe("MultiInput tokens", () => { - + diff --git a/packages/main/cypress/specs/Tokenizer.cy.tsx b/packages/main/cypress/specs/Tokenizer.cy.tsx index 48bedecca72d6..184490575e5eb 100755 --- a/packages/main/cypress/specs/Tokenizer.cy.tsx +++ b/packages/main/cypress/specs/Tokenizer.cy.tsx @@ -798,7 +798,7 @@ describe("Accessibility", () => { const resourceBundle = (tokenizer.constructor as any).i18nBundle; cy.get("@nMoreLabel") - .should("have.text", resourceBundle.getText(MULTIINPUT_SHOW_MORE_TOKENS.defaultText, 2)); + .should("have.text", resourceBundle.getText(MULTIINPUT_SHOW_MORE_TOKENS.defaultText, 3)); }); }); }); diff --git a/packages/main/src/Tokenizer.ts b/packages/main/src/Tokenizer.ts index a7d43780d5cfa..8cd2430b945f9 100644 --- a/packages/main/src/Tokenizer.ts +++ b/packages/main/src/Tokenizer.ts @@ -1272,22 +1272,32 @@ class Tokenizer extends UI5Element implements IFormInputElement { const tokensArray = this._tokens; - // Reset the overflow prop of the tokens first in order - // to use their dimensions for calculation because already - // hidden tokens are set to 'display: none' + // Reset overflow to measure all tokens tokensArray.forEach(token => { token.overflows = false; }); - return tokensArray.filter(token => { - const parentRect = this.contentDom.getBoundingClientRect(); + const parentRect = this.contentDom.getBoundingClientRect(); + const parentEnd = Number(parentRect.right.toFixed(2)); + const parentStart = Number(parentRect.left.toFixed(2)); + + // Measure "n more" width + let nMoreWidth = 0; + const nMoreElement = this.moreLink; + if (nMoreElement) { + nMoreWidth = nMoreElement.getBoundingClientRect().width; + } + + // Calculate overflow, reserving space for "n more" except on last token + return tokensArray.filter((token, index) => { const tokenRect = token.getBoundingClientRect(); const tokenEnd = Number(tokenRect.right.toFixed(2)); - const parentEnd = Number(parentRect.right.toFixed(2)); const tokenStart = Number(tokenRect.left.toFixed(2)); - const parentStart = Number(parentRect.left.toFixed(2)); - token.overflows = !this.expanded && ((tokenStart < parentStart) || (tokenEnd > parentEnd)); + const isLastToken = index === tokensArray.length - 1; + const effectiveParentEnd = isLastToken ? parentEnd : Number((parentRect.right - nMoreWidth).toFixed(2)); + + token.overflows = !this.expanded && ((tokenStart < parentStart) || (tokenEnd > effectiveParentEnd)); return token.overflows; }); From f7b7036a5fc78821e8253e0ad09e2282d53af236 Mon Sep 17 00:00:00 2001 From: Milen Karmidzhanov Date: Thu, 23 Jul 2026 08:42:22 +0300 Subject: [PATCH 2/5] fix(ui5-tokenizer): fix rare case when n-more button cause loop error --- .../main/cypress/specs/MultiComboBox.cy.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/main/cypress/specs/MultiComboBox.cy.tsx b/packages/main/cypress/specs/MultiComboBox.cy.tsx index f700bc9d06bd8..0f0e5fda1486b 100644 --- a/packages/main/cypress/specs/MultiComboBox.cy.tsx +++ b/packages/main/cypress/specs/MultiComboBox.cy.tsx @@ -5515,7 +5515,7 @@ describe("Tokenizer overflow calculation", () => { // Korean "1 more" = "1개 더" which is significantly wider than English // The loop is reproducable in english as well but we need to change the english // translation to be 1 character longer (instead of "more" use "moree") - + // Set language to Korean and wait for it to load cy.wrap({ setLanguage }) .then(api => api.setLanguage("ko")); @@ -5524,7 +5524,8 @@ describe("Tokenizer overflow calculation", () => { - + + ); @@ -5537,21 +5538,21 @@ describe("Tokenizer overflow calculation", () => { .find("[ui5-tokenizer]") .as("tokenizer"); - // Get the "n more" text element + // Wait a bit for language to fully apply + cy.wait(200); + + // Get the "n more" text element (if it exists) cy.get("@tokenizer") .shadow() .find(".ui5-tokenizer-more-text") .as("nMoreText"); - // Wait a bit for language to fully apply - cy.wait(200); - - // Verify "n more" indicator is shown (some tokens should overflow) + // Verify "n more" indicator is shown (some tokens should overflow at 208px with 3 tokens) cy.get("@nMoreText") .should("be.visible"); // Verify Korean text is shown (contains Korean characters) - // Korean shows "2개 항목" (wider than English "2 more") + // Korean shows "N개 항목" (wider than English "N more") cy.get("@nMoreText") .invoke("text") .should("match", /개|항목/); // Korean text contains these characters From c374f09e7706c9a02175ad8b8f9f1b80e8dde162 Mon Sep 17 00:00:00 2001 From: Milen Karmidzhanov Date: Thu, 23 Jul 2026 08:58:27 +0300 Subject: [PATCH 3/5] fix(ui5-tokenizer): fix rare case when n-more button cause loop error --- packages/main/cypress/specs/MultiComboBox.cy.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/main/cypress/specs/MultiComboBox.cy.tsx b/packages/main/cypress/specs/MultiComboBox.cy.tsx index 0f0e5fda1486b..edcb773768537 100644 --- a/packages/main/cypress/specs/MultiComboBox.cy.tsx +++ b/packages/main/cypress/specs/MultiComboBox.cy.tsx @@ -5525,7 +5525,6 @@ describe("Tokenizer overflow calculation", () => { - ); @@ -5538,19 +5537,16 @@ describe("Tokenizer overflow calculation", () => { .find("[ui5-tokenizer]") .as("tokenizer"); - // Wait a bit for language to fully apply - cy.wait(200); + // Wait for language to fully apply and rendering to stabilize + cy.wait(300); - // Get the "n more" text element (if it exists) + // Get the "n more" text element with timeout - should exist with 3 selected tokens at 208px cy.get("@tokenizer") .shadow() .find(".ui5-tokenizer-more-text") + .should("exist") .as("nMoreText"); - // Verify "n more" indicator is shown (some tokens should overflow at 208px with 3 tokens) - cy.get("@nMoreText") - .should("be.visible"); - // Verify Korean text is shown (contains Korean characters) // Korean shows "N개 항목" (wider than English "N more") cy.get("@nMoreText") From a1b44cc8524f99c7cffbcc7e0f904000cffae2fe Mon Sep 17 00:00:00 2001 From: Milen Karmidzhanov Date: Tue, 4 Aug 2026 16:51:11 +0300 Subject: [PATCH 4/5] fix(ui5-tokenizer): fix rare case when n-more button cause loop error --- .../main/cypress/specs/MultiComboBox.cy.tsx | 61 ++++++++++++------- packages/main/src/Tokenizer.ts | 20 ++++-- packages/main/test/pages/styles/Tokenizer.css | 4 +- 3 files changed, 58 insertions(+), 27 deletions(-) diff --git a/packages/main/cypress/specs/MultiComboBox.cy.tsx b/packages/main/cypress/specs/MultiComboBox.cy.tsx index 2eb4bf847c475..e296491742c24 100644 --- a/packages/main/cypress/specs/MultiComboBox.cy.tsx +++ b/packages/main/cypress/specs/MultiComboBox.cy.tsx @@ -345,30 +345,48 @@ describe("General", () => { cy.get("[ui5-multi-combobox]") .as("mcb") + .then($mcb => { + $mcb.get(0).addEventListener("ui5-selection-change", cy.stub().as("selectionChange")); + }); + + // With new overflow logic, both tokens should be hidden and show "2 items" + cy.get("@mcb") .shadow() .find("[ui5-tokenizer]") .as("tokenizer") - .invoke('on', 'ui5-token-delete', cy.spy().as('tokenDelete')); - - // The first token is the long one and should be hidden in the n-more, so we target the second token - cy.get("@tokenizer") - .find("[ui5-token]") - .eq(1) - .as("token") - .should("exist"); + .shadow() + .find(".ui5-tokenizer-more-text") + .should("exist") + .should("contain.text", "2") + .realClick(); - cy.get("@token") + // In MultiComboBox, clicking "n items" opens the main dropdown + cy.get("@mcb") .shadow() - .find("[ui5-icon]") + .find("ui5-responsive-popover") + .as("popover") + .ui5ResponsivePopoverOpened(); + + // Find and click the first item (the long token) to deselect it + cy.get("@mcb") + .find("[ui5-mcb-item]") + .first() + .should("have.attr", "text", "This is an extremely long token text that will definitely trigger the problematic code path in the deletion flow and should be properly deletable") .realClick(); - cy.get("@tokenDelete") - .should("have.been.calledOnce") - .should("have.been.calledWithMatch", Cypress.sinon.match(event => { - return event.detail.tokens.length === 1; - })); + cy.get("@selectionChange") + .should("have.been.called"); - cy.get("@token") + // After removing the long token, the remaining "Item" token should now be visible + cy.get("@tokenizer") + .find("[ui5-token]") + .should("have.length", 1) + .should("have.attr", "text", "Item"); + + // No "n more" should be shown since the single token fits + cy.get("@tokenizer") + .shadow() + .find(".ui5-tokenizer-more-text") .should("not.exist"); }); @@ -835,7 +853,7 @@ describe("General", () => { .should("have.text", "BG"); }); - it("N-more translation", () => { + it("N-items translation", () => { cy.mount( @@ -858,11 +876,12 @@ describe("General", () => { }) }); - it("N-items translation", () => { + it("N-more translation", () => { cy.mount( - - + + + ); @@ -877,7 +896,7 @@ describe("General", () => { .find("[ui5-tokenizer]") .shadow() .find(".ui5-tokenizer-more-text") - .should("have.text", resourceBundle.getText(MULTIINPUT_SHOW_MORE_TOKENS.defaultText, 1)); + .should("have.text", resourceBundle.getText(MULTIINPUT_SHOW_MORE_TOKENS.defaultText, 2)); }) }); diff --git a/packages/main/src/Tokenizer.ts b/packages/main/src/Tokenizer.ts index 8cd2430b945f9..c567282e8d938 100644 --- a/packages/main/src/Tokenizer.ts +++ b/packages/main/src/Tokenizer.ts @@ -1288,19 +1288,31 @@ class Tokenizer extends UI5Element implements IFormInputElement { nMoreWidth = nMoreElement.getBoundingClientRect().width; } - // Calculate overflow, reserving space for "n more" except on last token - return tokensArray.filter((token, index) => { + // Calculate overflow sequentially: show tokens only if token + "n more" indicator both fit + let firstOverflowIndex = -1; + + tokensArray.forEach((token, index) => { const tokenRect = token.getBoundingClientRect(); const tokenEnd = Number(tokenRect.right.toFixed(2)); const tokenStart = Number(tokenRect.left.toFixed(2)); const isLastToken = index === tokensArray.length - 1; + + // For the last token, check if it fits without "n more" + // For other tokens, check if token + "n more" fits together const effectiveParentEnd = isLastToken ? parentEnd : Number((parentRect.right - nMoreWidth).toFixed(2)); - token.overflows = !this.expanded && ((tokenStart < parentStart) || (tokenEnd > effectiveParentEnd)); + const tokenOverflows = !this.expanded && ((tokenStart < parentStart) || (tokenEnd > effectiveParentEnd)); - return token.overflows; + if (tokenOverflows && firstOverflowIndex === -1) { + firstOverflowIndex = index; + } + + // Mark this and all subsequent tokens as overflow + token.overflows = firstOverflowIndex !== -1 && index >= firstOverflowIndex; }); + + return tokensArray.filter(token => token.overflows); } get _isPhone() { diff --git a/packages/main/test/pages/styles/Tokenizer.css b/packages/main/test/pages/styles/Tokenizer.css index 9475f6d75451c..4719fdb340a3a 100644 --- a/packages/main/test/pages/styles/Tokenizer.css +++ b/packages/main/test/pages/styles/Tokenizer.css @@ -22,7 +22,7 @@ } ui5-tokenizer { - width: 240px; + width: 100%; } ui5-tokenizer#expanded-tokenizer { @@ -34,7 +34,7 @@ ui5-tokenizer#nmore-tokenizer { } .tokenizer-container { - width: 240px; + width: 300px; } .wrapper { From d21bf0806f0960947ecce7874ac802d72982b4b2 Mon Sep 17 00:00:00 2001 From: Milen Karmidzhanov Date: Thu, 6 Aug 2026 15:09:24 +0300 Subject: [PATCH 5/5] fix(ui5-tokenizer): fix rare case when n-more button cause loop error --- .../main/cypress/specs/MultiComboBox.cy.tsx | 67 +++++-------------- 1 file changed, 16 insertions(+), 51 deletions(-) diff --git a/packages/main/cypress/specs/MultiComboBox.cy.tsx b/packages/main/cypress/specs/MultiComboBox.cy.tsx index e296491742c24..ee9196b156615 100644 --- a/packages/main/cypress/specs/MultiComboBox.cy.tsx +++ b/packages/main/cypress/specs/MultiComboBox.cy.tsx @@ -5613,18 +5613,18 @@ describe("Select All with Groups", () => { }); describe("Tokenizer overflow calculation", () => { - it("should not cause render loop at specific widths with medium-length tokens", () => { - // This test verifies the fix for a render loop that occurred when: - // - MultiComboBox has a specific width (208px) - // - First token is medium-length (3-4 characters) - // - The "n more" indicator in Korean (wider text) causes oscillation - // Korean "1 more" = "1개 더" which is significantly wider than English - // The loop is reproducable in english as well but we need to change the english - // translation to be 1 character longer (instead of "more" use "moree") + afterEach(() => { + // Reset language regardless of test outcome to avoid leaking into other specs. + cy.wrap(setLanguage("en")); + }); - // Set language to Korean and wait for it to load - cy.wrap({ setLanguage }) - .then(api => api.setLanguage("ko")); + it("should not cause a render loop when the 'n more' width toggles overflow at boundary widths", () => { + // Regression for a render loop: at ~208px the number of overflowing tokens depends on + // the "n more" indicator width, which itself changes with the number of visible tokens, + // so the overflow count oscillates. Korean copy is wider than English, exposing the bug + // at this width. A loop trips RenderQueue's "processed too many times" guard, which + // surfaces as an uncaught exception and fails this test automatically — no polling needed. + cy.wrap(setLanguage("ko")); cy.mount( @@ -5635,56 +5635,21 @@ describe("Tokenizer overflow calculation", () => { ); cy.get("[ui5-multi-combobox]") - .as("mcb"); - - // Get the tokenizer element - cy.get("@mcb") .shadow() .find("[ui5-tokenizer]") .as("tokenizer"); - // Wait for language to fully apply and rendering to stabilize - cy.wait(300); - - // Get the "n more" text element with timeout - should exist with 3 selected tokens at 208px + // The component must settle on a stable state: all three tokens overflow, so the + // indicator reports "3". Cypress retries this until it holds; a loop would throw first. cy.get("@tokenizer") .shadow() .find(".ui5-tokenizer-more-text") .should("exist") - .as("nMoreText"); - - // Verify Korean text is shown (contains Korean characters) - // Korean shows "N개 항목" (wider than English "N more") - cy.get("@nMoreText") - .invoke("text") - .should("match", /개|항목/); // Korean text contains these characters - - // Capture the initial text of the "n more" indicator - cy.get("@nMoreText") - .invoke("text") - .as("initialText"); - - // Wait a bit to ensure no render loop is happening - cy.wait(500); - - // Verify the "n more" text hasn't changed (would change continuously in a render loop) - cy.get("@nMoreText") - .invoke("text") - .then(currentText => { - cy.get("@initialText").should("equal", currentText); - }); + .and("contain.text", "3"); - // Verify the tokenizer state is stable - cy.get("@tokenizer") - .should("exist"); - - // Verify tokens exist in the tokenizer (tokens are created from selected items) + // All three tokens remain in the DOM (overflowing tokens are hidden, not removed). cy.get("@tokenizer") .find("[ui5-token]") - .should("have.length.at.least", 1); - - // Reset language - cy.wrap({ setLanguage }) - .then(api => api.setLanguage("en")); + .should("have.length", 3); }); });