From 1faf7f697481a92c02ca40edbf71e317de1c06e3 Mon Sep 17 00:00:00 2001 From: Kevin Ingersoll Date: Fri, 10 Nov 2023 08:42:51 +0000 Subject: [PATCH] feat(dev-tools): show zustand tables (#1891) --- .changeset/hip-files-sin.md | 5 ++ .changeset/proud-turkeys-compete.md | 18 +++++ .../packages/client-vanilla/src/index.ts | 1 + packages/dev-tools/package.json | 1 + packages/dev-tools/src/App.tsx | 2 +- packages/dev-tools/src/RootPage.tsx | 12 ++- packages/dev-tools/src/TruncatedHex.tsx | 19 +++++ packages/dev-tools/src/common.ts | 7 +- .../dev-tools/src/recs/getComponentName.ts | 2 +- packages/dev-tools/src/router.tsx | 5 ++ .../dev-tools/src/summary/SummaryPage.tsx | 9 ++- .../dev-tools/src/summary/TablesSummary.tsx | 15 ++++ packages/dev-tools/src/zustand/FieldValue.tsx | 23 ++++++ packages/dev-tools/src/zustand/TableData.tsx | 19 +++++ .../dev-tools/src/zustand/TableDataTable.tsx | 52 ++++++++++++ packages/dev-tools/src/zustand/TablesPage.tsx | 81 +++++++++++++++++++ packages/dev-tools/src/zustand/useRecords.ts | 24 ++++++ packages/dev-tools/src/zustand/useTables.ts | 20 +++++ .../src/zustand/createStorageAdapter.test.ts | 1 + .../src/zustand/createStorageAdapter.ts | 28 +------ .../store-sync/src/zustand/createStore.ts | 4 +- pnpm-lock.yaml | 3 + templates/react/packages/client/src/index.tsx | 1 + 23 files changed, 317 insertions(+), 35 deletions(-) create mode 100644 .changeset/hip-files-sin.md create mode 100644 .changeset/proud-turkeys-compete.md create mode 100644 packages/dev-tools/src/TruncatedHex.tsx create mode 100644 packages/dev-tools/src/summary/TablesSummary.tsx create mode 100644 packages/dev-tools/src/zustand/FieldValue.tsx create mode 100644 packages/dev-tools/src/zustand/TableData.tsx create mode 100644 packages/dev-tools/src/zustand/TableDataTable.tsx create mode 100644 packages/dev-tools/src/zustand/TablesPage.tsx create mode 100644 packages/dev-tools/src/zustand/useRecords.ts create mode 100644 packages/dev-tools/src/zustand/useTables.ts 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 ? ( +
+

Tables

+ +
+ ) : 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) => ( + + ))} + {Object.keys(table.valueSchema).map((name) => ( + + ))} + + + + {records.map((record) => { + return ( + + {Object.keys(table.keySchema).map((name) => ( + + ))} + {Object.keys(table.valueSchema).map((name) => ( + + ))} + + ); + })} + +
+ {name} + + {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, }); } });