Skip to content

Commit

Permalink
feat(dev-tools): show zustand tables (#1891)
Browse files Browse the repository at this point in the history
  • Loading branch information
holic committed Nov 10, 2023
1 parent b37dec9 commit 1faf7f6
Show file tree
Hide file tree
Showing 23 changed files with 317 additions and 35 deletions.
5 changes: 5 additions & 0 deletions .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).
18 changes: 18 additions & 0 deletions .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,
});
```
1 change: 1 addition & 0 deletions examples/minimal/packages/client-vanilla/src/index.ts
Expand Up @@ -66,5 +66,6 @@ if (import.meta.env.DEV) {
worldAddress: network.worldContract.address,
worldAbi: network.worldContract.abi,
write$: network.write$,
useStore: network.useStore,
});
}
1 change: 1 addition & 0 deletions packages/dev-tools/package.json
Expand Up @@ -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:*",
Expand Down
2 changes: 1 addition & 1 deletion 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";
Expand Down
12 changes: 11 additions & 1 deletion packages/dev-tools/src/RootPage.tsx
Expand Up @@ -4,7 +4,7 @@ import { NavButton } from "./NavButton";
import { useDevToolsContext } from "./DevToolsContext";

export function RootPage() {
const { recsWorld } = useDevToolsContext();
const { recsWorld, useStore } = useDevToolsContext();
return (
<>
<div className="flex-none bg-slate-900 text-white/60 font-medium">
Expand Down Expand Up @@ -32,6 +32,16 @@ export function RootPage() {
>
Store log
</NavButton>
{useStore ? (
<NavButton
to="/tables"
className={({ isActive }) =>
twMerge("py-1.5 px-3", isActive ? "bg-slate-800 text-white" : "hover:bg-blue-800 hover:text-white")
}
>
Tables
</NavButton>
) : null}
{recsWorld ? (
<NavButton
to="/components"
Expand Down
19 changes: 19 additions & 0 deletions packages/dev-tools/src/TruncatedHex.tsx
@@ -0,0 +1,19 @@
import { Hex } from "viem";

type Props = {
hex: Hex;
};

export function TruncatedHex({ hex }: Props) {
if (hex.length <= 10) {
return <span>{hex}</span>;
}

return (
<span>
<span className="after:content-['…'] after:select-none">{hex.slice(0, 6)}</span>
<span className="tracking-[-1ch] text-transparent">{hex.slice(6, -4)}</span>
{hex.slice(-4)}
</span>
);
}
7 changes: 5 additions & 2 deletions packages/dev-tools/src/common.ts
Expand Up @@ -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<TConfig extends StoreConfig = StoreConfig> = {
config: TConfig;
export type DevToolsOptions<config extends StoreConfig = StoreConfig> = {
config: config;
publicClient: PublicClient<Transport, Chain>;
walletClient: WalletClient<Transport, Chain>;
latestBlock$: Observable<Block>;
Expand All @@ -15,4 +16,6 @@ export type DevToolsOptions<TConfig extends StoreConfig = StoreConfig> = {
worldAbi: Abi;
write$: Observable<ContractWrite>;
recsWorld?: RecsWorld;
// TODO: figure out why using `Tables` here causes downstream type errors
useStore?: ZustandStore<any>;
};
2 changes: 1 addition & 1 deletion 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);
}
5 changes: 5 additions & 0 deletions packages/dev-tools/src/router.tsx
Expand Up @@ -6,13 +6,18 @@ 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(
<Route path="/" element={<RootPage />} errorElement={<RouteError />}>
<Route index element={<SummaryPage />} />
<Route path="actions" element={<ActionsPage />} />
<Route path="events" element={<EventsPage />} />
<Route path="tables" element={<TablesPage />}>
<Route path=":id" element={<TableData />} />
</Route>
<Route path="components" element={<ComponentsPage />}>
<Route path=":id" element={<ComponentData />} />
</Route>
Expand Down
9 changes: 8 additions & 1 deletion packages/dev-tools/src/summary/SummaryPage.tsx
Expand Up @@ -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";
Expand All @@ -11,7 +12,7 @@ const isLinked = Object.entries(packageJson.dependencies).some(
);

export function SummaryPage() {
const { recsWorld } = useDevToolsContext();
const { recsWorld, useStore } = useDevToolsContext();
return (
<div className="h-full flex flex-col">
<div className="flex-grow p-6 space-y-8 relative">
Expand All @@ -31,6 +32,12 @@ export function SummaryPage() {
<h1 className="font-bold text-white/40 uppercase text-xs">Recent store events</h1>
<EventsSummary />
</div>
{useStore ? (
<div className="space-y-2">
<h1 className="font-bold text-white/40 uppercase text-xs">Tables</h1>
<TablesSummary />
</div>
) : null}
{recsWorld ? (
<div className="space-y-2">
<h1 className="font-bold text-white/40 uppercase text-xs">Components</h1>
Expand Down
15 changes: 15 additions & 0 deletions 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 (
<div className="flex flex-col gap-1 items-start">
{tables.map((table) => (
<NavButton key={table.tableId} to={`/tables/${table.tableId}`} className="font-mono text-xs hover:text-white">
{table.namespace}:{table.name}
</NavButton>
))}
</div>
);
}
23 changes: 23 additions & 0 deletions 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<SchemaAbiType>;
};

export function FieldValue({ value }: Props) {
return Array.isArray(value) ? (
value.map((item, i) => (
<React.Fragment key={JSON.stringify({ i, value })}>
{i > 0 ? ", " : null}
<FieldValue value={item} />
</React.Fragment>
))
) : isHex(value) ? (
<TruncatedHex hex={value} />
) : (
<>{String(value)}</>
);
}
19 changes: 19 additions & 0 deletions 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 <TableDataTable key={table.tableId} table={table} />;
}
52 changes: 52 additions & 0 deletions 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 (
<table className="w-full -mx-1">
<thead className="sticky top-0 z-10 bg-slate-800 text-left">
<tr className="text-amber-200/80 font-mono">
{Object.keys(table.keySchema).map((name) => (
<th key={name} className="px-1.5 pt-1.5 font-normal">
{name}
</th>
))}
{Object.keys(table.valueSchema).map((name) => (
<th key={name} className="px-1.5 pt-1.5 font-normal">
{name}
</th>
))}
</tr>
</thead>
<tbody className="font-mono text-xs">
{records.map((record) => {
return (
<tr key={record.id}>
{Object.keys(table.keySchema).map((name) => (
<td key={name} className="px-1.5 whitespace-nowrap overflow-hidden text-ellipsis">
<FieldValue value={record.key[name]} />
</td>
))}
{Object.keys(table.valueSchema).map((name) => (
<td key={name} className="px-1.5 whitespace-nowrap overflow-hidden text-ellipsis">
<FieldValue value={record.value[name]} />
</td>
))}
</tr>
);
})}
</tbody>
</table>
);
}
81 changes: 81 additions & 0 deletions 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<HTMLDetailsElement>(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 (
<div className="p-6 space-y-4">
<div className="space-y-2">
<h1 className="font-bold text-white/40 uppercase text-xs">Table</h1>

<details ref={detailsRef} className="pointer-events-none select-none">
<summary className="group pointer-events-auto cursor-pointer inline-flex">
<span
className={
"inline-flex gap-2 px-3 py-2 items-center border-2 border-white/10 rounded group-hover:border-blue-700 group-hover:bg-blue-700 group-hover:text-white"
}
>
{selectedTable ? (
<span className="font-mono">
{selectedTable.namespace}:{selectedTable.name}
</span>
) : (
<span>Pick a table…</span>
)}
<span className="text-white/40 text-xs"></span>
</span>
</summary>
<div className="relative">
<div className="pointer-events-auto absolute top-1 left-0 z-20 bg-slate-700 rounded shadow-lg flex flex-col py-1.5 font-mono text-xs leading-none">
{tables.map((table) => (
<NavButton
className={twMerge(
"px-2 py-1.5 text-left hover:bg-blue-700 hover:text-white",
table === selectedTable ? "bg-slate-600" : null
)}
key={table.tableId}
to={table.tableId}
onClick={() => {
if (detailsRef.current) {
detailsRef.current.open = false;
}
}}
>
{table.namespace}:{table.name}
</NavButton>
))}
</div>
</div>
</details>
</div>
<Outlet />
</div>
);
}
24 changes: 24 additions & 0 deletions 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 extends Table>(table: table): TableRecord<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 [records, setRecords] = useState<{ readonly [k: string]: TableRecord<table> }>(
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);
}

0 comments on commit 1faf7f6

Please sign in to comment.