diff --git a/docs/api-reference/errors/index.md b/docs/api-reference/errors/index.md index 001137ff2..89c95fad1 100644 --- a/docs/api-reference/errors/index.md +++ b/docs/api-reference/errors/index.md @@ -891,6 +891,27 @@ end, nil) ----- +
+

+ since v0.3 +

+ +## 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