Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
48 changes: 48 additions & 0 deletions src/components/tableOfContents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Tests for the safe hash-decoding logic used in TableOfContents.
*
* The component calls decodeURIComponent on the URL fragment so that element
* IDs containing non-ASCII characters can be found. When the fragment contains
* invalid percent-encoding (e.g. an unsubstituted template variable such as
* `%SENTRY_ENVIRONMENT`, where `%S` is not valid hex), decodeURIComponent
* throws a URIError. The component wraps the call in a try-catch and falls
* back to the raw hash value – this file verifies that contract at the logic
* level without requiring a DOM environment.
*/

import {describe, expect, it} from 'vitest';

/** Mirrors the safe-decode logic in tableOfContents.tsx */
function safeDecodeHash(hash: string): string {
const raw = hash.startsWith('#') ? hash.slice(1) : hash;
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}

describe('safeDecodeHash', () => {
it('decodes a valid percent-encoded fragment', () => {
expect(safeDecodeHash('#hello%20world')).toBe('hello world');
});

it('returns the raw value for a plain fragment with no encoding', () => {
expect(safeDecodeHash('#my-section')).toBe('my-section');
});

it('falls back to the raw value when the fragment contains invalid percent-encoding', () => {
// %SENTRY_ENVIRONMENT is an unsubstituted template variable; %S is not
// valid hex so decodeURIComponent would throw a URIError without the guard.
expect(() => decodeURIComponent('dsn%SENTRY_ENVIRONMENT')).toThrow(URIError);
expect(safeDecodeHash('#dsn%SENTRY_ENVIRONMENT')).toBe('dsn%SENTRY_ENVIRONMENT');
});

it('handles an empty hash gracefully', () => {
expect(safeDecodeHash('#')).toBe('');
});

it('decodes non-ASCII characters in a fragment', () => {
expect(safeDecodeHash('#%E4%B8%AD%E6%96%87')).toBe('中文');
});
});
10 changes: 9 additions & 1 deletion src/components/tableOfContents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,15 @@ export function TableOfContents({ignoreIds = []}: Props) {
}
hasScrolledToHash.current = true;
requestAnimationFrame(() => {
const id = decodeURIComponent(hash.slice(1));
let id: string;
try {
id = decodeURIComponent(hash.slice(1));
} catch {
// hash contains invalid percent-encoding (e.g. an unsubstituted template
// variable like %SENTRY_ENVIRONMENT). Fall back to the raw value so we
// still attempt to scroll rather than throwing a URIError.
id = hash.slice(1);
}
document.getElementById(id)?.scrollIntoView();
});
}, [treeItems]);
Expand Down
Loading