diff --git a/extension/src/bridge/BridgeContext.tsx b/extension/src/bridge/BridgeContext.tsx new file mode 100644 index 00000000..4d881edc --- /dev/null +++ b/extension/src/bridge/BridgeContext.tsx @@ -0,0 +1,25 @@ +import { invariant } from '@epic-web/invariant' +import { createContext, PropsWithChildren, useContext } from 'react' + +const BridgeContext = createContext<{ windowId: number | null }>({ + windowId: null, +}) + +type ProvideBridgeContextProps = PropsWithChildren<{ windowId: number }> + +export const ProvideBridgeContext = ({ + children, + windowId, +}: ProvideBridgeContextProps) => ( + + {children} + +) + +export const useWindowId = () => { + const { windowId } = useContext(BridgeContext) + + invariant(windowId != null, '"windowId" not set on BridgeContext') + + return windowId +} diff --git a/extension/src/bridge/index.ts b/extension/src/bridge/index.ts new file mode 100644 index 00000000..a89b84a0 --- /dev/null +++ b/extension/src/bridge/index.ts @@ -0,0 +1,2 @@ +export { ProvideBridgeContext, useWindowId } from './BridgeContext' +export { useProviderBridge } from './useProviderBridge' diff --git a/extension/src/panel/useProviderBridge.spec.ts b/extension/src/bridge/useProviderBridge.spec.tsx similarity index 77% rename from extension/src/panel/useProviderBridge.spec.ts rename to extension/src/bridge/useProviderBridge.spec.tsx index b17dd421..ddf89588 100644 --- a/extension/src/panel/useProviderBridge.spec.ts +++ b/extension/src/bridge/useProviderBridge.spec.tsx @@ -1,9 +1,16 @@ import { ZERO_ADDRESS } from '@/chains' import { InjectedProviderMessage, InjectedProviderMessageTyp } from '@/messages' -import { callListeners, chromeMock, mockActiveTab } from '@/test-utils' +import { + callListeners, + chromeMock, + createMockTab, + mockActiveTab, + renderHook, +} from '@/test-utils' import { Eip1193Provider } from '@/types' -import { cleanup, renderHook, waitFor } from '@testing-library/react' +import { cleanup, waitFor } from '@testing-library/react' import { toQuantity } from 'ethers' +import { PropsWithChildren } from 'react' import { ChainId } from 'ser-kit' import { afterEach, @@ -14,6 +21,7 @@ import { MockedFunction, vi, } from 'vitest' +import { ProvideBridgeContext } from './BridgeContext' import { useProviderBridge } from './useProviderBridge' describe('Bridge', () => { @@ -28,17 +36,23 @@ describe('Bridge', () => { } } + const Wrapper = ({ children }: PropsWithChildren) => ( + {children} + ) + afterEach(cleanup) describe('Provider handling', () => { beforeEach(() => { - mockActiveTab() + mockActiveTab({ windowId: 1 }) }) it('relays requests to the provider', async () => { const provider = new MockProvider() - renderHook(() => useProviderBridge({ provider })) + await renderHook(() => useProviderBridge({ provider }), { + wrapper: Wrapper, + }) const request = { method: 'eth_chainId' } @@ -49,7 +63,7 @@ describe('Bridge', () => { request, requestId: 'test-id', } satisfies InjectedProviderMessage, - { id: chromeMock.runtime.id }, + { id: chromeMock.runtime.id, tab: createMockTab({ windowId: 1 }) }, vi.fn() ) @@ -63,7 +77,9 @@ describe('Bridge', () => { provider.request.mockResolvedValue(response) - renderHook(() => useProviderBridge({ provider })) + await renderHook(() => useProviderBridge({ provider }), { + wrapper: Wrapper, + }) const request = { method: 'eth_chainId' } @@ -76,7 +92,7 @@ describe('Bridge', () => { request, requestId: 'test-id', } satisfies InjectedProviderMessage, - { id: chromeMock.runtime.id }, + { id: chromeMock.runtime.id, tab: createMockTab({ windowId: 1 }) }, sendMessage ) @@ -94,7 +110,9 @@ describe('Bridge', () => { provider.request.mockRejectedValue(error) - renderHook(() => useProviderBridge({ provider })) + await renderHook(() => useProviderBridge({ provider }), { + wrapper: Wrapper, + }) const request = { method: 'eth_chainId' } @@ -107,7 +125,7 @@ describe('Bridge', () => { request, requestId: 'test-id', } satisfies InjectedProviderMessage, - { id: chromeMock.runtime.id }, + { id: chromeMock.runtime.id, tab: createMockTab({ windowId: 1 }) }, sendMessage ) @@ -124,10 +142,10 @@ describe('Bridge', () => { const request = { method: 'eth_chainId' } - const { rerender } = renderHook( + const { rerender } = await renderHook( ({ provider }: { provider: Eip1193Provider }) => useProviderBridge({ provider }), - { initialProps: { provider: providerA } } + { initialProps: { provider: providerA }, wrapper: Wrapper } ) rerender({ provider: providerB }) @@ -139,7 +157,7 @@ describe('Bridge', () => { request, requestId: 'test-id', } satisfies InjectedProviderMessage, - { id: chromeMock.runtime.id }, + { id: chromeMock.runtime.id, tab: createMockTab({ windowId: 1 }) }, vi.fn() ) @@ -154,7 +172,10 @@ describe('Bridge', () => { it('emits an "accountsChanged" event when the hook initially renders with an account', async () => { const tab = mockActiveTab() - renderHook(() => useProviderBridge({ provider, account: ZERO_ADDRESS })) + await renderHook( + () => useProviderBridge({ provider, account: ZERO_ADDRESS }), + { wrapper: Wrapper } + ) await waitFor(() => { expect(chromeMock.tabs.sendMessage).toHaveBeenCalledWith(tab.id, { @@ -168,7 +189,9 @@ describe('Bridge', () => { it('does not emit an "accountsChanged" event when there is no account on the first render', async () => { mockActiveTab() - renderHook(() => useProviderBridge({ provider })) + await renderHook(() => useProviderBridge({ provider }), { + wrapper: Wrapper, + }) expect(chromeMock.tabs.sendMessage).not.toHaveBeenCalledWith() }) @@ -176,11 +199,12 @@ describe('Bridge', () => { it('does emit an "accountsChanged" event when the account resets on later renders', async () => { const tab = mockActiveTab() - const { rerender } = renderHook< + const { rerender } = await renderHook< void, { account: `0x${string}` | undefined } >(({ account }) => useProviderBridge({ provider, account }), { initialProps: { account: ZERO_ADDRESS }, + wrapper: Wrapper, }) rerender({ account: undefined }) @@ -192,10 +216,6 @@ describe('Bridge', () => { eventData: [], }) }) - - renderHook(() => useProviderBridge({ provider })) - - expect(chromeMock.tabs.sendMessage).not.toHaveBeenCalledWith() }) }) @@ -205,7 +225,9 @@ describe('Bridge', () => { it('emits a "connect" event when the chainId is initially set', async () => { const tab = mockActiveTab() - renderHook(() => useProviderBridge({ provider, chainId: 1 })) + await renderHook(() => useProviderBridge({ provider, chainId: 1 }), { + wrapper: Wrapper, + }) await waitFor(() => { expect(chromeMock.tabs.sendMessage).toHaveBeenCalledWith(tab.id, { @@ -219,9 +241,9 @@ describe('Bridge', () => { it('emits a "chainChanged" event when the chain changes on a later render', async () => { const tab = mockActiveTab() - const { rerender } = renderHook( + const { rerender } = await renderHook( ({ chainId }) => useProviderBridge({ provider, chainId }), - { initialProps: { chainId: 1 } } + { initialProps: { chainId: 1 }, wrapper: Wrapper } ) rerender({ chainId: 10 }) diff --git a/extension/src/panel/useProviderBridge.ts b/extension/src/bridge/useProviderBridge.ts similarity index 94% rename from extension/src/panel/useProviderBridge.ts rename to extension/src/bridge/useProviderBridge.ts index 2b8ed119..481fce5d 100644 --- a/extension/src/panel/useProviderBridge.ts +++ b/extension/src/bridge/useProviderBridge.ts @@ -6,13 +6,7 @@ import { invariant } from '@epic-web/invariant' import { toQuantity } from 'ethers' import { useCallback, useEffect, useRef } from 'react' import { ChainId } from 'ser-kit' - -let windowId: number | undefined - -/** Set the window ID RPC events will only be relayed to tabs in this window */ -export const setWindowId = (id: number) => { - windowId = id -} +import { useWindowId } from './BridgeContext' const emitEvent = async (eventName: string, eventData: any) => { const tab = await getActiveTab() @@ -71,6 +65,8 @@ export const useProviderBridge = ({ } const useHandleProviderRequests = (provider: Eip1193Provider) => { + const windowId = useWindowId() + const handleMessage = useCallback( ( message: InjectedProviderMessage, @@ -116,7 +112,7 @@ const useHandleProviderRequests = (provider: Eip1193Provider) => { // without this the response won't be sent return true }, - [provider] + [provider, windowId] ) useEffect(() => { diff --git a/extension/src/panel/app-routes/index.tsx b/extension/src/panel/app-routes/index.tsx index 4cc97b81..89def0a5 100644 --- a/extension/src/panel/app-routes/index.tsx +++ b/extension/src/panel/app-routes/index.tsx @@ -1,9 +1,9 @@ +import { useProviderBridge } from '@/bridge' import { getChainId } from '@/chains' import { useProvider } from '@/providers' import { useMarkRouteAsUsed, useZodiacRoute } from '@/zodiac-routes' import { Outlet, RouteObject } from 'react-router-dom' import { parsePrefixedAddress } from 'ser-kit' -import { useProviderBridge } from '../useProviderBridge' import { useStorage } from '../utils' import { EditRoute } from './edit-route' import { ListRoutes } from './list-routes' diff --git a/extension/src/panel/app.tsx b/extension/src/panel/app.tsx index 403679b9..0a244266 100644 --- a/extension/src/panel/app.tsx +++ b/extension/src/panel/app.tsx @@ -1,5 +1,6 @@ // This is the entrypoint to the panel app. // It has access to chrome.* APIs, but it can't interact with other extensions such as MetaMask. +import { ProvideBridgeContext } from '@/bridge' import { ZodiacToastContainer } from '@/components' import { ProvideInjectedWallet, ProvideProvider } from '@/providers' import { ProvideZodiacRoutes } from '@/zodiac-routes' @@ -10,24 +11,30 @@ import { createHashRouter, RouterProvider } from 'react-router-dom' import 'react-toastify/dist/ReactToastify.css' import { appRoutes } from './app-routes' import './global.css' -import { initPort } from './port' import { ProvideState } from './state' - -initPort() +import { usePilotPort } from './usePilotPort' const router = createHashRouter(appRoutes) const Root = () => { + const { activeWindowId } = usePilotPort() + + if (activeWindowId == null) { + return null + } + return ( -
- - -
+ +
+ + +
+
diff --git a/extension/src/panel/port.ts b/extension/src/panel/port.ts deleted file mode 100644 index 1079d7ab..00000000 --- a/extension/src/panel/port.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Message, PilotMessageType } from '@/messages' -import { getActiveTab, isValidTab } from '@/utils' -import { PILOT_PANEL_PORT } from '../const' -import { setWindowId } from './useProviderBridge' - -// notify the background script that the panel has been opened -export const initPort = async () => { - const activeTab = await getActiveTab() - - const windowId = activeTab.windowId - setWindowId(activeTab.windowId) - - const { promise, resolve } = Promise.withResolvers() - - if (!isValidTab(activeTab.url)) { - const handleActivate = () => { - chrome.tabs.onActivated.removeListener(handleActivate) - chrome.tabs.onUpdated.removeListener(handleUpdated) - - resolve(initPort()) - } - - chrome.tabs.onActivated.addListener(handleActivate) - - const handleUpdated = ( - updatedTabId: number, - changeInfo: chrome.tabs.TabChangeInfo - ) => { - if (updatedTabId !== activeTab.id) { - return - } - - if (changeInfo.url == null) { - return - } - - if (!isValidTab(changeInfo.url)) { - return - } - - chrome.tabs.onUpdated.removeListener(handleUpdated) - chrome.tabs.onActivated.removeListener(handleActivate) - - resolve(initPort()) - } - - chrome.tabs.onUpdated.addListener(handleUpdated) - - return promise - } - - const connectPort = () => { - const port = chrome.runtime.connect({ name: PILOT_PANEL_PORT }) - - port.postMessage({ - type: PilotMessageType.PILOT_PANEL_OPENED, - windowId, - tabId: activeTab.id, - } satisfies Message) - } - - if (activeTab.status === 'loading') { - const handleTabLoad = ( - tabId: number, - changeInfo: chrome.tabs.TabChangeInfo - ) => { - if (tabId !== activeTab.id) { - return - } - - if (changeInfo.status !== 'complete') { - return - } - - chrome.tabs.onUpdated.removeListener(handleTabLoad) - - connectPort() - } - - chrome.tabs.onUpdated.addListener(handleTabLoad) - } else { - connectPort() - } -} diff --git a/extension/src/panel/port.spec.ts b/extension/src/panel/usePilotPort.spec.ts similarity index 59% rename from extension/src/panel/port.spec.ts rename to extension/src/panel/usePilotPort.spec.ts index 02fbdefa..bc8c49ee 100644 --- a/extension/src/panel/port.spec.ts +++ b/extension/src/panel/usePilotPort.spec.ts @@ -6,24 +6,30 @@ import { createMockTab, mockActiveTab, mockRuntimeConnect, + renderHook, } from '@/test-utils' import { sleep } from '@/utils' -import { describe, expect, it } from 'vitest' -import { initPort } from './port' +import { cleanup, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { usePilotPort } from './usePilotPort' + +describe('usePilotPort', () => { + afterEach(cleanup) -describe('Init port', () => { it('sends the PILOT_PANEL_OPEN event to the current tab', async () => { const tab = mockActiveTab() const port = createMockPort() mockRuntimeConnect(port) - await initPort() + await renderHook(() => usePilotPort()) - expect(port.postMessage).toHaveBeenCalledWith({ - type: PilotMessageType.PILOT_PANEL_OPENED, - windowId: tab.windowId, - tabId: tab.id, + await waitFor(() => { + expect(port.postMessage).toHaveBeenCalledWith({ + type: PilotMessageType.PILOT_PANEL_OPENED, + windowId: tab.windowId, + tabId: tab.id, + }) }) }) @@ -33,16 +39,25 @@ describe('Init port', () => { mockRuntimeConnect(port) - await initPort() + await renderHook(() => usePilotPort()) expect(port.postMessage).not.toHaveBeenCalled() - chromeMock.tabs.onUpdated.callListeners(tab.id, { status: 'complete' }, tab) + mockActiveTab({ ...tab, status: 'complete' }) - expect(port.postMessage).toHaveBeenCalledWith({ - type: PilotMessageType.PILOT_PANEL_OPENED, - windowId: tab.windowId, - tabId: tab.id, + await callListeners( + chromeMock.tabs.onUpdated, + tab.id, + { status: 'complete' }, + tab + ) + + await waitFor(() => { + expect(port.postMessage).toHaveBeenCalledWith({ + type: PilotMessageType.PILOT_PANEL_OPENED, + windowId: tab.windowId, + tabId: tab.id, + }) }) }) @@ -56,11 +71,7 @@ describe('Init port', () => { mockRuntimeConnect(port) - const { promise, resolve } = Promise.withResolvers() - - initPort().then(resolve) - - await sleep(1) + await renderHook(() => usePilotPort()) expect(port.postMessage).not.toHaveBeenCalled() @@ -78,8 +89,6 @@ describe('Init port', () => { windowId: regularTab.windowId, tabId: regularTab.id, }) - - return promise }) it('sends the message to the same tab when it moves to a proper URL', async () => { @@ -91,11 +100,7 @@ describe('Init port', () => { mockRuntimeConnect(port) - const { promise, resolve } = Promise.withResolvers() - - initPort().then(resolve) - - await sleep(1) + await renderHook(() => usePilotPort()) expect(port.postMessage).not.toHaveBeenCalled() @@ -117,7 +122,23 @@ describe('Init port', () => { windowId: tab.windowId, tabId: tab.id, }) + }) + + it('does not connect again, when another tab becomes active', async () => { + mockActiveTab({ id: 1, windowId: 1 }) + + const port = createMockPort() + + mockRuntimeConnect(port) + + await renderHook(() => usePilotPort()) + + expect(port.postMessage).toHaveBeenCalledTimes(1) + + mockActiveTab({ id: 2, windowId: 1 }) + + await callListeners(chromeMock.tabs.onActivated, { tabId: 2, windowId: 2 }) - return promise + expect(port.postMessage).toHaveBeenCalledTimes(1) }) }) diff --git a/extension/src/panel/usePilotPort.ts b/extension/src/panel/usePilotPort.ts new file mode 100644 index 00000000..f17f484b --- /dev/null +++ b/extension/src/panel/usePilotPort.ts @@ -0,0 +1,51 @@ +import { Message, PilotMessageType } from '@/messages' +import { isValidTab, useActiveTab } from '@/utils' +import { useEffect, useState } from 'react' +import { PILOT_PANEL_PORT } from '../const' + +// notify the background script that the panel has been opened +export const usePilotPort = () => { + const activeTab = useActiveTab() + const [portIsActive, setPortIsActive] = useState(false) + const [activeWindowId, setActiveWindowId] = useState(null) + + useEffect(() => { + if (portIsActive) { + return + } + + if (activeTab == null) { + return + } + + if (!isValidTab(activeTab.url)) { + return + } + + if (activeTab.status !== 'complete') { + return + } + + connectPort({ windowId: activeTab.windowId, tabId: activeTab.id }) + + setActiveWindowId(activeTab.windowId) + setPortIsActive(true) + }, [activeTab, portIsActive]) + + return { activeWindowId } +} + +type ConnectPortOptions = { + tabId?: number + windowId: number +} + +const connectPort = ({ tabId, windowId }: ConnectPortOptions) => { + const port = chrome.runtime.connect({ name: PILOT_PANEL_PORT }) + + port.postMessage({ + type: PilotMessageType.PILOT_PANEL_OPENED, + windowId, + tabId, + } satisfies Message) +} diff --git a/extension/src/utils/getActiveTab.ts b/extension/src/utils/getActiveTab.ts index 858e174e..0f3bd1c8 100644 --- a/extension/src/utils/getActiveTab.ts +++ b/extension/src/utils/getActiveTab.ts @@ -1,4 +1,5 @@ import { invariant } from '@epic-web/invariant' +import { useEffect, useState } from 'react' export const getActiveTab = async () => { const [activeTab] = await chrome.tabs.query({ @@ -10,3 +11,43 @@ export const getActiveTab = async () => { return activeTab } + +export const useActiveTab = () => { + const [activeTab, setActiveTab] = useState(null) + + useEffect(() => { + getActiveTab().then(setActiveTab) + }, []) + + useEffect(() => { + const handleActivate = () => getActiveTab().then(setActiveTab) + + chrome.tabs.onActivated.addListener(handleActivate) + + return () => { + chrome.tabs.onActivated.removeListener(handleActivate) + } + }, []) + + useEffect(() => { + if (activeTab == null) { + return + } + + const handleUpdate = (tabId: number) => { + if (tabId !== activeTab.id) { + return + } + + getActiveTab().then(setActiveTab) + } + + chrome.tabs.onUpdated.addListener(handleUpdate) + + return () => { + chrome.tabs.onUpdated.removeListener(handleUpdate) + } + }, [activeTab]) + + return activeTab +} diff --git a/extension/src/utils/index.ts b/extension/src/utils/index.ts index 1be23b78..c949bf80 100644 --- a/extension/src/utils/index.ts +++ b/extension/src/utils/index.ts @@ -1,5 +1,5 @@ export * from './addressValidation' -export { getActiveTab } from './getActiveTab' +export { getActiveTab, useActiveTab } from './getActiveTab' export { isValidTab } from './isValidTab' export { reloadActiveTab } from './reloadActiveTab' export { reloadTab } from './reloadTab' diff --git a/extension/test-utils/RenderWrapper.tsx b/extension/test-utils/RenderWrapper.tsx new file mode 100644 index 00000000..29a36183 --- /dev/null +++ b/extension/test-utils/RenderWrapper.tsx @@ -0,0 +1,26 @@ +import { ProvideBridgeContext } from '@/bridge' +import { ProvideInjectedWallet, ProvideProvider } from '@/providers' +import { ProvideState } from '@/state' +import { ProvideZodiacRoutes } from '@/zodiac-routes' +import { PropsWithChildren } from 'react' + +type RenderWraperProps = PropsWithChildren<{ + windowId?: number +}> + +export const RenderWrapper = ({ + children, + windowId = 1, +}: RenderWraperProps) => ( + + + + + + {children} + + + + + +) diff --git a/extension/test-utils/index.ts b/extension/test-utils/index.ts index 2e91e98a..5948d9b2 100644 --- a/extension/test-utils/index.ts +++ b/extension/test-utils/index.ts @@ -3,6 +3,7 @@ export * from './creators' export { mockRoute } from './mockRoute' export { mockRoutes } from './mockRoutes' export { expectRouteToBe, render } from './render' +export { renderHook } from './renderHook' export { startPilotSession } from './startPilotSession' export { startSimulation } from './startSimulation' export { stopSimulation } from './stopSimulation' diff --git a/extension/test-utils/render.tsx b/extension/test-utils/render.tsx index d5c16dcb..469000f2 100644 --- a/extension/test-utils/render.tsx +++ b/extension/test-utils/render.tsx @@ -1,6 +1,3 @@ -import { ProvideInjectedWallet, ProvideProvider } from '@/providers' -import { ProvideState } from '@/state' -import { ProvideZodiacRoutes } from '@/zodiac-routes' import { render as baseRender, screen, waitFor } from '@testing-library/react' import { ComponentType } from 'react' import { @@ -13,6 +10,7 @@ import { import { expect } from 'vitest' import { mockActiveTab, mockTabConnect } from './chrome' import { createMockPort } from './creators' +import { RenderWrapper } from './RenderWrapper' type Route = { path: string @@ -41,31 +39,21 @@ export const render = async ( mockTabConnect(mockedPort) const result = baseRender( - - - - - - - }> - {routes.map(({ path, Component }) => ( - } /> - ))} + + + + }> + {routes.map(({ path, Component }) => ( + } /> + ))} - {inspectRoutes.map((route) => ( - } - /> - ))} - - - - - - - , + {inspectRoutes.map((route) => ( + } /> + ))} + + + + , options ) diff --git a/extension/test-utils/renderHook.ts b/extension/test-utils/renderHook.ts new file mode 100644 index 00000000..b7f84c89 --- /dev/null +++ b/extension/test-utils/renderHook.ts @@ -0,0 +1,18 @@ +import { sleep } from '@/utils' +import { + renderHook as renderHookBase, + RenderHookOptions, +} from '@testing-library/react' + +type Fn = (props: Props) => Result + +export const renderHook = async ( + fn: Fn, + options?: RenderHookOptions +) => { + const result = renderHookBase(fn, options) + + await sleep(1) + + return result +} diff --git a/extension/tsconfig.json b/extension/tsconfig.json index 1773b954..5c3f0cbb 100644 --- a/extension/tsconfig.json +++ b/extension/tsconfig.json @@ -27,7 +27,8 @@ "@/providers": ["./src/panel/providers/index.ts"], "@/e2e-utils": ["./e2e/utils/index.ts"], "@/test-utils": ["./test-utils/index.ts"], - "@/messages": ["./src/messages.ts"] + "@/messages": ["./src/messages.ts"], + "@/bridge": ["./src/bridge/index.ts"] } }, "include": ["src", "e2e", "test-utils", "./vitest.setup.mts"]