Frontity Priorities

Description

Many Frontity APIs need priorities. We should figure out a way to deal with priorities that is consistent across all the different Frontity APIs and can be used by both package creators and users.

Examples

A good use case that we could use to figure out how different solutions would look like could be:

  • We have two packages: packageA and packageB.

  • Main packageA is:

    export default {
      actions: {
        packageA: {
          action1: () => {
            // ...
          },
        },
      },
      server: {
        packageA: {
          middlware1: () => {
            // Needs to run before middlware2.
          },
          middlware2: () => {
            // Needs to run after middlware1.
          },
        },
      },
    };
    
  • Main packageB is:

    export default {
      actions: {
        packageA: {
          action1: () => {
            // Needs to overwrite `actions.packageA.action1`
          },
        },
      },
      server: {
        packageB: {
          middlware3: () => {
            // Needs to run in between middlware1 and middlware2.
          },
        },
      },
    };
    
  • The frontity.config.js file of packageA is:

    export const settings = (prevSettings) => {
      // Needs to run after all the settings modifications of other packages.
    };
    
    export const webpack = (prevConfig) => {
      // Needs to run before all the Webpack modifications of other packages.
    };
    
  • The frontity.config.js file of packageB is:

    export const settings = (prevSettings) => {
      // Needs to run before all the settings modifications of other packages.
    };
    
    export const webpack = (prevConfig) => {
      // Needs to run after all the Webpack modifications of other packages.
    };
    

Functionalities

Have a single way or API to define the priorities of the different Frontity APIs.

Requirements

  • They can be configured at a function level.
  • They work consistently across all the different APIs.
  • They work across packages.
  • They can be easily configured by package creators.
  • They can be easily modified by other packages if needed.
  • They are not required. When not set, a default middle priority should be used internally.
  • Ideally, they can be easily modified by final users if needed.

Dependencies

This feature doesn’t have any dependencies, but there are some dependent features that will use this:

  • The AMP package: It needs priorities because it needs to modify the settings via frontity.config.js before any other package runs.
  • Server Extensibility: It needs priorities to make sure that the server middlware functions run in the correct order across packages.
  • Frontity Hooks: It needs priorities to make sure that the hooks run in the correct order across packages.
  • Webpack Customization: It needs priorities to make sure that the Webpack modifications run in the correct order across packages.

There are other Frontity APIs that are using priorities right now:

  • Source Handlers.
  • Html2React Processors.
  • Fills (Slot and Fill).

What we are using for those is a priority property on their configuration objects. If this FD ends up with a different solution to handle priorities in Frontity, we should migrate those APIs as well. Maybe not immediately, but over time, so all the priorities are consistent among Frontity APIs.

Possible solution

These are some initial ideas.

Configuration objects

This is what we are already using for Source Handlers, Html2React Processors and Fills.

Instead of a function, the developer needs to create an object that has the function inside a func/processor/… property. The priority is added as an optional property:

export default {
  libraries: {
    html2react: {
      processors: [
        {
          processor: () => {
            // ...
          },
          priority: 5,
        },
      ],
    },
  },
};

Some pros (non-exhaustive):

  • They can be overwritten by other packages and at runtime.

Some cons (non-exhaustive):

  • They require an object, although that can be solved by making it optional.

    export default {
      libraries: {
        html2react: {
          processors: [
            () => {
              // Doesn't require an object with default priority.
            },
            {
              processor: () => {
                // Requires an object to set a custom priority.
              },
              priority: 5,
            },
          ],
        },
      },
    };
    
  • The requirement of the object is not that nice with namespaces, although not terrible I guess:

    export default {
      server: {
        myPackage: {
          myFirstMiddleware: () => {
            // Default priority middlware.
          },
          mySecondMiddleware: {
            fn: () => {
              // Custom priority middlware.
            },
            priority: 5,
          },
        },
      },
    };
    

Functions

This solution would be something similar to the add_action and add_filter functions of WordPress, where the first argument is the function and the second is the priority.

Something like this:

import { server } from "frontity";

export default {
  server: {
    myPackage: {
      myFirstMiddleware: server(() => {
        // Default priority middlware.
      }),
      mySecondMiddleware: server(() => {
        // Custom priority middlware.
      }, 5),
    },
  },
};

Some cons (non-exhaustive):

  • It requires an otherwise unnecessary extra import, although it could be avoided for default priorities.

  • It will require a function per API (server, handler, processor, hook…), although maybe it could be centralized with a single API:

import { priority } from "frontity";

export default {
  server: {
    myPackage: {
      myFirstMiddleware: () => {
        // Default priority middlware.
      },
      mySecondMiddleware: priority(() => {
        // Custom priority middlware.
      }, 5),
    },
  },
};
  • Priorities are obscured and cannot be changed by other packages or at runtime.

Priorities export

Another solution that comes to my mind would be to add a separate export for priorities:

export default {
  server: {
    myPackage: {
      myFirstMiddleware: () => {
        // Default priority middlware.
      },
      mySecondMiddleware: () => {
        // Custom priority middlware.
      },
    },
  },
  priorities: {
    server: {
      myPackage: {
        mySecondMiddleware: 5,
      },
    },
  },
};

Some pros (non-exhaustive):

  • They can be overwritten by other packages and at runtime.
  • All the priorities are stored in the same place (not really sure if that has a real benefit).

Some cons (non-exhaustive):

  • I am not sure how this could be integrated with the priorities required for the frontiy.config.js file because the store is not initialized at that point. Maybe we could do it with an additional priorities export in that file:

    export const settings = () => {
      // Modify settings...
    }
    
    export const webpack = () => {
      // Modify webpack...
    }
    
    export const priorities = {
      settings: 5,
      webpack: 1
    }
    

Priorities namespace

Similar to the priorities export, but in state.

export default {
  server: {
    myPackage: {
      myFirstMiddleware: () => {
        // Default priority middlware.
      },
      mySecondMiddleware: () => {
        // Custom priority middlware.
      },
    },
  },
  state: {
    priorities: {
      server: {
        myPackage: {
          mySecondMiddleware: 5,
        },
      },
    },
  },
};

Some pros (non-exhaustive):

  • It can be changed in frontity.settings.js, although we could do that
  • Priorities would be reactive, so packages could subscribe to priority changes if they have initialization functions.

Some cons (non-exhaustive):

  • I am not sure how this could be integrated with the priorities required for the frontiy.config.js file because the store is not initialized at that point.

I wonder if you gave some thought to a slightly different API for priorities that is not based on numeric priority but rather allows the user to define which packages should run “before” or “after” some other packages. I’m not writing a full proposal just yet because perhaps you have considered it already @luisherranz.

To borrow your example of packageA and packageB and using the “configuration objects” syntax:


export default {
  server: {
    myPackage: {
      myFirstMiddleware: () => {},
      mySecondMiddleware: {
        fn: () => {  },

        // Custom priority can be a single item,
        runBefore: "myFirstMiddleware",
      },
      myThirdMiddleware: {
        fn: () => {  },

        // Or it can be an array of items. 
        // (FYI: Specifying myFirstMiddleware is actually redundant in this case) 
        runBefore: ["myFirstMiddleware", "mySecondMiddleware"],
      },
    },
  },
};

So in the above case the middleware would execute in the following order:

  • myThirdMiddleware
  • mySecondMiddleware
  • myFirstMiddleware

Pros

The main benefit that I see of this approach is that the user would not have to know anything about the priorities of other packages. In most cases a user just knows that his code should run before (or after) some specific middleware. This way, they can specify that requirement declaratively and let Frontity figure out the specific order of all of the middlewares.

Cons

Of course, there would be some additional challenges with this approach.

For example, you have to check if there is no infinite loop because middleware A could try to run before middleware B and middleware B could try to run before middleware A:

However, as far as I can tell, solving this problem is equivalent to detecting cycles in a directed graph because our middlewares would then be a linked list which is the simplest directed graph.

So, I realize that this is approach has an obvious fatal flaw in that some package / middleware might have to, for example, run before all packages without knowing what these packages are ahead of time…

I think that numerical priority is the only way to achieve priorities in a world where a package might not know anything about all other packages.

That is indeed a very interesting approach to priorities. Thanks for sharing Michal :slightly_smiling_face:

Yes, this priority system would need an additional name for "all", like

runBefore: "all";

But then two of those packages could conflict with each other, and we will be back at numeric priorities…

// default priority
priority: 5;

// high-priority
runBefore: "all";
priority: 1;

// super high-priority!
runBefore: "??";
priority: 0;

To be honest, I’ve always thought that numeric priorities are like Churchill’s quote of Democracies. “The worst system except for all the others” :grinning_face_with_smiling_eyes:

Another thing that came to my mind after reading your proposal was to use derived priorities. Instead of this:

mySecondMiddleware: {
  runBefore: "myFirstMiddleware";
}

A priority could be defined as derived state from another priority, something like this.

const state = {
  priorities: {
    server: {
      myPackage: {
        myFirstMiddleware: 5, // Some priority.
        mySecondMiddleware: ({ state }) =>
          // Make sure it always runs after myFirstMiddleware, even if
          // its priority is changed.
          state.priorities.server.myPackage.myFirstMiddleware + 1,
      },
    },
  },
};

I don’t know how useful that would be in a real-life situation, but it is interesting 🤷🙂