From 704552bccd72842d12982804f4e3cc09d14eebc9 Mon Sep 17 00:00:00 2001 From: turbocrime Date: Fri, 9 Aug 2024 18:16:30 -0700 Subject: [PATCH] react --- .syncpackrc | 7 +++ packages/client/src/client.ts | 2 +- packages/react/package.json | 57 ++++++++++++++++++ .../components/penumbra-context-provider.tsx | 45 ++++++++++++++ .../src/context/penumbra-context-input.ts | 25 ++++++++ .../react/src/context/penumbra-context.ts | 4 ++ .../react/src/hooks/use-penumbra-query.ts | 60 +++++++++++++++++++ .../react/src/hooks/use-penumbra-service.ts | 15 +++++ packages/react/src/hooks/use-penumbra.ts | 4 ++ packages/react/src/index.ts | 5 ++ packages/react/tsconfig.json | 14 +++++ pnpm-lock.yaml | 43 +++++++++++++ 12 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 packages/react/package.json create mode 100644 packages/react/src/components/penumbra-context-provider.tsx create mode 100644 packages/react/src/context/penumbra-context-input.ts create mode 100644 packages/react/src/context/penumbra-context.ts create mode 100644 packages/react/src/hooks/use-penumbra-query.ts create mode 100644 packages/react/src/hooks/use-penumbra-service.ts create mode 100644 packages/react/src/hooks/use-penumbra.ts create mode 100644 packages/react/src/index.ts create mode 100644 packages/react/tsconfig.json diff --git a/.syncpackrc b/.syncpackrc index bdf39ce887..be54bf3b0f 100644 --- a/.syncpackrc +++ b/.syncpackrc @@ -35,6 +35,13 @@ } ], "versionGroups": [ + { + "label": "Ignore @tanstack/react-query", + "dependencies": [ + "@tanstack/react-query" + ], + "isIgnored": true + }, { "label": "Use workspace protocol for local packages", "dependencies": [ diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 0f7b7d1d28..d1086a74c4 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -22,7 +22,7 @@ const isLegacyProvider = ( provider: PenumbraProvider, ): provider is PenumbraProvider & { request: () => Promise } => 'request' in provider; -interface PenumbraClientOptions { +export interface PenumbraClientOptions { /** Custom options for this client's `Transport`. */ transportOptions: Omit; } diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 0000000000..4ad83683fd --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,57 @@ +{ + "name": "@penumbra-zone/react", + "version": "1.2.0", + "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:*" + }, + "devDependencies": { + "@bufbuild/protobuf": "^1.10.0", + "@connectrpc/connect": "^1.4.0", + "@penumbra-zone/transport-dom": "workspace:*", + "@tanstack/react-query": "5.51.23", + "@types/react": "^18.3.2", + "react": "^18.3.1" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^1.10.0", + "@connectrpc/connect": "^1.4.0", + "@penumbra-zone/protobuf": "workspace:*", + "@penumbra-zone/transport-dom": "workspace:*", + "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..89f35e07d2 --- /dev/null +++ b/packages/react/src/components/penumbra-context-provider.tsx @@ -0,0 +1,45 @@ +import { penumbraContext } from '../context/penumbra-context.js'; +import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import { + PenumbraContextInput, + resolvePenumbraContextInput, +} from '../context/penumbra-context-input.js'; +import { PenumbraEventDetail } from '@penumbra-zone/client'; + +export const PenumbraContextProvider = ({ + client, + provider, + children, +}: PenumbraContextInput & { children?: ReactNode }) => { + const penumbra = useMemo( + () => resolvePenumbraContextInput({ client, provider }), + [client, provider], + ); + + const [providerState, setProviderState] = useState(penumbra.state); + const [providerConnected, setProviderConnected] = useState(penumbra.connected); + + useEffect(() => { + console.log('setting providerConnected', providerState, penumbra.connected); + setProviderConnected(penumbra.connected); + }, [penumbra, providerState]); + + const listener = useCallback((update: PenumbraEventDetail<'penumbrastate'>) => { + console.log('listener', update); + setProviderState(update.state); + }, []); + + useEffect(() => { + const ac = new AbortController(); + penumbra.onConnectionStateChange(listener, ac.signal); + return () => ac.abort(); + }, [penumbra, listener]); + + console.log('providerConnected', providerConnected); + + return ( + + {children} + + ); +}; diff --git a/packages/react/src/context/penumbra-context-input.ts b/packages/react/src/context/penumbra-context-input.ts new file mode 100644 index 0000000000..57f8261e36 --- /dev/null +++ b/packages/react/src/context/penumbra-context-input.ts @@ -0,0 +1,25 @@ +import { PenumbraClient, PenumbraProvider } from '@penumbra-zone/client'; +import { PenumbraClientOptions } from '@penumbra-zone/client/client'; + +export interface PenumbraContextInput { + client?: PenumbraClient | PenumbraClientOptions; + provider?: PenumbraProvider | string; +} + +export const resolvePenumbraContextInput = (input: PenumbraContextInput): PenumbraClient => { + let client = input.client instanceof PenumbraClient ? input.client : undefined; + const options = input.client instanceof PenumbraClient ? undefined : input.client; + + const providerOrigin = + typeof input.provider === 'string' + ? input.provider + : input.provider && new URL(input.provider.manifest).origin; + + client ??= new PenumbraClient(providerOrigin, options); + + if (providerOrigin) { + void client.attach(providerOrigin); + } + + return client; +}; diff --git a/packages/react/src/context/penumbra-context.ts b/packages/react/src/context/penumbra-context.ts new file mode 100644 index 0000000000..5480bae525 --- /dev/null +++ b/packages/react/src/context/penumbra-context.ts @@ -0,0 +1,4 @@ +import { PenumbraClient } from '@penumbra-zone/client/client'; +import { createContext } from 'react'; + +export const penumbraContext = createContext(new PenumbraClient()); diff --git a/packages/react/src/hooks/use-penumbra-query.ts b/packages/react/src/hooks/use-penumbra-query.ts new file mode 100644 index 0000000000..7120cf46f0 --- /dev/null +++ b/packages/react/src/hooks/use-penumbra-query.ts @@ -0,0 +1,60 @@ +import { ServiceType } from '@bufbuild/protobuf'; +import { usePenumbra } from './use-penumbra.js'; +import { QueryOptions, useQuery, UseQueryResult } from '@tanstack/react-query'; +import { PromiseClient } from '@connectrpc/connect'; + +export const usePenumbraQuery = ( + serviceType: S, +): PenumbraQuerier | undefined => { + const penumbra = usePenumbra(); + + if (!penumbra.transport) { + return; + } + + const wrappedMethods = Object.keys(serviceType.methods).map( + (methodName: N) => { + const serviceClient = penumbra.service(serviceType); + + const queryFn: QueryOptions['queryFn'] = ({ meta }) => { + const { + params: [input, options], + } = meta as { params: Parameters[N]> }; + const response = serviceClient[methodName](input as never, options); + + if (Symbol.asyncIterator in response) { + return Array.fromAsync(response); + } else { + return response; + } + }; + + const useMethodQuery: PenumbraQuerierMethod = (queryOptions, ...params) => + useQuery({ + ...queryOptions, + queryKey: [serviceType.typeName, methodName, params], + queryFn, + meta: { params }, + }); + + return [methodName, useMethodQuery] as const; + }, + ); + + return Object.fromEntries(wrappedMethods) as PenumbraQuerier; +}; + +type PromiseClientMethod = PromiseClient[M]; + +type PenumbraQuerierMethod = ( + queryOptions: Omit, + ...args: Parameters> +) => UsePenumbraQueryResult; + +type UsePenumbraQueryResult = UseQueryResult< + ReturnType> +>; + +type PenumbraQuerier = { + [localName in keyof S['methods']]: PenumbraQuerierMethod; +}; 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..610fdaa49f --- /dev/null +++ b/packages/react/src/hooks/use-penumbra-service.ts @@ -0,0 +1,15 @@ +import { PromiseClient } from '@connectrpc/connect'; +import { useMemo } from 'react'; +import { usePenumbra } from './use-penumbra.js'; +import { ServiceType } from '@bufbuild/protobuf'; + +export const usePenumbraService = ( + serviceType: S, +): PromiseClient | undefined => { + const penumbra = usePenumbra(); + const connected = penumbra.connected; + return useMemo( + () => (connected ? penumbra.service(serviceType) : undefined), + [connected, penumbra, serviceType], + ); +}; diff --git a/packages/react/src/hooks/use-penumbra.ts b/packages/react/src/hooks/use-penumbra.ts new file mode 100644 index 0000000000..083c8571f6 --- /dev/null +++ b/packages/react/src/hooks/use-penumbra.ts @@ -0,0 +1,4 @@ +import { useContext } from 'react'; +import { penumbraContext } from '../context/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..8e5f45451d --- /dev/null +++ b/packages/react/src/index.ts @@ -0,0 +1,5 @@ +export { usePenumbra } from './hooks/use-penumbra.js'; +export { usePenumbraQuery } from './hooks/use-penumbra-query.js'; +export { usePenumbraService } from './hooks/use-penumbra-service.js'; +export { penumbraContext } from './context/penumbra-context.js'; +export { PenumbraContextProvider } from './components/penumbra-context-provider.js'; diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 100644 index 0000000000..80057ff1aa --- /dev/null +++ b/packages/react/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "exactOptionalPropertyTypes": false, + "composite": true, + "jsx": "react-jsx", + "module": "Node16", + "outDir": "dist", + "preserveWatchOutput": true, + "rootDir": "src", + "target": "ESNext" + }, + "extends": "@tsconfig/strictest/tsconfig.json", + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55e48e564d..655f70d17c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -476,6 +476,34 @@ importers: specifier: workspace:* version: link:../wasm + packages/react: + dependencies: + '@penumbra-zone/client': + specifier: workspace:* + version: link:../client + '@penumbra-zone/protobuf': + specifier: workspace:* + version: link:../protobuf + devDependencies: + '@bufbuild/protobuf': + specifier: ^1.10.0 + version: 1.10.0 + '@connectrpc/connect': + specifier: ^1.4.0 + version: 1.4.0(@bufbuild/protobuf@1.10.0) + '@penumbra-zone/transport-dom': + specifier: workspace:* + version: link:../transport-dom + '@tanstack/react-query': + specifier: 5.51.23 + version: 5.51.23(react@18.3.1) + '@types/react': + specifier: ^18.3.2 + version: 18.3.3 + react: + specifier: ^18.3.1 + version: 18.3.1 + packages/services: devDependencies: '@bufbuild/protobuf': @@ -4686,6 +4714,9 @@ packages: '@tanstack/query-core@4.36.1': resolution: {integrity: sha512-DJSilV5+ytBP1FbFcEJovv4rnnm/CokuVvrBEtW/Va9DvuJ3HksbXUJEpI0aV1KtuL4ZoO9AVE6PyNLzF7tLeA==} + '@tanstack/query-core@5.51.21': + resolution: {integrity: sha512-POQxm42IUp6n89kKWF4IZi18v3fxQWFRolvBA6phNVmA8psdfB1MvDnGacCJdS+EOX12w/CyHM62z//rHmYmvw==} + '@tanstack/react-query@4.36.1': resolution: {integrity: sha512-y7ySVHFyyQblPl3J3eQBWpXZkliroki3ARnBKsdJchlgt7yJLRDUcf4B8soufgiYt3pEQIkBWBx1N9/ZPIeUWw==} peerDependencies: @@ -4698,6 +4729,11 @@ packages: react-native: optional: true + '@tanstack/react-query@5.51.23': + resolution: {integrity: sha512-CfJCfX45nnVIZjQBRYYtvVMIsGgWLKLYC4xcUiYEey671n1alvTZoCBaU9B85O8mF/tx9LPyrI04A6Bs2THv4A==} + peerDependencies: + react: ^18.0.0 + '@tanstack/react-virtual@3.8.4': resolution: {integrity: sha512-Dq0VQr3QlTS2qL35g360QaJWBt7tCn/0xw4uZ0dHXPLO1Ak4Z4nVX4vuj1Npg1b/jqNMDToRtR5OIxM2NXRBWg==} peerDependencies: @@ -16907,6 +16943,8 @@ snapshots: '@tanstack/query-core@4.36.1': {} + '@tanstack/query-core@5.51.21': {} + '@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/query-core': 4.36.1 @@ -16915,6 +16953,11 @@ snapshots: optionalDependencies: react-dom: 18.3.1(react@18.3.1) + '@tanstack/react-query@5.51.23(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.51.21 + react: 18.3.1 + '@tanstack/react-virtual@3.8.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/virtual-core': 3.8.4