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
+57 -31
View File
@@ -2,9 +2,10 @@ import { notify } from '@affine/component';
import { getStoreManager } from '@affine/core/blocksuite/manager/store';
import { AffineContext } from '@affine/core/components/context';
import { AppFallback } from '@affine/core/mobile/components/app-fallback';
import { MobileModalConfigProvider } from '@affine/core/mobile/components/mobile-modal-config-provider';
import { configureMobileModules } from '@affine/core/mobile/modules';
import { MobileBackCoordinator } from '@affine/core/mobile/modules/back-coordinator';
import { HapticProvider } from '@affine/core/mobile/modules/haptics';
import { NavigationGestureProvider } from '@affine/core/mobile/modules/navigation-gesture';
import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard';
import { router } from '@affine/core/mobile/router';
import { configureCommonModules } from '@affine/core/modules';
@@ -46,6 +47,7 @@ import {
requestApplySubscriptionMutation,
} from '@affine/graphql';
import { I18n } from '@affine/i18n';
import { serveAuthRequests } from '@affine/mobile-shared/auth/channel';
import { StoreManagerClient } from '@affine/nbstore/worker/client';
import { setTelemetryTransport } from '@affine/track';
import { Container } from '@blocksuite/affine/global/di';
@@ -61,7 +63,13 @@ import { Browser } from '@capacitor/browser';
import { Capacitor } from '@capacitor/core';
import { Haptics } from '@capacitor/haptics';
import { Keyboard, KeyboardStyle } from '@capacitor/keyboard';
import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra';
import {
Framework,
FrameworkRoot,
getCurrentStore,
useLiveData,
useService,
} from '@toeverything/infra';
import { OpClient } from '@toeverything/infra/op';
import { AsyncCall } from 'async-call-rpc';
import { AppTrackingTransparency } from 'capacitor-plugin-app-tracking-transparency';
@@ -70,16 +78,19 @@ import { Suspense, useEffect } from 'react';
import { RouterProvider } from 'react-router-dom';
import { BlocksuiteMenuConfigProvider } from './bs-menu-config';
import { ModalConfigProvider } from './modal-config';
import { AffineTheme } from './plugins/affine-theme';
import { Auth } from './plugins/auth';
import { Hashcash } from './plugins/hashcash';
import { ImagePicker } from './plugins/image-picker';
import { NavigationGesture } from './plugins/navigation-gesture';
import { NbStoreNativeDBApis } from './plugins/nbstore';
import { PayWall } from './plugins/paywall';
import { Preview } from './plugins/preview';
import { clearEndpointSession, getValidAccessToken } from './proxy';
import { enableNavigationGesture$ } from './web-navigation-control';
import {
authRequestProvider,
clearEndpointSession,
getValidAccessToken,
} from './proxy';
const storeManagerClient = createStoreManagerClient();
setTelemetryTransport(storeManagerClient.telemetry);
@@ -165,11 +176,6 @@ framework.impl(VirtualKeyboardProvider, {
};
},
});
framework.impl(NavigationGestureProvider, {
isEnabled: () => enableNavigationGesture$.value,
enable: () => enableNavigationGesture$.next(true),
disable: () => enableNavigationGesture$.next(false),
});
framework.impl(HapticProvider, {
impact: options => Haptics.impact(options as any),
vibrate: options => Haptics.vibrate(options as any),
@@ -578,14 +584,49 @@ const KeyboardThemeProvider = () => {
return null;
};
const IOSBackAdapter = () => {
const coordinator = useService(MobileBackCoordinator);
const enabled = useLiveData(coordinator.canInteractivePop$);
useEffect(() => {
(enabled ? NavigationGesture.enable() : NavigationGesture.disable()).catch(
console.error
);
}, [enabled]);
useEffect(() => {
let disposed = false;
let remove = () => {};
NavigationGesture.addListener('gesture', event => {
coordinator.handleInteractivePhase(event.phase);
})
.then(handle => {
if (disposed) handle.remove().catch(console.error);
else
remove = () => {
handle.remove().catch(console.error);
};
})
.catch(console.error);
return () => {
disposed = true;
remove();
NavigationGesture.disable().catch(console.error);
};
}, [coordinator]);
return null;
};
export function App() {
return (
<Suspense>
<FrameworkRoot framework={frameworkProvider}>
<I18nProvider>
<AffineContext store={getCurrentStore()}>
<KeyboardThemeProvider />
<ModalConfigProvider>
<MobileModalConfigProvider>
<AffineContext store={getCurrentStore()}>
<KeyboardThemeProvider />
<IOSBackAdapter />
<BlocksuiteMenuConfigProvider>
<RouterProvider
fallbackElement={<AppFallback />}
@@ -593,8 +634,8 @@ export function App() {
future={future}
/>
</BlocksuiteMenuConfigProvider>
</ModalConfigProvider>
</AffineContext>
</AffineContext>
</MobileModalConfigProvider>
</I18nProvider>
</FrameworkRoot>
</Suspense>
@@ -626,22 +667,7 @@ function createStoreManagerClient() {
const { port1: authTokenChannelServer, port2: authTokenChannelClient } =
new MessageChannel();
authTokenChannelServer.addEventListener('message', event => {
const { id, endpoint } = event.data as { id?: string; endpoint?: string };
if (!id || !endpoint) return;
getValidAccessToken(endpoint)
.then(token => authTokenChannelServer.postMessage({ id, token }))
.catch(error =>
authTokenChannelServer.postMessage({
id,
error:
typeof error === 'object' && error && 'code' in error
? error.code
: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE',
})
);
});
authTokenChannelServer.start();
serveAuthRequests(authTokenChannelServer, authRequestProvider);
worker.postMessage(
{ type: 'auth-access-token-channel', port: authTokenChannelClient },
[authTokenChannelClient]
@@ -1,41 +1,21 @@
import { NavigationGestureService } from '@affine/core/mobile/modules/navigation-gesture';
import { MobileBackCoordinator } from '@affine/core/mobile/modules/back-coordinator';
import { onMenuOpen } from '@blocksuite/affine/components/context-menu';
import { useService } from '@toeverything/infra';
import { type PropsWithChildren, useCallback, useEffect, useRef } from 'react';
import { type PropsWithChildren, useEffect } from 'react';
export const BlocksuiteMenuConfigProvider = ({
children,
}: PropsWithChildren) => {
const navigationGesture = useService(NavigationGestureService);
const menuCountRef = useRef(0);
const prevEnabledRef = useRef(false);
const handleMenuState = useCallback(() => {
const currentCount = menuCountRef.current + 1;
menuCountRef.current = currentCount;
if (currentCount === 1) {
prevEnabledRef.current = navigationGesture.enabled$.value;
if (prevEnabledRef.current) {
navigationGesture.setEnabled(false);
}
}
return () => {
const currentCount = menuCountRef.current - 1;
menuCountRef.current = currentCount;
if (currentCount === 0 && prevEnabledRef.current) {
navigationGesture.setEnabled(true);
}
};
}, [navigationGesture]);
const coordinator = useService(MobileBackCoordinator);
useEffect(() => {
return onMenuOpen(() => {
return handleMenuState();
return coordinator.registerVisual({
interactive: false,
handle: () => false,
}).dispose;
});
}, [handleMenuState]);
}, [coordinator]);
return children;
};
@@ -1,30 +0,0 @@
import { ModalConfigContext } from '@affine/component';
import { NavigationGestureService } from '@affine/core/mobile/modules/navigation-gesture';
import { globalVars } from '@affine/core/mobile/styles/variables.css';
import { useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
export const ModalConfigProvider = ({ children }: React.PropsWithChildren) => {
const navigationGesture = useService(NavigationGestureService);
const onOpen = useCallback(() => {
const prev = navigationGesture.enabled$.value;
if (prev) {
navigationGesture.setEnabled(false);
return () => {
navigationGesture.setEnabled(prev);
};
}
return;
}, [navigationGesture]);
const modalConfigValue = useMemo(
() => ({ onOpen, dynamicKeyboardHeight: globalVars.appKeyboardHeight }),
[onOpen]
);
return (
<ModalConfigContext.Provider value={modalConfigValue}>
{children}
</ModalConfigContext.Provider>
);
};
@@ -1,5 +1,7 @@
import './setup-worker';
import { MessagePortAuthProvider } from '@affine/mobile-shared/auth/channel';
import { installAuthRequestProxy } from '@affine/mobile-shared/auth/request';
import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel';
import {
cloudStorages,
@@ -18,53 +20,19 @@ import {
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
import { AsyncCall } from 'async-call-rpc';
let authTokenPort: MessagePort | undefined;
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 pendingTokenRequests = new Map<
string,
{
resolve: (token: string | null) => void;
reject: (error: Error) => void;
}
>();
const authProvider = new MessagePortAuthProvider();
installAuthRequestProxy(authProvider);
configureSocketAuthMethod((endpoint, cb) => {
getValidAccessToken(endpoint)
authProvider
.getValidAccessToken(endpoint)
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
.catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' }));
});
globalThis.addEventListener('message', e => {
if (e.data.type === 'auth-access-token-channel') {
authTokenPort = e.ports[0] as MessagePort;
authTokenPort.addEventListener('message', e => {
const { id, token, error } = e.data as {
id?: string;
token?: string | null;
error?: string;
};
if (!id) return;
const pending = pendingTokenRequests.get(id);
if (error) {
if (terminalAuthErrors.has(error)) {
pending?.resolve(null);
} else {
pending?.reject(new Error(error));
}
} else {
pending?.resolve(token ?? null);
}
pendingTokenRequests.delete(id);
});
authTokenPort.start();
authProvider.setPort(e.ports[0] as MessagePort);
return;
}
@@ -94,31 +62,6 @@ globalThis.addEventListener('message', e => {
}
});
function getValidAccessToken(endpoint: string) {
if (!authTokenPort) {
return Promise.resolve(null);
}
const id = `${Date.now()}:${Math.random()}`;
return new Promise<string | null>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingTokenRequests.delete(id);
reject(new Error('AUTH_SESSION_TEMPORARILY_UNAVAILABLE'));
}, 5000);
pendingTokenRequests.set(id, {
resolve: token => {
clearTimeout(timeout);
resolve(token);
},
reject: error => {
clearTimeout(timeout);
reject(error);
},
});
authTokenPort?.postMessage({ id, endpoint });
});
}
const consumer = new OpConsumer<WorkerManagerOps>(
globalThis as MessageCommunicapable
);
@@ -1,5 +1,14 @@
import type { PluginListenerHandle } from '@capacitor/core';
export interface NavigationGesturePlugin {
isEnabled: () => Promise<boolean>;
enable: () => Promise<void>;
disable: () => Promise<void>;
addListener(
event: 'gesture',
listener: (event: {
phase: 'begin' | 'progress' | 'commit' | 'cancel';
progress: number;
}) => void
): Promise<PluginListenerHandle>;
}
@@ -180,7 +180,7 @@ export interface NbStorePlugin {
id: string;
indexName: string;
docId: string;
}) => Promise<{ text: string | null }>;
}) => Promise<{ text?: string | null }>;
ftsGetMatches: (options: {
id: string;
indexName: string;
@@ -2,6 +2,7 @@ import {
base64ToUint8Array,
uint8ArrayToBase64,
} from '@affine/core/modules/workspace-engine';
import { normalizeNativeOptional } from '@affine/mobile-shared/nbstore/optional';
import {
decodePayload,
MOBILE_BLOB_FILE_PREFIX,
@@ -405,7 +406,7 @@ export const NbStoreNativeDBApis: NativeDBApis = {
indexName,
docId,
});
return result.text;
return normalizeNativeOptional(result.text);
},
ftsGetMatches: async function (
id: string,
+20 -186
View File
@@ -1,196 +1,30 @@
import { canonicalAuthEndpoint } from '@affine/mobile-shared/auth/endpoint';
import {
type AuthRequestProvider,
installAuthRequestProxy,
} from '@affine/mobile-shared/auth/request';
import { Auth } from './plugins/auth';
function authEndpointForUrl(url: string | URL) {
try {
const parsed = new URL(url, globalThis.location.origin);
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
? parsed.origin
: null;
} catch {
return null;
}
}
/**
* the below code includes the custom fetch and xmlhttprequest implementation for ios webview.
* should be included in the entry file of the app or webworker.
*/
const rawFetch = globalThis.fetch;
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
const retry = request.clone();
const origin = authEndpointForUrl(request.url);
const token = origin ? await getValidAccessToken(origin) : null;
if (token) {
request.headers.set('Authorization', `Bearer ${token}`);
}
const response = await rawFetch(request);
if (response.status !== 401 || !origin) return response;
const body = await response
.clone()
.json()
.catch(() => null);
if (body?.code !== 'ACCESS_TOKEN_EXPIRED') return response;
const { token: refreshed } = await Auth.refreshAccessToken({
endpoint: origin,
});
retry.headers.set('Authorization', `Bearer ${refreshed}`);
return rawFetch(retry);
export const authRequestProvider: AuthRequestProvider = {
async getValidAccessToken(endpoint) {
const { token } = await Auth.getValidAccessToken({
endpoint: canonicalAuthEndpoint(endpoint),
});
return token ?? null;
},
async refreshAccessToken(endpoint) {
const { token } = await Auth.refreshAccessToken({
endpoint: canonicalAuthEndpoint(endpoint),
});
return token;
},
};
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;
installAuthRequestProxy(authRequestProvider);
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 (code !== 'ACCESS_TOKEN_EXPIRED') 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.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 origin = authEndpointForUrl(requestUrl ?? globalThis.location.href);
(origin ? getValidAccessToken(origin) : Promise.resolve(null))
.then(token => {
if (token) {
super.setRequestHeader('Authorization', `Bearer ${token}`);
}
return super.send(body);
})
.catch(() => {
this.dispatchEvent(new Event('error'));
this.dispatchEvent(new Event('loadend'));
});
}
private async replayWithFreshToken() {
const request = this.request;
if (!request) return this.failReplay();
const origin = authEndpointForUrl(request.url);
if (!origin) return this.failReplay();
try {
const { token } = await Auth.refreshAccessToken({ endpoint: origin });
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 {
this.failReplay();
}
}
private failReplay() {
this.replaying = false;
this.dispatchEvent(new Event('readystatechange'));
this.dispatchEvent(new Event('error'));
this.dispatchEvent(new Event('loadend'));
}
};
export async function getValidAccessToken(
endpoint: string
): Promise<string | null> {
const { token } = await Auth.getValidAccessToken({
endpoint: canonicalAuthEndpoint(endpoint),
});
return token ?? null;
export function getValidAccessToken(endpoint: string) {
return authRequestProvider.getValidAccessToken(endpoint);
}
export async function clearEndpointSession(endpoint: string) {
@@ -1,2 +1 @@
import '@affine/core/bootstrap/browser';
import './proxy';
@@ -1,13 +0,0 @@
import { LiveData } from '@toeverything/infra';
export const enableNavigationGesture$ = new LiveData(false);
const onTouchStart = (e: TouchEvent) => {
if (enableNavigationGesture$.value) return;
const clientX = e.changedTouches[0].clientX;
if (clientX <= 25) {
e.preventDefault();
}
};
document.body.addEventListener('touchstart', onTouchStart, { passive: false });