Deck.gl: Map flickers on transition when viewState is stored in Redux

Created on 29 Apr 2020  路  9Comments  路  Source: visgl/deck.gl

Description

My problem: https://imgur.com/a/haBPnKB

When putting my viewState in Redux - and starting any kind of transition, I get a clear flicker, where the map will start moving, then return back to its starting position, before continuing the movement. I sometimes also get a warning like the one below when this happens:

[Violation] 'requestAnimationFrame' handler took 103ms

If this is some kind of a race condition, it is a very consistent one. The violation for requestAnimationFrame only happens sometimes.

Neither of these problems exist when I store the viewState in a "useState"-hook instead of Redux.

Repro Steps

CodeSandbox: https://codesandbox.io/s/inspiring-cohen-li1zy

  • Click the map, and hold the + key for some intense flickering.

Environment (please complete the following information):

  • Framework Version: [deck.gl 8.1.3]
  • Browser Version: [
    Chrome 80.0.3987.149 (Official Build) (64-bit),
    Chrome 80.0.3987.163 (Official Build) (64-bit)
    ]
  • OS: [Manjaro Linux, Windows 10]

Logs

Whenever the onViewStateChange-event fires, I log out the current viewState, and the next one like this:

<DeckGL
  layers={[]}
  viewState={viewState}
  controller={MapController}
  onViewStateChange={e => {
    console.table([viewState, e.viewState])
    setViewState(e.viewState)
  }}
>
  <StaticMap reuseMaps mapStyle={mapStyle} />
</DeckGL>
useState (everything works as expected)

image

Redux (notice the extra row of output)

image

bug

All 9 comments

I think the problem here could stem from how JavaScript (as a turn-based language) decides what to run next.

Essentially what we have here is a race condition that will only happen if "the turn" is given up in between:

  • emitting a new viewState (with a transition)
  • before receiving the viewState it just emitted

Demonstration

Allowing the internal map handlers to run in between emitting a state and before receiving a new one can be accomplished with a setTimeout of 0ms.

export default function WorldMap() {
  const [viewState, setViewState] = useState(initialViewState);
  return (
    <div>
      <DeckGL
        layers={[]}
        viewState={viewState}
        controller={MapController}
        onViewStateChange={e => {
          setTimeout(() => setViewState(e.viewState), 0);
        }}
      >
        <StaticMap
          reuseMaps
          mapboxApiAccessToken={mapboxApiAccessToken}
          mapStyle={mapStyle}
        />
      </DeckGL>
    </div>
  );
}

This completely messes up transitions. Way worse than the example using Redux.

A potential solution

If the map never executes the onViewStateChange handler without first having received the latest viewState, then I believe that should fix the problem.

This will prevent multiple viewState's from being out in the feedback loop at the same time. (which based on the log output in the Redux case seems to be causing the flicker)

Might be related to this issue: https://github.com/reduxjs/react-redux/issues/1298

deck immediately dispatches an onViewStateChange when a new view state with transition is set. This is not an issue in non-React use cases, but React/Redux place a delay in between the update and the render.

One possible solution is to block the destination view state from rendering when the transition is first triggered. This will create a discrepancy between the React props and the camera for 1 frame, i.e. if you have other UI that updates to the longitude/latitude in the Redux store, they will go out of sync with the map during this frame.

Another solution is to add a transitionTo method on the DeckGL component. This will trigger onViewStateChange from the correct first frame.

Okay, so the transitionTo-method would mean that the first event here is never emitted?

image

My first impression based on what you are saying is that the transitionTo-method sounds like the better solution.

You are currently triggering the transition via an update to the Redux store:

-> redux store viewState updates with destination coordinates
-> DeckGL rerenders with new viewState
-> onViewStateChange is called
-> redux store viewState updates with intermediate coordinates
...

In the second proposal, you'll need to call the transitionTo API:
-> call DeckGL's ref.current.transitionTo(viewState)
-> onViewStateChange is called
-> redux store viewState updates with intermediate coordinates

To avoid having to use refs, and to make the style a bit more declarative - what about doing something like:

export default function WorldMap() {
  const [viewState, setViewState] = useState(initialViewState);
  const [transitionTo, setTransitionTo] = useState({})

  useEffect(() => {
    setTransitionTo({
      inTransition: true,
      // only the part of the viewState that is going to change needs to be specified here
      targetViewState: { longitude: 10, latitude: 62, zoom: 9 },
      transitionInterpolator: new LinearInterpolator(),
      durationLeft: 3000, // ms
      // alternatively, a speed could be specified here
    })
  }, [])

  return (
    <div>
      <DeckGL
        layers={[]}
        controller={MapController}

        // This keeps track of "where you are" at all times
        // viewState doesn't know anything about transitions
        viewState={viewState}
        onViewStateChange={e => setViewState(e.viewState)}

        // This keeps track of "where you are headed"
        // Kind of like you can set a destination on your self-driving car, and have it take you there
        // But then you could always change your mind half way to the destination
        transitionTo={transitionTo}
        onTransitionToChange={e => setTransitionTo(e.transitionTo)}

      >
        <StaticMap
          reuseMaps
          mapboxApiAccessToken={mapboxApiAccessToken}
          mapStyle={mapStyle}
        />
      </DeckGL>
    </div>
  );
}

Obviously this would be a rather significant API change - which might not be ideal for a library that is used by a lot of people.

Some things that would have to be figured out with this approach:

What happens if you change the viewState during a transition?

I'm guessing the transition should just continue (from wherever the new viewState was placed). If this is farther away from the target, then the transition might have to speed up.

Should the transition keep track of the time left of the transition rather than total time?

An advantage of this is that the transition-object and current viewState would be enough to describe how to continue the transition, and if it has finished. The transitions would be "stateless". (At least from Reacts perspective)

A problem here is that a feedback loop with a finite delay can mean that the transition takes longer than anticipated, or even cause a race. Instead, storing the absolute timestamp of completion could be more robust. (using performance.now())

// What is used now
{
  transitionDuration: 3000
}
// pros: Easy to understand
// cons: this can't be used to describe intermediate states, forcing Deck.GL to be more stateful

// duration proposal 1:
{
  transitionDurationLeft: 3000
}
// pros: Describes the current state of the transition, allowing Deck.GL to be more stateless
// cons: Can cause data races/could take longer than the specified time

// duration proposal 2:
{
  transitionCompletesAt: performance.now() + 3000
}
// pros: Avoids data race in case of delays (since it is a constant value), describes current state of transition.
// cons: looks a bit more noisy. Can't as easily pause transition.
Should transition be separate from viewState like in this example?

It could make sense to have them be combined as a single object, sharing a single event handler.

I guess an advantage of your first proposal is that it won't really change how the API is used from the outside? Meaning people wouldn't have to change their code. Is this correct?

The trade-off here being the discrepancy between props and camera for one frame as you mentioned.

In the idea above, as well as your second proposal - people would need to modify their code.

I ended up creating this as a workaround: https://github.com/bergkvist/react-context-toolkit

React Context API doesn't seem to have zero delays between a dispatch and a rerender. (in contrast to react-redux), which means the flicker doesn't happen.

We use react context to manage this. Be wary of combining state and state updaters in one consumable hook to avoid constant re-renders.

// ViewportContext.js
import React from 'react';

const ViewportContext = React.createContext();
const SetViewportContext = React.createContext();

/* Wrap your app in this bad boy */
export const ViewportProvider = ({defaultViewport = {}, children}) => {
  const [viewport, setViewport] = useState(defaultViewport);

  return (
    <ViewportContext.Provider value={viewport}>
      <SetViewportContext.Provider value={setViewport}>
        {children}
      </SetViewportContext.Provider>
    </ViewportContext.Provider>
  )
}

/** Read the viewport from anywhere */
export const useViewport = () => {
  const ctx = useContext(ViewportContext);
  if (!ctx) throw Error("Not wrapped in <ViewportProvider />.")
  return ctx;
}

/** Update the viewport from anywhere */
export const useSetViewport = () => {
  const ctx = useContext(SetViewportContext);
  if (!ctx) throw Error("Not wrapped in <ViewportProvider />.")
  return ctx;
}
// attaching to deck
import 

export const MyMap = () => {
  const viewport = useViewport();
  const setViewport = useSetViewport();

  return <Deck 
    viewState={viewport} 
    onViewStateChange={({viewState}) => setViewport(viewState)}
  />
}
Was this page helpful?
0 / 5 - 0 ratings