Connect Middleware

This is still a work-in-progress. We have had a lot of conversations about how we could design this. I will update the topic with the current implementation we have in mind as soon as possible.

User stories

As a Frontity developer,
I want to be able to subscribe to mutations in the state,
so that I can add logic that depends on some part of the state.

As a Frontity developer,
I want to be able to react to action executions,
so that I can add logic that happens when an action is triggered.

As a package developer,
I want to be able to revert or modify a state mutation,
so that I can modify how other packages mutate the state.

As a package developer,
I want to be able to abort the execution of some actions,
so that I am able to control the actions of other packages.

As a package developer,
I want to be able to change the arguments of some actions,
so that I am able to control the actions of other packages.

1. Context and scope

Frontity Connect is the name of Frontityā€™s state manager.

It can also be used outside of Frontity (https://codesandbox.io/s/frontityconnect-minimal-example-f7pw5), although that part is not documented yet.

The npm package is @frontity/connect although when used in Frontity you can import connect directly from the main frontity package:

// In a Frontity project:
import { connect } from "frontity";

// In an external project:
import connect from "@frontity/connect";

Our current implementation is a fork of react-easy-state which is based on @nx-js/observer-utils. These two libraries were created by Miklos Bertalan.

On top of that, we implemented a new API for actions and derived state, heavily inspired by overmind.

Both react-easy-state and overmind implement a pattern called transparent reactive programming (TRP). This pattern appeared in the JavaScript world thanks to Meteorā€™s Tracker library that later served as inspiration for Michel Westrateā€™s mobx.

The key concept to understand in TRP is that there are two types of entities: observables & computations. When observables are used inside computations, the library adds an internal dependency between then. When that observable is mutated, the library runs again any dependant computation.

In this article, Ryan Carniato gives a great explanation of TRP (although he calls it ā€œFine-Grained Reactive Programmingā€). Thereā€™s also a good explanation of how the dependency graph works on this old Michelā€™s article, back from the initial days of Mobx.

Although the three libraries mentioned so far (mobx, react-easy-state and overmind) implement TRP, mobx does it with a combination of ES5 proxies and special objects/arrays, while both react-easy-state and overmind do it with ES6 proxies and plain javascript objects/arrays. @frontity/connect is also based on ES6 proxies.

We chose not to use any of those libraries and instead fork react-easy-state because we have a more complex use case than the regular React apps: in Frontity all the code is contained in packages, some of them made by the developer itself, but many of them created by the community.

Similar to what happens in WordPress, thereā€™s a need for a powerful hook/extensibility system among all these packages. For that reason, we need to add middleware to both action executions and state mutations. These are some of the things packages should be able to do:

  • Listen to the actions of other packages (add_action in WordPress).
  • Modify args or even abort other package actions.
  • Listen to mutations of the state of other packages (this is normal in TRP).
  • Modify or even abort those mutations (add_filter in WordPress).

Besides that, the state manager API is so critical in the developer experience of Frontity itself that we deemed important to have total control over it. The middleware will also give us other opportunities, like for example releasing powerful devtools, like the ones of overmind.

The overmind inspired API we implemented on top of our react-easy-state fork is:

Actions (without params):

const actions = {
  toggleFilter: ({ state }) => { state.filter = !state.filter },
  fetchTodos: async ({ state }) => { state.todos = await api.getTodos() }
}

actions.toggleFilter()
actions.fetchTodos()

interface Actions {
  toggleFilter: Action<MyPkg>
  fetchTodos: Action<MyPkg>
}

Actions (with params):

const actions = {
  addTodo: ({ state }) => name => { state.todos.push({ name }) }
  fetchTodo: ({ state }) => async id => { state.todos[id] = await api.getTodo(id) }
}

actions.addTodo("finish the state manager")
actions.fetchTodo(374)

interface Actions {
  addTodo: Action<MyPkg, string>
  fetchTodo: Action<MyPkg, number>
}

Derived State:

const state = {
  todos: [],
  completedTodos: ({ state }) => state.todos.filter(todo => todo.completed)
}

state.todos
state.completedTodos

interface State {
  todos: Todos,
  completedTodos: Derived<MyPkg, Todos>
}

Derived State Functions:

const state = {
  todos: [],
  filteredTodos: ({ state }) => completed =>
    state.todos.filter(todo => todo.completed === completed)
}

state.todos
state.filteredTodos(false)

interface State {
  todos: Todos,
  filteredTodos: Derived<MyPkg, boolean, Todos>
}

React Connect

import connect from "@frontity/connect";

const Toggle = ({ state, actions }) => (
  <>
    <div>Toggle is {state.active ? "active" : "inactive"}</div>
    <button onClick={actions.toggle}>toggle!</button>
  </>
);

export default connect(Toggle);

Create Store & Provider (unnecessary in a Frontity project):

import { Provider, createStore } from "@frontity/connect";

const store = createStore({
  state: {
    active: false,
    inverse: ({ state }) => !state.active
  },
  actions: {
    toggle: ({ state }) => {
      state.active = !state.active;
    }
  }
});

const App = () => (
    <Provider value={store}>
      <Content />
    </Provider>
  );

We chose react-easy-state over overmind because the code is much simpler to start with.

2. Goals

There are the design goals we set at the beginning of the first implementation.

:white_check_mark: : Supported/available
:x: : Not ready yet
ā€“ MobxStateTree is an opinionated version of Mobx.

  • :white_check_mark: Full Typescript support - Overmind & MobxStateTree
  • :white_check_mark: Zero boilerplate - Overmind & MobxStateTree
  • :white_check_mark: Easy to learn - Overmind
  • :white_check_mark: State as a plain javascript object - Redux & Overmind
  • :white_check_mark: State mutations (devs donā€™t have to deal with immutability) - Overmind & MobxStateTree
  • :white_check_mark: Async actions using async/await - Overmind
  • :x: Serializable snapshots - MobxStateTree & Redux
  • :x: Serializable actions - MobxStateTree & Redux
  • :x: Serializable mutations (patches) - MobxStateTree
  • :x: Minimum rerender guarantee - MobxStateTree
  • :x: Avoid breaking object/array references on mutations - MobxStateTree
  • :x: Deterministic mutation (patches) - MobxStateTree
  • :x: Middleware support for actions - Redux & MobxStateTree
  • :x: Middleware support for mutations - none
  • :x: Filter support - none
  • :x: Devtools for actions and state - Redux & Overmind
  • :x: Devtools for components - Overmind
  • :white_check_mark: Listen to state mutations - MobxStateTree & Overmind
  • :x: Nested derived state - MobxStateTree
  • :white_check_mark: Small bundle size - Redux & Overmind

These are the specific goals we want to achieve in this iteration:

  • :x: Minimum rerender guarantee
  • :x: Avoid breaking object/array references on mutations
  • :x: Middleware support for actions
  • :x: Middleware support for mutations
  • :x: Serializable mutations (patches)

Minimum rerender guarantee

In order to provide the best possible developer experience, @frontity/connect should be able to detect when a primitive value of the state hasnā€™t changed, even in cases when one the object reference containing that value has changed.

For example, these two equivalent actions should behave exactly the same:

actions: {
    user: {
      changeNameObject: ({ state, actions }) => {
        state.user = { name: "Jon", surname: random() };
        actions.user.changeNameProperties();
      },
      changeNameProperties: ({ state }) => {
        state.user.name = "Jon";
        state.user.surname = random();
      }
    }
  }

state.user.name hasnā€™t changed (it is still ā€œJonā€), but most state managers today wonā€™t optimize this and will re-render any component which is listening to state.user.name:

Avoid breaking object/array references on mutations

There is a bug that affects all the current ES6 proxy-based libraries when using async actions which is not easy to debug or catch. Consider this state and actions:

state: {
  user: { 
    name: "Jon",
    surname: "Snow"
  }
},
actions: {
    user: {
      asyncAction: async ({ state }) => {
        // Store a reference to state.user.
        const user = state.user; 

        // Yield the action, maybe do some fetching...
        await new Promise(resolve => setTimeout(resolve, 5000));

        // Consider that while this function is waiting, the
        // changeNameObject actions is called, changing the 
        // reference of state.user.

        // Now, user.surname is not valid anymore. Still holds
        // the value of "Snow".
        console.log(user.surname); // <- "Snow"
        console.log(user.surname === state.user.surname); // <- false
      },
      changeNameObject: ({ state, actions }) => {
        state.user = { name: "Jon", surname: "Targaryen" };
      },
    }
  }

Middleware support for actions

We need to execute callbacks for action executions. Those callbacks:

  • Can subscribe to an action, a set of actions or to all actions.
  • Start before the action is executed.
  • Can await until the action is executed (await next like Koa, for example).
    • If the action is sync, they can await until the action has finished.
    • If the action is asyc, they can await both until the action has started and unil the action has finished. Maybe something like (await started and await finished instead of next)
    • Itā€™d be great if we later reuse the API of this ā€œawaitingā€ for our server middleware.
  • Can abort the action execution.
    • It can be done with a koa-like prop (ctx.abort = true) or a function (abort()). The benefits of the koa-like approach are that you donā€™t need a separate isAborded prop and that a subsequent action could easily ā€œunabortā€ the execution.
    • We need to decide if, after the abortion, the rest of middleware is run or not.
  • Get a relevant context of the action. Some examples:
    • Action name (ā€œmyActionā€).
    • Action path (ā€œactions.packageName.myActionā€ or maybe an array instead of string).
    • A unique identifier.
    • A parent. Maybe called triggeredBy? It probably needs several properties, like type (ā€œactionā€, ā€œcomponentā€ā€¦) and the identifier of the parent or a reference to its context. If we use the reference we need to be very careful with garbage collection to ensure old action execution contexts can be removed by the GC.
    • isAsync (we only know this after the action has started the execution looking if it returned a promise).
  • Get the arguments of the action and can mutate them at will.

Middleware support for mutations

We need to execute callbacks for action executions. Those callbacks:

  • Can subscribe to a mutation, a set of mutations or to all mutations.
  • Start before the mutation is done.
  • Can await until the mutation is done (await next() like Koa, for example).
  • Get the context of the mutation (similar to the action execution contexts).
  • Receive the patch with the information of what is about to happen.
  • Maybe receive the new value and the old value (maybe not needed if the patch and the current state have that info).
  • Can mutate the patch or the new value to modify the mutation.
  • Can abort the mutation (similar to action execution abortions).

We can use contextā€™s parent or triggeredBy property to implement the protect/unprotect state feature of this types of libraries, and thrown an error or log a warning when people try to modify state outside of an action.

Middleware could easily be able to use the mutation patches to generate immutable snapshots (using immer for example) for things like the devtools.

3. Design Challenges & Implementation Ideas

Avoid breaking object/array references on mutations

Iā€™ve made a proof of concept about how to preserve proxy references to solve this problem.
-> https://codesandbox.io/s/frontity-connect-stable-proxy-references-5knj5

If we use this approach, the things we still need to work on are:

  • Ensure proper garbage collection.
  • Make it work properly with all object stuff, like ownKeys.

Attaching contexts to action and state

In order to attach different contexts to actions and components my proposal is to reproxify the state for each action execution or component instance with a proxy that has both the state (or actions) and the context in its target.

I did a more detailed explanation in this video, including the object reference bug and how to solve it:

Instead of a createStore, we have a reproxify that accepts the state (or action) and the context. That reproxification happens inside connect each time a new component instance is created and inside the action executions.

You can create other proxies apart from those, like for example one for the console that you latter add to window.frontity or for the devtools in case we add the possibility of changing the state or dispatching actions from there.

const config = { actions, state, libraries };
const consoleContext = { type: "debug", parent: null... }
window.frontity = reproxify(config, consoleContext);

If we create middleware to protect state mutations outside actions, it can bypass that behavior if the type is "debug" for example.

Minimum rerender guarantee

This is probably the biggest challenge we have now.

We need to decide:

  • What type of patches do we emit:
    • The ones we get from the action.
    • Optimized to be deterministic and minimum.
  • How do we diff-by-value to avoid component re-renders or running middleware when values didnā€™t change.

Adding the middleware callbacks

There are several approaches. The MobxStateTree approach:

addMiddleware(state, myMiddleware);

Maybe a new prop in the package:

export default {
  state...
  actions...
  middleware // or a better name
}

Maybe an array with names and priorities in libraries, like what we are already doing for html2react processors and wp-source handlers.

libraries.middleware.push({
  name: "my-middleware",
  priority: 10,
  subscription: ...
  callback: ...
})

The benefit of this approach is that packages can change the priority or even remove the middleware added by other packages.

Subscriptions

1. We need both the action and state middleware to be able to subscribe to any number of actions/state.

We can use an approach similar to reaction in Mobx:

// One action
({ actions }) => actions.pkgName.myAction
// A set of actions
({ actions }) => {
  actions.pkgName.myFirstAction;
  actions.pkgName.mySecondAction;
}
// All actions?

Or string/regexps:

// One action (string)
"actions.pkgName.myAction"
// A set of actions (or regexp)
"actions\.pkgName\.my(First|Second)Action"
// All actions
"actions"

Same for state.

If we go with the reaction approach, there is one problem:

  • How do you listen to all state changes below certain path?
const state = {
  user: { name: "Jon", surname: "Snow" }
};

// Subscribe to the state.user object reference or to any change to
// user.name and user.surname?
({ state }) => state.user

One solution would be to iterate through the object keys, but itā€™s not straightforward and it gets complex if the object has many nested levels:

({ state }) => Object.keys(state.user) // only one level
({ state }) => JSON.stringify(state.user) // all nested levels

If we go with the string/regexp approach, we lose typings.

Maybe we can combine both.

2. Subscriptions can be done with two approaches: a callback or a promise that resolves.

Callback type, this is a bit like takeEvery in Redux Saga or reaction in Mobx:

// Create the middleware
const myMiddleware = {
  subscription: ({ actions }) => actions.pkgName.myAction,
  callback: ctx => {
    // do stuff...
  }
}
// Add it to the array
libraries.middleware.push(myMiddleware);

Promise type, this a bit like take in Redux Saga or when in Mobx:

const myMiddlewareAction = async () => {
  const ctx = await waitFor(({ actions }) => actions.pkgName.myAction);
  // do stuff...
}

If you need to repeat for each action execution, use a while or call the action again:

const myMiddlewareAction = async () => {
  while (true) {
    const ctx = await waitFor(({ actions }) => actions.pkgName.myAction);
    // do stuff...
  };
}

Benefits of the promise approach are that you have more control over when the middleware kicks in or stops. Cons of the promise approach are that other packages cannot remove the middleware, because itā€™s not exposed in a public array.

1 Like

Avoid breaking object/array references on mutations

Couldnā€™t the proxyequal or memoize-state package help us with that?

Could we have devtools support for middleware? It would be great I think to be able to show in the devtools that a particular middleware aborted or modified a particular action

I like the koa approach of await next(), etc !!!

We need to decide if, after the abortion, the rest of middleware is run or not.

What would the API for this look like?

Make it work properly with all object stuff, like ownKeys.

what does that mean?

  1. We need both the action and state middleware to be able to subscribe to any number of actions/state.

But shouldnā€™t we leave that to the user to determine? They should have access to the action.name in the middleware, right? If the middleware runs for every state change / action. they could just filter out all the irrelevant actions / state changes if they want. This way we donā€™t even need to provide an APIā€¦ Or am I missing some use case here? Might be bad for performance thoughā€¦

I donā€™t really have a good idea how to approach it off top of my head - would need a little bit of reasearch (how other libs deal with it, etc.)

  1. Subscriptions can be done with two approaches: a callback or a promise that resolves.

I think the same technically goes here, right? We could skip this API altogether. Not saying that we should , but we could.

Possible APIs for abortion: ctx.abort = true or ctx.abort().


Several things that we need to make a first decision to start the work:

  • The name of the middleware callbacks. Maybe ā€œreactionsā€?
  • Abortion: should we stop running middleware callbacks if another callback aborts the action/mutation?
  • What API do we give to the user to subscribe to actions/mutations.
    • Run all middleware and let the user filter using action.name or action.path.
    • Use a mobx-like reaction with a function that gets parts of the state/actions.
    • Use string/regexps against action.path.
    • Use the same API for actions/mutations or a separate.

If we keep the array idea, we could use separate props for mutations and actions. Something like this:

{
      priority: 10,
      reaction: ({ path }) => {
        if (path === "actions.theme.myAction") {
          // do stuff
        }
      }
    },
    {
      priority: 10,
      test: "actions.theme.myAction",
      reaction: ({ path }) => {
          // do stuff
      }
    },
    {
      priority: 10,
      test: /actions.theme.myAction/,
      reaction: ({ path }) => {
          // do stuff
      }
    },
    {
      priority: 10,
      test: ({ actions }) => actions.theme.myAction,
      reaction: ({ path }) => {
          // do stuff
      }
    },
    {
      priority: 10,
      test: ({ state }) => state.theme.myAction,
      mutation: ({ path }) => {
          // do stuff
      }
    },
    {
      name: "",
      priority: 10,
      test: (ctx) => ctx.path === "state.mything",
      mutation: (ctx) => {
        // each mutation
      }
    },

Another thing to have in mind is: do we want the devtools to report which middleware has run? For example, if an action is aborted, do we want to know which middleware did it? If so, we need a way to hook into that as well, and probably a separate ā€œsubscriptionā€ so we know which reactions were run by which action and so on.

Notes on middleware

Like you mentioned, I think we should try to avoid having string/regex based selectors for state/actions because then we lose TS support. If we can get type hints in the IDE for actions / state created by other packages, without having to look them up, itā€™s awesome for developer ergonomics :slight_smile:

Maybe something like (await started and await finished instead of next)

When I think about it, I donā€™t think we need await started() or await finished()`. I donā€™t think it makes sense to complicate the API just in order to be able to perform a bit of work before the asynchronous action finishes. The only benefit of having something like:

{
  // do some preparation
  await started();
  doSomeWork()
  await finished();
  // do some cleanup
}

is that we can call doSomeWork() before await finished(), so it potentially saves us a bit of time but, realistically, I donā€™t see it being used very much. Usually, the users will want to do something either before or after the action and any preparation for cleanup can be done simply after await next().

It can be done with a koa-like prop (ctx.abort = true) or a function (abort()). The benefits of the koa-like approach are that you donā€™t need a separate isAborded prop and that a subsequent action could easily ā€œunabortā€ the execution.

My gut tells me that we should make abort a function rather than a prop. I think that it does not matter very much API-wise, but rather that it would be easier to implement this way. If abort is a property, we will probably have to check it with an if statement in multiple places. However, if itā€™s a function, we can encapsulate the aborting logic in one place more easily. But this is one of those strong opinions that are weakly held, so I could definitely be persuaded otherwise :slight_smile:

I think it would be nice to have

Abortion: should we stop running middleware callbacks if another callback aborts the action/mutation?

I think that the answer is ā€œsort ofā€ :grin:. I think that middlewares should run in the order they are defined (like in mobx-state-tree or express). Letā€™s picture the (action) middleware execution as a stack :

middleware1
  middleware2   <- let's say we abort inside middleware2
    middleware3 <- this middleware will NOT run
        action  <- the action will NOT run
    middleware3
  middleware2
middleware1     <- but anything that is after `await next()` inside of                      middleware1 WILL still run because we return back to
                   middleware1 when we abort inside of middleware2

Hereā€™s a full example of what I have in mind:

// Same example for both mutation and action middleware.

// Important: the middlewares run in the order that they are 
// attached 

const loggerExampleMiddleware = async ( ctx, next, abort, resume ) => {
  if (ctx.isAborted) {
    resume(); // we can check if the current middleware has 
              // been aborted by previous middleware
  }
  if (!ctx.path === 'user.name') {
    return next();
    // We can bail out or running the middleware if we want to
  }
  
  console.log(ctx.patch) {
  // If this is a state mutation, we can have a patch
  
  if (ctx.path === 'user.email') {
    return abort();
    // If we abort, the middleware:
    // - stops executings here (we have to explicitly return)
    // - aborts all subsequently scheduled middlewares 
    //   (for the particular node in the tree / action).
    // - aborts any actions called from the current action.
  }

  console.log(`action ${ctx.name} will run`));

  await next(); 
  // - In the case of mutation middleware, we don't have to await
  //   because the state mutation is synchronous.
  // - In the case of action middleware, we always have to await, 
  //   because the action could be async (we don't know it if is)
  
  abort("optional value"); 
  // Aborting is still allowed after we run the action! 
  // In this case the action all subsequent middlewares are aborted!

}

// We can attach to the root [state middleware]
addMiddleware(store, loggerExampleMiddleware);

// We can attach to a node in the tree [state middleware]
addMiddleware(store.users, loggerExampleMiddleware);

// We can attach to all actions  [action middleware]
addMiddleware(store.actions.users.myAction, loggerExampleMiddleware);

// We can attach to a particular action [action middleware]
addMiddleware(store.actions.users.myAction, loggerExampleMiddleware);


// Maybe we should rather do something like:
addMiddleware(({ actions }) => actions.user.myAction, loggerMiddleware)
// But I'm not sure if this "selector" callback is necessary. Thoughts? :)

Notes on the proposal:

  • You must call either next(call) or abort(value) within a middleware.
  • The value from either abort(ā€˜valueā€™) or the returned value from the action can be manipulated by previous middlewares.

Outstanding questions:

  1. Like mentioned earlier, not sure yet what the best API to attach new middlewares would be.
  2. Should the middleware be able to return a value to be passed on to the next middleware? (Iā€™m leaning towards ā€œyesā€). I guess for mutation middleware this value would be the modified state? What should it be for the action middleware?
  3. Should we be able to call other actions in the middleware?
  4. Should we be able to return promises from middlewares (this would make middlewares async)? Or is this a terrible idea that just overcomplicates things? :smiley:
  5. How do we handle errors in the middleware?
  6. Why do we really need priority for the middlewares? Iā€™m trying to think of a use case that would be impossible to solve without it and came up with nothing so far. Iā€™m not personally a fan, becuse they seem to overcomplicate things. It reminds me of what a mess z-index is in most of CSS that Iā€™ve seen :smiley:

What else:

  • We should be careful and think about what we need in the implementation in order to be able to display middleware information in the devtools like:
    middleware mypackage.myMiddleware aborted action actions.user.myActions

Not really because they require immutability. Iā€™d do it within our proxify implementation.

Absolutely! Although I have no idea about how to do it. Weā€™ll need to think about it.

We need to make sure all the proxy traps work: https://ponyfoo.com/articles/es6-proxy-traps-in-depth

Iā€™ve been thinking about this and I have a new idea on how to store the context and keep the references stable while maintaining a proper target in the proxy. Iā€™ll try to make a new POC.

Agree. I added an example about how this can be explicit below, along with an example about how to still execute other middleware (for example devtools) and retrieve the name of the reaction that aborted the action/mutation.

Agree. I think abort() as a function makes more explicit that the subsequent reactions are not going to be executed.


I have an idea on how to ā€œtellā€ the devtools middleware which other reaction aborted the action/mutation and how to make ā€œnormal reactionsā€ donā€™t run if some other reaction aborted.

Itā€™s very similar to the middleware stack you explained, but adding explicit priorities and names.

reactions: [
  {
    name: "devtools",
    priority: 0 // This is the first middleware we run
    reaction: async ctx => {
      await ctx.finished();
      if (ctx.isAborted) {
        console.log(ctx.abortedBy) // <- "my-aborting-reaction"
      }     
    }
  }
]

ā€“ Default "test" could be () => true (always run).

Later onā€¦ in your package:

reactions: [
  {
    name: "my-aborting-reaction",
    priority: 1, // This is the recommendation for aborting reactions
    test: ctx => ctx.path === "actions.someNamespace.someAction",
    reaction: ctx => {
      ctx.abort(); // BOOM!
    }
  }
]

And on other package or themeā€¦

reactions: [
  {
    test: ctx => ctx.path === "actions.someNamespace.someAction",
    reaction: ctx => {
      // I never run because my priority is lower than "my-aborting-reaction"
    }
  }
]

ā€“ "name" and "priority" are optional. Default priority is 10.

Iā€™m not sure about this. If ctx.next() is going to be equivalent to ctx.actionFinished(), for example, why not call it ctx.actionFinished() which is more explicit and add ctx.actionStarted(), just in case?

By the way, in the server middleware we can add the ā€œsame kindā€ of awaitable functions, like ctx.settingsReady(), ctx.storeReady(), ctx.initFinished(), ctx.beforeSSRFinished(), ctx.renderFinished()ā€¦ so even tho itā€™s koa, I donā€™t think people will have to use next(). So Iā€™d go with more explicit naming in both places.

Based on our design principle that everything should be as simple as possible I think we can avoid the need of calling next() on each reaction. My guess is that most of the time the middleware is going to be used as mobx reactions instead of koa/express middleware:

{
  test: ({ path }) => path === "actions.router.set",
  afterAction: ({ actions, state }) => {
    // Send an pageview to Analytics each time the URL changes.
    actions.analytics.sendPageview(state.router.link);
  }
}

Hmmā€¦ I liked your proposal of using ctx to do the selection :smile:

Maybe we can offer both:

{
  test: ({ path }) => path === "actions.router.set",
  // or...
  test: ({ actions }) => actions.router.set,
}

Iā€™d do that like koa does it, with ctx.body, ctx.status and so onā€¦ that way we are not limited to ā€œone valueā€.

In the case of actions, the arguments of the action can be modified: ctx.arguments.
In the case of mutations, the new value can be modified, maybe: ctx.value or ctx.newValue.

Yes, we need that. Take a look at the analytics example above.

In that case, we can use the reaction name to create a new context with the parent or triggeredBy properly populated. People would have to add a "name" to the reaction for this, tho.

I understand your concern. Iā€™ve always felt that numerical priorities are like democracy: ā€œthe worst system except for all the othersā€. I welcome the day when somebody creates a better way to do prioritization. Until then, we have to deal with it.

The good thing is that it is optional, so it doesnā€™t affect the developer experience. For 95% of the use cases where you donā€™t care about running it before or after other middleware, the default priority works fine and can be omitted. And you can solve the other 5% with it. It conforms to our ā€œsimple but hackableā€ rule.

There you go, it works: https://codesandbox.io/s/frontity-connect-stable-proxy-references-idea-2-14ew6

Instead of storing context and state in target, Iā€™m storing them in handlers and accessing them using this inside the handlers.

The target is state again (instead of an object containing both state and context), but inside the handlers, you donā€™t use it, you use this.state.

Benefits of this approach:

  • People can console.log(proxy) and inspect the state in the [[Target]] (instead of an object containing both state and context).
  • Things like ownKeys work out of the box, because they look at target.

EDIT: Iā€™ve just realized that the console.log(proxy) will get out of sync when you overwrite the reference :sweat: We can control the traps (ownKeys) but not the console.log unless we monkey patch it.

Iā€™ve modified the original POC to include a set handler: https://codesandbox.io/s/frontity-connect-stable-proxy-references-wkm42

Now it works when you mutate through the proxy (which is going to be the default behaviour):

const state = {
    user: {
      name: "Jon",
      surname: "Snow",
    }
  };
const proxy = proxify(state, context);

const user = proxy.user;
expect(user.surname).toBe("Snow");

// Overwrite the reference.
proxy.user = {
  name: "Jon",
  surname: "Targerean"
};
expect(user.surname).toBe("Targerean");

Update on the implementation

There was a subtle bug that was kinda hard to catch:

// Update the proxy to reference to the new state.
// We only want to create a new proxy if the value is NOT a leaf
// of the state tree
if (!isPrimitive(value)) {
  proxify(value, target.context, `${target.path}.${key}`);
}

In the current implementation we only want to reproxify if the value is NOT a primitive! I I found it out when adding Reflect.get(target.state, key) and Reflect.set(target.state, key, value)

Interfacting with the current implementation of @frontity/connect

I had the idea that the easiest way to integrate our prototype with the current frontity/connect would be to create hide our implementation behind the rawToProxy and proxyToRaw . Basically, we can use the same interfaces, but the rawToProxy is not a WeakMap anymore, but rather itā€™s a kind of ā€œbridgeā€ object like:

// just to give an idea:
const proxyToRaw = {
  get: function(obj, path) {}, // todo
  set: function(obj, path) {}  // todo
}

const rawToProxy = {
  get: function(obj, path) {
    const proxies = contexts.get(path);
    if (!proxies) return undefined;
    return proxies.get(obj);
  },
  set: function (obj, path) {
    const proxies = contexts.get(path);
    if (!proxies) return 
  }
}
1 Like

Alternative to string-based paths

I realized that storing proxies by stringy path is not going to work for arrays, duh!!! :woman_facepalming:Even if we go with mongoDB-like notation: state.users.0.name, if a state mutation slices the users array, then weā€™d have to update the stored paths for all the other users, which I feel is going to get messy real quick.

Isnā€™t that the way all the implementations (mobx, react-easy-stateā€¦) work even tho they donā€™t use string paths?

Besides that, itā€™s not a problem for React because of its key prop requirement for arrays.

Iā€™m not actually sure. I will take a look!

I experimented with updating of the proxyToRaw and rawToProxy and I figured out that it wonā€™t work 100%, because of some code that is in collections.js .For example on this line: https://github.com/frontity/frontity/blob/dev/packages/connect/src/builtIns/collections.js#L12 we would need to pass the context and path to rawToProxy , but there is no way to access those parameters inside of findObservable() . Similarly, for proxyToRaw calls inside of instrumentation , e.g. here: https://github.com/frontity/frontity/blob/dev/packages/connect/src/builtIns/collections.js#L38.The silver lining is that this should only break the built-in objects (Map, WeakMap) and I think that we can work around it eventually.

Youā€™re right, we are moving from a 1-to-1 relation to a 1-to-many.

46

As far as I can remember, react-easy-state only needs the O->P relation to reuse the same proxy for the same object. But thatā€™s not something we need, we will reuse the same proxy using the path+context. But yeah, weā€™re going to break some stuff of react-easy-state.

By the way, if we need to access state , context , path or root from outside of the proxy, we can use symbols:

proxy[STATE]
proxy[CONTEXT]
proxy[PATH]
proxy[ROOT]

we can also use that approach to attach the path to the real state because path is invariant:

state[PATH]

Weā€™re working on the first PR: https://github.com/frontity/frontity/pull/208 to add state, path and context to the proxies.

The ā€œstable proxy referencesā€ implementation involves having to store a ā€œfake targetā€ instead of the real one, because we need to be able to change the reference, without creating a new proxy:

const proxify = (target, context) => {
  const fakeTarget = { realTarget: target, context };
  return new Proxy(fakeTarget, handlers);
};

It works ok, but all the traps must be used to change this ā€œfake targetā€ for the real one:

handler(fakeTarget, key, ...) {
  const target = fakeTarget.realTarget; // extract the real target.
}

Itā€™s not as clean as we thought and it feels kind of hacky because many internal things in JS expect the target to be the real target, obviously. Besides that, the console.log are a bit confusing because people have to look for the real state inside this fake target.

So weā€™ve been working on a new idea: stable state references (instead of stable proxy references).

It works by adding a new deepOverwrite function that preserves the state references, overwriting the old object with the new one.

Imagine this object with this object references.

const state = {
  user (ref1): {
    name (ref2): {
      first: "Jon",
      last: "Snow"
    },
    city: "Winterfell"
  }
}

And an action that overwrites the references like this:

const myAction = ({ state }) => {
  state.user (ref3) = {
    name (ref4): {
      first: "Jon",
      last: "Targaryen"
    }
  }
}

Usually, the result will be:

state = {
  user (ref3): {
    name (ref4): {
      first: "Jon",
      last: "Targaryen"
    }
  }
}

And this patch will be:

{
  path: "state.user",
  type: "set",
  value: (ref3) {
    name (ref4): {
      first: "Jon",
      last: "Targaryen"
    }
  }
}

If instead of changing the ref1 -> ref3, we do a deepOverwrite in the set handler, the result would be:

state = {
  user (ref1): {
    name (ref2): {
      first: "Jon",
      last: "Targaryen"
    }
  }
}

And the patches would be:

{
  path: "state.user.city",
  type: "delete"
}
{
  path: "state.user.surname",
  type: "Targaryen"
}

The implementation of this deepOverwrite function would be something like this:

function deepOverwrite(a, b) {
  // Delete the props of a that are not in b.
  Object.keys(a).forEach(key => {
    if (typeof b[key] === "undefined") delete a[key];
  });

  Object.keys(b).forEach(key => {
    // If that key doesn't exist on a or it is a primitive, we overwrite it.
    if (typeof a[key] === "undefined" || isPrimitive(b[key])) a[key] = b[key];
    // If it's an object, we deepOverwrite it.
    else deepOverwrite(a[key], b[key]);
  });
}

This approach has some additional benefits that I really like:

  • We can keep using the solid implementation of react-easy-state.
  • The patches would be atomic and deterministic out of the box.
  • The re-rendering problem would solved out of the box.

But it may have a performance impact when deep-overwriting big objects.

Iā€™ve done a quick test to measure the performance impact: https://codesandbox.io/s/deep-overwrite-hq9r4

The test fetches 10 posts (using _embed to get also categories, authors, mediaā€¦) from https://test.frontity.io/wp-json/wp/v2/posts?_embed=true. Then it normalizes the result with normalizr and deep-clones it before doing the deepOverwrite.

The JSON is 219Kb (unzipped) so itā€™s a big object. It takes 20-40 ms to do the deepOverwrite in my computer with a slowdown of x6. That means we would be skipping 2-3 frames for such a big object in a slow device.

Iā€™ve also measured the normalization we are already doing to that big object (done with the normalizr library) and itā€™s pretty similar:

normalize: 21.870849609375ms
deepOverwrite: 26.866943359375ms

So it doesnā€™t seem to be that bad. At least wonā€™t be introducing something which is 10x slower to what we already do.

I also think that mobx-state-tree has to do something like this under the hood. Iā€™d try to do another test with this approach vs mobx-state-tree to see if we are still in the same order of magnitude. Iā€™d bet this is still way faster.

Additional considerations about performance:

  • It has no impact the fist time you add an object to the state, it only has impact if you overwrite
  • It has an opt-in optimization for the final user when needed: donā€™t overwrite objects, just mutate the primitives!
  • The deepOverwrite would be performed before any React change is triggered, so it wonā€™t affect the render or any subsequent animations triggered by that mutation.

For example, we are currently overwriting all the entities fetched with actions.source.fetch(...), but thereā€™s no reason to do that. So we can check if that entity exists before overwriting it. We can make it opt-in in the future when we add { force: true } in case itā€™s a real refresh. So even the ā€œslowā€ deepOverwrite of my test can be pretty much avoided.

Another interesting idea worth exploring is to make mutations asynchronous, instead of synchronous. If we do that, the deepOverwrite function is a great candidate for the new facebookā€™s scheduler function (used internally for React Concurrent). That would solve all the performance problems because deepOverwrite could be chunked and there wonā€™t be any missing frame.

The problem with making mutations asynchronous is that a filter wonā€™t be ready synchronously:

// This "filter" adds an @ before the user name. "Jon" -> "@Jon".
const myFilter = {
  test: ({ path }) => path === "state.user.name",
  onMutation: ctx => {
    ctx.value = "@" + ctx.value
  }
}

// If mutations are synchronous:
const myAction = ({ state }) => {
  state.user.name = "Jon"; // This triggers myFilter
  console.log(state.user.name) // -> "@Jon"
}

// If mutations are asynchronous:
const myAction = ({ state }) => {
  state.user.name = "Jon"; // This triggers myFilter
  console.log(state.user.name) // -> "Jon"
}

But maybe we can find a solution for that.

I was experimenting with a different approach:

Maybe we could create a wrapper for a built-in Proxy, which could dynamically change the targets inside of the proxy without breaking the references. A sketch of this approach is mentioned here.

It seems like a kinda complicated idea and I was not able to use use the snippet from StackOverflow nor do I understand it completely :frowning_face: Iā€™m tempted to say that for this reason alone we should probably abandon this ideaā€¦ Iā€™d ideally like our code to be understandable to an average developer like me :sweat_smile:

Now, I investigated the deep-overwrite idea a little more and I realized that we have to change how we handle the reactions. Basically, if a user reassigns the state like in the example:

const myAction = ({ state }) => {
  state.user (ref3) = {
    name (ref4): {
      first: "Jon",
      last: "Targaryen"
    }
  }
}

then we have to ā€œfollowā€ the change down to the actual leaves of the state tree in order to have the patch like:

{
  path: "state.user.city",
  type: "delete"
}
{
  path: "state.user.surname",
  type: "Targaryen"
}

Agree. Besides, having proxies inside proxies will obfuscate the debugging even further:
58

Yes, but, if Iā€™m not mistaken, we only have to change ā€œhow we queueā€ reactions. The rest is going to work out of the box.

This is our current set handler (I omitted some comments and irrelevant code):

function set(target, key, value, receiver) {
  const hadKey = target.hasOwnProperty(key);
  const oldValue = target[key];
  const result = Reflect.set(target, key, value, receiver);

  if (!hadKey) {
    // Queue a reaction if it's a new property.
    queueReactionsForOperation({ target, key, value, receiver, type: "add" });
  } else if (value !== oldValue) {
    // Queue a reaction if there's a new value.
    queueReactionsForOperation({ target, key, value, oldValue, receiver, type: "set" });
  }

  return result;
}

This is a rough example of how the new set handler would look like:

function set(target, key, newValue, receiver) {
  const hadKey = target.hasOwnProperty(key);
  const oldValue = target[key];
  const result = Reflect.set(target, key, value, receiver);

  // If both the old value and the new value are objects, deepOverwrite.
  if (isObject(oldValue) && isObject(newValue) {
    deepOverwrite(oldValue, newValue);

  // Queue a reaction if it's a new property.
  } else if (!hadKey) {  
    queueReactionsForOperation({ target, key, value, receiver, type: "add" });

  // Queue a reaction if there's a new value.
  } else if (value !== oldValue) {
    queueReactionsForOperation({ target, key, value, oldValue, receiver, type: "set" });
  }

  return result;
}

function deepOverwrite(a, b) {   
  // Delete the props of a that are not in b.
  Object.keys(a).forEach(key => {
    if (typeof b[key] === "undefined") {
      // Trigger a delete reaction.
      queueReactionsForOperation({ target: a, key, oldValue: a[key], type: "delete" });
      // Delete the key.
      delete a[key];
    }
  });

  Object.keys(b).forEach(key => {
    // If that key doesn't exist, we add it.
    if (typeof a[key] === "undefined") {
      queueReactionsForOperation({ target: a, key, type: "add" });
      a[key] = b[key];

    // If that key exist, it's a primitive and the value has changed, we overwrite it.
    } else if (isPrimitive(b[key]) && a[key] !== b[key]) {
      queueReactionsForOperation({ target: a, key, oldValue: a[key] type: "set" });
      a[key] = b[key];

    // If it's an object, we deepOverwrite it again.
    } else {
      deepOverwrite(a[key], b[key]);
    }
  });
}