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
@@ -32,9 +32,8 @@ test('Create new workspace, then delete it', async ({ page, workspace }) => {
.getByTestId('delete-workspace-input')
.pressSequentially(currentWorkspaceName);
const promise = page
.locator('.affine-notification-center')
.first()
.waitFor({ state: 'attached' });
.getByText('Successfully deleted', { exact: true })
.waitFor({ state: 'visible' });
await page.getByTestId('delete-workspace-confirm-button').click();
await promise;
await page.reload();
@@ -61,7 +60,11 @@ test('Delete last workspace', async ({ page }) => {
await page
.getByTestId('delete-workspace-input')
.pressSequentially(currentWorkspaceName as string);
const deleted = page
.getByText('Successfully deleted', { exact: true })
.waitFor({ state: 'visible' });
await page.getByTestId('delete-workspace-confirm-button').click();
await deleted;
await openHomePage(page);
await expect(page.getByTestId('new-workspace')).toBeVisible();
await createLocalWorkspace({ name: 'Test Workspace' }, page, true);
@@ -1,22 +1,21 @@
import { test } from '@affine-test/kit/mobile';
import { expect, type Page } from '@playwright/test';
import { expandCollapsibleSection, openTab } from './utils';
import { expandCollapsibleSection, openTab, pageBack } from './utils';
const locateBack = (page: Page) => page.getByTestId('page-header-back');
test('new doc via app tab should not show back', async ({ page }) => {
// directly open new doc, should not show back
test('new doc returns to its source without creating a New destination', async ({
page,
}) => {
await openTab(page, 'New Page');
await expect(locateBack(page)).not.toBeVisible();
const docId = await page.evaluate(() => location.pathname.split('/').pop());
await expect(locateBack(page)).toBeVisible();
await pageBack(page);
await expect(page).toHaveURL(/\/home$/);
// back to home, and open the doc, should show back
await openTab(page, 'home');
await expandCollapsibleSection(page, 'recent');
const docCard = page.locator(
`[data-testid="doc-card"][data-doc-id="${docId}"]`
);
const docCard = page.getByTestId('doc-card').first();
await expect(docCard).toBeVisible();
await docCard.click();
await expect(locateBack(page)).toBeVisible();
});
+10 -5
View File
@@ -5,7 +5,14 @@ const openDocInfoModal = async (page: Page) => {
await page.click('[data-testid="detail-page-header-more-button"]');
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('menuitem', { name: 'view info' }).click();
const viewInfo = page.getByRole('button', { name: 'view info' });
const viewInfoBox = await viewInfo.boundingBox();
expect(viewInfoBox).not.toBeNull();
if (!viewInfoBox) throw new Error('View info action has no layout box');
await page.touchscreen.tap(
viewInfoBox.x + viewInfoBox.width / 2,
viewInfoBox.y + viewInfoBox.height / 2
);
await expect(page.getByTestId('mobile-menu-back-button')).toBeVisible();
};
@@ -26,9 +33,7 @@ test('switch to edgeless mode', async ({ page }) => {
await page.click('[data-testid="detail-page-header-more-button"]');
await expect(page.getByRole('dialog')).toBeVisible();
await page
.getByRole('menuitem', { name: 'Default to Edgeless mode' })
.click();
await page.getByRole('button', { name: 'Default to Edgeless mode' }).click();
await expect(page.locator('.affine-edgeless-viewport')).toBeVisible();
});
@@ -46,7 +51,7 @@ test('can add text property', async ({ page }) => {
).toBeVisible();
await page.getByRole('button', { name: 'Add property' }).click();
await page.getByRole('menuitem', { name: 'Text' }).click();
await page.getByRole('button', { name: 'Text' }).click();
await expect(
page.getByTestId('mobile-menu-back-button').last()
@@ -3,7 +3,11 @@ import { getBlockSuiteEditorTitle } from '@affine-test/kit/utils/page-logic';
import { getCurrentDocIdFromUrl } from '@affine-test/kit/utils/url';
import { expect } from '@playwright/test';
import { expandCollapsibleSection, pageBack } from './utils';
import {
expandCollapsibleSection,
openNavigationPanelNodeSwipeMenu,
pageBack,
} from './utils';
test('Create new doc in favorites', async ({ page }) => {
const section = await expandCollapsibleSection(page, 'favorites');
@@ -24,6 +28,17 @@ test('Create new doc in favorites', async ({ page }) => {
const node = section2.getByTestId(`navigation-panel-doc-${docId}`);
await expect(node).toBeVisible();
await node.getByTestId('navigation-panel-collapsed-button').tap();
await expect(
page.getByTestId('navigation-panel-doc-add-linked-page')
).toBeVisible();
await openNavigationPanelNodeSwipeMenu(page, node);
const menu = page.getByRole('dialog');
await expect(menu).toBeVisible();
await expect(menu.getByRole('button', { name: 'Duplicate' })).toBeVisible();
await expect(page.locator('#app-tabs')).toHaveCount(1);
// const label = node.getByText(testTitleText);
// await expect(label).toBeVisible();
});
@@ -44,7 +44,7 @@ const createSubFolder = async (page: Page, parent: Locator, name: string) => {
await page.keyboard.type(name);
await dialog.getByTestId('rename-confirm').tap();
await expect(dialog).not.toBeVisible();
const node = await locateFolder(parent, name);
const node = await locateFolder(page, name);
return node;
};
+24 -1
View File
@@ -45,6 +45,7 @@ async function createRootTag(
// set color
await changeTagColor(dialog, color);
// confirm
await dialog.getByTestId('rename-input').focus();
await dialog.getByTestId('rename-confirm').tap();
const tag = await locateTag(section, name);
await expect(tag).toBeVisible();
@@ -55,7 +56,10 @@ async function createRootTag(
}
test('create a tag from navigation panel', async ({ page }) => {
await createRootTag(page, 'Test Tag');
const tag = await createRootTag(page, 'Test Tag');
await tag.getByTestId('navigation-panel-collapsed-button').tap();
await expect(tag).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByTestId('navigation-panel-tag-add-page')).toBeVisible();
});
test('rename a tag from navigation panel', async ({ page }) => {
@@ -64,6 +68,25 @@ test('rename a tag from navigation panel', async ({ page }) => {
const tag = await createRootTag(page, originalName);
const menu = await openNavigationPanelNodeMenu(page, tag);
const rename = menu.getByTestId('rename-tag');
const renameBox = await rename.boundingBox();
expect(renameBox).not.toBeNull();
if (!renameBox) throw new Error('Rename action has no layout box');
const touchPoint = {
x: renameBox.x + renameBox.width / 2,
y: renameBox.y + renameBox.height / 2,
};
await page.touchscreen.tap(touchPoint.x, touchPoint.y);
await expect(menu.getByTestId('rename-input')).toBeVisible();
const back = menu.getByTestId('mobile-menu-back-button').last();
const backBox = await back.boundingBox();
expect(backBox).not.toBeNull();
if (!backBox) throw new Error('Rename back button has no layout box');
await page.touchscreen.tap(
backBox.x + backBox.width / 2,
backBox.y + backBox.height / 2
);
await expect(menu.getByTestId('rename-tag')).toBeVisible();
await menu.getByTestId('rename-tag').tap();
const focusedTestid = await getAttrOfActiveElement(page);
expect(focusedTestid).toEqual('rename-input');
+111 -1
View File
@@ -1,14 +1,80 @@
import { test } from '@affine-test/kit/mobile';
import { expect } from '@playwright/test';
import { expandCollapsibleSection } from './utils';
import { expandCollapsibleSection, pageBack } from './utils';
declare global {
interface Window {
currentWorkspace?: {
meta: { id: string; flavour: string };
};
}
}
test('after loaded, will land on the home page', async ({ page }) => {
await expect(page).toHaveURL(/.*\/home/);
});
test('stale first-open state still restores one local workspace', async ({
browser,
}) => {
const context = await browser.newContext();
await context.addInitScript(() => {
window.localStorage.setItem('app_config', '{"onBoarding":false}');
window.localStorage.setItem('is-first-open', 'false');
});
const page = await context.newPage();
await page.goto('http://localhost:8080/');
await page.waitForFunction(() => window.currentWorkspace !== undefined);
const firstWorkspace = await page.evaluate(async () => {
if (!window.currentWorkspace) {
await new Promise<void>(resolve => {
window.addEventListener('affine:workspace:change', () => resolve(), {
once: true,
});
});
}
return window.currentWorkspace?.meta;
});
expect(firstWorkspace?.flavour).toBe('local');
await page.goto(
`http://localhost:8080/workspace/${firstWorkspace?.id ?? ''}/home`
);
await expect(page.getByRole('searchbox')).toBeVisible();
await page
.locator('[data-testid="workspace-selector-trigger"]:visible')
.click();
await expect(
page.getByRole('dialog').getByTestId('workspace-avatar')
).toHaveCount(1);
await page.reload();
await page.waitForFunction(() => window.currentWorkspace !== undefined);
const reloadedWorkspace = await page.evaluate(
() => window.currentWorkspace?.meta
);
expect(reloadedWorkspace?.id).toBe(firstWorkspace?.id);
await context.close();
});
test('workspace selector does not offer workspace creation', async ({
page,
}) => {
await expect(page.getByRole('searchbox')).toBeVisible();
await page
.locator('[data-testid="workspace-selector-trigger"]:visible')
.click();
await expect(page.getByText('Workspace', { exact: true })).toBeVisible();
await expect(page.getByTestId('new-workspace')).toHaveCount(0);
await expect(page.getByText('Name your workspace')).toHaveCount(0);
});
test('app tabs is visible', async ({ page }) => {
const tabs = page.locator('#app-tabs');
await expect(tabs).toHaveCount(1);
await expect(tabs).toBeVisible();
await expect(tabs.getByRole('tab', { name: 'home' })).toBeVisible();
@@ -17,6 +83,16 @@ test('app tabs is visible', async ({ page }) => {
await expect(tabs.getByRole('tab', { name: 'new' })).toBeVisible();
});
test('notifications prompt unauthenticated users to sign in', async ({
page,
}) => {
await page.getByTestId('notification-button').tap();
const notifications = page.getByText('Sign in to continue');
await expect(notifications).toBeVisible();
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByTestId('continue-login-button')).toBeVisible();
});
test('recent docs', async ({ page }) => {
const recentSection = await expandCollapsibleSection(page, 'recent');
@@ -48,5 +124,39 @@ test('all tab', async ({ page }) => {
await docsTab.click();
const todayDocs = page.getByTestId('doc-list-item');
await expect(todayDocs.first()).toBeVisible();
expect(await todayDocs.count()).toBeGreaterThan(0);
});
test('search restores query and results without reopening the keyboard', async ({
page,
}) => {
await expandCollapsibleSection(page, 'recent');
const title = await page
.getByTestId('doc-card')
.first()
.getByTestId('doc-card-header')
.getByRole('heading')
.textContent();
expect(title).toBeTruthy();
await page.getByRole('searchbox').click();
await expect(page).toHaveURL(/\/search$/);
await expect(page.locator('#app-tabs')).toBeHidden();
const search = page.getByRole('searchbox');
await search.fill(title ?? '');
const result = page.locator('[data-scroll] [data-testid="doc-card"]').first();
await expect(result).toBeVisible();
await result.click();
await expect(page).not.toHaveURL(/\/search$/);
await pageBack(page);
await expect(page).toHaveURL(/\/search$/);
await expect(page.getByRole('searchbox')).toHaveValue(title ?? '');
await expect(
page.locator('[data-scroll] [data-testid="doc-card"]').first()
).toBeVisible();
expect(
await page
.getByRole('searchbox')
.evaluate(el => el === document.activeElement)
).toBe(false);
});
+1
View File
@@ -9,6 +9,7 @@ const openSettings = async (page: Page) => {
test('can open settings', async ({ page }) => {
await openSettings(page);
await expect(page.getByText('Devices', { exact: true })).toHaveCount(0);
});
test('can change theme', async ({ page }) => {
+48 -3
View File
@@ -2,16 +2,24 @@ import { expect, type Locator, type Page } from '@playwright/test';
export async function expandCollapsibleSection(page: Page, name: string) {
const divider = page.locator(`[data-collapsible]:has-text("${name}")`);
await divider.waitFor({ state: 'visible' });
// oxlint-disable-next-line prefer-dom-node-dataset
if ((await divider.getAttribute('data-collapsed')) === 'true') {
await divider.click();
if ((await divider.getAttribute('role')) === 'switch') {
await divider.click();
} else {
await divider.getByRole('button').click();
}
}
await expect(divider).toHaveAttribute('data-collapsed', 'false');
const section = divider.locator(
'~ [data-testid="collapsible-section-content"]'
);
await expect(section).toBeVisible();
return section;
if ((await section.count()) > 0) {
await expect(section).toBeVisible();
return section;
}
return page.locator('body');
}
/**
@@ -42,6 +50,43 @@ export async function openNavigationPanelNodeMenu(page: Page, node: Locator) {
return menu;
}
export async function openNavigationPanelNodeSwipeMenu(
page: Page,
node: Locator
) {
if (page.context().browser()?.browserType().name() !== 'chromium') {
await node
.getByTestId('swipe-menu-trigger')
.evaluate(button =>
button.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true })
)
);
return;
}
const box = await node.boundingBox();
if (!box) throw new Error('Navigation row is not visible');
const session = await page.context().newCDPSession(page);
try {
const y = box.y + box.height / 2;
await session.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: box.x + box.width - 20, y }],
});
await session.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: box.x, y }],
});
await session.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: [],
});
} finally {
await session.detach();
}
}
export async function openTab(
page: Page,
name: 'home' | 'all' | 'Journal' | 'New Page'
+2 -3
View File
@@ -13,9 +13,8 @@ export const test = baseTest.extend<{
}>({
page: async ({ page }, use) => {
await page.goto('/');
await expect(
page.locator('.affine-page-viewport[data-mode="page"]')
).toBeVisible({
const editor = page.locator('.affine-page-viewport[data-mode="page"]');
await expect(editor).toBeVisible({
timeout: 30 * 1000,
});
await page.goto('/');
+4 -3
View File
@@ -31,8 +31,9 @@ export async function createLocalWorkspace(
// }, []);
// input workspace name
await page.getByPlaceholder('Set a Workspace name').click();
await page.getByPlaceholder('Set a Workspace name').fill(params.name);
const workspaceName = page.getByTestId('create-workspace-input');
await workspaceName.click();
await workspaceName.fill(params.name);
await page.getByTestId('server-selector-trigger').click();
const serverSelectorList = page.getByTestId('server-selector-list');
@@ -45,7 +46,7 @@ export async function createLocalWorkspace(
await expect(
page.getByTestId('create-workspace-create-button')
).not.toBeAttached();
).not.toBeAttached({ timeout: 30 * 1000 });
await waitForEditorLoad(page);
await expect(page.getByTestId('workspace-name')).toHaveText(params.name);