diff --git a/.changeset/cuddly-pugs-cough.md b/.changeset/cuddly-pugs-cough.md new file mode 100644 index 0000000000..00de7fe84b --- /dev/null +++ b/.changeset/cuddly-pugs-cough.md @@ -0,0 +1,5 @@ +--- +"@latticexyz/entrykit": patch +--- + +Bumped react-error-boundary dependency. diff --git a/.changeset/five-bats-live.md b/.changeset/five-bats-live.md new file mode 100644 index 0000000000..d9cacf7466 --- /dev/null +++ b/.changeset/five-bats-live.md @@ -0,0 +1,5 @@ +--- +"create-mud": patch +--- + +Updated React template with Stash client state library, EntryKit for wallet support, and a cleaned up app structure. diff --git a/packages/create-mud/scripts/copy-templates.ts b/packages/create-mud/scripts/copy-templates.ts index af95cd2b19..0cb0c88a07 100644 --- a/packages/create-mud/scripts/copy-templates.ts +++ b/packages/create-mud/scripts/copy-templates.ts @@ -12,6 +12,7 @@ const __dirname = path.dirname(__filename); const packageDir = path.resolve(__dirname, ".."); const rootDir = path.resolve(packageDir, "../.."); + // TODO: could swap this with `pnpm m ls --json --depth=-1` const mudPackageNames = await (async () => { const files = await glob("packages/*/package.json", { cwd: rootDir }); const packages = await Promise.all( @@ -36,16 +37,17 @@ const __dirname = path.dirname(__filename); await fs.mkdir(path.dirname(destPath), { recursive: true }); - // Replace all MUD package links with mustache placeholder used by create-create-app - // that will be replaced with the latest MUD version number when the template is used. if (/package\.json$/.test(destPath)) { - const source = await fs.readFile(sourcePath, "utf-8"); - await fs.writeFile( - destPath, - source.replace(/"([^"]+)":\s*"(link|file):[^"]+"/g, (match, packageName) => - mudPackageNames.includes(packageName) ? `"${packageName}": "{{mud-version}}"` : match, - ), + let source = await fs.readFile(sourcePath, "utf-8"); + // Replace all MUD package links with mustache placeholder used by create-create-app + // that will be replaced with the latest MUD version number when the template is used. + source = source.replace(/"([^"]+)":\s*"(link|file):[^"]+"/g, (match, packageName) => + mudPackageNames.includes(packageName) ? `"${packageName}": "{{mud-version}}"` : match, ); + const json = JSON.parse(source); + // Strip out pnpm overrides + delete json.pnpm; + await fs.writeFile(destPath, JSON.stringify(json, null, 2) + "\n"); } // Replace template workspace root `tsconfig.json` files (which have paths relative to monorepo) // with one that inherits our base tsconfig. diff --git a/packages/entrykit/package.json b/packages/entrykit/package.json index b783cebded..48898c241b 100644 --- a/packages/entrykit/package.json +++ b/packages/entrykit/package.json @@ -56,7 +56,7 @@ "debug": "^4.3.4", "dotenv": "^16.0.3", "permissionless": "0.2.25", - "react-error-boundary": "^4.0.13", + "react-error-boundary": "5.0.0", "react-merge-refs": "^2.1.1", "tailwind-merge": "^1.12.0", "usehooks-ts": "^3.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8cf204f90..9c83428351 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -466,8 +466,8 @@ importers: specifier: 0.2.25 version: 0.2.25(viem@2.21.19(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))(webauthn-p256@0.0.10) react-error-boundary: - specifier: ^4.0.13 - version: 4.0.13(react@18.2.0) + specifier: 5.0.0 + version: 5.0.0(react@18.2.0) react-merge-refs: specifier: ^2.1.1 version: 2.1.1 @@ -2560,10 +2560,6 @@ packages: resolution: {integrity: sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.25.6': - resolution: {integrity: sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.25.7': resolution: {integrity: sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==} engines: {node: '>=6.9.0'} @@ -10367,8 +10363,8 @@ packages: peerDependencies: react: '>=16.13.1' - react-error-boundary@4.0.13: - resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==} + react-error-boundary@5.0.0: + resolution: {integrity: sha512-tnjAxG+IkpLephNcePNA7v6F/QpWLH8He65+DmedchDwg162JZqx4NmbXj0mlAYVVEd81OW7aFhmbsScYfiAFQ==} peerDependencies: react: '>=16.13.1' @@ -14508,10 +14504,6 @@ snapshots: dependencies: regenerator-runtime: 0.13.11 - '@babel/runtime@7.25.6': - dependencies: - regenerator-runtime: 0.14.1 - '@babel/runtime@7.25.7': dependencies: regenerator-runtime: 0.14.1 @@ -24138,9 +24130,9 @@ snapshots: '@babel/runtime': 7.25.7 react: 18.2.0 - react-error-boundary@4.0.13(react@18.2.0): + react-error-boundary@5.0.0(react@18.2.0): dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.25.7 react: 18.2.0 react-hook-form@7.52.2(react@18.2.0): diff --git a/templates/react/.gitignore b/templates/react/.gitignore index 3737e58258..b05d373f12 100644 --- a/templates/react/.gitignore +++ b/templates/react/.gitignore @@ -1,7 +1,16 @@ +.DS_Store +logs +*.log + node_modules -# mud artifacts -.mud -# sqlite indexer data -*.db -*.db-journal +.env.* + +# foundry +cache +broadcast +out/* +!out/IWorld.sol +out/IWorld.sol/* +!out/IWorld.sol/IWorld.abi.json +!out/IWorld.sol/IWorld.abi.d.json.ts diff --git a/templates/react/mprocs.yaml b/templates/react/mprocs.yaml index fa8c42c046..f5be11254f 100644 --- a/templates/react/mprocs.yaml +++ b/templates/react/mprocs.yaml @@ -1,3 +1,4 @@ +scrollback: 10000 procs: client: cwd: packages/client @@ -5,9 +6,16 @@ procs: contracts: cwd: packages/contracts shell: pnpm mud dev-contracts --rpc http://127.0.0.1:8545 + deploy-prereqs: + cwd: packages/contracts + shell: pnpm deploy-local-prereqs + env: + DEBUG: "mud:*" + # Anvil default account (0x70997970C51812dc3A010C7d01b50e0d17dc79C8) + PRIVATE_KEY: "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" anvil: cwd: packages/contracts - shell: anvil --base-fee 0 --block-time 2 + shell: anvil --block-time 2 explorer: cwd: packages/contracts shell: pnpm explorer diff --git a/templates/react/package.json b/templates/react/package.json index 3a8f28180a..661fc6e32f 100644 --- a/templates/react/package.json +++ b/templates/react/package.json @@ -27,5 +27,12 @@ "engines": { "node": "^18", "pnpm": "^8 || ^9" + }, + "pnpm": { + "overrides": { + "@tanstack/react-query": "link:../../packages/entrykit/node_modules/@tanstack/react-query", + "@types/react": "link:../../packages/entrykit/node_modules/@types/react", + "wagmi": "link:../../packages/entrykit/node_modules/wagmi" + } } } diff --git a/templates/react/packages/client/.gitignore b/templates/react/packages/client/.gitignore index 0ca39c007c..1521c8b765 100644 --- a/templates/react/packages/client/.gitignore +++ b/templates/react/packages/client/.gitignore @@ -1,3 +1 @@ -node_modules dist -.DS_Store diff --git a/templates/react/packages/client/index.html b/templates/react/packages/client/index.html index c3f06eb806..ccf1b76fb2 100644 --- a/templates/react/packages/client/index.html +++ b/templates/react/packages/client/index.html @@ -1,9 +1,9 @@ - + - a minimal MUD client + a MUD app
diff --git a/templates/react/packages/client/package.json b/templates/react/packages/client/package.json index 29825547ed..f9b2d4dee2 100644 --- a/templates/react/packages/client/package.json +++ b/templates/react/packages/client/package.json @@ -6,31 +6,39 @@ "type": "module", "scripts": { "build": "vite build", - "dev": "wait-port localhost:8545 && vite", + "dev": "vite", "preview": "vite preview", "test": "tsc --noEmit" }, "dependencies": { "@latticexyz/common": "link:../../../../packages/common", - "@latticexyz/dev-tools": "link:../../../../packages/dev-tools", + "@latticexyz/entrykit": "link:../../../../packages/entrykit", + "@latticexyz/explorer": "link:../../../../packages/explorer", "@latticexyz/react": "link:../../../../packages/react", "@latticexyz/schema-type": "link:../../../../packages/schema-type", + "@latticexyz/stash": "link:../../../../packages/stash", "@latticexyz/store-sync": "link:../../../../packages/store-sync", "@latticexyz/utils": "link:../../../../packages/utils", "@latticexyz/world": "link:../../../../packages/world", + "@tanstack/react-query": "^5.63.0", "contracts": "workspace:*", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "rxjs": "7.5.5", - "viem": "2.21.19" + "react": "18.2.0", + "react-dom": "18.2.0", + "react-error-boundary": "5.0.0", + "tailwind-merge": "^2.6.0", + "viem": "2.21.19", + "wagmi": "2.12.11" }, "devDependencies": { "@types/react": "18.2.22", "@types/react-dom": "18.2.7", - "@vitejs/plugin-react": "^3.1.0", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", "eslint-plugin-react": "7.31.11", "eslint-plugin-react-hooks": "4.6.0", - "vite": "^4.2.1", - "wait-port": "^1.0.4" + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "vite": "^6.0.7", + "vite-plugin-mud": "link:../../../../packages/vite-plugin-mud" } } diff --git a/templates/react/packages/client/postcss.config.cjs b/templates/react/packages/client/postcss.config.cjs new file mode 100644 index 0000000000..12a703d900 --- /dev/null +++ b/templates/react/packages/client/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/templates/react/packages/client/src/App.tsx b/templates/react/packages/client/src/App.tsx index 82c60486a9..ddc7bae21c 100644 --- a/templates/react/packages/client/src/App.tsx +++ b/templates/react/packages/client/src/App.tsx @@ -1,105 +1,46 @@ -import { useMUD } from "./MUDContext"; - -const styleUnset = { all: "unset" } as const; - -export const App = () => { - const { - network: { tables, useStore }, - systemCalls: { addTask, toggleTask, deleteTask }, - } = useMUD(); - - const tasks = useStore((state) => { - const records = Object.values(state.getRecords(tables.Tasks)); - records.sort((a, b) => Number(a.value.createdAt - b.value.createdAt)); - return records; - }); +import { stash } from "./mud/stash"; +import { useRecords } from "@latticexyz/stash/react"; +import { AccountButton } from "@latticexyz/entrykit/internal"; +import { Direction } from "./common"; +import mudConfig from "contracts/mud.config"; +import { useMemo } from "react"; +import { GameMap } from "./game/GameMap"; +import { useWorldContract } from "./mud/useWorldContract"; +import { Synced } from "./mud/Synced"; +import { useSync } from "@latticexyz/store-sync/react"; + +export function App() { + const players = useRecords({ stash, table: mudConfig.tables.app__Position }); + + const sync = useSync(); + const worldContract = useWorldContract(); + const onMove = useMemo( + () => + sync.data && worldContract + ? async (direction: Direction) => { + const tx = await worldContract.write.app__move([mudConfig.enums.Direction.indexOf(direction)]); + await sync.data.waitForTransaction(tx); + } + : undefined, + [sync.data, worldContract], + ); return ( <> - - - {tasks.map((task) => ( - - - - - - ))} - - - - - - - -
- 0n} - title={task.value.completedAt === 0n ? "Mark task as completed" : "Mark task as incomplete"} - onChange={async (event) => { - event.preventDefault(); - const checkbox = event.currentTarget; - - checkbox.disabled = true; - try { - await toggleTask(task.key.id); - } finally { - checkbox.disabled = false; - } - }} - /> - {task.value.completedAt > 0n ? {task.value.description} : <>{task.value.description}} - -
- - -
{ - event.preventDefault(); - const form = event.currentTarget; - const fieldset = form.querySelector("fieldset"); - if (!(fieldset instanceof HTMLFieldSetElement)) return; - - const formData = new FormData(form); - const desc = formData.get("description"); - if (typeof desc !== "string") return; - - fieldset.disabled = true; - try { - await addTask(desc); - form.reset(); - } finally { - fieldset.disabled = false; - } - }} - > -
- {" "} - -
-
-
+
+ ( +
+ {message} ({percentage.toFixed(1)}%)… +
+ )} + > + +
+
+
+ +
); -}; +} diff --git a/templates/react/packages/client/src/MUDContext.tsx b/templates/react/packages/client/src/MUDContext.tsx deleted file mode 100644 index 7b5637f6a6..0000000000 --- a/templates/react/packages/client/src/MUDContext.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, ReactNode, useContext } from "react"; -import { SetupResult } from "./mud/setup"; - -const MUDContext = createContext(null); - -type Props = { - children: ReactNode; - value: SetupResult; -}; - -export const MUDProvider = ({ children, value }: Props) => { - const currentValue = useContext(MUDContext); - if (currentValue) throw new Error("MUDProvider can only be used once"); - return {children}; -}; - -export const useMUD = () => { - const value = useContext(MUDContext); - if (!value) throw new Error("Must be used within a MUDProvider"); - return value; -}; diff --git a/templates/react/packages/client/src/Providers.tsx b/templates/react/packages/client/src/Providers.tsx new file mode 100644 index 0000000000..ed96171dbb --- /dev/null +++ b/templates/react/packages/client/src/Providers.tsx @@ -0,0 +1,35 @@ +import { WagmiProvider } from "wagmi"; +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; +import { ReactNode } from "react"; +import { createSyncAdapter } from "@latticexyz/store-sync/internal"; +import { SyncProvider } from "@latticexyz/store-sync/react"; +import { stash } from "./mud/stash"; +import { defineConfig, EntryKitProvider } from "@latticexyz/entrykit/internal"; +import { wagmiConfig } from "./wagmiConfig"; +import { chainId, getWorldAddress, startBlock } from "./common"; + +const queryClient = new QueryClient(); + +export type Props = { + children: ReactNode; +}; + +export function Providers({ children }: Props) { + const worldAddress = getWorldAddress(); + return ( + + + + + {children} + + + + + ); +} diff --git a/templates/react/packages/client/src/common.ts b/templates/react/packages/client/src/common.ts new file mode 100644 index 0000000000..6caa86136c --- /dev/null +++ b/templates/react/packages/client/src/common.ts @@ -0,0 +1,26 @@ +import mudConfig from "contracts/mud.config"; +import { chains } from "./wagmiConfig"; +import { Chain } from "viem"; + +export const chainId = import.meta.env.CHAIN_ID; +export const worldAddress = import.meta.env.WORLD_ADDRESS; +export const startBlock = import.meta.env.START_BLOCK; + +export const url = new URL(window.location.href); + +export type Direction = (typeof mudConfig.enums.Direction)[number]; + +export function getWorldAddress() { + if (!worldAddress) { + throw new Error("No world address configured. Is the world still deploying?"); + } + return worldAddress; +} + +export function getChain(): Chain { + const chain = chains.find((c) => c.id === chainId); + if (!chain) { + throw new Error(`No chain configured for chain ID ${chainId}.`); + } + return chain; +} diff --git a/templates/react/packages/client/src/game/GameMap.tsx b/templates/react/packages/client/src/game/GameMap.tsx new file mode 100644 index 0000000000..04d7d24d4f --- /dev/null +++ b/templates/react/packages/client/src/game/GameMap.tsx @@ -0,0 +1,102 @@ +import { serialize, useAccount } from "wagmi"; +import { useKeyboardMovement } from "./useKeyboardMovement"; +import { Address, Hex, hexToBigInt, keccak256 } from "viem"; +import { ArrowDownIcon } from "../ui/icons/ArrowDownIcon"; +import { twMerge } from "tailwind-merge"; +import { Direction } from "../common"; +import mudConfig from "contracts/mud.config"; +import { AsyncButton } from "../ui/AsyncButton"; +import { useAccountModal } from "@latticexyz/entrykit/internal"; + +export type Props = { + readonly players?: readonly { + readonly player: Address; + readonly x: number; + readonly y: number; + }[]; + + readonly onMove?: (direction: Direction) => Promise; +}; + +const size = 40; +const scale = 100 / size; + +function getColorAngle(seed: Hex) { + return Number(hexToBigInt(keccak256(seed)) % 360n); +} + +const rotateClassName = { + North: "rotate-0", + East: "rotate-90", + South: "rotate-180", + West: "-rotate-90", +} as const satisfies Record; + +export function GameMap({ players = [], onMove }: Props) { + const { openAccountModal } = useAccountModal(); + const { address: userAddress } = useAccount(); + const currentPlayer = players.find((player) => player.player.toLowerCase() === userAddress?.toLowerCase()); + useKeyboardMovement(onMove); + return ( +
+
+ {onMove + ? mudConfig.enums.Direction.map((direction) => ( + + )) + : null} + + {players.map((player) => ( +
+ {player === currentPlayer ?
: null} +
+ ))} + + {!currentPlayer ? ( + onMove ? ( +
+ onMove("North")} + > + Spawning… + +
+ ) : ( +
+ +
+ ) + ) : null} +
+
+ ); +} diff --git a/templates/react/packages/client/src/game/useKeyboardMovement.ts b/templates/react/packages/client/src/game/useKeyboardMovement.ts new file mode 100644 index 0000000000..7e9074290f --- /dev/null +++ b/templates/react/packages/client/src/game/useKeyboardMovement.ts @@ -0,0 +1,26 @@ +import { useEffect } from "react"; +import { Direction } from "../common"; + +const keys = new Map([ + ["ArrowUp", "North"], + ["ArrowRight", "East"], + ["ArrowDown", "South"], + ["ArrowLeft", "West"], +]); + +export const useKeyboardMovement = (move: undefined | ((direction: Direction) => void)) => { + useEffect(() => { + if (!move) return; + + const listener = (event: KeyboardEvent) => { + const direction = keys.get(event.key); + if (direction == null) return; + + event.preventDefault(); + move(direction); + }; + + window.addEventListener("keydown", listener); + return () => window.removeEventListener("keydown", listener); + }, [move]); +}; diff --git a/templates/react/packages/client/src/index.tsx b/templates/react/packages/client/src/index.tsx index c9b662c9f0..995e6ec9fd 100644 --- a/templates/react/packages/client/src/index.tsx +++ b/templates/react/packages/client/src/index.tsx @@ -1,34 +1,19 @@ -import ReactDOM from "react-dom/client"; +import "tailwindcss/tailwind.css"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { Providers } from "./Providers"; import { App } from "./App"; -import { setup } from "./mud/setup"; -import { MUDProvider } from "./MUDContext"; -import mudConfig from "contracts/mud.config"; +import { Explorer } from "./mud/Explorer"; +import { ErrorBoundary } from "react-error-boundary"; +import { ErrorFallback } from "./ui/ErrorFallback"; -const rootElement = document.getElementById("react-root"); -if (!rootElement) throw new Error("React root not found"); -const root = ReactDOM.createRoot(rootElement); - -// TODO: figure out if we actually want this to be async or if we should render something else in the meantime -setup().then(async (result) => { - root.render( - - - , - ); - - // https://vitejs.dev/guide/env-and-mode.html - if (import.meta.env.DEV) { - const { mount: mountDevTools } = await import("@latticexyz/dev-tools"); - mountDevTools({ - config: mudConfig, - publicClient: result.network.publicClient, - walletClient: result.network.walletClient, - latestBlock$: result.network.latestBlock$, - storedBlockLogs$: result.network.storedBlockLogs$, - worldAddress: result.network.worldContract.address, - worldAbi: result.network.worldContract.abi, - write$: result.network.write$, - useStore: result.network.useStore, - }); - } -}); +createRoot(document.getElementById("react-root")!).render( + + + + + + + + , +); diff --git a/templates/react/packages/client/src/mud/Explorer.tsx b/templates/react/packages/client/src/mud/Explorer.tsx new file mode 100644 index 0000000000..fe59d58c57 --- /dev/null +++ b/templates/react/packages/client/src/mud/Explorer.tsx @@ -0,0 +1,32 @@ +import { useState } from "react"; +import { getChain, getWorldAddress } from "../common"; +import { MUDIcon } from "../ui/icons/MUDIcon"; + +export function Explorer() { + const [open, setOpen] = useState(false); + + const chain = getChain(); + const worldAddress = getWorldAddress(); + + const explorerUrl = chain.blockExplorers?.worldsExplorer?.url; + if (!explorerUrl) return null; + + return ( +
+ + {open ?