mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 05:48:59 +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:
@@ -11,6 +11,7 @@ interface TestOps extends OpSchema {
|
||||
bin: [Uint8Array, Uint8Array];
|
||||
sub: [Uint8Array, number];
|
||||
init: [{ fastText?: boolean } | undefined, { ok: true }];
|
||||
any: [undefined, unknown];
|
||||
}
|
||||
|
||||
declare module 'vitest' {
|
||||
@@ -242,6 +243,41 @@ describe('op client', () => {
|
||||
sub.unsubscribe();
|
||||
});
|
||||
|
||||
it('hydrates worker stacks for calls and observables', async ctx => {
|
||||
const marker = 'WORKER_STACK_MARKER';
|
||||
const remoteError = {
|
||||
name: 'TypeError',
|
||||
message: marker,
|
||||
stacktrace: `TypeError: ${marker}\n at workerTask (worker.ts:42:7)`,
|
||||
} as unknown as Error;
|
||||
|
||||
const call = ctx.producer.call('any', undefined);
|
||||
ctx.handlers.return({
|
||||
type: 'return',
|
||||
id: 'any:1',
|
||||
error: remoteError,
|
||||
});
|
||||
await expect(call).rejects.toMatchObject({
|
||||
name: 'TypeError',
|
||||
message: marker,
|
||||
stack: expect.stringContaining('worker.ts:42:7'),
|
||||
});
|
||||
|
||||
const observableError = new Promise<unknown>(resolve => {
|
||||
ctx.producer.ob$('any').subscribe({ error: resolve });
|
||||
});
|
||||
ctx.handlers.error({
|
||||
type: 'error',
|
||||
id: 'any:2',
|
||||
error: remoteError,
|
||||
});
|
||||
await expect(observableError).resolves.toMatchObject({
|
||||
name: 'TypeError',
|
||||
message: marker,
|
||||
stack: expect.stringContaining('worker.ts:42:7'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should unsubscribe subscription op', ctx => {
|
||||
const sub = ctx.producer.ob$('sub', new Uint8Array([1, 2, 3])).subscribe({
|
||||
next: vi.fn(),
|
||||
|
||||
@@ -101,6 +101,44 @@ describe('op consumer', () => {
|
||||
expect(message).toContain('"code":"E_PANIC"');
|
||||
});
|
||||
|
||||
it('serializes worker stacks for calls and observables', async ctx => {
|
||||
for (const kind of ['call', 'observable'] as const) {
|
||||
ctx.postMessage.mockClear();
|
||||
const marker = 'WORKER_STACK_MARKER';
|
||||
const error = new TypeError(marker);
|
||||
error.stack = `TypeError: ${marker}\n at workerTask (worker.ts:42:7)`;
|
||||
ctx.consumer.register('any', () => {
|
||||
if (kind === 'observable') {
|
||||
return new Observable(observer => observer.error(error));
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (kind === 'observable') {
|
||||
ctx.handlers.subscribe({
|
||||
type: 'subscribe',
|
||||
id: 'any:1',
|
||||
name: 'any',
|
||||
payload: undefined,
|
||||
});
|
||||
} else {
|
||||
ctx.handlers.call({
|
||||
type: 'call',
|
||||
id: 'any:1',
|
||||
name: 'any',
|
||||
payload: undefined,
|
||||
});
|
||||
}
|
||||
await vi.advanceTimersToNextTimerAsync();
|
||||
|
||||
expect(ctx.postMessage.mock.calls[0][0].error).toMatchObject({
|
||||
name: 'TypeError',
|
||||
message: marker,
|
||||
stacktrace: expect.stringContaining('worker.ts:42:7'),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle cancel message', async ctx => {
|
||||
ctx.consumer.register('add', ({ a, b }, { signal }) => {
|
||||
const { reject, resolve, promise } = Promise.withResolvers<number>();
|
||||
|
||||
@@ -32,6 +32,18 @@ export interface OpClientOptions {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
function hydrateRemoteError(error: Error) {
|
||||
const hydrated = Object.assign(new Error(), error);
|
||||
if (
|
||||
'stacktrace' in error &&
|
||||
typeof error.stacktrace === 'string' &&
|
||||
error.stacktrace.length > 0
|
||||
) {
|
||||
hydrated.stack = error.stacktrace;
|
||||
}
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
export class OpClient<Ops extends OpSchema> extends AutoMessageHandler {
|
||||
private readonly callIds = new Map<OpNames<Ops>, number>();
|
||||
private readonly pendingCalls = new Map<string, PendingCall>();
|
||||
@@ -61,7 +73,7 @@ export class OpClient<Ops extends OpSchema> extends AutoMessageHandler {
|
||||
}
|
||||
|
||||
if ('error' in msg) {
|
||||
pending.reject(Object.assign(new Error(), msg.error));
|
||||
pending.reject(hydrateRemoteError(msg.error));
|
||||
} else {
|
||||
pending.resolve(msg.data);
|
||||
}
|
||||
@@ -86,7 +98,7 @@ export class OpClient<Ops extends OpSchema> extends AutoMessageHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
ob.error(Object.assign(new Error(), msg.error));
|
||||
ob.error(hydrateRemoteError(msg.error));
|
||||
};
|
||||
|
||||
private readonly handleSubscriptionCompleteMessage: MessageHandlers['complete'] =
|
||||
|
||||
@@ -20,6 +20,8 @@ import { createNode } from './node-builder';
|
||||
import { queryRaw } from './query';
|
||||
import { getText, tryParseArrayField } from './utils';
|
||||
|
||||
const SQLITE_INDEXER_VERSION_OFFSET = 1;
|
||||
|
||||
export class SqliteIndexerStorage extends IndexerStorageBase {
|
||||
static readonly identifier = 'SqliteIndexerStorage';
|
||||
override readonly recommendRefreshInterval = 30 * 1000; // 5 seconds
|
||||
@@ -92,7 +94,7 @@ export class SqliteIndexerStorage extends IndexerStorageBase {
|
||||
`${table}:${field as string}`,
|
||||
id
|
||||
);
|
||||
if (text) {
|
||||
if (typeof text === 'string' && text.length > 0) {
|
||||
let values: string[] = [text];
|
||||
const parsed = tryParseArrayField(text);
|
||||
if (parsed) {
|
||||
@@ -129,6 +131,13 @@ export class SqliteIndexerStorage extends IndexerStorageBase {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (text != null && typeof text !== 'string') {
|
||||
console.warn('[nbstore] invalid indexed aggregate type', {
|
||||
table,
|
||||
field: field as string,
|
||||
id,
|
||||
type: typeof text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +253,9 @@ export class SqliteIndexerStorage extends IndexerStorageBase {
|
||||
}
|
||||
|
||||
async indexVersion(): Promise<number> {
|
||||
return this.connection.apis.ftsIndexVersion();
|
||||
return (
|
||||
(await this.connection.apis.ftsIndexVersion()) +
|
||||
SQLITE_INDEXER_VERSION_OFFSET
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { NativeDBConnection } from '../db';
|
||||
import { SqliteIndexerStorage } from '.';
|
||||
import { createNode } from './node-builder';
|
||||
import { getText } from './utils';
|
||||
|
||||
const query = { type: 'match', field: 'title', match: 'query' } as const;
|
||||
|
||||
const connectionWith = (value: unknown) =>
|
||||
({
|
||||
apis: {
|
||||
ftsGetDocument: vi.fn().mockResolvedValue(value),
|
||||
ftsGetMatches: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
}) as unknown as NativeDBConnection;
|
||||
|
||||
describe('sqlite indexer node fields', () => {
|
||||
it.each([
|
||||
['string', 'summary', 'summary'],
|
||||
['singleton array', ['one'], 'one'],
|
||||
['array', ['one', 'two'], ['one', 'two']],
|
||||
['serialized array', '["one","two"]', ['one', 'two']],
|
||||
['malformed array', '[not-json]', '[not-json]'],
|
||||
['null', null, ''],
|
||||
['missing', undefined, ''],
|
||||
['wrong type', 42, ''],
|
||||
])('isolates %s values', async (_, value, expected) => {
|
||||
const node = await createNode(
|
||||
connectionWith(Array.isArray(value) ? getText(value) : value),
|
||||
'doc',
|
||||
'doc-id',
|
||||
1,
|
||||
{ fields: ['title'] },
|
||||
query
|
||||
);
|
||||
|
||||
expect(node.fields.title).toEqual(expected);
|
||||
});
|
||||
|
||||
it('does not highlight non-string values', async () => {
|
||||
const connection = connectionWith({ invalid: true });
|
||||
const node = await createNode(
|
||||
connection,
|
||||
'doc',
|
||||
'doc-id',
|
||||
1,
|
||||
{ highlights: [{ field: 'title', before: '<b>', end: '</b>' }] },
|
||||
query
|
||||
);
|
||||
|
||||
expect(node.highlights.title).toEqual([]);
|
||||
expect(connection.apis.ftsGetMatches).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('isolates wrong aggregate field types', async () => {
|
||||
const connection = connectionWith(42);
|
||||
connection.apis.ftsSearch = vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'doc-id', score: 1, terms: [] }]);
|
||||
const storage = Object.create(
|
||||
SqliteIndexerStorage.prototype
|
||||
) as SqliteIndexerStorage;
|
||||
Object.defineProperty(storage, 'connection', { value: connection });
|
||||
|
||||
await expect(
|
||||
storage.aggregate('doc', query, 'title')
|
||||
).resolves.toMatchObject({ buckets: [] });
|
||||
});
|
||||
});
|
||||
@@ -20,14 +20,22 @@ export async function createNode(
|
||||
`${table}:${field as string}`,
|
||||
id
|
||||
);
|
||||
if (text !== null) {
|
||||
if (typeof text === 'string') {
|
||||
const parsed = tryParseArrayField(text);
|
||||
if (parsed) {
|
||||
fields[field as string] = parsed;
|
||||
} else {
|
||||
fields[field as string] = text;
|
||||
}
|
||||
} else if (text == null) {
|
||||
fields[field as string] = '';
|
||||
} else {
|
||||
console.warn('[nbstore] invalid indexed field type', {
|
||||
table,
|
||||
field: field as string,
|
||||
id,
|
||||
type: typeof text,
|
||||
});
|
||||
fields[field as string] = '';
|
||||
}
|
||||
}
|
||||
@@ -43,7 +51,7 @@ export async function createNode(
|
||||
`${table}:${h.field as string}`,
|
||||
id
|
||||
);
|
||||
if (text) {
|
||||
if (typeof text === 'string' && text.length > 0) {
|
||||
const queryString = Array.from(queryStrings).join(' ');
|
||||
const matches = await connection.apis.ftsGetMatches(
|
||||
`${table}:${h.field as string}`,
|
||||
@@ -67,6 +75,14 @@ export async function createNode(
|
||||
highlights[h.field as string] = [];
|
||||
}
|
||||
} else {
|
||||
if (text != null && typeof text !== 'string') {
|
||||
console.warn('[nbstore] invalid indexed highlight type', {
|
||||
table,
|
||||
field: h.field as string,
|
||||
id,
|
||||
type: typeof text,
|
||||
});
|
||||
}
|
||||
highlights[h.field as string] = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ export function getText(
|
||||
val: string | string[] | undefined
|
||||
): string | undefined {
|
||||
if (Array.isArray(val)) {
|
||||
if (val.length === 1) {
|
||||
return val[0];
|
||||
}
|
||||
return JSON.stringify(val);
|
||||
}
|
||||
return val;
|
||||
|
||||
@@ -568,9 +568,7 @@ export class IndexerSyncImpl implements IndexerSync {
|
||||
const title = node.fields.title;
|
||||
return [
|
||||
node.id,
|
||||
{
|
||||
title: typeof title === 'string' ? title : title.at(0),
|
||||
},
|
||||
{ title: typeof title === 'string' ? title : undefined },
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user