From f559b812d3372879c126e4838973586723cbe0f8 Mon Sep 17 00:00:00 2001 From: Greg Richardson Date: Wed, 13 Nov 2024 13:03:21 -0700 Subject: [PATCH 1/3] feat: navigator locks hooks --- apps/postgres-new/components/app-provider.tsx | 7 + .../postgres-new/components/lock-provider.tsx | 182 ++++++++++++++++++ apps/postgres-new/components/providers.tsx | 5 +- apps/postgres-new/components/workspace.tsx | 9 +- 4 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 apps/postgres-new/components/lock-provider.tsx diff --git a/apps/postgres-new/components/app-provider.tsx b/apps/postgres-new/components/app-provider.tsx index 88663bbc..6a9b22f4 100644 --- a/apps/postgres-new/components/app-provider.tsx +++ b/apps/postgres-new/components/app-provider.tsx @@ -32,6 +32,7 @@ import { import { legacyDomainHostname } from '~/lib/util' import { parse, serialize } from '~/lib/websocket-protocol' import { createClient } from '~/utils/supabase/client' +import { useLocks } from './lock-provider' export type AppProps = PropsWithChildren @@ -259,6 +260,12 @@ export default function AppProvider({ children }: AppProps) { setIsRenameDialogOpen(isLegacyDomain || isLegacyDomainRedirect) }, []) + const locks = useLocks() + + useEffect(() => { + console.log('Locks update:', locks) + }, [locks]) + return ( = Omit & Required> + +export type LockProviderProps = PropsWithChildren<{ + /** + * The namespace for the locks. Used in both the + * `BroadcastChannel` and the lock names. + */ + namespace: string +}> + +/** + * A provider that manages locks across multiple tabs. + */ +export function LockProvider({ namespace, children }: LockProviderProps) { + // Receive messages from other tabs + const broadcastChannel = useMemo(() => new BroadcastChannel(namespace), [namespace]) + + // Receive messages from self + const selfChannel = useMemo(() => new MessageChannel(), []) + const messagePort = selfChannel.port1 + + // Track locks across all tabs + const [locks, setLocks] = useState(new Set()) + + const lockPrefix = `${namespace}:` + + useEffect(() => { + async function updateLocks() { + const locks = await navigator.locks.query() + const held = locks.held + ?.filter( + (lock): lock is RequireProp => + lock.name !== undefined && lock.name.startsWith(lockPrefix) + ) + .map((lock) => lock.name.slice(lockPrefix.length)) + + if (!held) { + return + } + + setLocks(new Set(held)) + } + + updateLocks() + messagePort.start() + + broadcastChannel.addEventListener('message', updateLocks) + messagePort.addEventListener('message', updateLocks) + + return () => { + broadcastChannel.removeEventListener('message', updateLocks) + messagePort.removeEventListener('message', updateLocks) + } + }, [lockPrefix, broadcastChannel, messagePort]) + + return ( + + {children} + + ) +} + +export type LockContextValues = { + /** + * The namespace for the locks. Used in both the + * `BroadcastChannel` and the lock names. + */ + namespace: string + + /** + * The `BroadcastChannel` used to notify other tabs + * of lock changes. + */ + broadcastChannel: BroadcastChannel + + /** + * The `MessagePort` used to notify this tab of + * lock changes. + */ + messagePort: MessagePort + + /** + * The set of keys locked across all tabs. + */ + locks: Set +} + +export const LockContext = createContext(undefined) + +/** + * Hook to access the locks across all tabs. + */ +export function useLocks() { + const context = useContext(LockContext) + + if (!context) { + throw new Error('LockContext missing. Are you accessing useLocks() outside of an LockProvider?') + } + + return context.locks +} + +/** + * Hook to check if a key is locked across all tabs. + */ +export function useIsLocked(key: string) { + const context = useContext(LockContext) + + if (!context) { + throw new Error( + 'LockContext missing. Are you accessing useIsLocked() outside of an LockProvider?' + ) + } + + return context.locks.has(`${context.namespace}:${key}`) +} + +/** + * Hook to acquire a lock for a key across all tabs. + */ +export function useAcquireLock(key: string) { + const context = useContext(LockContext) + const [hasAcquiredLock, setHasAcquiredLock] = useState(false) + + if (!context) { + throw new Error( + 'LockContext missing. Are you accessing useAcquireLock() outside of an LockProvider?' + ) + } + + const { namespace, broadcastChannel, messagePort } = context + + const lockName = `${namespace}:${key}` + + useEffect(() => { + const abortController = new AbortController() + let releaseLock: () => void + + // Request the lock and notify listeners + navigator.locks + .request(lockName, { signal: abortController.signal }, () => { + broadcastChannel.postMessage({ type: 'acquire', lockName }) + messagePort.postMessage({ type: 'acquire', lockName }) + setHasAcquiredLock(true) + + return new Promise((resolve) => { + releaseLock = resolve + }) + }) + .then(async () => { + broadcastChannel.postMessage({ type: 'release', lockName }) + messagePort.postMessage({ type: 'release', lockName }) + setHasAcquiredLock(false) + }) + .catch(() => {}) + + // Release the lock when the component is unmounted + function unload() { + abortController.abort('unmount') + releaseLock?.() + } + + // Release the lock when the tab is closed + window.addEventListener('beforeunload', unload) + + return () => { + unload() + window.removeEventListener('beforeunload', unload) + } + }, [lockName, broadcastChannel, messagePort]) + + return hasAcquiredLock +} diff --git a/apps/postgres-new/components/providers.tsx b/apps/postgres-new/components/providers.tsx index 49a19581..ace9377d 100644 --- a/apps/postgres-new/components/providers.tsx +++ b/apps/postgres-new/components/providers.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PropsWithChildren } from 'react' import AppProvider from './app-provider' +import { LockProvider } from './lock-provider' import { ThemeProvider } from './theme-provider' const queryClient = new QueryClient() @@ -12,7 +13,9 @@ export default function Providers({ children }: PropsWithChildren) { return ( - {children} + + {children} + ) diff --git a/apps/postgres-new/components/workspace.tsx b/apps/postgres-new/components/workspace.tsx index e0a6dfa9..a552e5cf 100644 --- a/apps/postgres-new/components/workspace.tsx +++ b/apps/postgres-new/components/workspace.tsx @@ -1,7 +1,7 @@ 'use client' import { CreateMessage, Message, useChat, UseChatHelpers } from 'ai/react' -import { createContext, useCallback, useContext, useMemo } from 'react' +import { createContext, useCallback, useContext, useEffect, useMemo } from 'react' import { useMessageCreateMutation } from '~/data/messages/message-create-mutation' import { useMessagesQuery } from '~/data/messages/messages-query' import { useTablesQuery } from '~/data/tables/tables-query' @@ -11,6 +11,7 @@ import { ensureMessageId, ensureToolResult } from '~/lib/util' import { useApp } from './app-provider' import Chat, { getInitialMessages } from './chat' import IDE from './ide' +import { useAcquireLock } from './lock-provider' // TODO: support public/private DBs that live in the cloud export type Visibility = 'local' @@ -107,6 +108,12 @@ export default function Workspace({ const isConversationStarted = initialMessages !== undefined && messages.length > initialMessages.length + const hasAcquiredLock = useAcquireLock(databaseId) + + useEffect(() => { + console.log('Has acquired lock:', databaseId, hasAcquiredLock) + }, [databaseId, hasAcquiredLock]) + return ( Date: Wed, 13 Nov 2024 13:44:21 -0700 Subject: [PATCH 2/3] feat: lock ui via lock hooks --- apps/postgres-new/app/(main)/db/[id]/page.tsx | 32 ++++++++ apps/postgres-new/components/app-provider.tsx | 7 -- .../postgres-new/components/lock-provider.tsx | 79 ++++++++++++++++--- apps/postgres-new/components/sidebar.tsx | 16 +++- apps/postgres-new/components/workspace.tsx | 9 +-- 5 files changed, 111 insertions(+), 32 deletions(-) diff --git a/apps/postgres-new/app/(main)/db/[id]/page.tsx b/apps/postgres-new/app/(main)/db/[id]/page.tsx index cd13c697..64b41c8a 100644 --- a/apps/postgres-new/app/(main)/db/[id]/page.tsx +++ b/apps/postgres-new/app/(main)/db/[id]/page.tsx @@ -1,14 +1,18 @@ 'use client' +import Link from 'next/link' import { useRouter } from 'next/navigation' import { useEffect } from 'react' import { useApp } from '~/components/app-provider' +import { useAcquireLock } from '~/components/lock-provider' import Workspace from '~/components/workspace' +import NewDatabasePage from '../../page' export default function Page({ params }: { params: { id: string } }) { const databaseId = params.id const router = useRouter() const { dbManager } = useApp() + const hasAcquiredLock = useAcquireLock(databaseId) useEffect(() => { async function run() { @@ -25,5 +29,33 @@ export default function Page({ params }: { params: { id: string } }) { run() }, [dbManager, databaseId, router]) + if (!hasAcquiredLock) { + return ( +
+ +
+

+ This database is already open in another tab or window. +
+
+ Due to{' '} + + PGlite's single-user mode limitation + + , only one connection is allowed at a time. +
+
+ Please close the database in the other location to access it here. +

+
+
+ ) + } + return } diff --git a/apps/postgres-new/components/app-provider.tsx b/apps/postgres-new/components/app-provider.tsx index 6a9b22f4..88663bbc 100644 --- a/apps/postgres-new/components/app-provider.tsx +++ b/apps/postgres-new/components/app-provider.tsx @@ -32,7 +32,6 @@ import { import { legacyDomainHostname } from '~/lib/util' import { parse, serialize } from '~/lib/websocket-protocol' import { createClient } from '~/utils/supabase/client' -import { useLocks } from './lock-provider' export type AppProps = PropsWithChildren @@ -260,12 +259,6 @@ export default function AppProvider({ children }: AppProps) { setIsRenameDialogOpen(isLegacyDomain || isLegacyDomainRedirect) }, []) - const locks = useLocks() - - useEffect(() => { - console.log('Locks update:', locks) - }, [locks]) - return ( = Omit & Required> @@ -24,6 +33,9 @@ export function LockProvider({ namespace, children }: LockProviderProps) { // Track locks across all tabs const [locks, setLocks] = useState(new Set()) + // Track locks acquired by this tab + const [selfLocks, setSelfLocks] = useState(new Set()) + const lockPrefix = `${namespace}:` useEffect(() => { @@ -62,6 +74,8 @@ export function LockProvider({ namespace, children }: LockProviderProps) { broadcastChannel, messagePort: selfChannel.port2, locks, + selfLocks, + setSelfLocks, }} > {children} @@ -92,27 +106,45 @@ export type LockContextValues = { * The set of keys locked across all tabs. */ locks: Set + + /** + * The set of keys locked by this tab. + */ + selfLocks: Set + + /** + * Set the locks acquired by this tab. + */ + setSelfLocks: Dispatch>> } export const LockContext = createContext(undefined) /** - * Hook to access the locks across all tabs. + * Hook to access the locks acquired by all tabs. + * Can optionally exclude keys acquired by current tab. */ -export function useLocks() { +export function useLocks(excludeSelf = false) { const context = useContext(LockContext) if (!context) { throw new Error('LockContext missing. Are you accessing useLocks() outside of an LockProvider?') } - return context.locks + let set = context.locks + + if (excludeSelf) { + set = set.difference(context.selfLocks) + } + + return set } /** - * Hook to check if a key is locked across all tabs. + * Hook to check if a key is locked by any tab. + * Can optionally exclude keys acquired by current tab. */ -export function useIsLocked(key: string) { +export function useIsLocked(key: string, excludeSelf = false) { const context = useContext(LockContext) if (!context) { @@ -121,7 +153,13 @@ export function useIsLocked(key: string) { ) } - return context.locks.has(`${context.namespace}:${key}`) + let set = context.locks + + if (excludeSelf) { + set = set.difference(context.selfLocks) + } + + return set.has(key) } /** @@ -137,8 +175,9 @@ export function useAcquireLock(key: string) { ) } - const { namespace, broadcastChannel, messagePort } = context + const { namespace, broadcastChannel, messagePort, setSelfLocks } = context + const lockPrefix = `${namespace}:` const lockName = `${namespace}:${key}` useEffect(() => { @@ -148,18 +187,32 @@ export function useAcquireLock(key: string) { // Request the lock and notify listeners navigator.locks .request(lockName, { signal: abortController.signal }, () => { - broadcastChannel.postMessage({ type: 'acquire', lockName }) - messagePort.postMessage({ type: 'acquire', lockName }) + const key = lockName.startsWith(lockPrefix) ? lockName.slice(lockPrefix.length) : undefined + + if (!key) { + return + } + + broadcastChannel.postMessage({ type: 'acquire', key }) + messagePort.postMessage({ type: 'acquire', key }) setHasAcquiredLock(true) + setSelfLocks((locks) => locks.union(new Set([key]))) return new Promise((resolve) => { releaseLock = resolve }) }) .then(async () => { - broadcastChannel.postMessage({ type: 'release', lockName }) - messagePort.postMessage({ type: 'release', lockName }) + const key = lockName.startsWith(lockPrefix) ? lockName.slice(lockPrefix.length) : undefined + + if (!key) { + return + } + + broadcastChannel.postMessage({ type: 'release', key }) + messagePort.postMessage({ type: 'release', key }) setHasAcquiredLock(false) + setSelfLocks((locks) => locks.difference(new Set([key]))) }) .catch(() => {}) @@ -176,7 +229,7 @@ export function useAcquireLock(key: string) { unload() window.removeEventListener('beforeunload', unload) } - }, [lockName, broadcastChannel, messagePort]) + }, [lockName, lockPrefix, broadcastChannel, messagePort, setSelfLocks]) return hasAcquiredLock } diff --git a/apps/postgres-new/components/sidebar.tsx b/apps/postgres-new/components/sidebar.tsx index 3356eb47..9db7d255 100644 --- a/apps/postgres-new/components/sidebar.tsx +++ b/apps/postgres-new/components/sidebar.tsx @@ -1,5 +1,6 @@ 'use client' +import { TooltipPortal } from '@radix-ui/react-tooltip' import { AnimatePresence, m } from 'framer-motion' import { ArrowLeftToLine, @@ -32,6 +33,8 @@ import { downloadFile, titleToKebabCase } from '~/lib/util' import { cn } from '~/lib/utils' import { useApp } from './app-provider' import { CodeBlock } from './code-block' +import { LiveShareIcon } from './live-share-icon' +import { useIsLocked } from './lock-provider' import SignInButton from './sign-in-button' import ThemeDropdown from './theme-dropdown' import { @@ -41,8 +44,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from './ui/dropdown-menu' -import { TooltipPortal } from '@radix-ui/react-tooltip' -import { LiveShareIcon } from './live-share-icon' export default function Sidebar() { const { @@ -309,6 +310,8 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) { const { data: isOnDeployWaitlist } = useIsOnDeployWaitlistQuery() const { mutateAsync: joinDeployWaitlist } = useDeployWaitlistCreateMutation() + const isLocked = useIsLocked(database.id, true) + return ( <> { e.preventDefault() @@ -460,6 +464,7 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) { Rename { e.preventDefault() @@ -493,7 +498,7 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) { setIsDeployDialogOpen(true) setIsPopoverOpen(false) }} - disabled={user === undefined} + disabled={user === undefined || isLocked} > { e.preventDefault() @@ -539,6 +546,7 @@ function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) { type ConnectMenuItemProps = { databaseId: string isActive: boolean + disabled?: boolean setIsPopoverOpen: (open: boolean) => void } @@ -564,7 +572,7 @@ function LiveShareMenuItem(props: ConnectMenuItemProps) { return ( { e.preventDefault() diff --git a/apps/postgres-new/components/workspace.tsx b/apps/postgres-new/components/workspace.tsx index a552e5cf..e0a6dfa9 100644 --- a/apps/postgres-new/components/workspace.tsx +++ b/apps/postgres-new/components/workspace.tsx @@ -1,7 +1,7 @@ 'use client' import { CreateMessage, Message, useChat, UseChatHelpers } from 'ai/react' -import { createContext, useCallback, useContext, useEffect, useMemo } from 'react' +import { createContext, useCallback, useContext, useMemo } from 'react' import { useMessageCreateMutation } from '~/data/messages/message-create-mutation' import { useMessagesQuery } from '~/data/messages/messages-query' import { useTablesQuery } from '~/data/tables/tables-query' @@ -11,7 +11,6 @@ import { ensureMessageId, ensureToolResult } from '~/lib/util' import { useApp } from './app-provider' import Chat, { getInitialMessages } from './chat' import IDE from './ide' -import { useAcquireLock } from './lock-provider' // TODO: support public/private DBs that live in the cloud export type Visibility = 'local' @@ -108,12 +107,6 @@ export default function Workspace({ const isConversationStarted = initialMessages !== undefined && messages.length > initialMessages.length - const hasAcquiredLock = useAcquireLock(databaseId) - - useEffect(() => { - console.log('Has acquired lock:', databaseId, hasAcquiredLock) - }, [databaseId, hasAcquiredLock]) - return ( Date: Thu, 14 Nov 2024 08:56:15 +0100 Subject: [PATCH 3/3] use useIsLocked everywher --- apps/postgres-new/app/(main)/db/[id]/page.tsx | 7 ++++--- apps/postgres-new/components/lock-provider.tsx | 5 ----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/postgres-new/app/(main)/db/[id]/page.tsx b/apps/postgres-new/app/(main)/db/[id]/page.tsx index 64b41c8a..eed16979 100644 --- a/apps/postgres-new/app/(main)/db/[id]/page.tsx +++ b/apps/postgres-new/app/(main)/db/[id]/page.tsx @@ -4,7 +4,7 @@ import Link from 'next/link' import { useRouter } from 'next/navigation' import { useEffect } from 'react' import { useApp } from '~/components/app-provider' -import { useAcquireLock } from '~/components/lock-provider' +import { useAcquireLock, useIsLocked } from '~/components/lock-provider' import Workspace from '~/components/workspace' import NewDatabasePage from '../../page' @@ -12,7 +12,8 @@ export default function Page({ params }: { params: { id: string } }) { const databaseId = params.id const router = useRouter() const { dbManager } = useApp() - const hasAcquiredLock = useAcquireLock(databaseId) + useAcquireLock(databaseId) + const isLocked = useIsLocked(databaseId, true) useEffect(() => { async function run() { @@ -29,7 +30,7 @@ export default function Page({ params }: { params: { id: string } }) { run() }, [dbManager, databaseId, router]) - if (!hasAcquiredLock) { + if (isLocked) { return (
diff --git a/apps/postgres-new/components/lock-provider.tsx b/apps/postgres-new/components/lock-provider.tsx index a27ffa9d..b94769c0 100644 --- a/apps/postgres-new/components/lock-provider.tsx +++ b/apps/postgres-new/components/lock-provider.tsx @@ -167,7 +167,6 @@ export function useIsLocked(key: string, excludeSelf = false) { */ export function useAcquireLock(key: string) { const context = useContext(LockContext) - const [hasAcquiredLock, setHasAcquiredLock] = useState(false) if (!context) { throw new Error( @@ -195,7 +194,6 @@ export function useAcquireLock(key: string) { broadcastChannel.postMessage({ type: 'acquire', key }) messagePort.postMessage({ type: 'acquire', key }) - setHasAcquiredLock(true) setSelfLocks((locks) => locks.union(new Set([key]))) return new Promise((resolve) => { @@ -211,7 +209,6 @@ export function useAcquireLock(key: string) { broadcastChannel.postMessage({ type: 'release', key }) messagePort.postMessage({ type: 'release', key }) - setHasAcquiredLock(false) setSelfLocks((locks) => locks.difference(new Set([key]))) }) .catch(() => {}) @@ -230,6 +227,4 @@ export function useAcquireLock(key: string) { window.removeEventListener('beforeunload', unload) } }, [lockName, lockPrefix, broadcastChannel, messagePort, setSelfLocks]) - - return hasAcquiredLock }