Skip to content

Commit 3362494

Browse files
committed
AG-57807 Fix empty user rules editor after update
1 parent 496161c commit 3362494

9 files changed

Lines changed: 195 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9797
### Fixed
9898

9999
- Short-lived duplicated elements on pages when CSS hits counting is enabled.
100+
- User rules saved with Windows line endings appeared empty in the editor after
101+
updating to 5.5 [#3598].
100102

101103
[5.5 patch 1]: https://github.com/AdguardTeam/AdguardBrowserExtension/compare/v5.5.0.6...v5.5.1.0
104+
[#3598]: https://github.com/AdguardTeam/AdguardBrowserExtension/issues/3598
102105

103106
## [5.4 patch 2] - 2026-05-14
104107

Extension/src/background/api/filters/userrules.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { notifier } from '../../notifier';
3232
import { settingsStorage, editorStorage } from '../../storages';
3333
import { FiltersStoragesAdapter } from '../../storages/filters-adapter';
3434
import { getZodErrorMessage } from '../../../common/error';
35+
import { normalizeUserRulesLineEndings } from '../../../common/utils/user-rules';
3536
import { LineScanner } from '../../utils';
3637

3738
/**
@@ -42,10 +43,13 @@ export class UserRulesApi {
4243
* Parses data from user rules list.
4344
* If it's undefined or if it's an initialization after installation - sets
4445
* empty user rules list.
46+
* Existing user rules line endings are normalized to Unix-style line feeds.
4547
*
4648
* @param isInstall Is this is an installation initialization or not.
4749
*/
4850
public static async init(isInstall: boolean): Promise<void> {
51+
let userRules: FilterList | undefined;
52+
4953
try {
5054
// Check if user filter is present in the storage to avoid errors.
5155
if (!(await FiltersStoragesAdapter.has(AntiBannerFiltersId.UserFilterId))) {
@@ -55,7 +59,7 @@ export class UserRulesApi {
5559
);
5660
} else {
5761
// In this case zod will validate the data.
58-
await FiltersStoragesAdapter.get(AntiBannerFiltersId.UserFilterId);
62+
userRules = await FiltersStoragesAdapter.get(AntiBannerFiltersId.UserFilterId);
5963
}
6064
} catch (e) {
6165
if (!isInstall) {
@@ -66,6 +70,26 @@ export class UserRulesApi {
6670
FilterList.createEmpty(),
6771
);
6872
}
73+
74+
if (!userRules) {
75+
return;
76+
}
77+
78+
const originalUserRules = userRules.getOriginalContent();
79+
const normalizedUserRules = normalizeUserRulesLineEndings(originalUserRules);
80+
81+
if (normalizedUserRules === originalUserRules) {
82+
return;
83+
}
84+
85+
try {
86+
await FiltersStoragesAdapter.set(
87+
AntiBannerFiltersId.UserFilterId,
88+
normalizedUserRules,
89+
);
90+
} catch (e) {
91+
logger.warn('[ext.UserRulesApi.init]: cannot normalize user rules line endings, keeping persisted rules unchanged. Origin error:', getZodErrorMessage(e));
92+
}
6993
}
7094

7195
/**
@@ -221,7 +245,10 @@ export class UserRulesApi {
221245
* @param rulesText Rule text.
222246
*/
223247
public static async setUserRules(rulesText: string): Promise<void> {
224-
await FiltersStoragesAdapter.set(AntiBannerFiltersId.UserFilterId, rulesText);
248+
await FiltersStoragesAdapter.set(
249+
AntiBannerFiltersId.UserFilterId,
250+
normalizeUserRulesLineEndings(rulesText),
251+
);
225252

226253
notifier.notifyListeners(NotifierType.UserFilterUpdated);
227254
}

Extension/src/common/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ export enum FiltersUpdateTime {
246246
}
247247

248248
export const NEWLINE_CHAR_UNIX = '\n';
249-
export const NEWLINE_CHAR_REGEX = /\r?\n/;
249+
export const NEWLINE_CHAR_REGEX = /\r\n|\r|\n/;
250250

251251
export const OPTIONS_PAGE = 'pages/options.html';
252252

Extension/src/common/utils/user-rules.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,17 @@ export type MergeImportedRulesResult = {
3535
addedCount: number;
3636
};
3737

38+
/**
39+
* Normalizes user rules line endings to Unix-style line feeds.
40+
*
41+
* @param rulesText User rules text.
42+
*
43+
* @returns User rules text with normalized line endings.
44+
*/
45+
export function normalizeUserRulesLineEndings(rulesText: string): string {
46+
return rulesText.split(NEWLINE_CHAR_REGEX).join(NEWLINE_CHAR_UNIX);
47+
}
48+
3849
/**
3950
* Parses raw imported rule text and merges only the genuinely-new, non-blank
4051
* rules into the existing rules string.

Extension/src/pages/common/components/Editor/editor-handle.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,15 @@ export const createEditorHandle = (
139139
* @param value The new content to set.
140140
*/
141141
setValue(value: string) {
142+
const changes = view.state.changes({
143+
from: 0,
144+
to: view.state.doc.length,
145+
insert: value,
146+
});
147+
142148
view.dispatch({
143-
changes: { from: 0, to: view.state.doc.length, insert: value },
144-
selection: { anchor: value.length },
149+
changes,
150+
selection: { anchor: changes.newLength },
145151
annotations: isolateHistory.of('full'),
146152
});
147153
},
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Copyright (c) 2015-2026 Adguard Software Ltd.
3+
*
4+
* @file
5+
* This file is part of AdGuard Browser Extension (https://github.com/AdguardTeam/AdguardBrowserExtension).
6+
*
7+
* AdGuard Browser Extension is free software: you can redistribute it and/or modify
8+
* it under the terms of the GNU General Public License as published by
9+
* the Free Software Foundation, either version 3 of the License, or
10+
* (at your option) any later version.
11+
*
12+
* AdGuard Browser Extension is distributed in the hope that it will be useful,
13+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
15+
* See the GNU General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU General Public License
18+
* along with AdGuard Browser Extension. If not, see <http://www.gnu.org/licenses/>.
19+
*/
20+
21+
import { type Storage } from 'webextension-polyfill';
22+
import {
23+
afterEach,
24+
beforeEach,
25+
describe,
26+
expect,
27+
it,
28+
vi,
29+
} from 'vitest';
30+
31+
import { UserRulesApi } from '../../../../../Extension/src/background/api/filters/userrules';
32+
import { FiltersStoragesAdapter } from '../../../../../Extension/src/background/storages/filters-adapter';
33+
import { hybridStorage } from '../../../../../Extension/src/background/storages';
34+
import { AntiBannerFiltersId } from '../../../../../Extension/src/common/constants';
35+
import { mockLocalStorage } from '../../../../helpers';
36+
37+
describe('UserRulesApi', () => {
38+
let localStorage: Storage.StorageArea;
39+
40+
beforeEach(() => {
41+
localStorage = mockLocalStorage();
42+
});
43+
44+
afterEach(async () => {
45+
await localStorage.clear();
46+
await hybridStorage.clear();
47+
});
48+
49+
it('normalizes line endings when user rules are saved', async () => {
50+
await UserRulesApi.setUserRules('||a.com^\r\n||b.com^\r||c.com^');
51+
52+
await expect(UserRulesApi.getOriginalUserRules())
53+
.resolves.toBe('||a.com^\n||b.com^\n||c.com^');
54+
});
55+
56+
it('normalizes line endings in existing user rules during initialization', async () => {
57+
await FiltersStoragesAdapter.set(
58+
AntiBannerFiltersId.UserFilterId,
59+
'||a.com^\r\n||b.com^',
60+
);
61+
62+
await expect(UserRulesApi.getOriginalUserRules())
63+
.resolves.toBe('||a.com^\r\n||b.com^');
64+
65+
await UserRulesApi.init(false);
66+
67+
await expect(UserRulesApi.getOriginalUserRules())
68+
.resolves.toBe('||a.com^\n||b.com^');
69+
});
70+
71+
it('preserves existing rules when initialization cannot save normalized content', async () => {
72+
await FiltersStoragesAdapter.set(
73+
AntiBannerFiltersId.UserFilterId,
74+
'||a.com^\r\n||b.com^',
75+
);
76+
const setSpy = vi.spyOn(FiltersStoragesAdapter, 'set')
77+
.mockRejectedValueOnce(new Error('Storage write failed'));
78+
79+
try {
80+
await UserRulesApi.init(false);
81+
82+
await expect(UserRulesApi.getOriginalUserRules())
83+
.resolves.toBe('||a.com^\r\n||b.com^');
84+
} finally {
85+
setSpy.mockRestore();
86+
}
87+
});
88+
});

tests/src/common/utils/user-rules.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,14 @@ import {
2424
expect,
2525
} from 'vitest';
2626

27-
import { mergeImportedRules } from '../../../../Extension/src/common/utils/user-rules';
27+
import { mergeImportedRules, normalizeUserRulesLineEndings } from '../../../../Extension/src/common/utils/user-rules';
28+
29+
describe('normalizeUserRulesLineEndings', () => {
30+
it('normalizes mixed line endings to Unix-style line feeds', () => {
31+
expect(normalizeUserRulesLineEndings('||a.com^\r\n||b.com^\r||c.com^\n'))
32+
.toBe('||a.com^\n||b.com^\n||c.com^\n');
33+
});
34+
});
2835

2936
describe('mergeImportedRules', () => {
3037
it('appends only genuinely-new rules and preserves existing order', () => {

tests/src/pages/common/components/Editor/Editor.test.tsx

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ import {
3333
it,
3434
vi,
3535
} from 'vitest';
36+
import {
37+
EditorState,
38+
type Extension,
39+
type TransactionSpec,
40+
} from '@codemirror/state';
41+
42+
import { initEditor as initRulesEditor } from '@adguard/rules-editor';
3643

3744
import { Editor } from '../../../../../../Extension/src/pages/common/components/Editor/Editor';
3845

@@ -74,24 +81,26 @@ describe('Editor component', () => {
7481
localStorage.clear();
7582
});
7683

77-
const createFakeView = () => ({
78-
state: {
79-
doc: {
80-
toString: () => 'fake content',
81-
length: 12,
82-
lineAt: () => ({ number: 1, from: 0, length: 12 }),
83-
lines: 1,
84-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
85-
line: (_n: number) => ({ from: 0, length: 12 }),
84+
const createFakeView = () => {
85+
const config = vi.mocked(initRulesEditor).mock.lastCall?.[2] as { extensions?: Extension[] };
86+
let state = EditorState.create({
87+
doc: 'fake content',
88+
extensions: config.extensions,
89+
});
90+
91+
return {
92+
get state() {
93+
return state;
8694
},
87-
selection: { main: { head: 0 } },
88-
readOnly: false,
89-
},
90-
dispatch: mockDispatch,
91-
destroy: mockDestroy,
92-
focus: mockFocus,
93-
lineWrapping: false,
94-
});
95+
dispatch: (...specs: TransactionSpec[]) => {
96+
mockDispatch(...specs);
97+
state = state.update(...specs).state;
98+
},
99+
destroy: mockDestroy,
100+
focus: mockFocus,
101+
lineWrapping: false,
102+
};
103+
};
95104

96105
const renderEditor = (props = {}) => render(React.createElement(Editor as any, {
97106
name: 'test-editor',
@@ -204,11 +213,7 @@ describe('Editor component', () => {
204213
resolveInit(createFakeView());
205214
});
206215

207-
// Buffered write is replayed onto the view via a dispatch.
208-
const replayed = mockDispatch.mock.calls.some(
209-
([tr]) => tr?.changes?.insert === 'content set before ready',
210-
);
211-
expect(replayed).toBe(true);
216+
expect(editorRef.current.getValue()).toBe('content set before ready');
212217

213218
rendered!.unmount();
214219
});

tests/src/pages/common/components/Editor/editor-handle.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ describe('createEditorHandle', () => {
7070
expect(handle.getCursor()).toEqual({ line: 1, ch: 'new content'.length });
7171
});
7272

73+
it('loads CRLF content and places the cursor at the normalized document end', () => {
74+
const handle = createEditorHandle(view, { wrap, readOnly });
75+
76+
expect(() => handle.setValue('first line\r\nsecond line')).not.toThrow();
77+
expect(handle.getValue()).toBe('first line\nsecond line');
78+
expect(handle.getCursor()).toEqual({ line: 2, ch: 'second line'.length });
79+
});
80+
7381
it('round-trips the cursor (1-based line, 0-based ch)', () => {
7482
const handle = createEditorHandle(view, { wrap, readOnly });
7583
handle.setCursor({ line: 2, ch: 3 });
@@ -142,6 +150,18 @@ describe('createDeferredEditorHandle', () => {
142150
view.destroy();
143151
});
144152

153+
it('replays buffered CRLF content once attached', () => {
154+
const deferred = createDeferredEditorHandle();
155+
deferred.handle.setValue('first line\r\nsecond line');
156+
157+
const view = createView();
158+
159+
expect(() => deferred.attach(view, { wrap, readOnly })).not.toThrow();
160+
expect(deferred.handle.getValue()).toBe('first line\nsecond line');
161+
expect(deferred.handle.getCursor()).toEqual({ line: 2, ch: 'second line'.length });
162+
view.destroy();
163+
});
164+
145165
it('replays setValue before setCursor so the cursor is preserved', () => {
146166
const deferred = createDeferredEditorHandle();
147167
deferred.handle.setValue('alpha\nbeta');

0 commit comments

Comments
 (0)