src/useObserver.spec.tsx
/* eslint-disable max-classes-per-file,react/button-has-type,no-plusplus */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import * as React from 'react'
import {render, act} from '@testing-library/react'
import useObserver from './useObserver'
import '@testing-library/jest-dom/extend-expect'
test('add hash internally', () => {
class TestClass {
current = 2
previous(): void {
this.current--
}
getCurrent(): number {
return this.current
}
}
const obj = new TestClass()
function ComponentUsingModel({model}: {model: TestClass}) {
useObserver(model)
return (
<div>
<button onClick={(): void => model.previous()}>
Change the numbers in first component
</button>
<div data-testid="numberInFirst">{model.getCurrent()}</div>
</div>
)
}
function OtherComponentUsingModel({model}: {model: TestClass}) {
useObserver(model)
return (
<div>
<button onClick={(): void => model.previous()}>
Change in the other component
</button>
<div data-testid="numberInOther">{model.getCurrent()}</div>
</div>
)
}
function ComponentWithNestedUseOfTheModelObject() {
return (
<>
<ComponentUsingModel model={obj} />
<OtherComponentUsingModel model={obj} />
</>
)
}
const {getByTestId, getByText} = render(
<ComponentWithNestedUseOfTheModelObject />
)
expect(getByTestId('numberInFirst')).toHaveTextContent('2')
expect(getByTestId('numberInOther')).toHaveTextContent('2')
getByText('Change the numbers in first component').click()
expect(getByTestId('numberInFirst')).toHaveTextContent('1')
expect(getByTestId('numberInOther')).toHaveTextContent('1')
})
test('that it can work with objects', () => {
const obj = {
foo: 'here',
mutateMe: () => {
obj.foo = 'there'
},
}
const ComponentUsingModel = ({model}: {model: typeof obj}) => {
useObserver(model)
return (
<div>
<div data-testid="foo">{model.foo}</div>
</div>
)
}
const {getByTestId} = render(<ComponentUsingModel model={obj} />)
expect(getByTestId('foo')).toHaveTextContent('here')
act(() => {
obj.mutateMe()
})
expect(getByTestId('foo')).toHaveTextContent('there')
})
test('that it can work with multiple objects', () => {
const obj1 = {
foo: 'here',
mutateMe: () => {
obj1.foo = 'there'
},
}
const obj2 = {
bar: 'pete',
mutateMe: () => {
obj2.bar = 'paul'
},
}
function ComponentUsingModel({
model1,
model2,
}: {
model1: typeof obj1
model2: typeof obj2
}) {
useObserver(model1)
useObserver(model2)
return (
<div>
<div data-testid="foo">{model1.foo}</div>
<div data-testid="bar">{model2.bar}</div>
</div>
)
}
const {getByTestId} = render(
<ComponentUsingModel model1={obj1} model2={obj2} />
)
expect(getByTestId('foo')).toHaveTextContent('here')
expect(getByTestId('bar')).toHaveTextContent('pete')
act(() => {
obj1.mutateMe()
})
expect(getByTestId('foo')).toHaveTextContent('there')
expect(getByTestId('bar')).toHaveTextContent('pete')
act(() => {
obj2.mutateMe()
})
expect(getByTestId('bar')).toHaveTextContent('paul')
})
test('that it can work with multiple objects', () => {
const obj1 = {
foo: 'here',
mutateMe: () => {
obj1.foo = 'there'
},
}
const obj2 = {
bar: 'pete',
mutateMe: () => {
obj2.bar = 'paul'
},
}
function ComponentUsingModel1({model}: {model: typeof obj1}) {
useObserver(model)
return (
<div>
<div data-testid="foo">{model.foo}</div>
</div>
)
}
function ComponentUsingModel2({model}: {model: typeof obj2}) {
useObserver(model)
return (
<div>
<div data-testid="bar">{model.bar}</div>
</div>
)
}
const getByTestId1 = render(<ComponentUsingModel1 model={obj1} />).getByTestId
const getByTestId2 = render(<ComponentUsingModel2 model={obj2} />).getByTestId
expect(getByTestId1('foo')).toHaveTextContent('here')
expect(getByTestId2('bar')).toHaveTextContent('pete')
act(() => {
obj1.mutateMe()
})
expect(getByTestId1('foo')).toHaveTextContent('there')
expect(getByTestId2('bar')).toHaveTextContent('pete')
act(() => {
obj2.mutateMe()
})
expect(getByTestId1('foo')).toHaveTextContent('there')
expect(getByTestId2('bar')).toHaveTextContent('paul')
})
test('add hash explicitly', () => {
class TestClass {
current = 2
previous(): void {
this.current--
}
getCurrent(): number {
return this.current
}
}
function ComponentUsingModel({model}: {model: TestClass}) {
const methods = useObserver(model)
return (
<div>
<button onClick={() => methods.previous()}>
Change the numbers in first component
</button>
<div data-testid="numberInFirst">{methods.getCurrent()}</div>
</div>
)
}
function OtherComponentUsingModel({model}: {model: TestClass}) {
const methods = useObserver(model)
return (
<div>
<button onClick={() => methods.previous()}>
Change in the other component
</button>
<div data-testid="numberInOther">{methods.getCurrent()}</div>
</div>
)
}
function ComponentWithNestedUseOfTheModelObject() {
const model = new TestClass()
return (
<>
<ComponentUsingModel model={model} />
<OtherComponentUsingModel model={model} />
</>
)
}
const {getByTestId, getByText} = render(
<ComponentWithNestedUseOfTheModelObject />
)
expect(getByTestId('numberInFirst')).toHaveTextContent('2')
expect(getByTestId('numberInOther')).toHaveTextContent('2')
getByText('Change the numbers in first component').click()
expect(getByTestId('numberInFirst')).toHaveTextContent('1')
expect(getByTestId('numberInOther')).toHaveTextContent('1')
})
test('have a global model', async () => {
class TestClass {
__current = 2
get current(): number {
return this.__current
}
set current(value: number) {
this.__current = value
}
previous(): void {
this.current--
}
getCurrent(): number {
return this.current
}
}
const model = new TestClass()
const useNumberChanger = () => {
return useObserver(model)
}
function ComponentUsingModel() {
const methods = useNumberChanger()
return (
<div>
<button onClick={() => methods.previous()}>
Change the numbers in first component
</button>
<div data-testid="numberInFirst">{methods.getCurrent()}</div>
</div>
)
}
function OtherComponentUsingModel() {
const methods = useNumberChanger()
return (
<div>
<button onClick={() => methods.previous()}>
Change in the other component
</button>
<div data-testid="numberInOther">{methods.getCurrent()}</div>
</div>
)
}
function ComponentWithNestedUseOfTheModelObject() {
return (
<>
<ComponentUsingModel />
<OtherComponentUsingModel />
</>
)
}
const {getByTestId, getByText} = render(
<ComponentWithNestedUseOfTheModelObject />
)
expect(getByTestId('numberInFirst')).toHaveTextContent('2')
expect(getByTestId('numberInOther')).toHaveTextContent('2')
expect(model.getCurrent()).toEqual(2)
getByText('Change the numbers in first component').click()
expect(getByTestId('numberInFirst')).toHaveTextContent('1')
expect(getByTestId('numberInOther')).toHaveTextContent('1')
expect(model.getCurrent()).toEqual(1)
act(() => {
model.previous()
})
expect(model.getCurrent()).toEqual(0)
expect(getByTestId('numberInFirst')).toHaveTextContent('0')
expect(getByTestId('numberInOther')).toHaveTextContent('0')
})
test('nested classes', () => {
class MemberClass {
__current = 2
get current(): number {
return this.__current
}
set current(value: number) {
this.__current = value
}
}
class TestClass {
member: MemberClass = new MemberClass()
previous(): void {
this.member.current--
}
getCurrent(): number {
return this.member.current
}
}
const model = new TestClass()
const useNumberChanger = () => {
return useObserver(model)
}
function ComponentUsingModel() {
const methods = useNumberChanger()
return (
<div>
<button onClick={() => methods.previous()}>
Change the numbers in first component
</button>
<div data-testid="numberInFirst">{methods.getCurrent()}</div>
</div>
)
}
function OtherComponentUsingModel() {
const methods = useNumberChanger()
return (
<div>
<button onClick={() => methods.previous()}>
Change in the other component
</button>
<div data-testid="numberInOther">{methods.getCurrent()}</div>
</div>
)
}
function ComponentWithNestedUseOfTheModelObject() {
return (
<>
<ComponentUsingModel />
<OtherComponentUsingModel />
</>
)
}
const {getByTestId, getByText} = render(
<ComponentWithNestedUseOfTheModelObject />
)
expect(getByTestId('numberInFirst')).toHaveTextContent('2')
expect(getByTestId('numberInOther')).toHaveTextContent('2')
expect(model.getCurrent()).toEqual(2)
getByText('Change the numbers in first component').click()
expect(getByTestId('numberInFirst')).toHaveTextContent('1')
expect(getByTestId('numberInOther')).toHaveTextContent('1')
expect(model.getCurrent()).toEqual(1)
act(() => {
model.previous()
})
expect(model.getCurrent()).toEqual(0)
expect(getByTestId('numberInFirst')).toHaveTextContent('0')
expect(getByTestId('numberInOther')).toHaveTextContent('0')
})
test('Changing a state of one model should not re-render a react component using a different model', () => {
const firstModel = {
foo: 'here',
mutateMe: () => {
firstModel.foo = 'there'
},
}
let firstComponentRerunTimes = 0
function ComponentUsingModel() {
firstComponentRerunTimes++
useObserver(firstModel)
return (
<div>
<div data-testid="foo">{firstModel.foo}</div>
</div>
)
}
const otherModel = {
someValue: 'someString',
changeMe(): void {
this.someValue = 'otherString'
},
getRerunTimes(): string {
return this.someValue
},
}
let differentComponentRerunTimes = 0
function ComponentUsingDifferentModel() {
differentComponentRerunTimes++
useObserver(otherModel)
return (
<div>
<div>{otherModel.getRerunTimes()}</div>
</div>
)
}
const {getByTestId} = render(<ComponentUsingModel />)
render(<ComponentUsingDifferentModel />)
expect(differentComponentRerunTimes).toEqual(1)
expect(firstComponentRerunTimes).toEqual(1)
expect(getByTestId('foo')).toHaveTextContent('here')
act(() => {
firstModel.mutateMe()
})
expect(getByTestId('foo')).toHaveTextContent('there')
expect(firstComponentRerunTimes).toEqual(2)
expect(differentComponentRerunTimes).toEqual(1)
act(() => {
otherModel.changeMe()
})
expect(differentComponentRerunTimes).toEqual(2)
expect(firstComponentRerunTimes).toEqual(2)
})
test('it should re-render when null fields are set to a value', () => {
const object = {field: null}
function Component() {
useObserver(object)
return (
<div data-testid="foo">
{object.field === null ? 'null' : object.field}
</div>
)
}
const {getByTestId} = render(<Component />)
expect(getByTestId('foo')).toHaveTextContent('null')
act(() => {
object.field = 'boo'
})
expect(getByTestId('foo')).toHaveTextContent('boo')
})
test('it should re-render when null fields are set to an object whose value changes', () => {
const object = {field: null}
function Component() {
useObserver(object)
return (
<div data-testid="foo">
{object.field === null ? 'null' : object.field.nested.deep}
</div>
)
}
const {getByTestId} = render(<Component />)
expect(getByTestId('foo')).toHaveTextContent('null')
act(() => {
object.field = {
nested: {
deep: 'value',
},
}
})
expect(getByTestId('foo')).toHaveTextContent('value')
act(() => {
object.field.nested.deep = 'fathoms'
})
expect(getByTestId('foo')).toHaveTextContent('fathoms')
})
test('it should re-render when multi-level depth fields are set to an object whose value changes - no proxy', () => {
const object = {field: null}
function Component() {
useObserver(object)
return (
<div data-testid="foo">
{object.field === null ? 'null' : object.field.nested.deep.very}
</div>
)
}
const {getByTestId} = render(<Component />)
expect(getByTestId('foo')).toHaveTextContent('null')
act(() => {
object.field = {
nested: {
deep: 'value',
},
}
})
act(() => {
object.field.nested = {
deep: {
very: 'deeper',
},
}
})
expect(getByTestId('foo')).toHaveTextContent('deeper')
act(() => {
object.field.nested.deep.very = 'fathoms'
})
expect(getByTestId('foo')).toHaveTextContent('fathoms')
})
test('it should re-render when array values change', () => {
const object = {arr: ['zero']}
function Component() {
useObserver(object)
return <div data-testid="foo">{object.arr.toString()}</div>
}
const {getByTestId} = render(<Component />)
act(() => {
object.arr[0] = 'one'
})
expect(getByTestId('foo')).toHaveTextContent('one')
act(() => {
object.arr.push('two')
})
expect(getByTestId('foo')).toHaveTextContent('one,two')
})
describe.skip('pending edge-cases', () => {
test('it should re-render when array values have objects whose internal values change', () => {
const object = {arr: []}
function Component() {
useObserver(object)
return <div data-testid="foo">{object.arr[0].hello}</div>
}
object.arr[0] = {
hello: 'world',
}
const {getByTestId} = render(<Component />)
expect(getByTestId('foo')).toHaveTextContent('world')
object.arr[0].hello = 'there'
expect(getByTestId('foo')).toHaveTextContent('there')
})
// test('it should re-render when multi-level depth fields are set to an object whose value changes - new field', () => {
// const object = {}
//
// function Component() {
// useObserver(object)
// return (
// <div data-testid="foo">
// {object &&
// object.field &&
// object.field.nested &&
// object.field.nested.deep
// ? object.field.nested.deep.very
// : 'null'}
// </div>
// )
// }
//
// const {getByTestId} = render(<Component />)
//
// expect(getByTestId('foo')).toHaveTextContent('null')
// act(() => {
// // @ts-ignore
// object.field = {
// nested: {
// deep: 'value',
// },
// }
//
// // @ts-ignore
// object.field.nested = {
// deep: {
// very: 'deeper',
// },
// }
// })
//
// expect(getByTestId('foo')).toHaveTextContent('deeper')
// act(() => {
// // @ts-ignore
// object.field.nested.deep.very = 'fathoms'
// })
// expect(getByTestId('foo')).toHaveTextContent('fathoms')
// })
})
test('unmounting one component does not cause other components to be unsubscribed', () => {
class TestClass {
current = 5
previous(): void {
this.current--
}
}
const model = new TestClass()
function ComponentUsingModel() {
const methods = useObserver(model)
return (
<div>
<button onClick={() => methods.previous()}>
Change the numbers in first component
</button>
<div data-testid="numberInFirst">{methods.current}</div>
</div>
)
}
function OtherComponentUsingModel() {
const methods = useObserver(model)
return (
<div>
<button onClick={() => methods.previous()}>
Change in the other component
</button>
<div data-testid="numberInOther">{methods.current}</div>
</div>
)
}
function ComponentWithNestedUseOfTheModelObject() {
useObserver(model)
return (
<>
<ComponentUsingModel />
{model.current > 3 ? <OtherComponentUsingModel /> : null}
</>
)
}
const {getByTestId, getByText} = render(
<ComponentWithNestedUseOfTheModelObject />
)
expect(getByTestId('numberInFirst')).toHaveTextContent('5')
expect(getByTestId('numberInOther')).toHaveTextContent('5')
expect(model.current).toEqual(5)
getByText('Change the numbers in first component').click()
expect(getByTestId('numberInFirst')).toHaveTextContent('4')
expect(getByTestId('numberInOther')).toHaveTextContent('4')
expect(model.current).toEqual(4)
act(() => {
model.previous()
})
expect(model.current).toEqual(3)
expect(getByTestId('numberInFirst')).toHaveTextContent('3')
act(() => {
model.previous()
})
expect(model.current).toEqual(2)
expect(getByTestId('numberInFirst')).toHaveTextContent('2')
})