diff --git a/.changeset/lucky-goats-sell.md b/.changeset/lucky-goats-sell.md new file mode 100644 index 0000000000..c6dabfe651 --- /dev/null +++ b/.changeset/lucky-goats-sell.md @@ -0,0 +1,31 @@ +--- +"@latticexyz/store-sync": patch +--- + +Added an experimental `@latticexyz/store-sync/react` export with a `SyncProvider` and `useSync` hook. This allows for easier syncing MUD data to React apps. + +Note that this is currently only usable with Stash and assumes you are also using Wagmi in your React app. + +```tsx +import { WagmiProvider } from "wagmi"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { SyncProvider } from "@latticexyz/store-sync/react"; +import { createSyncAdapter } from "@latticexyz/store-sync/internal"; + +export function App() { + return ( + + + + {children} + + + + ); +} +``` diff --git a/packages/store-sync/package.json b/packages/store-sync/package.json index 3d1b69051d..d42b5d731b 100644 --- a/packages/store-sync/package.json +++ b/packages/store-sync/package.json @@ -13,6 +13,7 @@ ".": "./dist/index.js", "./indexer-client": "./dist/indexer-client/index.js", "./internal": "./dist/exports/internal.js", + "./react": "./dist/exports/react.js", "./postgres": "./dist/postgres/index.js", "./postgres-decoded": "./dist/postgres-decoded/index.js", "./recs": "./dist/recs/index.js", @@ -32,6 +33,9 @@ "internal": [ "./dist/exports/internal.d.ts" ], + "react": [ + "./dist/exports/react.d.ts" + ], "postgres": [ "./dist/postgres/index.d.ts" ], @@ -95,16 +99,39 @@ "zustand": "^4.3.7" }, "devDependencies": { + "@tanstack/react-query": "^5.56.2", + "@testing-library/react": "^16.0.0", + "@testing-library/react-hooks": "^8.0.1", "@types/debug": "^4.1.7", "@types/node": "20.12.12", + "@types/react": "18.2.22", "@types/sql.js": "^1.4.4", "@viem/anvil": "^0.0.7", + "eslint-plugin-react": "7.31.11", + "eslint-plugin-react-hooks": "4.6.0", + "react": "18.2.0", + "react-dom": "18.2.0", "tsup": "^6.7.0", "viem": "2.21.19", - "vitest": "0.34.6" + "vitest": "0.34.6", + "wagmi": "2.12.11" }, "peerDependencies": { - "viem": "2.x" + "@tanstack/react-query": "5.x", + "react": "18.x", + "viem": "2.x", + "wagmi": "2.x" + }, + "peerDependenciesMeta": { + "@tanstack/react-query": { + "optional": true + }, + "react": { + "optional": true + }, + "wagmi": { + "optional": true + } }, "publishConfig": { "access": "public" diff --git a/packages/store-sync/src/common.ts b/packages/store-sync/src/common.ts index 518d9fa434..f4179b70ed 100644 --- a/packages/store-sync/src/common.ts +++ b/packages/store-sync/src/common.ts @@ -126,6 +126,8 @@ export type SyncResult = { waitForTransaction: (tx: Hex) => Promise; }; +export type SyncAdapter = (opts: SyncOptions) => Promise; + // TODO: add optional, original log to this? export type StorageAdapterLog = Partial & UnionPick; export type StorageAdapterBlock = { blockNumber: BlockLogs["blockNumber"]; logs: readonly StorageAdapterLog[] }; diff --git a/packages/store-sync/src/exports/internal.ts b/packages/store-sync/src/exports/internal.ts index 65f0049838..38c719d9ed 100644 --- a/packages/store-sync/src/exports/internal.ts +++ b/packages/store-sync/src/exports/internal.ts @@ -1,2 +1,8 @@ +// SQL export * from "../sql"; -export * from "../stash"; + +// Stash +export * from "../stash/common"; +export * from "../stash/createStorageAdapter"; +export * from "../stash/createSyncAdapter"; +export * from "../stash/syncToStash"; diff --git a/packages/store-sync/src/exports/react.ts b/packages/store-sync/src/exports/react.ts new file mode 100644 index 0000000000..e47b4a61a5 --- /dev/null +++ b/packages/store-sync/src/exports/react.ts @@ -0,0 +1,2 @@ +export * from "../react/SyncProvider"; +export * from "../react/useSync"; diff --git a/packages/store-sync/src/react/SyncProvider.tsx b/packages/store-sync/src/react/SyncProvider.tsx new file mode 100644 index 0000000000..3c5379232e --- /dev/null +++ b/packages/store-sync/src/react/SyncProvider.tsx @@ -0,0 +1,57 @@ +import { ReactNode, createContext, useContext, useEffect } from "react"; +import { useConfig } from "wagmi"; +import { getClient } from "wagmi/actions"; +import { useQuery } from "@tanstack/react-query"; +import { SyncAdapter, SyncOptions, SyncResult } from "../common"; + +/** @internal */ +export const SyncContext = createContext<{ + sync?: SyncResult; +} | null>(null); + +export type Props = Omit & { + chainId: number; + adapter: SyncAdapter; + children: ReactNode; +}; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function SyncProvider({ chainId, adapter, children, ...syncOptions }: Props) { + const existingValue = useContext(SyncContext); + if (existingValue != null) { + throw new Error("A `SyncProvider` cannot be nested inside another."); + } + + const config = useConfig(); + + const { data: sync, error: syncError } = useQuery({ + queryKey: ["sync", chainId], + queryFn: async () => { + const client = getClient(config, { chainId }); + if (!client) { + throw new Error(`Unable to retrieve Viem client for chain ${chainId}.`); + } + + return adapter({ publicClient: client, ...syncOptions }); + }, + staleTime: Infinity, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + if (syncError) throw syncError; + + useEffect(() => { + if (!sync) return; + + const sub = sync.storedBlockLogs$.subscribe({ + error: (error) => console.error("got sync error", error), + }); + + return (): void => { + sub.unsubscribe(); + }; + }, [sync]); + + return {children}; +} diff --git a/packages/store-sync/src/react/useSync.ts b/packages/store-sync/src/react/useSync.ts new file mode 100644 index 0000000000..fa4e66afc8 --- /dev/null +++ b/packages/store-sync/src/react/useSync.ts @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import { SyncContext } from "./SyncProvider"; +import { SyncResult } from "../common"; + +export function useSync(): Partial { + const value = useContext(SyncContext); + if (value == null) { + throw new Error("`useSync` must be used inside a `SyncProvider`."); + } + const { sync } = value; + return sync ?? {}; +} diff --git a/packages/store-sync/src/stash/common.ts b/packages/store-sync/src/stash/common.ts new file mode 100644 index 0000000000..1aee0ceec2 --- /dev/null +++ b/packages/store-sync/src/stash/common.ts @@ -0,0 +1,24 @@ +import { defineTable } from "@latticexyz/store/internal"; +import { SyncStep } from "../SyncStep"; +import { getSchemaPrimitives, getValueSchema } from "@latticexyz/protocol-parser/internal"; + +export const SyncProgress = defineTable({ + namespaceLabel: "syncToStash", + label: "SyncProgress", + schema: { + step: "string", + percentage: "uint32", + latestBlockNumber: "uint256", + lastBlockNumberProcessed: "uint256", + message: "string", + }, + key: [], +}); + +export const initialProgress = { + step: SyncStep.INITIALIZE, + percentage: 0, + latestBlockNumber: 0n, + lastBlockNumberProcessed: 0n, + message: "Connecting", +} satisfies getSchemaPrimitives>; diff --git a/packages/store-sync/src/stash/createSyncAdapter.ts b/packages/store-sync/src/stash/createSyncAdapter.ts new file mode 100644 index 0000000000..b2892f7e10 --- /dev/null +++ b/packages/store-sync/src/stash/createSyncAdapter.ts @@ -0,0 +1,30 @@ +import { getRecord, setRecord, registerTable, Stash } from "@latticexyz/stash/internal"; +import { createStorageAdapter } from "./createStorageAdapter"; +import { SyncStep } from "../SyncStep"; +import { SyncAdapter } from "../common"; +import { createStoreSync } from "../createStoreSync"; +import { SyncProgress } from "./common"; + +export type CreateSyncAdapterOptions = { stash: Stash }; + +export function createSyncAdapter({ stash }: CreateSyncAdapterOptions): SyncAdapter { + return (opts) => { + // TODO: clear stash? + + registerTable({ stash, table: SyncProgress }); + + const storageAdapter = createStorageAdapter({ stash }); + + return createStoreSync({ + ...opts, + storageAdapter, + onProgress: (nextValue) => { + const currentValue = getRecord({ stash, table: SyncProgress, key: {} }); + // update sync progress until we're caught up and live + if (currentValue?.step !== SyncStep.LIVE) { + setRecord({ stash, table: SyncProgress, key: {}, value: nextValue }); + } + }, + }); + }; +} diff --git a/packages/store-sync/src/stash/index.ts b/packages/store-sync/src/stash/index.ts deleted file mode 100644 index acfaf6b1ad..0000000000 --- a/packages/store-sync/src/stash/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./createStorageAdapter"; -export * from "./syncToStash"; diff --git a/packages/store-sync/src/stash/syncToStash.ts b/packages/store-sync/src/stash/syncToStash.ts index cfda022373..694ded1443 100644 --- a/packages/store-sync/src/stash/syncToStash.ts +++ b/packages/store-sync/src/stash/syncToStash.ts @@ -1,33 +1,8 @@ -import { getRecord, setRecord, registerTable, Stash } from "@latticexyz/stash/internal"; -import { createStorageAdapter } from "./createStorageAdapter"; -import { defineTable } from "@latticexyz/store/internal"; -import { SyncStep } from "../SyncStep"; +import { Stash } from "@latticexyz/stash/internal"; import { SyncOptions, SyncResult } from "../common"; -import { createStoreSync } from "../createStoreSync"; -import { getSchemaPrimitives, getValueSchema } from "@latticexyz/protocol-parser/internal"; +import { createSyncAdapter } from "./createSyncAdapter"; -export const SyncProgress = defineTable({ - namespaceLabel: "syncToStash", - label: "SyncProgress", - schema: { - step: "string", - percentage: "uint32", - latestBlockNumber: "uint256", - lastBlockNumberProcessed: "uint256", - message: "string", - }, - key: [], -}); - -export const initialProgress = { - step: SyncStep.INITIALIZE, - percentage: 0, - latestBlockNumber: 0n, - lastBlockNumberProcessed: 0n, - message: "Connecting", -} satisfies getSchemaPrimitives>; - -export type SyncToStashOptions = Omit & { +export type SyncToStashOptions = SyncOptions & { stash: Stash; startSync?: boolean; }; @@ -41,21 +16,7 @@ export async function syncToStash({ startSync = true, ...opts }: SyncToStashOptions): Promise { - registerTable({ stash, table: SyncProgress }); - - const storageAdapter = createStorageAdapter({ stash }); - - const sync = await createStoreSync({ - ...opts, - storageAdapter, - onProgress: (nextValue) => { - const currentValue = getRecord({ stash, table: SyncProgress, key: {} }); - // update sync progress until we're caught up and live - if (currentValue?.step !== SyncStep.LIVE) { - setRecord({ stash, table: SyncProgress, key: {}, value: nextValue }); - } - }, - }); + const sync = await createSyncAdapter({ stash })(opts); const sub = startSync ? sync.storedBlockLogs$.subscribe() : null; function stopSync(): void { diff --git a/packages/store-sync/tsconfig.json b/packages/store-sync/tsconfig.json index b4e69ae1f9..004f435e6b 100644 --- a/packages/store-sync/tsconfig.json +++ b/packages/store-sync/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "dist" + "outDir": "dist", + "jsx": "react-jsx", + "jsxImportSource": "react" } } diff --git a/packages/store-sync/tsup.config.ts b/packages/store-sync/tsup.config.ts index 18b1edd6ea..63c13f2207 100644 --- a/packages/store-sync/tsup.config.ts +++ b/packages/store-sync/tsup.config.ts @@ -12,6 +12,7 @@ export default defineConfig((opts) => ({ "src/world/index.ts", "src/zustand/index.ts", "src/exports/internal.ts", + "src/exports/react.ts", ], target: "esnext", format: ["esm"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48ef785b8f..406accef36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1337,18 +1337,42 @@ importers: specifier: ^4.3.7 version: 4.3.7(react@18.2.0) devDependencies: + '@tanstack/react-query': + specifier: ^5.56.2 + version: 5.56.2(react@18.2.0) + '@testing-library/react': + specifier: ^16.0.0 + version: 16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.2.7)(@types/react@18.2.22)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@testing-library/react-hooks': + specifier: ^8.0.1 + version: 8.0.1(@types/react@18.2.22)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@types/debug': specifier: ^4.1.7 version: 4.1.7 '@types/node': specifier: 20.12.12 version: 20.12.12 + '@types/react': + specifier: 18.2.22 + version: 18.2.22 '@types/sql.js': specifier: ^1.4.4 version: 1.4.4 '@viem/anvil': specifier: ^0.0.7 version: 0.0.7(bufferutil@4.0.8)(debug@4.3.4)(utf-8-validate@5.0.10) + eslint-plugin-react: + specifier: 7.31.11 + version: 7.31.11(eslint@8.57.0) + eslint-plugin-react-hooks: + specifier: 4.6.0 + version: 4.6.0(eslint@8.57.0) + react: + specifier: 18.2.0 + version: 18.2.0 + react-dom: + specifier: 18.2.0 + version: 18.2.0(react@18.2.0) tsup: specifier: ^6.7.0 version: 6.7.0(postcss@8.5.1)(typescript@5.4.2) @@ -1358,6 +1382,9 @@ importers: vitest: specifier: 0.34.6 version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.34.1) + wagmi: + specifier: 2.12.11 + version: 2.12.11(@tanstack/query-core@5.56.2)(@tanstack/react-query@5.56.2(react@18.2.0))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react-dom@18.2.0(react@18.2.0))(react-native@0.75.2(@babel/core@7.25.7)(@babel/preset-env@7.25.3(@babel/core@7.25.7))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.2.0)(typescript@5.4.2)(utf-8-validate@5.0.10))(react@18.2.0)(rollup@4.30.1)(typescript@5.4.2)(utf-8-validate@5.0.10)(viem@2.21.19(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8) packages/utils: dependencies: @@ -8632,10 +8659,6 @@ packages: is-core-module@2.12.0: resolution: {integrity: sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==} - is-core-module@2.15.0: - resolution: {integrity: sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==} - engines: {node: '>= 0.4'} - is-core-module@2.15.1: resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} engines: {node: '>= 0.4'} @@ -18249,7 +18272,7 @@ snapshots: '@testing-library/react-hooks@8.0.1(@types/react@18.2.22)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.21.0 + '@babel/runtime': 7.25.7 react: 18.2.0 react-error-boundary: 3.1.4(react@18.2.0) optionalDependencies: @@ -18258,7 +18281,7 @@ snapshots: '@testing-library/react-hooks@8.0.1(@types/react@18.2.22)(react-test-renderer@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.21.0 + '@babel/runtime': 7.25.7 react: 18.2.0 react-error-boundary: 3.1.4(react@18.2.0) optionalDependencies: @@ -18267,7 +18290,7 @@ snapshots: '@testing-library/react@16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.2.7)(@types/react@18.2.22)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.21.0 + '@babel/runtime': 7.25.7 '@testing-library/dom': 10.4.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -20945,7 +20968,7 @@ snapshots: eslint-import-resolver-node@0.3.9: dependencies: debug: 3.2.7 - is-core-module: 2.15.0 + is-core-module: 2.15.1 resolve: 1.22.8 transitivePeerDependencies: - supports-color @@ -20959,7 +20982,7 @@ snapshots: eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.1.1(eslint@8.57.0)(typescript@5.4.2))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.5 - is-core-module: 2.15.0 + is-core-module: 2.15.1 is-glob: 4.0.3 transitivePeerDependencies: - '@typescript-eslint/parser' @@ -20990,7 +21013,7 @@ snapshots: eslint-import-resolver-node: 0.3.9 eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.1.1(eslint@8.57.0)(typescript@5.4.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.1(eslint@8.57.0)(typescript@5.4.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) hasown: 2.0.2 - is-core-module: 2.15.0 + is-core-module: 2.15.1 is-glob: 4.0.3 minimatch: 3.1.2 object.fromentries: 2.0.8 @@ -22114,10 +22137,6 @@ snapshots: dependencies: has: 1.0.3 - is-core-module@2.15.0: - dependencies: - hasown: 2.0.2 - is-core-module@2.15.1: dependencies: hasown: 2.0.2 @@ -24455,7 +24474,7 @@ snapshots: react-error-boundary@3.1.4(react@18.2.0): dependencies: - '@babel/runtime': 7.21.0 + '@babel/runtime': 7.25.7 react: 18.2.0 react-error-boundary@4.0.13(react@18.2.0): @@ -24824,7 +24843,7 @@ snapshots: resolve@2.0.0-next.5: dependencies: - is-core-module: 2.15.0 + is-core-module: 2.15.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -26443,6 +26462,43 @@ snapshots: - utf-8-validate - zod + wagmi@2.12.11(@tanstack/query-core@5.56.2)(@tanstack/react-query@5.56.2(react@18.2.0))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react-dom@18.2.0(react@18.2.0))(react-native@0.75.2(@babel/core@7.25.7)(@babel/preset-env@7.25.3(@babel/core@7.25.7))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.2.0)(typescript@5.4.2)(utf-8-validate@5.0.10))(react@18.2.0)(rollup@4.30.1)(typescript@5.4.2)(utf-8-validate@5.0.10)(viem@2.21.19(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8): + dependencies: + '@tanstack/react-query': 5.56.2(react@18.2.0) + '@wagmi/connectors': 5.1.10(@types/react@18.2.22)(@wagmi/core@2.13.5(@tanstack/query-core@5.56.2)(@types/react@18.2.22)(react@18.2.0)(typescript@5.4.2)(viem@2.21.19(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(encoding@0.1.13)(react-dom@18.2.0(react@18.2.0))(react-native@0.75.2(@babel/core@7.25.7)(@babel/preset-env@7.25.3(@babel/core@7.25.7))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.2.0)(typescript@5.4.2)(utf-8-validate@5.0.10))(react@18.2.0)(rollup@4.30.1)(typescript@5.4.2)(utf-8-validate@5.0.10)(viem@2.21.19(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8) + '@wagmi/core': 2.13.5(@tanstack/query-core@5.56.2)(@types/react@18.2.22)(react@18.2.0)(typescript@5.4.2)(viem@2.21.19(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8)) + react: 18.2.0 + use-sync-external-store: 1.2.0(react@18.2.0) + viem: 2.21.19(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8) + optionalDependencies: + typescript: 5.4.2 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@tanstack/query-core' + - '@types/react' + - '@upstash/redis' + - '@vercel/kv' + - bufferutil + - encoding + - immer + - ioredis + - react-dom + - react-native + - rollup + - supports-color + - uWebSockets.js + - utf-8-validate + - zod + walker@1.0.8: dependencies: makeerror: 1.0.12