Skip to content

Commit

Permalink
feat(react): add react package (#294)
Browse files Browse the repository at this point in the history
* feat: add mud-react

* feat: rename mud-react to react

* chore(react): clean up

* chore(react): rename mobx-dependent module

* feat(react): add useObservableValue hook

* chore(react): add deprecation notice

* chore(react): remove unused deps

* fix(react): version

* fix(react): use @deprecated tag

Co-authored-by: alvarius <89248902+alvrs@users.noreply.github.com>

* chore(react): more deprecated notices

* test(react): set up test framework, add tests for useEntityQuery

* test(react): add tests for useComponentValue

* test(react): refactor useComponentValue to only re-render when entity's value changes

* docs(react): add README

* fix(react): use typed helper for checking if it's a component update

Co-authored-by: alvarius <89248902+alvrs@users.noreply.github.com>

* chore(react): add frolic to codeowners

* refactor(react): change useComponentValue args ordering

* style(react): simplify tsconfig

* fix(react): remove unused dep

Co-authored-by: alvarius <89248902+alvrs@users.noreply.github.com>
  • Loading branch information
holic and alvrs authored Jan 12, 2023
1 parent d3de8d2 commit f5ee290
Show file tree
Hide file tree
Showing 21 changed files with 1,573 additions and 13 deletions.
5 changes: 3 additions & 2 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# These owners will be the default owners for everything in
# the repo. Unless a later match takes precedence.

* @alvrs
* @alvrs

# Package-specific owners. Order is important; the last matching
# pattern takes the most precedence. When someone opens a pull request
# that only modifies a specific package for which there is an owner
# specified, only that owner and not the global owner(s) will be requested
# for a review.

/packages/services/ @authcall
/packages/services @authcall
/packages/react @holic
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"packages/solecs",
"packages/cli",
"packages/recs",
"packages/react",
"packages/phaserx",
"packages/network",
"packages/std-contracts",
Expand Down Expand Up @@ -60,7 +61,7 @@
"foundryup": "curl -L https://foundry.paradigm.xyz | bash && bash ~/.foundry/bin/foundryup",
"link:packages": "yarn lerna run link",
"docs": "yarn lerna run docs && yarn retype:updateversion && yarn retype build",
"retype:updateversion":"sed \"s/label: .*/label: $(yarn list --pattern @latticexyz/solecs | grep -e @latticexyz | sed \"s/.*@//\")/\" retype.yml > retype.yml.tmp && mv retype.yml.tmp retype.yml",
"retype:updateversion": "sed \"s/label: .*/label: $(yarn list --pattern @latticexyz/solecs | grep -e @latticexyz | sed \"s/.*@//\")/\" retype.yml > retype.yml.tmp && mv retype.yml.tmp retype.yml",
"test": "yarn workspaces run test",
"yalc:release": "yarn entry:dist && yarn lerna exec yalc push",
"yalc:reset": "yarn entry:src",
Expand Down
4 changes: 4 additions & 0 deletions packages/react/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": ["../../.eslintrc", "plugin:react/recommended", "plugin:react-hooks/recommended"],
"plugins": ["react", "react-hooks"]
}
4 changes: 4 additions & 0 deletions packages/react/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
docs
API
6 changes: 6 additions & 0 deletions packages/react/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
*

!dist/**
!package.json
!README.md
!CHANGELOG.md
20 changes: 20 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# MUD React

React hooks (and more) for building MUD clients.

### useComponentValue

Returns the value of the component for the entity, and triggers a re-render as the component is added/removed/updated.

```
const position = useComponentValue(entity, Position);
```

### useEntityQuery

Returns all matching `EntityIndex`es for a given entity query, and triggers a re-render as new query results come in.

```
const entities = useEntityQuery([Has(Position)]);
const playersAtPosition = useEntityQuery([Has(Player), HasValue(Position, { x: 0, y: 0 })]);
```
2 changes: 2 additions & 0 deletions packages/react/index.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
label: react
order: 8
5 changes: 5 additions & 0 deletions packages/react/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
};
54 changes: 54 additions & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"name": "@latticexyz/react",
"version": "1.32.0",
"description": "React tools for MUD client.",
"license": "MIT",
"source": "src/index.ts",
"main": "src/index.ts",
"repository": {
"type": "git",
"url": "https://github.com/latticexyz/mud.git",
"directory": "packages/react"
},
"scripts": {
"prepare": "yarn build",
"link": "yarn link",
"docs": "rimraf API && typedoc src && find API -type f -name '*.md' -exec sed -E -i \"\" \"s/(#.*)(<.*>)/\\1/\" {} \\; && echo 'label: API' > API/index.yml",
"test": "tsc && jest",
"prepack": "mv package.json package.json.bak && jq \".main = \\\"dist/index.js\\\"\" package.json.bak > package.json ",
"postpack": "mv package.json.bak package.json || echo 'no package.json.bak'",
"build": "rimraf dist && rollup -c rollup.config.js",
"release": "npm publish || echo 'version already published'"
},
"devDependencies": {
"@latticexyz/recs": "^1.32.0",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.1.3",
"@rollup/plugin-typescript": "^8.3.1",
"@testing-library/react-hooks": "^8.0.1",
"@types/react": "^18.0.12",
"eslint-plugin-react": "^7.31.11",
"eslint-plugin-react-hooks": "^4.6.0",
"jest": "^29.3.1",
"mobx": "^6.4.2",
"react": "^18.2.0",
"react-test-renderer": "^18.2.0",
"rimraf": "^3.0.2",
"rollup": "^2.69.0",
"rollup-plugin-commonjs": "^10.1.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rxjs": "^7.5.5",
"ts-jest": "^29.0.3",
"tslib": "^2.3.1",
"typedoc": "0.23.21",
"typedoc-plugin-markdown": "^3.13.6",
"typescript": "^4.5.5"
},
"peerDependencies": {
"@latticexyz/recs": "^1.32.0",
"mobx": "^6.4.2",
"react": "^18.2.0",
"rxjs": "^7.5.5"
},
"gitHead": "218f56893d268b0c5157a3e4c603b859e287a343"
}
17 changes: 17 additions & 0 deletions packages/react/rollup.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import typescript from "@rollup/plugin-typescript";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import commonjs from "rollup-plugin-commonjs";
import peerDepsExternal from "rollup-plugin-peer-deps-external";
import pluginJson from "@rollup/plugin-json"

import { defineConfig } from "rollup";

export default defineConfig({
input: "./src/index.ts",
treeshake: true,
output: {
dir: "dist",
sourcemap: true,
},
plugins: [nodeResolve(), typescript(), commonjs(), peerDepsExternal(), pluginJson()],
});
4 changes: 4 additions & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./useComponentValue";
export * from "./useDeprecatedComputedValue";
export * from "./useEntityQuery";
export * from "./useObservableValue";
88 changes: 88 additions & 0 deletions packages/react/src/useComponentValue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { renderHook, act } from "@testing-library/react-hooks";
import {
World,
Type,
createWorld,
defineComponent,
Component,
createEntity,
withValue,
setComponent,
removeComponent,
} from "@latticexyz/recs";
import { useComponentValue } from "./useComponentValue";

describe("useComponentValue", () => {
let world: World;
let Position: Component<{
x: Type.Number;
y: Type.Number;
}>;

beforeEach(() => {
world = createWorld();
Position = defineComponent(world, { x: Type.Number, y: Type.Number }, { id: "Position" });
});

it("should return Position value for entity", () => {
const entity = createEntity(world, [withValue(Position, { x: 1, y: 1 })]);

const { result } = renderHook(() => useComponentValue(Position, entity));
expect(result.current).toEqual({ x: 1, y: 1 });

act(() => {
setComponent(Position, entity, { x: 0, y: 0 });
});
expect(result.current).toEqual({ x: 0, y: 0 });

act(() => {
removeComponent(Position, entity);
});
expect(result.current).toBe(undefined);
});

it("should re-render only when Position changes for entity", () => {
const entity = createEntity(world, [withValue(Position, { x: 1, y: 1 })]);
const otherEntity = createEntity(world, [withValue(Position, { x: 2, y: 2 })]);

const { result } = renderHook(() => useComponentValue(Position, entity));
expect(result.all.length).toBe(2);
expect(result.current).toEqual({ x: 1, y: 1 });

act(() => {
setComponent(Position, entity, { x: 0, y: 0 });
});
expect(result.all.length).toBe(3);
expect(result.current).toEqual({ x: 0, y: 0 });

act(() => {
setComponent(Position, otherEntity, { x: 0, y: 0 });
removeComponent(Position, otherEntity);
});
expect(result.all.length).toBe(3);
expect(result.current).toEqual({ x: 0, y: 0 });

act(() => {
removeComponent(Position, entity);
});
expect(result.all.length).toBe(4);
expect(result.current).toBe(undefined);
});

it("should return default value when Position is not set", () => {
const entity = createEntity(world);

const { result } = renderHook(() => useComponentValue(Position, entity, { x: -1, y: -1 }));
expect(result.current).toEqual({ x: -1, y: -1 });

act(() => {
setComponent(Position, entity, { x: 0, y: 0 });
});
expect(result.current).toEqual({ x: 0, y: 0 });

act(() => {
removeComponent(Position, entity);
});
expect(result.current).toEqual({ x: -1, y: -1 });
});
});
48 changes: 48 additions & 0 deletions packages/react/src/useComponentValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
Component,
ComponentValue,
defineQuery,
EntityIndex,
getComponentValue,
Has,
isComponentUpdate,
Metadata,
Schema,
} from "@latticexyz/recs";
import { useEffect, useState } from "react";

export function useComponentValue<S extends Schema>(
component: Component<S, Metadata, undefined>,
entityIndex: EntityIndex | undefined,
defaultValue: ComponentValue<S>
): ComponentValue<S>;

export function useComponentValue<S extends Schema>(
component: Component<S, Metadata, undefined>,
entityIndex: EntityIndex | undefined
): ComponentValue<S> | undefined;

export function useComponentValue<S extends Schema>(
component: Component<S, Metadata, undefined>,
entityIndex: EntityIndex | undefined,
defaultValue?: ComponentValue<S>
) {
const [value, setValue] = useState(entityIndex != null ? getComponentValue(component, entityIndex) : undefined);

useEffect(() => {
// component or entityIndex changed, update state to latest value
setValue(entityIndex != null ? getComponentValue(component, entityIndex) : undefined);
if (entityIndex == null) return;

const queryResult = defineQuery([Has(component)], { runOnInit: false });
const subscription = queryResult.update$.subscribe((update) => {
if (isComponentUpdate(update, component) && update.entity === entityIndex) {
const [nextValue] = update.value;
setValue(nextValue);
}
});
return () => subscription.unsubscribe();
}, [component, entityIndex]);

return value ?? defaultValue;
}
14 changes: 14 additions & 0 deletions packages/react/src/useDeprecatedComputedValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { IComputedValue } from "mobx";
import { useEffect, useState } from "react";

/** @deprecated See https://github.com/latticexyz/mud/issues/339 */
export const useDeprecatedComputedValue = <T>(computedValue: IComputedValue<T>) => {
const [value, setValue] = useState<T>(computedValue.get());

useEffect(() => {
const unsubscribe = computedValue.observe_(() => setValue(computedValue.get()));
return () => unsubscribe();
}, [computedValue]);

return value;
};
71 changes: 71 additions & 0 deletions packages/react/src/useEntityQuery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { renderHook, act } from "@testing-library/react-hooks";
import {
World,
Type,
createWorld,
defineComponent,
Component,
createEntity,
withValue,
Has,
setComponent,
} from "@latticexyz/recs";
import { useEntityQuery } from "./useEntityQuery";
import { useMemo } from "react";

describe("useEntityQuery", () => {
let world: World;
let Position: Component<{
x: Type.Number;
y: Type.Number;
}>;
let OwnedBy: Component<{ value: Type.Entity }>;

beforeEach(() => {
world = createWorld();
Position = defineComponent(world, { x: Type.Number, y: Type.Number }, { id: "Position" });
OwnedBy = defineComponent(world, { value: Type.Entity }, { id: "OwnedBy" });
});

it("should find entities with Position component", () => {
const entity1 = createEntity(world, [withValue(Position, { x: 1, y: 1 })]);
const entity2 = createEntity(world, [withValue(Position, { x: 2, y: 2 })]);
const entity3 = createEntity(world, []);

const { result } = renderHook(() => useEntityQuery(useMemo(() => [Has(Position)], [])));

expect(result.current.length).toBe(2);
expect(result.current).toContain(entity1);
expect(result.current).toContain(entity2);
expect(result.current).not.toContain(entity3);
});

it("should re-render only when Position changes", () => {
const entity1 = createEntity(world, [withValue(Position, { x: 1, y: 1 })]);
const entity2 = createEntity(world, [withValue(Position, { x: 2, y: 2 })]);
const entity3 = createEntity(world, []);

const { result } = renderHook(() => useEntityQuery(useMemo(() => [Has(Position)], [])));

expect(result.all.length).toBe(2);

act(() => {
setComponent(Position, entity2, { x: 0, y: 0 });
});

expect(result.all.length).toBe(3);

act(() => {
setComponent(OwnedBy, entity2, { value: world.entities[entity1] });
setComponent(OwnedBy, entity3, { value: world.entities[entity1] });
});

expect(result.all.length).toBe(3);

act(() => {
setComponent(Position, entity3, { x: 0, y: 0 });
});

expect(result.all.length).toBe(4);
});
});
Loading

0 comments on commit f5ee290

Please sign in to comment.