7.2.0 • Published 1 year ago

react-states v7.2.0

Weekly downloads
238
License
MIT
Repository
github
Last release
1 year ago

react-states

Explicit states for predictable user experiences

react-states

Your application logic is constantly bombarded by events. Some events are related to user interaction, others from the browser. Also any asynchronous code results in resolvement or rejection, which are also events. We typically write our application logic in such a way that our state changes and side effects are run as a direct result of these events. This approach can create unpredictable user experiences. The reason is that users treats our applications like Mr and Ms Potato Head, bad internet connections causes latency and the share complexity of a user flow grows out of hand and out of mind for all of us. Our code does not always run the way we intended it to.

react-states is 3 utility functions made up of 20 lines of code that will make your user experience more predictable.

NOTE! This documentation is a good read if you have no intention of using the tools provided. It points to complexities that we rarely deal with in application development and is good to reflect upon :-)

Problem statement

A typical way to express state in React is:

const [todos, dispatch] = React.useReducer(
  (state, action) => {
    switch (action.type) {
      case 'FETCH_TODOS':
        return { ...state, isLoading: true };
      case 'FETCH_TODOS_SUCCESS':
        return { ...state, isLoading: false, data: action.data };
      case 'FETCH_TODOS_ERROR':
        return { ...state, isLoading: false, error: action.error };
    }
  },
  {
    isLoading: false,
    data: [],
    error: null,
  },
);

This way of expressing state has issues:

  • We are not being explicit about what states this reducer can be in: NOT_LOADED, LOADING, LOADED and ERROR
  • There is one state not expressed at all, NOT_LOADED
  • There is no internal understanding of state when an action is handled. It will be handled regardless of the current state of the reducer

A typical way to express logic in React is:

const fetchTodos = React.useCallback(() => {
  dispatch({ type: 'FETCH_TODOS' });
  axios
    .get('/todos')
    .then(response => {
      dispatch({ type: 'FETCH_TODOS_SUCCESS', data: response.data });
    })
    .catch(error => {
      dispatch({ type: 'FETCH_TODOS_ERROR', error: error.message });
    });
}, []);

This way of expressing logic has issues:

  • The logic of fetchTodos is at the mercy of whoever triggers it. There is no explicit state guarding that it should run or not
  • You have to create callbacks that needs to be passed down as props

A typical way to express dynamic rendering in React is:

const Todos = ({ todos }) => {
  let content = null;

  if (todos.error) {
    content = 'There was an error';
  } else if (todos.isLoading) {
    content = 'Loading...';
  } else {
    content = (
      <ul>
        {todos.map(todo => (
          <li>{todo.title}</li>
        ))}
      </ul>
    );
  }

  return <div className="wrapper">{content}</div>;
};

This way of expressing dynamic render has issues:

  • Since the reducer has no explicit states, it can have an error and isLoading at the same time, it is not necessarily correct to render an error over the isLoading state
  • It is not very appealing is it?

Solution

import { useStates } from 'react-states';

const Todos = () => {
  const todos = useStates(
    {
      NOT_LOADED: {
        FETCH_TODOS: () => ({ state: 'LOADING' }),
      },
      LOADING: {
        FETCH_TODOS_SUCCESS: ({ data }) => ({ state: 'LOADED', data }),
        FETCH_TODOS_ERROR: ({ error }) => ({ state: 'ERROR', error }),
      },
      LOADED: {},
      ERROR: {},
    },
    { state: 'NOT_LOADED' },
  );

  useEffect(
    () =>
      todos.exec({
        LOADING: () => {
          axios
            .get('/todos')
            .then(response => {
              dispatch({ type: 'FETCH_TODOS_SUCCESS', data: response.data });
            })
            .catch(error => {
              dispatch({ type: 'FETCH_TODOS_ERROR', error: error.message });
            });
        },
      }),
    [todos.exec],
  );

  return (
    <div className="wrapper">
      {todos.transform({
        NOT_LOADED: () => 'Not loaded',
        LOADING: () => 'Loading...',
        LOADED: ({ data }) => (
          <ul>
            {data.map(todo => (
              <li>{todo.title}</li>
            ))}
          </ul>
        ),
        ERROR: ({ error }) => error.message,
      })}
    </div>
  );
};
  • The todos will only be loaded once, no matter how many times FETCH_TODOS is dispatched
  • The logic for actually fetching the todos will also only run once, because it is an effect of moving into the LOADING state
  • We only need dispatch now
  • We are explicit about what state the reducer is in, meaning if we do want to enable fetching the todos several times we can allow it in the LOADED state, meaning you will at least not fetch the todos while they are already being fetched

The solution here is not specifically related to controlling data fetching. It is putting you into the mindset of explicit states and guarding the state changes and execution of side effects. It applies to everything in your application, especially async code

Predictable user experience by example

Authentication

const Auth = () => {
  const [auth, dispatch] = useReducer(
    (state, action) => {
      switch (action.type) {
        case 'SIGN_IN':
          return { ...state, isAuthenticating: true };
        case 'SIGN_IN_SUCCESS':
          return { ...state, isAuthenticating: false, user: action.user };
        case 'SIGN_IN_ERROR':
          return { ...state, isAuthenticating: false, error: action.error };
      }
    },
    {
      user: null,
      isAuthenticating: false,
      error: null,
    },
  );

  const signIn = useCallback(() => {
    dispatch({ type: 'SIGN_IN' });
    axios
      .get('/signin')
      .then(response => {
        dispatch({ type: 'SIGN_IN_SUCCESS', user: response.data });
      })
      .catch(error => {
        dispatch({ type: 'SIGN_IN_ERROR', error: error.message });
      });
  }, []);

  return (
    <button onClick={signIn} disabled={auth.isAuthenticating}>
      Log In
    </button>
  );
};

Calling authenticate twice is invalid behaviour, but this is not defined within your reducer. The only thing preventing your authentication logic from running multiple time, causing all sorts of weirdness, is an HTML attribute. This is fragile because you separate the logic. With explicit states you could rather:

const Auth = () => {
  const auth = useStates(
    {
      UNAUTHENTICATED: {
        SIGN_IN: () => ({ state: 'AUTHENTICATING' }),
      },
      AUTHENTICATING: {
        SIGN_IN_SUCCESS: ({ user }) => ({ state: 'AUTHENTICATED', user }),
        SIGN_IN_ERROR: ({ error }) => ({ state: 'ERROR', error }),
      },
      AUTHENTICATED: {},
      ERROR: {},
    },
    {
      state: 'UNAUTHENTICATED',
    },
  );

  useEffect(
    () =>
      auth.exec({
        AUTHENTICATING: () => {
          axios
            .get('/signin')
            .then(response => {
              dispatch({ type: 'SIGN_IN_SUCCESS', user: response.data });
            })
            .catch(error => {
              dispatch({ type: 'SIGN_IN_ERROR', error: error.message });
            });
        },
      }),
    [auth.exec],
  );

  return (
    <button onClick={() => dispatch({ type: 'SIGN_IN' })} disabled={auth.context.state === 'AUTHENTICATING'}>
      Log In
    </button>
  );
};

Now the application can dispatch as many SIGN_IN as it wants, the reducer will only handle a single one whenever the current state is UNAUTHENTICATED.Of course we still disable the button, but this is a property of the UI, it is not part of our application logic.

Initial data

Typically you want to load some initial data in your application. This might be loaded behind a TAB in the UI, meaning you pass down a callback to trigger the fetching of data when the child TAB component mounts.

const Tabs = () => {
  const [list, dispatch] = useReducer(
    (state, action) => {
      switch (action.type) {
        case 'FETCH':
          return { ...state, isLoading: true };
        case 'FETCH_SUCCESS':
          return { ...state, isLoading: false, data: action.data };
        case 'FETCH_ERROR':
          return { ...state, isLoading: false, error: action.error };
      }
    },
    {
      data: null,
      isLoading: false,
      error: null,
    },
  );

  const fetchList = useCallback(() => {
    dispatch({ type: 'FETCH' });
    axios
      .get('/list')
      .then(response => {
        dispatch({ type: 'FETCH_SUCCESS', data: response.data });
      })
      .catch(error => {
        dispatch({ type: 'FETCH_ERROR', error: error.message });
      });
  }, []);

  return (
    <TabsComponent currentIndex={0} tabs={['List']}>
      <List list={list} fetchList={fetchList} />
    </TabsComponent>
  );
};

The issue we have created now is that the mounting of our List is what drives our logic. That means whenever the user would move away from this List tab and back, it would trigger a new fetch of the list. But this is typically not what you want. The fetching of the list could also be slow, where the user again moves back and forth. That means when they move back the list might be there, but then suddenly it is set again because of a late resolvement causing UI flicker or more critical issues.

With explicit states:

const Tabs = () => {
  const list = useStates(
    {
      NOT_LOADED: {
        FETCH: () => ({ state: 'LOADING' }),
      },
      LOADING: {
        FETCH_SUCCESS: ({ data }) => ({ state: 'LOADED', data }),
        FETCH_ERROR: ({ error }) => ({ state: 'LOADED', error }),
      },
      LOADED: {},
      ERROR: {},
    },
    {
      state: 'NOT_LOADED',
    },
  );

  useEffect(
    () =>
      list.exec({
        LOADING: () => {
          axios
            .get('/list')
            .then(response => {
              listDispatch({ type: 'FETCH_SUCCESS', data: response.data });
            })
            .catch(error => {
              listDispatch({ type: 'FETCH_ERROR', error: error.message });
            });
        },
      }),
    [list.exec],
  );

  return (
    <TabsComponent currentIndex={0} tabs={['List']}>
      <List list={list} />
    </TabsComponent>
  );
};

Now it does not matter how many times the List component mounts. There will only be a single fetching of the list. If you wanted to fetch the list again when it was already loaded you could just do the following change:

const list = useStates(
  {
    NOT_LOADED: {
      FETCH: () => ({ state: 'LOADING' }),
    },
    LOADING: {
      FETCH_SUCCESS: ({ data }) => ({ state: 'LOADED', data }),
      FETCH_ERROR: ({ error }) => ({ state: 'LOADED', error }),
    },
    LOADED: {
      // We allow fetching again when we have loaded the data
      FETCH: () => ({ state: 'LOADING' }),
    },
    ERROR: {},
  },
  {
    state: 'NOT_LOADED',
  },
);

But we would still never get into a situation when we are loading the list, that we start loading it again.

Logic within same state

Imagine you want to add and remove items from the list, where any new items are POSTed to the server and any updates are PATCHed to the server. We are going to deal with the real complexity of this:

  • When you create an item it might fail
  • When you update an item it might fail
  • When changing an item being created, it needs to finish creating it before we send the update
  • When updating an item being updated, it needs to finish updating it before we send the update
  • When changing an item being created and the creation fails, we should not send the update
  • When changing an item being updated and the update fails, we should not send the update

We will only care about the LOADED state in this example and we introduce the same explicit state to every item:

const Items = () => {
  const items = useStates(
    {
      LOADED: {
        ADD_ITEM: ({ id, title }, { data }) => ({
          state: 'LOADED',
          data: { ...data, [id]: { id, title, state: 'QUEUED_CREATE' } },
        }),
        CHANGE_ITEM: ({ id, title }, { data }) => ({
          state: 'LOADED',
          data: {
            ...data,
            [id]: {
              id,
              title,
              state:
                data[id].state === 'CREATING' || data[id].state === 'UPDATING'
                  ? 'QUEUED_DIRTY'
                  : data[id].state === 'CREATE_ERROR'
                  ? 'QUEUED_CREATE'
                  : 'QUEUED_UPDATE',
            },
          },
        }),
        CREATE_ITEM: ({ id }, { data }) => ({
          state: 'LOADED',
          data: { ...data, [id]: { ...data[id], state: 'CREATING' } },
        }),
        CREATE_ITEM_SUCCESS: ({ id }, { data }) => ({
          state: 'LOADED',
          data: {
            ...data,
            [id]: { ...data[id], state: data[id].state === 'QUEUED_DIRTY' ? 'QUEUED_UPDATE' : 'CREATED' },
          },
        }),
        CREATE_ITEM_ERROR: ({ id, error }, { data }) => ({
          state: 'LOADED',
          data: { ...data, [id]: { ...data[id], state: 'CREATE_ERROR', error } },
        }),
        UPDATE_ITEM: ({ id }, { data }) => ({
          state: 'LOADED',
          data: { ...data, [id]: { ...data[id], state: 'UPDATING' } },
        }),
        UPDATE_ITEM_SUCCESS: ({ id }, { data }) => ({
          state: 'LOADED',
          data: {
            ...data,
            [id]: { ...data[id], state: data[id].state === 'QUEUED_DIRTY' ? 'QUEUED_UPDATE' : 'UPDATED' },
          },
        }),
        UPDATE_ITEM_ERROR: ({ id, error }, { data }) => ({
          state: 'LOADED',
          data: { ...data, [id]: { ...data[id], state: 'UPDATE_ERROR', error } },
        }),
      },
    },
    { state: 'NOT_LOADED' },
  );

  useEffect(
    () =>
      items.exec({
        LOADING: [
          function createItem({ data }) {
            const queuedItem = Object.values(data).find(item => item.state === 'QUEUED_CREATE');

            if (queuedItem) {
              dispatch({ type: 'CREATE_ITEM', id: queuedItem.id });
              axios
                .post('/items', queuedItem)
                .then(response => {
                  dispatch({ type: 'CREATE_ITEM_SUCCESS', data: response.data });
                })
                .catch(error => {
                  dispatch({ type: 'CREATE_ITEM_ERROR', error: error.message });
                });
            }
          },
          function updateItem({ data }) {
            const queuedItem = Object.values(data).find(item => item.state === 'QUEUED_UPDATE');

            if (queuedItem) {
              dispatch({ type: 'UPDATE_ITEM', id: queuedItem.id });
              axios
                .patch('/items', queuedItem)
                .then(response => {
                  dispatch({ type: 'UPDATE_ITEM_SUCCESS', data: response.data });
                })
                .catch(error => {
                  dispatch({ type: 'UPDATE_ITEM_ERROR', error: error.message });
                });
            }
          },
        ],
      }),
    [items.exec],
  );
};

This example shows the real complexity of doing optimistic updates and keeping our request to the server in order, also dealing with any errors that can occur. Typically we do not deal with this at all, but with explicit states we are drawn into reasoning about and model this complexity.

The lifetime of an item can now be:

  • QUEUED_CREATE -> CREATED
  • QUEUED_UPDATE -> UPDATED
  • QUEUED_CREATE -> CREATE_ERROR
  • QUEUED_UPDATE -> UPDATE_ERROR
  • QUEUED_CREATE -> It was changed -> QUEUED_DIRTY -> QUEUED_UPDATE -> UPDATED
  • QUEUED_UPDATE -> It was changed -> QUEUED_DIRTY -> QUEUED_UPDATE -> UPDATED
  • QUEUED_CREATE -> It was changed -> QUEUED_DIRTY -> CREATE_ERROR
  • QUEUED_UPDATE -> It was changed -> QUEUED_DIRTY -> UPDATE_ERROR
  • QUEUED_CREATE -> CREATE_ERROR -> It was changed -> QUEUED_CREATE

As context provider

Since there is no need for callbacks we have an opportunity to expose features as context providers which are strictly driven by dispatches and explicit states to drive side effects.

const context = createContext(null);

export const useAuth = () => useContext(context);

export const AuthProvider = ({ children }) => {
  const auth = useStates(
    {
      UNAUTHENTICATED: {
        SIGN_IN: () => ({ state: 'AUTHENTICATING' }),
      },
      AUTHENTICATING: {
        SIGN_IN_SUCCESS: ({ user }) => ({ state: 'AUTHENTICATED', user }),
        SIGN_IN_ERROR: ({ error }) => ({ state: 'ERROR', error }),
      },
      AUTHENTICATED: {},
      ERROR: {},
    },
    {
      state: 'UNAUTHENTICATED',
    },
  );

  useEffect(
    () =>
      auth.exec({
        AUTHENTICATING: () => {
          axios
            .get('/signin')
            .then(response => {
              dispatch({ type: 'SIGN_IN_SUCCESS', user: response.data });
            })
            .catch(error => {
              dispatch({ type: 'SIGN_IN_ERROR', error: error.message });
            });
        },
      }),
    [auth.exec],
  );

  return <context.Provider value={auth}>{children}</context.Provider>;
};

Patterns

Lift actions

Sometimes you might have one or multiple handlers across states. You can lift them up and compose them back into your transitions.

const globalActions = {
  CHANGE_DESCRIPTION: ({ description }, state) => ({
    ...state,
    description,
  }),
};

const reducer = (state, action) =>
  transition(state, action, {
    FOO: {
      ...globalActions,
    },
    BAR: {
      ...globalActions,
    },
  });

TypeScript

Using TypeScript with react-states gives you a lot of benefits. Most of the typing is inferred, the only thing you really need to define is your explicit states and actions.

type User = { username: string };

type AuthContext =
  | {
      state: 'UNAUTHENTICATED';
      error?: string;
    }
  | {
      state: 'AUTHENTICATING';
    }
  | {
      state: 'AUTHENTICATED';
      user: User;
    };

type AuthAction =
  | {
      type: 'SIGN_IN';
      provider: 'google' | 'facebook';
    }
  | {
      type: 'SIGN_IN_SUCCESS';
      user: User;
    }
  | {
      type: 'SIGN_IN_ERROR';
      error: string;
    };

You use these types on the reducer and the rest works itself out:

const auth = useStates<AuthContext, AuthAction>({
    // All states are required to be defined
    UNAUTHENTICATED: {
      // Action types are optional
      SIGN_IN: (
        // Typed to SIGN_IN
        action,
        // Typed to UNAUTHENTICATED
        state,
      ) => {},
    },
    AUTHENTICATING: {
      SIGN_IN_SUCCESS: () => {},
      SIGN_IN_ERROR: () => {},
    },
    AUTHENTICATED: {},
  }),
);

useEffect(
  () =>
    auth.exec({
      // Optional states
      AUTHENTICATING: (
        // Typed to AUTHENTICATING state
        state,
      ) => {},
    }),
  [auth.exec],
);

const result = auth.transform({
  // Optional states
  UNAUTHENTICATED: (
    // Typed to UNAUTHENTICATED
    state,
  ) => null,
});

if (auth.context.state === 'AUTHENTICATED') {
  // auth.context is now typed to AUTHENTICATED
}

Helper types

react-states exposes the PickState and PickAction helper types. Use these helper types when you "lift" your action handlers into separate functions.

type Context =
  | {
      state: 'FOO';
    }
  | {
      state: 'BAR';
    };

type Action =
  | {
      type: 'A';
    }
  | {
      type: 'B';
    };

const actions = {
  A: (action: PickAction<Action, 'A'>, context: PickState<Context, 'FOO'>) => {},
  B: (action: PickAction<Action, 'B'>, context: PickState<Context, 'FOO'>) => {},
};

const foo = useStates<Context, Action>({
  FOO: {
    ...actions,
  },
  BAR: {},
});

API

useStates

This is the opinoinated single API. You can go lower level to control things more explicitly.

const foo = useStates(
  {
    SOME_STATE: {
      SOME_ACTION_TYPE: (action, currentContext) => ({ state: 'NEW_STATE' }),
    },
    NEW_STATE: {},
  },
  {
    state: 'SOME_STATE',
  },
);

React.useEffect(
  () =>
    foo.exec({
      NEW_STATE: context => {
        // Do something when moving into NEW_STATE
      },
    }),
  [foo.exec],
);

// Transform a state into a value, typically react nodes
const value = foo.transform({
  SOME_STATE: context => 'foo',
  NEW_STATE: context => 'bar',
});

transition

useReducer((state, action) =>
  transition(state, action, {
    SOME_STATE: {
      SOME_ACTION_TYPE: (action, currentState) => ({ state: 'NEW_STATE' }),
    },
  }),
);

transition expects that your reducer state has a state property:

{
    state: 'SOME_STATE',
    otherValue: {}
}

transition expects that your reducer actions has a type property:

{
    type: 'SOME_EVENT',
    otherValue: {}
}

exec

useEffect(
  () =>
    exec(someState, {
      SOME_STATE: currentState => {},
    }),
  [someState],
);

If your state triggers multiple effects you can give an array instead:

useEffect(
  () =>
    exec(someState, {
      SOME_STATE: [function effectA(currentState) {}, function effectB(currentState) {}],
    }),
  [someState],
);

The effects works like normal React effects, meaning you can return a function which will execute when the state changes:

useEffect(
  () =>
    exec(someState, {
      TIMER_RUNNING: () => {
        const id = setInterval(() => dispatch({ type: 'TICK' }), 1000);

        return () => clearInterval(id);
      },
    }),
  [someState],
);

transform

const result = transform(state, {
  SOME_STATE: currentState => 'foo',
});

Is especially useful with rendering:

return (
  <div className="wrapper">
    {transform(todos, {
      NOT_LOADED: () => 'Not loaded',
      LOADING: () => 'Loading...',
      LOADED: ({ data }) => (
        <ul>
          {data.map(todo => (
            <li>{todo.title}</li>
          ))}
        </ul>
      ),
      ERROR: ({ error }) => error.message,
    })}
  </div>
);

Inspirations

Me learning state machines and state charts is heavily influenced by @davidkpiano and his XState library. So why not just use that? Well, XState is framework agnostic and needs more concepts like storing the state, sending events and subscriptions. These are concepts React already provides with reducer state, dispatches and the following reconciliation. Funny thing is that react-states is actually technically framework agnostic, but its API is designed to be used with React.

8.0.0-rc1

1 year ago

8.0.0-rc7

1 year ago

8.0.0-rc6

1 year ago

8.0.0-rc9

1 year ago

8.0.0-rc8

1 year ago

8.0.0-rc3

1 year ago

8.0.0-rc2

1 year ago

8.0.0-rc5

1 year ago

8.0.0-rc4

1 year ago

6.26.2-next-fix

1 year ago

7.0.0

1 year ago

7.0.4

1 year ago

7.0.3

1 year ago

7.0.2

1 year ago

7.0.1

1 year ago

8.0.0-rc13

1 year ago

8.0.0-rc10

1 year ago

8.0.0-rc11

1 year ago

8.0.0-rc12

1 year ago

7.1.1

1 year ago

7.1.0

1 year ago

6.4.0

1 year ago

7.2.0

1 year ago

14.2.0-next

2 years ago

13.1.0-next

2 years ago

15.0.2-next

2 years ago

14.1.4-next

2 years ago

6.0.0

2 years ago

14.1.0-next

2 years ago

14.0.1-next

2 years ago

15.0.0-next

2 years ago

14.1.2-next

2 years ago

6.1.0

2 years ago

13.0.0-next

2 years ago

6.2.5

2 years ago

6.2.4

2 years ago

6.2.6

2 years ago

14.1.5-next

2 years ago

6.2.1

2 years ago

15.0.4-next

2 years ago

6.2.0

2 years ago

6.2.3

2 years ago

6.2.2

2 years ago

6.3.0

2 years ago

14.0.0-next

2 years ago

14.1.3-next

2 years ago

14.1.1-next

2 years ago

14.0.2-next

2 years ago

15.3.0-next

2 years ago

15.1.0-next

2 years ago

10.0.0-next

2 years ago

12.2.2-next

2 years ago

10.3.0-next

2 years ago

10.5.1-next

2 years ago

10.4.1-next

2 years ago

12.1.1-next

2 years ago

11.0.0-next

2 years ago

12.0.0-next

2 years ago

12.1.0-next

2 years ago

12.2.0-next

2 years ago

12.2.1-next

2 years ago

10.2.0-next

2 years ago

10.1.0-next

2 years ago

10.3.1-next

2 years ago

9.7.2-next

2 years ago

9.8.0-next

2 years ago

9.7.1-next

2 years ago

9.7.0-next

2 years ago

9.6.0-next

2 years ago

9.3.0-next

2 years ago

9.0.0-next

2 years ago

9.5.0-next

2 years ago

9.1.0-next

2 years ago

9.2.0-next

2 years ago

9.4.0-next

2 years ago

8.0.0-next

2 years ago

7.4.2-next

2 years ago

7.4.1-next

2 years ago

7.3.0-next

2 years ago

7.4.0-next

2 years ago

7.2.0-next

2 years ago

7.1.4-next

2 years ago

7.1.3-next

2 years ago

7.1.1-next

2 years ago

7.1.0-next

2 years ago

7.0.0-next

2 years ago

7.1.2-next

2 years ago

7.1.5-next

2 years ago

6.30.1-next

2 years ago

6.30.0-next

2 years ago

6.30.2-next

2 years ago

6.28.2-next

2 years ago

6.29.0-next

2 years ago

6.28.0-next

2 years ago

6.28.1-next

2 years ago

6.28.3-next

2 years ago

6.27.12-next

2 years ago

6.27.1-next

2 years ago

6.27.11-next

2 years ago

6.27.7-next

2 years ago

6.27.8-next

2 years ago

6.27.0-next

2 years ago

6.27.9-next

2 years ago

6.27.6-next

2 years ago

6.27.10-next

2 years ago

6.27.5-next

2 years ago

6.27.2-next

2 years ago

6.27.3-next

2 years ago

6.27.4-next

2 years ago

6.23.1-next

3 years ago

6.17.2-next

3 years ago

6.26.0-next

3 years ago

6.15.0-next

3 years ago

6.6.0-next

3 years ago

6.2.0-next

3 years ago

6.3.0-next

3 years ago

6.14.0-next

3 years ago

6.11.0-next

3 years ago

6.10.0-next

3 years ago

6.24.2-next

3 years ago

6.20.2-next

3 years ago

6.26.1-next

3 years ago

6.24.1-next

3 years ago

6.8.2-next

3 years ago

6.15.1-next

3 years ago

6.20.1-next

3 years ago

6.25.0-next

3 years ago

6.17.3-next

3 years ago

6.7.0-next

3 years ago

6.19.0-next

3 years ago

6.18.0-next

3 years ago

6.8.0-next

3 years ago

6.9.0-next

3 years ago

6.24.0-next

3 years ago

6.23.0-next

3 years ago

6.22.0-next

3 years ago

6.21.0-next

3 years ago

6.20.0-next

3 years ago

6.17.1-next

3 years ago

6.19.1-next

3 years ago

6.8.1-next

3 years ago

6.25.1-next

3 years ago

6.2.1-next

3 years ago

6.8.3-next

3 years ago

6.17.0-next

3 years ago

6.13.0-next

3 years ago

6.12.0-next

3 years ago

6.1.0-next

3 years ago

6.20.3-next

3 years ago

6.16.0-next

3 years ago

6.5.0-next

3 years ago

6.4.0-next

3 years ago

6.0.0-next

3 years ago

6.26.2-next

3 years ago

6.20.4-next

3 years ago

6.20.6-next

3 years ago

6.20.5-next

3 years ago

5.3.0

3 years ago

5.1.0-next

3 years ago

5.2.2-next

3 years ago

5.2.1-next

3 years ago

5.4.0

3 years ago

5.0.1

3 years ago

5.0.0

3 years ago

5.2.0-next

3 years ago

4.1.0

3 years ago

4.0.0

3 years ago

4.7.0-next

3 years ago

4.6.0-next

3 years ago

4.5.0-next

3 years ago

4.4.2-next

3 years ago

4.4.1-next

3 years ago

4.4.3-next

3 years ago

4.4.0-next

3 years ago

4.3.0-next

3 years ago

4.1.0-next

3 years ago

4.2.0-next

3 years ago

4.0.0-next

3 years ago

3.1.3

3 years ago

3.1.2

3 years ago

3.1.1

3 years ago

3.1.0

3 years ago

3.0.1

3 years ago

3.0.0

3 years ago

2.4.1

3 years ago

2.4.0

3 years ago

2.4.2

3 years ago

2.3.0

3 years ago

2.2.1

3 years ago

2.2.0

3 years ago

2.2.2

3 years ago

2.1.9

3 years ago

2.1.8

3 years ago

2.1.7

3 years ago

2.1.4

3 years ago

2.1.3

3 years ago

2.1.6

3 years ago

2.1.5

3 years ago

2.1.4-next

3 years ago

2.1.2

3 years ago

2.1.0-next

3 years ago

2.1.1

3 years ago

2.1.0

3 years ago

2.0.1

3 years ago

2.0.0

3 years ago

1.3.2

3 years ago

1.3.1

3 years ago

1.3.0

3 years ago

1.2.0

3 years ago

1.2.2-next

3 years ago

1.2.2

3 years ago

1.2.1

3 years ago

1.1.0

3 years ago

1.0.0

3 years ago