From 72d83eaf000e3680ffb5e7703f8aed04c9d534bd Mon Sep 17 00:00:00 2001 From: the letter L <134443988+turbocrime@users.noreply.github.com> Date: Mon, 15 Jul 2024 01:54:59 -0700 Subject: [PATCH] react provider 3 (#1450) * react wallet * provide more specific interface, type guards from client package * use event targets and listeners --- .changeset/dirty-mice-help.md | 5 + .changeset/tidy-spiders-hang.md | 5 + apps/minifront/src/prax.ts | 27 +-- packages/client/package.json | 8 +- packages/client/src/assert.ts | 82 +++++++ packages/client/src/create.ts | 82 +------ packages/client/src/event.ts | 50 +++- packages/client/src/index.ts | 134 ++++------- packages/client/src/manifest.ts | 55 +++++ packages/client/src/provider.ts | 73 ++++++ packages/client/src/state.ts | 20 ++ packages/client/src/symbol.ts | 1 + packages/react/README.md | 220 ++++++++++++++++++ packages/react/package.json | 54 +++++ .../components/penumbra-context-provider.tsx | 204 ++++++++++++++++ .../react/src/hooks/use-penumbra-service.ts | 27 +++ .../react/src/hooks/use-penumbra-transport.ts | 90 +++++++ packages/react/src/hooks/use-penumbra.ts | 4 + packages/react/src/index.ts | 2 + packages/react/src/penumbra-context.ts | 23 ++ packages/react/tsconfig.json | 12 + pnpm-lock.yaml | 37 ++- 22 files changed, 1013 insertions(+), 202 deletions(-) create mode 100644 .changeset/dirty-mice-help.md create mode 100644 .changeset/tidy-spiders-hang.md create mode 100644 packages/client/src/assert.ts create mode 100644 packages/client/src/manifest.ts create mode 100644 packages/client/src/provider.ts create mode 100644 packages/client/src/state.ts create mode 100644 packages/client/src/symbol.ts create mode 100644 packages/react/README.md create mode 100644 packages/react/package.json create mode 100644 packages/react/src/components/penumbra-context-provider.tsx create mode 100644 packages/react/src/hooks/use-penumbra-service.ts create mode 100644 packages/react/src/hooks/use-penumbra-transport.ts create mode 100644 packages/react/src/hooks/use-penumbra.ts create mode 100644 packages/react/src/index.ts create mode 100644 packages/react/src/penumbra-context.ts create mode 100644 packages/react/tsconfig.json diff --git a/.changeset/dirty-mice-help.md b/.changeset/dirty-mice-help.md new file mode 100644 index 0000000000..47339b8e58 --- /dev/null +++ b/.changeset/dirty-mice-help.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/react': major +--- + +initial react wallet diff --git a/.changeset/tidy-spiders-hang.md b/.changeset/tidy-spiders-hang.md new file mode 100644 index 0000000000..5a6864e106 --- /dev/null +++ b/.changeset/tidy-spiders-hang.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/client': minor +--- + +provide type guards for event, and better types for event interface diff --git a/apps/minifront/src/prax.ts b/apps/minifront/src/prax.ts index 8ef3b0c03a..f8123518db 100644 --- a/apps/minifront/src/prax.ts +++ b/apps/minifront/src/prax.ts @@ -1,10 +1,11 @@ import { createPromiseClient, PromiseClient, Transport } from '@connectrpc/connect'; +import { getPenumbraManifest } from '@penumbra-zone/client'; import { + assertProvider, assertProviderConnected, assertProviderManifest, - getPenumbraPort, - syncCreatePenumbraChannelTransport, -} from '@penumbra-zone/client/create'; +} from '@penumbra-zone/client/assert'; +import { createPenumbraChannelTransportSync } from '@penumbra-zone/client/create'; import { jsonOptions, PenumbraService } from '@penumbra-zone/protobuf'; const prax_id = 'lkpmkhpnhknhmibgnmmhdhgdilepfghe'; @@ -12,11 +13,7 @@ const prax_origin = `chrome-extension://${prax_id}`; export const getPraxOrigin = () => prax_origin; -export const getPraxManifest = async () => { - const { manifest } = await assertProviderManifest(prax_origin); - const requestManifest = await fetch(manifest); - return (await requestManifest.json()) as unknown; -}; +export const getPraxManifest = () => getPenumbraManifest(prax_origin); export const isPraxConnected = () => { try { @@ -29,7 +26,7 @@ export const isPraxConnected = () => { export const isPraxInstalled = async () => { try { - await assertProviderManifest(); + await assertProviderManifest(prax_origin); return true; } catch { return false; @@ -40,16 +37,10 @@ export const throwIfPraxNotConnected = () => assertProviderConnected(prax_origin export const throwIfPraxNotInstalled = async () => assertProviderManifest(prax_origin); -export const getPraxPort = () => getPenumbraPort(prax_origin); - -export const requestPraxAccess = () => getPraxPort(); - -export const praxTransportOptions = { - jsonOptions, - getPort: getPraxPort, -}; +export const requestPraxAccess = () => assertProvider(prax_origin).then(p => p.request()); -export const createPraxTransport = () => syncCreatePenumbraChannelTransport(prax_origin); +export const createPraxTransport = () => + createPenumbraChannelTransportSync(prax_origin, { jsonOptions }); let praxTransport: Transport | undefined; export const createPraxClient = (service: T): PromiseClient => diff --git a/packages/client/package.json b/packages/client/package.json index 3840b219d1..5884e33725 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -20,7 +20,7 @@ ], "exports": { ".": "./src/index.ts", - "./create": "./src/create.ts" + "./*": "./src/*.ts" }, "publishConfig": { "exports": { @@ -28,9 +28,9 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, - "./create": { - "types": "./dist/create.d.ts", - "default": "./dist/create.js" + "./*": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" } } }, diff --git a/packages/client/src/assert.ts b/packages/client/src/assert.ts new file mode 100644 index 0000000000..072e5adf04 --- /dev/null +++ b/packages/client/src/assert.ts @@ -0,0 +1,82 @@ +import { + PenumbraNotInstalledError, + PenumbraProviderNotAvailableError, + PenumbraProviderNotConnectedError, +} from './error.js'; +import { PenumbraSymbol } from './symbol.js'; + +export const assertStringIsOrigin = (s?: string) => { + if (!s || new URL(s).origin !== s) { + throw new TypeError('Invalid origin'); + } + return s; +}; + +export const assertGlobalPresent = () => { + if (!window[PenumbraSymbol]) { + throw new PenumbraNotInstalledError(); + } + return window[PenumbraSymbol]; +}; + +/** + * Given a specific origin, identify the relevant injection or throw. An + * `undefined` origin is accepted but will throw. + */ +export const assertProviderRecord = (providerOrigin?: string) => { + const provider = providerOrigin && assertGlobalPresent()[assertStringIsOrigin(providerOrigin)]; + if (!provider) { + throw new PenumbraProviderNotAvailableError(providerOrigin); + } + return provider; +}; + +export const assertProvider = (providerOrigin?: string) => + assertProviderManifest(providerOrigin).then(() => assertProviderRecord(providerOrigin)); + +/** + * Given a specific origin, identify the relevant injection, and confirm + * provider is connected or throw. An `undefined` origin is accepted but will + * throw. + */ +export const assertProviderConnected = (providerOrigin?: string) => { + const provider = assertProviderRecord(providerOrigin); + if (!provider.isConnected()) { + throw new PenumbraProviderNotConnectedError(providerOrigin); + } + return provider; +}; + +/** + * Given a specific origin, identify the relevant injection, and confirm its + * manifest is actually present or throw. An `undefined` origin is accepted but + * will throw. + */ +export const assertProviderManifest = async (providerOrigin?: string, signal?: AbortSignal) => { + // confirm the provider injection is present + const provider = assertProviderRecord(providerOrigin); + + let manifest: unknown; + + try { + // confirm the provider manifest is located at the expected origin + if (new URL(provider.manifest).origin !== providerOrigin) { + throw new Error('Manifest located at unexpected origin'); + } + + // confirm the provider manifest can be fetched, and is json + const req = await fetch(provider.manifest, { signal }); + manifest = await req.json(); + + if (!manifest) { + throw new Error(`Cannot confirm ${providerOrigin} is real.`); + } + } catch (e) { + if (signal?.aborted !== true) { + console.warn(e); + throw new PenumbraProviderNotAvailableError(providerOrigin); + } + } + + return manifest; +}; diff --git a/packages/client/src/create.ts b/packages/client/src/create.ts index cea8bb2d4a..c2301c04a5 100644 --- a/packages/client/src/create.ts +++ b/packages/client/src/create.ts @@ -4,80 +4,12 @@ import { createChannelTransport, type ChannelTransportOptions, } from '@penumbra-zone/transport-dom/create'; -import { PenumbraSymbol, type PenumbraInjection } from './index.js'; -import { - PenumbraNotInstalledError, - PenumbraProviderNotAvailableError, - PenumbraProviderNotConnectedError, -} from './error.js'; +import { assertProviderManifest, assertProviderRecord } from './assert.js'; +import { PenumbraSymbol } from './symbol.js'; // Naively return the first available provider origin, or `undefined`. const availableOrigin = () => Object.keys(window[PenumbraSymbol] ?? {})[0]; -export const assertGlobalPresent = () => { - if (!window[PenumbraSymbol]) { - throw new PenumbraNotInstalledError(); - } - return window[PenumbraSymbol]; -}; - -/** - * Given a specific origin, identify the relevant injection or throw. An - * `undefined` origin is accepted but will throw. - */ -export const assertProvider = (providerOrigin?: string): PenumbraInjection => { - const provider = providerOrigin && assertGlobalPresent()[providerOrigin]; - if (!provider) { - throw new PenumbraProviderNotAvailableError(providerOrigin); - } - return provider; -}; - -/** - * Given a specific origin, identify the relevant injection, and confirm - * provider is connected or throw. An `undefined` origin is accepted but will - * throw. - */ -export const assertProviderConnected = (providerOrigin?: string) => { - const provider = assertProvider(providerOrigin); - if (!provider.isConnected()) { - throw new PenumbraProviderNotConnectedError(providerOrigin); - } - return provider; -}; - -/** - * Given a specific origin, identify the relevant injection, and confirm its - * manifest is actually present or throw. An `undefined` origin is accepted but - * will throw. - */ -export const assertProviderManifest = async ( - providerOrigin?: string, -): Promise => { - // confirm the provider injection is present - const provider = assertProvider(providerOrigin); - - try { - // confirm the provider manifest is located at the expected origin - if (new URL(provider.manifest).origin !== providerOrigin) { - throw new Error('Manifest located at unexpected origin'); - } - - // confirm the provider manifest can be fetched, and is json - const req = await fetch(provider.manifest); - const manifest: unknown = await req.json(); - - if (!manifest) { - throw new Error(`Cannot confirm ${providerOrigin} is real.`); - } - } catch (e) { - console.warn(e); - throw new PenumbraProviderNotAvailableError(providerOrigin); - } - - return provider; -}; - /** * Asynchronously get a connection to the specified provider, or the first * available provider if unspecified. @@ -88,7 +20,9 @@ export const assertProviderManifest = async ( * @param requireProvider optional string identifying a provider origin */ export const getPenumbraPort = async (requireProvider?: string) => { - const provider = await assertProviderManifest(requireProvider ?? availableOrigin()); + const penumbraOrigin = requireProvider ?? availableOrigin(); + await assertProviderManifest(penumbraOrigin); + const provider = assertProviderRecord(penumbraOrigin); if (!provider.isConnected()) { await provider.request(); } @@ -108,7 +42,7 @@ export const getPenumbraPort = async (requireProvider?: string) => { * @param requireProvider optional string identifying a provider origin * @param transportOptions optional `ChannelTransportOptions` without `getPort` */ -export const syncCreatePenumbraChannelTransport = ( +export const createPenumbraChannelTransportSync = ( requireProvider?: string, transportOptions: Omit = { jsonOptions }, ): Transport => @@ -140,10 +74,10 @@ export const createPenumbraChannelTransport = async ( * * If the provider is unavailable, the client will fail to make requests. */ -export const syncCreatePenumbraClient =

( +export const createPenumbraClientSync =

( service: P, requireProvider?: string, -) => createPromiseClient(service, syncCreatePenumbraChannelTransport(requireProvider)); +) => createPromiseClient(service, createPenumbraChannelTransportSync(requireProvider)); /** * Asynchronously create a client for `service` from the specified provider, or diff --git a/packages/client/src/event.ts b/packages/client/src/event.ts index 1501b72f29..9e8a6c7da9 100644 --- a/packages/client/src/event.ts +++ b/packages/client/src/event.ts @@ -1,15 +1,51 @@ -import { PenumbraInjectionState, PenumbraSymbol } from './index.js'; +import { PenumbraState } from './state.js'; +import { PenumbraSymbol } from './symbol.js'; -export class PenumbraInjectionStateEvent extends CustomEvent<{ +export interface PenumbraStateEventDetail { origin: string; - state?: PenumbraInjectionState; -}> { - constructor(injectionProviderOrigin: string, injectionState?: PenumbraInjectionState) { + state?: PenumbraState; +} + +export class PenumbraStateEvent extends CustomEvent { + constructor(penumbraOrigin: string, penumbraState?: PenumbraState) { super('penumbrastate', { detail: { - state: injectionState ?? window[PenumbraSymbol]?.[injectionProviderOrigin]?.state(), - origin: injectionProviderOrigin, + origin: penumbraOrigin, + state: penumbraState ?? window[PenumbraSymbol]?.[penumbraOrigin]?.state(), }, }); } } + +export const isPenumbraStateEvent = (evt: Event): evt is PenumbraStateEvent => + evt instanceof PenumbraStateEvent || ('detail' in evt && isPenumbraStateEventDetail(evt.detail)); + +export const isPenumbraStateEventDetail = (detail: unknown): detail is PenumbraStateEventDetail => + typeof detail === 'object' && + detail !== null && + 'origin' in detail && + typeof detail.origin === 'string'; + +// utility type for SpecificEventTarget. any is required for type inference +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ParametersTail any> = + Parameters extends [unknown, ...infer TailParams] ? TailParams : never; + +// like EventTarget, but restricts possible event types +interface SpecificEventTarget + extends EventTarget { + addEventListener: ( + type: SpecificTypeName, + ...rest: ParametersTail + ) => void; + removeEventListener: ( + type: SpecificTypeName, + ...rest: ParametersTail + ) => void; + dispatchEvent: (event: SpecificEvent) => boolean; +} + +export type PenumbraStateEventTarget = Omit< + SpecificEventTarget<'penumbrastate', never>, + 'dispatchEvent' +>; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index e49e4874c5..1c573c8f0b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,100 +1,48 @@ -export * from './error.js'; -export * from './event.js'; - -export const PenumbraSymbol = Symbol.for('penumbra'); - -/** - * This interface describes the simple API to request, connect, or disconnect a - * provider. These methods allow a page to acquire permission to connect, and - * obtain a `MessagePort` to be used for client creation. - * - * There are three connection states for each provider, which may be identified - * by calling the synchronous method `isConnected()`: - * - `true`: a connection is available, and a call to `connect` should resolve - * - `false`: no connection available. calls to `connect` or `request` will fail - * - `undefined`: a call may be pending, or no call has been made - * - * Each injection should also track state-changing actions, so calling - * `.state()` should provide more detail including currently pending state, - * enumerated by `PenumbraInjectionState`. - * - * Any script in page scope may create an object like this, so clients should - * confirm a provider is actually present. Presence can be securely verified by - * fetching the identified provider manifest from the provider's origin. - * - * Presently clients can expect the manifest is a chrome extension manifest v3. - * Provider details such as name, version, website, brief descriptive text, and - * icons should be available in the manifest. - * @see https://developer.chrome.com/docs/extensions/reference/manifest - * - * Clients may `request()` approval to connect. This method may reject if the - * provider chooses to deny approval. Approval granted by a successful - * request will persist accross sessions. - * - * Clients must `connect()` to acquire a `MessagePort`. The resulting - * `MessagePort` represents an active, type-safe communication channel to the - * provider. It is convenient to provide the `connect` method as the `getPort` - * option for `createChannelTransport` from `@penumbra-zone/transport-dom`, or - * use the helpers available in `@penumbra-zone/client/create`. - * - */ -export interface PenumbraInjection { - /** Should contain a URI at the provider's origin, serving a manifest - * describing this provider. */ - readonly manifest: string; - - /** Call to acquire a `MessagePort` to this provider, subject to approval. */ - readonly connect: () => Promise; - - /** Call to gain approval. May reject with a `PenumbraProviderRequestError` - * containing an enumerated `PenumbraRequestFailure` cause. */ - readonly request: () => Promise; - - /** Call to indicate the provider should discard approval of this origin. */ - readonly disconnect: () => Promise; - - /** Should synchronously return the present connection state. - * - `true` indicates active connection. - * - `false` indicates connection is closed or rejected. - * - `undefined` no attempt has resolved. connection may be attempted. - */ - readonly isConnected: () => boolean | undefined; - - /** Synchronously return present injection state. */ - readonly state: () => PenumbraInjectionState; - - /** Emits `PenubraInjectionStateEvent` when state changes. Listen for - * `'penumbrastate'` events, and check the `detail` field for a - * `PenumbraInjectionState` value. */ - readonly addEventListener: EventTarget['addEventListener']; - readonly removeEventListener: EventTarget['removeEventListener']; -} - -export enum PenumbraInjectionState { - /* error is present */ - 'Failed' = 'Failed', - - /* no action has been taken */ - 'Present' = 'Present', - - /* approval request pending */ - 'RequestPending' = 'RequestPending', - /* request for approval satisfied */ - 'Requested' = 'Requested', - - /* connection attempt pending */ - 'ConnectPending' = 'ConnectPending', - /* connection successful and active */ - 'Connected' = 'Connected', - - /* disconnect was called to release approval */ - 'Disconnected' = 'Disconnected', -} +import { assertGlobalPresent, assertProvider, assertProviderManifest } from './assert.js'; +import { isPenumbraManifest, type PenumbraManifest } from './manifest.js'; +import type { PenumbraProvider } from './provider.js'; +import { PenumbraSymbol } from './symbol.js'; declare global { interface Window { /** Records injected upon this global should identify themselves by a field * name matching the origin of the provider. */ - readonly [PenumbraSymbol]?: undefined | Readonly>; + readonly [PenumbraSymbol]?: undefined | Readonly>; } } + +/** Synchronously return the specified provider, without verifying anything. */ +export const getPenumbraUnsafe = (penumbraOrigin: string) => + window[PenumbraSymbol]?.[penumbraOrigin]; + +/** Return the specified provider after confirming presence of its manifest. */ +export const getPenumbra = (penumbraOrigin: string) => assertProvider(penumbraOrigin); + +/** Return the specified provider's manifest. */ +export const getPenumbraManifest = async ( + penumbraOrigin: string, + signal?: AbortSignal, +): Promise => { + const manifestJson = await assertProviderManifest(penumbraOrigin, signal); + if (!isPenumbraManifest(manifestJson)) { + throw new TypeError('Invalid manifest'); + } + return manifestJson; +}; + +export const getAllPenumbraManifests = (): Record< + keyof (typeof window)[typeof PenumbraSymbol], + Promise +> => + Object.fromEntries( + Object.keys(assertGlobalPresent()).map(providerOrigin => [ + providerOrigin, + getPenumbraManifest(providerOrigin), + ]), + ); + +export * from './error.js'; +export type { PenumbraManifest } from './manifest.js'; +export type { PenumbraProvider } from './provider.js'; +export { PenumbraState } from './state.js'; +export { PenumbraSymbol } from './symbol.js'; diff --git a/packages/client/src/manifest.ts b/packages/client/src/manifest.ts new file mode 100644 index 0000000000..a6bb8cad0f --- /dev/null +++ b/packages/client/src/manifest.ts @@ -0,0 +1,55 @@ +/** Currently, Penumbra manifests are chrome extension manifest v3. There's no type + * guard because manifest format is enforced by chrome. This type only describes + * fields we're interested in as a client. + * + * @see https://developer.chrome.com/docs/extensions/reference/manifest#keys + */ +export interface PenumbraManifest { + /** + * manifest id is present in production, but generally not in dev, because + * they are inserted by chrome store tooling. chrome extension id are simple + * hashes of the 'key' field, an extension-specific public key. + * + * developers may configure a public key in dev, and the extension id will + * match appropriately, but will not be present in the manifest. + * + * the extension id is also part of the extension's origin URI. + * + * @see https://developer.chrome.com/docs/extensions/reference/manifest/key + * @see https://web.archive.org/web/20120606044635/http://supercollider.dk/2010/01/calculating-chrome-extension-id-from-your-private-key-233 + */ + id?: string; + key?: string; + + // these are required + name: string; + version: string; + description: string; + + // these are optional, but might be nice to have + homepage_url?: string; + options_ui?: { page: string }; + options_page?: string; + + // icons are not indexed by number, but by a stringified number. they may be + // any square size but the power-of-two sizes are typical. the chrome store + // requires a '128' icon. + icons: Record<`${number}`, string> & { + ['128']: string; + }; +} + +export const isPenumbraManifest = (mf: unknown): mf is PenumbraManifest => + mf !== null && + typeof mf === 'object' && + 'name' in mf && + typeof mf.name === 'string' && + 'version' in mf && + typeof mf.version === 'string' && + 'description' in mf && + typeof mf.description === 'string' && + 'icons' in mf && + typeof mf.icons === 'object' && + mf.icons !== null && + '128' in mf.icons && + mf.icons['128'] === 'string'; diff --git a/packages/client/src/provider.ts b/packages/client/src/provider.ts new file mode 100644 index 0000000000..726e527686 --- /dev/null +++ b/packages/client/src/provider.ts @@ -0,0 +1,73 @@ +import type { PenumbraStateEventTarget } from './event.js'; +import type { PenumbraState } from './state.js'; + +/** + * This interface describes the simple API to request, connect, or disconnect a + * provider. These methods allow a page to acquire permission to connect, and + * obtain a `MessagePort` to be used for client creation. + * + * There are three connection states for each provider, which may be identified + * by calling the synchronous method `isConnected()`: + * - `true`: a connection is available, and a call to `connect` should resolve + * - `false`: no connection available. calls to `connect` or `request` will fail + * - `undefined`: a call may be pending, or no call has been made + * + * Each injection should also track state-changing actions, so calling + * `.state()` should provide more detail including currently pending state, + * enumerated by `PenumbraInjectionState`. + * + * Any script in page scope may create an object like this, so clients should + * confirm a provider is actually present. Presence can be securely verified by + * fetching the identified provider manifest from the provider's origin. + * + * Presently clients can expect the manifest is a chrome extension manifest v3. + * Provider details such as name, version, website, brief descriptive text, and + * icons should be available in the manifest. + * @see https://developer.chrome.com/docs/extensions/reference/manifest + * + * Clients may `request()` approval to connect. This method may reject if the + * provider chooses to deny approval. Approval granted by a successful + * request will persist accross sessions. + * + * Clients must `connect()` to acquire a `MessagePort`. The resulting + * `MessagePort` represents an active, type-safe communication channel to the + * provider. It is convenient to provide the `connect` method as the `getPort` + * option for `createChannelTransport` from `@penumbra-zone/transport-dom`, or + * use the helpers available in `@penumbra-zone/client/create`. + * + */ + +export interface PenumbraProvider extends Readonly { + /** Should contain a URI at the provider's origin, serving a manifest + * describing this provider. */ + readonly manifest: string; + + /** Call to acquire a `MessagePort` to this provider, subject to approval. */ + readonly connect: () => Promise; + + /** Call to gain approval. May reject with a `PenumbraProviderRequestError` + * containing an enumerated `PenumbraRequestFailure` cause. */ + readonly request: () => Promise; + + /** Call to indicate the provider should discard approval of this origin. */ + readonly disconnect: () => Promise; + + /** Should synchronously return the present connection state. + * - `true` indicates active connection. + * - `false` indicates connection is closed or rejected. + * - `undefined` no attempt has resolved. connection may be attempted. + */ + readonly isConnected: () => boolean | undefined; + + /** Synchronously return present injection state. */ + readonly state: () => PenumbraState; + + /** Like a normal EventTarget.addEventListener, but should only emit + * `PenubraInjectionStateEvent` when state changes. Listen for + * `'penumbrastate'` events, and check the `detail` field for a + * `PenumbraInjectionState` value. + * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener + */ + readonly addEventListener: PenumbraStateEventTarget['addEventListener']; + readonly removeEventListener: PenumbraStateEventTarget['addEventListener']; +} diff --git a/packages/client/src/state.ts b/packages/client/src/state.ts new file mode 100644 index 0000000000..8d4de8789f --- /dev/null +++ b/packages/client/src/state.ts @@ -0,0 +1,20 @@ +export enum PenumbraState { + /* error is present */ + 'Failed' = 'Failed', + + /* no action has been taken */ + 'Present' = 'Present', + + /* approval request pending */ + 'RequestPending' = 'RequestPending', + /* request for approval satisfied */ + 'Requested' = 'Requested', + + /* connection attempt pending */ + 'ConnectPending' = 'ConnectPending', + /* connection successful and active */ + 'Connected' = 'Connected', + + /* disconnect was called to release approval */ + 'Disconnected' = 'Disconnected', +} diff --git a/packages/client/src/symbol.ts b/packages/client/src/symbol.ts new file mode 100644 index 0000000000..12635d2a0a --- /dev/null +++ b/packages/client/src/symbol.ts @@ -0,0 +1 @@ +export const PenumbraSymbol = Symbol.for('penumbra'); diff --git a/packages/react/README.md b/packages/react/README.md new file mode 100644 index 0000000000..72f42d9da0 --- /dev/null +++ b/packages/react/README.md @@ -0,0 +1,220 @@ +# `@penumbra-zone/react` + +This package contains a React context provider and some simple hooks for using +the page API described in `@penumbra-zone/client`. You might want to use this if +you're writing a Penumbra dapp in React. + +**To use this package, you need to [enable the Buf Schema Registry](https://buf.build/docs/bsr/generated-sdks/npm):** + +```sh +npm config set @buf:registry https://buf.build/gen/npm/v1 +``` + +## Overview + +If a user has a Penumbra provider in their browser, it may be present (injected) +in the record at the window global `window[Symbol.for('penumbra')]` identified +by a URL origin at which the provider can serve a manifest. For example, Prax +Wallet's origin is `chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe`, so its provider record may be accessed like + +```ts +const prax: PenumbraProvider | undefined = + window[Symbol.for('penumbra')]?.['chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe']; +``` + +So, use of `` with an `origin` prop identifying your +preferred extension, or `injection` prop identifying the actual page injection +from your preferred extension, will result in automatic progress towards a +successful connection. + +Hooks `usePenumbraTransport` and `usePenumbraService` will promise a transport +or client that inits when the configured provider becomes connected, or rejects +with a failure before connection. + +Hooks `usePenumbraTransportSync` or `usePenumbraServiceSync` will +unconditionally provide a transport or client to the Penumbra extension that +queues requests while connection is pending, and begins returning responses when +appropriate. If the provider fails to connect, requests via the transport or +client may time out. + +## `` + +This wrapping component will provide a context available to all child components +that is directly accessible by `usePenumbra`, or additionally by +`usePenumbraTransport` or `usePenumbraService`. + +### Unary requests may use `@connectrpc/connect-query` + +If you'd like to use `@connectrpc/connect-query`, you may call +`usePenumbraTransport` to satisfy ``. + +Be aware that connect query only supports unary requests at the moment (no +streaming). + +A wrapping component: + +```tsx +import { Outlet } from 'react-router-dom'; +import { PenumbraProvider } from '@penumbra-zone/react'; +import { usePenumbraTransportSync } from '@penumbra-zone/react/hooks/use-penumbra-transport'; +import { TransportProvider } from '@connectrpc/connect-query'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const praxOrigin = 'chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe'; +const queryClient = new QueryClient(); + +export const PenumbraDappPage = () => ( + + + + + + + +); +``` + +A querying component: + +```tsx +import { addressByIndex } from '@buf/penumbra-zone_penumbra.connectrpc_query-es/penumbra/view/v1/view-ViewService_connectquery'; +import { useQuery } from '@connectrpc/connect-query'; +import { bech32mAddress } from '@penumbra-zone/bech32m/penumbra'; + +export const PraxAddress = ({ account }: { account?: number }) => { + // note this is not tanstack's useQuery + const { data } = useQuery(addressByIndex, { addressIndex: { account } }); + return data?.address && bech32mAddress(data.address); +}; +``` + +### Streaming requests must directly use a `PromiseClient` + +If you'd like to make streaming queries, or you just want to manage queries +yourself, you can call `usePenumbraService` with the `ServiceType` you're +interested in to acquire a `PromiseClient` of that service. A simplistic example +is below. + +Some streaming queries may return large amounts of data, or stream updates +continuosuly until aborted. For a good user experience with those queries, you +may need more complex query and state management. + +```tsx +import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; +import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb.js'; +import { usePenumbraServiceSync } from '@penumbra-zone/react/hooks/use-penumbra-service'; +import { ViewService } from '@penumbra-zone/protobuf'; +import { useQuery } from '@tanstack/react-query'; +import { AccountBalancesTable } from './imaginary-components'; + +export default function AssetBalancesByAccount({ assetIdFilter }: { assetIdFilter: AssetId }) { + const viewClient = usePenumbraServiceSync(ViewService); + + const { isPending, data: groupedBalances } = useQuery({ + queryKey: ['balances', assetIdFilter.inner], + + queryFn: ({ signal }): Promise => + // wait for stream to collect + Array.fromAsync(viewClient.balances({ assetIdFilter }, { signal })), + + select: (data: BalancesResponse[]) => + Map.groupBy( + // filter undefined + data.filter(({ balanceView, accountAddress }) => accountAddress?.addressView?.value), + // group by account + ({ accountAddress }) => accountAddress.addressView.value.index, + ), + }); + + if (isPending) return ; + if (groupedBalances) + return Array.from(groupedBalances.entries()).map(([accountIndex, balanceResponses]) => ( + + )); +} +``` + +## Possible provider states + +Each Penumbra provider exposes a simple `.isConnected()` method and a more +complex `.state()` method, which also tracks pending transitions. It is +generally robust and should asynchronously progress towards an active connection +if possible, even if steps are performed slightly 'out-of-order'. + +This package's exported `` component handles this state +and all of these transitions for you. Use of `` with an +`origin` or `provider` prop will result in automatic progress towards a +`Connected` state. + +During this progress, the context exposes an explicit status, so you may easily +condition your layout and display. You can access this status via +`usePenumbra().state`. All possible values are represented by the enum +`PenumbraState` available from `@penumbra-zone/client`. + +Hooks `usePenumbraTransportSync` and `usePenumbraServiceSync` conceal this +state, and unconditionally provide a transport or client. + +`Connected` is the only state in which a `MessagePort`, working `Transport`, or +working client is available. + +### State chart + +This flowchart reads from top (page load) to bottom (page unload). Each labelled +chart node is a possible value of `PenumbraProviderState`. Diamond-shaped nodes +are conditions described by the surrounding path labels. + +There are more possible transitions than diagrammed here - for instance once +methods are exposed, a `disconnect()` call will always transition directly into +a `Disconnected` state. A developer not using this wrapper, calling methods +directly, may enjoy failures at any moment. This diagram only represents a +typical state flow. + +The far right side path is the "happy path". + +```mermaid +stateDiagram-v2 + classDef GoodNode fill:chartreuse + classDef BadNode fill:salmon + classDef PossibleNode fill:thistle + + state global_exists <> + state manifest_present <> + state make_request <> + + + [*] --> global_exists: p = window[Symbol.for('penumbra')][validOrigin] + global_exists --> [*]: undefined + + Failed:::BadNode --> [*]: p.failure + Disconnected --> [*] + Connected:::GoodNode --> [*] + + manifest_present --> Failed + RequestPending --> Failed + ConnectPending --> Failed + + global_exists --> manifest_present: fetch(p.manifest) + manifest_present --> Present: json + + Present:::PossibleNode --> make_request: makeApprovalRequest + + make_request --> RequestPending: p.request() + RequestPending:::PossibleNode --> Requested + Requested:::PossibleNode --> ConnectPending: p.connect() + + + make_request --> ConnectPending: p.connect() + ConnectPending:::PossibleNode --> Connected:::PossibleNode + + Connected --> Disconnected: p.disconnect() + + note left of Present + Methods on the injection may + be called after this point. + end note + + note left of Connected + Port is acquired and + transports become active. + end note +``` diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 0000000000..ab036389db --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,54 @@ +{ + "name": "@penumbra-zone/react", + "version": "0.0.1", + "license": "(MIT OR Apache-2.0)", + "description": "React package for connecting to any Penumbra extension, including Prax.", + "type": "module", + "scripts": { + "build": "tsc --build --verbose", + "clean": "rm -rfv dist *.tsbuildinfo package penumbra-zone-*.tgz", + "dev:pack": "tsc-watch --onSuccess \"$npm_execpath pack\"", + "lint": "eslint src", + "lint:fix": "eslint src --fix", + "lint:strict": "tsc --noEmit && eslint src --max-warnings 0" + }, + "files": [ + "dist" + ], + "exports": { + ".": "./src/index.ts", + "./components/*": "./src/components/*.tsx", + "./hooks/*": "./src/hooks/*.ts" + }, + "publishConfig": { + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./components/*": { + "types": "./dist/components/*.d.ts", + "default": "./dist/components/*.js" + }, + "./hooks/*": { + "types": "./dist/hooks/*.d.ts", + "default": "./dist/hooks/*.js" + } + } + }, + "dependencies": { + "@penumbra-zone/client": "workspace:*", + "@penumbra-zone/protobuf": "workspace:*", + "@penumbra-zone/transport-dom": "workspace:*" + }, + "devDependencies": { + "@connectrpc/connect": "^1.4.0", + "@types/react": "^18.3.2", + "react": "^18.3.1" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^1.10.0", + "@connectrpc/connect": "^1.4.0", + "react": "^18.3.1" + } +} diff --git a/packages/react/src/components/penumbra-context-provider.tsx b/packages/react/src/components/penumbra-context-provider.tsx new file mode 100644 index 0000000000..2883ecd9f6 --- /dev/null +++ b/packages/react/src/components/penumbra-context-provider.tsx @@ -0,0 +1,204 @@ +import { getPenumbraManifest, PenumbraProvider, PenumbraState } from '@penumbra-zone/client'; +import { assertProviderRecord } from '@penumbra-zone/client/assert'; +import { isPenumbraStateEvent } from '@penumbra-zone/client/event'; +import { PenumbraManifest } from '@penumbra-zone/client/manifest'; +import { jsonOptions } from '@penumbra-zone/protobuf'; +import { + ChannelTransportOptions, + createChannelTransport, +} from '@penumbra-zone/transport-dom/create'; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { PenumbraContext, penumbraContext } from '../penumbra-context.js'; + +type PenumbraContextProviderProps = { + children?: ReactNode; + origin: string; + makeApprovalRequest?: boolean; + transportOpts?: Omit; +} & ({ provider: PenumbraProvider } | { origin: string }); + +export const PenumbraContextProvider = ({ + children, + origin: providerOrigin, + makeApprovalRequest = false, + transportOpts, +}: PenumbraContextProviderProps) => { + const penumbra = assertProviderRecord(providerOrigin); + + const [providerConnected, setProviderConnected] = useState(); + const [providerManifest, setProviderManifest] = useState(); + const [providerPort, setProviderPort] = useState(); + const [providerState, setProviderState] = useState(); + const [failure, dispatchFailure] = useState(); + + // force destruction on any failure + const setFailure = useCallback( + (cause: unknown) => { + if (failure) { + console.warn('PenumbraContextProvider not replacing existing failure with new cause', { + failure, + cause, + }); + } + + setProviderConnected(false); + setProviderPort(undefined); + setProviderState(PenumbraState.Failed); + dispatchFailure( + failure ?? (cause instanceof Error ? cause : new Error('Unknown failure', { cause })), + ); + }, + [failure], + ); + + // fetch manifest to confirm presence of provider + useEffect(() => { + // require origin. skip if failure or manifest present + if (!providerOrigin || (failure ?? providerManifest)) { + return; + } + + // abortable effect + const ac = new AbortController(); + + void getPenumbraManifest(providerOrigin, ac.signal) + .then(manifestJson => ac.signal.aborted || setProviderManifest(manifestJson)) + .catch(setFailure); + + return () => ac.abort(); + }, [failure, penumbra, providerManifest, providerOrigin, setFailure, setProviderManifest]); + + // attach state event listener + useEffect(() => { + // require manifest. unnecessary if failed + if (!providerManifest || failure) { + return; + } + + // abortable listener + const ac = new AbortController(); + penumbra.addEventListener( + 'penumbrastate', + (evt: Event) => { + if (isPenumbraStateEvent(evt)) { + if (evt.detail.origin !== providerOrigin) { + setFailure(new Error('State change from unexpected origin')); + } else if (evt.detail.state !== penumbra.state()) { + console.warn('State change not verifiable'); + } else { + setProviderState(penumbra.state()); + setProviderConnected(penumbra.isConnected()); + } + } + }, + { signal: ac.signal }, + ); + return () => ac.abort(); + }, [failure, penumbra, penumbra.addEventListener, providerManifest, providerOrigin, setFailure]); + + // request effect + useEffect(() => { + // require manifest, no failures + if (providerManifest && !failure) { + switch (providerState) { + case PenumbraState.Present: + if (makeApprovalRequest) { + void penumbra.request().catch(setFailure); + } + break; + default: + break; + } + } + }, [ + failure, + makeApprovalRequest, + penumbra, + penumbra.request, + providerManifest, + providerState, + setFailure, + ]); + + // connect effect + useEffect(() => { + // require manifest, no failures + if (providerManifest && !failure) { + switch (providerState) { + case PenumbraState.Present: + if (!makeApprovalRequest) { + void penumbra + .connect() + .then(p => setProviderPort(p)) + .catch(setFailure); + } + break; + case PenumbraState.Requested: + void penumbra + .connect() + .then(p => setProviderPort(p)) + .catch(setFailure); + break; + default: + break; + } + } + }, [ + failure, + makeApprovalRequest, + penumbra, + penumbra.connect, + providerManifest, + providerState, + setFailure, + ]); + + const createdContext: PenumbraContext = useMemo( + () => ({ + failure, + manifest: providerManifest, + origin: providerOrigin, + + // require manifest to forward state + state: providerManifest && providerState, + transport: + providerConnected && providerPort + ? createChannelTransport({ + jsonOptions, + ...transportOpts, + getPort: () => Promise.resolve(providerPort), + }) + : undefined, + transportOpts, + + // require manifest and no failures to forward injected methods + ...(providerManifest && !failure + ? { + port: providerConnected && providerPort, + connect: penumbra.connect, + request: penumbra.request, + disconnect: penumbra.disconnect, + + addEventListener: penumbra.addEventListener, + removeEventListener: penumbra.removeEventListener, + } + : {}), + }), + [ + failure, + penumbra.addEventListener, + penumbra.connect, + penumbra.disconnect, + penumbra.removeEventListener, + penumbra.request, + providerConnected, + providerManifest, + providerOrigin, + providerPort, + providerState, + transportOpts, + ], + ); + + return {children}; +}; diff --git a/packages/react/src/hooks/use-penumbra-service.ts b/packages/react/src/hooks/use-penumbra-service.ts new file mode 100644 index 0000000000..ccbc4058e3 --- /dev/null +++ b/packages/react/src/hooks/use-penumbra-service.ts @@ -0,0 +1,27 @@ +import { createPromiseClient, PromiseClient } from '@connectrpc/connect'; +import { PenumbraService } from '@penumbra-zone/protobuf'; +import { useMemo } from 'react'; +import { usePenumbraTransportAsync, usePenumbraTransportSync } from './use-penumbra-transport.js'; +import { usePenumbra } from './use-penumbra.js'; + +export const usePenumbraService = ( + service: S, +): PromiseClient | undefined => { + const { transport } = usePenumbra(); + return useMemo(() => transport && createPromiseClient(service, transport), [service, transport]); +}; + +export const usePenumbraServiceSync = (service: S): PromiseClient => { + const transport = usePenumbraTransportSync(); + return useMemo(() => createPromiseClient(service, transport), [service, transport]); +}; + +export const usePenumbraServiceAsync = ( + service: S, +): Promise> => { + const transportPromise = usePenumbraTransportAsync(); + return useMemo( + () => transportPromise.then(transport => createPromiseClient(service, transport)), + [service, transportPromise], + ); +}; diff --git a/packages/react/src/hooks/use-penumbra-transport.ts b/packages/react/src/hooks/use-penumbra-transport.ts new file mode 100644 index 0000000000..65671dd0d7 --- /dev/null +++ b/packages/react/src/hooks/use-penumbra-transport.ts @@ -0,0 +1,90 @@ +import { + type ChannelTransportOptions, + createChannelTransport, +} from '@penumbra-zone/transport-dom/create'; +import { useEffect, useMemo, useState } from 'react'; +import { usePenumbra } from './use-penumbra.js'; +import { PenumbraState } from '@penumbra-zone/client'; + +export const usePenumbraTransport = () => usePenumbra().transport; + +/** This method immediately returns a new, unshared Transport to the surrounding + * Penumbra context. This transport will always create synchronously, but may + * time out and reject all requests if the Penumbra context does not provide a + * port within your configured defaultTimeoutMs (defaults to 10 seconds). */ +export const usePenumbraTransportSync = (opts?: Omit) => { + const penumbra = usePenumbra(); + const { port, failure, state } = penumbra; + + // use a local promise to avoid re-rendering when the port is set + const [{ resolve: resolvePort, reject: rejectPort, promise: portPromise }] = useState( + Promise.withResolvers(), + ); + + // memoize the transport to avoid re-creating it on every render + const transport = useMemo( + () => + createChannelTransport({ + ...penumbra.transportOpts, + ...opts, + getPort: () => portPromise, + }), + [penumbra, portPromise, opts], + ); + + // handle context updates + useEffect(() => { + if (port) { + resolvePort(port); + } else if (failure) { + rejectPort(failure); + } + }, [failure, penumbra, port, resolvePort, rejectPort, state]); + + return transport; +}; + +/** This method Promises a new, unshared Transport to the provided Penumbra + * context. Awaits confirmation of a MessagePort to the provider in context + * before attempting to create the Transport, so this will not time out if + * approval takes very long - but it must be async. The returned promise may + * reject with a connection failure. */ +export const usePenumbraTransportAsync = (opts?: Omit) => { + const penumbra = usePenumbra(); + const { port, failure, state } = penumbra; + + // use a local promise to avoid re-rendering when the port is set + const [{ resolve: resolvePort, reject: rejectPort, promise: portPromise }] = useState( + Promise.withResolvers(), + ); + + // memoize the transport to avoid re-creating it on every render + const transportPromise = useMemo( + () => + portPromise.then(() => + createChannelTransport({ + ...penumbra.transportOpts, + ...opts, + getPort: () => portPromise, + }), + ), + [penumbra, portPromise, opts], + ); + + // handle context updates + useEffect(() => { + if (port) { + resolvePort(port); + } else if (failure ?? state === PenumbraState.Failed) { + rejectPort(failure ?? new Error('Unknown failure')); + } + }, [failure, penumbra, port, resolvePort, rejectPort, state]); + + switch (state) { + case PenumbraState.Disconnected: + case PenumbraState.Failed: + return Promise.reject(failure ?? new Error(state)); + default: + return transportPromise; + } +}; diff --git a/packages/react/src/hooks/use-penumbra.ts b/packages/react/src/hooks/use-penumbra.ts new file mode 100644 index 0000000000..06ab4e0802 --- /dev/null +++ b/packages/react/src/hooks/use-penumbra.ts @@ -0,0 +1,4 @@ +import { useContext } from 'react'; +import { penumbraContext } from '../penumbra-context.js'; + +export const usePenumbra = () => useContext(penumbraContext); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts new file mode 100644 index 0000000000..160a0d591a --- /dev/null +++ b/packages/react/src/index.ts @@ -0,0 +1,2 @@ +export { usePenumbra } from './hooks/use-penumbra.js'; +export { PenumbraContextProvider } from './components/penumbra-context-provider.js'; diff --git a/packages/react/src/penumbra-context.ts b/packages/react/src/penumbra-context.ts new file mode 100644 index 0000000000..3a300c2412 --- /dev/null +++ b/packages/react/src/penumbra-context.ts @@ -0,0 +1,23 @@ +import type { Transport } from '@connectrpc/connect'; +import { + PenumbraProvider, + PenumbraSymbol, + type PenumbraManifest, + type PenumbraState, +} from '@penumbra-zone/client'; +import type { ChannelTransportOptions } from '@penumbra-zone/transport-dom/create'; +import { createContext } from 'react'; + +const penumbraGlobal = window[PenumbraSymbol]; + +export type PenumbraContext = Partial> & { + origin?: keyof NonNullable; + manifest?: PenumbraManifest; + port?: MessagePort | false; + failure?: Error; + state?: PenumbraState; + transport?: Transport; + transportOpts?: Omit; +}; + +export const penumbraContext = createContext({}); diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 100644 index 0000000000..885e36dc3c --- /dev/null +++ b/packages/react/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "exactOptionalPropertyTypes": false, + "composite": true, + "jsx": "react-jsx", + "module": "Node16", + "noEmit": true, + "target": "ESNext" + }, + "extends": "@tsconfig/strictest/tsconfig.json", + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d9fa622d2..82da4d97ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,7 +176,7 @@ importers: version: 3.3.0(vite@5.3.3(@types/node@20.14.10)(terser@5.31.1)) vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0)(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) + version: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0(playwright@1.45.1)(vitest@1.6.0))(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) apps/minifront: dependencies: @@ -490,6 +490,31 @@ importers: specifier: workspace:* version: link:../wasm + packages/react: + dependencies: + '@bufbuild/protobuf': + specifier: ^1.10.0 + version: 1.10.0 + '@penumbra-zone/client': + specifier: workspace:* + version: link:../client + '@penumbra-zone/protobuf': + specifier: workspace:* + version: link:../protobuf + '@penumbra-zone/transport-dom': + specifier: workspace:* + version: link:../transport-dom + devDependencies: + '@connectrpc/connect': + specifier: ^1.4.0 + version: 1.4.0(@bufbuild/protobuf@1.10.0) + '@types/react': + specifier: ^18.3.2 + version: 18.3.3 + react: + specifier: ^18.3.1 + version: 18.3.1 + packages/services: devDependencies: '@buf/penumbra-zone_penumbra.bufbuild_es': @@ -17067,7 +17092,7 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 optionalDependencies: - vitest: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0)(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) + vitest: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0(playwright@1.45.1)(vitest@1.6.0))(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) '@testing-library/jest-dom@6.4.6(vitest@1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0)(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1))': dependencies: @@ -17080,7 +17105,7 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 optionalDependencies: - vitest: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0)(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) + vitest: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0(playwright@1.45.1)(vitest@1.6.0))(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) '@testing-library/react@15.0.7(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -17674,7 +17699,7 @@ snapshots: '@vitest/utils': 1.6.0 magic-string: 0.30.10 sirv: 2.0.4 - vitest: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0)(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) + vitest: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0(playwright@1.45.1)(vitest@1.6.0))(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) optionalDependencies: playwright: 1.45.1 @@ -19605,7 +19630,7 @@ snapshots: '@typescript-eslint/utils': 7.16.0(eslint@9.6.0)(typescript@5.5.3) eslint: 9.6.0 optionalDependencies: - vitest: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0)(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) + vitest: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0(playwright@1.45.1)(vitest@1.6.0))(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) transitivePeerDependencies: - supports-color - typescript @@ -23363,7 +23388,7 @@ snapshots: fsevents: 2.3.3 terser: 5.31.1 - vitest@1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0)(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1): + vitest@1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0(playwright@1.45.1)(vitest@1.6.0))(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1): dependencies: '@vitest/expect': 1.6.0 '@vitest/runner': 1.6.0