From 145d6b372283f825ada568e79e89d72a11bf343f Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 16 Apr 2026 19:46:12 +0200 Subject: [PATCH 01/13] Fix `relative-time` error with invalid `navigator.language` `navigator.language` can be the string `"undefined"` in Playwright Firefox (not just the undefined value), causing `Intl.DateTimeFormat` to throw `RangeError: invalid language tag: "undefined"`. The prior fix in #37021 only handled the undefined value. Validate both the `lang` attribute and `navigator.language` through `Intl.Locale` and fall back to `'en'` if both yield invalid tags. Also enhances the global error UI: - Render the error stack trace (hidden, included when copying) - Add a copy-to-clipboard button with icon-swap feedback - Drop the "Open browser console" hint - Expose `copy` i18n key for the button's aria-label Fixes #37239 Co-Authored-By: Claude (Opus 4.7) --- templates/base/head_script.tmpl | 1 + tests/e2e/homepage.test.ts | 7 +++ web_src/js/modules/errors.test.ts | 43 ++++++++++++++++-- web_src/js/modules/errors.ts | 45 ++++++++++++++----- .../js/webcomponents/relative-time.test.ts | 14 ++++++ web_src/js/webcomponents/relative-time.ts | 11 ++--- 6 files changed, 100 insertions(+), 21 deletions(-) create mode 100644 tests/e2e/homepage.test.ts diff --git a/templates/base/head_script.tmpl b/templates/base/head_script.tmpl index 1f21c5bee53d6..7c5ddac1fdc38 100644 --- a/templates/base/head_script.tmpl +++ b/templates/base/head_script.tmpl @@ -19,6 +19,7 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly. sharedWorkerUri: '{{AssetURI "js/eventsource.sharedworker.js"}}', {{/* this global i18n object should only contain general texts. for specialized texts, it should be provided inside the related modules by: (1) API response (2) HTML data-attribute (3) PageData */}} i18n: { + copy: {{ctx.Locale.Tr "copy"}}, copy_success: {{ctx.Locale.Tr "copy_success"}}, copy_error: {{ctx.Locale.Tr "copy_error"}}, error_occurred: {{ctx.Locale.Tr "error.occurred"}}, diff --git a/tests/e2e/homepage.test.ts b/tests/e2e/homepage.test.ts new file mode 100644 index 0000000000000..1e16daf1cb6d8 --- /dev/null +++ b/tests/e2e/homepage.test.ts @@ -0,0 +1,7 @@ +import {test} from '@playwright/test'; +import {assertNoJsError} from './utils.ts'; + +test('homepage renders without errors', async ({page}) => { + await page.goto('/'); + await assertNoJsError(page); +}); diff --git a/web_src/js/modules/errors.test.ts b/web_src/js/modules/errors.test.ts index efa114a88d063..077e75f9db2c5 100644 --- a/web_src/js/modules/errors.test.ts +++ b/web_src/js/modules/errors.test.ts @@ -1,4 +1,4 @@ -import {isGiteaError, showGlobalErrorMessage} from './errors.ts'; +import {isGiteaError, processWindowErrorEvent, showGlobalErrorMessage} from './errors.ts'; test('isGiteaError', () => { expect(isGiteaError('', '')).toBe(true); @@ -21,7 +21,42 @@ test('showGlobalErrorMessage', () => { showGlobalErrorMessage('test msg 2'); showGlobalErrorMessage('test msg 1'); // duplicated - expect(document.body.innerHTML).toContain('>test msg 1 (2)<'); - expect(document.body.innerHTML).toContain('>test msg 2<'); - expect(document.querySelectorAll('.js-global-error').length).toEqual(2); + const errs = document.querySelectorAll('.js-global-error'); + expect(errs.length).toEqual(2); + expect(errs[0].querySelector('.js-global-error-msg')!.textContent).toBe('test msg 1'); + expect(errs[0].querySelector('.js-global-error-count')!.textContent).toBe(' (2)'); + expect(errs[1].querySelector('.js-global-error-msg')!.textContent).toBe('test msg 2'); + expect(errs[1].querySelector('.js-global-error-count')!.textContent).toBe(''); +}); + +test('showGlobalErrorMessage stores stack hidden for copy', () => { + document.body.innerHTML = '
'; + showGlobalErrorMessage('hi', 'error', 'at foo (x:1:1)\nat bar (y:2:2)'); + const stackEl = document.querySelector('.js-global-error-stack')!; + expect(stackEl.tagName).toBe('PRE'); + expect(stackEl.classList.contains('tw-hidden')).toBe(true); + expect(stackEl.textContent).toBe('at foo (x:1:1)\nat bar (y:2:2)'); + expect(document.querySelector('.js-global-error-copy')).toBeTruthy(); +}); + +test('processWindowErrorEvent renders stack trace', () => { + document.body.innerHTML = '
'; + const error = new Error('boom'); + error.stack = `Error: boom\n at fn (${window.location.origin}/assets/js/index.js:1:1)`; + processWindowErrorEvent({error, type: 'error'} as ErrorEvent & PromiseRejectionEvent); + expect(document.querySelector('.js-global-error-msg')!.textContent).toBe('JavaScript error: boom'); + expect(document.querySelector('.js-global-error-stack')!.textContent).toContain('/assets/js/index.js:1:1'); +}); + +test('processWindowErrorEvent falls back to message without stack', () => { + document.body.innerHTML = '
'; + const stacklessError = {message: 'script error'} as Error; + processWindowErrorEvent({ + error: stacklessError, type: 'error', + filename: `${window.location.origin}/assets/js/x.js`, lineno: 5, colno: 10, + } as ErrorEvent & PromiseRejectionEvent); + const msgText = document.querySelector('.js-global-error-msg')!.textContent; + expect(msgText).toContain('JavaScript error: script error'); + expect(msgText).toContain('@ 5:10'); + expect(document.querySelector('.js-global-error-stack')!.textContent).toBe(''); }); diff --git a/web_src/js/modules/errors.ts b/web_src/js/modules/errors.ts index ddda0ebe42b92..4777341519d1c 100644 --- a/web_src/js/modules/errors.ts +++ b/web_src/js/modules/errors.ts @@ -1,8 +1,10 @@ // keep this file lightweight, it's imported into IIFE chunk in bootstrap import {html} from '../utils/html.ts'; +import octiconCheck from '../../../public/assets/img/svg/octicon-check.svg'; +import octiconCopy from '../../../public/assets/img/svg/octicon-copy.svg'; import type {Intent} from '../types.ts'; -export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') { +export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error', stack?: string) { const msgContainer = document.querySelector('.page-content') ?? document.body; if (!msgContainer) { alert(`${msgType}: ${msg}`); @@ -12,14 +14,34 @@ export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') { let msgDiv = msgContainer.querySelector(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`); if (!msgDiv) { const el = document.createElement('div'); - el.innerHTML = html`
`; - msgDiv = el.childNodes[0] as HTMLDivElement; + el.innerHTML = html` +
+
+ + +

+        
+
+ `; + msgDiv = el.firstElementChild as HTMLDivElement; + const copyBtn = msgDiv.querySelector('.js-global-error-copy')!; + copyBtn.innerHTML = octiconCopy; + copyBtn.addEventListener('click', async () => { + const msgText = msgDiv!.querySelector('.js-global-error-msg')!.textContent; + const stackText = msgDiv!.querySelector('.js-global-error-stack')!.textContent; + try { + await navigator.clipboard?.writeText([msgText, stackText].filter(Boolean).join('\n')); + copyBtn.innerHTML = octiconCheck; + setTimeout(() => { copyBtn.innerHTML = octiconCopy }, 1500); + } catch {} // swallow clipboard failures so they don't trigger the global error handler + }); } - // merge duplicated messages into "the message (count)" format - const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1; - msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact); - msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString()); - msgDiv.querySelector('.ui.message')!.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : ''); + const msgCount = Number(msgDiv.getAttribute('data-global-error-msg-count')) + 1; + msgDiv.setAttribute('data-global-error-msg-compact', msgCompact); + msgDiv.setAttribute('data-global-error-msg-count', String(msgCount)); + msgDiv.querySelector('.js-global-error-msg')!.textContent = msg; + msgDiv.querySelector('.js-global-error-count')!.textContent = msgCount > 1 ? ` (${msgCount})` : ''; + if (stack) msgDiv.querySelector('.js-global-error-stack')!.textContent = stack; msgContainer.prepend(msgDiv); } @@ -49,9 +71,8 @@ export function processWindowErrorEvent({error, reason, message, type, filename, // Filter out errors from browser extensions or other non-Gitea scripts. if (!isGiteaError(filename ?? '', err?.stack ?? '')) return; - let msg = err?.message ?? message; - if (lineno) msg += ` (${filename} @ ${lineno}:${colno})`; - const dot = msg.endsWith('.') ? '' : '.'; const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type; - showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`); + let msg = err?.message ?? message; + if (!err?.stack && lineno) msg += ` (${filename} @ ${lineno}:${colno})`; + showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}`, 'error', err?.stack); } diff --git a/web_src/js/webcomponents/relative-time.test.ts b/web_src/js/webcomponents/relative-time.test.ts index 009006c71ba18..c7aef047e8860 100644 --- a/web_src/js/webcomponents/relative-time.test.ts +++ b/web_src/js/webcomponents/relative-time.test.ts @@ -109,6 +109,20 @@ test('respects lang from parent element', async () => { expect(getText(el)).toBe('vor 3 Tagen'); }); +test('falls back when navigator.language is invalid', async () => { + vi.spyOn(navigator, 'language', 'get').mockReturnValue('undefined'); + try { + const el = document.createElement('relative-time'); + el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 1000).toISOString()); + document.body.append(el); + await Promise.resolve(); + expect(getText(el)).toBe('3 minutes ago'); + el.remove(); + } finally { + vi.restoreAllMocks(); + } +}); + test('switches to datetime with P1D threshold', async () => { const el = createRelativeTime(new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), { lang: 'en-US', diff --git a/web_src/js/webcomponents/relative-time.ts b/web_src/js/webcomponents/relative-time.ts index 8a5d98873371f..12ba13bc4c1e9 100644 --- a/web_src/js/webcomponents/relative-time.ts +++ b/web_src/js/webcomponents/relative-time.ts @@ -258,13 +258,14 @@ class RelativeTime extends HTMLElement { } get #lang(): string { - const lang = this.closest('[lang]')?.getAttribute('lang'); - if (lang) { + // navigator.language can be `undefined` or the string `"undefined"` in headless browsers + for (const candidate of [this.closest('[lang]')?.getAttribute('lang'), navigator.language]) { + if (!candidate) continue; try { - return new Intl.Locale(lang).toString(); - } catch { /* invalid locale, fall through */ } + return String(new Intl.Locale(candidate)); + } catch {} } - return navigator.language ?? 'en'; + return 'en'; } get second(): 'numeric' | '2-digit' | undefined { From 6bba33a1df0993368d2bc38b7a70848f5d7f29bf Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 16 Apr 2026 19:53:49 +0200 Subject: [PATCH 02/13] Add margin and green tint to copy-success feedback Co-Authored-By: Claude (Opus 4.7) --- web_src/js/modules/errors.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/web_src/js/modules/errors.ts b/web_src/js/modules/errors.ts index 4777341519d1c..c5315638eec51 100644 --- a/web_src/js/modules/errors.ts +++ b/web_src/js/modules/errors.ts @@ -18,7 +18,7 @@ export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error', s
- +

         
@@ -32,7 +32,11 @@ export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error', s try { await navigator.clipboard?.writeText([msgText, stackText].filter(Boolean).join('\n')); copyBtn.innerHTML = octiconCheck; - setTimeout(() => { copyBtn.innerHTML = octiconCopy }, 1500); + copyBtn.classList.replace('tw-text-inherit', 'tw-text-green'); + setTimeout(() => { + copyBtn.innerHTML = octiconCopy; + copyBtn.classList.replace('tw-text-green', 'tw-text-inherit'); + }, 1500); } catch {} // swallow clipboard failures so they don't trigger the global error handler }); } From 986524b586c036b8fbb53154b22155dc96653a24 Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 16 Apr 2026 20:20:53 +0200 Subject: [PATCH 03/13] Refactor showGlobalError to accept Error + options Rename `showGlobalErrorMessage` to `showGlobalError`, accept an `Error` as the first argument, and move the `"JavaScript error:"` / `"JavaScript promise rejection:"` prefix into the function. Replace raw `navigator.clipboard.writeText` with `clippie` and guard rapid clicks from stacking reset timeouts. Co-Authored-By: Claude (Opus 4.7) --- web_src/js/bootstrap.ts | 4 +-- web_src/js/features/common-page.ts | 8 +++--- web_src/js/modules/errors.test.ts | 29 +++++++++++++------- web_src/js/modules/errors.ts | 43 ++++++++++++++++++++---------- 4 files changed, 55 insertions(+), 29 deletions(-) diff --git a/web_src/js/bootstrap.ts b/web_src/js/bootstrap.ts index f88f4900637cc..76639531bff7f 100644 --- a/web_src/js/bootstrap.ts +++ b/web_src/js/bootstrap.ts @@ -1,14 +1,14 @@ // DO NOT IMPORT window.config HERE! // to make sure the error handler always works, we should never import `window.config`, because // some user's custom template breaks it. -import {showGlobalErrorMessage, processWindowErrorEvent} from './modules/errors.ts'; +import {showGlobalError, processWindowErrorEvent} from './modules/errors.ts'; // A module should not be imported twice, otherwise there will be bugs when a module has its internal states. // A real example is "generateElemId" in "utils/dom.ts", if it is imported twice in different module scopes, // It will generate duplicate IDs (ps: don't try to use "random" to fix, it is just a real example to show the importance of "do not import a module twice") if (!window._globalHandlerErrors?._inited) { if (!window.config) { - showGlobalErrorMessage(`Gitea JavaScript code couldn't run correctly, please check your custom templates`); + showGlobalError(new Error(`Gitea JavaScript code couldn't run correctly, please check your custom templates`), {noStack: true}); } // we added an event handler for window error at the very beginning of