Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
145d6b3
Fix `relative-time` error with invalid `navigator.language`
silverwind Apr 16, 2026
6bba33a
Add margin and green tint to copy-success feedback
silverwind Apr 16, 2026
986524b
Refactor showGlobalError to accept Error + options
silverwind Apr 16, 2026
991e3d9
Revert showGlobalErrorMessage API to string + optional stack
silverwind Apr 16, 2026
7a3668c
Address review feedback
silverwind Apr 16, 2026
fed4ff3
Drop redundant stack-for-copy test
silverwind Apr 16, 2026
7d99c9d
Shrink diff against main
silverwind Apr 16, 2026
60a6352
Rework error UI to use <details>/<summary>/<pre><code>
silverwind Apr 17, 2026
708ec77
Merge branch 'main' into relative-time-invalid-lang
silverwind Apr 17, 2026
131b7cd
fix
wxiaoguang Apr 18, 2026
c3a286e
Merge branch 'main' into relative-time-invalid-lang
silverwind Apr 19, 2026
e90c234
Address review: restore console hint, drop homepage e2e, simplify sta…
silverwind Apr 19, 2026
aaca6e7
Address review: drop text-center/whitespace, revert to simple append
silverwind Apr 20, 2026
03ebb69
Merge remote-tracking branch 'origin/main' into relative-time-invalid…
silverwind Apr 20, 2026
7cd7a9e
fix lint
silverwind Apr 20, 2026
8ec30f7
fix lint
wxiaoguang Apr 21, 2026
26bf1e4
Merge branch 'main' into relative-time-invalid-lang
silverwind Apr 21, 2026
ca6f7df
Merge branch 'main' into relative-time-invalid-lang
lunny Apr 21, 2026
ab30e9f
Merge branch 'main' into relative-time-invalid-lang
GiteaBot Apr 21, 2026
4a25a35
Merge branch 'main' into relative-time-invalid-lang
GiteaBot Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions templates/base/head_script.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
Expand Down
7 changes: 7 additions & 0 deletions tests/e2e/homepage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {test} from '@playwright/test';
import {assertNoJsError} from './utils.ts';

test('homepage renders without errors', async ({page}) => {
await page.goto('/');
Comment thread
silverwind marked this conversation as resolved.
Outdated
await assertNoJsError(page);
});
Comment thread
silverwind marked this conversation as resolved.
Outdated
4 changes: 2 additions & 2 deletions web_src/js/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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 <script> of page head the
// handler calls `_globalHandlerErrors.push` (array method) to record all errors occur before
Expand Down
8 changes: 4 additions & 4 deletions web_src/js/features/common-page.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {GET, POST} from '../modules/fetch.ts';
import {showGlobalErrorMessage} from '../modules/errors.ts';
import {showGlobalError} from '../modules/errors.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
Expand Down Expand Up @@ -156,14 +156,14 @@ export function checkAppUrl() {
if (curUrl.startsWith(appUrl) || `${curUrl}/` === appUrl) {
return;
}
showGlobalErrorMessage(`The detected web site URL is "${appUrl}", it's unlikely matching the site config.
Mismatched app.ini ROOT_URL or reverse proxy "Host/X-Forwarded-Proto" config might cause wrong URL links for web UI/mail content/webhook notification/OAuth2 sign-in.`, 'warning');
showGlobalError(new Error(`The detected web site URL is "${appUrl}", it's unlikely matching the site config.
Mismatched app.ini ROOT_URL or reverse proxy "Host/X-Forwarded-Proto" config might cause wrong URL links for web UI/mail content/webhook notification/OAuth2 sign-in.`), {msgType: 'warning', noStack: true});
}

export function checkAppUrlScheme() {
const curUrl = window.location.href;
// some users visit "http://domain" while appUrl is "https://domain", COOKIE_SECURE makes it impossible to sign in
if (curUrl.startsWith('http:') && appUrl.startsWith('https:')) {
showGlobalErrorMessage(`This instance is configured to run under HTTPS (by ROOT_URL config), you are accessing by HTTP. Mismatched scheme might cause problems for sign-in/sign-up.`, 'warning');
showGlobalError(new Error(`This instance is configured to run under HTTPS (by ROOT_URL config), you are accessing by HTTP. Mismatched scheme might cause problems for sign-in/sign-up.`), {msgType: 'warning', noStack: true});
}
}
62 changes: 54 additions & 8 deletions web_src/js/modules/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {isGiteaError, showGlobalErrorMessage} from './errors.ts';
import {isGiteaError, processWindowErrorEvent, showGlobalError} from './errors.ts';

test('isGiteaError', () => {
expect(isGiteaError('', '')).toBe(true);
Expand All @@ -15,13 +15,59 @@ test('isGiteaError', () => {
expect(isGiteaError('http://localhost:3000/assets/js/index.js', `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false);
});

test('showGlobalErrorMessage', () => {
test('showGlobalError', () => {
document.body.innerHTML = '<div class="page-content"></div>';
showGlobalErrorMessage('test msg 1');
showGlobalErrorMessage('test msg 2');
showGlobalErrorMessage('test msg 1'); // duplicated
showGlobalError(new Error('test msg 1'), {noStack: true});
showGlobalError(new Error('test msg 2'), {noStack: true});
showGlobalError(new Error('test msg 1'), {noStack: true}); // 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('JavaScript error: test msg 1');
expect(errs[0].querySelector('.js-global-error-count')!.textContent).toBe(' (2)');
expect(errs[1].querySelector('.js-global-error-msg')!.textContent).toBe('JavaScript error: test msg 2');
expect(errs[1].querySelector('.js-global-error-count')!.textContent).toBe('');
});

test('showGlobalError stores stack hidden for copy', () => {
document.body.innerHTML = '<div class="page-content"></div>';
const err = new Error('hi');
err.stack = 'at foo (x:1:1)\nat bar (y:2:2)';
showGlobalError(err);
const stackEl = document.querySelector<HTMLElement>('.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('showGlobalError noStack hides stack', () => {
document.body.innerHTML = '<div class="page-content"></div>';
const err = new Error('warning');
err.stack = 'stack content';
showGlobalError(err, {msgType: 'warning', noStack: true});
expect(document.querySelector('.js-global-error-msg')!.textContent).toBe('warning');
expect(document.querySelector('.js-global-error-stack')!.textContent).toBe('');
});

test('processWindowErrorEvent renders stack trace', () => {
document.body.innerHTML = '<div class="page-content"></div>';
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 = '<div class="page-content"></div>';
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('');
});
64 changes: 52 additions & 12 deletions web_src/js/modules/errors.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
// keep this file lightweight, it's imported into IIFE chunk in bootstrap
import {clippie} from 'clippie';
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';
Comment thread
silverwind marked this conversation as resolved.
Outdated
import type {Intent} from '../types.ts';

Comment thread
silverwind marked this conversation as resolved.
Outdated
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
type ShowGlobalErrorOpts = {
msgType?: Intent,
type?: 'error' | 'unhandledrejection',
noStack?: boolean,
};

export function showGlobalError(err: Error, {msgType = 'error', type = 'error', noStack = false}: ShowGlobalErrorOpts = {}) {
const kind = type === 'unhandledrejection' ? 'promise rejection' : 'error';
const msg = msgType === 'error' ? `JavaScript ${kind}: ${err.message}` : err.message;
const stack = noStack ? undefined : err.stack;
const msgContainer = document.querySelector('.page-content') ?? document.body;
if (!msgContainer) {
alert(`${msgType}: ${msg}`);
Expand All @@ -12,14 +24,38 @@ export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
if (!msgDiv) {
const el = document.createElement('div');
el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`;
msgDiv = el.childNodes[0] as HTMLDivElement;
el.innerHTML = html`
<div class="ui container js-global-error tw-my-[--page-spacing]">
<div class="ui ${msgType} message tw-flex tw-justify-center tw-items-center">
<span class="js-global-error-msg"></span><span class="js-global-error-count"></span>
Comment thread
silverwind marked this conversation as resolved.
Outdated
<button type="button" class="js-global-error-copy interact-bg tw-text-inherit tw-p-2 tw-rounded tw-ml-1" aria-label="${window.config.i18n.copy}"></button>
Comment thread
silverwind marked this conversation as resolved.
Outdated
<pre class="js-global-error-stack tw-hidden"></pre>
</div>
</div>
`;
msgDiv = el.firstElementChild as HTMLDivElement;
const copyBtn = msgDiv.querySelector<HTMLButtonElement>('.js-global-error-copy')!;
copyBtn.innerHTML = octiconCopy;
let resetTimeout: ReturnType<typeof setTimeout> | undefined;
copyBtn.addEventListener('click', async () => {
const msgText = msgDiv!.querySelector('.js-global-error-msg')!.textContent;
const stackText = msgDiv!.querySelector('.js-global-error-stack')!.textContent;
if (!await clippie([msgText, stackText].filter(Boolean).join('\n'))) return;
copyBtn.innerHTML = octiconCheck;
copyBtn.classList.replace('tw-text-inherit', 'tw-text-green');
clearTimeout(resetTimeout);
resetTimeout = setTimeout(() => {
copyBtn.innerHTML = octiconCopy;
copyBtn.classList.replace('tw-text-green', 'tw-text-inherit');
}, 1500);
});
}
// 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;
Comment thread
silverwind marked this conversation as resolved.
Outdated
msgContainer.prepend(msgDiv);
}

Expand All @@ -46,12 +82,16 @@ export function processWindowErrorEvent({error, reason, message, type, filename,
if (window.config.runModeIsProd) return;
}

// Filter out errors from browser extensions or other non-Gitea scripts.
if (!isGiteaError(filename ?? '', err?.stack ?? '')) return;

const eventType = type === 'unhandledrejection' ? 'unhandledrejection' : 'error';
if (err instanceof Error && err.stack) {
showGlobalError(err, {type: eventType});
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.`);
const wrapped = new Error(msg);
wrapped.stack = err?.stack;
showGlobalError(wrapped, {type: eventType});
}
14 changes: 14 additions & 0 deletions web_src/js/webcomponents/relative-time.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 6 additions & 5 deletions web_src/js/webcomponents/relative-time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading