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:
DarkSky
2026-07-23 00:23:21 +08:00
committed by GitHub
parent 02e75862cc
commit 1d36e2e4b2
160 changed files with 6660 additions and 1890 deletions
@@ -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);