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
@@ -6,12 +6,15 @@
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./auth/channel": "./src/auth/channel.ts",
"./auth/endpoint": "./src/auth/endpoint.ts",
"./auth/request": "./src/auth/request.ts",
"./nbstore/optional": "./src/nbstore/optional.ts",
"./nbstore/payload": "./src/nbstore/payload.ts"
},
"dependencies": {
"@affine/core": "workspace:*",
"@capacitor/core": "^7.0.0"
"@capacitor/core": "^8.4.2"
},
"devDependencies": {
"typescript": "^5.9.3",
@@ -0,0 +1,319 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { MessagePortAuthProvider, serveAuthRequests } from './channel';
import { canonicalAuthEndpoint, shouldRefreshAccessToken } from './endpoint';
import { createAuthFetch, installAuthRequestProxy } from './request';
function stubXMLHttpRequest(completeOnSend = false) {
const send = vi.fn();
const abort = vi.fn();
const instances = new Set<FakeXMLHttpRequest>();
class FakeXMLHttpRequest extends EventTarget {
static readonly DONE = 4;
readyState = 0;
status = 0;
responseType: XMLHttpRequestResponseType = '';
response: unknown;
responseText = '';
timeout = 0;
withCredentials = false;
constructor() {
super();
instances.add(this);
}
open() {
this.readyState = 1;
}
setRequestHeader() {}
send(body?: Document | XMLHttpRequestBodyInit | null) {
send(body);
if (completeOnSend) {
this.readyState = FakeXMLHttpRequest.DONE;
this.dispatchEvent(new Event('loadend'));
}
}
abort() {
abort();
this.dispatchEvent(new Event('abort'));
this.dispatchEvent(new Event('loadend'));
}
}
vi.stubGlobal('XMLHttpRequest', FakeXMLHttpRequest);
return {
send,
abort,
respond(status: number, responseText: string) {
const current = [...instances].at(-1);
if (!current) throw new Error('No XMLHttpRequest instance');
current.status = status;
current.responseText = responseText;
current.readyState = FakeXMLHttpRequest.DONE;
current.dispatchEvent(new Event('readystatechange'));
current.dispatchEvent(new Event('load'));
current.dispatchEvent(new Event('loadend'));
},
};
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('canonicalAuthEndpoint', () => {
test.each([
['https://AFFINE.PRO/path?query=1', 'https://affine.pro'],
['https://affine.pro:443', 'https://affine.pro'],
['http://localhost:80/path', 'http://localhost'],
['http://localhost:8080/path', 'http://localhost:8080'],
['capacitor://localhost/path', 'capacitor://localhost/path'],
['invalid endpoint', 'invalid endpoint'],
])('normalizes %s', (endpoint, expected) => {
expect(canonicalAuthEndpoint(endpoint)).toBe(expected);
});
});
describe('shouldRefreshAccessToken', () => {
test.each(['ACCESS_TOKEN_EXPIRED', 'ACCESS_TOKEN_INVALID'])(
'refreshes for %s',
code => {
expect(shouldRefreshAccessToken(code)).toBe(true);
}
);
test.each(['INVALID_REFRESH_TOKEN', undefined, null])(
'does not refresh for %s',
code => {
expect(shouldRefreshAccessToken(code)).toBe(false);
}
);
});
describe('auth request fetch', () => {
test('injects the endpoint token', async () => {
const provider = {
getValidAccessToken: vi.fn(async () => 'access-token'),
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
};
const rawFetch = vi.fn<typeof fetch>(
async () => new Response(null, { status: 200 })
);
const fetch = createAuthFetch(provider, rawFetch);
await fetch('https://example.com/api/workspaces/1/blobs/1');
expect(provider.getValidAccessToken).toHaveBeenCalledWith(
'https://example.com'
);
expect(
(rawFetch.mock.calls[0][0] as Request).headers.get('Authorization')
).toBe('Bearer access-token');
});
test('refreshes and replays an expired request once', async () => {
const provider = {
getValidAccessToken: vi.fn(async () => 'expired-token'),
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
};
const rawFetch = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
new Response(JSON.stringify({ code: 'ACCESS_TOKEN_EXPIRED' }), {
status: 401,
headers: { 'content-type': 'application/json' },
})
)
.mockResolvedValueOnce(new Response(null, { status: 200 }));
const fetch = createAuthFetch(provider, rawFetch);
const response = await fetch('https://example.com/graphql');
expect(response.status).toBe(200);
expect(provider.refreshAccessToken).toHaveBeenCalledOnce();
expect(provider.refreshAccessToken).toHaveBeenCalledWith(
'https://example.com'
);
expect(rawFetch).toHaveBeenCalledTimes(2);
expect(
(rawFetch.mock.calls[1][0] as Request).headers.get('Authorization')
).toBe('Bearer refreshed-token');
});
test('does not attach a token when the endpoint has no session', async () => {
const provider = {
getValidAccessToken: vi.fn(async () => null),
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
};
const rawFetch = vi.fn<typeof fetch>(
async () => new Response(null, { status: 200 })
);
const fetch = createAuthFetch(provider, rawFetch);
await fetch('https://cdn.example.com/presigned/blob');
expect(
(rawFetch.mock.calls[0][0] as Request).headers.has('Authorization')
).toBe(false);
});
});
describe('auth request XMLHttpRequest', () => {
test('does not send after abort while waiting for a token', async () => {
let resolveToken: (token: string | null) => void = () => {};
const token = new Promise<string | null>(resolve => {
resolveToken = resolve;
});
const xhrCalls = stubXMLHttpRequest();
installAuthRequestProxy({
getValidAccessToken: vi.fn(() => token),
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
});
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://example.com/graphql');
xhr.send('body');
xhr.abort();
resolveToken('access-token');
await Promise.resolve();
expect(xhrCalls.abort).toHaveBeenCalledOnce();
expect(xhrCalls.send).not.toHaveBeenCalled();
});
test('does not send a stale body after reopening', async () => {
const tokenResolvers: ((token: string | null) => void)[] = [];
const xhrCalls = stubXMLHttpRequest();
installAuthRequestProxy({
getValidAccessToken: vi.fn(
() =>
new Promise<string | null>(resolve => {
tokenResolvers.push(resolve);
})
),
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
});
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://example.com/first');
xhr.send('first-body');
xhr.open('POST', 'https://example.com/second');
xhr.send('second-body');
tokenResolvers[1](null);
tokenResolvers[0](null);
await Promise.resolve();
expect(xhrCalls.send).toHaveBeenCalledOnce();
expect(xhrCalls.send).toHaveBeenCalledWith('second-body');
});
test('uses the native lifecycle when token lookup fails', async () => {
const xhrCalls = stubXMLHttpRequest(true);
installAuthRequestProxy({
getValidAccessToken: vi.fn(async () => {
throw new Error('token lookup failed');
}),
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
});
const xhr = new XMLHttpRequest();
const loadend = vi.fn();
xhr.addEventListener('loadend', loadend);
xhr.open('POST', 'https://example.com/graphql');
xhr.send('body');
await vi.waitFor(() => expect(xhrCalls.send).toHaveBeenCalledWith('body'));
expect(xhr.readyState).toBe(XMLHttpRequest.DONE);
expect(loadend).toHaveBeenCalledOnce();
});
test('preserves abort lifecycle while waiting to replay', async () => {
let resolveRefresh: (token: string) => void = () => {};
const refresh = new Promise<string>(resolve => {
resolveRefresh = resolve;
});
const xhrCalls = stubXMLHttpRequest();
const provider = {
getValidAccessToken: vi.fn(async () => null),
refreshAccessToken: vi.fn(() => refresh),
};
installAuthRequestProxy(provider);
const xhr = new XMLHttpRequest();
const loadend = vi.fn();
xhr.addEventListener('loadend', loadend);
xhr.open('POST', 'https://example.com/graphql');
xhr.send('body');
await vi.waitFor(() => expect(xhrCalls.send).toHaveBeenCalledOnce());
xhrCalls.respond(401, JSON.stringify({ code: 'ACCESS_TOKEN_EXPIRED' }));
expect(provider.refreshAccessToken).toHaveBeenCalledOnce();
xhr.abort();
resolveRefresh('refreshed-token');
await Promise.resolve();
expect(loadend).toHaveBeenCalledOnce();
expect(xhrCalls.send).toHaveBeenCalledOnce();
});
});
describe('auth message channel', () => {
test('serves get-valid and refresh operations', async () => {
const channel = new MessageChannel();
const nativeProvider = {
getValidAccessToken: vi.fn(async () => 'access-token'),
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
};
serveAuthRequests(channel.port1, nativeProvider);
const workerProvider = new MessagePortAuthProvider();
workerProvider.setPort(channel.port2);
await expect(
workerProvider.getValidAccessToken('https://example.com')
).resolves.toBe('access-token');
await expect(
workerProvider.refreshAccessToken('https://example.com')
).resolves.toBe('refreshed-token');
expect(nativeProvider.getValidAccessToken).toHaveBeenCalledWith(
'https://example.com'
);
expect(nativeProvider.refreshAccessToken).toHaveBeenCalledWith(
'https://example.com'
);
channel.port1.close();
channel.port2.close();
});
test('maps terminal get-valid errors to an empty session', async () => {
const channel = new MessageChannel();
const error = Object.assign(new Error('expired'), {
code: 'AUTH_SESSION_EXPIRED',
});
serveAuthRequests(channel.port1, {
getValidAccessToken: vi.fn(async () => {
throw error;
}),
refreshAccessToken: vi.fn(async () => {
throw error;
}),
});
const workerProvider = new MessagePortAuthProvider();
workerProvider.setPort(channel.port2);
await expect(
workerProvider.getValidAccessToken('https://example.com')
).resolves.toBeNull();
await expect(
workerProvider.refreshAccessToken('https://example.com')
).rejects.toThrow('AUTH_SESSION_EXPIRED');
channel.port1.close();
channel.port2.close();
});
});
@@ -0,0 +1,137 @@
import type { AuthRequestProvider } from './request';
type AuthOperation = 'get-valid' | 'refresh';
type AuthRequest = {
id: string;
operation: AuthOperation;
endpoint: string;
};
type AuthResponse = {
id: string;
token?: string | null;
error?: string;
};
const terminalAuthErrors = new Set([
'ACCESS_TOKEN_INVALID',
'AUTH_SESSION_EXPIRED',
'AUTH_SESSION_REVOKED',
'REFRESH_TOKEN_INVALID',
'REFRESH_TOKEN_REUSED',
'UNSUPPORTED_CLIENT_VERSION',
'AUTH_SESSION_EMPTY',
]);
const temporaryAuthError = 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE';
function errorCode(error: unknown) {
return typeof error === 'object' &&
error &&
'code' in error &&
typeof error.code === 'string'
? error.code
: temporaryAuthError;
}
export function serveAuthRequests(
port: MessagePort,
provider: AuthRequestProvider
) {
port.addEventListener('message', event => {
const { id, operation, endpoint } = event.data as Partial<AuthRequest>;
if (
!id ||
(operation !== 'get-valid' && operation !== 'refresh') ||
!endpoint
) {
return;
}
const request =
operation === 'refresh'
? provider.refreshAccessToken(endpoint)
: provider.getValidAccessToken(endpoint);
request.then(
token => port.postMessage({ id, token } satisfies AuthResponse),
error =>
port.postMessage({
id,
error: errorCode(error),
} satisfies AuthResponse)
);
});
port.start();
}
export class MessagePortAuthProvider implements AuthRequestProvider {
private port?: MessagePort;
private nextRequestId = 0;
private readonly pending = new Map<
string,
{
resolve: (token: string | null) => void;
reject: (error: Error) => void;
}
>();
setPort(port: MessagePort) {
this.port = port;
port.addEventListener('message', event => {
const { id, token, error } = event.data as Partial<AuthResponse>;
if (!id) return;
const pending = this.pending.get(id);
if (!pending) return;
if (error) {
pending.reject(new Error(error));
} else {
pending.resolve(token ?? null);
}
this.pending.delete(id);
});
port.start();
}
async getValidAccessToken(endpoint: string) {
try {
return await this.request('get-valid', endpoint);
} catch (error) {
if (error instanceof Error && terminalAuthErrors.has(error.message)) {
return null;
}
throw error;
}
}
async refreshAccessToken(endpoint: string) {
const token = await this.request('refresh', endpoint);
if (!token) throw new Error(temporaryAuthError);
return token;
}
private request(operation: AuthOperation, endpoint: string) {
const port = this.port;
if (!port) return Promise.reject(new Error(temporaryAuthError));
const id = String(++this.nextRequestId);
return new Promise<string | null>((resolve, reject) => {
const timeout = setTimeout(() => {
this.pending.delete(id);
reject(new Error(temporaryAuthError));
}, 5000);
this.pending.set(id, {
resolve: token => {
clearTimeout(timeout);
resolve(token);
},
reject: error => {
clearTimeout(timeout);
reject(error);
},
});
port.postMessage({ id, operation, endpoint } satisfies AuthRequest);
});
}
}
@@ -1,16 +0,0 @@
import { describe, expect, test } from 'vitest';
import { canonicalAuthEndpoint } from './endpoint';
describe('canonicalAuthEndpoint', () => {
test.each([
['https://AFFINE.PRO/path?query=1', 'https://affine.pro'],
['https://affine.pro:443', 'https://affine.pro'],
['http://localhost:80/path', 'http://localhost'],
['http://localhost:8080/path', 'http://localhost:8080'],
['capacitor://localhost/path', 'capacitor://localhost/path'],
['invalid endpoint', 'invalid endpoint'],
])('normalizes %s', (endpoint, expected) => {
expect(canonicalAuthEndpoint(endpoint)).toBe(expected);
});
});
@@ -8,3 +8,7 @@ export function canonicalAuthEndpoint(endpoint: string) {
return endpoint;
}
}
export function shouldRefreshAccessToken(code: unknown) {
return code === 'ACCESS_TOKEN_EXPIRED' || code === 'ACCESS_TOKEN_INVALID';
}
@@ -0,0 +1,213 @@
import { shouldRefreshAccessToken } from './endpoint';
export interface AuthRequestProvider {
getValidAccessToken(endpoint: string): Promise<string | null>;
refreshAccessToken(endpoint: string): Promise<string>;
}
function authEndpointForUrl(url: string | URL) {
try {
const parsed = new URL(
url,
globalThis.location?.origin ?? 'http://localhost'
);
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
? parsed.origin
: null;
} catch {
return null;
}
}
export function createAuthFetch(
provider: AuthRequestProvider,
rawFetch: typeof globalThis.fetch
) {
return async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
const retry = request.clone();
const endpoint = authEndpointForUrl(request.url);
const token = endpoint
? await provider.getValidAccessToken(endpoint)
: null;
if (token) {
request.headers.set('Authorization', `Bearer ${token}`);
}
const response = await rawFetch(request);
if (response.status !== 401 || !endpoint) return response;
const body = await response
.clone()
.json()
.catch(() => null);
if (!shouldRefreshAccessToken(body?.code)) return response;
const refreshed = await provider.refreshAccessToken(endpoint);
retry.headers.set('Authorization', `Bearer ${refreshed}`);
return rawFetch(retry);
};
}
export function installAuthRequestProxy(provider: AuthRequestProvider) {
const rawFetch = globalThis.fetch;
globalThis.fetch = createAuthFetch(provider, rawFetch);
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
private request:
| {
method: string;
url: string | URL;
async: boolean;
username?: string | null;
password?: string | null;
}
| undefined;
private readonly headers = new Map<string, string>();
private requestBody?: Document | XMLHttpRequestBodyInit | null;
private replaying = false;
private hasReplayed = false;
private sendVersion = 0;
constructor() {
super();
const suppressExpiredResponse = (event: Event) => {
if (this.replaying) event.stopImmediatePropagation();
};
this.addEventListener('load', suppressExpiredResponse, true);
this.addEventListener('loadend', suppressExpiredResponse, true);
this.addEventListener(
'readystatechange',
event => {
if (
this.readyState !== rawXMLHttpRequest.DONE ||
this.status !== 401 ||
this.replaying ||
this.hasReplayed ||
!this.request?.async
) {
return;
}
let code: unknown;
try {
code =
this.responseType === 'json'
? this.response?.code
: JSON.parse(this.responseText)?.code;
} catch {
return;
}
if (!shouldRefreshAccessToken(code)) return;
event.stopImmediatePropagation();
this.replaying = true;
this.hasReplayed = true;
this.replayWithFreshToken().catch(() => {});
},
true
);
}
override open(
method: string,
url: string | URL,
async: boolean = true,
username?: string | null,
password?: string | null
): void {
this.sendVersion++;
this.request = { method, url, async, username, password };
this.headers.clear();
this.requestBody = undefined;
this.replaying = false;
this.hasReplayed = false;
xhrRequestUrls.set(this, url.toString());
return super.open(
method,
url,
async,
username ?? undefined,
password ?? undefined
);
}
override setRequestHeader(name: string, value: string): void {
this.headers.set(name, value);
super.setRequestHeader(name, value);
}
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
this.requestBody = body;
const requestUrl = xhrRequestUrls.get(this);
const endpoint = authEndpointForUrl(
requestUrl ?? globalThis.location.href
);
const sendVersion = this.sendVersion;
const sendWithToken = (token: string | null) => {
if (sendVersion !== this.sendVersion) return;
if (token) {
super.setRequestHeader('Authorization', `Bearer ${token}`);
}
super.send(body);
};
(endpoint
? provider.getValidAccessToken(endpoint)
: Promise.resolve(null)
).then(sendWithToken, () => sendWithToken(null));
}
override abort(): void {
this.sendVersion++;
this.replaying = false;
super.abort();
}
private async replayWithFreshToken() {
const request = this.request;
if (!request) return this.failReplay();
const endpoint = authEndpointForUrl(request.url);
if (!endpoint) return this.failReplay();
const sendVersion = this.sendVersion;
try {
const token = await provider.refreshAccessToken(endpoint);
if (sendVersion !== this.sendVersion) return;
const responseType = this.responseType;
const timeout = this.timeout;
const withCredentials = this.withCredentials;
super.open(
request.method,
request.url,
true,
request.username ?? undefined,
request.password ?? undefined
);
this.replaying = false;
this.headers.forEach((value, name) => {
if (name.toLowerCase() !== 'authorization') {
super.setRequestHeader(name, value);
}
});
super.setRequestHeader('Authorization', `Bearer ${token}`);
this.responseType = responseType;
this.timeout = timeout;
this.withCredentials = withCredentials;
super.send(this.requestBody);
} catch {
if (sendVersion === this.sendVersion) {
this.failReplay();
}
}
}
private failReplay() {
this.replaying = false;
this.dispatchEvent(new Event('readystatechange'));
this.dispatchEvent(new Event('error'));
this.dispatchEvent(new Event('loadend'));
}
};
}
@@ -1,2 +1,5 @@
export * from './auth/channel';
export * from './auth/endpoint';
export * from './auth/request';
export * from './nbstore/optional';
export * from './nbstore/payload';
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { normalizeNativeOptional } from './optional';
describe('normalizeNativeOptional', () => {
it.each([
['string', 'summary', 'summary'],
['null', null, null],
['missing key', undefined, null],
['wrong type', 42, 42],
])('normalizes %s', (_, input, expected) => {
expect(normalizeNativeOptional(input)).toBe(expected);
});
});
@@ -0,0 +1,3 @@
export function normalizeNativeOptional<T>(value: T | null | undefined) {
return value ?? null;
}