Fix off-by-one and whitespace bugs in the text truncation utilities
What does this MR do and why?
This fixes two bugs in the truncation helpers of app/assets/javascripts/lib/utils/text_utility.js.
truncate() keeps a string that is one character over the limit. It only shortens a string when string.length - 1 > maxLength, so a string exactly one character longer than maxLength comes back unchanged, longer than the limit, while longer strings are cut to exactly maxLength characters: truncate('abcdef', 5) returns 'abcdef', and truncate('abcdefg', 5) returns 'abcd\u2026'. The check now compares the length itself.
Two callers show it. topic_badges.vue limits topic names to 15 characters, so a 16-character topic such as machine-learning is shown in full and without a tooltip, while a 17-character topic is cut to 15 characters and shows the full name in a tooltip. Pasting an image into a Markdown field calls truncate(filename, 246), which lets a 247-character file name through.
truncateWidth() relied on the old check: when the whole string fits, it passes the index of the last character as maxLength, which the old check let through. With only the one-line change, its existing specs for a string narrower than, or exactly as wide as, maxWidth fail. It now returns the string unchanged when its estimated width fits, and otherwise calls truncate() as before. That also fixes the same off-by-one in truncateWidth(): a string exactly one character too wide is now shortened, where it used to be returned as is.
truncateByWords() adds an ellipsis to text it did not cut. It decides whether it cut the text by comparing the length of the kept words, joined with single spaces, with the length of the trimmed input. Any repeated whitespace (two spaces, a tab after a space, a blank line between paragraphs) makes the joined text shorter even when there are no more than maxWords words, so truncateByWords('one two', 7) returns 'one two\u2026'. It now compares the word count with maxWords and returns the trimmed text unchanged when it is within the limit. Text over the limit is truncated as before, with single spaces between the kept words. Its only caller, note_actions.vue, adds the result to the aria labels of the note actions, so for a comment whose first line is Done. Thanks, with two spaces after the period, the label of the edit button ends in : 'Done. Thanks\u2026' instead of : 'Done. Thanks'.
The spec of text_utility.js gets an example for each bug, and the specs of topic_badges.vue and note_actions.vue get one each for what those two components show.
Behaviour change: every caller of truncate() now gets a shortened string, ending in the ellipsis, for input exactly one character over its limit, where it used to get the input back. The existing specs of all of them pass unchanged (list below).
References
There is no issue for this; the new specs reproduce both bugs on master. truncateByWords() came with !225106 (merged).
Screenshots or screen recordings
Explore > Projects on GitLab CE 19.5.0 (the official Docker image), signed out, with one public project whose topics are web-development (15 characters), machine-learning (16) and kubernetes-config (17), and the pointer on the machine-learning badge. That release has the same text_utility.js, topic_badges.vue and projects_list_item.vue as the base of this MR. The After image is the same page with the served bundle's compiled truncate() check changed in the browser the way this MR changes the source; it is not a build of this branch.
Before, machine-learning is shown in full and has no tooltip, while kubernetes-config is cut to 15 characters. After, machine-learning is cut to 15 characters too and shows its full name in a tooltip.
| Before | After |
|---|---|
![]() |
![]() |
How to set up and validate locally
- Run
yarn jest spec/frontend/lib/utils/text_utility_spec.js spec/frontend/vue_shared/components/topic_badges_spec.js spec/frontend/notes/components/note_actions_spec.js. - The new examples are
truncate › when str length is one more than maxLength › returns truncated string of maxLength characters,truncateWidth › when rendered width is one character more than maxWidth › returns truncated string, the two undertruncateByWords › with whitespace handling(when words are separated by repeated whitespace › returns the original stringandwhen lines are separated by blank lines › returns the original string), the topic badges exampletruncation › when topic name is one character over the limit › truncates name and shows tooltip with full name, and the note actions examplewhen note preview is short and has repeated whitespace › should not add an ellipsis. Each of them fails with the code onmaster, with the values below, and passes on this branch.
What I ran and what it printed
All runs used Node 22.23.3 and yarn install --frozen-lockfile on master at f0464c04. In the values below the ellipsis is written as \u2026.
Red, the new examples against the text_utility.js of master (Tests: 4 failed, 128 passed, 132 total for text_utility_spec.js, and Tests: 2 failed, 88 passed, 90 total for the topic badges and note actions specs):
| Example | Expected | Received on master |
|---|---|---|
truncate one more than maxLength |
"abcd\u2026" |
"abcdef" |
truncateWidth one character more than maxWidth |
"Twelve cha\u2026" |
"Twelve chars" |
truncateByWords repeated whitespace |
"one two" |
"one two\u2026" |
truncateByWords blank lines |
"first line\n\nsecond line" |
"first line second line\u2026" |
Topic badges, the topic machine-learning |
"machine-learni\u2026" |
"machine-learning" |
Note actions, the preview Done. Thanks |
aria label ending in : 'Done. Thanks' |
aria label ending in : 'Done. Thanks\u2026' |
Green:
yarn jest spec/frontend/lib/utils/text_utility_spec.js: 132 passed. The topic badges and note actions specs: 90 passed. Every spec underspec/frontend/lib/utils/: 109 suites passed.- The specs of the callers of
truncate(),truncateWidth()andtruncateByWords(), with this branch, and withmasterand its own specs: 19 suites passed both times. CE:behaviors/markdown/accessibility,ci/catalog/components/list/ci_resources_list_item,content_editor/extensions/task_item,diffs/components/diff_gutter_avatars,dropzone_input,environments/new_environment_item,import_entities/components/import_target_dropdown,merge_conflicts/utils,notes/components/note_actions,rapid_diffs/app/discussions/diff_gutter_toggle,vue_shared/components/topic_badges,vue_shared/components/users_table/user_avatar. EE:ai/catalog/router/ai_catalog_breadcrumbs,ai/duo_agentic_chat/plugins/start_flow/components/message_tool_start_flow,analytics/contribution_analytics/components/column_chart(thetruncateWidth()caller),environments/new_environment_item,explore/analytics_dashboards/data_sources/code_suggestions_acceptance_by_language,oncall_schedule/rotations/components/rotation_assignee,packages_and_registries/artifact_registry/repositories/components/tag_badges. The two that import generated fixtures ran with the frontend fixtures package published for 7cf59326, two commits before the base. prettier --checkandeslint --max-warnings 0on the four changed files: clean.scripts/lint/commit_linter.rbon the commit: no findings.- To check each assertion, I reverted the
truncate()comparison, changedwidth <= maxWidthto<, removed the early return fromtruncateWidth(), and changed the word count check from>to>=, one at a time;text_utility_spec.jsfailed each time.
I did not run the whole Jest suite: jest --findRelatedTests lists 3588 spec files that reach text_utility.js, and I left those beyond the ones above to the pipeline.
I found this while maintaining gitlab-mcp-server; its upstream-bugs.md records everything done on its dependencies and sibling projects.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

