Skip to content

Commit

Permalink
Initial implementation of contextuals (#299)
Browse files Browse the repository at this point in the history
Initial implementation of contextuals
  • Loading branch information
dphfox authored Jan 18, 2024
2 parents 3d54b0b + ea8f6f4 commit eb18b71
Show file tree
Hide file tree
Showing 7 changed files with 181 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/Logging/messages.lua
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ return {
cannotConnectEvent = "The %s class doesn't have an event called '%s'.",
cannotCreateClass = "Can't create a new instance of class '%s'.",
computedCallbackError = "Computed callback error: ERROR_MESSAGE",
contextualCallbackError = "Contextual callback error: ERROR_MESSAGE",
destructorNeededValue = "To save instances into Values, provide a destructor function. This will be an error soon - see discussion #183 on GitHub.",
destructorNeededComputed = "To return instances from Computeds, provide a destructor function. This will be an error soon - see discussion #183 on GitHub.",
multiReturnComputed = "Returning multiple values from Computeds is discouraged, as behaviour will change soon - see discussion #189 on GitHub.",
Expand Down
12 changes: 12 additions & 0 deletions src/PubTypes.lua
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ export type Version = {
minor: number,
isRelease: boolean
}

-- An object which stores a value scoped in time.
export type Contextual<T> = {
type: "Contextual",
now: (Contextual<T>) -> T,
is: (Contextual<T>, T) -> ContextualIsMethods
}

type ContextualIsMethods = {
during: <T, A...>(ContextualIsMethods, (A...) -> T, A...) -> T
}

--[[
Generic reactive graph types
]]
Expand Down
6 changes: 6 additions & 0 deletions src/Types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export type Error = {
trace: string
}

-- An object which stores a value scoped in time.
export type Contextual<T> = PubTypes.Contextual<T> & {
_valuesNow: {[thread]: {value: T}},
_defaultValue: T
}

--[[
Generic reactive graph types
]]
Expand Down
81 changes: 81 additions & 0 deletions src/Utility/Contextual.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
--!strict
--!nolint LocalShadow

--[[
Time-based contextual values, to allow for transparently passing values down
the call stack.
]]

local Package = script.Parent.Parent
local Types = require(Package.Types)
-- Logging
local logError = require(Package.Logging.logError)
local parseError = require(Package.Logging.parseError)

local class = {}

local CLASS_METATABLE = {__index = class}
local WEAK_KEYS_METATABLE = {__mode = "k"}

--[[
Returns the current value of this contextual.
]]
function class:now(): unknown
local thread = coroutine.running()
local value = self._valuesNow[thread]
if typeof(value) ~= "table" then
return self._defaultValue
else
local value: {value: unknown} = value :: any
return value.value
end
end

--[[
Temporarily assigns a value to this contextual.
]]
function class:is(
newValue: unknown
)
local methods = {}
-- Methods use colon `:` syntax for consistency and autocomplete but we
-- actually want them to operate on the `self` from this outer lexical scope
local contextual = self

function methods:during<T, A...>(
callback: (A...) -> T,
...: A...
): T
local thread = coroutine.running()
local prevValue = contextual._valuesNow[thread]
-- Storing the value in this format allows us to distinguish storing
-- `nil` from not calling `:during()` at all.
contextual._valuesNow[thread] = { value = newValue }
local ok, value = xpcall(callback, parseError, ...)
contextual._valuesNow[thread] = prevValue
if ok then
return value
else
logError("contextualCallbackError", value)
end
end

return methods
end

local function Contextual<T>(
defaultValue: T
): Types.Contextual<T>
local self = setmetatable({
type = "Contextual",
-- if we held strong references to threads here, then if a thread was
-- killed before this contextual had a chance to finish executing its
-- callback, it would be held strongly in this table forever
_valuesNow = setmetatable({}, WEAK_KEYS_METATABLE),
_defaultValue = defaultValue
}, CLASS_METATABLE)

return self
end

return Contextual
3 changes: 3 additions & 0 deletions src/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ local Fusion = restrictRead("Fusion", {
Tween = require(script.Animation.Tween),
Spring = require(script.Animation.Spring),

Contextual = require(script.Utility.Contextual),
cleanup = require(script.Utility.cleanup),
doNothing = require(script.Utility.doNothing),
peek = require(script.State.peek)
Expand All @@ -57,6 +58,7 @@ export type Observer = PubTypes.Observer
export type Tween<T> = PubTypes.Tween<T>
export type Spring<T> = PubTypes.Spring<T>
export type Use = PubTypes.Use
export type Contextual<T> = PubTypes.Contextual<T>

type Fusion = {
version: PubTypes.Version,
Expand All @@ -83,6 +85,7 @@ type Fusion = {
Tween: <T>(goalState: StateObject<T>, tweenInfo: TweenInfo?) -> Tween<T>,
Spring: <T>(goalState: StateObject<T>, speed: CanBeState<number>?, damping: CanBeState<number>?) -> Spring<T>,

Contextual: <T>(defaultValue: T) -> Contextual<T>,
cleanup: (...any) -> (),
doNothing: (...any) -> (),
peek: Use
Expand Down
77 changes: 77 additions & 0 deletions test/Utility/Contextual.spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
local Package = game:GetService("ReplicatedStorage").Fusion
local Contextual = require(Package.Utility.Contextual)

return function()
it("should construct a Contextual object", function()
local ctx = Contextual()

expect(ctx).to.be.a("table")
expect(ctx.type).to.equal("Contextual")
end)

it("should provide its default value", function()
local ctx = Contextual("foo")

expect(ctx:now()).to.equal("foo")
end)

it("should correctly scope temporary values", function()
local ctx = Contextual("foo")

expect(ctx:now()).to.equal("foo")

ctx:is("bar"):during(function()
expect(ctx:now()).to.equal("bar")

ctx:is("baz"):during(function()
expect(ctx:now()).to.equal("baz")
end)

expect(ctx:now()).to.equal("bar")
end)

expect(ctx:now()).to.equal("foo")
end)

it("should allow for argument passing", function()
local ctx = Contextual("foo")

local function test(a, b, c, d)
expect(a).to.equal("a")
expect(b).to.equal("b")
expect(c).to.equal("c")
expect(d).to.equal("d")
end

ctx:is("bar"):during(test, "a", "b", "c", "d")
end)

it("should not interfere across coroutines", function()
local ctx = Contextual("foo")

local coro1 = coroutine.create(function()
ctx:is("bar"):during(function()
expect(ctx:now()).to.equal("bar")
coroutine.yield()
expect(ctx:now()).to.equal("bar")
end)
end)

local coro2 = coroutine.create(function()
ctx:is("baz"):during(function()
expect(ctx:now()).to.equal("baz")
coroutine.yield()
expect(ctx:now()).to.equal("baz")
end)
end)

coroutine.resume(coro1)
expect(ctx:now()).to.equal("foo")
coroutine.resume(coro2)
expect(ctx:now()).to.equal("foo")
coroutine.resume(coro1)
expect(ctx:now()).to.equal("foo")
coroutine.resume(coro2)
expect(ctx:now()).to.equal("foo")
end)
end
1 change: 1 addition & 0 deletions test/init.spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ return function()
Tween = "function",
Spring = "function",

Contextual = "function",
cleanup = "function",
doNothing = "function",
peek = "function"
Expand Down

0 comments on commit eb18b71

Please sign in to comment.