Skip to content
Open
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
170 changes: 170 additions & 0 deletions src/engines/BrowserCore/BrowserCore.webviewRetention.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { type Root, createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import type { UseBrowserStateReturn } from "./hooks/useBrowserState";
import {
BrowserCore,
MAX_RETAINED_BROWSER_WEBVIEWS,
selectRetainedBrowserSessionIds,
} from "./index";
import type { BrowserSession } from "./types";

vi.mock("jotai", async (importOriginal) => {
const actual = await importOriginal<typeof import("jotai")>();
return {
...actual,
useAtomValue: () => false,
};
});

vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));

vi.mock("./BrowserSessionWebview", () => ({
default: ({
session,
isActive,
}: {
session: BrowserSession;
isActive: boolean;
}) =>
createElement("div", {
"data-browser-webview-session": session.id,
"data-active": String(isActive),
}),
}));

const reactActEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};

function session(
id: string,
url = `https://${id}.example.com`
): BrowserSession {
return {
id,
title: id,
url,
history: url ? [url] : [],
historyIndex: url ? 0 : -1,
historyEntries: [],
isLoading: false,
error: null,
incognito: false,
};
}

function browserState(
sessions: BrowserSession[],
activeSessionId: string
): UseBrowserStateReturn {
return {
sessions,
activeSessionId,
activeSession: sessions.find((item) => item.id === activeSessionId),
addSession: vi.fn(),
closeSession: vi.fn(),
setActiveSession: vi.fn(),
updateSession: vi.fn(),
};
}

describe("BrowserCore retained native WebViews", () => {
let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = {};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
container.remove();
Reflect.deleteProperty(
window as unknown as Record<string, unknown>,
"__TAURI_INTERNALS__"
);
Reflect.deleteProperty(reactActEnvironment, "IS_REACT_ACT_ENVIRONMENT");
vi.restoreAllMocks();
});

function renderWith(
sessions: BrowserSession[],
activeSessionId: string
): void {
act(() => {
root.render(
createElement(BrowserCore, {
browserState: browserState(sessions, activeSessionId),
})
);
});
}

function mountedSessionIds(): string[] {
return Array.from(
container.querySelectorAll("[data-browser-webview-session]")
).map((element) => element.getAttribute("data-browser-webview-session")!);
}

it("keeps only the active and most recently active native WebViews mounted", () => {
const sessions = [session("a"), session("b"), session("c")];

renderWith(sessions, "a");
expect(mountedSessionIds()).toEqual(["a"]);

renderWith(sessions, "b");
expect(mountedSessionIds()).toEqual(["a", "b"]);

renderWith(sessions, "c");
expect(mountedSessionIds()).toEqual(["b", "c"]);

renderWith(sessions, "a");
expect(mountedSessionIds()).toEqual(["a", "c"]);
expect(mountedSessionIds()).toHaveLength(MAX_RETAINED_BROWSER_WEBVIEWS);
});

it("does not mount restored background sessions or blank tabs eagerly", () => {
const sessions = [session("a"), session("blank", ""), session("c")];

renderWith(sessions, "blank");
expect(mountedSessionIds()).toEqual([]);

renderWith(sessions, "c");
expect(mountedSessionIds()).toEqual(["c"]);
});

it("keeps the native mount count bounded across repeated session switches", () => {
const sessions = [session("a"), session("b"), session("c"), session("d")];

for (let index = 0; index < 50; index += 1) {
const activeSessionId = sessions[index % sessions.length].id;
renderWith(sessions, activeSessionId);

expect(mountedSessionIds()).toContain(activeSessionId);
expect(mountedSessionIds().length).toBeLessThanOrEqual(
MAX_RETAINED_BROWSER_WEBVIEWS
);
}
});
});

describe("selectRetainedBrowserSessionIds", () => {
it("drops deleted and non-navigable sessions while preserving recency", () => {
expect(
selectRetainedBrowserSessionIds(
["deleted", "a", "blank"],
[session("a"), session("blank", ""), session("b")],
"b"
)
).toEqual(["a", "b"]);
});
});
80 changes: 69 additions & 11 deletions src/engines/BrowserCore/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,14 @@ import BrowserSessionWebview from "./BrowserSessionWebview";
import type { UseBrowserStateReturn } from "./hooks/useBrowserState";
import "./index.scss";
import { BROWSER_WEBVIEW_FRAME_ANCHOR_ATTRIBUTE } from "./nativeFrameAnchor";
import type { BrowserSession } from "./types";

const log = createLogger("BrowserCore");

const ABOUT_BLANK_URL = "about:blank";
const SHOW_WEBVIEW_FRAME_ANCHOR = false;
const EMBEDDED_BROWSER_WARNING_DELAY_MS = 3000;
export const MAX_RETAINED_BROWSER_WEBVIEWS = 2;
const EMBEDDED_BROWSER_SENSITIVE_HOSTS = new Set([
"github.com",
"www.github.com",
Expand All @@ -66,6 +68,37 @@ function shouldShowEmbeddedBrowserFallback(url?: string): boolean {
}
}

export function selectRetainedBrowserSessionIds(
previousIds: readonly string[],
sessions: readonly BrowserSession[],
activeSessionId: string,
maxRetained = MAX_RETAINED_BROWSER_WEBVIEWS
): string[] {
if (maxRetained <= 0) return [];

const navigableIds = new Set(
sessions
.filter((session) => !isBlankBrowserUrl(session.url))
.map((session) => session.id)
);
const next = previousIds.filter(
(sessionId) => navigableIds.has(sessionId) && sessionId !== activeSessionId
);

if (navigableIds.has(activeSessionId)) {
next.push(activeSessionId);
}

return next.slice(-maxRetained);
}

function sameIds(left: readonly string[], right: readonly string[]): boolean {
return (
left.length === right.length &&
left.every((sessionId, index) => sessionId === right[index])
);
}

// ============================================
// Props
// ============================================
Expand Down Expand Up @@ -112,6 +145,29 @@ export const BrowserCore: React.FC<BrowserCoreProps> = ({
}) => {
const { t } = useTranslation();
const { sessions, activeSessionId, updateSession, addSession } = browserState;
const [retainedWebviewSessionIds, setRetainedWebviewSessionIds] =
React.useState<string[]>([]);
const nextRetainedWebviewSessionIds = useMemo(
() =>
selectRetainedBrowserSessionIds(
retainedWebviewSessionIds,
sessions,
activeSessionId
),
[activeSessionId, retainedWebviewSessionIds, sessions]
);
const retainedWebviewSessionIdSet = useMemo(
() => new Set(nextRetainedWebviewSessionIds),
[nextRetainedWebviewSessionIds]
);

React.useLayoutEffect(() => {
setRetainedWebviewSessionIds((previousIds) =>
sameIds(previousIds, nextRetainedWebviewSessionIds)
? previousIds
: nextRetainedWebviewSessionIds
);
}, [nextRetainedWebviewSessionIds]);

// Check if webviews should be blocked by overlays or station ownership.
const isWebviewBlocked = useAtomValue(webviewBlockedAtom);
Expand Down Expand Up @@ -282,17 +338,19 @@ export const BrowserCore: React.FC<BrowserCoreProps> = ({

{/* Only the owning instance renders BrowserSessionWebview. */}
{manageWebviews &&
sessions.map((session) => (
<BrowserSessionWebview
key={session.id}
session={session}
isActive={session.id === activeSessionId}
isTabActive={isTabReallyActive}
containerRef={webviewFrameAnchorRef}
onSessionUpdate={updateSession}
onNewTab={addSession}
/>
))}
sessions
.filter((session) => retainedWebviewSessionIdSet.has(session.id))
.map((session) => (
<BrowserSessionWebview
key={session.id}
session={session}
isActive={session.id === activeSessionId}
isTabActive={isTabReallyActive}
containerRef={webviewFrameAnchorRef}
onSessionUpdate={updateSession}
onNewTab={addSession}
/>
))}

{/* Desktop-only notice */}
{!isWebviewAvailable && (
Expand Down
Loading
Loading