dphfox / Fusion

A modern reactive UI library, built specifically for Roblox and Luau.
https://elttob.uk/Fusion/
MIT License
569 stars 101 forks source link

Should peek() deep copy by default? #78

Open dphfox opened 2 years ago

dphfox commented 2 years ago

This is proposed as an alternate solution to the problems set out in #44.

It may be more straightforward to make :get() (on all state objects) perform a deep copy if the state object is holding a table value.

This would achieve the same results that force = true achieves now, except without the possibility of mutating the state object's current value.

For cases where the exact reference is important, we could also introduce an option to return the raw (uncloned) value.

Considerations:

I think that in order to make the right call here we just have to test these two approaches out with real code - both seem like valid approaches for different and often incomparable reasons. It's worth noting however that this approach is not a complete solution to the problem, but rather a 'good-enough' approach that allows users to get the results they probably wanted.

dphfox commented 2 years ago

One other consequence of this approach is that we would also retain the same redundancy problems we currently have with force = true - namely that operations like state:set(state:get()) would now cause updates that are almost certainly unnecessary.

dphfox commented 2 years ago

An interesting gotcha here - while reference-type tables are not useful for dealing with most things, they are useful when dealing with some things like objects where reference-style equality is useful.

Consider, for example, storing a Value inside a Value; in this case, it makes sense that you get back the same Value you put in:

local subValue = Value()
local superValue = Value(subValue)

print(superValue:get() == subValue) -- true - makes sense!

This is a useful case to support, because you can use it to dynamically switch out dependencies and values:

-- healths are Values because they can change at any time
local player1Health = Value(100)
local player2Health = Value(50)
local player3Health = Value(50)

-- we want to select one player's health to show
local selectedHealth = Value(player1Health)

-- process the selected player's health into a message
local message = Computed(function()
    local healthObject = selectedHealth:get()
    return "This player has " .. healthObject:get() .. " HP."
end)

This is a specific example of a general truth - if we are going to implement some kind of deep clone, we will need to support both objects and tables in the same data structures.

Consider this data structure - if we were to deep copy this, we want to keep the objects intact, but the tables containing them should be cloned:

local myData = {
    player1 = {
        health = Value(100),
        armour = {head = Value(100), torso = Value(100), legs = Value(100), feet = Value(100)}
    },
    player2 = {
        health = Value(100),
        armour = {head = Value(100), torso = Value(100), legs = Value(100), feet = Value(100)}
    }
}

Something that this has led me to realise is that 'share-ability' is a property of tables - we will need some way of marking a table as 'cloneable' or 'shareable'. I propose that we should make 'cloneable' the default so that tables have value-like semantics by default and have the most intuitive behaviour, with the more performant 'shareable' behaviour being opt-in. This rules out using table.isfrozen for the purpose of indicating clonability.

So far, I see a few ways of marking a table as 'shareable':

In my mind, the metatable option makes most sense - after all, we want to add metadata to a table, not data. Metatables are useful because they describe a table without requiring modifying their contents. It seems like the most logical place to put a field which influences the behaviour of the table, even if it's a custom field. (if we do this though, we definitely should not use double underscores, since this is meant to be reserved for Luau fields)

To help out the DX, we could expose a utility function for making a table shareable:

local function shareable(t)
    getmetatable(t).shareable = true
    return t
end

local willBeCopied = {1, 2, 3, 4, 5}
local willBeShared = shareable {1, 2, 3, 4, 5}

I'd definitely like further thoughts on this. I have approximately zero idea what I'm doing here lol

dphfox commented 2 years ago

To be clear - the goal here is making table data act more like a value-type, while not breaking objects.

dphfox commented 2 years ago

I think that this is a good idea, but we'll need to test it out in the real world to evaluate things like the performance impact of doing this for most tables by default.

jmkd3v commented 2 years ago

Here's a crazy and bad idea, but maybe worth thinking about: :get() could do something sneaky when returning a table, like proxying it and watching for __newindex to mark it as "edited", or maybe the value object could contain its own shallow copy of the table at all times (updated upon :set()) which it compares with a shallow compare?

dphfox commented 2 years ago

I see you are a Swift user ;)

Jokes aside, copy-on-edit is certainly something you could do, but I don't think that's a very elegant solution to the problem. I think we could maybe get away with a shallow copy.

Hexcede commented 1 year ago

@Elttob I have a fairly big proposal here to solve this issue, I tried to remain mostly faithful to existing Fusion features, and still supporting all use cases (but without too much hassle).

I'd actually somewhat prefer the current behaviour, despite the footgun in the example. The reason being that it supports all use cases out of the box, with minimal changes. From the referenced issue #44, if I want to implement the copy behaviour as an end user it's easy to do:

local array = {1, 2, 3, 4, 5}
local state = State(array)

table.insert(array, 6)
state:set(table.clone(array))

However, I believe mutations sort of go against the nature of Fusion anyways. Perhaps it would be better to provide the user with a way to mutate data in a Fusion-managed form, with the behaviour being an extra.


Proposal

I would propose a state wrapper similar to Tween, Spring, etc which is used for states that control mutable data:

local array = {1, 2, 3, 4, 5}
local immutableState = Value(array) -- Naming aside, immutableState's value is still mutable (currently*)

local mutableState = Mutable(immutableState) -- Wraps immutableState, which maintains a mutable copy of the underlying data.
local mutableArray = mutableState:get() -- We grab our table
table.insert(mutableArray, 6) -- Make some changes
mutableState:set(mutableArray) -- Update the mutable version of the array

Behaviour

This maintains the ability for the user to do arbitrary mutations, but enforces Fusion's control over said mutations. This means the user code stays happy, and it solves the original footgun in a way that's consistent with the existing Fusion patterns.

Final justifications

I believe the ability to perform mutations is useful, even if it is a bad pattern. Similar to Hydrate, it allows bridging "bad" (for lack of a better word) code with "good" Fusion code that follows best practices by offering a compromise between the two.

In contrast to some other solutions, old code can be easily adapted like so and remain fully compatible:

-- Old
local myTable = Value({1, 2, 3})
myTable:get()[3] = 4
myTable:set(myTable:get(), true)

-- New
local myTable = Mutable(Value({1, 2, 3}))
myTable:get()[3] = 4
myTable:set(myTable:get())

The only exceptions here are that myTable:get() will be a different reference, so if references are needed, Mutable just doesn't get wrapped, and forced :set() gets used again.

dphfox commented 1 year ago

@Hexcede you make some valuable points about preserving existing use cases here. We should definitely look into some way of ensuring both immutable and mutable use cases are supported pretty optimally. Thanks for the detailed post!

Using an intermediate object is an interesting approach but the problem is that there is no standardised API for setting values on state objects, because different state objects derive their values from different sources and methodologies by design. I think perhaps it'd be ultimately simpler to implement and simpler to extend if state objects provided their own routines for handling both paths.

We could possibly look at splitting Value's set method into two separate methods that each optimise for immutable and mutable sets respectively. We would need to name them according to verbs that intuitively represent the kind of use cases they should be used for so that newer users can grasp the concept. I think that this is probably a good approach, though I'll need a little more time to become confident in that conviction.

krypt102 commented 1 year ago

This might be redundant due to #216 being written.

dphfox commented 7 months ago

Here's an interesting proposal; in the spirit of #291, we could conceivably use a table's frozen status to decide whether it should be cloned or not.

dphfox commented 4 months ago

Here's an interesting proposal; in the spirit of #291, we could conceivably use a table's frozen status to decide whether it should be cloned or not.

This is now implemented, by the way.