mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-01 01:29:31 +08:00
feat(core): improve mobile perf (#15317)
#### PR Dependency Tree * **PR #15317** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Virtualized mobile navigation with shell navigation and interactive swipe menus; coordinated mobile back handling with interactive phases/state restoration. * Added shared auth request proxy and message-port based token handling across mobile and worker flows. * **Bug Fixes** * Hydrated remote worker error stacks for calls and observable errors. * Improved SQLite FTS/indexer and nbstore optional text handling; refined docs-search ref parsing and notification loading/retry. * **Refactor / UX** * Modal focus-preservation and pointer behavior updates; improved mobile menu controls and back gesture plugins. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
export { DocsSearchService } from './services/docs-search';
|
||||
export {
|
||||
DocsSearchService,
|
||||
type IndexedDocReference,
|
||||
} from './services/docs-search';
|
||||
|
||||
import { type Framework } from '@toeverything/infra';
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
/* eslint-disable rxjs/finnish */
|
||||
|
||||
import { Framework } from '@toeverything/infra';
|
||||
import { firstValueFrom, of } from 'rxjs';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DocsSearchService } from './docs-search';
|
||||
|
||||
describe('DocsSearchService refs', () => {
|
||||
it('skips malformed reference items without poisoning the result', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const indexer = {
|
||||
state$: of({ indexing: 0, errorMessage: null }),
|
||||
search$: vi.fn().mockReturnValue(
|
||||
of({
|
||||
nodes: [
|
||||
{
|
||||
fields: {
|
||||
docId: 'source',
|
||||
ref: [
|
||||
JSON.stringify({ docId: 'target' }),
|
||||
'{invalid-json',
|
||||
JSON.stringify({ docId: 42 }),
|
||||
7,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
};
|
||||
const workspaceService = { workspace: { engine: { indexer } } };
|
||||
const docsService = {
|
||||
list: {
|
||||
doc$: (id: string) => ({
|
||||
value:
|
||||
id === 'target'
|
||||
? { id, title$: { value: 'Target document' } }
|
||||
: null,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const framework = new Framework();
|
||||
framework.service(
|
||||
DocsSearchService,
|
||||
() =>
|
||||
new DocsSearchService(
|
||||
workspaceService as unknown as ConstructorParameters<
|
||||
typeof DocsSearchService
|
||||
>[0],
|
||||
docsService as unknown as ConstructorParameters<
|
||||
typeof DocsSearchService
|
||||
>[1]
|
||||
)
|
||||
);
|
||||
const service = framework.provider().get(DocsSearchService);
|
||||
|
||||
await expect(
|
||||
firstValueFrom(service.watchRefsFrom('source'))
|
||||
).resolves.toEqual([
|
||||
{ docId: 'target', title: 'Target document', params: undefined },
|
||||
]);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[docs-search] skipped malformed references',
|
||||
{ count: 3 }
|
||||
);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('groups one batch of references by source document', async () => {
|
||||
const indexer = {
|
||||
state$: of({ indexing: 0, errorMessage: null }),
|
||||
search$: vi.fn().mockReturnValue(
|
||||
of({
|
||||
nodes: [
|
||||
{
|
||||
fields: {
|
||||
docId: 'a',
|
||||
ref: JSON.stringify({ docId: 'target-a' }),
|
||||
},
|
||||
},
|
||||
{
|
||||
fields: {
|
||||
docId: ['b'],
|
||||
ref: [
|
||||
JSON.stringify({ docId: 'target-b' }),
|
||||
JSON.stringify({ docId: 'b' }),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
};
|
||||
const docs = new Map([
|
||||
['target-a', 'Target A'],
|
||||
['target-b', 'Target B'],
|
||||
]);
|
||||
const framework = new Framework();
|
||||
framework.service(
|
||||
DocsSearchService,
|
||||
() =>
|
||||
new DocsSearchService(
|
||||
{ workspace: { engine: { indexer } } } as never,
|
||||
{
|
||||
list: {
|
||||
doc$: (id: string) => ({
|
||||
value: docs.has(id)
|
||||
? { id, title$: { value: docs.get(id) } }
|
||||
: null,
|
||||
}),
|
||||
},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
const service = framework.provider().get(DocsSearchService);
|
||||
|
||||
const grouped = await firstValueFrom(
|
||||
service.watchRefsBySourceFrom(['a', 'b'])
|
||||
);
|
||||
expect([...grouped]).toEqual([
|
||||
['a', [{ docId: 'target-a', title: 'Target A', params: undefined }]],
|
||||
['b', [{ docId: 'target-b', title: 'Target B', params: undefined }]],
|
||||
]);
|
||||
expect(indexer.search$).toHaveBeenCalledTimes(1);
|
||||
expect(indexer.search$.mock.calls[0][2]).toMatchObject({
|
||||
pagination: { limit: Infinity },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { toDocSearchParams } from '@affine/core/modules/navigation';
|
||||
import type { IndexerPreferOptions, IndexerSyncState } from '@affine/nbstore';
|
||||
import type { ReferenceParams } from '@blocksuite/affine/model';
|
||||
import {
|
||||
type ReferenceParams,
|
||||
ReferenceParamsSchema,
|
||||
} from '@blocksuite/affine/model';
|
||||
import { fromPromise, LiveData, Service } from '@toeverything/infra';
|
||||
import { isEmpty, omit } from 'lodash-es';
|
||||
import {
|
||||
@@ -16,6 +19,61 @@ import { normalizeSearchText } from '../../../utils/normalize-search-text';
|
||||
import type { DocsService } from '../../doc/services/docs';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
|
||||
const IndexedReferenceSchema = ReferenceParamsSchema.extend({
|
||||
docId: z.string().min(1),
|
||||
});
|
||||
|
||||
function parseIndexedReferences(value: unknown) {
|
||||
const payloads =
|
||||
typeof value === 'string'
|
||||
? [value]
|
||||
: Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
const refs: ({ docId: string } & ReferenceParams)[] = [];
|
||||
let malformed = Array.isArray(value)
|
||||
? value.length - payloads.length
|
||||
: typeof value === 'string'
|
||||
? 0
|
||||
: 1;
|
||||
|
||||
for (const payload of payloads) {
|
||||
try {
|
||||
const result = IndexedReferenceSchema.safeParse(JSON.parse(payload));
|
||||
if (result.success) {
|
||||
refs.push(result.data);
|
||||
} else {
|
||||
malformed++;
|
||||
}
|
||||
} catch {
|
||||
malformed++;
|
||||
}
|
||||
}
|
||||
return { refs, malformed };
|
||||
}
|
||||
|
||||
export type IndexedDocReference = {
|
||||
title: string;
|
||||
docId: string;
|
||||
params?: ReturnType<typeof toDocSearchParams>;
|
||||
};
|
||||
|
||||
const stringField = (value: unknown) =>
|
||||
typeof value === 'string'
|
||||
? value
|
||||
: Array.isArray(value) && typeof value[0] === 'string'
|
||||
? value[0]
|
||||
: null;
|
||||
|
||||
const equalReferenceSets = (
|
||||
previous: readonly IndexedDocReference[],
|
||||
current: readonly IndexedDocReference[]
|
||||
) => {
|
||||
if (previous.length !== current.length) return false;
|
||||
const currentIds = new Set(current.map(reference => reference.docId));
|
||||
return previous.every(reference => currentIds.has(reference.docId));
|
||||
};
|
||||
|
||||
export class DocsSearchService extends Service {
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
@@ -176,6 +234,29 @@ export class DocsSearchService extends Service {
|
||||
return of([]);
|
||||
}
|
||||
|
||||
return this.watchRefsBySourceFrom(docIds).pipe(
|
||||
map((refsBySource): IndexedDocReference[] => {
|
||||
const refs = Array.from(refsBySource.values()).flat();
|
||||
return Array.from(
|
||||
new Map(
|
||||
refs
|
||||
.filter(ref => !docIds.includes(ref.docId))
|
||||
.map(ref => [ref.docId, ref])
|
||||
).values()
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged((previous, current) =>
|
||||
equalReferenceSets(previous, current)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
watchRefsBySourceFrom(ids: string | string[]) {
|
||||
const docIds = Array.isArray(ids) ? ids : [ids];
|
||||
if (docIds.length === 0) {
|
||||
return of(new Map<string, IndexedDocReference[]>());
|
||||
}
|
||||
|
||||
return this.indexer
|
||||
.search$(
|
||||
'block',
|
||||
@@ -199,62 +280,71 @@ export class DocsSearchService extends Service {
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: ['refDocId', 'ref'],
|
||||
fields: ['docId', 'refDocId', 'ref'],
|
||||
pagination: {
|
||||
limit: 100,
|
||||
limit: Infinity,
|
||||
},
|
||||
}
|
||||
)
|
||||
.pipe(
|
||||
switchMap(({ nodes }) => {
|
||||
return fromPromise(async () => {
|
||||
const refs: ({ docId: string } & ReferenceParams)[] = Array.from(
|
||||
new Map(
|
||||
nodes
|
||||
.flatMap(node => {
|
||||
const { ref } = node.fields;
|
||||
return typeof ref === 'string'
|
||||
? [JSON.parse(ref)]
|
||||
: ref.map(item => JSON.parse(item));
|
||||
})
|
||||
.filter(ref => !docIds.includes(ref.docId))
|
||||
.map(ref => [ref.docId, ref])
|
||||
).values()
|
||||
let malformed = 0;
|
||||
const refsBySource = new Map<
|
||||
string,
|
||||
Map<string, { docId: string } & ReferenceParams>
|
||||
>();
|
||||
for (const node of nodes) {
|
||||
const sourceId = stringField(node.fields.docId);
|
||||
const parsed = parseIndexedReferences(node.fields.ref);
|
||||
malformed += parsed.malformed;
|
||||
if (!sourceId || !docIds.includes(sourceId)) continue;
|
||||
let sourceRefs = refsBySource.get(sourceId);
|
||||
if (!sourceRefs) {
|
||||
sourceRefs = new Map();
|
||||
refsBySource.set(sourceId, sourceRefs);
|
||||
}
|
||||
for (const ref of parsed.refs) {
|
||||
if (ref.docId !== sourceId) sourceRefs.set(ref.docId, ref);
|
||||
}
|
||||
}
|
||||
if (malformed > 0) {
|
||||
console.warn('[docs-search] skipped malformed references', {
|
||||
count: malformed,
|
||||
});
|
||||
}
|
||||
|
||||
return new Map(
|
||||
docIds.map(sourceId => [
|
||||
sourceId,
|
||||
Array.from(refsBySource.get(sourceId)?.values() ?? []).flatMap(
|
||||
ref => {
|
||||
const doc = this.docsService.list.doc$(ref.docId).value;
|
||||
if (!doc) return [];
|
||||
const params = omit(ref, ['docId']);
|
||||
return [
|
||||
{
|
||||
title: doc.title$.value,
|
||||
docId: doc.id,
|
||||
params: isEmpty(params)
|
||||
? undefined
|
||||
: toDocSearchParams(params),
|
||||
},
|
||||
];
|
||||
}
|
||||
),
|
||||
])
|
||||
);
|
||||
|
||||
return refs
|
||||
.flatMap(ref => {
|
||||
const doc = this.docsService.list.doc$(ref.docId).value;
|
||||
if (!doc) return null;
|
||||
|
||||
const title = doc.title$.value;
|
||||
const params = omit(ref, ['docId']);
|
||||
|
||||
return {
|
||||
title,
|
||||
docId: doc.id,
|
||||
params: isEmpty(params)
|
||||
? undefined
|
||||
: toDocSearchParams(params),
|
||||
};
|
||||
})
|
||||
.filter(ref => !!ref);
|
||||
});
|
||||
}),
|
||||
// Only propagate downstream when the actual set of linked docs
|
||||
// changes (a link was added or removed). Without this guard,
|
||||
// every re-index triggered by typing emits a new array (same
|
||||
// docs, arbitrary search-engine order) and the navigation panel
|
||||
// visibly reorders on every keystroke.
|
||||
//
|
||||
// Note: this compares docId sets, not order. A stable, meaningful
|
||||
// sort order (e.g. document appearance order) requires block
|
||||
// position data from the indexer and is tracked separately.
|
||||
distinctUntilChanged((prev, curr) => {
|
||||
if (prev.length !== curr.length) return false;
|
||||
const currIds = new Set(curr.map(r => r.docId));
|
||||
return prev.every(r => currIds.has(r.docId));
|
||||
})
|
||||
distinctUntilChanged((previous, current) =>
|
||||
docIds.every(sourceId =>
|
||||
equalReferenceSets(
|
||||
previous.get(sourceId) ?? [],
|
||||
current.get(sourceId) ?? []
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,12 @@ export class NotificationListService extends Service {
|
||||
this.loadMore.reset();
|
||||
}
|
||||
|
||||
retry() {
|
||||
this.error$.setValue(null);
|
||||
this.loadMore.reset();
|
||||
this.loadMore();
|
||||
}
|
||||
|
||||
async readNotification(id: string) {
|
||||
await this.store.readNotification(id);
|
||||
this.notifications$.next(
|
||||
|
||||
@@ -137,7 +137,14 @@ export class WorkspaceMetaImpl implements WorkspaceMeta {
|
||||
this.docMetaUpdated.next();
|
||||
}
|
||||
|
||||
private _assertValidDocTitle(meta: Partial<DocMeta>) {
|
||||
if (meta.title !== undefined && typeof meta.title !== 'string') {
|
||||
throw new TypeError('Document title must be a string');
|
||||
}
|
||||
}
|
||||
|
||||
addDocMeta(doc: DocMeta, index?: number) {
|
||||
this._assertValidDocTitle(doc);
|
||||
this._doc.transact(() => {
|
||||
if (!this.docs) {
|
||||
return;
|
||||
@@ -181,6 +188,7 @@ export class WorkspaceMetaImpl implements WorkspaceMeta {
|
||||
}
|
||||
|
||||
setDocMeta(id: string, props: Partial<DocMeta>) {
|
||||
this._assertValidDocTitle(props);
|
||||
const docs = (this.docs as DocMeta[]) ?? [];
|
||||
const index = docs.findIndex((doc: DocMeta) => id === doc.id);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user