Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

What should we do with the warning "Cannot update a component from inside the function body of a different component." #7

Closed
dai-shi opened this issue Oct 24, 2019 · 46 comments · Fixed by #13

Comments

@dai-shi
Copy link
Owner

dai-shi commented Oct 24, 2019

Currently, our Provider invokes subscribed components listeners so that they can force update.
The reason why we need it is that we use the undocumented changedBits = 0 in Context API.

// we call listeners in render intentionally.
// listeners are not technically pure, but
// otherwise we can't get benefits from concurrent mode.
// we make sure to work with double or more invocation of listeners.
listeners.forEach((listener) => {
listener(value);
});

facebook/react#17099 will warn this usage as it does not know about force update.

That's fair. But, unless there's another recommended way, we would need to keep our current approach.

Nobody will use this library if React shows the warning all the time. What should we do?

(Not only this library but also react-tracked and reactive-react-redux.)

@JoviDeCroock
Copy link
Contributor

Moving this to ‘useEffect’ might possibly circumvent this and make it more concurrent-friendly. This will have a small perf tradeoff I think but this could maybe be caught by wrapping in batchUpdates.

@dai-shi
Copy link
Owner Author

dai-shi commented Oct 24, 2019

Interestingly, I didn't think about useEffect... I thought there would be a tearing issue with useEffect, but not for this case because we have only one useEffect in the parent.

I did use useLayoutEffect before obviously, which works perfectly but it de-opts to sync mode and we don't get benefit from concurrent mode.


I tried https://github.com/dai-shi/will-this-react-global-state-work-in-concurrent-mode checking again.

In summary, here's what I understand so far:

in render: all checks pass
in useLayoutEffect: check3 fails
in useEffect: check4 fails

check4 is also failing with use-subscription, so I might be doing something wrong. It would be so nice if we could use useEffect. Either, the check4 is wrong or there is another workaround.


@JoviDeCroock Thanks for the hint! and questions are welcome.

@JoviDeCroock
Copy link
Contributor

JoviDeCroock commented Oct 25, 2019

Hmm, I’ll have to double-check for myself. The only other wat I’m aware of would be a custom event emitter. Much like a proxy but a bit more manual book keeping, I’m making a PoC off that since that would increase perf even further by calling every entry max. once.

That being said it should be concurrent since effects registered in P1 should be disposed on interrupt. I’ll check what the failing test is when I can open my laptop.

EDIT: I did this, https://github.com/JoviDeCroock/hooked-form/pull/61/files#diff-b863cdcf29e9f6f006d596a9ac293ed4 seems to work well in concurrent too perf seems to improve too since we can't hit a callback twice anymore

@dai-shi
Copy link
Owner Author

dai-shi commented Oct 27, 2019

Hi @JoviDeCroock
Thanks for your trial. Good to know that performance isn't an issue. My concern is the "check4" of my checking tool. We could wait and see how the discussion in facebook/react#16396 (comment) goes.

My understanding is that the use of useEffect could lead to some kind of inconsistency (tearing) because it's a passive effect (other code run before that).

we can't hit a callback twice anymore

I'm not sure if I understand this part.

@eddyw
Copy link

eddyw commented Oct 28, 2019

Hi, trying to solve a similar issue I came across this lib. I thought it's a good idea but I honestly do hate those 3 lines of code xD

Here is a quick idea:

const listeners = new Set()
const context = React.createContext(defaultValue, (prev, next) => {
  if (prev === next || !listeners.size) return 0
  listeners.forEach(l => l(next))
  return 0
})

Now, using useLayoutEffect is also not a good idea. First, when doing SSR, you'll see a warning. Secondly, we can learn something from useSubscription (written by React team)
https://github.com/facebook/react/blob/master/packages/use-subscription/src/useSubscription.js
and that's that subscriptions must not happen sync and instead be passive. See the source code comments' explanations.

@dai-shi
Copy link
Owner Author

dai-shi commented Oct 28, 2019

@eddyw Hey, thanks for coming! Do you "hate" them because they are in render?
I haven't thought about invoking listeners in calculateChangedBits. I wonder if this eliminates the warning and if it's recommended by the react team... Side effects in calculateChangedBits don't sound natural...

I'm aware of useSubscription to some extent. It's failing my "check4" too. If it's reasonable, it's easy and we can simply move the three lines into useEffect.

@eddyw
Copy link

eddyw commented Oct 28, 2019

@dai-shi hm, yeah, it just doesn't feel "right" doing so in render. For instance, if the provider is a child of a container that manages its own local state, every render will also trigger calling all listeners on the provider which may be unnecessary. calculateChangeBits is not widely documented, from what we know, we return a bitmask that can tell which consumers should update (if using unstable_bits ... something which isn't supported in useContext .. at least yet?). So my point is, there is no rule written in stone that prevents us from doing so and it seems like that is the only reliable way to know when provider has received a new value and if it should propagate this change. So, here is an idea:

const context = React.createContext({ value, listeners }, (prev, next) => {
   if (!next.listeners.length) return 0
   if (prev === next && prev.listeners === next.listeners) return 0
   next.listeners.forEach(l => l(next))
   return 0
})

A few points:

  1. The problem with context is that we can't use a hook to subscribe to a certain slice of state. The problem is not that context doesn't know when to propagate this change. So using calculateChangedBits we can know when value/state has changed and only thing left is to (reinvent the wheel of) manage subscriptions.
  2. There are several issues with using a single Set mutable object to manage subscriptions. As commented in code, it's shared, so it's not ideal. Another issue is that, I feel we could potentially be running on the same issue Redux had before with subscriptions to the store or removing subscriptions while listeners are called, something like (speudo-code):
listeners = [ a, b, c ]
...
store.subscribe(() => { //  <<< listener 'a'
  removeListener(b)
  addListener(d)
  // we end up with: listeners = [a, c, d]
  // while listener 'a' has been called
  // next listener called should be .. ???
})

What Redux does now is listeners.slice() to ensure those exact listeners are called. The same idea could be applied in calculateChangedBits where prev.listeners could be different from next.listeners and it'd be easier to debug if that's the reason of certain unwanted re-renders.

Finally, the order in which listeners are called is somewhat important. React Redux does create a kind of tree of listeners every time connect is used to ensure a parent component is updated before its children. It'd look kind of like (speudo-code, assuming all this components are connected to a store):

[
  listener-a, // <connected(Todo.Header)>
  listener-b, // <connected(Todo)>
  listener-b.listeners  [ // connected children
       listener-c, // <connected(TodoItem) id=1 >
       listener-d, // <connected(TodoItem) id=1 >
   ]
]

However, this is not possible to do with hooks, that's why react-redux warns about it if you opt to use hooks instead of connect. Maybe this is the reason of that warning? I mean, suppose you're calling listener-d which is somewhere deep in the tree while in fact react is rendering the container where the provider is. A quick dirty idea led me to try listeners.reverse().forEach(...) and it worked because the order in which those were subscribed was actually inverted but that's far from a solution and more a hack for a very specific scenario.

At least these are the issues I'm having with my implementation. I hope this helps .. somehow xD

@dai-shi
Copy link
Owner Author

dai-shi commented Oct 28, 2019

@eddyw Hi, thanks for your detailed explanation.

calculateChangeBits is not widely documented,

True. Both calculateChangedBits and observedBits are unstable. Using observedBits in useContext raises a warning and I needed an undesired workaround in another lib of mine. Luckily, we don't have any warning for calculateChangedBits yet.

there is no rule written in stone that prevents us from doing so

Yeah, but the rule can be changed. As we've been seeing this and the useContext warning.
What I'm comfortable with is this special use case of calculateChangedBits = () => 0.
If this is disabled in the future, we could move to the readContext hack. (I'm not very familiar but that's something react-sweet-state does.)

const context = React.createContext({ value, listeners }, (prev, next) => {

You know what. This is very interesting to me. I haven't thought about passing listeners in the context value. This seems to do the hack.
I would seriously consider this if other means are all disabled.

So using calculateChangedBits we can know when value/state

Yeah, but again I'm not sure how reliable this is in the future.

on the same issue Redux had before with subscriptions to the store or removing subscriptions while listeners are called

I'm not sure if I understand this part. We never remove listeners in the callback.

Finally, the order in which listeners are called is somewhat important.

I suppose this is incorrect. React Redux now uses batchedUpdates, so the order of listeners is not important. In our case, we call listeners in render now (or effect in the past) which are basically batched. We still have the stale props issue though.

the issues I'm having with my implementation.

So, what are you trying in your implementation?

@dai-shi
Copy link
Owner Author

dai-shi commented Oct 29, 2019

I haven't thought about passing listeners in the context value.

Yet, I had thought about passing subscribe in the context value, like I do in react-tracked.
The reason why I put the bare value in this lib is that it would allow devtools show the real context value. I'm not sure if devtools show the context value though.

@eddyw
Copy link

eddyw commented Oct 29, 2019

@dai-shi it doesn't have anything to do with batching updates and more with stale props and zombie children explained in react-redux while using hooks:

Starting with version 5, React Redux has attempted to guarantee that consistency with ownProps. In version 7, that is implemented using a custom Subscription class internally in connect(), which forms a nested hierarchy. This ensures that connected components lower in the tree will only receive store update notifications once the nearest connected ancestor has been updated. However, this relies on each connect() instance overriding part of the internal React context, supplying its own unique Subscription instance to form that nesting, and rendering the <ReactReduxContext.Provider> with that new context value.

https://github.com/reduxjs/react-redux/blob/master/src/components/connectAdvanced.js#L394-L404

This is to ensure that listeners are called in a specific order, first the ones at the top, then the once nested inside a connected child component. However, as they mention in the docs, this seems not to be possible using just useContext because it cannot inject that overwritten context value to a provider just above the component that's using the context to form that nested hierarchy tree.

So, what are you trying in your implementation?

It's written around useReducer. So the provider is created as { state, dispatch }. That's more or less the core. However, I'm running into the same issues when using useContext with not being able to use a selector function to update only when certain values have changed. React does a good job already propagating these changes, so my primary issue right now is with the subscriptions (zombie children and stale props).

The reason why I put the bare value in this lib is that it would allow devtools show the real context value. I'm not sure if devtools show the context value though.

This is probably off-topic 🙈
Redux dev tools has an API to allow using its devtools outside of a Redux store, so I'm not worrying about that, I have a tiny wrapper around it that automatically connects it to devtools and I'm able to inspect values and dispatch actions there (which map to dispatch of useReducer):
https://medium.com/@zalmoxis/redux-devtools-without-redux-or-how-to-have-a-predictable-state-with-any-architecture-61c5f5a7716f

@dai-shi
Copy link
Owner Author

dai-shi commented Oct 29, 2019

OK, if you have Redux background. You might be interested in this thread.

stale props

My understanding is that stale props issue with hooks can't be solved in userland. (I wrote it in README).
I'm mostly convinced by "RFC: Context selectors" and the discussion in react-redux.
But, I've once tried a heuristic approach to sort listeners by render order. (dai-shi/reactive-react-redux#22)
I gave it up because I knew my approach is not performant.
Lately, zustand tried and did a similar approach. pmndrs/zustand#65
I don't fully understand the details, but there would be a possibility of a heuristic approach to solve the stale props issue.
That would be a good challenge and worth trying. (but yeah this is also the off-topic of this issue.)

so my primary issue right now is with the subscriptions (zombie children and stale props).

You may or may not be interested in my other projects: this and that.
Their useTrackedState doesn't have the stale props issue, because it doesn't "select" a state. It doesn't require listeners to be ordered. Just batched updates work.

Redux dev tools has an API to allow using its devtools outside of a Redux store

That's interesting.

I actually meant React devtools, but still in that case it will show state but maybe not context.
So, putting some other values in context shouldn't be a big problem anyway.

@eddyw
Copy link

eddyw commented Oct 30, 2019

@dai-shi useImperativeHandle! 🙈

So trying to solve a completely different problem, I thought about this issue. Technically, there is an official way to expose instance methods to a parent using refs with useImperativeHandle hook.

This is kind of the idea: https://codesandbox.io/s/dazzling-euler-0kbce
Check hooks.js which is copy/paste of use-context-selector with some changes.
Basically, this is the most important change:

  const ref = React.useRef();
  const subscription = React.useRef();
  ...
  React.useImperativeHandle(ref, () => ({
    f: selector,
    v: value,
    s: selected
  }));
  React.useImperativeHandle(subscription, () => listener, [listener]);
  React.useEffect(() => () => listeners.delete(subscription), [listeners]); // <<< handle unsubscribe

   if (!listeners.has(subscription)) listeners.add(subscription); // <<< sync

So listeners are actually a collection of refs which point to a callback (forceUpdate). We subscribe in the render phase, however, that doesn't ensure that the callback will be available (render was interrupted / paused / or just on render phase). This handles the case:

    listeners.forEach(listener => {
      if (!listener.current) {
        console.warn("Render phase! Interrupted render! ... or unmounting.");
        return;
      }
      listener.current(value);
    });

Since refs are not set on the render phase (first render phase) the ref will be null however a listener will be added. So, to solve the issue we basically need to "wait" for this listener.current to become not null to be able to force an update. Otherwise, we'd get that warning. Does it make sense? 🙈 I think that can somehow easily be solved 🤔

FYI, I tested the code in codesandbox on will-this-react-global-state-work-in-concurrent-mode and it still passes:

    ✓ check1: updated properly (8331ms)
    ✓ check2: no tearing during update (2ms)
    ✓ check3: ability to interrupt render (1ms)
    ✓ check4: proper update after interrupt (1183ms)

😅

@dai-shi
Copy link
Owner Author

dai-shi commented Oct 30, 2019

@eddyw I haven't thought about useImperativeHandle.
I'm not sure if I understand the point, but do you mean that we can eliminate the warning message with this approach?

@eddyw
Copy link

eddyw commented Oct 31, 2019

The point of useImperativeHandle is to customize and expose instance methods to a parent through refs. So it won't attach the ref if it's in the "render phase". useImperativeHandle seems to happen at the same time useLayoutEffect, it's sync but both don't happen during "render phase". This is a simple test I made, it basically just logs once a ref is attached and when a useLayoutEffect is run:

[Imperative] Rendering! 1
[Imperative] Rendering! 2
[Imperative] Rendering! 3
[Imperative] Rendering! 4
[Imperative] useLayoutEffect 1
[Imperative] useImperativeHandle 1 // refs attached here
[Imperative] useLayoutEffect 2
[Imperative] useImperativeHandle 2 // refs attached here
[Imperative] useLayoutEffect 4
[Imperative] useImperativeHandle 4 // refs attached here
[Imperative] useLayoutEffect 3
[Imperative] useImperativeHandle 3 // refs attached here

Since we're using useLayoutEffect, we technically shouldn't run into this issue if the subscription happens there. However, I don't know what the case will be if render is interrupted / paused with useLayoutEffect. Since useImperativeHandle was made to expose instance methods to a parent once "it's ready", I assume this will be null if interrupted / paused. Otherwise, you wouldn't be able to do:

React.useImperativeHandle(ref, () => ({ setState }))

which is perfectly legal.

@dai-shi
Copy link
Owner Author

dai-shi commented Oct 31, 2019

So it won't attach the ref if it's in the "render phase".

Hmm, as I understand the warning will be shown when we invoke listeners in provider, not when we subscribe listeners in children. I'm just guessing though.

@eddyw
Copy link

eddyw commented Oct 31, 2019

https://github.com/facebook/react/pull/17099/files#diff-63c455475b8566c10993b2daa2c3211bR2666
warn About "Render Phase" Updates In DEV 🙈

Hmm, as I understand the warning will be shown when we invoke listeners in provider, not when we subscribe listeners in children. I'm just guessing though.

Ok, so maybe we understand the problem in a different way 😛
Technically, the first time provider renders, there are no listeners (you can do console.log(listeners.size) // 0). The listeners are added with useLayoutEffect that we could think of as componentDidMount. So, useLayoutEffect is actually async, only the updates you make within are scheduled to be sync. The point is, the listeners are not added in the "first" render phase but after a complete first render phase which means, calling listeners in the provider render won't cause this issue because the listener is not added in the "render phase".

@dai-shi
Copy link
Owner Author

dai-shi commented Oct 31, 2019

warn About "Render Phase" Updates In DEV

what I understand is it warns the invocation of dispatch (of useReducer). 🤔

@JoviDeCroock
Copy link
Contributor

Yes the above should still throw, the way I found that it doesn't is if you call a function that has been bound in render like: https://github.com/JoviDeCroock/hooked-form/blob/master/src/useContextEmitter.ts#L8

@dai-shi
Copy link
Owner Author

dai-shi commented Oct 31, 2019

Yeah, useEffect solves my-understanding-of-the warning. My concern is "check4" which I would call parent/child tearing. Probably it would not happen in the hooked-form use case.

(I am still interested in how hooked-form is implemented. I could learn real use cases. It would be nice to continue the discussion in #8.)

@dai-shi
Copy link
Owner Author

dai-shi commented Nov 2, 2019

const context = React.createContext({ value, listeners }, (prev, next) => {

You know what. This is very interesting to me. I haven't thought about passing listeners in the context value. This seems to do the hack.
I would seriously consider this if other means are all disabled.

Turns out it doesn't solve the issue with this hack. 😕

Now, what's left:

  • useLayoutEffect and give up getting benefit from CM
  • useEffect and somehow solve parent/child tearing issue (no idea yet)
  • completely move away from context and emulate context behavior with subscriptions with versioning? (too much work, not sure if it will really be CM-compatible and get its benefit)

@dai-shi
Copy link
Owner Author

dai-shi commented Nov 2, 2019

The third option will be something like a completely different library. The second one may sound good, but it's potentially doing something different. I'd go with the first option with opt-in (the current) update-in-render mode for production.

@dai-shi
Copy link
Owner Author

dai-shi commented Nov 2, 2019

Just in case, let me ping @acdlite and see if he can give us an advice.
Andrew, you don't need to read the whole thread, the question is rather simple: We use forceUpdate,

const [, forceUpdate] = React.useReducer(c => c + 1, 0);

and invoke it from a different component.
Is it not recommended in CM? I assume facebook/react#17099 warns this usage. (Well, I actually tried your branch.)

@eddyw
Copy link

eddyw commented Nov 2, 2019

Hm, ok. So yeah, I understood that the warning may be happening if child is in render phase while the action is dispatched 🤦‍♂

useEffect and somehow solve parent/child tearing issue (no idea yet)

I had an idea, I did:

  const [prevValue, setPrevValue] = React.useState(value);

  React.useEffect(() => {
    listeners.forEach((listener) => {
      listener(value);
    });
    setPrevValue(value);
  }, [value]);

  return React.createElement(OrigProvider, { value: prevValue }, children);

Since Main is direct child of the provider, just by providing a new value will cause the provider children to re-render, so Main will actually get the current value not from listener(value) but from re-rendering. So I thought maybe delaying setting the value on the provider until we updated subscribed once first would do the trick. Turn out, this works ... sometimes. Out of 10 tests, it passed 7 times which makes me wonder if this is a bug in async rendering (experimental). I tried to follow a different approach, instead of forceUpdate I directly set the current value:

export const useContextSelector = (context, selector, id) => {
  const listeners = context[CONTEXT_LISTENERS];
  if (!listeners) {
    if (process.env.NODE_ENV !== 'production') {
      throw new Error('useContextSelector requires special context');
    } else {
      throw new Error();
    }
  }

  const initialValue = React.useContext(context);
  const [selectedValue, setSelectedValue] = React.useState(() => selector(initialValue));
  const selectorRef = React.useRef(null);

  React.useImperativeHandle(selectorRef, () => selector);

  React.useEffect(() => {
    const callback = (nextValue) => {
      console.log('[%s] Calling setSelectedValue', id, nextValue);
      setSelectedValue((prevSelectedValue) => {
        try {
          const nextSelectedValue = selectorRef.current(nextValue);
          if (nextSelectedValue === prevSelectedValue) return prevSelectedValue;

          console.log('[%s] Setting this value:', id, nextSelectedValue, prevSelectedValue);
          return nextSelectedValue;
        } catch (e) {
          console.warn(e);
        }
        return prevSelectedValue;
      });
    };

    listeners.add(callback);

    return () => {
      listeners.delete(callback);
    };
  }, [listeners]);

  return selectedValue;
};

So useContextSelector received a third argument which is an id. This is just to track updates. It logs when the callback is called and when it should call setSelectedValue but apparently this doesn't happen .. sometimes. It's failing that test4 about ~3 out 10 times 🤔

@eddyw
Copy link

eddyw commented Nov 2, 2019

Ok, this is what I did that worked:
This gave me an idea -> https://reactjs.org/docs/concurrent-mode-patterns.html#baking-transitions-into-the-design-system

  React.useEffect(() => {
    listeners.forEach((listener) => {
      listener(value);
    });
  }, [value]);

And:

const [startTransition] = React.useTransition({ timeoutMs: 1 });
...
startTransition(() => forceUpdate());

However, test3 will fail:

    Expected: < 300
    Received:   580.3

In async mode, it seems like there is a kind of race condition if you call forceUpdate or setSelectedValue while one of them is still running, so the next one seems to be stopped. Not sure if this is a bug or expected behavior but useTransition seems to .. solve the problem?

@dai-shi
Copy link
Owner Author

dai-shi commented Nov 2, 2019

I think it’s the behavior that I’m checking with check4. I haven’t tried if useTransition solves it.

@dai-shi
Copy link
Owner Author

dai-shi commented Nov 3, 2019

I tried useTransition, but it seems to me that it doesn't improve anything.
Honestly, I really want to avoid using useEffect which is something really different.
(Surely, useState + useEffect solves the warning, but it is so delayed. It will tear if you use the normal context and this special context.)

@eddyw
Copy link

eddyw commented Nov 3, 2019

I mean, keeping as is, just use useEffect for listeners and wrap forceUpdate in a transition. Did you set timeoutMs? If it's 0 it has no effect, so I set to 1. It's faster than sync updates but slower than 300ms (took about 500ms). I'll try to put it on a codesandbox once I'm home.

@eddyw
Copy link

eddyw commented Nov 3, 2019

Also, I thought that maybe this will make more sense:

React.useEffect(() => {
    startTransition(() => {
      listeners.forEach((listener) => {
        listener(value);
      });
    });
  }, [value]);

According to docs, I understand that everything within the transition will make all the updates work like if they were a separe branch. So after the timeout, all components ""should"" technically display the same value. However, I'm away from my laptop to try this right now. I'll see how this works later.

@dai-shi
Copy link
Owner Author

dai-shi commented Nov 3, 2019

Meanwhile, let me explain why I don't want to use useState + useEffect.

const Ctx = createContext(null);

const Children = ({ count }) => {
  const value = useContext(Ctx);
  if (count !== value.count) {
    console.log('tear!!!');
  }
  return <div>{count}, {value.count}</div>;
};

const Parent = () => {
  const [count, setCount] = useState(1);
  return (
    <Ctx.Provider value={{ count }}>
      <button onClick={() => setCount(c => c + 1)}>Click me</button>
      <Children count={count} />
    </Ctx.Provider>
  );
};

I didn't run this, but I assume something like this leads to tear with useEffect.

@eddyw
Copy link

eddyw commented Nov 4, 2019

It won't cause tearing 😛
I mean, it will cause a visual tearing (with useEffect) but this won't happen:

  if (count !== value.count) {
    console.log('tear!!!');
  }

useLayoutEffect just makes the update sync before commit, so that's why you can't actually see the "visual tearing" when it happens.

You mention:

  1. useLayoutEffect and give up getting benefit from CM
  2. useEffect and somehow solve parent/child tearing issue (no idea yet)
  3. completely move away from context and emulate context behavior with subscriptions with versioning? (too much work, not sure if it will really be CM-compatible and get its benefit)

I think 2. is rather easy to solve. The main issue is that Provider children re-render when a new value is passed to Provider. This doesn't really happen with external stores because a state update isn't even associated with React.

I believe that will-this-react-global-state-work-in-concurrent-mode is not entirely fair ? For instance, useSubscription test is using an external Redux store, so all listeners are called from Redux. However, useContextSelector is using React useState which behaves differently, a change in state will immediately cause the Provider children to re-render, so you're trying to compete with it doing:

 listeners.forEach((listener) => { 
   listener(value); 
 }); 

If you make it so Provider children don't re-render when value changes, then it'd be entirely up to the above code to update them. What I mean is, it's two different problems. What I do right now is, React.useMemo(() => children, []), so it won't re-render children and they'll be updated only when listeners are told to force update. However, maybe there is a better way to handle this. I hope this makes sense 🙈

@dai-shi
Copy link
Owner Author

dai-shi commented Nov 4, 2019

If you make it so Provider children don't re-render when value changes

We can't assume this. Children can re-render at the same time Provider re-renders.

const Children = () => {
  const [count, setCount] = useContext(Ctx);
  const [localCount, setLocalCount] = useState(1);
  return (
    <div>
      <div>{count}</div>
      <div>{localCount}</div>
      <button onClick={() => { setCount(c => c + 1); setLocalCount(c => c + 1); }}>Click me</button>
    </div>
  );
};

const Parent = () => {
  const value = useState(1);
  return (
    <Ctx.Provider value={value}>
      <Children />
    </Ctx.Provider>
  );
};

@eddyw
Copy link

eddyw commented Nov 4, 2019

ash, I was just making a point. Maybe I can summarize all without going in circles 🙈

  1. using useLayoutEffect or calling the listeners directly in render causes a blocking update. So in reality, you give up the benefits of CM in both.

I made this Sandbox: https://codesandbox.io/s/nice-beaver-9uli8
I just copied the current code of useContextSelector but modified the example to use transitions and suspense (as explained in React docs "Concurrent UI Patterns") in one component that also uses useContextSelector. If you click Increment Remote Count enough times (at different intervals), you will eventually see this warning:

Warning:  triggered a user-blocking update that suspended.

The fix is to split the update into multiple parts: a user-blocking update to provide immediate feedback, and another update that triggers the bulk of the changes.

Refer to the documentation for useTransition to learn how to implement this pattern.

This is because forceUpdate is a blocking update, so it's not CM friendly 🙄
This is explained here: https://reactjs.org/docs/concurrent-mode-patterns.html

  1. Using useEffect will just remove that warning.

    1. The Provider parent / child tearing is a different problem caused because Provider re-renders its children.
    2. Using useEffectLayout solves only the ""visible"" tearing problem.
    3. Using call listeners in render phase will cause the warning, so the solution isn't finding another sync blocking alternative that will be async mode friendly 🙃 (if it makes sense to say so)
  2. Current (experimental) docs regarding concurrent patterns recommends baking useTransition into the design system components of the app: https://reactjs.org/docs/concurrent-mode-patterns.html#baking-transitions-into-the-design-system
    This should, somehow, be within forceUpdate or whatever that causes an update in the component, so if it's wrapped within Suspense it won't cause the warning Warning: triggered a user-blocking update that suspended.. If it's not wrapped within Suspense, it won't matter if it's within startTransition, it will still work

@dai-shi
Copy link
Owner Author

dai-shi commented Nov 4, 2019

@eddyw Thanks for the summary.
Unfortunately, it seems to me like we are still not on the same page.
Let me focus on one thing step by step.

calling the listeners directly in render causes a blocking update.

As far as I understand, it doesn't block. (Or we might be using "block" in different meanings.)
forceUpdate is a setState function which doesn't change the state immediately, but just marks a component to render later with a new state. (Here "later" means in the same render cycle. Not sure about the precise term.)

So, the current code works perfectly in CM. I only care about the warning, and my assumption is that this warning is not applicable to our case because we don't use the forceUpdate's counter value. (It would be really nice if someone can endorse that.)

@eddyw
Copy link

eddyw commented Nov 10, 2019

@dai-shi ok, so I'll leave aside the issues I'm trying to solve for now 😛

my assumption is that this warning is not applicable to our case because we don't use the forceUpdate's counter value

Actually, this has nothing to do with using the value, the state is updated regardless. You can test it yourself:

let setState = null;
class A extends React.Component {
  state = { v: 0 };
  render() {
    setState = this.setState.bind(this);
    return null; // <<<<< not using value
  }
}
class B extends React.Component {
  render() {
    setState({ v: Math.random() });
    return null;
  }
}
...
<A />
<B /> // << warning

While a component can update itself in the "render phase" (at least on functional components, is this missing test in React PR?), a component can't or shouldn't update other components state while in "render phase". To be more specific, this is in "initial render phase" before life-cycle methods are attached. If you put a setTimeout(() => setState(...), 16), in the example above, you'll see the warning disappears because both components will exit "render phase", they will have life-cycle methods attached, and in "commit phase". It's a bit difficult to reason about in functional components because a functional component is just a function, it doesn't mean that the first time it's called, what you return will be rendered. In CM this initial "render" can be interrupted and thrown away so it's not safe.

It "may" work as it is right now without displaying warning:

  1. Children subscribe using useLayoutEffect, so technically there won't be listeners the first time Provider is called (note I said "called" not "rendered"). You add listeners in a child component's "commit phase" (useLayoutEffect before browser paint), so we can assume the provider rendered (otherwise it wouldn't have children)
  2. Since state lives inside React, it should be .. impossible? to try to update the Provider's parent value (through props) while this one is in "initial render phase", so as long as React manages the state passed to the provider, it should be ok?

This may help:
https://twitter.com/dan_abramov/status/981712092611989509?lang=en

@dai-shi
Copy link
Owner Author

dai-shi commented Nov 11, 2019

@eddyw Welcome back. Now we are discussing the same issue. Uh, probably.

Sorry that my wording is a bit unclear.

a component can't or shouldn't update other components state while in "render phase".

This is my question. Does this apply to the forceUpdate use case? I think it's OK-ish.

(If there is an issue even with the forceUpdate use case, I'd use useLayoutEffect which I used to do.)

Children subscribe using useLayoutEffect

You might miss the point (at least the one I see). The warning is not shown for children subscribing parent, but parent notifying children in our case.

@eddyw
Copy link

eddyw commented Nov 11, 2019

🤦‍♂ I mentioned:

It "may" work as it is right now without displaying warning

And point 1 and 2 explain why. However, I decided to do one test more:
https://codesandbox.io/s/peaceful-sun-08g72

And it'd actually break, you can think of setState in demo as listeners and component B as the provider which "calls its listeners" on render phase when it receives a new prop force={true | false}. So the warning will in fact happen.

@eddyw
Copy link

eddyw commented Nov 11, 2019

Again, this has nothing to do with "using the value". Regardless if you do or not use the value of forceUpdate, it'll cause the warning. In demo, you can set the return in "render" method to null if you want to try it out.

@dai-shi
Copy link
Owner Author

dai-shi commented Nov 11, 2019

And point 1 and 2 explain why.

Sorry, I will read it again.

Meanwhile,

Again, this has nothing to do with "using the value". Regardless if you do or not use the value of forceUpdate, it'll cause the warning.

This is I'm aware of. My question is whether the warning is valid or not in our use case.

@dai-shi
Copy link
Owner Author

dai-shi commented Nov 11, 2019

It "may" work as it is right now without displaying warning:

OK, I misread this one. You said "as it is right now".

So the warning will in fact happen.

Yes, I have already confirmed that with one of the examples.


Do you think this warning is valid for the use case in this project?

@eddyw
Copy link

eddyw commented Nov 11, 2019

I believe the warning will in fact happen. At least from testing a pseudo implementation using Class components. From what I see in that PR, there is one missing test. I think this relates to a component updating itself in render phase.

@dai-shi
Copy link
Owner Author

dai-shi commented Nov 11, 2019

I already confirmed this warning in fact happens with the branch in that PR.

I'm wondering if I can ignore the warning in our case, or if there's a fundamental issue even with our use case. Naively, it seems OK, but in CM, maybe not?

@dai-shi
Copy link
Owner Author

dai-shi commented Nov 26, 2019

useLayoutEffect and give up getting benefit from CM

This is the final resort, but according to facebook/react#17334 (comment)

synchronous render, React also flushes the remaining/pending effects (including the passive ones

I didn't expect the de-opt case flushes passive effects. It's a huge downside.

Really want an escape hatch for facebook/react#17099, if we can ignore the warning with our use case.

@dai-shi
Copy link
Owner Author

dai-shi commented Feb 20, 2020

facebook/react#17099 is merged now.

Hopefully, with useMutableSource, it will be fine. Let's wait for it.
Draft PR: #12

@dai-shi
Copy link
Owner Author

dai-shi commented Feb 22, 2020

Found a solution:

useLayoutEffect(() => {
  runWithPriority(NormalPriority, () => {
    // ...
  });
});

@dai-shi
Copy link
Owner Author

dai-shi commented Feb 23, 2020

Hmmmm, this solution still leads tearing in useTransition.

@dai-shi
Copy link
Owner Author

dai-shi commented Mar 7, 2020

I made a proper fix in react-tracked: dai-shi/react-tracked#42
This is only possible because react-tracked has "update" function.

This is not possible with useContextSelector in userland, because update only happens in render. #13 is still a dirty workaround for now, until v2 lands (#12).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants