+
+## noTaskScheduler
+
+```
+Fusion is not connected to an external task scheduler.
+```
+
+This message shows when Fusion attempts to schedule something for execution
+without first setting a task scheduler for the library to use.
+
+For users of Fusion on Roblox, this generally shouldn't occur as Fusion should
+automatically be bound to Roblox's task scheduler. However, when using Fusion
+in other environments, the fix is to provide Fusion with all the task scheduler
+callbacks necessary to schedule tasks for execution in the future.
+
+-----
+
since v0.2
diff --git a/src/Animation/SpringScheduler.lua b/src/Animation/SpringScheduler.lua
index bdf5e3966..ae5ad8d3d 100644
--- a/src/Animation/SpringScheduler.lua
+++ b/src/Animation/SpringScheduler.lua
@@ -6,6 +6,7 @@
local Package = script.Parent.Parent
local Types = require(Package.Types)
+local External = require(Package.External)
local packType = require(Package.Animation.packType)
local springCoefficients = require(Package.Animation.springCoefficients)
local updateAll = require(Package.State.updateAll)
@@ -17,7 +18,7 @@ local SpringScheduler = {}
local EPSILON = 0.0001
local activeSprings: Set = {}
-local lastUpdateTime = os.clock()
+local lastUpdateTime = External.lastUpdateStep()
function SpringScheduler.add(spring: Spring)
-- we don't necessarily want to use the most accurate time - here we snap to
@@ -38,9 +39,11 @@ function SpringScheduler.remove(spring: Spring)
activeSprings[spring] = nil
end
-function SpringScheduler.updateAllSprings()
+local function updateAllSprings(
+ now: number
+)
local springsToSleep: Set = {}
- lastUpdateTime = os.clock()
+ lastUpdateTime = now
for spring in pairs(activeSprings) do
local posPos, posVel, velPos, velVel = springCoefficients(lastUpdateTime - spring._lastSchedule, spring._currentDamping, spring._currentSpeed)
@@ -82,4 +85,6 @@ function SpringScheduler.updateAllSprings()
end
end
+External.bindToUpdateStep(updateAllSprings)
+
return SpringScheduler
\ No newline at end of file
diff --git a/src/Animation/Tween.lua b/src/Animation/Tween.lua
index 9430e1ba8..3cee343e1 100644
--- a/src/Animation/Tween.lua
+++ b/src/Animation/Tween.lua
@@ -7,6 +7,7 @@
local Package = script.Parent.Parent
local PubTypes = require(Package.PubTypes)
+local External = require(Package.External)
local Types = require(Package.Types)
local TweenScheduler = require(Package.Animation.TweenScheduler)
local logError = require(Package.Logging.logError)
@@ -43,7 +44,7 @@ function class:update(): boolean
self._prevValue = self._currentValue
self._nextValue = goalValue
- self._currentTweenStartTime = os.clock()
+ self._currentTweenStartTime = External.lastUpdateStep()
self._currentTweenInfo = tweenInfo
local tweenDuration = tweenInfo.DelayTime + tweenInfo.Time
diff --git a/src/Animation/TweenScheduler.lua b/src/Animation/TweenScheduler.lua
index 721fd96ce..548c900a9 100644
--- a/src/Animation/TweenScheduler.lua
+++ b/src/Animation/TweenScheduler.lua
@@ -6,6 +6,7 @@
local Package = script.Parent.Parent
local Types = require(Package.Types)
+local External = require(Package.External)
local lerpType = require(Package.Animation.lerpType)
local getTweenRatio = require(Package.Animation.getTweenRatio)
local updateAll = require(Package.State.updateAll)
@@ -38,8 +39,9 @@ end
--[[
Updates all Tween objects.
]]
-function TweenScheduler.updateAllTweens()
- local now = os.clock()
+local function updateAllTweens(
+ now: number
+)
-- FIXME: Typed Luau doesn't understand this loop yet
for tween: Tween in pairs(allTweens :: any) do
local currentTime = now - tween._currentTweenStartTime
@@ -63,4 +65,6 @@ function TweenScheduler.updateAllTweens()
end
end
+External.bindToUpdateStep(updateAllTweens)
+
return TweenScheduler
\ No newline at end of file
diff --git a/src/External.lua b/src/External.lua
new file mode 100644
index 000000000..0b274b1d8
--- /dev/null
+++ b/src/External.lua
@@ -0,0 +1,114 @@
+--!strict
+--[[
+ Abstraction layer between Fusion internals and external environments,
+ allowing for flexible integration with schedulers and test mocks.
+]]
+
+local Package = script.Parent
+local logError = require(Package.Logging.logError)
+
+local External = {}
+
+export type Scheduler = {
+ doTaskImmediate: (
+ resume: () -> ()
+ ) -> (),
+ doTaskDeferred: (
+ resume: () -> ()
+ ) -> (),
+ startScheduler: () -> (),
+ stopScheduler: () -> ()
+}
+
+local updateStepCallbacks = {}
+local currentScheduler: Scheduler? = nil
+local lastUpdateStep = 0
+
+--[[
+ Sets the external scheduler that Fusion will use for queuing async tasks.
+ Returns the previous scheduler so it can be reset later.
+]]
+function External.setExternalScheduler(
+ newScheduler: Scheduler?
+): Scheduler?
+ local oldScheduler = currentScheduler
+ if oldScheduler ~= nil then
+ oldScheduler.stopScheduler()
+ end
+ currentScheduler = newScheduler
+ if newScheduler ~= nil then
+ newScheduler.startScheduler()
+ end
+ return oldScheduler
+end
+
+--[[
+ Sends an immediate task to the external scheduler. Throws if none is set.
+]]
+function External.doTaskImmediate(
+ resume: () -> ()
+)
+ if currentScheduler == nil then
+ logError("noTaskScheduler")
+ else
+ currentScheduler.doTaskImmediate(resume)
+ end
+end
+
+--[[
+ Sends a deferred task to the external scheduler. Throws if none is set.
+]]
+function External.doTaskDeferred(
+ resume: () -> ()
+)
+ if currentScheduler == nil then
+ logError("noTaskScheduler")
+ else
+ currentScheduler.doTaskDeferred(resume)
+ end
+end
+
+--[[
+ Registers a callback to the update step of the external scheduler.
+ Returns a function that can be used to disconnect later.
+
+ Callbacks are given the current number of seconds since an arbitrary epoch.
+
+ TODO: This epoch may change between schedulers. We could investigate ways
+ of allowing schedulers to co-operate to keep the epoch the same, so that
+ monotonicity can be better preserved.
+]]
+function External.bindToUpdateStep(
+ callback: (
+ now: number
+ ) -> ()
+): () -> ()
+ local uniqueIdentifier = {}
+ updateStepCallbacks[uniqueIdentifier] = callback
+ return function()
+ updateStepCallbacks[uniqueIdentifier] = nil
+ end
+end
+
+--[[
+ Steps time-dependent systems with the current number of seconds since an
+ arbitrary epoch. This should be called as early as possible in the external
+ scheduler's update cycle.
+]]
+function External.performUpdateStep(
+ now: number
+)
+ lastUpdateStep = now
+ for _, callback in updateStepCallbacks do
+ callback(now)
+ end
+end
+
+--[[
+ Returns the timestamp of the last update step.
+]]
+function External.lastUpdateStep()
+ return lastUpdateStep
+end
+
+return External
\ No newline at end of file
diff --git a/src/Instances/Attribute.lua b/src/Instances/Attribute.lua
index f9a8083ac..d9a968b0f 100644
--- a/src/Instances/Attribute.lua
+++ b/src/Instances/Attribute.lua
@@ -7,6 +7,7 @@
local Package = script.Parent.Parent
local PubTypes = require(Package.PubTypes)
+local External = require(Package.External)
local logError = require(Package.Logging.logError)
local xtypeof = require(Package.Utility.xtypeof)
local Observer = require(Package.State.Observer)
@@ -22,7 +23,7 @@ local function bindAttribute(instance: Instance, attribute: string, value: any,
local function update()
if not didDefer then
didDefer = true
- task.defer(function()
+ External.doTaskDeferred(function()
didDefer = false
setAttribute(instance, attribute, peek(value))
end)
diff --git a/src/Instances/Children.lua b/src/Instances/Children.lua
index 178992ed5..2e756a8c9 100644
--- a/src/Instances/Children.lua
+++ b/src/Instances/Children.lua
@@ -7,6 +7,7 @@
local Package = script.Parent.Parent
local PubTypes = require(Package.PubTypes)
+local External = require(Package.External)
local logWarn = require(Package.Logging.logWarn)
local Observer = require(Package.State.Observer)
local peek = require(Package.State.peek)
@@ -131,7 +132,7 @@ function Children:apply(propValue: any, applyTo: Instance, cleanupTasks: {PubTyp
queueUpdate = function()
if not updateQueued then
updateQueued = true
- task.defer(updateChildren)
+ External.doTaskDeferred(updateChildren)
end
end
diff --git a/src/Instances/applyInstanceProps.lua b/src/Instances/applyInstanceProps.lua
index a030649b9..967b37a66 100644
--- a/src/Instances/applyInstanceProps.lua
+++ b/src/Instances/applyInstanceProps.lua
@@ -15,6 +15,7 @@
local Package = script.Parent.Parent
local PubTypes = require(Package.PubTypes)
+local External = require(Package.External)
local cleanup = require(Package.Utility.cleanup)
local xtypeof = require(Package.Utility.xtypeof)
local logError = require(Package.Logging.logError)
@@ -56,7 +57,7 @@ local function bindProperty(instance: Instance, property: string, value: PubType
local function updateLater()
if not willUpdate then
willUpdate = true
- task.defer(function()
+ External.doTaskDeferred(function()
willUpdate = false
setProperty(instance, property, peek(value))
end)
diff --git a/src/Logging/logErrorNonFatal.lua b/src/Logging/logErrorNonFatal.lua
index f434f149d..e5b740d86 100644
--- a/src/Logging/logErrorNonFatal.lua
+++ b/src/Logging/logErrorNonFatal.lua
@@ -26,9 +26,9 @@ local function logErrorNonFatal(messageID: string, errObj: Types.Error?, ...)
errorString = string.format("[Fusion] " .. formatString .. "\n(ID: " .. messageID .. ")\n---- Stack trace ----\n" .. errObj.trace, ...)
end
- task.spawn(function(...)
+ coroutine.wrap(function()
error(errorString:gsub("\n", "\n "), 0)
- end, ...)
+ end)()
end
return logErrorNonFatal
\ No newline at end of file
diff --git a/src/Logging/messages.lua b/src/Logging/messages.lua
index 853c2178b..17b3a3c72 100644
--- a/src/Logging/messages.lua
+++ b/src/Logging/messages.lua
@@ -40,6 +40,7 @@ return {
mistypedSpringDamping = "The damping ratio for a spring must be a number. (got a %s)",
mistypedSpringSpeed = "The speed of a spring must be a number. (got a %s)",
mistypedTweenInfo = "The tween info of a tween must be a TweenInfo. (got a %s)",
+ noTaskScheduler = "Fusion is not connected to an external task scheduler.",
springTypeMismatch = "The type '%s' doesn't match the spring's type '%s'.",
stateGetWasRemoved = "`StateObject:get()` has been replaced by `use()` and `peek()` - see discussion #217 on GitHub.",
strictReadError = "'%s' is not a valid member of '%s'.",
diff --git a/src/RobloxExternal.lua b/src/RobloxExternal.lua
new file mode 100644
index 000000000..d2370b2f4
--- /dev/null
+++ b/src/RobloxExternal.lua
@@ -0,0 +1,77 @@
+--!strict
+--[[
+ Roblox implementation for Fusion's abstract scheduler layer.
+]]
+
+local RunService = game:GetService("RunService")
+local HttpService = game:GetService("HttpService")
+
+local Package = script.Parent
+local External = require(Package.External)
+
+local RobloxExternal = {}
+
+--[[
+ Sends an immediate task to the external scheduler. Throws if none is set.
+]]
+function RobloxExternal.doTaskImmediate(
+ resume: () -> ()
+)
+ task.spawn(resume)
+end
+
+--[[
+ Sends a deferred task to the external scheduler. Throws if none is set.
+]]
+function RobloxExternal.doTaskDeferred(
+ resume: () -> ()
+)
+ task.defer(resume)
+end
+
+--[[
+ Sends an update step to Fusion using the Roblox clock time.
+]]
+local function performUpdateStep()
+ External.performUpdateStep(os.clock())
+end
+
+--[[
+ Binds Fusion's update step to RunService step events.
+]]
+local stopSchedulerFunc = nil
+function RobloxExternal.startScheduler()
+ if stopSchedulerFunc ~= nil then
+ return
+ end
+ if RunService:IsClient() then
+ -- In cases where multiple Fusion modules are running simultaneously,
+ -- this prevents collisions.
+ local id = "FusionUpdateStep_" .. HttpService:GenerateGUID()
+ RunService:BindToRenderStep(
+ id,
+ Enum.RenderPriority.First.Value,
+ performUpdateStep
+ )
+ stopSchedulerFunc = function()
+ RunService:UnbindFromRenderStep(id)
+ end
+ else
+ local connection = RunService.Heartbeat:Connect(performUpdateStep)
+ stopSchedulerFunc = function()
+ connection:Disconnect()
+ end
+ end
+end
+
+--[[
+ Unbinds Fusion's update step from RunService step events.
+]]
+function RobloxExternal.stopScheduler()
+ if stopSchedulerFunc ~= nil then
+ stopSchedulerFunc()
+ stopSchedulerFunc = nil
+ end
+end
+
+return RobloxExternal
\ No newline at end of file
diff --git a/src/State/Observer.lua b/src/State/Observer.lua
index 7f07210dc..5a89f2a8e 100644
--- a/src/State/Observer.lua
+++ b/src/State/Observer.lua
@@ -10,6 +10,7 @@
local Package = script.Parent.Parent
local PubTypes = require(Package.PubTypes)
local Types = require(Package.Types)
+local External = require(Package.External)
type Set = {[T]: any}
@@ -24,7 +25,7 @@ local strongRefs: Set = {}
]]
function class:update(): boolean
for _, callback in pairs(self._changeListeners) do
- task.spawn(callback)
+ External.doTaskImmediate(callback)
end
return false
end
@@ -67,7 +68,7 @@ end
immediately.
]]
function class:onBind(callback: () -> ()): () -> ()
- task.spawn(callback)
+ External.doTaskImmediate(callback)
return self:onChange(callback)
end
diff --git a/src/bindScheduler.lua b/src/bindScheduler.lua
deleted file mode 100644
index 672836c28..000000000
--- a/src/bindScheduler.lua
+++ /dev/null
@@ -1,24 +0,0 @@
-local RunService = game:GetService("RunService")
-
-local TweenScheduler = require(script.Parent.Animation.TweenScheduler)
-local SpringScheduler = require(script.Parent.Animation.SpringScheduler)
-
-local function bindScheduler()
- if RunService:IsClient() then
- RunService:BindToRenderStep(
- "__FusionTweenScheduler",
- Enum.RenderPriority.First.Value,
- TweenScheduler.updateAllTweens
- )
- RunService:BindToRenderStep(
- "__FusionSpringScheduler",
- Enum.RenderPriority.First.Value,
- SpringScheduler.updateAllSprings
- )
- else
- RunService.Heartbeat:Connect(TweenScheduler.updateAllTweens)
- RunService.Heartbeat:Connect(SpringScheduler.updateAllSprings)
- end
-end
-
-return bindScheduler
\ No newline at end of file
diff --git a/src/init.lua b/src/init.lua
index 79fe026d1..d2e283887 100644
--- a/src/init.lua
+++ b/src/init.lua
@@ -5,8 +5,15 @@
]]
local PubTypes = require(script.PubTypes)
+local External = require(script.External)
local restrictRead = require(script.Utility.restrictRead)
-local bindScheduler = require(script.bindScheduler)
+
+-- Down the line, this will be conditional based on whether Fusion is being
+-- compiled for Roblox.
+do
+ local RobloxExternal = require(script.RobloxExternal)
+ External.setExternalScheduler(RobloxExternal)
+end
local Fusion = restrictRead("Fusion", {
version = {major = 0, minor = 3, isRelease = false},
@@ -81,6 +88,4 @@ type Fusion = {
peek: Use
}
-bindScheduler()
-
return Fusion