Skip to content

Commit

Permalink
feat(store-sync): add react provider and hook (#3451)
Browse files Browse the repository at this point in the history
  • Loading branch information
holic authored Jan 16, 2025
1 parent 16242b7 commit 5f493cd
Show file tree
Hide file tree
Showing 14 changed files with 274 additions and 65 deletions.
31 changes: 31 additions & 0 deletions .changeset/lucky-goats-sell.md
Original file line number Diff line number Diff line change
@@ -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 (
<WagmiProvider config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
<SyncProvider
chainId={chainId}
address={worldAddress}
startBlock={startBlock}
adapter={createSyncAdapter({ stash })}
>
{children}
</SyncProvider>
</QueryClientProvider>
</WagmiProvider>
);
}
```
31 changes: 29 additions & 2 deletions packages/store-sync/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -32,6 +33,9 @@
"internal": [
"./dist/exports/internal.d.ts"
],
"react": [
"./dist/exports/react.d.ts"
],
"postgres": [
"./dist/postgres/index.d.ts"
],
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions packages/store-sync/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ export type SyncResult = {
waitForTransaction: (tx: Hex) => Promise<WaitForTransactionResult>;
};

export type SyncAdapter = (opts: SyncOptions) => Promise<SyncResult>;

// TODO: add optional, original log to this?
export type StorageAdapterLog = Partial<StoreEventsLog> & UnionPick<StoreEventsLog, "address" | "eventName" | "args">;
export type StorageAdapterBlock = { blockNumber: BlockLogs["blockNumber"]; logs: readonly StorageAdapterLog[] };
Expand Down
8 changes: 7 additions & 1 deletion packages/store-sync/src/exports/internal.ts
Original file line number Diff line number Diff line change
@@ -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";
2 changes: 2 additions & 0 deletions packages/store-sync/src/exports/react.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "../react/SyncProvider";
export * from "../react/useSync";
57 changes: 57 additions & 0 deletions packages/store-sync/src/react/SyncProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<SyncOptions, "publicClient"> & {
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 <SyncContext.Provider value={{ sync }}>{children}</SyncContext.Provider>;
}
12 changes: 12 additions & 0 deletions packages/store-sync/src/react/useSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useContext } from "react";
import { SyncContext } from "./SyncProvider";
import { SyncResult } from "../common";

export function useSync(): Partial<SyncResult> {
const value = useContext(SyncContext);
if (value == null) {
throw new Error("`useSync` must be used inside a `SyncProvider`.");
}
const { sync } = value;
return sync ?? {};
}
24 changes: 24 additions & 0 deletions packages/store-sync/src/stash/common.ts
Original file line number Diff line number Diff line change
@@ -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<getValueSchema<typeof SyncProgress>>;
30 changes: 30 additions & 0 deletions packages/store-sync/src/stash/createSyncAdapter.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
},
});
};
}
2 changes: 0 additions & 2 deletions packages/store-sync/src/stash/index.ts

This file was deleted.

47 changes: 4 additions & 43 deletions packages/store-sync/src/stash/syncToStash.ts
Original file line number Diff line number Diff line change
@@ -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<getValueSchema<typeof SyncProgress>>;

export type SyncToStashOptions = Omit<SyncOptions, "config"> & {
export type SyncToStashOptions = SyncOptions & {
stash: Stash;
startSync?: boolean;
};
Expand All @@ -41,21 +16,7 @@ export async function syncToStash({
startSync = true,
...opts
}: SyncToStashOptions): Promise<SyncToStashResult> {
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 {
Expand Down
4 changes: 3 additions & 1 deletion packages/store-sync/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist"
"outDir": "dist",
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}
1 change: 1 addition & 0 deletions packages/store-sync/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Loading

0 comments on commit 5f493cd

Please sign in to comment.