Skip to content

Commit

Permalink
Force clientside use of @penumbra-zone/react (#1506)
Browse files Browse the repository at this point in the history
* use prop forcing client-side execution
  • Loading branch information
turbocrime authored Jul 18, 2024
1 parent b6c6a3c commit 1a269d4
Show file tree
Hide file tree
Showing 3 changed files with 62 additions and 45 deletions.
5 changes: 5 additions & 0 deletions .changeset/moody-carrots-rhyme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@penumbra-zone/react': patch
---

encourage client-side execution with input prop that cannot be obtained server-side
45 changes: 31 additions & 14 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,38 @@ you're writing a Penumbra dapp in React.
npm config set @buf:registry https://buf.build/gen/npm/v1
```

## This is a client-side package

The components in this package interact with a browser extension, so can only be
executed in a browser, not in any server-side rendering context. To encourage
this, `<PenumbraContextProvider>` uses the `penumbra` input prop which may only
be obtained client-side. It's recommended to use methods from
`@penumbra-zone/client` to obtain this value, as described below.

## 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
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 `<PenumbraContextProvider>` 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.
or with helpers available from `@penumbra-zone/client`, like

```ts
import { assertProvider } from '@penumbra-zone/client';
const prax = assertProvider('chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe');
```

Use of `<PenumbraContextProvider>` with a `penumbra` prop identifying your
provider will result in automatic progress towards a successful connection.
Connection requires user approval, so it's recommended provide UI on your page
controlling the `makeApprovalRequest` prop.

Hooks `usePenumbraTransport` and `usePenumbraService` will promise a transport
or client that inits when the configured provider becomes connected, or rejects
Expand All @@ -40,8 +56,10 @@ client may time out.
## `<PenumbraContextProvider>`

This wrapping component will provide a context available to all child components
that is directly accessible by `usePenumbra`, or additionally by
`usePenumbraTransport` or `usePenumbraService`.
that is directly accessible by `usePenumbra`, or by `usePenumbraTransport` or
`usePenumbraService`. Accepts a `makeApprovalRequest` prop, off by default, to
configure conditional use of the `request` method of the Penumbra interface,
which may trigger a popup or require user interaction.

### Unary requests may use `@connectrpc/connect-query`

Expand All @@ -55,7 +73,8 @@ A wrapping component:

```tsx
import { Outlet } from 'react-router-dom';
import { PenumbraProvider } from '@penumbra-zone/react';
import { assertProvider } from '@penumbra-zone/client';
import { PenumbraContextProvider } 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';
Expand All @@ -64,13 +83,13 @@ const praxOrigin = 'chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe';
const queryClient = new QueryClient();

export const PenumbraDappPage = () => (
<PenumbraProvider origin={praxOrigin} makeApprovalRequest>
<PenumbraContextProvider penumbra={assertProvider(praxOrigin)} makeApprovalRequest>
<TransportProvider transport={usePenumbraTransportSync()}>
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
</TransportProvider>
</PenumbraProvider>
</PenumbraContextProvider>
);
```

Expand Down Expand Up @@ -142,9 +161,7 @@ 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 `<PenumbraContextProvider>` component handles this state
and all of these transitions for you. Use of `<PenumbraContextProvider>` with an
`origin` or `provider` prop will result in automatic progress towards a
`Connected` state.
and all of these transitions for you.

During this progress, the context exposes an explicit status, so you may easily
condition your layout and display. You can access this status via
Expand All @@ -160,7 +177,7 @@ 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
chart node is a possible value of `PenumbraState`. Diamond-shaped nodes
are conditions described by the surrounding path labels.

There are more possible transitions than diagrammed here - for instance once
Expand Down
57 changes: 26 additions & 31 deletions packages/react/src/components/penumbra-context-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
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';
Expand All @@ -10,21 +9,19 @@ import {
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { PenumbraContext, penumbraContext } from '../penumbra-context.js';

type PenumbraContextProviderProps = {
interface PenumbraContextProviderProps {
children?: ReactNode;
origin: string;
penumbra?: PenumbraProvider;
makeApprovalRequest?: boolean;
transportOpts?: Omit<ChannelTransportOptions, 'getPort'>;
} & ({ provider: PenumbraProvider } | { origin: string });
}

export const PenumbraContextProvider = ({
children,
origin: providerOrigin,
penumbra,
makeApprovalRequest = false,
transportOpts,
}: PenumbraContextProviderProps) => {
const penumbra = assertProviderRecord(providerOrigin);

const [providerConnected, setProviderConnected] = useState<boolean>();
const [providerManifest, setProviderManifest] = useState<PenumbraManifest>();
const [providerPort, setProviderPort] = useState<MessagePort>();
Expand Down Expand Up @@ -53,25 +50,25 @@ export const PenumbraContextProvider = ({

// fetch manifest to confirm presence of provider
useEffect(() => {
// require origin. skip if failure or manifest present
if (!providerOrigin || (failure ?? providerManifest)) {
// require provider manifest uri, skip if failure or manifest present
if (!penumbra?.manifest || (failure ?? providerManifest)) {
return;
}

// abortable effect
const ac = new AbortController();

void getPenumbraManifest(providerOrigin, ac.signal)
void getPenumbraManifest(new URL(penumbra.manifest).origin, ac.signal)
.then(manifestJson => ac.signal.aborted || setProviderManifest(manifestJson))
.catch(setFailure);

return () => ac.abort();
}, [failure, penumbra, providerManifest, providerOrigin, setFailure, setProviderManifest]);
}, [failure, penumbra?.manifest, providerManifest, setFailure, setProviderManifest]);

// attach state event listener
useEffect(() => {
// require manifest. unnecessary if failed
if (!providerManifest || failure) {
// require penumbra, manifest. unnecessary if failed
if (!penumbra || !providerManifest || failure) {
return;
}

Expand All @@ -81,7 +78,7 @@ export const PenumbraContextProvider = ({
'penumbrastate',
(evt: Event) => {
if (isPenumbraStateEvent(evt)) {
if (evt.detail.origin !== providerOrigin) {
if (evt.detail.origin !== new URL(penumbra.manifest).origin) {
setFailure(new Error('State change from unexpected origin'));
} else if (evt.detail.state !== penumbra.state()) {
console.warn('State change not verifiable');
Expand All @@ -94,12 +91,12 @@ export const PenumbraContextProvider = ({
{ signal: ac.signal },
);
return () => ac.abort();
}, [failure, penumbra, penumbra.addEventListener, providerManifest, providerOrigin, setFailure]);
}, [failure, penumbra?.addEventListener, providerManifest, penumbra?.manifest, setFailure]);

// request effect
useEffect(() => {
// require manifest, no failures
if (providerManifest && !failure) {
// require penumbra, manifest, no failures
if (penumbra?.request && providerManifest && !failure) {
switch (providerState) {
case PenumbraState.Present:
if (makeApprovalRequest) {
Expand All @@ -113,8 +110,7 @@ export const PenumbraContextProvider = ({
}, [
failure,
makeApprovalRequest,
penumbra,
penumbra.request,
penumbra?.request,
providerManifest,
providerState,
setFailure,
Expand All @@ -123,7 +119,7 @@ export const PenumbraContextProvider = ({
// connect effect
useEffect(() => {
// require manifest, no failures
if (providerManifest && !failure) {
if (penumbra && providerManifest && !failure) {
switch (providerState) {
case PenumbraState.Present:
if (!makeApprovalRequest) {
Expand All @@ -146,8 +142,7 @@ export const PenumbraContextProvider = ({
}, [
failure,
makeApprovalRequest,
penumbra,
penumbra.connect,
penumbra?.connect,
providerManifest,
providerState,
setFailure,
Expand All @@ -157,7 +152,7 @@ export const PenumbraContextProvider = ({
() => ({
failure,
manifest: providerManifest,
origin: providerOrigin,
origin: penumbra?.manifest && new URL(penumbra.manifest).origin,

// require manifest to forward state
state: providerManifest && providerState,
Expand All @@ -171,8 +166,8 @@ export const PenumbraContextProvider = ({
: undefined,
transportOpts,

// require manifest and no failures to forward injected methods
...(providerManifest && !failure
// require penumbra, manifest and no failures to forward injected things
...(penumbra && providerManifest && !failure
? {
port: providerConnected && providerPort,
connect: penumbra.connect,
Expand All @@ -186,14 +181,14 @@ export const PenumbraContextProvider = ({
}),
[
failure,
penumbra.addEventListener,
penumbra.connect,
penumbra.disconnect,
penumbra.removeEventListener,
penumbra.request,
penumbra?.addEventListener,
penumbra?.connect,
penumbra?.disconnect,
penumbra?.manifest,
penumbra?.removeEventListener,
penumbra?.request,
providerConnected,
providerManifest,
providerOrigin,
providerPort,
providerState,
transportOpts,
Expand Down

0 comments on commit 1a269d4

Please sign in to comment.