diff --git a/.changeset/hip-files-sin.md b/.changeset/hip-files-sin.md
new file mode 100644
index 0000000000..d23a2f6866
--- /dev/null
+++ b/.changeset/hip-files-sin.md
@@ -0,0 +1,5 @@
+---
+"@latticexyz/store-sync": major
+---
+
+`syncToZustand` now uses `tables` argument to populate the Zustand store's `tables` key, rather than the on-chain table registration events. This means we'll no longer store data into Zustand you haven't opted into receiving (e.g. other namespaces).
diff --git a/.changeset/proud-turkeys-compete.md b/.changeset/proud-turkeys-compete.md
new file mode 100644
index 0000000000..4713d60bc9
--- /dev/null
+++ b/.changeset/proud-turkeys-compete.md
@@ -0,0 +1,18 @@
+---
+"@latticexyz/dev-tools": minor
+"create-mud": minor
+---
+
+Added Zustand support to Dev Tools:
+
+```ts
+const { syncToZustand } from "@latticexyz/store-sync";
+const { mount as mountDevTools } from "@latticexyz/dev-tools";
+
+const { useStore } = syncToZustand({ ... });
+
+mountDevTools({
+ ...
+ useStore,
+});
+```
diff --git a/examples/minimal/packages/client-vanilla/src/index.ts b/examples/minimal/packages/client-vanilla/src/index.ts
index 07db012b23..a0aa64edca 100644
--- a/examples/minimal/packages/client-vanilla/src/index.ts
+++ b/examples/minimal/packages/client-vanilla/src/index.ts
@@ -66,5 +66,6 @@ if (import.meta.env.DEV) {
worldAddress: network.worldContract.address,
worldAbi: network.worldContract.abi,
write$: network.write$,
+ useStore: network.useStore,
});
}
diff --git a/packages/dev-tools/package.json b/packages/dev-tools/package.json
index 1fd2ceb198..05c10f8e83 100644
--- a/packages/dev-tools/package.json
+++ b/packages/dev-tools/package.json
@@ -26,6 +26,7 @@
"@latticexyz/common": "workspace:*",
"@latticexyz/react": "workspace:*",
"@latticexyz/recs": "workspace:*",
+ "@latticexyz/schema-type": "workspace:*",
"@latticexyz/store": "workspace:*",
"@latticexyz/store-sync": "workspace:*",
"@latticexyz/utils": "workspace:*",
diff --git a/packages/dev-tools/src/App.tsx b/packages/dev-tools/src/App.tsx
index 712002642b..08f1fd7ccb 100644
--- a/packages/dev-tools/src/App.tsx
+++ b/packages/dev-tools/src/App.tsx
@@ -1,6 +1,6 @@
import "./preflight.css";
import "tailwindcss/tailwind.css";
-import { useEffect, useState } from "react";
+import { useEffect } from "react";
import { twMerge } from "tailwind-merge";
import { router } from "./router";
import { RouterProvider } from "react-router-dom";
diff --git a/packages/dev-tools/src/RootPage.tsx b/packages/dev-tools/src/RootPage.tsx
index 575596d9c8..d2c0345b79 100644
--- a/packages/dev-tools/src/RootPage.tsx
+++ b/packages/dev-tools/src/RootPage.tsx
@@ -4,7 +4,7 @@ import { NavButton } from "./NavButton";
import { useDevToolsContext } from "./DevToolsContext";
export function RootPage() {
- const { recsWorld } = useDevToolsContext();
+ const { recsWorld, useStore } = useDevToolsContext();
return (
<>
@@ -32,6 +32,16 @@ export function RootPage() {
>
Store log
+ {useStore ? (
+
+ twMerge("py-1.5 px-3", isActive ? "bg-slate-800 text-white" : "hover:bg-blue-800 hover:text-white")
+ }
+ >
+ Tables
+
+ ) : null}
{recsWorld ? (
{hex};
+ }
+
+ return (
+
+ {hex.slice(0, 6)}
+ {hex.slice(6, -4)}
+ {hex.slice(-4)}
+
+ );
+}
diff --git a/packages/dev-tools/src/common.ts b/packages/dev-tools/src/common.ts
index 42475fbab3..6032648e95 100644
--- a/packages/dev-tools/src/common.ts
+++ b/packages/dev-tools/src/common.ts
@@ -2,11 +2,12 @@ import { Observable } from "rxjs";
import { Abi, Block, Chain, PublicClient, Transport, WalletClient } from "viem";
import { StoreConfig } from "@latticexyz/store";
import { StorageAdapterBlock } from "@latticexyz/store-sync";
+import { ZustandStore } from "@latticexyz/store-sync/zustand";
import { ContractWrite } from "@latticexyz/common";
import { World as RecsWorld } from "@latticexyz/recs";
-export type DevToolsOptions = {
- config: TConfig;
+export type DevToolsOptions = {
+ config: config;
publicClient: PublicClient;
walletClient: WalletClient;
latestBlock$: Observable;
@@ -15,4 +16,6 @@ export type DevToolsOptions = {
worldAbi: Abi;
write$: Observable;
recsWorld?: RecsWorld;
+ // TODO: figure out why using `Tables` here causes downstream type errors
+ useStore?: ZustandStore;
};
diff --git a/packages/dev-tools/src/recs/getComponentName.ts b/packages/dev-tools/src/recs/getComponentName.ts
index 3b37186ed8..af717317f1 100644
--- a/packages/dev-tools/src/recs/getComponentName.ts
+++ b/packages/dev-tools/src/recs/getComponentName.ts
@@ -1,5 +1,5 @@
import { Component } from "@latticexyz/recs";
export function getComponentName(component: Component): string {
- return String(component.metadata?.componentName ?? component.id);
+ return String(component.metadata?.tableName ?? component.metadata?.componentName ?? component.id);
}
diff --git a/packages/dev-tools/src/router.tsx b/packages/dev-tools/src/router.tsx
index ccef99b707..c2f78f4d64 100644
--- a/packages/dev-tools/src/router.tsx
+++ b/packages/dev-tools/src/router.tsx
@@ -6,6 +6,8 @@ import { SummaryPage } from "./summary/SummaryPage";
import { ActionsPage } from "./actions/ActionsPage";
import { ComponentsPage } from "./recs/ComponentsPage";
import { ComponentData } from "./recs/ComponentData";
+import { TablesPage } from "./zustand/TablesPage";
+import { TableData } from "./zustand/TableData";
export const router = createMemoryRouter(
createRoutesFromElements(
@@ -13,6 +15,9 @@ export const router = createMemoryRouter(
} />
} />
} />
+ }>
+ } />
+
}>
} />
diff --git a/packages/dev-tools/src/summary/SummaryPage.tsx b/packages/dev-tools/src/summary/SummaryPage.tsx
index 7ff54f8881..230e18fa40 100644
--- a/packages/dev-tools/src/summary/SummaryPage.tsx
+++ b/packages/dev-tools/src/summary/SummaryPage.tsx
@@ -2,6 +2,7 @@ import { NetworkSummary } from "./NetworkSummary";
import { AccountSummary } from "./AccountSummary";
import { EventsSummary } from "./EventsSummary";
import { ActionsSummary } from "./ActionsSummary";
+import { TablesSummary } from "./TablesSummary";
import { ComponentsSummary } from "./ComponentsSummary";
import packageJson from "../../package.json";
import { useDevToolsContext } from "../DevToolsContext";
@@ -11,7 +12,7 @@ const isLinked = Object.entries(packageJson.dependencies).some(
);
export function SummaryPage() {
- const { recsWorld } = useDevToolsContext();
+ const { recsWorld, useStore } = useDevToolsContext();
return (
@@ -31,6 +32,12 @@ export function SummaryPage() {
Recent store events
+ {useStore ? (
+
+ ) : null}
{recsWorld ? (
Components
diff --git a/packages/dev-tools/src/summary/TablesSummary.tsx b/packages/dev-tools/src/summary/TablesSummary.tsx
new file mode 100644
index 0000000000..80db5dbb83
--- /dev/null
+++ b/packages/dev-tools/src/summary/TablesSummary.tsx
@@ -0,0 +1,15 @@
+import { NavButton } from "../NavButton";
+import { useTables } from "../zustand/useTables";
+
+export function TablesSummary() {
+ const tables = useTables();
+ return (
+
+ {tables.map((table) => (
+
+ {table.namespace}:{table.name}
+
+ ))}
+
+ );
+}
diff --git a/packages/dev-tools/src/zustand/FieldValue.tsx b/packages/dev-tools/src/zustand/FieldValue.tsx
new file mode 100644
index 0000000000..7922151da6
--- /dev/null
+++ b/packages/dev-tools/src/zustand/FieldValue.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import { SchemaAbiType, SchemaAbiTypeToPrimitiveType } from "@latticexyz/schema-type";
+import { isHex } from "viem";
+import { TruncatedHex } from "../TruncatedHex";
+
+type Props = {
+ value: SchemaAbiTypeToPrimitiveType
;
+};
+
+export function FieldValue({ value }: Props) {
+ return Array.isArray(value) ? (
+ value.map((item, i) => (
+
+ {i > 0 ? ", " : null}
+
+
+ ))
+ ) : isHex(value) ? (
+
+ ) : (
+ <>{String(value)}>
+ );
+}
diff --git a/packages/dev-tools/src/zustand/TableData.tsx b/packages/dev-tools/src/zustand/TableData.tsx
new file mode 100644
index 0000000000..d8e6d6c50e
--- /dev/null
+++ b/packages/dev-tools/src/zustand/TableData.tsx
@@ -0,0 +1,19 @@
+import { useParams } from "react-router-dom";
+import { TableDataTable } from "./TableDataTable";
+import { useTables } from "./useTables";
+
+// TODO: use react-table or similar for better perf with lots of logs
+
+export function TableData() {
+ const tables = useTables();
+
+ const { id: idParam } = useParams();
+ const table = tables.find((t) => t.tableId === idParam);
+
+ // TODO: error message or redirect?
+ if (!table) return null;
+
+ // key here is useful to force a re-render on component changes,
+ // otherwise state hangs around from previous render during navigation (entities)
+ return ;
+}
diff --git a/packages/dev-tools/src/zustand/TableDataTable.tsx b/packages/dev-tools/src/zustand/TableDataTable.tsx
new file mode 100644
index 0000000000..2bd95d2dd1
--- /dev/null
+++ b/packages/dev-tools/src/zustand/TableDataTable.tsx
@@ -0,0 +1,52 @@
+import { Table } from "@latticexyz/store";
+import { useRecords } from "./useRecords";
+import { isHex } from "viem";
+import { TruncatedHex } from "../TruncatedHex";
+import { FieldValue } from "./FieldValue";
+
+// TODO: use react-table or similar for better perf with lots of logs
+
+type Props = {
+ table: Table;
+};
+
+export function TableDataTable({ table }: Props) {
+ const records = useRecords(table);
+
+ return (
+
+
+
+ {Object.keys(table.keySchema).map((name) => (
+
+ {name}
+ |
+ ))}
+ {Object.keys(table.valueSchema).map((name) => (
+
+ {name}
+ |
+ ))}
+
+
+
+ {records.map((record) => {
+ return (
+
+ {Object.keys(table.keySchema).map((name) => (
+
+
+ |
+ ))}
+ {Object.keys(table.valueSchema).map((name) => (
+
+
+ |
+ ))}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/packages/dev-tools/src/zustand/TablesPage.tsx b/packages/dev-tools/src/zustand/TablesPage.tsx
new file mode 100644
index 0000000000..c1907d8c82
--- /dev/null
+++ b/packages/dev-tools/src/zustand/TablesPage.tsx
@@ -0,0 +1,81 @@
+import { Outlet, useNavigate, useParams } from "react-router-dom";
+import { NavButton } from "../NavButton";
+import { useEffect, useRef } from "react";
+import { twMerge } from "tailwind-merge";
+import { useTables } from "./useTables";
+
+export function TablesPage() {
+ const tables = useTables();
+
+ // TODO: lift up selected component so we can remember previous selection between tab nav
+ const { id: idParam } = useParams();
+ const selectedTable = tables.find((table) => table.tableId === idParam) ?? tables[0];
+
+ const detailsRef = useRef(null);
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ if (idParam !== selectedTable.tableId) {
+ navigate(selectedTable.tableId);
+ }
+ }, [idParam, selectedTable.tableId]);
+
+ useEffect(() => {
+ const listener = (event: MouseEvent) => {
+ if (!detailsRef.current) return;
+ if (event.target instanceof Node && detailsRef.current.contains(event.target)) return;
+ detailsRef.current.open = false;
+ };
+ window.addEventListener("click", listener);
+ return () => window.removeEventListener("click", listener);
+ });
+
+ return (
+
+
+
Table
+
+
+
+
+ {selectedTable ? (
+
+ {selectedTable.namespace}:{selectedTable.name}
+
+ ) : (
+ Pick a table…
+ )}
+ ▼
+
+
+
+
+ {tables.map((table) => (
+ {
+ if (detailsRef.current) {
+ detailsRef.current.open = false;
+ }
+ }}
+ >
+ {table.namespace}:{table.name}
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/packages/dev-tools/src/zustand/useRecords.ts b/packages/dev-tools/src/zustand/useRecords.ts
new file mode 100644
index 0000000000..2ce2b2eb2e
--- /dev/null
+++ b/packages/dev-tools/src/zustand/useRecords.ts
@@ -0,0 +1,24 @@
+import { Table } from "@latticexyz/store";
+import { useDevToolsContext } from "../DevToolsContext";
+import { useEffect, useState } from "react";
+import { TableRecord } from "@latticexyz/store-sync/zustand";
+
+export function useRecords(table: table): TableRecord[] {
+ const { useStore } = useDevToolsContext();
+ if (!useStore) throw new Error("Missing useStore");
+
+ // React doesn't like using hooks from another copy of React libs, so we have to use the non-React API to get data out of Zustand
+ const [records, setRecords] = useState<{ readonly [k: string]: TableRecord }>(
+ useStore.getState().getRecords(table)
+ );
+ useEffect(() => {
+ return useStore.subscribe((state) => {
+ const nextRecords = useStore.getState().getRecords(table);
+ if (nextRecords !== records) {
+ setRecords(nextRecords);
+ }
+ });
+ }, [useStore, records]);
+
+ return Object.values(records);
+}
diff --git a/packages/dev-tools/src/zustand/useTables.ts b/packages/dev-tools/src/zustand/useTables.ts
new file mode 100644
index 0000000000..81f37eeaf4
--- /dev/null
+++ b/packages/dev-tools/src/zustand/useTables.ts
@@ -0,0 +1,20 @@
+import { Table } from "@latticexyz/store";
+import { useDevToolsContext } from "../DevToolsContext";
+import { useEffect, useState } from "react";
+
+export function useTables(): Table[] {
+ const { useStore } = useDevToolsContext();
+ if (!useStore) throw new Error("Missing useStore");
+
+ // React doesn't like using hooks from another copy of React libs, so we have to use the non-React API to get data out of Zustand
+ const [tables, setTables] = useState<{ readonly [k: string]: Table }>(useStore.getState().tables);
+ useEffect(() => {
+ return useStore.subscribe((state) => {
+ if (state.tables !== tables) {
+ setTables(state.tables);
+ }
+ });
+ }, [useStore, tables]);
+
+ return Object.values(tables);
+}
diff --git a/packages/store-sync/src/zustand/createStorageAdapter.test.ts b/packages/store-sync/src/zustand/createStorageAdapter.test.ts
index 883345075f..76fa8fc707 100644
--- a/packages/store-sync/src/zustand/createStorageAdapter.test.ts
+++ b/packages/store-sync/src/zustand/createStorageAdapter.test.ts
@@ -45,6 +45,7 @@ describe("createStorageAdapter", () => {
"tableId": "0x746200000000000000000000000000004e756d6265724c697374000000000000",
"valueSchema": {
"value": {
+ "internalType": "uint32[]",
"type": "uint32[]",
},
},
diff --git a/packages/store-sync/src/zustand/createStorageAdapter.ts b/packages/store-sync/src/zustand/createStorageAdapter.ts
index bdb0dc4b38..a44025b94d 100644
--- a/packages/store-sync/src/zustand/createStorageAdapter.ts
+++ b/packages/store-sync/src/zustand/createStorageAdapter.ts
@@ -2,8 +2,6 @@ import { Tables } from "@latticexyz/store";
import { StorageAdapter } from "../common";
import { RawRecord } from "./common";
import { ZustandStore } from "./createStore";
-import { isTableRegistrationLog } from "../isTableRegistrationLog";
-import { logToTable } from "./logToTable";
import { hexToResource, spliceHex } from "@latticexyz/common";
import { debug } from "./debug";
import { getId } from "./getId";
@@ -22,30 +20,6 @@ export function createStorageAdapter({
return async function zustandStorageAdapter({ blockNumber, logs }) {
// TODO: clean this up so that we do one store write per block
- const previousTables = store.getState().tables;
- const newTables = logs
- .filter(isTableRegistrationLog)
- .map(logToTable)
- .filter((newTable) => {
- const existingTable = previousTables[newTable.tableId];
- if (existingTable) {
- console.warn("table already registered, ignoring", {
- newTable,
- existingTable,
- });
- return false;
- }
- return true;
- });
- if (newTables.length) {
- store.setState({
- tables: {
- ...previousTables,
- ...Object.fromEntries(newTables.map((table) => [table.tableId, table])),
- },
- });
- }
-
const updatedIds: string[] = [];
const deletedIds: string[] = [];
@@ -53,7 +27,7 @@ export function createStorageAdapter({
const table = store.getState().tables[log.args.tableId];
if (!table) {
const { namespace, name } = hexToResource(log.args.tableId);
- debug(`skipping update for unknown table: ${namespace}:${name} at ${log.address}`);
+ debug(`skipping update for unknown table: ${namespace}:${name} (${log.args.tableId}) at ${log.address}`);
console.log(store.getState().tables, log.args.tableId);
continue;
}
diff --git a/packages/store-sync/src/zustand/createStore.ts b/packages/store-sync/src/zustand/createStore.ts
index d2dc23bb22..bf51bf380f 100644
--- a/packages/store-sync/src/zustand/createStore.ts
+++ b/packages/store-sync/src/zustand/createStore.ts
@@ -1,7 +1,7 @@
import { SchemaToPrimitives, Table, Tables } from "@latticexyz/store";
import { StoreApi, UseBoundStore, create } from "zustand";
import { RawRecord, TableRecord } from "./common";
-import { Hex, concatHex } from "viem";
+import { Hex } from "viem";
import { encodeKey } from "@latticexyz/protocol-parser";
import { flattenSchema } from "../flattenSchema";
import { getId } from "./getId";
@@ -44,7 +44,7 @@ export type CreateStoreOptions = {
export function createStore(opts: CreateStoreOptions): ZustandStore {
return create>((set, get) => ({
- tables: {},
+ tables: Object.fromEntries(Object.entries(opts.tables).map(([, table]) => [table.tableId, table])),
rawRecords: {},
records: {},
getRecords: (table: table): TableRecords => {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 24e082641c..3b59259eac 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -353,6 +353,9 @@ importers:
'@latticexyz/recs':
specifier: workspace:*
version: link:../recs
+ '@latticexyz/schema-type':
+ specifier: workspace:*
+ version: link:../schema-type
'@latticexyz/store':
specifier: workspace:*
version: link:../store
diff --git a/templates/react/packages/client/src/index.tsx b/templates/react/packages/client/src/index.tsx
index b4de3825ed..3ba2a44052 100644
--- a/templates/react/packages/client/src/index.tsx
+++ b/templates/react/packages/client/src/index.tsx
@@ -28,6 +28,7 @@ setup().then(async (result) => {
worldAddress: result.network.worldContract.address,
worldAbi: result.network.worldContract.abi,
write$: result.network.write$,
+ useStore: result.network.useStore,
});
}
});