facebook / react

The library for web and native user interfaces.
https://react.dev
MIT License
228.5k stars 46.76k forks source link

Provide a renderer-agnostic equivalent of setNativeProps() #18499

Open drcmda opened 4 years ago

drcmda commented 4 years ago

Dan asked me to open up an issue: https://twitter.com/dan_abramov/status/1246883821477339139

My proposal is to extend React with a small hook that allows us to mutate nodes without causing render. React has no official means to deal with fast occurring updates and libraries like react-spring and framer-motion already do something similar but in a way that forces them to carry a lot of burden.

import React, { useMutation }

function A() {
  const [specialRef, set] = useMutation()

  useEffect(() => {
    // the following would execute sync and without causing render
    // going through the same channel as a regular props update with all
    // the internal interpolation (100 --> "100px")
    set({ style: { left: 100 } })
  }, [])

  return <div ref={specialRef} ... />

It uses the fact that reconcilers know how to handle props, something we don't know in userland unless we cause render to set fresh props, which is not at all optimal for animation or anything frame based. react-dom for instance knows what margin: 3px is, react-three-fiber knows what position: [1,2,3] is, and so on. These details are defined in the reconciler:

  commitUpdate(instance: any, updatePayload: any, type: string, oldProps: any, newProps: any, fiber: Reconciler.Fiber)

If libraries could use this knowledge from outside they could deal with any platform. Animation libraries like react-spring or framer-motion would turn x-platform in one strike, they could animate everything: dom nodes, react native views, meshes, hardware diodes. We could finally write libraries that are not reliant on platforms.

drcmda commented 4 years ago

And this is how we do it today: https://github.com/react-spring/react-spring/tree/master/src/targets

The original idea for this is by @vjeux (from the animated library). Each target defines a function that is able to transport props into the target system. This of course isn't dynamic, targets have to added and maintained, and it's also superfluous since reconcilers have that knowledge.

gaearon commented 4 years ago

When you mean "reconcilers", you mean "renderers", right?

drcmda commented 4 years ago

Yes. As an example, here's react-three-fibers commitUpdate: https://github.com/react-spring/react-three-fiber/blob/master/src/reconciler.tsx#L382 which calls an apply function here: https://github.com/react-spring/react-three-fiber/blob/master/src/reconciler.tsx#L108

commitUpdate has full knowledge of platform details. This is of course also how React handles props on re-rendering.

gaearon commented 4 years ago

Let me try to rephrase your request to make sure I understand it.

I think you're asking for a way for renderer-agnostic libraries to tell React to imperatively synchronously update a host node with given props. But without actually specifying how that update gets applied because presumably the renderer's host config already knows that. So the host node itself is opaque.

drcmda commented 4 years ago

Yes, that is exactly it.

gaearon commented 4 years ago

Do you always know the desired prop values at the moment of the call? Or can they depend on previous prop values?

How would you express a request to delete a prop with your proposed API? Should that even be possible?

drcmda commented 4 years ago

The values are known, it wouldn't have to depend on previous values. Although they are available anyway through ref.current pointing to the actual object. But,

const [ref, set] = useMutation()

set(obj => ({ ... }))

would be more than welcome, why not.

Deletion isn't required, just like you can do:

<xyz something={123} />

// later

<xyz />

set({ x: 0 })
set({ x: undefined }) // i guess? it's just like setState, which technically doesn't delete

The reconciler handles it through commitUpdate(..., oldProps, newProps).

gaearon commented 4 years ago

Deletion isn't required, just like you can do:

There is a difference between deleting a prop and having a prop with a null or undefined value. Whether or not the renderer interprets them the same or differently is up to the renderer, but technically these are two different things. So I think we need to be clearer about the behavior here. If I pass set({ a: 1 }) and then set({ a: 2, b: 1 }) and then set({ b: 2 }), what happens exactly?

gaearon commented 4 years ago

It's interesting that commitUpdate doesn't exist in the persistent mode. Which the new React Native renderer uses. If this is built into React, we'd need a way for it to work across both modes somehow.

drcmda commented 4 years ago
const [ref, set] = useMutation()

set({ a: 1 }) ---> obj.a === 1
set({ a: 2, b: 1 }) ---> obj.a === 2, b === 1
set({ b: 2 }) ---> obj.a === 2, b === 2

no deletion required imo. it's a escape hatch, similar to dangerouslysetinnerhtml. i guess the name "useMutation" is also too harmless, it should have a threatening, evil name with underscores.

commitUpdate doesn't exist in the persistent mode. Which the new React Native renderer uses. If this is built into React, we'd need a way for it to work across both modes somehow.

Im curious how it can do that. I have never understood these modes in depth.

gaearon commented 4 years ago

Im curious how it can do that. I have never understood these modes in depth.

Mutation mode is for host APIs where you're dealing with mutable nodes that have methods like insert / append / remove / update. Like DOM.

Persistent mode is for host APIs where the only way to update something is to create a copy of the tree with changes and then replaceRoot(copy). Essentially an immutable host API. The new RN engine is written like this to better take advantage of multi-threading. I'm not sure how this proposal would work with it.

gaearon commented 4 years ago

I guess this is similar to the concept of the NativeDriver animations in RN, except the "native" part is actually still JavaScript. The way they solve the problem there is that only non-layout props are animatable — therefore, it is safe to do imperative updates "out of band" without worrying about what happens to the persistent data structures used for layout.

gaearon commented 4 years ago

I think what you're essentially asking for is a generalized setNativeProps which is (was) a thing in RN. However, I think that was deprecated in Fabric. @sebmarkbage and @shergin might be able to tell us more about how the thinking has been changing there. I know there's been a few iterations.

drcmda commented 4 years ago

In animatedjs, react-spring and framer-motion the user makes a specific commitment. Something like:

const props = useAnimation({ x: props.x }, [props.x])
return <a.div style={{ left: x }} />

these libs use "<a.xyz>" so that they can receive props directly, so that it is all still declarative on the outside and there is no conflict between props and mutations. They do mutate internally and "a" packs the platform knowledge.

But i imagine that hook as a very simple, no-rules tool. Whoever's using this knows what they are doing, and it's their responsibility to serve this in a way that's safe to use from the outside.

I think what you're essentially asking for is a generalized setNativeProps

Yes. Something like this as a hook, basically going through the same channel as a props update, but sync and without re-render.

elicwhite commented 4 years ago

setNativeProps was removed in Fabric because it had undefined behavior that couldn't be modeled the same way between paper and fabric. Also, in paper all of the communication is async so this function could t happen sync. That could work in Fabric though.

The land mines with setNativeProps is that it was essentially setting state on the native views and there wasn't a guarantee for when that would get reset since it can be out of sync with a React render.

For example: React renders a view with background blue SetNativeProps to set the background to green React update to change border color. Should background reset to blue?

React doesn't know that the background is green, only the native view (or div) does. This makes it more complicated when that native view is actually removed from the hierarchy because of view flattening. That should be totally transparent to the user, but it can change the views that are rendered.

If something like this is added, it should have defined behavior for these kinds of gotchas.