Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions e2e/ecosystem-miniapp.mock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,48 @@ test.describe('小程序权限集成测试', () => {
await injectTestData(page)
})

test('底部 Tab 切换后返回生态,小程序仍可见', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem(
'ecosystem_my_apps',
JSON.stringify([{ appId: 'xin.dweb.teleport', installedAt: Date.now() - 3600000, lastUsedAt: Date.now() - 1800000 }])
)
})

await page.goto('/#/')
await page.waitForLoadState('networkidle')
await page.waitForTimeout(500)

// 进入生态 - 我的,并打开 Teleport
await page.getByTestId('tab-ecosystem').click()
await page.waitForTimeout(300)
await swipeToMyAppsPage(page)
await page.waitForTimeout(300)

await page.locator('[data-testid="ios-app-icon-xin.dweb.teleport"]').click({ button: 'right' })
await page.waitForTimeout(200)
await page.locator('button:has-text("打开")').click()

const teleportFrame = page.frameLocator('iframe[data-app-id="xin.dweb.teleport"]')
await teleportFrame.getByRole('button', { name: '启动传送门' }).waitFor({ state: 'visible', timeout: 15000 })

// 切走到底部其他 tab,再切回来
await page.getByTestId('tab-wallet').click()
await page.waitForTimeout(300)
await page.getByTestId('tab-ecosystem').click()
await page.waitForTimeout(500)

// 生态页重新挂载后需要再次滑到"我的"页
await swipeToMyAppsPage(page)
await page.waitForTimeout(300)

// Teleport iframe/窗口应重新可见
await expect(page.locator('[data-testid="miniapp-window"][data-app-id="xin.dweb.teleport"]')).toBeVisible({
timeout: 15000,
})
await expect(teleportFrame.getByRole('button', { name: '启动传送门' })).toBeVisible({ timeout: 15000 })
})

test('Teleport 启动传送门应弹出权限请求', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem(
Expand Down
2 changes: 2 additions & 0 deletions src/StackflowApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export function StackflowApp() {
<MiniappWindow />
{/* 层叠视图 - 多应用管理 */}
<MiniappStackView visible={isStackViewOpen} onClose={closeStackView} />
{/* Fallback 容器 - 当 slot lost 时保持 MiniappWindow 挂载 */}
<div id="miniapp-fallback-portal" style={{ display: 'contents' }} />
</>
</LayoutGroup>
</MiniappVisualProvider>
Expand Down
167 changes: 166 additions & 1 deletion src/components/ecosystem/miniapp-window.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
*/

import type { Meta, StoryObj } from '@storybook/react';
import { useRef, useEffect, useState } from 'react';
import { useRef, useEffect, useState, Activity } from 'react';
import { within, userEvent, expect } from '@storybook/test';
import { MiniappSplashScreen } from './miniapp-splash-screen';
import { MiniappCapsule } from './miniapp-capsule';
import { MiniappWindow } from './miniapp-window';
Expand All @@ -17,6 +18,7 @@ import {
launchApp,
resetMiniappVisualConfig,
setMiniappMotionTimeScale,
useMiniappVisibilityRestore,
} from '@/services/miniapp-runtime';
import { MiniappVisualProvider } from '@/services/miniapp-runtime/MiniappVisualProvider';
import type { MiniappManifest } from '@/services/ecosystem';
Expand Down Expand Up @@ -271,3 +273,166 @@ export const LaunchDemo: Story = {
);
},
};

/**
* 模拟 EcosystemTab 组件
* 在 Activity 内部调用 activateApp,确保 slots 已注册
*/
function EcosystemTabMock({
desktopRef,
myApps,
onAppOpen,
}: {
desktopRef: React.RefObject<EcosystemDesktopHandle | null>;
myApps: { app: MiniappManifest; lastUsed: number }[];
onAppOpen: (app: MiniappManifest) => void;
}) {
// 使用与真实 EcosystemTab 相同的 hook
useMiniappVisibilityRestore();

return (
<EcosystemDesktop
ref={desktopRef}
showDiscoverPage={false}
showStackPage="auto"
apps={mockApps}
myApps={myApps}
onAppOpen={onAppOpen}
onAppDetail={(app) => console.log('Detail:', app.name)}
onAppRemove={(id) => console.log('Remove:', id)}
/>
);
}

/**
* Tab 切换可见性测试
*
* 使用真实的 TabBar 和 React 19 Activity 模拟底部 Tab 切换场景:
* - MiniappWindow 在全局层渲染(不随 Tab 切换卸载)
* - EcosystemTabMock 在 Activity 内,包含 activateApp 逻辑
*
* 测试步骤:
* 1. 点击生态 Tab,然后点击图标启动应用
* 2. 点击钱包 Tab 切走
* 3. 点击生态 Tab 切回
* 4. 观察小程序窗口是否仍然可见
*/
export const TabSwitchVisibility: Story = {
decorators: [
(Story) => (
<SwiperSyncProvider>
<div className="h-screen">
<Story />
</div>
</SwiperSyncProvider>
),
],
render: function TabSwitchVisibilityStory() {
const desktopRef = useRef<EcosystemDesktopHandle>(null);
const [activeTab, setActiveTab] = useState<'wallet' | 'ecosystem' | 'settings'>('ecosystem');

// 清理旧状态
useEffect(() => {
closeAllApps();
resetMiniappVisualConfig();
return () => {
resetMiniappVisualConfig();
};
}, []);



const myApps = mockApps.map((app, i) => ({
app,
lastUsed: Date.now() - i * 1000 * 60 * 60,
}));

const handleAppOpen = (app: MiniappManifest) => {
console.log('[TabSwitchVisibility] Opening app:', app.name);
const manifest: MiniappManifest = { ...app, targetDesktop: 'mine' };
desktopRef.current?.slideTo('mine');
requestAnimationFrame(() => launchApp(app.id, manifest));
};

const handleTabChange = (tab: 'wallet' | 'ecosystem' | 'settings') => {
console.log('[TabSwitchVisibility] Tab changed to:', tab);
setActiveTab(tab);
};

return (
<MiniappVisualProvider>
<div className="flex h-screen flex-col bg-background">
<div className="relative flex-1 overflow-hidden">
{/* 使用 React 19 Activity 模拟真实 Tab 切换行为 */}
<Activity mode={activeTab === 'wallet' ? 'visible' : 'hidden'}>
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<div className="text-4xl mb-2">👛</div>
<div>钱包页面</div>
</div>
</div>
</Activity>

<Activity mode={activeTab === 'ecosystem' ? 'visible' : 'hidden'}>
<EcosystemTabMock
desktopRef={desktopRef}
myApps={myApps}
onAppOpen={handleAppOpen}
/>
</Activity>

<Activity mode={activeTab === 'settings' ? 'visible' : 'hidden'}>
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<div className="text-4xl mb-2">⚙️</div>
<div>设置页面</div>
</div>
</div>
</Activity>

{/* MiniappWindow 在全局层,不随 Tab 切换卸载(与 StackflowApp 一致) */}
<MiniappWindow />
{/* Fallback 容器 - 当 slot lost 时保持 MiniappWindow 挂载 */}
<div id="miniapp-fallback-portal" style={{ display: 'contents' }} />
</div>

{/* 真实 TabBar */}
<TabBar activeTab={activeTab} onTabChange={handleTabChange} className="static" />
</div>
</MiniappVisualProvider>
);
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await new Promise((r) => setTimeout(r, 500));

await step('点击第一个应用图标启动', async () => {
const appIcon = canvas.getAllByTestId(/^ios-app-icon-/)[0];
expect(appIcon).toBeInTheDocument();
await userEvent.click(appIcon);
await new Promise((r) => setTimeout(r, 1500));
});

await step('验证窗口已渲染', async () => {
const window = canvas.getByTestId('miniapp-window');
expect(window).toBeInTheDocument();
});

await step('点击钱包 Tab 切走', async () => {
const walletTab = canvas.getByTestId('tab-wallet');
await userEvent.click(walletTab);
await new Promise((r) => setTimeout(r, 300));
});

await step('点击生态 Tab 切回', async () => {
const ecosystemTab = canvas.getByTestId('tab-ecosystem');
await userEvent.click(ecosystemTab);
await new Promise((r) => setTimeout(r, 500));
});

await step('验证窗口重新可见', async () => {
const window = canvas.getByTestId('miniapp-window');
expect(window).toBeInTheDocument();
});
},
};
29 changes: 23 additions & 6 deletions src/components/ecosystem/miniapp-window.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
didDismiss,
didPresent,
settleFlow,
useSlotStatus,
} from '@/services/miniapp-runtime';
import { getMiniappMotionPresets, type MiniappMotionPresets } from '@/services/miniapp-runtime/visual-config';
import { MiniappSplashScreen } from './miniapp-splash-screen';
Expand Down Expand Up @@ -114,8 +115,17 @@ function MiniappWindowPortal({
return a?.state === 'launching' || a?.state === 'splash' || a?.state === 'closing';
});

// 订阅 slot 状态,响应式感知 slot 注册/注销
const slotStatus = useSlotStatus(presentation.desktop, appId);
const portalHost = getDesktopAppSlotRef(presentation.desktop, appId);

// 当 slot lost 时使用 fallback 容器,保持组件挂载避免 motion 动画问题
const fallbackHost = useMemo(() => {
return document.getElementById('miniapp-fallback-portal') ?? document.body;
}, []);
const actualHost = portalHost ?? fallbackHost;
const isSlotLost = slotStatus === 'lost' || !portalHost;

useLayoutEffect(() => {
if (!app) {
setPresentApp(null);
Expand Down Expand Up @@ -209,7 +219,8 @@ function MiniappWindowPortal({
requestDismissSplash(appId);
}, [appId]);

if (!portalHost) return null;
// 当 slot lost 时禁用 layoutId,防止动画到错误位置
const enableLayoutId = !isSlotLost;

const node = (
<AnimatePresence
Expand All @@ -227,17 +238,23 @@ function MiniappWindowPortal({
<div
ref={windowRef}
className={cn(styles.window, isAnimating && styles.animating, className)}
style={{ zIndex: presentation.zOrder, pointerEvents: isFocused ? undefined : 'none' }}
style={{
zIndex: presentation.zOrder,
pointerEvents: isFocused ? undefined : 'none',
// slot lost 时隐藏窗口
visibility: isSlotLost ? 'hidden' : 'visible',
}}
data-testid="miniapp-window"
data-app-id={presentApp.appId}
data-slot-lost={isSlotLost ? 'true' : undefined}
>
<motion.div
className={styles.windowInner}
variants={WINDOW_CONTAINER_VARIANTS}
initial={false}
animate={windowContainerVariant}
onLayoutAnimationComplete={handleLayoutAnimationComplete}
{...(appDisplay.appId
{...(enableLayoutId && appDisplay.appId
? {
layoutId: `miniapp:${appDisplay.appId}:container`,
'data-layoutid': `miniapp:${appDisplay.appId}:container`,
Expand All @@ -248,7 +265,7 @@ function MiniappWindowPortal({
>
<motion.div
className={cn(styles.contentLayer, isTransitioning && styles.blending)}
{...(appDisplay.appId
{...(enableLayoutId && appDisplay.appId
? {
layoutId: `miniapp:${appDisplay.appId}:inner`,
'data-layoutid': `miniapp:${appDisplay.appId}:inner`,
Expand Down Expand Up @@ -296,7 +313,7 @@ function MiniappWindowPortal({
>
<motion.div
className="size-30 items-center justify-center"
{...(splashIconHasLayoutId && appDisplay.appId
{...(enableLayoutId && splashIconHasLayoutId && appDisplay.appId
? {
layoutId: `miniapp:${appDisplay.appId}:logo`,
'data-layoutid': `miniapp:${appDisplay.appId}:logo`,
Expand Down Expand Up @@ -326,7 +343,7 @@ function MiniappWindowPortal({
</AnimatePresence>
);

return createPortal(node, portalHost);
return createPortal(node, actualHost);
}

export default MiniappWindow;
Loading