ore-ui icon indicating copy to clipboard operation
ore-ui copied to clipboard

RFC: Dynamic path for shared facets

Open pirelenito opened this issue 3 years ago • 3 comments

Motivation

Shared facets are defined statically at a global level using the sharedFacet function as a combination of a name and a type. As an example from our documentation:

export const userFacet = sharedFacet<UserFacet>('data.user', {
  username: 'Alex'
  signOut() {},
})

We want to take the existing concept of a name and expand it to be more of a path to look into a big global state object within the game engine.

Thinking about the previous example, the name data.user could be interpreted the as the path to the contents of the user in the object below:

{
  data: {
    user: {}
  }
}

This is really interesting when we consider that we could build a path dinamically in a React component to point to an specific instance of an object. Let's say we have a list of creatures in a game:

{
  entity: {
    hostile: {
      '123': { name: 'creeper' },
      '456': { name: 'piglin' }
    }
  }
}

And we want to subscribe to changes only to the creeper entity. With the current static definition of shared facets this could only be achieved via a dynamicSelector, but then every selector would be notified when any entity changes. So we propose that we remove sharedFacet as a function and instead have our API for subscribing to a shared facet to be completely handled in a hook by taking both the path and the type:

const entityFacet = useSharedFacet<Entity>(['entity', 'hostile', '123'])

Type safety

cc @xaviervia can you add some notes about it?

Global definitions

We could still have a "global definition" of a facet, but this would be instead achieved via a hook:

--- export const userFacet = sharedFacet<UserFacet>('data.user')
+++ export const useUserFacet = useSharedFacet<UserFacet>(['data', 'user'])

Deprecations

This change will remove/deprecate the APIs for:

  • sharedFacet
  • sharedSelector
  • sharedDynamicSelector

In favor of using hooks and useFacetMap or useFacetMemo to achieve the same functionality.

It is important to assess if there are any risks on removing these APIs. And although we would be removing them from the open-source packages, they could still be kept implemented internally by consumers of our packages (if we need to provide any backwards compatibility).

Implementation considerations

When requesting a shared facet to the engine we need to make sure we only make one request for a given "path", even if multiple places are subscribing to it. This is currently achieved by having a single instance for each shared facet via memoisation in the sharedFacet definition.

This behavior would need to be re-implemented as part of the useSharedFacet hook.

pirelenito avatar Jun 02 '22 12:06 pirelenito

Hi all 👋🏼, reading through this proposal I see a number of technical similarities with GraphQL and specifically Relay's client side implementation when it comes to the singleton hook concept.

By separating queries into modular parts and essentially creating a chain of requests, each part or request can be optimised independently in the same way that resolvers do within GraphQL.

Another advantage to breaking these queries down is that this inherently creates creates nodes of parent - child relationships where information can be passed from one node to another within a given context.

In the example above, we may want to query some data about the user but the user data may be associated with other information such as the users inventory or world position which may be expensive to calculate or retrieve. The REST approach would be to create separate requests for the user, user inventory and user position information passing some form of identifier along with the query such as a username or ID.

The graph based approach for the example above would be to have a number of functions associated with a specific query and each function would be run on demand based on the information it had received from the output of the previous function.

In the client side library Relay by Facebook a key optimisation technique that they use is to batch many small requests into lesser larger requests once a React render cycle has complete. The returned information is then redistributed back to components once the requests have complete. An advantage to this approach is that many components can request parts of the user information for example, and the server can then make a single call to retrieve that information.

I hope that this information aligns with the initial proposal and that it can provide some useful insights on some technical considerations.

example

  • data
    • user(username) => { userId }
      • name(userId) => string
      • position(userId) => number
      • inventory(userId) => Inventory
    • Inventory => { inventoryId }
      • count(inventoryId) => number
      • full(inventoryId) => boolean

const userInventoryFull = sharedFacet<UserFacet>(['user', 'inventory', 'full'] { username: 'Alex' })

+-------+    +-------+    +-----------+       +---------------+ +-----------------+
| Data  |    | User  |    | Inventory |       | inventoryFull | | inventoryCount  |
+-------+    +-------+    +-----------+       +---------------+ +-----------------+
    |            |              |                     |                  |
    | "Alex"     |              |                     |                  |
    |----------->|              |                     |                  |
    |            |              |                     |                  |
    |            | userId       |                     |                  |
    |            |------------->|                     |                  |
    |            |              |                     |                  |
    |            |              | inventoryId         |                  |
    |            |              |-------------------->|                  |
    |            |              |                     |                  |
    |            |            { inventoryFull: true } |                  |
    |<------------------------------------------------|                  |
    |            |              |                     |                  |

ja-ni avatar Jun 17 '22 08:06 ja-ni

Regarding the type safety, we will need to add a mechanism for information the useSharedFacet hook about the types that are available in the storage. The proposal is to create a factory, something like:

type Store = {
  users: User[]
  currentUser: number
}

const useSharedFacet = createUseSharedFacet<Store>()

...where createUseSharedFacet doesn't perform any runtime operations, it just allows the useSharedFacet returned to have the type in scope (in this case, Store) so that the returned facets have the right type:

const usersFacet = useSharedFacet(['users']) // type will be Facet<User[]>

xaviervia avatar Jul 05 '22 11:07 xaviervia

@ja-ni , that's a good point. The issue here is that the calls to useSharedFacet are not requests: we don't get a 1:1 response for each time we run that. Instead, useSharedFacet sets up a subscription to data from the store, and the facet returned will be updated each time the data changes. The Relay model does not apply in the same manner.

It is possible to imagine that we would benefit from a more expressive query language to setup the shared facets, but given setting up facets is so much cheaper than doing requests (since we just read data already in memory instead of retrieving remote data through HTTP via a network with latency) it's probably not worth the effort.

xaviervia avatar Jul 05 '22 11:07 xaviervia

Closing this issue as we are exploring other alternatives.

pirelenito avatar Feb 07 '24 13:02 pirelenito