Fusion
Fusion copied to clipboard
Add contexts to Fusion
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.
You can use a table of States. Or computed's if you want to change it conditionally.
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?
Isn't the Fusion table read-only?
idk I tried it out and it worked
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.
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?
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 :)
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.
Some thoughts on Twitter about two approaches I came up with: https://twitter.com/Elttob_/status/1481782022712573964
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.
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.
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.
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.
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'?
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.
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.
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).
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.
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 theContextual, calls the callback, then resets the value of theContextualto 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.
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?
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.
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