graphistry / falcor

Graphistry forks of the FalcorJS family of projects in a lerna-powered mono-repo.
23 stars 3 forks source link

Connect Falcor model to react #12

Closed jameslaneconkling closed 7 years ago

jameslaneconkling commented 7 years ago

This is not really an issue at all, so I can migrate elsewhere if you think there's a better place to discuss.

After trying out a bunch of different patterns for integrating Falcor into a React app (in my case backed by a Redux store, though that wasn't a hard requirement), I came up with the following naive approach, based in large part on some of the approaches you take in falcor-react-redux/falcor-react-schema.

// initialize model with change$ stream
const change$ = new BehaviorSubject();

const model = new Model({
  source: new LocalDatasource(graph),
  onChange: () => change$.next()
})
  .batch()
  .boxValues()
  .treatErrorsAsValues();

// create connectFalcor HOC
const connectFalcor = propsToGraphStream => BaseComponent =>
  hoistStatics(compose(
    setDisplayName(wrapDisplayName(BaseComponent, 'Falcor')),
    getContext({ falcor: PropTypes.object }),
    mapPropsStream(props$ => props$
      .merge(change$.withLatestFrom(
        props$,
        (_, props) => props
      ))
      .switchMap(
        ({ falcor, ...props }) => propsToGraphStream(props, falcor),
        ({ falcor, ...props }, falcorResponse) => ({ ...props, falcorResponse })
      )
      .auditTime(0, animationFrame)
    )
  ))(BaseComponent);

// connect an example todo component
const ConnectedTodoList = compose(
  // optionally connect to Redux store, though props needed for falcor request
  // could just as easily be passed as props to the connected HOC
  connectRedux(state => ({
    range: state.todos.range
  })),
  connectFalcor(({ range }, falcor) =>
    Observable.from(falcor.get(
      ['todos', range, ['title', 'status', 'createdAt']],
      ['todos', 'length']
    ).progressively())
  ),
  // optionally deserialize the falcor response
  mapProps(({ range, falcorResponse: jsonEnvelope }) => ({
    todosLength: jsonEnvelope.json.todos.length,
    todos: jsonEnvelope.json.todos.slice(range.from, range.to).map(deserializeTodo)
  }))
)(TodoList);

So far, everything works pretty well for the few use cases I've come across so far. Curious if you have any feedback, and whether or not this tracks at all with what you are trying to do w/ falcor-react-redux/falcor-react-schema.

There are some notable potential performance issues where the wrapped component will be updated unnecessarily:

Anyhow, thought I'd share. And thanks for open sourcing the work you've done, which all of the above is based on.

trxcllnt commented 7 years ago

@jameslaneconkling you're on the right track. I'm going to spend some time finishing the port of the Container HOC to falcor-react-schema this weekend, as its whole reason for existing is to automate the Falcor <-> React integration in an efficient manner (while handling errors, loading states, and streaming data).

This has driven the development of most of the new work/projects in the mono-repo, and (especially with streaming data/partial renders) some of the problems we've encountered are subtle and can be difficult to track down, so I'll try to summarize them below.

So the premise is we want to use a root Falcor Model as a single source of truth, much in the same way as the Redux store. Better yet, we can use the Falcor Model to wholesale replace the Redux store, as all operations on the Model can be communicated and sync'd in real-time with programs running elsewhere. This most commonly means a server, but here @graphistry we have different front-end applications synchronizing parts of their Falcor cache over PostMessage, as well as with our servers!

We also want to have a compositional component API, which is more in-line with React's philosophy, and makes it easier to do things like port to React Native. We want to declare a public Falcor API once, decorate view components with the parts of the remote Falcor API they're interested in, and compose the app and its logic as a function of local + remote state. I drew heavy inspiration from Redux, Relay, and Om/Next, and I believe what we have so far reflects this.

Reacting to changes from the root, and the new onChangesCompleted handler

As you've noted, we need to listen for changes on the root model and do a React render from the root. In addition to the regular onChange handler, we added another onChangesCompleted handler, which runs after all outstanding cache operations (async, or otherwise) have either completed or been canceled, and the cache has been pruned from the LRU if a max cache size has been allocated.

We combine both onChange and onChangesCompleted into the changes stream, which helps ensure the component tree is 100% synchronized with the cache state after the last operation is completed. In the case the cache was pruned, components might need to re-request for data we kicked out, though the LRU makes that unlikely.

Side note: Falcor Models all share some state, like the cache, via an instance of ModelRoot, which is accessible via a Model's private _root key. The ModelRoot is created when you call the Model constructor, but any Models you create off that one will share the _root instance.

For convenience, the ModelRoot holds a reference to the "top-level" Model it was created by, so it can be used as the this context when invoking the onChange and onChangesCompleted handlers. This makes it easier to convert the change handlers to an Observable, but options added via boxValues(), treatErrorsAsValues() etc. won't be on that instance, since they create copies of the Model instead of mutating. If you provide a cache to the Model constructor, the onChange handler will be called synchronously from the Model constructor, and if the onChange handler tries to access the Model instance via closure state, the model variable will still be undefined.

Adding all the configuration to the first call to the Model constructor is the best way to mitigate all these problems, though setting model._root.topLevelModel to the most recent Model returned from the chain of configuration functions also works, except for the first call to onChange.

Combining local and remote state to retrieve data from the cache

The withFragment container HOC lets you define a fragment function to build the query for a component as an async recursive reduction over the most recent data from the cache and the component's props. When the withFragment HOC gets updated by React, the HOC will immediately try to query the cache for the latest Falcor state.

To do this, it calls your fragment function with the data it got from Falcor on the last update, and the current props, and your fragment function is expected to return a query to run on the cache. This way, we can use local client state (like viewport width/height) to build the Falcor request. When the HOC gets a response back from Falcor, it will call your fragment function again with the response to build a new query. This cycle is repeated until the query stabilizes.

This uses the falcor-query-syntax project, and is advantageous for two reasons:

  1. We can compose component queries declaratively, which makes it easier to write (and refactor) the relationship between our components and the data they get remotely, similar to React's propTypes validation.

  2. We can use the intermediate AST created by the falcor-query-syntax parser to dramatically speed up the Falcor cache search. The falcor-query-syntax AST computes hash codes for each branch of the query that are unique to the subtrees under it. When we combine this with the new recycleJSON Model option, we can compare the AST's hash code and the cache's internal version numbers against the previous JSON result. If the version + hash code of the previous JSON match the cache's version + query hash code, we can bail on this subtree of the cache search and return the previous results to React.

The most important thing for cache search optimization is creating fully connected, stable queries. Wherever possible, the parent components should try to compose their children's queries, as it allows Falcor to make the minimum number of requests up front, and will result in the fewest react re-renders. This also makes it possible to populate the cache server-side with all the state needed for the current screen, so when the JS page is loaded, Falcor doesn't have to make any additional network requests on startup (you can see this in action by opening a netflix genre page and inspecting netflix.falkorCache in the console).

The significance of memoizing the cache search can't be overstated:

   @netflix/falcor getJSON - 100 paths from cache x 18,701 ops/sec ±0.54% (93 runs sampled) (0.3% of 1 frame @ 60FPS)
@graphistry/falcor getJSON - 100 paths from cache x 27,284 ops/sec ±2.57% (89 runs sampled) (0.24% of 1 frame @ 60FPS)
@graphistry/falcor getJSON - 100 paths from cache x 4,611,657 ops/sec ±1.12% (92 runs sampled) (0% of 1 frame @ 60FPS) (recycled JSON)

We also add some extra metadata that indicates whether we're waiting on data to finish streaming in to Falcor, which is all the info needed to optimize component updates in shouldComponentUpdate. React virtualizes/batches the view updates, and Falcor virtualizes/batches the model updates! 💯

Here's a pseudo-code example of a List and ListItem using the withFragment HOC:

function List({ items = [] }) {
    return (
        <ul>
        {items.map((item, index) => (
            <ListItem falcorData={item} key={`${index}: ${item.id}`}/>
        ))}
        </ul>
    );
}

function ListItem({ id, value }) {
    return (
        <li>{id}: {value}</li>
    );
}

List = withFragment({
    renderLoading: true,
    fragment: (falcorData, props) => {

        const { items = [] } = falcorData;
        const { scrollTop = 0, listHeight = 0, itemHeight = 30 } = props;
        const itemsInView = Math.max(0, Math.floor(listHeight / itemHeight));

        const startIndex = Math.floor(scrollTop / itemHeight);
        const endIndex = Math.min(items.length, startIndex + itemsInView);

        return `{
            items: {
                length, [${startIndex}...${endIndex}]: ${
                    // instead of using range syntax, we could iterate through the `items` Array,
                    // and build a more comprehensive query per list-item, e.g. `ListItem.fragment(items[idx])`
                    // But since ListItem doesn't use the falcorData argument to create its query,
                    // we can build a smaller fragment query this way
                    ListItem.fragment()
                }
            }
        }`;
    }
})(List);

ListItem = withFragment({
    renderLoading: true,
    fragment: (falcorData, props) => {
        return `{ id, value }`;
    }
})(ListItem);

// In reality there is a `<Provider/>` here that takes the root model as a prop, and makes it available via `context`
ReactDOM.render(
    <List listHeight={200}/>
    document.getElementById('root-div')
);

The withFragment HOC plucks its Falcor Model from React's context, deref's it based on the falcorData property (if provided), and executes the fragment query against the deref'd Model. Children of a withFragment component get the most recently deref'd Model in their contexts, so the whole component tree can be defined decoupled from their location in the falcor graph.

arguably the request shouldn't be made in the first place if the path is unchanged

If we don't use props to create a fragment query, we could get away with diff'ing the current value of model.getVersion() against the most recent component update. In practice we've found this too restrictive, as the alternative is to write all the UI state into the Falcor cache.

trxcllnt commented 7 years ago

@jameslaneconkling I've added some initial tests that should make this pattern more obvious. I'm still validating the port/new features in falcor-react-schema are really working, but these should be able to serve as inspiration until it's finished: https://github.com/graphistry/falcor/blob/master/packages/falcor-react-schema/src/components/__tests__/test-containers.js

jameslaneconkling commented 7 years ago

Better yet, we can use the Falcor Model to wholesale replace the Redux store, as all operations on the Model can be communicated and sync'd in real-time with programs running elsewhere.

Ha, yeah. I started working on integrating Falcor w/ Redux thinking that I definitely didn't want to use Falcor to handle view state, but so much of my app state ended up in Falcor anyway that I've started thinking that there's actually a lot to potentially gain from dropping Redux altogether and using Falcor as the single store (...maybe). For many conventional cases (or at least mine), I'd imagine you'd want the ability to not sync the app view state in the Falcor graph with your server, keeping it instead in memory or maybe the browser's local storage. But I could imagine a falcor datasource that has different handlers for different branches of the graph, e.g. persisting anything starting w/ key data to the server, while persisting anything starting w/ key local to memory (...maybe).

Add to that the composability you are going for w/ withFragment, and you basically have Relay with (as far as I can tell) a much leaner API.

Thanks for adding tests. I'll take try it out for a test spin in a bit.