From 61c9e803f142cec9da8be0002fcec5b9ab7d7745 Mon Sep 17 00:00:00 2001 From: likaixuan Date: Tue, 20 Oct 2026 19:47:56 +0800 Subject: [PATCH] fix(app): keep every floating panel host registered Anchored panels resolve their portal host by name. Several screens can mount a host under the same name, but the registry kept only the newest element: when that element unmounted, its cleanup deleted the entry, and the surviving host was no longer measurable. Every anchored panel then returned null with no error and no log, until something remounted the screen or reloaded the window. Track all hosts per name and measure the newest laid-out one, so unmounting a host only removes that host. Measurement now skips hosts that report no size, which covers retained copies that are still mounted but not laid out. --- .../ui/floating-panel-hosts.test.ts | 65 +++++++++++++++++++ .../src/components/ui/floating-panel-hosts.ts | 57 ++++++++++++++++ .../components/ui/floating-panel-portal.tsx | 36 +++------- 3 files changed, 132 insertions(+), 26 deletions(-) create mode 100644 packages/app/src/components/ui/floating-panel-hosts.test.ts create mode 100644 packages/app/src/components/ui/floating-panel-hosts.ts diff --git a/packages/app/src/components/ui/floating-panel-hosts.test.ts b/packages/app/src/components/ui/floating-panel-hosts.test.ts new file mode 100644 index 0000000000..a4b52d3a2c --- /dev/null +++ b/packages/app/src/components/ui/floating-panel-hosts.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + createFloatingPanelHosts, + type FloatingPanelHostRect, + type MeasurableFloatingPanelHost, +} from "./floating-panel-hosts"; + +function createHost(rect: FloatingPanelHostRect): MeasurableFloatingPanelHost { + return { + measureInWindow: (callback) => callback(rect.x, rect.y, rect.width, rect.height), + }; +} + +const WORKSPACE_RECT: FloatingPanelHostRect = { x: 10, y: 20, width: 800, height: 600 }; +const RETAINED_VIEWPORT: FloatingPanelHostRect = { x: 0, y: 0, width: 1200, height: 900 }; + +describe("floating panel hosts", () => { + it("measures the newest host registered under the name", async () => { + const hosts = createFloatingPanelHosts(); + hosts.register("workspace", createHost(WORKSPACE_RECT)); + hosts.register("workspace", createHost(RETAINED_VIEWPORT)); + + expect(await hosts.measure("workspace")).toEqual(RETAINED_VIEWPORT); + }); + + it("keeps measuring a survivor after the newest host unregisters", async () => { + const hosts = createFloatingPanelHosts(); + const unregisterSurvivor = hosts.register("workspace", createHost(WORKSPACE_RECT)); + const unregisterNewest = hosts.register("workspace", createHost(RETAINED_VIEWPORT)); + + unregisterNewest(); + + expect(await hosts.measure("workspace")).toEqual(WORKSPACE_RECT); + unregisterSurvivor(); + }); + + it("returns null once every host under the name is gone", async () => { + const hosts = createFloatingPanelHosts(); + const unregisterFirst = hosts.register("workspace", createHost(WORKSPACE_RECT)); + const unregisterSecond = hosts.register("workspace", createHost(RETAINED_VIEWPORT)); + + unregisterFirst(); + unregisterSecond(); + + expect(await hosts.measure("workspace")).toBeNull(); + }); + + it("skips hosts that are not laid out", async () => { + const hosts = createFloatingPanelHosts(); + hosts.register("workspace", createHost(WORKSPACE_RECT)); + hosts.register("workspace", createHost({ x: 0, y: 0, width: 0, height: 0 })); + + expect(await hosts.measure("workspace")).toEqual(WORKSPACE_RECT); + }); + + it("ignores a repeated unregister of the same host", async () => { + const hosts = createFloatingPanelHosts(); + const unregister = hosts.register("workspace", createHost(WORKSPACE_RECT)); + + unregister(); + unregister(); + + expect(await hosts.measure("workspace")).toBeNull(); + }); +}); diff --git a/packages/app/src/components/ui/floating-panel-hosts.ts b/packages/app/src/components/ui/floating-panel-hosts.ts new file mode 100644 index 0000000000..88451b1ebc --- /dev/null +++ b/packages/app/src/components/ui/floating-panel-hosts.ts @@ -0,0 +1,57 @@ +export interface FloatingPanelHostRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface MeasurableFloatingPanelHost { + measureInWindow(callback: (x: number, y: number, width: number, height: number) => void): void; +} + +export interface FloatingPanelHosts { + register(name: string, host: T): () => void; + measure(name: string): Promise; +} + +function measureHost(host: MeasurableFloatingPanelHost): Promise { + return new Promise((resolve) => { + host.measureInWindow((x, y, width, height) => resolve({ x, y, width, height })); + }); +} + +/** + * Hosts are addressed by name, and one name can have several live hosts: a workspace + * screen mounts its own host while retained copies of that screen stay mounted. Track + * every host so unmounting one leaves the others measurable. + */ +export function createFloatingPanelHosts< + T extends MeasurableFloatingPanelHost, +>(): FloatingPanelHosts { + const hostsByName = new Map(); + + function register(name: string, host: T): () => void { + const hosts = hostsByName.get(name) ?? []; + hostsByName.set(name, hosts); + hosts.push(host); + + return () => { + const index = hosts.indexOf(host); + if (index === -1) return; + hosts.splice(index, 1); + if (hosts.length === 0 && hostsByName.get(name) === hosts) { + hostsByName.delete(name); + } + }; + } + + async function measure(name: string): Promise { + const hosts = hostsByName.get(name); + if (!hosts || hosts.length === 0) return null; + + const rects = await Promise.all(hosts.toReversed().map(measureHost)); + return rects.find((rect) => rect.width > 0 && rect.height > 0) ?? rects[0]; + } + + return { register, measure }; +} diff --git a/packages/app/src/components/ui/floating-panel-portal.tsx b/packages/app/src/components/ui/floating-panel-portal.tsx index 21ea94a03f..e19e1a5575 100644 --- a/packages/app/src/components/ui/floating-panel-portal.tsx +++ b/packages/app/src/components/ui/floating-panel-portal.tsx @@ -8,32 +8,21 @@ import { } from "react"; import { StyleSheet, View } from "react-native"; import { PortalHost } from "@gorhom/portal"; +import { + createFloatingPanelHosts, + type FloatingPanelHostRect, +} from "@/components/ui/floating-panel-hosts"; export const DEFAULT_FLOATING_PANEL_PORTAL_HOST = "content-floating-panels"; const FloatingPanelPortalHostNameContext = createContext(DEFAULT_FLOATING_PANEL_PORTAL_HOST); -interface Rect { - x: number; - y: number; - width: number; - height: number; -} +const portalHosts = createFloatingPanelHosts(); -const portalHostElements = new Map(); - -function measureElement(element: View): Promise { - return new Promise((resolve) => { - element.measureInWindow((x, y, width, height) => { - resolve({ x, y, width, height }); - }); - }); -} - -export function measureFloatingPanelPortalHost(name: string): Promise { - const element = portalHostElements.get(name); - if (!element) return Promise.resolve(null); - return measureElement(element); +export function measureFloatingPanelPortalHost( + name: string, +): Promise { + return portalHosts.measure(name); } export function FloatingPanelPortalHostNameProvider({ @@ -64,12 +53,7 @@ export function FloatingPanelPortalHost({ useEffect(() => { const host = hostRef.current; if (!host) return undefined; - portalHostElements.set(name, host); - return () => { - if (portalHostElements.get(name) === host) { - portalHostElements.delete(name); - } - }; + return portalHosts.register(name, host); }, [name]); return (