Fusion icon indicating copy to clipboard operation
Fusion copied to clipboard

Add contexts to Fusion

Open ImAvafe opened this issue 3 years ago • 14 comments

I want to store data and any of my components of varying nesting levels to be able to access it.

I looked through the documentation many times and couldn't find an example of this, so I'm hoping that I'm not just blind.

ImAvafe avatar Jan 01 '22 13:01 ImAvafe

You can use a table of States. Or computed's if you want to change it conditionally.

Gargafield avatar Jan 02 '22 09:01 Gargafield

I'm hoping for an official implementation that lets me access the data directly through Fusion.

It's definitely something good to have and include in the documentation even just to encourage the usage of global data.

This will be obvious to people with experience from Roact or React, but it makes sense to include it in Fusion because it's Roblox homegrown.

In the meantime, is it fine for me to just assign data to Fusion like Fusion.GlobalData or Fusion.Components?

ImAvafe avatar Jan 02 '22 14:01 ImAvafe

Isn't the Fusion table read-only?

Gargafield avatar Jan 02 '22 17:01 Gargafield

idk I tried it out and it worked

ImAvafe avatar Jan 02 '22 17:01 ImAvafe

This is a difficult problem because, unlike frameworks like React, we have almost no abstractions for things like components by design. Components are a user-side coding pattern rather than a concrete feature, so they don't really exist in the eyes of Fusion at all.

Really what your feature request implies is a way to secretly pass arguments down through plain Lua function calls. Something like this:

local function child()
    print(getContext().foo)
end

local function parent()
    getContext().foo = "Hello world"
    child()
end

parent() -- should print Hello world

~~This is practically impossible to do in Luau with anything other than immediate function calls.~~ edit: okay you can abuse coroutines for this, i was wrong lolz https://www.youtube.com/watch?v=4a7iuW_b5ew

Luckily however, there's an alternate interpretation of this idea - instead of basing context on function calls, you could possibly tie context to instances instead. That is, instances can define values that cascade down to their child instances, and children can read or override these. This would satisfy most context use cases, such as passing down theme options.

dphfox avatar Jan 04 '22 10:01 dphfox

Would you tie data to the PlayerGui instance for something like themes?

Also, how should game data / player data be tied into Fusion? I've heard of external state management systems like Rodux or BasicState, but they don't work with Fusion out of the box. Is there even any purpose in using an external state manager versus using Fusion states and computations?

ImAvafe avatar Jan 04 '22 16:01 ImAvafe

I personally use Fusion alone in my projects, but what you specifically use in your project is up to you - you know your project and requirements best :)

dphfox avatar Jan 04 '22 17:01 dphfox

I've heard of external state management systems like Rodux or BasicState, but they don't work with Fusion out of the box.

I have no idea what BasicState is but Rodux does work with Fusion out of the box. It's not in any way linked to Roact, it is on its own a state management library and that is all. Can it be used with Roact? Sure. Can it also be used with Fusion? Yes and it's even easier since you don't need to rely on another library (Roact-Rodux) to properly bind them together.

This would satisfy most context use cases, such as passing down theme options.

This still doesn't really make sense to me. Fusion is really nice because of the way it handles state right now. There's no need for context when global state is easily usable through module scripts. I don't see a reason to clutter Fusion's code base with a feature that should be designed based on each individual user's needs.

Isn't the Fusion table read-only?

The table doesn't seem to be read-only. Maybe this is implied? The way its designed right now it just prevents trying to read an invalid entry but allows setting new entries. I would like to actually go the route of making it read-only via table.freeze() but perhaps that should be a separate issue on its own.

astrealRBLX avatar Jan 05 '22 02:01 astrealRBLX

Some thoughts on Twitter about two approaches I came up with: https://twitter.com/Elttob_/status/1481782022712573964

dphfox avatar Jan 14 '22 00:01 dphfox

Been drafting out some approaches over in the Discord with some others - arriving at this rough first draft of a concrete implementation of call-stack-based cascades that doesn't interfere with coroutines:

--!strict

type anyfunc = (...any) -> (...any)

local function Cascade<T>(defaultValue: T)
    local self = {}
    local values: {[thread]: {[anyfunc]: {value: T}}} = {}
    
    function self:send(value: T)
        local thread = coroutine.running()
        local caller = debug.info(2, "f")
        values[thread] = values[thread] or {}
        values[thread][caller] = {value = value}
    end
    
    function self:clear()
        local thread = coroutine.running()
        local caller = debug.info(2, "f")
        values[thread] = values[thread] or {}
        values[thread][caller] = nil
    end
    
    function self:receive(): T
        local thread = coroutine.running()
        local threadValues = values[thread]
        if threadValues == nil then
            return defaultValue
        end
        
        for level = 2, math.huge do
            local caller = debug.info(level, "f")
            if caller == nil then
                break
            elseif threadValues[caller] ~= nil then
                return threadValues[caller].value
            end
        end
        
        return defaultValue
    end
    
    return self
end

return Cascade

I have some reservations about how this syntax will play out in all cases - specifically for sending values to children being constructed in an instance tree - but that could be addressed by allowing for a callback to be run with a given value for the cascade perhaps.

dphfox avatar Jun 05 '22 18:06 dphfox

Some further clarification on my issue. Suppose we create a themeable Button component using a theme cascade:

local themes = {
	dark = {
		buttonBG = Color3.new(0.5, 0.75, 1),
		buttonText = Color3.new(0.2, 0.2, 0.2)
	},
	light = {
		buttonBG = Color3.new(0, 0.25, 0.5),
		buttonText = Color3.new(1, 1, 1)
	}
}

local defaultTheme = "dark"
local theme = Cascade(defaultTheme)

local function Button(props)
	local colours = themes[theme:receive()]
	
	return New "TextButton" {
		Text = props.Text,
		BackgroundColor3 = colours.buttonBG,
		TextColor3 = colours.buttonText
	}
end

Now, let's suppose we're constructing a button inline like this:

New "ScreenGui" {
    [Children] = {
        New "UIListLayout" {},
        
        Button {
            Text = "Hello, world"
        }
    }
}

What if we want to make the button (and only the button) see a light theme value? We could use an IIFE:

New "ScreenGui" {
    [Children] = {
        New "UIListLayout" {},
        
        (function()
            theme:send("light")
            return Button {
                Text = "Hello, world"
            }
        end)()
    }
}

Generally I don't like having to use IIFEs like this - they can be error prone and generally don't read as well as they could. Furthermore, this is probably going to be a very common way to set a cascading value on a piece of UI, perhaps including theming UI at the root.

We could introduce a method dedicated to running a callback with a scoped cascading value:

New "ScreenGui" {
    [Children] = {
        New "UIListLayout" {},
        
        theme:as("light", function()
            return Button {
                Text = "Hello, world"
            }
        end)
    }
}

This would likely be a more ergonomic option in this case. I don't see any harm in having this live alongside the regular :send() method.

dphfox avatar Jun 05 '22 19:06 dphfox

Here's a second draft implementation of Cascades, which cascades values down the instance hierarchy rather than the call stack. This one uses special keys for the send/receive interface:

local function Cascade<T>(defaultValue: T)
	local self = {}
	
	local senders: {[Instance]: {value: T}} = {}
	setmetatable(senders, {__mode = "k"})
	
	local function getCascadedValue(location: Instance?): T
		while location ~= nil do
			local sender = senders[location]
			if sender ~= nil then
				return sender.value
			end
			location = location.Parent
		end
		return defaultValue
	end
	
	self.Send = {}
	self.Send.type = "SpecialKey"
	self.Send.kind = "Cascade.Send"
	self.Send.stage = "self"
	function self.Send:apply(givenValue: T, applyToRef: SemiWeakRef, cleanupTasks: {Task})
		local instance = applyToRef.instance :: Instance
		assert(senders[instance] == nil, "Can't apply Cascade.Send on an instance twice")
		senders[instance] = {value = givenValue}
		
		table.insert(cleanupTasks, function()
			senders[instance] = nil
		end)
	end
	
	self.Receive = {}
	self.Receive.type = "SpecialKey"
	self.Receive.kind = "Cascade.Receive"
	self.Receive.stage = "observer"
	function self.Receive:apply(outValue: Value<T>, applyToRef: SemiWeakRef, cleanupTasks: {Task})
		local function recalculate()
			if applyToRef.instance ~= nil then
				outValue:set(getCascadedValue(applyToRef.instance))
			end
		end
		recalculate()
		
		local instance = applyToRef.instance :: Instance
		table.insert(cleanupTasks, instance.AncestryChanged:Connect(recalculate))
		table.insert(cleanupTasks, function()
			outValue:set(defaultValue)
		end)
	end

	return self
end

Example usage:

local theme = Cascade("dark")

local function Button(props)
	local themeVal = Value()
	
	return New "TextButton" {
		[theme.Receive] = themeVal
	}
end

local ui = New "ScreenGui" {
	[theme.Send] = "light",
	[Children] = Button {
		Text = "Hello world!"
	}
}

It's notable that, since the TextButton starts off parented to nil, the themeVal object will first be set to the default value of the cascade (dark). When it's later parented to the ScreenGui, the object will start inheriting the ScreenGui's value of light instead, and so two state changes occur.

dphfox avatar Jun 06 '22 09:06 dphfox

Here's a third draft implementation of Cascades, again instance-based rather than call-stack based, but this time exposing a more flexible API that doesn't use special keys:

local function Cascade<T>(defaultValue: T)
	local senders: {[Instance]: {value: T}} = {}
	setmetatable(senders, {__mode = "k"})

	local function getCascadedValue(location: Instance?): T
		while location ~= nil do
			local sender = senders[location]
			if sender ~= nil then
				return sender.value
			end
			location = location.Parent
		end
		return defaultValue
	end
	
	local self = {}
	function self.Send(givenValue, children)
		local prevChildren = {}
		local prevStateObjects = {}
		local disconnectors = {}
		
		local queueReconcile
		
		local function reconcile()
			local nextChildren = {}
			local nextStateObjects = {}
			
			local function processChild(child: any)
				local kind = xtypeof(child)
				if kind == "Instance" then
					nextChildren[child] = true

				elseif kind == "State" then
					-- case 2; state object
					local value = child:get(false)
					if value ~= nil then -- allow nil to represent no children
						processChild(value)
					end
					nextStateObjects[value] = true

				elseif kind == "table" then
					-- case 3; table of objects
					for _, subChild in pairs(child) do
						processChild(subChild)
					end
				end
			end
			
			if children ~= nil then
				processChild(children)
			end
			
			for child in nextChildren do
				if prevChildren[child] then
					-- was parented, still parented
					prevChildren[child] = nil
				else
					-- newly parented
					senders[child] = {value = givenValue}
				end
			end
			
			for obj in nextStateObjects do
				if prevStateObjects[obj] then
					-- was parented, still parented
					prevStateObjects[obj] = nil
				else
					-- newly parented
					disconnectors[obj] = Observer(obj):onChange(queueReconcile)
				end
			end
			
			for child in prevChildren do
				senders[child] = nil
			end
			for obj in prevStateObjects do
				disconnectors[obj]()
				disconnectors[obj] = nil
			end
			
			prevChildren = nextChildren
			prevStateObjects = nextStateObjects
		end
		
		local reconcileQueued = false
		function queueReconcile()
			if not reconcileQueued then
				reconcileQueued = true
				task.defer(reconcile)
			end
		end

		reconcile()
		
		local probe = New "Configuration" {
			Name = "(Cascade.Send)",
			[Cleanup] = function()
				children = nil
				reconcile()
			end
		}

		return {probe, children}
	end
	
	function self.Receive(callback)
		local cascadedValue = Value(defaultValue)
		local readOnlyValue = Computed(function()
			return cascadedValue:get()
		end)
		
		local probe
		local function recalculate()
			cascadedValue:set(getCascadedValue(probe))
		end
		probe = New "Configuration" {
			Name = "(Cascade.Receive)",
			[OnEvent "AncestryChanged"] = recalculate
		}
		recalculate()
		
		return {probe, callback(readOnlyValue)}
	end

	return self
end

Example usage:

local themes = {
	dark = {
		buttonBG = Color3.new(0.5, 0.75, 1),
		buttonText = Color3.new(0.2, 0.2, 0.2)
	},
	light = {
		buttonBG = Color3.new(0, 0.25, 0.5),
		buttonText = Color3.new(1, 1, 1)
	}
}

local defaultTheme = "dark"
local theme = Cascade(defaultTheme)

local function Button(props)
	return theme.Receive(function(themeState)
		-- `themeState` is a state object that can change over time
		return New "TextButton" {
			Text = props.Text,
			AutomaticSize = "XY",
			BackgroundColor3 = Computed(function()
				return themes[themeState:get()].buttonBG
			end),
			TextColor3 = Computed(function()
				return themes[themeState:get()].buttonText
			end)
		}
	end)
end

New "ScreenGui" {
	Parent = game.Players.LocalPlayer.PlayerGui,
	
	[Children] = {
		New "UIListLayout" {},

		theme.Send("light",
			Button {
				Text = "Hello, world"
			}
		)
	}
}

The idea of this method is that you can pass children through Send to make them provide a value. Then, Receive lets you receive those values further down the instance hierarchy.

This notably avoids the pitfalls of special keys, i.e. this does not require access to any property tables, or even any special knowledge about what instances you're using. That's because this approach instead inserts its own probes into the data model to monitor for ancestry changes and destruction. Of course, that could lead to a more cluttered data model, but that might be a worthwhile price to pay for the convenience of having an API not dependent on labelling each instance explicitly with a Send.

dphfox avatar Jun 06 '22 12:06 dphfox

Interesting thought which I don't have time to flesh out right now.

Suppose you have a cascading 'text colour' which you apply to some text labels, which you can change at any point in the hierarchy e.g. if you're showing text on a bright background and need to recolour it for accessibility. Similar to how you might colour text in CSS.

What if you have a frame which is transparent most of the time (and thus doesn't need to affect text colour), but at other times is a solid colour (which needs to overwrite the text colour to remain accessible)? How do we accommodate for this kind of 'dynamic send'?

dphfox avatar Aug 10 '22 21:08 dphfox

I'd like to give a bit of attention to this issue- it's something that I really want. My use case is passing down a Character object through multiple different components, as multiple components need to read, and sometimes write, data.

ffrostfall avatar Jan 07 '23 20:01 ffrostfall

I've been working on more deep code bases that move heavily away from instances as a way of defining hierarchy, and I realise that instance based context does not solve the problem generally for components. It's merely a convenience for Roblox specific code, which sounds obvious but it's still worth noting.

Perhaps if you have to specify where your context is sampled from (rather than sampling it at the call site which leads to Problems™ in callbacks) then coroutine based callbacks can become a good structural solution for generic code bases.

dphfox avatar Aug 23 '23 11:08 dphfox

Actually, if we force users to sample the context at a conventional point rather than allowing free form usage, we could use the same system as the automatic dependency manager; in fact, substantially simpler because we wouldn't have to maintain stacks to prevent unwanted dependencies.

Consider e.g.

local ctx = Context()

local function child(name)
    -- must sample before yielding or returning
    local sampled = ctx:sample()
    print(name, "is", sampled)
end

local function hiddenLayer()
    child("foo") 
    ctx:as("inner context", function()
        child("bar")
    end) 
end

ctx:as("outer context", function()
    hiddenLayer()
end)

-- foo is outer context
-- bar is inner context

The only thing required to make this work is a single private field in ctx, no coroutines needed (though you won't be able to verify correct usage).

dphfox avatar Aug 25 '23 12:08 dphfox

I think we can move forward with this most recent idea. We just need to be careful about how exactly we present the API surface.

dphfox avatar Jan 18 '24 05:01 dphfox

Here's my idea for how things should be presented:

I propose calling them Contextual - this lines up with common naming for this sort of feature. I did consider Context as a possible name, but it sounds too 'singleton' to me. I don't want people to think about "THE context" as one single thing; instead, I want to make sure that the API surface makes clear you're meant to have multiple of these things. Contextual makes it sound much more like a behaviour of the individual variable, rather than referring to some "singular context". It also lines up nicely with the way we seem to name other things, for example Eventual for computations that take a while to resolve, or Computed for generic computations. They can all act as prefixes (a computed value, an eventual value, a contextual value) so I like this symmetry.

We need to be especially careful about communicating how a Contextual works, because it is timing-based, not lexically scoped. This changes the behaviour of deferred tasks in a way that may not be intuitive if we use language that implies lexical or hierarchical scoping. That is not what is happening under the hood and it is a disingenuous framing. I would like to veto naming that does not communicate the time-sensitive nature of the operation, so Contextual:get() or peek(Contextual) are out.

As a starting point, we could consider Contextual:now() for getting the value and Contextual:is(value):during(callback) for setting the value:

local ctx = Contextual()

local function child(name)
    -- must sample before yielding or returning
    local sampled = ctx:now()
    print(name, "is", sampled)
end

local function hiddenLayer()
    child("foo") 
    ctx:is("inner context"):during(function()
        child("bar")
    end) 
end

ctx:is("outer context"):during(function()
    hiddenLayer()
end)

-- Output:
-- foo is outer context
-- bar is inner context

The reason I think this naming is better is because it helps when explaining why deferred tasks break. When explaining how Contextual works, we can say completely accurately that;

during() sets the value of the Contextual, calls the callback, then resets the value of the Contextual to what it was before.

This framing does not attempt to build some new abstract way of thinking about contextual values. It explicitly frames things as they are implemented, which means people are less primed to think of it as a type of scoping, even if it does ultimately lead to similar organisational benefits. In particular, if someone were to be presented with the following code, I suspect more people would be able to detect the mistake:

local ctx = Contextual()

ctx:is("outer"):during(function()
    local callback
    ctx:is("inner"):during(function()
        callback = function()
            assert(ctx:now() == "inner")
        end
    end)
    callback()
end)

Compared to a misleading naming which implies lexical scoping:

local ctx = Contextual()

ctx:is("outer"):inside(function()
    local callback
    ctx:is("inner"):inside(function()
        callback = function()
            assert(ctx:here() == "inner")
        end
    end)
    callback()
end)

Lastly, I want to address why I'm not going forward with instance-based solutions. While it's true that Roblox's hierarchy is a nice thing to build features like these around (heck, there's even evidence Roblox is looking into this for advanced theming features soon), the ultimate reality is that it's a niche solution that doesn't generalise to broader coding convention. As Fusion moves away from being a purely Roblox-UI-focused framework and towards a general-use Luau state management solution, I want to make sure that all of our critical abstractions and conventions can be used in all codebases, so that all code can be structured with one unified philosophy. Instance-based solutions, by their innate nature, cannot solve for this.

dphfox avatar Jan 18 '24 06:01 dphfox

An interesting point to consider; should Contextual hold one value per coroutine, or one value globally? Per-coroutine would allow some level of yielding support, and would probably be more efficient by virtue of not creating a new coroutine for every :during() call to detect and error on yields, but would this be understandable to users?

dphfox avatar Jan 18 '24 06:01 dphfox

Another point to consider; what is the value of a Contextual whose value has not been set? My suggestion would be to match whatever behaviour we implement for Eventual - either we mandate that a default value is passed in (lining up with Value today) or we error on invalid usage at runtime (which can sometimes be cleaner). I'm inclined toward default values as being able to guarantee a call is infallible at runtime is pretty powerful for making assertions about code reliability.

dphfox avatar Jan 18 '24 06:01 dphfox

Here's a somewhat realistic example of how this API might be used to implement a dynamic theme system.

local themeColours = {
    buttonBG = {dark = Color3.new(0.5, 0.75, 1), light = Color3.new(0, 0.25, 0.5)},
    buttonText = {dark = Color3.new(0.2, 0.2, 0.2), light = Color3.new(1, 1, 1)}
}

local function makeTheme(currentThemeName)
    local dynamicTheme = {}
    for name, variants in themeColours do
        dynamicTheme[name] = Computed(function(use)
            return variants[use(currentThemeName)]
        end)
    end
    return dynamicTheme
end

local currentTheme = Contextual()

local function Button(props)
    local buttonTheme = currentTheme:now()

    return New "TextButton" {
        Text = props.Text,
        AutomaticSize = "XY",
        BackgroundColor3 = buttonTheme.buttonBG,
        TextColor3 = buttonTheme.buttonText,
    }
end

local userPrefersThemeName = Value("light")

local userPrefersTheme = makeTheme(userPrefersThemeName)
local alwaysDarkTheme = makeTheme("dark")

New "ScreenGui" {
    Parent = game.Players.LocalPlayer.PlayerGui,
    
    [Children] = {
        New "UIListLayout" {},

        currentTheme:is(userPrefersTheme):during(function()
            return Button {
                Text = "I adapt to user preference!"
            }
        end),
        currentTheme:is(alwaysDarkTheme):during(function()
            return Button {
                Text = "I am always dark theme!"
            }
        end)
    }
}

while true do
    userPrefersThemeName:set("dark")
    task.wait(1)
    userPrefersThemeName:set("light")
    task.wait(1)
end

dphfox avatar Jan 18 '24 07:01 dphfox