diff --git a/.vscode/Fusion.code-workspace b/.vscode/Fusion.code-workspace new file mode 100644 index 000000000..bab1b7f61 --- /dev/null +++ b/.vscode/Fusion.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": ".." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/docs/api-reference/animation/types/spring.md b/docs/api-reference/animation/types/spring.md index 98655730a..3a1017437 100644 --- a/docs/api-reference/animation/types/spring.md +++ b/docs/api-reference/animation/types/spring.md @@ -10,7 +10,7 @@ ```Lua -export type Spring = StateObject & Dependent & { +export type Spring = StateObject & { kind: "Spring", setPosition: (self, newPosition: T) -> (), setVelocity: (self, newVelocity: T) -> (), @@ -21,9 +21,6 @@ export type Spring = StateObject & Dependent & { A specialised [state object](../stateobject) for following a goal state smoothly over time, using physics to shape the motion. -In addition to the standard state object interfaces, this object is a -[dependent](../dependent) so it can receive updates from the goal state. - The methods on this type allow for direct control over the position and velocity of the motion. Other than that, this type is of limited utility outside of Fusion itself. diff --git a/docs/api-reference/animation/types/tween.md b/docs/api-reference/animation/types/tween.md index dab6ac00b..41f57c7c6 100644 --- a/docs/api-reference/animation/types/tween.md +++ b/docs/api-reference/animation/types/tween.md @@ -10,7 +10,7 @@ ```Lua -export type Tween = StateObject & Dependent & { +export type Tween = StateObject & { kind: "Tween" } ``` @@ -18,9 +18,6 @@ export type Tween = StateObject & Dependent & { A specialised [state object](../stateobject) for following a goal state smoothly over time, using a `TweenInfo` to shape the motion. -In addition to the standard state object interfaces, this object is a -[dependent](../dependent) so it can receive updates from the goal state. - This type isn't generally useful outside of Fusion itself. ----- diff --git a/docs/api-reference/general/errors.md b/docs/api-reference/general/errors.md index b8e0caa58..16430788a 100644 --- a/docs/api-reference/general/errors.md +++ b/docs/api-reference/general/errors.md @@ -130,6 +130,27 @@ You attempted to create a type of instance that Fusion can't create.
+## cannotDepend + +``` +Observer can't depend on Observer. +``` + +**Thrown by:** +[`Observer`](../../graph/members/observer) + +You attempted to form a dependency between two +[graph objects](../../graph/types/graphobject), but either the dependency set or +dependent set were frozen. + +You might be trying to connect them in the wrong order, or the objects might not +be designed to have dependents or dependencies. +
+ +----- + +
+ ## cleanupWasRenamed ``` @@ -492,12 +513,33 @@ Roblox's task scheduling APIs.
+## poisonedScope + +``` +Attempted to use a scope after it's been destroyed; `doCleanup()` was previously +called on this scope. Ensure you are not reusing scopes after cleanup. +``` + +**Thrown by:** +scopes after being passed to [`doCleanup`](../../memory/members/doCleanup) + +If you attempt to read from, or write to, a scope that's been destroyed, this +message is shown. After a scope has been cleaned up, your code should forget the +reference to it, as it is no longer valid. + +
+ +----- + +
+ ## possiblyOutlives ``` -The Value object could be destroyed before the Computed that is use()-ing it; -review the order they're created in, and what scopes they belong to. See -discussion #292 on GitHub for advice. +The Computed (bound to the PaddingLeft property) will be destroyed before the +UIPadding instance; the latter is in a different scope that gets destroyed too +quickly. To fix this, review the order they're created in, and what scopes they +belong to. See discussion #292 on GitHub for advice. ``` **Thrown by:** @@ -684,6 +726,50 @@ for more predictable behaviour and better support for constant values.
+## tweenNanGoal + +``` +A tween was given a NaN goal, so some animation has been skipped. Ensure no +tweens have NaN goals. +``` + +**Thrown by:** +[`Tween`](../../animation/members/tween) + +The goal parameter given to the tween during construction contained one or more +NaN values. + +This typically occurs when zero is accidentally divided by zero, or some other +invalid mathematical operation has occurred. Check that your code is free of +maths errors, and handles all edge cases. +
+ +----- + +
+ +## tweenNanMotion + +``` +A tween encountered NaN during motion, so has snapped to the goal. Ensure no +tweens have NaN in their tween infos. +``` + +**Thrown by:** +[`Tween`](../../animation/members/tween) + +While calculating an updated tween position, the final value contained one or +more NaN values. + +This typically occurs when zero is accidentally divided by zero, or some other +invalid mathematical operation has occurred. Check that your code is free of +maths errors, and handles all edge cases. +
+ +----- + +
+ ## unknownMessage ``` diff --git a/docs/api-reference/state/members/observer.md b/docs/api-reference/graph/members/observer.md similarity index 91% rename from docs/api-reference/state/members/observer.md rename to docs/api-reference/graph/members/observer.md index 41b8f4179..584d5ae22 100644 --- a/docs/api-reference/state/members/observer.md +++ b/docs/api-reference/graph/members/observer.md @@ -1,5 +1,5 @@ @@ -50,8 +50,8 @@ destruction tasks for this object. The target that the observer should watch for changes. -!!! note "Works best with state objects" - While non-[state object](../../../state/types/stateobject) values are +!!! note "Works best with graph objects" + While non-[graph object](../../types/graphobject) values are accepted for compatibility, they won't be able to trigger updates. ----- diff --git a/docs/api-reference/graph/types/graphobject.md b/docs/api-reference/graph/types/graphobject.md new file mode 100644 index 000000000..a19207131 --- /dev/null +++ b/docs/api-reference/graph/types/graphobject.md @@ -0,0 +1,126 @@ + + +

+ :octicons-note-24: + GraphObject +

+ +```Lua +export type GraphObject = ScopedObject & { + createdAt: number + dependencySet: {[GraphObject]: unknown}, + dependentSet: {[GraphObject]: unknown}, + lastChange: number?, + timeliness: "lazy" | "eager", + validity: "valid" | "invalid" | "busy", + _evaluate: (GraphObject, lastChange: number?) -> boolean +} +``` + +A reactive graph object which can broadcast and receive updates among other +members of the reactive graph. + +This type includes [`ScopedObject`](../../../memory/types/scopedobject), which +allows the lifetime and destruction order of the reactive graph to be analysed. + +!!! note "Non-standard type syntax" + The above type definition uses `self` to denote methods. At time of writing, + Luau does not interpret `self` specially. + +----- + +## Members + +

+ createdAt + + : number + +

+ +The `os.clock()` time of this object's construction, measured as early as +possible in the object's constructor. + +

+ dependencySet + + : {[GraphObject]: unknown} + +

+ +Everything this reactive graph object currently declares itself as dependent +upon. + +

+ dependentSet + + : {[GraphObject]: unknown} + +

+ +The reactive graph objects which declare themselves as dependent upon this +object. + +

+ lastChange + + : number? + +

+ +The `os.clock()` time of this object's most recent meaningful change, or `nil` +if the object is newly created. + +

+ timeliness + + : "lazy" | "eager" + +

+ +Describes when this object expects to be revalidated. Most objects should use +`lazy` timeliness to defer computation as late as possible. However, if it's +important for this object to respond to changes as soon as possible, for example +for the purposes of observation, then `eager` timeliness ensures that a +revalidation is dispatched as soon as possible. + +

+ validity + + : "valid" | "invalid" | "busy" + +

+ +Whether the most recent validation operation done on this graph object was a +revalidation or an invalidation. `busy` is used while the graph object is in +the middle of a revalidation. + +----- + +## Methods + +

+ _evaluate + + -> boolean + +

+ +```Lua +function GraphObject:_evaluate(): boolean +``` + +Called by Fusion while the graph object is in the process of being evaluated. +This is where logic to do with computational updates should be placed. + +The return value is `true` when a 'meaningful change' occurs because of this +revalidation. A 'meaningful change' is one that would affect dependencies' +behaviour. This is used to efficiently skip over calculations for dependencies. + +!!! fail "Restrictions" + This method should finish without spawning new processes, blocking the + thread, or erroring. \ No newline at end of file diff --git a/docs/api-reference/state/types/observer.md b/docs/api-reference/graph/types/observer.md similarity index 88% rename from docs/api-reference/state/types/observer.md rename to docs/api-reference/graph/types/observer.md index e133ed33d..17dfb2b9a 100644 --- a/docs/api-reference/state/types/observer.md +++ b/docs/api-reference/graph/types/observer.md @@ -1,5 +1,5 @@ @@ -10,15 +10,16 @@ ```Lua -export type Observer = Dependent & { +export type Observer = GraphObject & { type: "Observer", + timeliness: "eager", onChange: (self, callback: () -> ()) -> (() -> ()), onBind: (self, callback: () -> ()) -> (() -> ()) } ``` -A user-constructed [dependent](../dependent) that runs user code when its -[dependency](../dependency) is updated. +A [graph object](../graph object) that runs user code when it's updated by the +reactive graph. !!! note "Non-standard type syntax" The above type definition uses `self` to denote methods. At time of writing, diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index f23ab05f1..bf99ccdab 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -56,6 +56,15 @@ For a beginner-friendly experience, [try the tutorials.](../tutorials/)
+ + + + - -
\ No newline at end of file diff --git a/docs/api-reference/state/members/peek.md b/docs/api-reference/state/members/peek.md index d4313d667..b9500ca0d 100644 --- a/docs/api-reference/state/members/peek.md +++ b/docs/api-reference/state/members/peek.md @@ -18,7 +18,7 @@ function Fusion.peek( ): T ``` -Extract a value of type `T` from its input. +Extracts a value of type `T` from its input. This is a general-purpose implementation of [`Use`](../../types/use). It does not do any extra processing or book-keeping beyond what is required to determine diff --git a/docs/api-reference/state/types/computed.md b/docs/api-reference/state/types/computed.md index c6d81f297..5dfe357a3 100644 --- a/docs/api-reference/state/types/computed.md +++ b/docs/api-reference/state/types/computed.md @@ -10,18 +10,15 @@ ```Lua -export type Computed = StateObject & Dependent & { - kind: "Computed" +export type Computed = StateObject & { + kind: "Computed", + timeliness: "lazy" } ``` A specialised [state object](../stateobject) for tracking single values computed from a user-defined computation. -In addition to the standard state object interfaces, this object is a -[dependent](../dependent) so it can receive updates from the objects used as -part of the computation. - This type isn't generally useful outside of Fusion itself. ----- diff --git a/docs/api-reference/state/types/dependency.md b/docs/api-reference/state/types/dependency.md deleted file mode 100644 index fd4d7969a..000000000 --- a/docs/api-reference/state/types/dependency.md +++ /dev/null @@ -1,37 +0,0 @@ - - -

- :octicons-note-24: - Dependency -

- -```Lua -export type Dependency = ScopedObject & { - dependentSet: {[Dependent]: unknown} -} -``` - -A reactive graph object which can broadcast updates. Other graph objects can -declare themselves as [dependent](../dependent) upon this object to receive -updates. - -This type includes [`ScopedObject`](../../../memory/types/scopedobject), which -allows the lifetime and destruction order of the reactive graph to be analysed. - ------ - -## Members - -

- dependentSet - - : {[Dependent]: unknown} - -

- -The reactive graph objects which declare themselves as dependent upon this -object. \ No newline at end of file diff --git a/docs/api-reference/state/types/dependent.md b/docs/api-reference/state/types/dependent.md deleted file mode 100644 index 613d1c6b7..000000000 --- a/docs/api-reference/state/types/dependent.md +++ /dev/null @@ -1,65 +0,0 @@ - - -

- :octicons-note-24: - Dependent -

- -```Lua -export type Dependent = ScopedObject & { - update: (self) -> boolean, - dependencySet: {[Dependency]: unknown} -} -``` - -A reactive graph object which can add itself to [dependencies](../dependency) -and receive updates. - -This type includes [`ScopedObject`](../../../memory/types/scopedobject), which -allows the lifetime and destruction order of the reactive graph to be analysed. - -!!! note "Non-standard type syntax" - The above type definition uses `self` to denote methods. At time of writing, - Luau does not interpret `self` specially. - ------ - -## Members - -

- dependencySet - - : {[Dependency]: unknown} - -

- -Everything this reactive graph object currently declares itself as dependent -upon. - ------ - -## Methods - -

- update - - -> boolean - -

- -```Lua -function Dependent:update(): boolean -``` - -Called from a dependency when a change occurs. Returns `true` if the update -should propagate transitively through this object, or `false` if the update -should not continue through this object specifically. - -!!! note "Return value ignored for non-dependencies" - If this `Dependent` is not also a `Dependency`, the return value does - nothing, as an object must be declarable as a dependency for other objects - to receive updates from it. diff --git a/docs/api-reference/state/types/for.md b/docs/api-reference/state/types/for.md index ea85c8b44..f8559b19d 100644 --- a/docs/api-reference/state/types/for.md +++ b/docs/api-reference/state/types/for.md @@ -10,7 +10,7 @@ ```Lua -export type For = StateObject<{[KO]: VO}> & Dependent & { +export type For = StateObject<{[KO]: VO}> & { kind: "For" } ``` @@ -18,10 +18,6 @@ export type For = StateObject<{[KO]: VO}> & Dependent & { A specialised [state object](../stateobject) for tracking multiple values computed from user-defined computations, which are merged into an output table. -In addition to the standard state object interfaces, this object is a -[dependent](../dependent) so it can receive updates from objects used as -part of any of the computations. - This type isn't generally useful outside of Fusion itself. ----- diff --git a/docs/api-reference/state/types/stateobject.md b/docs/api-reference/state/types/stateobject.md index 783613aeb..b2a0af2a1 100644 --- a/docs/api-reference/state/types/stateobject.md +++ b/docs/api-reference/state/types/stateobject.md @@ -10,14 +10,15 @@ ```Lua -export type StateObject = Dependency & { +export type StateObject = GraphObject & { type: "State", - kind: string + kind: string, + _EXTREMELY_DANGEROUS_usedAsValue: T } ``` -Stores a value of `T` which can change over time. As a -[dependency](../dependency), it can broadcast updates when its value changes. +Stores a value of `T` which can change over time. As a +[graph object](../graphobject), it can broadcast updates when its value changes. This type isn't generally useful outside of Fusion itself; you should prefer to work with [`UsedAs`](../usedas) in your own code. @@ -43,4 +44,29 @@ A type string which can be used for runtime type checking. A more specific type string which can be used for runtime type checking. This -can be used to tell types of state object apart. \ No newline at end of file +can be used to tell types of state object apart. + +

+ _EXTREMELY_DANGEROUS_usedAsValue + + : T + +

+ +!!! danger "This is for low-level library authors ***only!***" + ***DO NOT USE THIS UNDER ANY CIRCUMSTANCES. IT IS UNNECESSARILY DANGEROUS TO + DO SO.*** + + You should ***never, ever*** access this in end user code. It doesn't + matter if you think it'll save you from importing a function or typing a few + characters. **YOUR CODE WILL NOT WORK.** + + If you choose to use it anyway, you give full permission for your employer + to fire you immediately and personally defenestrate your laptop. + +The value that should be read out by any [use functions](../use). Implementors +of the state object interface must ensure this property contains a valid value +whenever the validity of the object is `valid`. + +This property **must never** invoke side effects in the reactive graph when +read from or written to. \ No newline at end of file diff --git a/docs/api-reference/state/types/value.md b/docs/api-reference/state/types/value.md index 697456cf2..3b124bda7 100644 --- a/docs/api-reference/state/types/value.md +++ b/docs/api-reference/state/types/value.md @@ -12,7 +12,8 @@ ```Lua export type Value = StateObject & { kind: "State", - set: (self, newValue: T) -> () + set: (self, newValue: T) -> (), + timeliness: "lazy" } ``` diff --git a/docs/tutorials/animation/tweens.md b/docs/tutorials/animation/tweens.md index cc7723a3b..aef019062 100644 --- a/docs/tutorials/animation/tweens.md +++ b/docs/tutorials/animation/tweens.md @@ -95,10 +95,10 @@ is not generally useful for transition animations. ## Reversing The fifth parameter of `TweenInfo` is a reversing option. When enabled, the -animation will return to the starting point. +animation will include a reverse motion, before snapping to the goal at the +end. -This is not typically useful because the animation doesn't end at the goal value, -and might not end at the start value either if the animation is interrupted. +This is not typically useful. ![Animation and graph toggling reversing on and off.](Reversing-Dark.png#only-dark) ![Animation and graph toggling reversing on and off.](Reversing-Light.png#only-light) diff --git a/mkdocs.yml b/mkdocs.yml index 251f3c007..7f88df4bc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -118,14 +118,17 @@ nav: - deriveScope: api-reference/memory/members/derivescope.md - doCleanup: api-reference/memory/members/docleanup.md - scoped: api-reference/memory/members/scoped.md + - Graph: + - Types: + - GraphObject: api-reference/graph/types/graphobject.md + - Observer: api-reference/graph/types/observer.md + - Members: + - Observer: api-reference/graph/members/observer.md - State: - Types: - UsedAs: api-reference/state/types/usedas.md - Computed: api-reference/state/types/computed.md - - Dependency: api-reference/state/types/dependency.md - - Dependent: api-reference/state/types/dependent.md - - For: api-reference/state/types/for.md - - Observer: api-reference/state/types/observer.md + - For: api-reference/state/types/for.md - StateObject: api-reference/state/types/stateobject.md - Use: api-reference/state/types/use.md - Value: api-reference/state/types/value.md @@ -134,7 +137,6 @@ nav: - ForKeys: api-reference/state/members/forkeys.md - ForPairs: api-reference/state/members/forpairs.md - ForValues: api-reference/state/members/forvalues.md - - Observer: api-reference/state/members/observer.md - peek: api-reference/state/members/peek.md - Value: api-reference/state/members/value.md - Roblox: diff --git a/src/Animation/ExternalTime.luau b/src/Animation/ExternalTime.luau new file mode 100644 index 000000000..8e18ce204 --- /dev/null +++ b/src/Animation/ExternalTime.luau @@ -0,0 +1,84 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + Outputs the current external time as a state object. +]] + +local Package = script.Parent.Parent +local Types = require(Package.Types) +local External = require(Package.External) +-- Graph +local change = require(Package.Graph.change) +-- Utility +local nicknames = require(Package.Utility.nicknames) + +type ExternalTime = Types.StateObject + +type Self = ExternalTime + +local class = {} +class.type = "State" +class.kind = "ExternalTime" +class.timeliness = "lazy" +class.dependencySet = table.freeze {} +class._EXTREMELY_DANGEROUS_usedAsValue = External.lastUpdateStep() + +local METATABLE = table.freeze {__index = class} + +local allTimers: {Self} = {} + +local function ExternalTime( + scope: Types.Scope +): ExternalTime + local createdAt = os.clock() + local self: Self = setmetatable( + { + createdAt = createdAt, + dependentSet = {}, + lastChange = nil, + scope = scope, + validity = "invalid" + }, + METATABLE + ) :: any + local destroy = function() + self.scope = nil + local index = table.find(allTimers, self) + if index ~= nil then + table.remove(allTimers, index) + end + end + self.oldestTask = destroy + nicknames[self.oldestTask] = "ExternalTime" + table.insert(scope, destroy) + table.insert(allTimers, self) + return self +end + +function class._evaluate( + self: Self +): boolean + -- While someone else could call `change()` on this object, it wouldn't be + -- idiomatic. So, since the only idiomatic time this function runs is when + -- the external update step runs, it's safe enough to assume that the result + -- has always meaningfully changed. The worst that can happen is unexpected + -- refreshing for people doing unorthodox shenanigans, which is an OK trade. + return true +end + +External.bindToUpdateStep(function( + externalNow: number +): () + class._EXTREMELY_DANGEROUS_usedAsValue = External.lastUpdateStep() + for _, timer in allTimers do + change(timer) + end +end) + +-- Do *not* freeze the class table, because it stores the shared value of all +-- external time objects, and is updated every frame because of that. +-- table.freeze(class) +return ExternalTime \ No newline at end of file diff --git a/src/Animation/Spring.luau b/src/Animation/Spring.luau index a27af1a64..9b95cdbba 100644 --- a/src/Animation/Spring.luau +++ b/src/Animation/Spring.luau @@ -4,258 +4,319 @@ local task = nil -- Disable usage of Roblox's task scheduler --[[ - Constructs a new computed state object, which follows the value of another - state object using a spring simulation. + A specialised state object for following a goal state smoothly over time, + using physics to shape the motion. + + https://elttob.uk/Fusion/0.3/api-reference/animation/types/spring/ ]] local Package = script.Parent.Parent local Types = require(Package.Types) -local InternalTypes = require(Package.InternalTypes) local External = require(Package.External) -local unpackType = require(Package.Animation.unpackType) -local SpringScheduler = require(Package.Animation.SpringScheduler) -local updateAll = require(Package.State.updateAll) -local isState = require(Package.State.isState) +-- Memory +local checkLifetime = require(Package.Memory.checkLifetime) +-- Graph +local depend = require(Package.Graph.depend) +local change = require(Package.Graph.change) +local evaluate = require(Package.Graph.evaluate) +-- State +local castToState = require(Package.State.castToState) local peek = require(Package.State.peek) -local whichLivesLonger = require(Package.Memory.whichLivesLonger) +-- Animation +local ExternalTime = require(Package.Animation.ExternalTime) +local Stopwatch = require(Package.Animation.Stopwatch) +local packType = require(Package.Animation.packType) +local unpackType = require(Package.Animation.unpackType) +local springCoefficients = require(Package.Animation.springCoefficients) +-- Utility +local nicknames = require(Package.Utility.nicknames) + +local EPSILON = 0.00001 + +type Self = Types.Spring & { + _activeDamping: number, + _activeGoal: T, + _activeLatestP: {number}, + _activeLatestV: {number}, + _activeNumSprings: number, + _activeSpeed: number, + _activeStartP: {number}, + _activeStartV: {number}, + _activeTargetP: {number}, + _activeType: string, + _speed: Types.UsedAs, + _damping: Types.UsedAs, + _goal: Types.UsedAs, + _stopwatch: Stopwatch.Stopwatch +} local class = {} class.type = "State" class.kind = "Spring" +class.timeliness = "eager" -local CLASS_METATABLE = {__index = class} +local METATABLE = table.freeze {__index = class} ---[[ - Sets the position of the internal springs, meaning the value of this - Spring will jump to the given value. This doesn't affect velocity. - - If the type doesn't match the current type of the spring, an error will be - thrown. -]] -function class:setPosition( - newValue: Types.Animatable -) - local self = self :: InternalTypes.Spring - local newType = typeof(newValue) - if newType ~= self._currentType then - External.logError("springTypeMismatch", nil, newType, self._currentType) +local function Spring( + scope: Types.Scope, + goal: Types.UsedAs, + speed: Types.UsedAs?, + damping: Types.UsedAs? +): Types.Spring + local createdAt = os.clock() + if typeof(scope) ~= "table" or castToState(scope) ~= nil then + External.logError("scopeMissing", nil, "Springs", "myScope:Spring(goalState, speed, damping)") end - self._springPositions = unpackType(newValue, newType) - self._currentValue = newValue - SpringScheduler.add(self) - updateAll(self) -end - ---[[ - Sets the velocity of the internal springs, overwriting the existing velocity - of this Spring. This doesn't affect position. - - If the type doesn't match the current type of the spring, an error will be - thrown. -]] -function class:setVelocity( - newValue: Types.Animatable -) - local self = self :: InternalTypes.Spring - local newType = typeof(newValue) - if newType ~= self._currentType then - External.logError("springTypeMismatch", nil, newType, self._currentType) + local goalState = castToState(goal) + local stopwatch = nil + if goalState ~= nil then + stopwatch = Stopwatch(scope, ExternalTime(scope)) + stopwatch:unpause() end - self._springVelocities = unpackType(newValue, newType) - SpringScheduler.add(self) -end - ---[[ - Adds to the velocity of the internal springs, on top of the existing - velocity of this Spring. This doesn't affect position. - - If the type doesn't match the current type of the spring, an error will be - thrown. -]] -function class:addVelocity( - deltaValue: Types.Animatable -) - local self = self :: InternalTypes.Spring - local deltaType = typeof(deltaValue) - if deltaType ~= self._currentType then - External.logError("springTypeMismatch", nil, deltaType, self._currentType) + local speed = speed or 10 + local damping = damping or 1 + + local self: Self = setmetatable( + { + createdAt = createdAt, + dependencySet = {}, + dependentSet = {}, + lastChange = nil, + scope = scope, + validity = "invalid", + _activeDamping = -1, + _activeGoal = nil, + _activeLatestP = {}, + _activeLatestV = {}, + _activeNumSprings = 0, + _activeSpeed = -1, + _activeStartP = {}, + _activeStartV = {}, + _activeTargetP = {}, + _activeType = "", + _damping = damping, + _EXTREMELY_DANGEROUS_usedAsValue = peek(goal), + _goal = goal, + _speed = speed, + _stopwatch = stopwatch + }, + METATABLE + ) :: any + local destroy = function() + self.scope = nil + for dependency in pairs(self.dependencySet) do + dependency.dependentSet[self] = nil + end end - - local springDeltas = unpackType(deltaValue, deltaType) - for index, delta in ipairs(springDeltas) do - self._springVelocities[index] += delta + self.oldestTask = destroy + nicknames[self.oldestTask] = "Spring" + table.insert(scope, destroy) + + if goalState ~= nil then + checkLifetime.bOutlivesA( + scope, self.oldestTask, + goalState.scope, goalState.oldestTask, + checkLifetime.formatters.animationGoal + ) + end + local speedState = castToState(speed) + if speedState ~= nil then + checkLifetime.bOutlivesA( + scope, self.oldestTask, + speedState.scope, speedState.oldestTask, + checkLifetime.formatters.parameter, "speed" + ) + end + local dampingState = castToState(damping) + if dampingState ~= nil then + checkLifetime.bOutlivesA( + scope, self.oldestTask, + dampingState.scope, dampingState.oldestTask, + checkLifetime.formatters.parameter, "damping" + ) end - SpringScheduler.add(self) -end - ---[[ - Called when the goal state changes value, or when the speed or damping has - changed. -]] -function class:update(): boolean - local self = self :: InternalTypes.Spring - local goalValue = peek(self._goal) - - -- figure out if this was a goal change or a speed/damping change - if goalValue == self._goalValue then - -- speed/damping change - local damping = peek(self._damping) - if typeof(damping) ~= "number" then - External.logErrorNonFatal("mistypedSpringDamping", nil, typeof(damping)) - elseif damping < 0 then - External.logErrorNonFatal("invalidSpringDamping", nil, damping) - else - self._currentDamping = damping - end - - local speed = peek(self._speed) - if typeof(speed) ~= "number" then - External.logErrorNonFatal("mistypedSpringSpeed", nil, typeof(speed)) - elseif speed < 0 then - External.logErrorNonFatal("invalidSpringSpeed", nil, speed) - else - self._currentSpeed = speed - end - - return false - else - -- goal change - reconfigure spring to target new goal - self._goalValue = goalValue - - local oldType = self._currentType - local newType = typeof(goalValue) - self._currentType = newType - - local springGoals = unpackType(goalValue, newType) - local numSprings = #springGoals - self._springGoals = springGoals - - if newType ~= oldType then - -- if the type changed, snap to the new value and rebuild the - -- position and velocity tables - self._currentValue = self._goalValue - - local springPositions = table.create(numSprings, 0) - local springVelocities = table.create(numSprings, 0) - for index, springGoal in ipairs(springGoals) do - springPositions[index] = springGoal - end - self._springPositions = springPositions - self._springVelocities = springVelocities - -- the spring may have been animating before, so stop that - SpringScheduler.remove(self) - return true + -- Eagerly evaluated objects need to evaluate themselves so that they're + -- valid at all times. + evaluate(self, true) - -- otherwise, the type hasn't changed, just the goal... - elseif numSprings == 0 then - -- if the type isn't animatable, snap to the new value - self._currentValue = self._goalValue - return true + return self +end - else - -- if it's animatable, let it animate to the goal - SpringScheduler.add(self) - return false - end +function class.addVelocity( + self: Self, + deltaValue: T +): () + evaluate(self, false) -- ensure the _active params are up to date + local deltaType = typeof(deltaValue) + if deltaType ~= self._activeType then + External.logError("springTypeMismatch", nil, deltaType, self._activeType) + end + local newStartV = unpackType(deltaValue, deltaType) + for index, velocity in self._activeLatestV do + newStartV[index] += velocity end + self._activeStartP = table.clone(self._activeLatestP) + self._activeStartV = newStartV + self._stopwatch:zero() + self._stopwatch:unpause() + change(self) end ---[[ - Returns the interior value of this state object. -]] -function class:_peek(): unknown - local self = self :: InternalTypes.Spring - return self._currentValue +function class.get( + self: Self +): never + return External.logError("stateGetWasRemoved") end -function class:get() - External.logError("stateGetWasRemoved") +function class.setPosition( + self: Self, + newValue: T +): () + evaluate(self, false) -- ensure the _active params are up to date + local newType = typeof(newValue) + if newType ~= self._activeType then + External.logError("springTypeMismatch", nil, newType, self._activeType) + end + self._activeStartP = unpackType(newValue, newType) + self._activeStartV = table.clone(self._activeLatestV) + self._stopwatch:zero() + self._stopwatch:unpause() + change(self) end -local function Spring( - scope: Types.Scope, - goal: Types.UsedAs, - speed: Types.UsedAs?, - damping: Types.UsedAs? -): Types.Spring - if typeof(scope) ~= "table" or isState(scope) then - External.logError("scopeMissing", nil, "Springs", "myScope:Spring(goalState, speed, damping)") - end - -- apply defaults for speed and damping - if speed == nil then - speed = 10 - end - if damping == nil then - damping = 1 +function class.setVelocity( + self: Self, + newValue: T +): () + evaluate(self, false) -- ensure the _active params are up to date + local newType = typeof(newValue) + if newType ~= self._activeType then + External.logError("springTypeMismatch", nil, newType, self._activeType) end + self._activeStartP = table.clone(self._activeLatestP) + self._activeStartV = unpackType(newValue, newType) + self._stopwatch:zero() + self._stopwatch:unpause() + change(self) +end - local dependencySet: {[Types.Dependency]: unknown} = {} - local goalIsState = isState(goal) - if goalIsState then - local goal = goal :: Types.StateObject - dependencySet[goal] = true - end - if isState(speed) then - local speed = speed :: Types.StateObject - dependencySet[speed] = true +function class._evaluate( + self: Self +): boolean + local goal = castToState(self._goal) + -- Allow non-state goals to pass through transparently. + if goal == nil then + self._EXTREMELY_DANGEROUS_usedAsValue = self._goal :: T + return false end - if isState(damping) then - local damping = damping :: Types.StateObject - dependencySet[damping] = true + -- depend(self, goal) + local nextFrameGoal = peek(goal) + -- Protect against NaN goals. + if nextFrameGoal ~= nextFrameGoal then + External.logWarn("springNanGoal") + return false end - - local self = setmetatable({ - scope = scope, - dependencySet = dependencySet, - dependentSet = {}, - _speed = speed, - _damping = damping, - - _goal = goal, - _goalValue = nil, - - _currentType = nil, - _currentValue = nil, - _currentSpeed = peek(speed), - _currentDamping = peek(damping), - - _springPositions = nil, - _springGoals = nil, - _springVelocities = nil, - - _lastSchedule = -math.huge, - _startDisplacements = {}, - _startVelocities = {} - }, CLASS_METATABLE) - local self = (self :: any) :: InternalTypes.Spring - - local destroy = function() - SpringScheduler.remove(self :: any) - self.scope = nil - for dependency in pairs(self.dependencySet) do - dependency.dependentSet[self] = nil + local nextFrameGoalType = typeof(nextFrameGoal) + local discontinuous = nextFrameGoalType ~= self._activeType + + local stopwatch = self._stopwatch :: Stopwatch.Stopwatch + local elapsed = peek(stopwatch) + depend(self, stopwatch) + + local oldValue = self._EXTREMELY_DANGEROUS_usedAsValue + local newValue: T + + if discontinuous then + -- Propagate changes in type instantly throughout the whole reactive + -- graph, even if simulation is logically one frame behind, because it + -- makes the whole graph behave more consistently. + newValue = nextFrameGoal + elseif elapsed <= 0 then + newValue = oldValue + else + -- Calculate spring motion. + -- IMPORTANT: use the parameters from last frame, not this frame. We're + -- integrating the motion that happened over the last frame, after all. + -- The stopwatch will have captured the length of time needed correctly. + local posPos, posVel, velPos, velVel = springCoefficients( + elapsed, + self._activeDamping, + self._activeSpeed + ) + local isMoving = false + for index = 1, self._activeNumSprings do + local startP = self._activeStartP[index] + local targetP = self._activeTargetP[index] + local startV = self._activeStartV[index] + local startD = startP - targetP + local latestD = startD * posPos + startV * posVel + local latestV = startD * velPos + startV * velVel + if latestD ~= latestD or latestV ~= latestV then + External.logWarn("springNanMotion") + latestD, latestV = 0, 0 + end + if math.abs(latestD) > EPSILON or math.abs(latestV) > EPSILON then + isMoving = true + end + local latestP = latestD + targetP + self._activeLatestP[index] = latestP + self._activeLatestV[index] = latestV end - end - self.oldestTask = destroy - table.insert(scope, destroy) - - if goalIsState then - local goal = goal :: Types.StateObject - if goal.scope == nil then - External.logError("useAfterDestroy", nil, `The {goal.kind} object`, `the Spring that is following it`) - elseif whichLivesLonger(scope, self.oldestTask, goal.scope, goal.oldestTask) == "definitely-a" then - External.logWarn("possiblyOutlives", `The {goal.kind} object`, `the Spring that is following it`) + -- Sleep and snap to goal if the motion has decayed to a negligible amount. + if not isMoving then + for index = 1, self._activeNumSprings do + self._activeLatestP[index] = self._activeTargetP[index] + end + -- TODO: figure out how to do sleeping correctly for single frame + -- changes + -- stopwatch:pause() + -- stopwatch:zero() end - -- add this object to the goal state's dependent set - goal.dependentSet[self] = true + -- Pack springs into final value. + newValue = packType(self._activeLatestP, self._activeType) :: any end - self:update() + -- Reconfigure spring when any of its parameters are changed. + -- This should happen after integrating the last frame's motion. + -- NOTE: don't need to add a dependency on these objects! they do not cause + -- a spring to wake from sleep, so the stopwatch dependency is sufficient. + local nextFrameSpeed = peek(self._speed) :: number + local nextFrameDamping = peek(self._damping) :: number + if + discontinuous or + nextFrameGoal ~= self._activeGoal or + nextFrameSpeed ~= self._activeSpeed or + nextFrameDamping ~= self._activeDamping + then + self._activeTargetP = unpackType(nextFrameGoal, nextFrameGoalType) + self._activeNumSprings = #self._activeTargetP + if discontinuous then + self._activeStartP = table.clone(self._activeTargetP) + self._activeLatestP = table.clone(self._activeTargetP) + self._activeStartV = table.create(self._activeNumSprings, 0) + self._activeLatestV = table.create(self._activeNumSprings, 0) + else + self._activeStartP = table.clone(self._activeLatestP) + self._activeStartV = table.clone(self._activeLatestV) + end + self._activeType = nextFrameGoalType + self._activeGoal = nextFrameGoal + self._activeDamping = nextFrameDamping + self._activeSpeed = nextFrameSpeed + stopwatch:zero() + stopwatch:unpause() + end - return self + -- Push update and check for similarity. + -- Don't need to use the similarity test here because this code doesn't + -- deal with tables, and NaN is already guarded against, so the similarity + -- test doesn't actually add any new safety here. + self._EXTREMELY_DANGEROUS_usedAsValue = newValue + return oldValue ~= newValue end -return Spring \ No newline at end of file +table.freeze(class) +return Spring :: Types.SpringConstructor \ No newline at end of file diff --git a/src/Animation/SpringScheduler.luau b/src/Animation/SpringScheduler.luau deleted file mode 100644 index 8457422d1..000000000 --- a/src/Animation/SpringScheduler.luau +++ /dev/null @@ -1,112 +0,0 @@ ---!strict ---!nolint LocalUnused ---!nolint LocalShadow -local task = nil -- Disable usage of Roblox's task scheduler - ---[[ - Manages batch updating of spring objects. -]] - -local Package = script.Parent.Parent -local InternalTypes = require(Package.InternalTypes) -local External = require(Package.External) -local packType = require(Package.Animation.packType) -local springCoefficients = require(Package.Animation.springCoefficients) -local updateAll = require(Package.State.updateAll) - -type Set = {[T]: unknown} - -local SpringScheduler = {} - -local EPSILON = 0.0001 -local activeSprings: Set> = {} -local lastUpdateTime = External.lastUpdateStep() - -function SpringScheduler.add( - spring: InternalTypes.Spring -) - -- we don't necessarily want to use the most accurate time - here we snap to - -- the last update time so that springs started within the same frame have - -- identical time steps - spring._lastSchedule = lastUpdateTime - table.clear(spring._startDisplacements) - table.clear(spring._startVelocities) - for index, goal in ipairs(spring._springGoals) do - spring._startDisplacements[index] = spring._springPositions[index] - goal - spring._startVelocities[index] = spring._springVelocities[index] - end - - activeSprings[spring] = true -end - -function SpringScheduler.remove( - spring: InternalTypes.Spring -) - activeSprings[spring] = nil -end - -local function updateAllSprings( - now: number -) - local springsToSleep: Set> = {} - lastUpdateTime = now - - for spring in pairs(activeSprings) do - local posPos, posVel, velPos, velVel = springCoefficients( - lastUpdateTime - spring._lastSchedule, - spring._currentDamping, - spring._currentSpeed - ) - - local positions = spring._springPositions - local velocities = spring._springVelocities - local startDisplacements = spring._startDisplacements - local startVelocities = spring._startVelocities - local isMoving = false - - for index, goal in ipairs(spring._springGoals) do - if goal ~= goal then - External.logWarn("springNanGoal") - continue - end - - local oldDisplacement = startDisplacements[index] - local oldVelocity = startVelocities[index] - local newDisplacement = oldDisplacement * posPos + oldVelocity * posVel - local newVelocity = oldDisplacement * velPos + oldVelocity * velVel - - if newDisplacement ~= newDisplacement or newVelocity ~= newVelocity then - External.logWarn("springNanMotion") - newDisplacement = 0 - newVelocity = 0 - end - - if math.abs(newDisplacement) > EPSILON or math.abs(newVelocity) > EPSILON then - isMoving = true - end - - positions[index] = newDisplacement + goal - velocities[index] = newVelocity - end - - if not isMoving then - springsToSleep[spring] = true - end - end - - for spring in pairs(springsToSleep) do - activeSprings[spring] = nil - -- Guarantee that springs reach exact goals, since mathematically they only approach it infinitely - spring._currentValue = packType(spring._springGoals, spring._currentType) - updateAll(spring) - end - - for spring in pairs(activeSprings) do - spring._currentValue = packType(spring._springPositions, spring._currentType) - updateAll(spring) - end -end - -External.bindToUpdateStep(updateAllSprings) - -return SpringScheduler diff --git a/src/Animation/Stopwatch.luau b/src/Animation/Stopwatch.luau new file mode 100644 index 000000000..b2e330c9b --- /dev/null +++ b/src/Animation/Stopwatch.luau @@ -0,0 +1,128 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + State object for measuring time since an event using a reference timer. + + TODO: this should not be exposed to users until it has a proper reactive API + surface +]] + +local Package = script.Parent.Parent +local Types = require(Package.Types) +-- Memory +local checkLifetime = require(Package.Memory.checkLifetime) +-- Graph +local depend = require(Package.Graph.depend) +local change = require(Package.Graph.change) +-- State +local peek = require(Package.State.peek) +-- Utility +local nicknames = require(Package.Utility.nicknames) + +export type Stopwatch = Types.StateObject & { + zero: (Stopwatch) -> (), + pause: (Stopwatch) -> (), + unpause: (Stopwatch) -> () +} + +type Self = Stopwatch & { + _measureTimeSince: number, + _playing: boolean, + _timer: Types.StateObject +} + +local class = {} +class.type = "State" +class.kind = "Stopwatch" +class.timeliness = "lazy" + +local METATABLE = table.freeze {__index = class} + +local function Stopwatch( + scope: Types.Scope, + timer: Types.StateObject +): Stopwatch + local createdAt = os.clock() + local self: Self = setmetatable( + { + awake = true, + createdAt = createdAt, + dependencySet = {}, + dependentSet = {}, + lastChange = nil, + scope = scope, + validity = "invalid", + _EXTREMELY_DANGEROUS_usedAsValue = 0, + _measureTimeSince = 0, -- this should be set on unpause + _playing = false, + _timer = timer + }, + METATABLE + ) :: any + local destroy = function() + self.scope = nil + end + self.oldestTask = destroy + nicknames[self.oldestTask] = "Stopwatch" + table.insert(scope, destroy) + + checkLifetime.bOutlivesA( + scope, self.oldestTask, + timer.scope, timer.oldestTask, + checkLifetime.formatters.parameter, "timer" + ) + depend(self, timer) + return self +end + +function class.zero( + self: Self +): () + local newTimepoint = peek(self._timer) + if newTimepoint ~= self._measureTimeSince then + self._measureTimeSince = newTimepoint + self._EXTREMELY_DANGEROUS_usedAsValue = 0 + change(self) + end +end + +function class.pause( + self: Self +): () + if self._playing == true then + self._playing = false + change(self) + end +end + +function class.unpause( + self: Self +): () + if self._playing == false then + self._playing = true + self._measureTimeSince = peek(self._timer) - self._EXTREMELY_DANGEROUS_usedAsValue + change(self) + end +end + +function class._evaluate( + self: Self +): boolean + if self._playing then + depend(self, self._timer) + local currentTime = peek(self._timer) + local oldValue = self._EXTREMELY_DANGEROUS_usedAsValue + local newValue = currentTime - self._measureTimeSince + self._EXTREMELY_DANGEROUS_usedAsValue = newValue + return oldValue ~= newValue + else + return false + end + +end + +table.freeze(class) +return Stopwatch \ No newline at end of file diff --git a/src/Animation/Tween.luau b/src/Animation/Tween.luau index e6b72b419..f8d042ed3 100644 --- a/src/Animation/Tween.luau +++ b/src/Animation/Tween.luau @@ -4,156 +4,184 @@ local task = nil -- Disable usage of Roblox's task scheduler --[[ - Constructs a new computed state object, which follows the value of another - state object using a tween. + A specialised state object for following a goal state smoothly over time, + using a TweenInfo to shape the motion. + + https://elttob.uk/Fusion/0.3/api-reference/animation/types/tween/ ]] local Package = script.Parent.Parent local Types = require(Package.Types) -local InternalTypes = require(Package.InternalTypes) local External = require(Package.External) -local TweenScheduler = require(Package.Animation.TweenScheduler) -local isState = require(Package.State.isState) +-- Memory +local checkLifetime = require(Package.Memory.checkLifetime) +-- Graph +local depend = require(Package.Graph.depend) +local evaluate = require(Package.Graph.evaluate) +-- State +local castToState = require(Package.State.castToState) local peek = require(Package.State.peek) -local whichLivesLonger = require(Package.Memory.whichLivesLonger) +-- Animation +local ExternalTime = require(Package.Animation.ExternalTime) +local Stopwatch = require(Package.Animation.Stopwatch) +local lerpType = require(Package.Animation.lerpType) +local getTweenRatio = require(Package.Animation.getTweenRatio) +local getTweenDuration = require(Package.Animation.getTweenDuration) +-- Utility +local nicknames = require(Package.Utility.nicknames) + +export type Self = Types.Tween & { + _activeDuration: number, + _activeElapsed: number, + _activeFrom: T, + _activeTo: T, + _activeTweenInfo: TweenInfo, + _goal: Types.UsedAs, + _stopwatch: Stopwatch.Stopwatch?, + _tweenInfo: Types.UsedAs, +} local class = {} class.type = "State" class.kind = "Tween" +class.timeliness = "eager" -local CLASS_METATABLE = {__index = class} - ---[[ - Called when the goal state changes value; this will initiate a new tween. - Returns false as the current value doesn't change right away. -]] -function class:update(): boolean - local self = self :: InternalTypes.Tween - local goalValue = peek(self._goal) - - -- if the goal hasn't changed, then this is a TweenInfo change. - -- in that case, if we're not currently animating, we can skip everything - if goalValue == self._nextValue and not self._currentlyAnimating then - return false - end - - local tweenInfo = peek(self._tweenInfo) - - -- if we receive a bad TweenInfo, then error and stop the update - if typeof(tweenInfo) ~= "TweenInfo" then - External.logErrorNonFatal("mistypedTweenInfo", nil, typeof(tweenInfo)) - return false - end - - self._prevValue = self._currentValue - self._nextValue = goalValue - - self._currentTweenStartTime = External.lastUpdateStep() - self._currentTweenInfo = tweenInfo - - local tweenDuration = tweenInfo.DelayTime + tweenInfo.Time - if tweenInfo.Reverses then - tweenDuration += tweenInfo.Time - end - tweenDuration *= tweenInfo.RepeatCount + 1 - self._currentTweenDuration = tweenDuration - - -- start animating this tween - TweenScheduler.add(self) - - return false -end - ---[[ - Returns the interior value of this state object. -]] -function class:_peek(): unknown - local self = self :: InternalTypes.Tween - return self._currentValue -end - -function class:get() - External.logError("stateGetWasRemoved") -end +local METATABLE = table.freeze {__index = class} local function Tween( scope: Types.Scope, goal: Types.UsedAs, tweenInfo: Types.UsedAs? ): Types.Tween - if isState(scope) then + local createdAt = os.clock() + if castToState(scope) then External.logError("scopeMissing", nil, "Tweens", "myScope:Tween(goalState, tweenInfo)") end - local currentValue = peek(goal) - -- apply defaults for tween info - if tweenInfo == nil then - tweenInfo = TweenInfo.new() + local goalState = castToState(goal) + local stopwatch = nil + if goalState ~= nil then + stopwatch = Stopwatch(scope, ExternalTime(scope)) end - local dependencySet: {[Types.Dependency]: unknown} = {} - - local goalIsState = isState(goal) - if goalIsState then - local goal = goal :: Types.StateObject - dependencySet[goal] = true - end - - local tweenInfoIsState = isState(tweenInfo) - if tweenInfoIsState then - local tweenInfo = tweenInfo :: Types.StateObject - dependencySet[tweenInfo] = true - end - - local startingTweenInfo = peek(tweenInfo) - -- If we start with a bad TweenInfo, then we don't want to construct a Tween - if typeof(startingTweenInfo) ~= "TweenInfo" then - External.logError("mistypedTweenInfo", nil, typeof(startingTweenInfo)) - end - - local self = setmetatable({ - scope = scope, - dependencySet = dependencySet, - dependentSet = {}, - _goal = goal, - _tweenInfo = tweenInfo, - _tweenInfoIsState = tweenInfoIsState, - - _prevValue = currentValue, - _nextValue = currentValue, - _currentValue = currentValue, - - -- store current tween into separately from 'real' tween into, so it - -- isn't affected by :setTweenInfo() until next change - _currentTweenInfo = tweenInfo, - _currentTweenDuration = 0, - _currentTweenStartTime = 0, - _currentlyAnimating = false - }, CLASS_METATABLE) - local self = (self :: any) :: InternalTypes.Tween - + local self: Self = setmetatable( + { + createdAt = createdAt, + dependencySet = {}, + dependentSet = {}, + lastChange = nil, + scope = scope, + validity = "invalid", + _activeDuration = nil, + _activeElapsed = nil, + _activeFrom = nil, + _activeTo = nil, + _activeTweenInfo = nil, + _EXTREMELY_DANGEROUS_usedAsValue = peek(goal), + _goal = goal, + _stopwatch = stopwatch, + _tweenInfo = tweenInfo or TweenInfo.new() + }, + METATABLE + ) :: any local destroy = function() - TweenScheduler.remove(self) self.scope = nil for dependency in pairs(self.dependencySet) do dependency.dependentSet[self] = nil end end self.oldestTask = destroy + nicknames[self.oldestTask] = "Tween" table.insert(scope, destroy) - - if goalIsState then - local goal = goal :: any - if goal.scope == nil then - External.logError("useAfterDestroy", nil, `The {goal.kind} object`, `the Tween that is following it`) - elseif whichLivesLonger(scope, self.oldestTask, goal.scope, goal.oldestTask) == "definitely-a" then - External.logWarn("possiblyOutlives", `The {goal.kind} object`, `the Tween that is following it`) - end - -- add this object to the goal state's dependent set - goal.dependentSet[self] = true + + if goalState ~= nil then + checkLifetime.bOutlivesA( + scope, self.oldestTask, + goalState.scope, goalState.oldestTask, + checkLifetime.formatters.animationGoal + ) + end + + local tweenInfoState = castToState(tweenInfo) + if tweenInfoState ~= nil then + checkLifetime.bOutlivesA( + scope, self.oldestTask, + tweenInfoState.scope, tweenInfoState.oldestTask, + checkLifetime.formatters.parameter, "tween info" + ) end + -- Eagerly evaluated objects need to evaluate themselves so that they're + -- valid at all times. + evaluate(self, true) + return self end -return Tween \ No newline at end of file +function class.get( + self: Self +): never + return External.logError("stateGetWasRemoved") +end + +function class._evaluate( + self: Self +): boolean + local goal = castToState(self._goal) + -- Allow non-state goals to pass through transparently. + if goal == nil then + self._EXTREMELY_DANGEROUS_usedAsValue = self._goal :: T + return false + end + depend(self, goal) + local newTweenTo = peek(goal) + -- Protect against NaN goals. + if newTweenTo ~= newTweenTo then + External.logWarn("tweenNanGoal") + return false + end + local stopwatch = self._stopwatch :: Stopwatch.Stopwatch + local tweenInfo = peek(self._tweenInfo) :: TweenInfo + -- Restart animation when the goal changes, or if the tween info changes + -- partway through another animation. + if + self._activeTo ~= newTweenTo or + (self._activeElapsed < self._activeDuration and self._activeTweenInfo ~= tweenInfo) + then + self._activeDuration = getTweenDuration(tweenInfo) + self._activeFrom = self._EXTREMELY_DANGEROUS_usedAsValue + self._activeTo = newTweenTo + self._activeTweenInfo = tweenInfo + stopwatch:zero() + stopwatch:unpause() + end + depend(self, stopwatch) + self._activeElapsed = peek(stopwatch) + if + self._activeFrom == self._activeTo or -- endpoints match + self._activeElapsed >= self._activeDuration or -- animation is done + typeof(self._activeTo) ~= typeof(self._activeFrom) -- type difference + then + self._activeFrom = self._activeTo + self._activeElapsed = self._activeDuration + stopwatch:pause() + end + -- Compute actual tweened value. + local ratio = getTweenRatio(tweenInfo, self._activeElapsed) + local oldValue = self._EXTREMELY_DANGEROUS_usedAsValue + local newValue = lerpType(self._activeFrom, self._activeTo, ratio) :: T + -- Protect against NaN after motion. + if newValue ~= newValue then + External.logWarn("tweenNanMotion") + newValue = self._activeTo + end + -- Push update and check for similarity. + -- Don't need to use the similarity test here because this code doesn't + -- deal with tables, and NaN is already guarded against, so the similarity + -- test doesn't actually add any new safety here. + self._EXTREMELY_DANGEROUS_usedAsValue = newValue + return oldValue ~= newValue +end + +table.freeze(class) +return Tween :: Types.TweenConstructor diff --git a/src/Animation/TweenScheduler.luau b/src/Animation/TweenScheduler.luau deleted file mode 100644 index fb0bdbb6e..000000000 --- a/src/Animation/TweenScheduler.luau +++ /dev/null @@ -1,72 +0,0 @@ ---!strict ---!nolint LocalUnused ---!nolint LocalShadow -local task = nil -- Disable usage of Roblox's task scheduler - ---[[ - Manages batch updating of tween objects. -]] - -local Package = script.Parent.Parent -local InternalTypes = require(Package.InternalTypes) -local External = require(Package.External) -local lerpType = require(Package.Animation.lerpType) -local getTweenRatio = require(Package.Animation.getTweenRatio) -local updateAll = require(Package.State.updateAll) - -local TweenScheduler = {} - -type Set = {[T]: unknown} - --- all the tweens currently being updated -local allTweens: Set> = {} - ---[[ - Adds a Tween to be updated every render step. -]] -function TweenScheduler.add( - tween: InternalTypes.Tween -) - allTweens[tween] = true -end - ---[[ - Removes a Tween from the scheduler. -]] -function TweenScheduler.remove( - tween: InternalTypes.Tween -) - allTweens[tween] = nil -end - ---[[ - Updates all Tween objects. -]] -local function updateAllTweens( - now: number -) - for tween in allTweens do - local currentTime = now - tween._currentTweenStartTime - - if currentTime > tween._currentTweenDuration and tween._currentTweenInfo.RepeatCount > -1 then - if tween._currentTweenInfo.Reverses then - tween._currentValue = tween._prevValue - else - tween._currentValue = tween._nextValue - end - tween._currentlyAnimating = false - updateAll(tween) - TweenScheduler.remove(tween) - else - local ratio = getTweenRatio(tween._currentTweenInfo, currentTime) - local currentValue = lerpType(tween._prevValue, tween._nextValue, ratio) - tween._currentValue = currentValue - tween._currentlyAnimating = true - updateAll(tween) - end - end -end - -External.bindToUpdateStep(updateAllTweens) - -return TweenScheduler \ No newline at end of file diff --git a/src/Animation/getTweenDuration.luau b/src/Animation/getTweenDuration.luau new file mode 100644 index 000000000..f0756a2f6 --- /dev/null +++ b/src/Animation/getTweenDuration.luau @@ -0,0 +1,27 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + Given a `tweenInfo`, returns how many seconds it will take before the tween + finishes moving. The result may be infinite if the tween repeats forever. +]] + +local TweenService = game:GetService("TweenService") + +local function getTweenDuration( + tweenInfo: TweenInfo +): number + if tweenInfo.RepeatCount <= -1 then + return math.huge + end + local tweenDuration = tweenInfo.DelayTime + tweenInfo.Time + if tweenInfo.Reverses then + tweenDuration += tweenInfo.Time + end + tweenDuration *= tweenInfo.RepeatCount + 1 + return tweenDuration +end + +return getTweenDuration diff --git a/src/Animation/getTweenRatio.luau b/src/Animation/getTweenRatio.luau index bd5487303..65c7e3198 100644 --- a/src/Animation/getTweenRatio.luau +++ b/src/Animation/getTweenRatio.luau @@ -20,27 +20,26 @@ local function getTweenRatio( local numCycles = 1 + tweenInfo.RepeatCount local easeStyle = tweenInfo.EasingStyle local easeDirection = tweenInfo.EasingDirection - local cycleDuration = delay + duration if reverses then cycleDuration += duration end - + -- If currentTime is infinity, then presumably the tween should be over. + -- This avoids NaN when the duration of an infinitely repeating tween is given. + if currentTime == math.huge then + return 1 + end if currentTime >= cycleDuration * numCycles and tweenInfo.RepeatCount > -1 then return 1 end - local cycleTime = currentTime % cycleDuration - if cycleTime <= delay then return 0 end - local tweenProgress = (cycleTime - delay) / duration if tweenProgress > 1 then tweenProgress = 2 - tweenProgress end - local ratio = TweenService:GetValue(tweenProgress, easeStyle, easeDirection) return ratio end diff --git a/src/Animation/springCoefficients.luau b/src/Animation/springCoefficients.luau index ab7864315..e41973537 100644 --- a/src/Animation/springCoefficients.luau +++ b/src/Animation/springCoefficients.luau @@ -4,13 +4,18 @@ local task = nil -- Disable usage of Roblox's task scheduler --[[ - Returns a 2x2 matrix of coefficients for a given time, damping and speed. + Returns a 2x2 matrix of coefficients for a given time, damping and angular + frequency (aka 'speed'). + Specifically, this returns four coefficients - posPos, posVel, velPos, and velVel - which can be multiplied with position and velocity like so: local newPosition = oldPosition * posPos + oldVelocity * posVel local newVelocity = oldPosition * velPos + oldVelocity * velVel + For speed = 1 and damping = 0, the result is a simple harmonic oscillator + with a period of tau. + Special thanks to AxisAngle for helping to improve numerical precision. ]] @@ -27,62 +32,46 @@ local function springCoefficients( if damping > 1 then -- overdamped spring - -- solution to the characteristic equation: - -- z = -ζω ± Sqrt[ζ^2 - 1] ω - -- x[t] -> x0(e^(t z2) z1 - e^(t z1) z2)/(z1 - z2) - -- + v0(e^(t z1) - e^(t z2))/(z1 - z2) - -- v[t] -> x0(z1 z2(-e^(t z1) + e^(t z2)))/(z1 - z2) - -- + v0(z1 e^(t z1) - z2 e^(t z2))/(z1 - z2) - local scaledTime = time * speed local alpha = math.sqrt(damping^2 - 1) - local scaledInvAlpha = -0.5 / alpha - local z1 = -alpha - damping - local z2 = 1 / z1 - local expZ1 = math.exp(scaledTime * z1) - local expZ2 = math.exp(scaledTime * z2) + local negHalf_over_alpha_speed = -0.5 / (alpha * speed) + local z1 = speed * (alpha + damping) * -1 + local z2 = speed * (alpha - damping) + local exp1 = math.exp(time * z1) + local exp2 = math.exp(time * z2) - posPos = (expZ2*z1 - expZ1*z2) * scaledInvAlpha - posVel = (expZ1 - expZ2) * scaledInvAlpha / speed - velPos = (expZ2 - expZ1) * scaledInvAlpha * speed - velVel = (expZ1*z1 - expZ2*z2) * scaledInvAlpha + posPos = (exp2 * z1 - exp1 * z2) * negHalf_over_alpha_speed + posVel = (exp1 - exp2) * negHalf_over_alpha_speed / speed + velPos = (exp2 - exp1) * negHalf_over_alpha_speed * speed + velVel = (exp1 * z1 - exp2 * z2) * negHalf_over_alpha_speed elseif damping == 1 then -- critically damped spring - -- x[t] -> x0(e^-tω)(1+tω) + v0(e^-tω)t - -- v[t] -> x0(t ω^2)(-e^-tω) + v0(1 - tω)(e^-tω) - - local scaledTime = time * speed - local expTerm = math.exp(-scaledTime) - posPos = expTerm * (1 + scaledTime) - posVel = expTerm * time - velPos = expTerm * (-scaledTime*speed) - velVel = expTerm * (1 - scaledTime) + local time_speed = time * speed + local time_speed_neg1 = time_speed * -1 + local exp = math.exp(time_speed_neg1) + posPos = exp * (time_speed + 1) + posVel = exp * time + velPos = exp * (time_speed_neg1 * speed) + velVel = exp * (time_speed_neg1 + 1) else -- underdamped spring - -- factored out of the solutions to the characteristic equation: - -- α = Sqrt[1 - ζ^2] - -- x[t] -> x0(e^-tζω)(α Cos[tα] + ζω Sin[tα])/α - -- + v0(e^-tζω)(Sin[tα])/α - -- v[t] -> x0(-e^-tζω)(α^2 + ζ^2 ω^2)(Sin[tα])/α - -- + v0(e^-tζω)(α Cos[tα] - ζω Sin[tα])/α - local scaledTime = time * speed - local alpha = math.sqrt(1 - damping^2) - local invAlpha = 1 / alpha - local alphaTime = alpha * scaledTime - local expTerm = math.exp(-scaledTime*damping) - local sinTerm = expTerm * math.sin(alphaTime) - local cosTerm = expTerm * math.cos(alphaTime) - local sinInvAlpha = sinTerm*invAlpha - local sinInvAlphaDamp = sinInvAlpha*damping + local alpha = speed * math.sqrt(1 - damping^2) + local overAlpha = 1 / alpha + local exp = math.exp(-1 * time * speed * damping) + local sin = math.sin(alpha * time) + local cos = math.cos(alpha * time) + local exp_sin = exp * sin + local exp_cos = exp * cos + local exp_sin_speed_damping_overAlpha = exp_sin * speed * damping * overAlpha - posPos = sinInvAlphaDamp + cosTerm - posVel = sinInvAlpha - velPos = -(sinInvAlphaDamp*damping + sinTerm*alpha) - velVel = cosTerm - sinInvAlphaDamp + posPos = exp_sin_speed_damping_overAlpha + exp_cos + posVel = exp_sin * overAlpha + velPos = -1 * ( exp_sin * alpha + speed * damping * exp_sin_speed_damping_overAlpha ) + velVel = exp_cos - exp_sin_speed_damping_overAlpha end return posPos, posVel, velPos, velVel diff --git a/src/External.luau b/src/External.luau index 734ecf1a6..1caf3b7c9 100644 --- a/src/External.luau +++ b/src/External.luau @@ -16,6 +16,15 @@ local ERROR_INFO_URL = "https://elttob.uk/Fusion/0.3/api-reference/general/error local External = {} +-- Indicates that a highly time-critical passage of code is running. During +-- critical periods of a program, Fusion might decide to change some of its +-- internal behaviour to be more performance friendly. +local timeCritical = false + +-- Multiplier for running-time safety checks across the Fusion codebase. Used to +-- stricten tests on infinite loop detection during unit testing. +External.safetyTimerMultiplier = 1 + local updateStepCallbacks = {} local currentProvider: Types.ExternalProvider? = nil local lastUpdateStep = 0 @@ -38,6 +47,13 @@ function External.setExternalProvider( return oldProvider end +--[[ + Returns true if a highly time-critical passage of code is running. +]] +function External.isTimeCritical(): boolean + return timeCritical +end + --[[ Sends an immediate task to the external provider. Throws if none is set. ]] @@ -45,7 +61,7 @@ function External.doTaskImmediate( resume: () -> () ) if currentProvider == nil then - formatError(currentProvider, "noTaskScheduler") + External.logError("noTaskScheduler") else currentProvider.doTaskImmediate(resume) end @@ -58,7 +74,7 @@ function External.doTaskDeferred( resume: () -> () ) if currentProvider == nil then - formatError(currentProvider, "noTaskScheduler") + External.logError("noTaskScheduler") else currentProvider.doTaskDeferred(resume) end diff --git a/src/Graph/Observer.luau b/src/Graph/Observer.luau new file mode 100644 index 000000000..b0d086591 --- /dev/null +++ b/src/Graph/Observer.luau @@ -0,0 +1,114 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + A graph object that runs user code when it's updated by the reactive graph. + + http://elttob.uk/Fusion/0.3/api-reference/state/types/observer/ +]] + +local Package = script.Parent.Parent +local Types = require(Package.Types) +local External = require(Package.External) +-- Memory +local checkLifetime = require(Package.Memory.checkLifetime) +-- Graph +local castToGraph = require(Package.Graph.castToGraph) +local depend = require(Package.Graph.depend) +local evaluate = require(Package.Graph.evaluate) +-- Utility +local nicknames = require(Package.Utility.nicknames) + +type Self = Types.Observer & { + _watchingGraph: Types.GraphObject?, + _changeListeners: {[unknown]: () -> ()} +} + +local class = {} +class.type = "Observer" +class.timeliness = "eager" +class.dependentSet = table.freeze {} + +local METATABLE = table.freeze {__index = class} + +local function Observer( + scope: Types.Scope, + watching: unknown +): Types.Observer + local createdAt = os.clock() + if watching == nil then + External.logError("scopeMissing", nil, "Observers", "myScope:Observer(watching)") + end + + local self: Self = setmetatable( + { + scope = scope, + createdAt = createdAt, + dependencySet = {}, + lastChange = nil, + validity = "invalid", + _watchingGraph = castToGraph(watching), + _changeListeners = {} + }, + METATABLE + ) :: any + local destroy = function() + self.scope = nil + for dependency in pairs(self.dependencySet) do + dependency.dependentSet[self] = nil + end + end + self.oldestTask = destroy + nicknames[self.oldestTask] = "Observer" + table.insert(scope, destroy) + + if self._watchingGraph ~= nil then + checkLifetime.bOutlivesA( + scope, self.oldestTask, + self._watchingGraph.scope, self._watchingGraph.oldestTask, + checkLifetime.formatters.observer + ) + end + + -- Eagerly evaluated objects need to evaluate themselves so that they're + -- valid at all times. + evaluate(self, true) + + return self +end + +function class.onBind( + self: Self, + callback: () -> () +): () -> () + External.doTaskImmediate(callback) + return self:onChange(callback) +end + +function class.onChange( + self: Self, + callback: () -> () +): () -> () + local uniqueIdentifier = table.freeze {} + self._changeListeners[uniqueIdentifier] = callback + return function() + self._changeListeners[uniqueIdentifier] = nil + end +end + +function class._evaluate( + self: Self +): () + if self._watchingGraph ~= nil then + depend(self, self._watchingGraph) + end + for _, callback in self._changeListeners do + External.doTaskImmediate(callback) + end + return true +end + +table.freeze(class) +return Observer :: Types.ObserverConstructor \ No newline at end of file diff --git a/src/Graph/castToGraph.luau b/src/Graph/castToGraph.luau new file mode 100644 index 000000000..4d612a04f --- /dev/null +++ b/src/Graph/castToGraph.luau @@ -0,0 +1,29 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + Returns the input *only* if it is a graph object. +]] + +local Package = script.Parent.Parent +local Types = require(Package.Types) + +local function castToGraph( + target: any +): Types.GraphObject? + if + typeof(target) == "table" and + typeof(target.validity) == "string" and + typeof(target.timeliness) == "string" and + typeof(target.dependencySet) == "table" and + typeof(target.dependentSet) == "table" + then + return target + else + return nil + end +end + +return castToGraph \ No newline at end of file diff --git a/src/Graph/change.luau b/src/Graph/change.luau new file mode 100644 index 000000000..2095efed8 --- /dev/null +++ b/src/Graph/change.luau @@ -0,0 +1,81 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + Prompts a graph object to re-evaluate its own value. If it meaningfully + changes, then dependents will have to re-evaluate their own values in the + future. + + https://fluff.blog/2024/04/16/monotonic-painting.html +]] + +local Package = script.Parent.Parent +local Types = require(Package.Types) +local External = require(Package.External) +local evaluate = require(Package.Graph.evaluate) + +-- How long should this function run before it's considered to be in an infinite +-- cycle and error out? +local TERMINATION_TIME = 1 + +local function change( + target: Types.GraphObject +): () + if target.validity == "busy" then + return External.logError("infiniteLoop") + end + + local meaningfullyChanged = evaluate(target, true) + if not meaningfullyChanged then + return + end + + local searchInNow: {Types.GraphObject} = {} + local searchInNext: {Types.GraphObject} = {} + local invalidateList: {Types.GraphObject} = {} + + searchInNow[1] = target + local terminateBy = os.clock() + TERMINATION_TIME * External.safetyTimerMultiplier + repeat + if os.clock() > terminateBy then + return External.logError("infiniteLoop") + end + local done = true + for _, searchTarget in searchInNow do + for dependent in searchTarget.dependentSet do + if dependent.validity == "valid" then + done = false + table.insert(invalidateList, dependent) + table.insert(searchInNext, dependent) + elseif dependent.validity == "busy" then + return External.logError("infiniteLoop") + end + end + end + searchInNow, searchInNext = searchInNext, searchInNow + table.clear(searchInNext) + until done + + local eagerList: {Types.GraphObject} = {} + + for _, invalidateTarget in invalidateList do + invalidateTarget.validity = "invalid" + if invalidateTarget.timeliness == "eager" then + table.insert(eagerList, invalidateTarget) + end + end + -- If objects are not executed in order of creations, then dynamic graphs + -- may experience 'glitches' where nested graph objects see intermediate + -- values before being destroyed. + -- https://fluff.blog/2024/07/14/glitches-in-dynamic-reactive-graphs.html + table.sort(eagerList, function(a, b) + return a.createdAt < b.createdAt + end) + for _, eagerTarget in eagerList do + evaluate(eagerTarget, false) + end +end + +return change \ No newline at end of file diff --git a/src/Graph/depend.luau b/src/Graph/depend.luau new file mode 100644 index 000000000..542dc81c0 --- /dev/null +++ b/src/Graph/depend.luau @@ -0,0 +1,33 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + Forms a dependency on a graph object. +]] + +local Package = script.Parent.Parent +local Types = require(Package.Types) +local External = require(Package.External) +local evaluate = require(Package.Graph.evaluate) +local nameOf = require(Package.Utility.nameOf) + +local function depend( + dependent: Types.GraphObject, + dependency: Types.GraphObject +): () + -- Ensure dependencies are evaluated and up-to-date + -- when they are depended on. Also, newly created objects + -- might not have any transitive dependencies captured yet, + -- so ensure that they're present. + evaluate(dependency, false) + + if table.isfrozen(dependent.dependencySet) or table.isfrozen(dependency.dependentSet) then + External.logError("cannotDepend", nil, nameOf(dependent, "Dependent"), nameOf(dependency, "dependency")) + end + dependency.dependentSet[dependent] = true + dependent.dependencySet[dependency] = true +end + +return depend \ No newline at end of file diff --git a/src/Graph/evaluate.luau b/src/Graph/evaluate.luau new file mode 100644 index 000000000..59c747fd8 --- /dev/null +++ b/src/Graph/evaluate.luau @@ -0,0 +1,56 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + Evaluates the graph object if necessary, so that it is up to date. + Returns true if it meaningfully changed. + + https://fluff.blog/2024/04/16/monotonic-painting.html +]] + +local Package = script.Parent.Parent +local Types = require(Package.Types) +local External = require(Package.External) + +local function evaluate( + target: Types.GraphObject, + forceComputation: boolean +): boolean + if target.validity == "busy" then + return External.logError("infiniteLoop") + end + local firstEvaluation = target.lastChange == nil + local isInvalid = target.validity == "invalid" + if firstEvaluation or isInvalid or forceComputation then + local needsComputation = firstEvaluation or forceComputation + if not needsComputation then + for dependency in target.dependencySet do + evaluate(dependency, false) + if dependency.lastChange > target.lastChange then + needsComputation = true + break + end + end + end + local targetMeaningfullyChanged = false + if needsComputation then + for dependency in target.dependencySet do + dependency.dependentSet[target] = nil + target.dependencySet[dependency] = nil + end + target.validity = "busy" + targetMeaningfullyChanged = target:_evaluate() or firstEvaluation + end + if targetMeaningfullyChanged then + target.lastChange = os.clock() + end + target.validity = "valid" + return targetMeaningfullyChanged + else + return false + end +end + +return evaluate \ No newline at end of file diff --git a/src/Instances/Attribute.luau b/src/Instances/Attribute.luau index f6952d701..f573695c6 100644 --- a/src/Instances/Attribute.luau +++ b/src/Instances/Attribute.luau @@ -10,11 +10,13 @@ local task = nil -- Disable usage of Roblox's task scheduler local Package = script.Parent.Parent local Types = require(Package.Types) -local External = require(Package.External) -local isState = require(Package.State.isState) -local Observer = require(Package.State.Observer) +-- Memory +local checkLifetime = require(Package.Memory.checkLifetime) +-- Graph +local Observer = require(Package.Graph.Observer) +-- State +local castToState = require(Package.State.castToState) local peek = require(Package.State.peek) -local whichLivesLonger = require(Package.Memory.whichLivesLonger) local keyCache: {[string]: Types.SpecialKey} = {} @@ -33,13 +35,13 @@ local function Attribute( value: unknown, applyTo: Instance ) - if isState(value) then + if castToState(value) then local value = value :: Types.StateObject - if value.scope == nil then - External.logError("useAfterDestroy", nil, `The {value.kind} object, bound to [Attribute "{attributeName}"],`, `the {applyTo.ClassName} instance`) - elseif whichLivesLonger(scope, applyTo, value.scope, value.oldestTask) == "definitely-a" then - External.logWarn("possiblyOutlives", `The {value.kind} object, bound to [Attribute "{attributeName}"],`, `the {applyTo.ClassName} instance`) - end + checkLifetime.bOutlivesA( + scope, applyTo, + value.scope, value.oldestTask, + checkLifetime.formatters.boundAttribute, attributeName + ) Observer(scope, value :: any):onBind(function() applyTo:SetAttribute(attributeName, peek(value)) end) diff --git a/src/Instances/AttributeOut.luau b/src/Instances/AttributeOut.luau index 3545c095c..deaefecd4 100644 --- a/src/Instances/AttributeOut.luau +++ b/src/Instances/AttributeOut.luau @@ -11,8 +11,10 @@ local task = nil -- Disable usage of Roblox's task scheduler local Package = script.Parent.Parent local Types = require(Package.Types) local External = require(Package.External) -local isState = require(Package.State.isState) -local whichLivesLonger = require(Package.Memory.whichLivesLonger) +-- Memory +local checkLifetime = require(Package.Memory.checkLifetime) +-- State +local castToState = require(Package.State.castToState) local keyCache: {[string]: Types.SpecialKey} = {} @@ -33,7 +35,7 @@ local function AttributeOut( ) local event = applyTo:GetAttributeChangedSignal(attributeName) - if not isState(value) then + if not castToState(value) then External.logError("invalidAttributeOutType") end local value = value :: Types.StateObject @@ -41,12 +43,12 @@ local function AttributeOut( External.logError("invalidAttributeOutType") end local value = value :: Types.Value - - if value.scope == nil then - External.logError("useAfterDestroy", nil, `The Value object, which [AttributeOut "{attributeName}"] outputs to,`, `the {applyTo.ClassName} instance`) - elseif whichLivesLonger(scope, applyTo, value.scope, value.oldestTask) == "definitely-a" then - External.logWarn("possiblyOutlives", `The Value object, which [AttributeOut "{attributeName}"] outputs to,`, `the {applyTo.ClassName} instance`) - end + checkLifetime.bOutlivesA( + scope, applyTo, + value.scope, value.oldestTask, + checkLifetime.formatters.attributeOutputsTo, attributeName + ) + value:set((applyTo :: any):GetAttribute(attributeName)) table.insert(scope, event:Connect(function() value:set((applyTo :: any):GetAttribute(attributeName)) diff --git a/src/Instances/Children.luau b/src/Instances/Children.luau index fff04aa9a..9e6822f11 100644 --- a/src/Instances/Children.luau +++ b/src/Instances/Children.luau @@ -11,11 +11,10 @@ local task = nil -- Disable usage of Roblox's task scheduler local Package = script.Parent.Parent local Types = require(Package.Types) local External = require(Package.External) -local Observer = require(Package.State.Observer) +local Observer = require(Package.Graph.Observer) local peek = require(Package.State.peek) -local isState = require(Package.State.isState) +local castToState = require(Package.State.castToState) local doCleanup = require(Package.Memory.doCleanup) -local scopePool = require(Package.Memory.scopePool) type Set = {[T]: unknown} @@ -45,8 +44,6 @@ return { local function updateChildren() oldParented, newParented = newParented, oldParented oldScopes, newScopes = newScopes, oldScopes - table.clear(newParented) - table.clear(newScopes) local function processChild( child: unknown, @@ -74,7 +71,7 @@ return { child.Name = autoName end - elseif isState(child) then + elseif castToState(child) then -- case 2; state object local child = child :: Types.StateObject @@ -131,12 +128,13 @@ return { for oldInstance in pairs(oldParented) do oldInstance.Parent = nil end + table.clear(oldParented) -- disconnect observers which weren't reused for oldState, childScope in pairs(oldScopes) do doCleanup(childScope) - scopePool.clearAndGive(childScope) end + table.clear(oldScopes) end table.insert(scope, function() diff --git a/src/Instances/New.luau b/src/Instances/New.luau index 0510ec525..ac994f5df 100644 --- a/src/Instances/New.luau +++ b/src/Instances/New.luau @@ -14,6 +14,8 @@ local External = require(Package.External) local defaultProps = require(Package.Instances.defaultProps) local applyInstanceProps = require(Package.Instances.applyInstanceProps) +type Component = (Types.PropertyTable) -> Instance + local function New( scope: Types.Scope, className: string @@ -22,6 +24,10 @@ local function New( local scope = (scope :: any) :: string External.logError("scopeMissing", nil, "instances using New", "myScope:New \"" .. scope .. "\" { ... }") end + + -- This might look appealing to try and cache. But please don't. The scope + -- upvalue is shared between the two curried function calls, so this will + -- open incredible cross-codebase wormholes like you've never seen before. return function( props: Types.PropertyTable ): Instance diff --git a/src/Instances/Out.luau b/src/Instances/Out.luau index 96f0adbf2..2cfe5669a 100644 --- a/src/Instances/Out.luau +++ b/src/Instances/Out.luau @@ -11,8 +11,10 @@ local task = nil -- Disable usage of Roblox's task scheduler local Package = script.Parent.Parent local Types = require(Package.Types) local External = require(Package.External) -local isState = require(Package.State.isState) -local whichLivesLonger = require(Package.Memory.whichLivesLonger) +-- Memory +local checkLifetime = require(Package.Memory.checkLifetime) +-- State +local castToState = require(Package.State.castToState) local keyCache: {[string]: Types.SpecialKey} = {} @@ -36,7 +38,7 @@ local function Out( External.logError("invalidOutProperty", nil, applyTo.ClassName, propertyName) end - if not isState(value) then + if not castToState(value) then External.logError("invalidOutType") end local value = value :: Types.StateObject @@ -44,12 +46,12 @@ local function Out( External.logError("invalidOutType") end local value = value :: Types.Value - - if value.scope == nil then - External.logError("useAfterDestroy", nil, `The Value, which [Out "{propertyName}"] outputs to,`, `the {applyTo.ClassName} instance`) - elseif whichLivesLonger(scope, applyTo, value.scope, value.oldestTask) == "definitely-a" then - External.logWarn("possiblyOutlives", `The Value, which [Out "{propertyName}"] outputs to,`, `the {applyTo.ClassName} instance`) - end + checkLifetime.bOutlivesA( + scope, applyTo, + value.scope, value.oldestTask, + checkLifetime.formatters.propertyOutputsTo, propertyName + ) + value:set((applyTo :: any)[propertyName]) table.insert( scope, diff --git a/src/Instances/applyInstanceProps.luau b/src/Instances/applyInstanceProps.luau index 7d0954a98..3dc56c615 100644 --- a/src/Instances/applyInstanceProps.luau +++ b/src/Instances/applyInstanceProps.luau @@ -19,12 +19,17 @@ local task = nil -- Disable usage of Roblox's task scheduler local Package = script.Parent.Parent local Types = require(Package.Types) local External = require(Package.External) -local isState = require(Package.State.isState) +-- Logging local parseError = require(Package.Logging.parseError) -local Observer = require(Package.State.Observer) +-- Memory +local checkLifetime = require(Package.Memory.checkLifetime) +-- Graph +local Observer = require(Package.Graph.Observer) +-- State +local castToState = require(Package.State.castToState) local peek = require(Package.State.peek) +-- Utility local xtypeof = require(Package.Utility.xtypeof) -local whichLivesLonger = require(Package.Memory.whichLivesLonger) local function setProperty_unsafe( instance: Instance, @@ -72,13 +77,13 @@ local function bindProperty( property: string, value: Types.UsedAs ) - if isState(value) then + if castToState(value) then local value = value :: Types.StateObject - if value.scope == nil then - External.logError("useAfterDestroy", nil, `The {value.kind} object, bound to {property},`, `the {instance.ClassName} instance`) - elseif whichLivesLonger(scope, instance, value.scope, value.oldestTask) == "definitely-a" then - External.logWarn("possiblyOutlives", `The {value.kind} object, bound to {property},`, `the {instance.ClassName} instance`) - end + checkLifetime.bOutlivesA( + scope, instance, + value.scope, value.oldestTask, + checkLifetime.formatters.boundProperty, property + ) -- value is a state object - bind to changes Observer(scope, value :: any):onBind(function() setProperty(instance, property, peek(value)) diff --git a/src/InternalTypes.luau b/src/InternalTypes.luau deleted file mode 100644 index be70642c7..000000000 --- a/src/InternalTypes.luau +++ /dev/null @@ -1,114 +0,0 @@ ---!strict ---!nolint LocalUnused ---!nolint LocalShadow -local task = nil -- Disable usage of Roblox's task scheduler - ---[[ - Stores common type information used internally. - - These types may be used internally so Fusion code can type-check, but - should never be exposed to public users, as these definitions are fair game - for breaking changes. -]] - -local Package = script.Parent -local Types = require(Package.Types) - ---[[ - General use types -]] - --- An object which stores a value scoped in time. -export type Contextual = Types.Contextual & { - _valuesNow: {[thread]: {value: T}}, - _defaultValue: T -} - ---[[ - Generic reactive graph types -]] - -export type StateObject = Types.StateObject & { - _peek: (StateObject) -> T -} - ---[[ - Specific reactive graph types -]] - --- A state object whose value can be set at any time by the user. -export type Value = Types.Value & { - _value: S -} - --- A state object whose value is derived from other objects using a callback. -export type Computed = Types.Computed & { - scope: Types.Scope?, - _oldDependencySet: {[Types.Dependency]: unknown}, - _processor: (Types.Use, Types.Scope) -> T, - _value: T, - _innerScope: Types.Scope? -} - --- A state object which maps over keys and/or values in another table. -export type For = Types.For & { - scope: Types.Scope?, - _processor: ( - Types.Scope, - Types.StateObject<{key: KI, value: VI}> - ) -> (Types.StateObject<{key: KO?, value: VO?}>), - _inputTable: Types.UsedAs<{[KI]: VI}>, - _existingInputTable: {[KI]: VI}?, - _existingOutputTable: {[KO]: VO}, - _existingProcessors: {[ForProcessor]: true}, - _newOutputTable: {[KO]: VO}, - _newProcessors: {[ForProcessor]: true}, - _remainingPairs: {[KI]: {[VI]: true}} -} -type ForProcessor = { - inputPair: Types.Value<{key: unknown, value: unknown}>, - outputPair: Types.StateObject<{key: unknown, value: unknown}>, - scope: Types.Scope? -} - --- A state object which follows another state object using tweens. -export type Tween = Types.Tween & { - _goal: Types.UsedAs, - _tweenInfo: TweenInfo, - _prevValue: T, - _nextValue: T, - _currentValue: T, - _currentTweenInfo: TweenInfo, - _currentTweenDuration: number, - _currentTweenStartTime: number, - _currentlyAnimating: boolean -} - --- A state object which follows another state object using spring simulation. -export type Spring = Types.Spring & { - _speed: Types.UsedAs, - _damping: Types.UsedAs, - _goal: Types.UsedAs, - _goalValue: T, - - _currentType: string, - _currentValue: T, - _currentSpeed: number, - _currentDamping: number, - - _springPositions: {number}, - _springGoals: {number}, - _springVelocities: {number}, - - _lastSchedule: number, - _startDisplacements: {number}, - _startVelocities: {number} -} - --- An object which can listen for updates on another state object. -export type Observer = Types.Observer & { - _changeListeners: {[{}]: () -> ()}, - _numChangeListeners: number -} - -return nil \ No newline at end of file diff --git a/src/Logging/messages.luau b/src/Logging/messages.luau index 9dd95b6ed..5a4250264 100644 --- a/src/Logging/messages.luau +++ b/src/Logging/messages.luau @@ -8,15 +8,17 @@ local task = nil -- Disable usage of Roblox's task scheduler ]] return { - callbackError = "Error in callback: ERROR_MESSAGE", + callbackError = "Error in callback:\nERROR_MESSAGE", cannotAssignProperty = "The class type '%s' has no assignable property '%s'.", cannotConnectChange = "The %s class doesn't have a property called '%s'.", cannotConnectEvent = "The %s class doesn't have an event called '%s'.", cannotCreateClass = "Can't create a new instance of class '%s'.", + cannotDepend = "%s can't depend on %s.", cleanupWasRenamed = "`Fusion.cleanup` was renamed to `Fusion.doCleanup`. This will be an error in future versions of Fusion.", destroyedTwice = "`doCleanup()` was given something that it is already cleaning up. Unclear how to proceed.", destructorRedundant = "%s destructors no longer do anything. If you wish to run code on destroy, `table.insert` a function into the `scope` argument. See discussion #292 on GitHub for advice.", forKeyCollision = "The key '%s' was returned multiple times simultaneously, which is not allowed in `For` objects.", + infiniteLoop = "Detected an infinite loop. Consider adding an explicit breakpoint to your code to prevent a cyclic dependency.", invalidAttributeChangeHandler = "The change handler for the '%s' attribute must be a function.", invalidAttributeOutType = "[AttributeOut] properties must be given Value objects.", invalidChangeHandler = "The change handler for the '%s' property must be a function.", @@ -32,14 +34,17 @@ return { 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.", - possiblyOutlives = "%s could be destroyed before %s; review the order they're created in, and what scopes they belong to. See discussion #292 on GitHub for advice.", - propertySetError = "Error setting property: ERROR_MESSAGE", + poisonedScope = "Attempted to use a scope after it's been destroyed; %s", + possiblyOutlives = "%s will be destroyed before %s; %s. To fix this, review the order they're created in, and what scopes they belong to. See discussion #292 on GitHub for advice.", + propertySetError = "Error setting property:\nERROR_MESSAGE", scopeMissing = "To create %s, provide a scope. (e.g. `%s`). See discussion #292 on GitHub for advice.", springNanGoal = "A spring was given a NaN goal, so some simulation has been skipped. Ensure no springs have NaN goals.", springNanMotion = "A spring encountered NaN during motion, so has snapped to the goal position. Ensure no springs have NaN positions or velocities.", 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.", - unknownMessage = "Unknown error: ERROR_MESSAGE", + tweenNanGoal = "A tween was given a NaN goal, so some animation has been skipped. Ensure no tweens have NaN goals.", + tweenNanMotion = "A tween encountered NaN during motion, so has snapped to the goal. Ensure no tweens have NaN in their tween infos.", + unknownMessage = "Unknown error:\nERROR_MESSAGE", unrecognisedChildType = "'%s' type children aren't accepted by `[Children]`.", unrecognisedPropertyKey = "'%s' keys aren't accepted in property tables.", unrecognisedPropertyStage = "'%s' isn't a valid stage for a special key to be applied at.", diff --git a/src/Memory/checkLifetime.luau b/src/Memory/checkLifetime.luau new file mode 100644 index 000000000..e8f075072 --- /dev/null +++ b/src/Memory/checkLifetime.luau @@ -0,0 +1,134 @@ + + +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + Procedures for checking lifetimes and printing helpful warnings about them. +]] + +local Package = script.Parent.Parent +local Types = require(Package.Types) +local External = require(Package.External) +local whichLivesLonger = require(Package.Memory.whichLivesLonger) +local nameOf = require(Package.Utility.nameOf) + +local checkLifetime = {} + +checkLifetime.formatters = {} + +function checkLifetime.formatters.useFunction( + self: unknown, + used: unknown +): (string, string) + local selfName = nameOf(self, "object") + local usedName = nameOf(used, "object") + return `The use()-d {usedName}`, `the {selfName}` +end + +function checkLifetime.formatters.boundProperty( + instance: Instance, + bound: unknown, + property: string +): (string, string) + local selfName = instance.Name + local boundName = nameOf(bound, "value") + return `The {boundName} (bound to the {property} property)`, `the {selfName} instance` +end + +function checkLifetime.formatters.boundAttribute( + instance: Instance, + bound: unknown, + attribute: string +): (string, string) + local selfName = instance.Name + local boundName = nameOf(bound, "value") + return `The {boundName} (bound to the {attribute} attribute)`, `the {selfName} instance` +end + +function checkLifetime.formatters.propertyOutputsTo( + instance: Instance, + bound: unknown, + property: string +): (string, string) + local selfName = instance.Name + local boundName = nameOf(bound, "object") + return `The {boundName} (which the {property} property outputs to)`, `the {selfName} instance` +end + +function checkLifetime.formatters.attributeOutputsTo( + instance: Instance, + bound: unknown, + attribute: string +): (string, string) + local selfName = instance.Name + local boundName = nameOf(bound, "object") + return `The {boundName} (which the {attribute} attribute outputs to)`, `the {selfName} instance` +end + +function checkLifetime.formatters.refOutputsTo( + instance: Instance, + bound: unknown +): (string, string) + local selfName = instance.Name + local boundName = nameOf(bound, "object") + return `The {boundName} (which the Ref key outputs to)`, `the {selfName} instance` +end + +function checkLifetime.formatters.animationGoal( + self: unknown, + goal: unknown +): (string, string) + local selfName = nameOf(self, "object") + local goalName = nameOf(goal, "object") + return `The goal {goalName}`, `the {selfName} that is following it` +end + +function checkLifetime.formatters.parameter( + self: unknown, + used: unknown, + parameterName: string | false +): (string, string) + local selfName = nameOf(self, "object") + local usedName = nameOf(used, "object") + if parameterName == false then + return `The {usedName} parameter`, `the {selfName} that it was used for` + else + return `The {usedName} representing the {parameterName} parameter`, `the {selfName} that it was used for` + end +end + +function checkLifetime.formatters.observer( + self: unknown, + watched: unknown +): (string, string) + local selfName = nameOf(self, "object") + local watchedName = nameOf(watched, "object") + return `The watched {watchedName}`, `the {selfName} that's observing it for changes` +end + +function checkLifetime.bOutlivesA( + scopeA: Types.Scope, + a: A, + scopeB: Types.Scope?, + b: B, + formatter: (a: A, b: B, Args...) -> (string, string), + ...: Args... +) + if scopeB == nil then + External.logError("useAfterDestroy", nil, formatter(a, b, ...)) + elseif whichLivesLonger(scopeA, a, scopeB, b) == "definitely-a" then + local aName, bName = formatter(a, b, ...) + External.logWarn( + "possiblyOutlives", + aName, bName, + if scopeA == scopeB then + "they're in the same scope, but the latter is destroyed too quickly" + else + "the latter is in a different scope that gets destroyed too quickly" + ) + end +end +return checkLifetime \ No newline at end of file diff --git a/src/Memory/deriveScope.luau b/src/Memory/deriveScope.luau index 46a381110..2ef921ff0 100644 --- a/src/Memory/deriveScope.luau +++ b/src/Memory/deriveScope.luau @@ -11,6 +11,7 @@ local task = nil -- Disable usage of Roblox's task scheduler debugging hooks. ]] local Package = script.Parent.Parent +local Types = require(Package.Types) local ExternalDebug = require(Package.ExternalDebug) local deriveScopeImpl = require(Package.Memory.deriveScopeImpl) @@ -20,4 +21,4 @@ local function deriveScope(...) return scope end -return deriveScope \ No newline at end of file +return deriveScope :: Types.DeriveScopeConstructor \ No newline at end of file diff --git a/src/Memory/doCleanup.luau b/src/Memory/doCleanup.luau index 2cf856413..63aa0d97a 100644 --- a/src/Memory/doCleanup.luau +++ b/src/Memory/doCleanup.luau @@ -16,6 +16,8 @@ local task = nil -- Disable usage of Roblox's task scheduler local Package = script.Parent.Parent local Types = require(Package.Types) local External = require(Package.External) +local scopePool = require(Package.Memory.scopePool) +local poisonScope = require(Package.Memory.poisonScope) local alreadyDestroying: {[Types.Task]: true} = {} @@ -52,15 +54,22 @@ local function doCleanup( local task = (task :: any) :: {Destroy: (...unknown) -> (...unknown)} task:Destroy() - -- case 6: array of tasks + -- case 6: table of tasks with an array part elseif task[1] ~= nil then local task = task :: {Types.Task} + -- It is important to iterate backwards through the table, since -- objects are added in order of construction. for index = #task, 1, -1 do doCleanup(task[index]) task[index] = nil end + + if External.isTimeCritical() then + scopePool.giveIfEmpty(task) + else + poisonScope(task, "`doCleanup()` was previously called on this scope. Ensure you are not reusing scopes after cleanup.") + end end end diff --git a/src/Memory/poisonScope.luau b/src/Memory/poisonScope.luau new file mode 100644 index 000000000..316f45ce0 --- /dev/null +++ b/src/Memory/poisonScope.luau @@ -0,0 +1,34 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + 'Poisons' the given scope; if the scope is used again, then it will cause + the program to crash. +]] +local Package = script.Parent.Parent +local Types = require(Package.Types) +local External = require(Package.External) + +local function poisonScope( + scope: Types.Scope, + context: string +): () + local mt = getmetatable(scope) + if typeof(mt) == "table" and mt._FUSION_POISONED then + return + end + table.clear(scope) + setmetatable(scope :: any, { + _FUSION_POISONED = true, + __index = function() + External.logError("poisonedScope", nil, context) + end, + __newindex = function() + External.logError("poisonedScope", nil, context) + end + }) +end + +return poisonScope \ No newline at end of file diff --git a/src/Memory/scopePool.luau b/src/Memory/scopePool.luau index 7a0da26be..12b7dcece 100644 --- a/src/Memory/scopePool.luau +++ b/src/Memory/scopePool.luau @@ -5,8 +5,10 @@ local task = nil -- Disable usage of Roblox's task scheduler local Package = script.Parent.Parent local Types = require(Package.Types) +local poisonScope = require(Package.Memory.poisonScope) local ExternalDebug = require(Package.ExternalDebug) +local ENABLE_POOLING = false local MAX_POOL_SIZE = 16 -- TODO: need to test what an ideal number for this is local pool = {} @@ -18,9 +20,11 @@ return { ): Types.Scope? if next(scope) == nil then ExternalDebug.untrackScope(scope) - if poolSize < MAX_POOL_SIZE then + if ENABLE_POOLING and poolSize < MAX_POOL_SIZE then poolSize += 1 pool[poolSize] = scope + else + poisonScope(scope, "previously passed to the internal scope pool, which indicates a Fusion bug.") end return nil else @@ -31,10 +35,12 @@ return { scope: Types.Scope ) ExternalDebug.untrackScope(scope) - if poolSize < MAX_POOL_SIZE then - table.clear(scope) + table.clear(scope) + if ENABLE_POOLING and poolSize < MAX_POOL_SIZE then poolSize += 1 pool[poolSize] = scope :: any + else + poisonScope(scope, "previously passed to the internal scope pool, which indicates a Fusion bug.") end end, reuseAny = function(): Types.Scope diff --git a/src/Memory/whichLivesLonger.luau b/src/Memory/whichLivesLonger.luau index 09fb76dad..76b7e7c62 100644 --- a/src/Memory/whichLivesLonger.luau +++ b/src/Memory/whichLivesLonger.luau @@ -10,6 +10,7 @@ local task = nil -- Disable usage of Roblox's task scheduler ]] local Package = script.Parent.Parent local Types = require(Package.Types) +local External = require(Package.External) local function whichScopeLivesLonger( scopeA: Types.Scope, @@ -19,7 +20,8 @@ local function whichScopeLivesLonger( -- scope must live longer than the inner scope (assuming idiomatic scopes). -- So, we will search the scopes recursively until we find one of them, at -- which point we know they must have been found inside the other scope. - local openSet, nextOpenSet = {scopeA, scopeB}, {} + local openSet: {Types.Scope} = {scopeA, scopeB} + local nextOpenSet: {Types.Scope} = {} local openSetSize, nextOpenSetSize = 2, 0 local closedSet = {} while openSetSize > 0 do @@ -34,7 +36,7 @@ local function whichScopeLivesLonger( local inScope = inScope :: {unknown} if inScope[1] ~= nil and closedSet[scope] == nil then nextOpenSetSize += 1 - nextOpenSet[nextOpenSetSize] = inScope + nextOpenSet[nextOpenSetSize] = inScope :: Types.Scope end end end @@ -52,7 +54,9 @@ local function whichLivesLonger( scopeB: Types.Scope, b: unknown ): "definitely-a" | "definitely-b" | "unsure" - if scopeA == scopeB then + if External.isTimeCritical() then + return "unsure" + elseif scopeA == scopeB then local scopeA: {unknown} = scopeA for index = #scopeA, 1, -1 do local value = scopeA[index] diff --git a/src/State/Computed.luau b/src/State/Computed.luau index 39ea8cd59..df09ed2fb 100644 --- a/src/State/Computed.luau +++ b/src/State/Computed.luau @@ -4,163 +4,136 @@ local task = nil -- Disable usage of Roblox's task scheduler --[[ - Constructs and returns objects which can be used to model derived reactive - state. + A specialised state object for tracking single values computed from a + user-defined computation. + + https://elttob.uk/Fusion/0.3/api-reference/state/types/computed/ ]] local Package = script.Parent.Parent local Types = require(Package.Types) -local InternalTypes = require(Package.InternalTypes) local External = require(Package.External) -- Logging local parseError = require(Package.Logging.parseError) -- Utility local isSimilar = require(Package.Utility.isSimilar) +local never = require(Package.Utility.never) +-- Graph +local depend = require(Package.Graph.depend) -- State -local isState = require(Package.State.isState) +local castToState = require(Package.State.castToState) +local peek = require(Package.State.peek) -- Memory local doCleanup = require(Package.Memory.doCleanup) local deriveScope = require(Package.Memory.deriveScope) -local whichLivesLonger = require(Package.Memory.whichLivesLonger) +local checkLifetime = require(Package.Memory.checkLifetime) local scopePool = require(Package.Memory.scopePool) +-- Utility +local nicknames = require(Package.Utility.nicknames) + +type Self = Types.Computed & { + _innerScope: Types.Scope?, + _processor: (Types.Use, Types.Scope) -> T +} local class = {} class.type = "State" class.kind = "Computed" +class.timeliness = "lazy" -local CLASS_METATABLE = {__index = class} +local METATABLE = table.freeze {__index = class} ---[[ - Called when a dependency changes value. - Recalculates this Computed's cached value and dependencies. - Returns true if it changed, or false if it's identical. -]] -function class:update(): boolean - local self = self :: InternalTypes.Computed - if self.scope == nil then - return false +local function Computed( + scope: S & Types.Scope, + processor: (Types.Use, S) -> T, + destructor: unknown? +): Types.Computed + local createdAt = os.clock() + if typeof(scope) == "function" then + External.logError("scopeMissing", nil, "Computeds", "myScope:Computed(function(use, scope) ... end)") + elseif destructor ~= nil then + External.logWarn("destructorRedundant", "Computed") end - local outerScope = self.scope :: Types.Scope - - -- remove this object from its dependencies' dependent sets - for dependency in pairs(self.dependencySet) do - dependency.dependentSet[self] = nil + local self: Self = setmetatable( + { + createdAt = createdAt, + dependencySet = {}, + dependentSet = {}, + lastChange = nil, + scope = scope, + validity = "invalid", + _EXTREMELY_DANGEROUS_usedAsValue = nil, + _innerScope = nil, + _processor = processor + }, + METATABLE + ) :: any + local destroy = function() + self.scope = nil + for dependency in pairs(self.dependencySet) do + dependency.dependentSet[self] = nil + end + if self._innerScope ~= nil then + doCleanup(self._innerScope) + end end + self.oldestTask = destroy + nicknames[self.oldestTask] = "Computed" + table.insert(scope, destroy) + return self +end - -- we need to create a new, empty dependency set to capture dependencies - -- into, but in case there's an error, we want to restore our old set of - -- dependencies. by using this table-swapping solution, we can avoid the - -- overhead of allocating new tables each update. - self._oldDependencySet, self.dependencySet = self.dependencySet, self._oldDependencySet - table.clear(self.dependencySet) +function class.get( + _self: Self +): never + External.logError("stateGetWasRemoved") + return never() +end +function class._evaluate( + self: Self +): boolean + if self.scope == nil then + return false + end + local outerScope = self.scope :: S & Types.Scope local innerScope = deriveScope(outerScope) local function use(target: Types.UsedAs): T - if isState(target) then - local target = target :: Types.StateObject - if target.scope == nil then - External.logError("useAfterDestroy", nil, `The {target.kind} object`, "the Computed that is use()-ing it") - elseif whichLivesLonger(outerScope, self.oldestTask, target.scope, target.oldestTask) == "definitely-a" then - External.logWarn("possiblyOutlives", `The {target.kind} object`, "the Computed that is use()-ing it") - end - self.dependencySet[target] = true - return (target :: InternalTypes.StateObject):_peek() - else - return target :: T + local targetState = castToState(target) + if targetState ~= nil then + checkLifetime.bOutlivesA( + outerScope, self.oldestTask, + targetState.scope, targetState.oldestTask, + checkLifetime.formatters.useFunction + ) + depend(self, targetState) end + return peek(target) end local ok, newValue = xpcall(self._processor, parseError, use, innerScope) local innerScope = scopePool.giveIfEmpty(innerScope) - if ok then - local oldValue = self._value - local similar = isSimilar(oldValue, newValue) + local similar = isSimilar(self._EXTREMELY_DANGEROUS_usedAsValue, newValue) if self._innerScope ~= nil then doCleanup(self._innerScope) - scopePool.clearAndGive(self._innerScope) end - self._value = newValue self._innerScope = innerScope - -- add this object to the dependencies' dependent sets - for dependency in pairs(self.dependencySet) do - dependency.dependentSet[self] = true - end - + self._EXTREMELY_DANGEROUS_usedAsValue = newValue return not similar else local errorObj = (newValue :: any) :: Types.Error - -- this needs to be non-fatal, because otherwise it'd disrupt the - -- update process - External.logErrorNonFatal("callbackError", errorObj) - if innerScope ~= nil then doCleanup(innerScope) - scopePool.clearAndGive(self._innerScope :: any) - self._innerScope = nil end - - -- restore old dependencies, because the new dependencies may be corrupt - self._oldDependencySet, self.dependencySet = self.dependencySet, self._oldDependencySet - - -- restore this object in the dependencies' dependent sets - for dependency in pairs(self.dependencySet) do - dependency.dependentSet[self] = true - end - + innerScope = nil + + -- this needs to be non-fatal, because otherwise it'd disrupt the + -- update process + External.logErrorNonFatal("callbackError", errorObj) return false end end ---[[ - Returns the interior value of this state object. -]] -function class:_peek(): unknown - local self = self :: InternalTypes.Computed - return self._value -end - -function class:get() - External.logError("stateGetWasRemoved") -end - -local function Computed( - scope: Types.Scope, - processor: (Types.Use, Types.Scope) -> T, - destructor: unknown? -): Types.Computed - if typeof(scope) == "function" then - External.logError("scopeMissing", nil, "Computeds", "myScope:Computed(function(use, scope) ... end)") - elseif destructor ~= nil then - External.logWarn("destructorRedundant", "Computed") - end - local self = setmetatable({ - scope = scope, - dependencySet = {}, - dependentSet = {}, - _oldDependencySet = {}, - _processor = processor, - _value = nil, - _innerScope = nil - }, CLASS_METATABLE) - local self = (self :: any) :: InternalTypes.Computed - - local destroy = function() - self.scope = nil - for dependency in pairs(self.dependencySet) do - dependency.dependentSet[self] = nil - end - if self._innerScope ~= nil then - doCleanup(self._innerScope) - scopePool.clearAndGive(self._innerScope) - end - end - self.oldestTask = destroy - table.insert(scope, destroy) - - self:update() - - return self -end - -return Computed \ No newline at end of file +table.freeze(class) +return Computed :: Types.ComputedConstructor \ No newline at end of file diff --git a/src/State/For.luau b/src/State/For.luau deleted file mode 100644 index 2befd16b0..000000000 --- a/src/State/For.luau +++ /dev/null @@ -1,252 +0,0 @@ ---!strict ---!nolint LocalUnused ---!nolint LocalShadow -local task = nil -- Disable usage of Roblox's task scheduler - ---[[ - The private generic implementation for all public `For` objects. -]] - -local Package = script.Parent.Parent -local Types = require(Package.Types) -local InternalTypes = require(Package.InternalTypes) -local External = require(Package.External) --- Logging -local parseError = require(Package.Logging.parseError) --- State -local peek = require(Package.State.peek) -local isState = require(Package.State.isState) -local Value = require(Package.State.Value) --- Memory -local doCleanup = require(Package.Memory.doCleanup) -local deriveScope = require(Package.Memory.deriveScope) -local scopePool = require(Package.Memory.scopePool) - -local class = {} -class.type = "State" -class.kind = "For" - -local CLASS_METATABLE = { __index = class } - ---[[ - Called when the original table is changed. -]] - -function class:update(): boolean - local self = self :: InternalTypes.For - if self.scope == nil then - return false - end - local outerScope = self.scope :: Types.Scope - local existingInputTable = self._existingInputTable - local existingOutputTable = self._existingOutputTable - local existingProcessors = self._existingProcessors - local newInputTable = peek(self._inputTable) - local newOutputTable = self._newOutputTable - local newProcessors = self._newProcessors - local remainingPairs = self._remainingPairs - - -- clean out main dependency set - for dependency in pairs(self.dependencySet) do - dependency.dependentSet[self] = nil - end - table.clear(self.dependencySet) - - if isState(self._inputTable) then - local inputTable = self._inputTable :: Types.StateObject<{[unknown]: unknown}> - inputTable.dependentSet[self], self.dependencySet[inputTable] = true, true - end - - if newInputTable ~= existingInputTable then - for key, value in newInputTable do - if remainingPairs[key] == nil then - remainingPairs[key] = {[value] = true} - else - remainingPairs[key][value] = true - end - end - - -- First, try and reuse processors who match both the key and value of a - -- remaining pair. This can be done with no recomputation. - -- NOTE: we also reuse processors with nil output keys here, so long as - -- they match values. This ensures they don't get recomputed either. - for tryReuseProcessor in existingProcessors do - local value = peek(tryReuseProcessor.inputPair).value - if peek(tryReuseProcessor.outputPair).key == nil then - for key, remainingValues in remainingPairs do - if remainingValues[value] ~= nil then - remainingValues[value] = nil - tryReuseProcessor.inputPair:set({key = key, value = value}) - newProcessors[tryReuseProcessor] = true - existingProcessors[tryReuseProcessor] = nil - break - end - end - else - local key = peek(tryReuseProcessor.inputPair).key - local remainingValues = remainingPairs[key] - if remainingValues ~= nil and remainingValues[value] ~= nil then - remainingValues[value] = nil - newProcessors[tryReuseProcessor] = true - existingProcessors[tryReuseProcessor] = nil - end - end - - end - -- Next, try and reuse processors who match the key of a remaining pair. - -- The value will change but the key will stay stable. - for tryReuseProcessor in existingProcessors do - local key = peek(tryReuseProcessor.inputPair).key - local remainingValues = remainingPairs[key] - if remainingValues ~= nil then - local value = next(remainingValues) - if value ~= nil then - remainingValues[value] = nil - tryReuseProcessor.inputPair:set({key = key, value = value}) - newProcessors[tryReuseProcessor] = true - existingProcessors[tryReuseProcessor] = nil - end - end - end - -- Next, try and reuse processors who match the value of a remaining pair. - -- The key will change but the value will stay stable. - for tryReuseProcessor in existingProcessors do - local value = peek(tryReuseProcessor.inputPair).value - for key, remainingValues in remainingPairs do - if remainingValues[value] ~= nil then - remainingValues[value] = nil - tryReuseProcessor.inputPair:set({key = key, value = value}) - newProcessors[tryReuseProcessor] = true - existingProcessors[tryReuseProcessor] = nil - break - end - end - end - -- Finally, try and reuse any remaining processors, even if they do not - -- match a pair. Both key and value will be changed. - for tryReuseProcessor in existingProcessors do - for key, remainingValues in remainingPairs do - local value = next(remainingValues) - if value ~= nil then - remainingValues[value] = nil - tryReuseProcessor.inputPair:set({key = key, value = value}) - newProcessors[tryReuseProcessor] = true - existingProcessors[tryReuseProcessor] = nil - break - end - end - end - -- By this point, we can be in one of three cases: - -- 1) some existing processors are left over; no remaining pairs (shrunk) - -- 2) no existing processors are left over; no remaining pairs (same size) - -- 3) no existing processors are left over; some remaining pairs (grew) - -- So, existing processors should be destroyed, and remaining pairs should - -- be created. This accomodates for table growth and shrinking. - for unusedProcessor in existingProcessors do - doCleanup(unusedProcessor.scope) - scopePool.clearAndGive(unusedProcessor.scope :: any) - end - - for key, remainingValues in remainingPairs do - for value in remainingValues do - local innerScope = deriveScope(outerScope) - local inputPair = Value(innerScope, {key = key, value = value}) - local processOK, outputPair = xpcall(self._processor, parseError, innerScope, inputPair) - local innerScope = scopePool.giveIfEmpty(innerScope) - if processOK then - local processor = { - inputPair = inputPair, - outputPair = outputPair, - scope = innerScope - } - newProcessors[processor] = true - else - local errorObj = (outputPair :: any) :: Types.Error - External.logErrorNonFatal("callbackError", errorObj) - end - end - end - end - - for processor in newProcessors do - local pair = processor.outputPair - pair.dependentSet[self], self.dependencySet[pair] = true, true - local key, value = peek(pair).key, peek(pair).value - if value == nil then - continue - end - if key == nil then - key = #newOutputTable + 1 - end - if newOutputTable[key] == nil then - newOutputTable[key] = value - else - External.logErrorNonFatal("forKeyCollision", nil, key) - end - end - - self._existingProcessors = newProcessors - self._existingOutputTable = newOutputTable - table.clear(existingOutputTable) - table.clear(existingProcessors) - table.clear(remainingPairs) - self._newProcessors = existingProcessors - self._newOutputTable = existingOutputTable - - return true -end - ---[[ - Returns the interior value of this state object. -]] -function class:_peek(): unknown - return self._existingOutputTable -end - -function class:get() - External.logError("stateGetWasRemoved") -end - -local function For( - scope: Types.Scope, - inputTable: Types.UsedAs<{ [KI]: VI }>, - processor: ( - Types.Scope, - Types.StateObject<{key: KI, value: VI}> - ) -> (Types.StateObject<{key: KO?, value: VO?}>) -): Types.For - - local self = setmetatable({ - scope = scope, - dependencySet = {}, - dependentSet = {}, - _processor = processor, - _inputTable = inputTable, - _existingInputTable = nil, - _existingOutputTable = {}, - _existingProcessors = {}, - _newOutputTable = {}, - _newProcessors = {}, - _remainingPairs = {} - }, CLASS_METATABLE) - local self = (self :: any) :: InternalTypes.For - - local destroy = function() - self.scope = nil - for dependency in pairs(self.dependencySet) do - dependency.dependentSet[self] = nil - end - for unusedProcessor in self._existingProcessors do - doCleanup(unusedProcessor.scope) - scopePool.clearAndGive(unusedProcessor.scope) - end - end - self.oldestTask = destroy - table.insert(scope, destroy) - - self:update() - - return self -end - -return For \ No newline at end of file diff --git a/src/State/For/Disassembly.luau b/src/State/For/Disassembly.luau new file mode 100644 index 000000000..a7c760581 --- /dev/null +++ b/src/State/For/Disassembly.luau @@ -0,0 +1,211 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + Breaks down an input table into reactive sub-objects for each pair. +]] + +local Package = script.Parent.Parent.Parent +local Types = require(Package.Types) +local External = require(Package.External) +-- Graph +local depend = require(Package.Graph.depend) +-- State +local peek = require(Package.State.peek) +local castToState = require(Package.State.castToState) +local ForTypes = require(Package.State.For.ForTypes) +-- Memory +local doCleanup = require(Package.Memory.doCleanup) +local deriveScope = require(Package.Memory.deriveScope) +local scopePool = require(Package.Memory.scopePool) +-- Utility +local nameOf = require(Package.Utility.nameOf) +local nicknames = require(Package.Utility.nicknames) + +type Self = ForTypes.Disassembly & { + scope: (S & Types.Scope)?, + _inputTable: Types.UsedAs<{[KI]: VI}>, + _constructor: ( + Types.Scope, + initialKey: KI, + initialValue: VI + ) -> ForTypes.SubObject, + _subObjects: {[ForTypes.SubObject]: true} +} + + +local class = {} +class.type = "Graph" +class.kind = "For.Disassembly" +class.timeliness = "lazy" + +local METATABLE = table.freeze {__index = class} + +local function Disassembly( + scope: S & Types.Scope, + inputTable: Types.UsedAs<{[KI]: VI}>, + constructor: ( + Types.Scope, + initialKey: KI, + initialValue: VI + ) -> ForTypes.SubObject +): ForTypes.Disassembly + local createdAt = os.clock() + local self = setmetatable( + { + createdAt = createdAt, + dependencySet = {}, + dependentSet = {}, + scope = scope, + validity = "invalid", + _inputTable = inputTable, + _constructor = constructor, + _subObjects = {} + }, + METATABLE + ) :: any + + local destroy = function() + self.scope = nil + for dependency in pairs(self.dependencySet) do + dependency.dependentSet[self] = nil + end + for subObject in self._subObjects do + if subObject.maybeScope ~= nil then + doCleanup(subObject.maybeScope) + subObject.maybeScope = nil + end + end + end + self.oldestTask = destroy + nicknames[self.oldestTask] = "For (internal disassembler)" + table.insert(scope, destroy) + + return self +end + +function class.populate( + self: Self, + use: Types.Use, + output: {[KO]: VO} +): () + local minArrayIndex = math.huge + local maxArrayIndex = -math.huge + local hasHoles = false + for subObject in self._subObjects do + local outputKey, outputValue = subObject:useOutputPair(use) + if outputKey == nil or outputValue == nil then + hasHoles = true + continue + elseif output[outputKey] ~= nil then + External.logErrorNonFatal("forKeyCollision", nil, tostring(outputKey)) + continue + end + output[outputKey] = outputValue + if typeof(outputKey) == "number" then + minArrayIndex = math.min(minArrayIndex, outputKey) + maxArrayIndex = math.max(maxArrayIndex, outputKey) + end + end + -- Be careful of NaN here + if hasHoles and maxArrayIndex > minArrayIndex then + local output: {[number]: VO} = output :: any + local moveToIndex = minArrayIndex + for moveFromIndex = minArrayIndex, maxArrayIndex do + local outputValue = output[moveFromIndex] + if outputValue == nil then + continue + end + -- The ordering is important in case the indices are the same + output[moveFromIndex] = nil + output[moveToIndex] = outputValue + moveToIndex += 1 + end + end +end + +function class._evaluate( + self: Self +): boolean + local outerScope = self.scope :: S & Types.Scope + local inputState = castToState(self._inputTable) + if inputState ~= nil then + if inputState.scope == nil then + External.logError( + "useAfterDestroy", + nil, + `The input {nameOf(inputState, "table")}`, + `the For object that is watching it` + ) + end + depend(self, inputState) + end + + local pendingPairs = {} :: {[KI]: VI} + for key, value in peek(self._inputTable) do + pendingPairs[key] = value + end + + local newSubObjects = {} :: typeof(self._subObjects) + + for subObject in self._subObjects do + local reused = false + local oldInputKey = subObject.inputKey + local oldInputValue = subObject.inputValue + local newInputKey: KI + -- Reuse when the keys are identical. + if not subObject.roamKeys and pendingPairs[oldInputKey] ~= nil then + reused = true + newInputKey = oldInputKey + else -- Try and reuse some other pair instead. + for pendingKey, pendingValue in pendingPairs do + reused = true + newInputKey = pendingKey + if subObject.roamValues then + break + end + if pendingValue == oldInputValue then + -- If the values are the same, then no need to update those, + -- so prefer this choice to any other. + break + end + end + end + if reused then + local newInputValue = pendingPairs[newInputKey] + newSubObjects[subObject] = true + if newInputKey ~= oldInputKey then + subObject.inputKey = newInputKey + subObject:invalidateInputKey() + end + if newInputValue ~= oldInputValue then + subObject.inputValue = newInputValue + subObject:invalidateInputValue() + end + pendingPairs[newInputKey] = nil + else -- Too many sub objects for the number of pairs. + if subObject.maybeScope ~= nil then + doCleanup(subObject.maybeScope) + subObject.maybeScope = nil + end + end + end + + -- Generate new objects if needed to cover the remaining pending pairs. + for pendingKey, pendingValue in pendingPairs do + local subObject = self._constructor(deriveScope(outerScope), pendingKey, pendingValue) + if subObject.maybeScope ~= nil then + subObject.maybeScope = scopePool.giveIfEmpty(subObject.maybeScope) + end + newSubObjects[subObject] = true + end + + self._subObjects = newSubObjects + + return true +end + +table.freeze(class) +return Disassembly \ No newline at end of file diff --git a/src/State/For/ForTypes.luau b/src/State/For/ForTypes.luau new file mode 100644 index 000000000..6d6dca291 --- /dev/null +++ b/src/State/For/ForTypes.luau @@ -0,0 +1,30 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + Stores types that are commonly used between For objects. +]] + +local Package = script.Parent.Parent.Parent +local Types = require(Package.Types) + +export type SubObject = { + -- Not all sub objects need to store a scope, for example if the scope + -- remains empty, it'll be given back to the scope pool. + maybeScope: Types.Scope?, + inputKey: KI, + inputValue: VI, + roamKeys: boolean, + roamValues: boolean, + invalidateInputKey: (SubObject) -> (), + invalidateInputValue: (SubObject) -> (), + useOutputPair: (SubObject, Types.Use) -> (KO?, VO?) +} + +export type Disassembly = Types.GraphObject & { + populate: (Disassembly, Types.Use, output: {[KO]: VO}) -> () +} + +return nil diff --git a/src/State/For/init.luau b/src/State/For/init.luau new file mode 100644 index 000000000..2da2b2136 --- /dev/null +++ b/src/State/For/init.luau @@ -0,0 +1,110 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + The generic implementation for all `For` objects. +]] + +local Package = script.Parent.Parent +local Types = require(Package.Types) +local External = require(Package.External) +-- Graph +local depend = require(Package.Graph.depend) +-- State +local peek = require(Package.State.peek) +local castToState = require(Package.State.castToState) +local ForTypes = require(Package.State.For.ForTypes) +-- Utility +local never = require(Package.Utility.never) +local nicknames = require(Package.Utility.nicknames) + +local Disassembly = require(Package.State.For.Disassembly) + +type Self = Types.For & { + _disassembly: ForTypes.Disassembly +} + +local class = {} +class.type = "State" +class.kind = "For" +class.timeliness = "lazy" + +local METATABLE = table.freeze {__index = class} + +local function For( + scope: Types.Scope, + inputTable: Types.UsedAs<{[KI]: VI}>, + constructor: ( + Types.Scope, + initialKey: KI, + initialValue: VI + ) -> ForTypes.SubObject +): Types.For + local createdAt = os.clock() + local self: Self = setmetatable( + { + createdAt = createdAt, + dependencySet = {}, + dependentSet = {}, + scope = scope, + validity = "invalid", + _EXTREMELY_DANGEROUS_usedAsValue = {}, + _disassembly = Disassembly( + scope, + inputTable, + constructor + ) + }, + METATABLE + ) :: any + + local destroy = function() + self.scope = nil + for dependency in pairs(self.dependencySet) do + dependency.dependentSet[self] = nil + end + end + self.oldestTask = destroy + nicknames[self.oldestTask] = "For" + table.insert(scope, destroy) + + return self +end + +function class.get( + _self: Self +): never + External.logError("stateGetWasRemoved") + return never() +end + +function class._evaluate( + self: Self +): boolean + if self.scope == nil then + return false + end + local outerScope = self.scope :: S & Types.Scope + + depend(self, self._disassembly) + table.clear(self._EXTREMELY_DANGEROUS_usedAsValue) + self._disassembly:populate( + function( + maybeState: Types.UsedAs + ): T + local state = castToState(maybeState) + if state ~= nil then + depend(self, state) + end + return peek(maybeState) + end, + self._EXTREMELY_DANGEROUS_usedAsValue + ) + + return true +end + +table.freeze(class) +return For \ No newline at end of file diff --git a/src/State/ForKeys.luau b/src/State/ForKeys.luau index 1945f9ac4..650eefcb4 100644 --- a/src/State/ForKeys.luau +++ b/src/State/ForKeys.luau @@ -4,26 +4,72 @@ local task = nil -- Disable usage of Roblox's task scheduler --[[ - Constructs a new For object which maps keys of a table using a `processor` - function. + Constructs and returns a new For state object which processes keys and + preserves values. - Optionally, a `destructor` function can be specified for cleaning up output. + https://elttob.uk/Fusion/0.3/api-reference/state/members/forkeys/ - Additionally, a `meta` table/value can optionally be returned to pass data - created when running the processor to the destructor when the created object - is cleaned up. + TODO: the sub objects constructed here can be more efficiently implemented + as a dedicated state object. ]] local Package = script.Parent.Parent local Types = require(Package.Types) local External = require(Package.External) +-- Memory +local doCleanup = require(Package.Memory.doCleanup) -- State local For = require(Package.State.For) +local Value = require(Package.State.Value) local Computed = require(Package.State.Computed) +local ForTypes = require(Package.State.For.ForTypes) -- Logging local parseError = require(Package.Logging.parseError) --- Memory -local doCleanup = require(Package.Memory.doCleanup) + +local SUB_OBJECT_META = { + __index = { + roamKeys = false, + roamValues = true, + invalidateInputKey = function(self): () + self._inputKeyState:set(self.inputKey) + end, + invalidateInputValue = function(self): () + -- do nothing + end, + useOutputPair = function(self, use) + return use(self._outputKeyState), self.inputValue + end + } +} + +local function SubObject( + scope: Types.Scope, + initialKey: KI, + initialValue: V, + processor: (Types.Use, Types.Scope, KI) -> KO +): ForTypes.SubObject + local self = {} + self.maybeScope = scope + self.inputKey = initialKey + self.inputValue = initialValue + self._inputKeyState = Value(scope, initialKey) + self._processor = processor + self._outputKeyState = Computed(scope, function(use, scope): KO? + local inputKey = use(self._inputKeyState) + local ok, outputKey = xpcall(self._processor, parseError, use, scope, inputKey) + if ok then + return outputKey + else + local error: Types.Error = outputKey :: any + error.context = `while processing key {tostring(inputKey)}` + External.logErrorNonFatal("callbackError", error) + doCleanup(scope) + table.clear(scope) + return nil + end + end) + return setmetatable(self, SUB_OBJECT_META) :: any +end local function ForKeys( scope: Types.Scope, @@ -32,37 +78,17 @@ local function ForKeys( destructor: unknown? ): Types.For if typeof(inputTable) == "function" then - External.logError("scopeMissing", nil, "ForKeys", "myScope:ForKeys(inputTable, function(use, scope, key) ... end)") + External.logError("scopeMissing", nil, "ForKeys", "myScope:ForKeys(inputTable, function(scope, use, key) ... end)") elseif destructor ~= nil then External.logWarn("destructorRedundant", "ForKeys") end return For( - scope, - inputTable, - function( - scope: Types.Scope, - inputPair: Types.StateObject<{key: KI, value: V}> - ) - local inputKey = Computed(scope, function(use, scope): KI - return use(inputPair).key - end) - local outputKey = Computed(scope, function(use, scope): KO? - local ok, key = xpcall(processor, parseError, use, scope, use(inputKey)) - if ok then - return key - else - local errorObj = (key :: any) :: Types.Error - External.logErrorNonFatal("callbackError", errorObj) - doCleanup(scope) - table.clear(scope) - return nil - end - end) - return Computed(scope, function(use, scope) - return {key = use(outputKey), value = use(inputPair).value} - end) + scope, + inputTable, + function(scope, initialKey, initialValue) + return SubObject(scope, initialKey, initialValue, processor) end ) end -return ForKeys \ No newline at end of file +return ForKeys :: Types.ForKeysConstructor \ No newline at end of file diff --git a/src/State/ForPairs.luau b/src/State/ForPairs.luau index 9d274091c..f1005123e 100644 --- a/src/State/ForPairs.luau +++ b/src/State/ForPairs.luau @@ -4,14 +4,13 @@ local task = nil -- Disable usage of Roblox's task scheduler --[[ - Constructs a new For object which maps pairs of a table using a `processor` - function. + Constructs and returns a new For state object which processes keys and + values in pairs. - Optionally, a `destructor` function can be specified for cleaning up output. + https://elttob.uk/Fusion/0.3/api-reference/state/members/forpairs/ - Additionally, a `meta` table/value can optionally be returned to pass data - created when running the processor to the destructor when the created object - is cleaned up. + TODO: the sub objects constructed here can be more efficiently implemented + as a dedicated state object. ]] local Package = script.Parent.Parent @@ -19,12 +18,62 @@ local Types = require(Package.Types) local External = require(Package.External) -- State local For = require(Package.State.For) +local Value = require(Package.State.Value) local Computed = require(Package.State.Computed) +local ForTypes = require(Package.State.For.ForTypes) -- Logging local parseError = require(Package.Logging.parseError) -- Memory local doCleanup = require(Package.Memory.doCleanup) +local SUB_OBJECT_META = { + __index = { + roamKeys = false, + roamValues = false, + invalidateInputKey = function(self): () + self._inputKeyState:set(self.inputKey) + end, + invalidateInputValue = function(self): () + self._inputValueState:set(self.inputValue) + end, + useOutputPair = function(self, use) + local pair = use(self._outputPairState) + return pair.key, pair.value + end + } +} + +local function SubObject( + scope: Types.Scope, + initialKey: KI, + initialValue: VI, + processor: (Types.Use, Types.Scope, KI, VI) -> (KO, VO) +): ForTypes.SubObject + local self = {} + self.maybeScope = scope + self.inputKey = initialKey + self.inputValue = initialValue + self._inputKeyState = Value(scope, initialKey) + self._inputValueState = Value(scope, initialValue) + self._processor = processor + self._outputPairState = Computed(scope, function(use, scope): {key: KO?, value: VO?} + local inputKey = use(self._inputKeyState) + local inputValue = use(self._inputValueState) + local ok, outputKey, outputValue = xpcall(self._processor, parseError, use, scope, inputKey, inputValue) + if ok then + return {key = outputKey, value = outputValue} + else + local error: Types.Error = outputKey :: any + error.context = `while processing key {tostring(inputValue)} and value {tostring(inputValue)}` + External.logErrorNonFatal("callbackError", error) + doCleanup(scope) + table.clear(scope) + return {key = nil, value = nil} + end + end) + return setmetatable(self, SUB_OBJECT_META) :: any +end + local function ForPairs( scope: Types.Scope, inputTable: Types.UsedAs<{[KI]: VI}>, @@ -32,31 +81,17 @@ local function ForPairs( destructor: unknown? ): Types.For if typeof(inputTable) == "function" then - External.logError("scopeMissing", nil, "ForPairs", "myScope:ForPairs(inputTable, function(use, scope, key, value) ... end)") + External.logError("scopeMissing", nil, "ForPairs", "myScope:ForPairs(inputTable, function(scope, use, key, value) ... end)") elseif destructor ~= nil then External.logWarn("destructorRedundant", "ForPairs") end return For( scope, inputTable, - function( - scope: Types.Scope, - inputPair: Types.StateObject<{key: KI, value: VI}> - ) - return Computed(scope, function(use, scope): {key: KO?, value: VO?} - local ok, key, value = xpcall(processor, parseError, use, scope, use(inputPair).key, use(inputPair).value) - if ok then - return {key = key, value = value} - else - local errorObj = (key :: any) :: Types.Error - External.logErrorNonFatal("callbackError", errorObj) - doCleanup(scope) - table.clear(scope) - return {key = nil, value = nil} - end - end) + function(scope, initialKey, initialValue) + return SubObject(scope, initialKey, initialValue, processor) end ) end -return ForPairs \ No newline at end of file +return ForPairs :: Types.ForPairsConstructor \ No newline at end of file diff --git a/src/State/ForValues.luau b/src/State/ForValues.luau index 4e697c6c5..d4b2e5ccc 100644 --- a/src/State/ForValues.luau +++ b/src/State/ForValues.luau @@ -4,14 +4,13 @@ local task = nil -- Disable usage of Roblox's task scheduler --[[ - Constructs a new For object which maps values of a table using a `processor` - function. + Constructs and returns a new For state object which processes values and + preserves keys. - Optionally, a `destructor` function can be specified for cleaning up output. + https://elttob.uk/Fusion/0.3/api-reference/state/members/forvalues/ - Additionally, a `meta` table/value can optionally be returned to pass data - created when running the processor to the destructor when the created object - is cleaned up. + TODO: the sub objects constructed here can be more efficiently implemented + as a dedicated state object. ]] local Package = script.Parent.Parent @@ -19,12 +18,59 @@ local Types = require(Package.Types) local External = require(Package.External) -- State local For = require(Package.State.For) +local Value = require(Package.State.Value) local Computed = require(Package.State.Computed) +local ForTypes = require(Package.State.For.ForTypes) -- Logging local parseError = require(Package.Logging.parseError) -- Memory local doCleanup = require(Package.Memory.doCleanup) +local SUB_OBJECT_META = { + __index = { + roamKeys = true, + roamValues = false, + invalidateInputKey = function(self): () + -- do nothing + end, + invalidateInputValue = function(self): () + self._inputValueState:set(self.inputValue) + end, + useOutputPair = function(self, use) + return self.inputKey, use(self._outputValueState) + end + } +} + +local function SubObject( + scope: Types.Scope, + initialKey: K, + initialValue: VI, + processor: (Types.Use, Types.Scope, VI) -> VO +): ForTypes.SubObject + local self = {} + self.maybeScope = scope + self.inputKey = initialKey + self.inputValue = initialValue + self._inputValueState = Value(scope, initialValue) + self._processor = processor + self._outputValueState = Computed(scope, function(use, scope): VO? + local inputValue = use(self._inputValueState) + local ok, outputValue = xpcall(self._processor, parseError, use, scope, inputValue) + if ok then + return outputValue + else + local error: Types.Error = outputValue :: any + error.context = `while processing value {tostring(inputValue)}` + External.logErrorNonFatal("callbackError", error) + doCleanup(scope) + table.clear(scope) + return nil + end + end) + return setmetatable(self, SUB_OBJECT_META) :: any +end + local function ForValues( scope: Types.Scope, inputTable: Types.UsedAs<{[K]: VI}>, @@ -32,34 +78,17 @@ local function ForValues( destructor: unknown? ): Types.For if typeof(inputTable) == "function" then - External.logError("scopeMissing", nil, "ForValues", "myScope:ForValues(inputTable, function(use, scope, value) ... end)") + External.logError("scopeMissing", nil, "ForValues", "myScope:ForValues(inputTable, function(scope, use, value) ... end)") elseif destructor ~= nil then External.logWarn("destructorRedundant", "ForValues") end return For( scope, inputTable, - function( - scope: Types.Scope, - inputPair: Types.StateObject<{key: K, value: VI}> - ) - local inputValue = Computed(scope, function(use, scope): VI - return use(inputPair).value - end) - return Computed(scope, function(use, scope): {key: nil, value: VO?} - local ok, value = xpcall(processor, parseError, use, scope, use(inputValue)) - if ok then - return {key = nil, value = value} - else - local errorObj = (value :: any) :: Types.Error - External.logErrorNonFatal("callbackError", errorObj) - doCleanup(scope) - table.clear(scope) - return {key = nil, value = nil} - end - end) + function(scope, initialKey, initialValue) + return SubObject(scope, initialKey, initialValue, processor) end ) end -return ForValues \ No newline at end of file +return ForValues :: Types.ForValuesConstructor \ No newline at end of file diff --git a/src/State/Observer.luau b/src/State/Observer.luau deleted file mode 100644 index 1b041e08d..000000000 --- a/src/State/Observer.luau +++ /dev/null @@ -1,115 +0,0 @@ ---!strict ---!nolint LocalUnused ---!nolint LocalShadow -local task = nil -- Disable usage of Roblox's task scheduler - ---[[ - Constructs a new state object which can listen for updates on another state - object. -]] - -local Package = script.Parent.Parent -local Types = require(Package.Types) -local InternalTypes = require(Package.InternalTypes) -local External = require(Package.External) -local whichLivesLonger = require(Package.Memory.whichLivesLonger) - -local class = {} -class.type = "Observer" - -local CLASS_METATABLE = {__index = class} - ---[[ - Called when the watched state changes value. -]] -function class:update(): boolean - local self = self :: InternalTypes.Observer - for _, callback in pairs(self._changeListeners) do - External.doTaskImmediate(callback) - end - return false -end - ---[[ - Adds a change listener. When the watched state changes value, the listener - will be fired. - - Returns a function which, when called, will disconnect the change listener. - As long as there is at least one active change listener, this Observer - will be held in memory, preventing GC, so disconnecting is important. -]] -function class:onChange( - callback: () -> () -): () -> () - local self = self :: InternalTypes.Observer - local uniqueIdentifier = {} - self._changeListeners[uniqueIdentifier] = callback - return function() - self._changeListeners[uniqueIdentifier] = nil - end -end - ---[[ - Similar to `class:onChange()`, however it runs the provided callback - immediately. -]] -function class:onBind( - callback: () -> () -): () -> () - local self = self :: InternalTypes.Observer - External.doTaskImmediate(callback) - return self:onChange(callback) -end - -local function Observer( - scope: Types.Scope, - watching: unknown -): Types.Observer - if watching == nil then - External.logError("scopeMissing", nil, "Observers", "myScope:Observer(watching)") - end - - local watchingState = typeof(watching) == "table" and (watching :: any).dependentSet ~= nil - - local self = setmetatable({ - scope = scope, - dependencySet = if watchingState then {[watching] = true} else {}, - dependentSet = {}, - _changeListeners = {} - }, CLASS_METATABLE) - local self = (self :: any) :: InternalTypes.Observer - - local destroy = function() - self.scope = nil - for dependency in pairs(self.dependencySet) do - dependency.dependentSet[self] = nil - end - end - self.oldestTask = destroy - table.insert(scope, destroy) - - if watchingState then - local watching: any = watching - if watching.scope == nil then - External.logError( - "useAfterDestroy", - nil, - `The {watching.kind or watching.type or "watched"} object`, - `the Observer that is watching it` - ) - elseif whichLivesLonger(scope, self.oldestTask, watching.scope, watching.oldestTask) == "definitely-a" then - local watching: any = watching - External.logWarn( - "possiblyOutlives", - `The {watching.kind or watching.type or "watched"} object`, - `the Observer that is watching it` - ) - end - -- add this object to the watched object's dependent set - watching.dependentSet[self] = true - end - - return self -end - -return Observer \ No newline at end of file diff --git a/src/State/Value.luau b/src/State/Value.luau index d1e724f26..b96802543 100644 --- a/src/State/Value.luau +++ b/src/State/Value.luau @@ -4,79 +4,85 @@ local task = nil -- Disable usage of Roblox's task scheduler --[[ - Constructs and returns objects which can be used to model independent - reactive state. + A state object which allows regular Luau code to control its value. + + https://elttob.uk/Fusion/0.3/api-reference/state/types/value/ ]] local Package = script.Parent.Parent local Types = require(Package.Types) -local InternalTypes = require(Package.InternalTypes) local External = require(Package.External) --- State -local updateAll = require(Package.State.updateAll) +-- Graph +local change = require(Package.Graph.change) -- Utility local isSimilar = require(Package.Utility.isSimilar) +local never = require(Package.Utility.never) +local nicknames = require(Package.Utility.nicknames) + +type Self = Types.Value local class = {} class.type = "State" class.kind = "Value" +class.timeliness = "lazy" +class.dependencySet = table.freeze {} -local CLASS_METATABLE = {__index = class} - ---[[ - Updates the value stored in this State object. - - If `force` is enabled, this will skip equality checks and always update the - state object and any dependents - use this with care as this can lead to - unnecessary updates. -]] -function class:set( - newValue: unknown, - force: boolean? -): unknown - local self = self :: InternalTypes.Value - local oldValue = self._value - if force or not isSimilar(oldValue, newValue) then - self._value = newValue - updateAll(self) - end - return newValue -end - ---[[ - Returns the interior value of this state object. -]] -function class:_peek(): unknown - local self = self :: InternalTypes.Value - return self._value -end - -function class:get() - External.logError("stateGetWasRemoved") -end +local METATABLE = table.freeze {__index = class} local function Value( scope: Types.Scope, initialValue: T ): Types.Value + local createdAt = os.clock() if initialValue == nil and (typeof(scope) ~= "table" or (scope[1] == nil and next(scope) ~= nil)) then External.logError("scopeMissing", nil, "Value", "myScope:Value(initialValue)") end - - local self = setmetatable({ - scope = scope, - dependentSet = {}, - _value = initialValue - }, CLASS_METATABLE) - local self = (self :: any) :: InternalTypes.Value - + local self: Self = setmetatable( + { + createdAt = createdAt, + dependentSet = {}, + lastChange = os.clock(), + scope = scope, + validity = "valid", + _EXTREMELY_DANGEROUS_usedAsValue = initialValue + }, + METATABLE + ) :: any local destroy = function() self.scope = nil end self.oldestTask = destroy + nicknames[self.oldestTask] = "Value" table.insert(scope, destroy) - return self end -return Value \ No newline at end of file +function class:get( + _self: Self +): never + External.logError("stateGetWasRemoved") + return never() +end + +function class.set( + self: Self, + newValue: S +): S + local oldValue = self._EXTREMELY_DANGEROUS_usedAsValue + if not isSimilar(oldValue, newValue) then + self._EXTREMELY_DANGEROUS_usedAsValue = newValue :: any + change(self) + end + return newValue +end + +function class._evaluate( + _self: Self +): boolean + -- The similarity test is done in advance when the value is set, so this + -- should be fine. + return true +end + +table.freeze(class) +return Value :: Types.ValueConstructor \ No newline at end of file diff --git a/src/State/castToState.luau b/src/State/castToState.luau new file mode 100644 index 000000000..4fd7c6dfd --- /dev/null +++ b/src/State/castToState.luau @@ -0,0 +1,26 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + Returns the input *only* if it is a state object. +]] + +local Package = script.Parent.Parent +local Types = require(Package.Types) + +local function castToState( + target: Types.UsedAs +): Types.StateObject? + if + typeof(target) == "table" and + target.type == "State" + then + return target + else + return nil + end +end + +return castToState \ No newline at end of file diff --git a/src/State/isState.luau b/src/State/isState.luau deleted file mode 100644 index ae5d7f205..000000000 --- a/src/State/isState.luau +++ /dev/null @@ -1,20 +0,0 @@ ---!strict ---!nolint LocalUnused ---!nolint LocalShadow -local task = nil -- Disable usage of Roblox's task scheduler - ---[[ - Returns true if the given value can be assumed to be a valid state object. -]] - -local function isState( - target: unknown -): boolean - if typeof(target) == "table" then - local target = target :: {_peek: unknown?} - return typeof(target._peek) == "function" - end - return false -end - -return isState \ No newline at end of file diff --git a/src/State/peek.luau b/src/State/peek.luau index d1e61f546..8e3087e5a 100644 --- a/src/State/peek.luau +++ b/src/State/peek.luau @@ -4,20 +4,25 @@ local task = nil -- Disable usage of Roblox's task scheduler --[[ - A common interface for accessing the values of state objects or constants. + Extracts a value of type T from its input. + + https://elttob.uk/Fusion/0.3/api-reference/state/members/peek/ ]] local Package = script.Parent.Parent local Types = require(Package.Types) -local InternalTypes = require(Package.InternalTypes) -- State -local isState = require(Package.State.isState) +local castToState = require(Package.State.castToState) +-- Graph +local evaluate = require(Package.Graph.evaluate) local function peek( target: Types.UsedAs ): T - if isState(target) then - return (target :: InternalTypes.StateObject):_peek() + local targetState = castToState(target) + if targetState ~= nil then + evaluate(targetState, false) + return targetState._EXTREMELY_DANGEROUS_usedAsValue :: T else return target :: T end diff --git a/src/State/updateAll.luau b/src/State/updateAll.luau index 2b3219034..0b5ef75cd 100644 --- a/src/State/updateAll.luau +++ b/src/State/updateAll.luau @@ -1,71 +1 @@ ---!strict ---!nolint LocalUnused ---!nolint LocalShadow -local task = nil -- Disable usage of Roblox's task scheduler - ---[[ - Given a reactive object, updates all dependent reactive objects. - Objects are only ever updated after all of their dependencies are updated, - are only ever updated once, and won't be updated if their dependencies are - unchanged. -]] - -local Package = script.Parent.Parent -local Types = require(Package.Types) - -type Descendant = (Types.Dependent & Types.Dependency) | Types.Dependent - --- Credit: https://blog.elttob.uk/2022/11/07/sets-efficient-topological-search.html -local function updateAll( - root: Types.Dependency -) - local counters: {[Descendant]: number} = {} - local flags: {[Descendant]: boolean} = {} - local queue: {Descendant} = {} - local queueSize = 0 - local queuePos = 1 - - for object in root.dependentSet do - queueSize += 1 - queue[queueSize] = object - flags[object] = true - end - - -- Pass 1: counting up - while queuePos <= queueSize do - local next = queue[queuePos] - local counter = counters[next] - counters[next] = if counter == nil then 1 else counter + 1 - if (next :: any).dependentSet ~= nil then - local next = next :: (Types.Dependent & Types.Dependency) - for object in next.dependentSet do - queueSize += 1 - queue[queueSize] = object - end - end - queuePos += 1 - end - - -- Pass 2: counting down + processing - queuePos = 1 - while queuePos <= queueSize do - local next = queue[queuePos] - local counter = counters[next] - 1 - counters[next] = counter - if - counter == 0 - and flags[next] - and next.scope ~= nil - and next:update() - and (next :: any).dependentSet ~= nil - then - local next = next :: (Types.Dependent & Types.Dependency) - for object in next.dependentSet do - flags[object] = true - end - end - queuePos += 1 - end -end - -return updateAll \ No newline at end of file +return nil -- dummy file so I can write tests \ No newline at end of file diff --git a/src/Types.luau b/src/Types.luau index 1e2356aab..a11c2db03 100644 --- a/src/Types.luau +++ b/src/Types.luau @@ -46,7 +46,7 @@ export type Task = {Task} -- A scope of tasks to clean up. -export type Scope = {Task} & Constructors +export type Scope = {Task} & Constructors -- An object which uses a scope to dictate how long it lives. export type ScopedObject = { @@ -72,22 +72,22 @@ type ContextualIsMethods = { during: (ContextualIsMethods, (A...) -> R, A...) -> R } --- A graph object which can have dependents. -export type Dependency = ScopedObject & { - dependentSet: {[Dependent]: unknown} -} - --- A graph object which can have dependencies. -export type Dependent = ScopedObject & { - update: (Dependent) -> boolean, - dependencySet: {[Dependency]: unknown} +-- A graph object which can have dependencies and dependencies. +export type GraphObject = ScopedObject & { + createdAt: number, + dependencySet: {[GraphObject]: unknown}, + dependentSet: {[GraphObject]: unknown}, + lastChange: number?, + timeliness: "lazy" | "eager", + validity: "valid" | "invalid" | "busy", + _evaluate: (GraphObject) -> boolean } -- An object which stores a piece of reactive state. -export type StateObject = Dependency & { +export type StateObject = GraphObject & { type: "State", kind: string, - ____phantom_peekType: (never) -> T -- phantom data so this contains a T + _EXTREMELY_DANGEROUS_usedAsValue: T } -- Passing values of this type to `Use` returns `T`. @@ -99,8 +99,9 @@ export type Use = (target: UsedAs) -> T -- A state object whose value can be set at any time by the user. export type Value = StateObject & { kind: "State", - set: (Value, newValue: S, force: boolean?) -> S, - ____phantom_setType: (never) -> S -- phantom data so this contains a T + timeliness: "lazy", + set: (Value, newValue: S, force: boolean?) -> S, + ____phantom_setType: (never) -> S -- phantom data so this contains S } export type ValueConstructor = ( scope: Scope, @@ -108,37 +109,39 @@ export type ValueConstructor = ( ) -> Value -- A state object whose value is derived from other objects using a callback. -export type Computed = StateObject & Dependent & { - kind: "Computed" +export type Computed = StateObject & { + kind: "Computed", + timeliness: "lazy" } export type ComputedConstructor = ( - scope: Scope, - callback: (Use, Scope) -> T + scope: S & Scope, + callback: (Use, S) -> T ) -> Computed -- A state object which maps over keys and/or values in another table. -export type For = StateObject<{[KO]: VO}> & Dependent & { +export type For = StateObject<{[KO]: VO}> & { kind: "For" } export type ForPairsConstructor = ( - scope: Scope, + scope: S & Scope, inputTable: UsedAs<{[KI]: VI}>, - processor: (Use, Scope, key: KI, value: VI) -> (KO, VO) + processor: (Use, S, key: KI, value: VI) -> (KO, VO) ) -> For export type ForKeysConstructor = ( - scope: Scope, + scope: S & Scope, inputTable: UsedAs<{[KI]: V}>, - processor: (Use, Scope, key: KI) -> KO + processor: (Use, S, key: KI) -> KO ) -> For export type ForValuesConstructor = ( - scope: Scope, + scope: S & Scope, inputTable: UsedAs<{[K]: VI}>, - processor: (Use, Scope, value: VI) -> VO + processor: (Use, S, value: VI) -> VO ) -> For -- An object which can listen for updates on another state object. -export type Observer = Dependent & { +export type Observer = GraphObject & { type: "Observer", + timeliness: "eager", onChange: (Observer, callback: () -> ()) -> (() -> ()), onBind: (Observer, callback: () -> ()) -> (() -> ()) } @@ -148,7 +151,7 @@ export type ObserverConstructor = ( ) -> Observer -- A state object which follows another state object using tweens. -export type Tween = StateObject & Dependent & { +export type Tween = StateObject & { kind: "Tween" } export type TweenConstructor = ( @@ -158,7 +161,7 @@ export type TweenConstructor = ( ) -> Tween -- A state object which follows another state object using spring simulation. -export type Spring = StateObject & Dependent & { +export type Spring = StateObject & { kind: "Spring", setPosition: (Spring, newPosition: T) -> (), setVelocity: (Spring, newVelocity: T) -> (), @@ -278,7 +281,7 @@ export type ExternalProvider = { policies: { allowWebLinks: boolean }, - + logErrorNonFatal: ( errorString: string ) -> (), diff --git a/src/Utility/Contextual.luau b/src/Utility/Contextual.luau index a56c00d30..9dc45f22e 100644 --- a/src/Utility/Contextual.luau +++ b/src/Utility/Contextual.luau @@ -10,22 +10,44 @@ local task = nil -- Disable usage of Roblox's task scheduler local Package = script.Parent.Parent local Types = require(Package.Types) -local InternalTypes = require(Package.InternalTypes) local External = require(Package.External) -- Logging local parseError = require(Package.Logging.parseError) +export type Self = Types.Contextual & { + _valuesNow: {[thread]: {value: T}}, + _defaultValue: T +} + local class = {} class.type = "Contextual" -local CLASS_METATABLE = {__index = class} -local WEAK_KEYS_METATABLE = {__mode = "k"} +local METATABLE = table.freeze {__index = class} +local WEAK_KEYS_METATABLE = table.freeze {__mode = "k"} + +local function Contextual( + defaultValue: T +): Types.Contextual + local self: Self = setmetatable( + { + -- 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 + }, + METATABLE + ) :: any + + return self +end --[[ Returns the current value of this contextual. ]] -function class:now(): unknown - local self = self :: InternalTypes.Contextual +function class.now( + self: Self +): T local thread = coroutine.running() local value = self._valuesNow[thread] if typeof(value) ~= "table" then @@ -38,25 +60,24 @@ end --[[ Temporarily assigns a value to this contextual. ]] -function class:is( - newValue: unknown +function class.is( + self: Self, + newValue: T ) - -- Methods use colon `:` syntax for consistency and autocomplete but we - -- actually want them to operate on the `self` from this outer lexical scope - local outerSelf = self :: InternalTypes.Contextual local methods = {} - function methods:during( + function methods.during( + _: any, -- during is called with colon syntax but we don't care callback: (A...) -> T, ...: A... ): T local thread = coroutine.running() - local prevValue = outerSelf._valuesNow[thread] + local prevValue = self._valuesNow[thread] -- Storing the value in this format allows us to distinguish storing -- `nil` from not calling `:during()` at all. - outerSelf._valuesNow[thread] = { value = newValue } + self._valuesNow[thread] = { value = newValue } local ok, value = xpcall(callback, parseError, ...) - outerSelf._valuesNow[thread] = prevValue + self._valuesNow[thread] = prevValue if not ok then External.logError("callbackError", value :: any) end @@ -66,19 +87,5 @@ function class:is( return methods end -local function Contextual( - defaultValue: T -): Types.Contextual - local self = setmetatable({ - -- 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) - local self = (self :: any) :: InternalTypes.Contextual - - return self -end - +table.freeze(class) return Contextual \ No newline at end of file diff --git a/src/Utility/merge.luau b/src/Utility/merge.luau index 89c07685c..63b3446d7 100644 --- a/src/Utility/merge.luau +++ b/src/Utility/merge.luau @@ -20,10 +20,10 @@ local function merge( return into else for key, value in from do - if into[key] ~= nil and overwrite == "none" then - External.logError("mergeConflict", nil, tostring(key)) - else + if into[key] == nil then into[key] = value + elseif overwrite == "none" then + External.logError("mergeConflict", nil, tostring(key)) end end return merge(if overwrite == "first" then "none" else overwrite, into, ...) diff --git a/src/Utility/nameOf.luau b/src/Utility/nameOf.luau new file mode 100644 index 000000000..837c98413 --- /dev/null +++ b/src/Utility/nameOf.luau @@ -0,0 +1,35 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + Returns the most specific custom name for the given object. +]] + +local Package = script.Parent.Parent +-- Utility +local nicknames = require(Package.Utility.nicknames) + +local function nameOf( + x: unknown, + defaultName: string +): string + local nickname = nicknames[x] + if typeof(nickname) == "string" then + return nickname + end + if typeof(x) == "table" then + local x = x :: {[any]: any} + if typeof(x.name) == "string" then + return x.name + elseif typeof(x.kind) == "string" then + return x.kind + elseif typeof(x.type) == "string" then + return x.type + end + end + return defaultName +end + +return nameOf \ No newline at end of file diff --git a/src/Utility/never.luau b/src/Utility/never.luau new file mode 100644 index 000000000..a2f4a1def --- /dev/null +++ b/src/Utility/never.luau @@ -0,0 +1,14 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + Never returns. +]] + +local function never(): never + error("This codepath should not be reachable") +end + +return never \ No newline at end of file diff --git a/src/Utility/nicknames.luau b/src/Utility/nicknames.luau new file mode 100644 index 000000000..a7d8cd893 --- /dev/null +++ b/src/Utility/nicknames.luau @@ -0,0 +1,11 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + Stores nicknames for values that don't support metatables, so that `nameOf` + can return values for them. +]] + +return setmetatable({}, {__mode = "k"}) \ No newline at end of file diff --git a/src/init.luau b/src/init.luau index 841f392b4..a834643be 100644 --- a/src/init.luau +++ b/src/init.luau @@ -14,8 +14,7 @@ export type UsedAs = Types.UsedAs export type Child = Types.Child export type Computed = Types.Computed export type Contextual = Types.Contextual -export type Dependency = Types.Dependency -export type Dependent = Types.Dependent +export type GraphObject = Types.GraphObject export type For = Types.For export type Observer = Types.Observer export type PropertyTable = Types.PropertyTable @@ -50,12 +49,14 @@ local Fusion: Types.Fusion = table.freeze { innerScope = require(script.Memory.innerScope), scoped = require(script.Memory.scoped), + -- Graph + Observer = require(script.Graph.Observer), + -- State Computed = require(script.State.Computed), ForKeys = require(script.State.ForKeys) :: Types.ForKeysConstructor, ForPairs = require(script.State.ForPairs) :: Types.ForPairsConstructor, ForValues = require(script.State.ForValues) :: Types.ForValuesConstructor, - Observer = require(script.State.Observer), peek = require(script.State.peek), Value = require(script.State.Value), diff --git a/test-runner.project.json b/test-runner.project.json index 8bab1e91d..04881787d 100644 --- a/test-runner.project.json +++ b/test-runner.project.json @@ -1,20 +1,21 @@ { - "name": "Fusion Test Runner", - "tree": { - "$className": "DataModel", - - "ReplicatedStorage": { - "$className": "ReplicatedStorage", - "Fusion": { - "$path": "default.project.json" - } - }, - - "ServerScriptService": { - "$className": "ServerScriptService", - "FusionTest": { - "$path": "test" + "name": "Fusion Test Runner", + "servePlaceIds": [ + 0 + ], + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$className": "ReplicatedStorage", + "Fusion": { + "$path": "default.project.json" + } + }, + "ServerScriptService": { + "$className": "ServerScriptService", + "FusionTest": { + "$path": "test" + } } - } - } + } } \ No newline at end of file diff --git a/test/Spec/Animation/springCoefficients.spec.luau b/test/Spec/Animation/springCoefficients.spec.luau index 5db45f27c..e2eec8204 100644 --- a/test/Spec/Animation/springCoefficients.spec.luau +++ b/test/Spec/Animation/springCoefficients.spec.luau @@ -39,18 +39,18 @@ return function() local ERROR_MARGIN = 0.00001 - it("should return reasonable underdamped values", function() + it("should return the same underdamped values as before", function() local expect = getfenv().expect local posPos, posVel, velPos, velVel = springCoefficients(3.6, 0.2, 6.3) expect(posPos).to.be.near(-0.010932478209024278, ERROR_MARGIN) - expect(posVel).to.be.near(-0.002500054168246333, ERROR_MARGIN) - expect(velPos).to.be.near(0.002500054168246333, ERROR_MARGIN) - expect(velVel).to.be.near(-0.009932456541725745, ERROR_MARGIN) + expect(posVel).to.be.near(-0.00039683399495972943, ERROR_MARGIN) + expect(velPos).to.be.near(0.015750341259951655, ERROR_MARGIN) + expect(velVel).to.be.near(-0.009932456541725759, ERROR_MARGIN) end) - it("should return reasonable critically damped values", function() + it("should return the same critically damped values as before", function() local expect = getfenv().expect local posPos, posVel, velPos, velVel = springCoefficients(0.24, 1, 3.6) @@ -61,14 +61,14 @@ return function() expect(velVel).to.be.near(0.057320302809524805, ERROR_MARGIN) end) - it("should return reasonable overdamped values", function() + it("should return the same overdamped values as before", function() local expect = getfenv().expect local posPos, posVel, velPos, velVel = springCoefficients(1.74, 8.4, 7.2) - expect(posPos).to.be.near(0.4748290157123269, ERROR_MARGIN) - expect(posVel).to.be.near(0.003939512259321826, ERROR_MARGIN) - expect(velPos).to.be.near(-0.2042243155232435, ERROR_MARGIN) - expect(velVel).to.be.near(-0.0016943871752413216, ERROR_MARGIN) + expect(posPos).to.be.near(0.47482901571232816, ERROR_MARGIN) + expect(posVel).to.be.near(0.0005471544804613663, ERROR_MARGIN) + expect(velPos).to.be.near(-0.02836448826711723, ERROR_MARGIN) + expect(velVel).to.be.near(-0.0016943871752413203, ERROR_MARGIN) end) end \ No newline at end of file diff --git a/test/Spec/State/Observer.spec.luau b/test/Spec/Graph/Observer.spec.luau similarity index 60% rename from test/Spec/State/Observer.spec.luau rename to test/Spec/Graph/Observer.spec.luau index 03021d0e3..f5706cbbb 100644 --- a/test/Spec/State/Observer.spec.luau +++ b/test/Spec/Graph/Observer.spec.luau @@ -5,8 +5,10 @@ local task = nil -- Disable usage of Roblox's task scheduler local ReplicatedStorage = game:GetService("ReplicatedStorage") local Fusion = ReplicatedStorage.Fusion -local Observer = require(Fusion.State.Observer) +local Observer = require(Fusion.Graph.Observer) local Value = require(Fusion.State.Value) +local Computed = require(Fusion.State.Computed) +local innerScope = require(Fusion.Memory.innerScope) local doCleanup = require(Fusion.Memory.doCleanup) return function() @@ -21,7 +23,7 @@ return function() expect(observer).to.be.a("table") expect(observer.type).to.equal("Observer") - expect(scope[2]).to.equal(observer.oldestTask) + expect(table.find(scope, observer.oldestTask :: any)).never.to.equal(nil) doCleanup(scope) end) @@ -121,8 +123,7 @@ return function() local scope = {} local dependency = Value(scope, 5) - local subScope = {} - table.insert(scope, subScope) + local subScope = innerScope(scope) local observer = Observer(subScope, dependency) local numFires = 0 @@ -138,4 +139,78 @@ return function() doCleanup(scope) end) + it("never fires onChange for constants", function() + local expect = getfenv().expect + + local scope = {} + local observer = Observer(scope, 5) + local numFires = 0 + local disconnect = observer:onChange(function() + numFires += 1 + end) + expect(numFires).to.equal(0) + + doCleanup(scope) + end) + + it("fires onBind at bind time for constants", function() + local expect = getfenv().expect + + local scope = {} + local observer = Observer(scope, 5) + local numFires = 0 + local disconnect = observer:onBind(function() + numFires += 1 + end) + disconnect() + + expect(numFires).to.equal(1) + + doCleanup(scope) + end) + + it("ensures dependencies are captured for unobserved lazy inputs", function() + local expect = getfenv().expect + + local scope = {} + local value = Value(scope, 5) + local double = Computed(scope, function(use) + return use(value) * 2 + end) + local numFires = 0 + Observer(scope, double):onChange(function() + numFires += 1 + end) + + expect(numFires).to.equal(0) + value:set(10) + expect(numFires).to.equal(1) + value:set(20) + expect(numFires).to.equal(2) + + doCleanup(scope) + end) + + it("correctly preserves its dependency across multiple evaluations", function() + local expect = getfenv().expect + + local scope = {} + local value = Value(scope, 5) + local numFires = 0 + Observer(scope, value):onChange(function() + numFires += 1 + end) + + expect(numFires).to.equal(0) + value:set(10) + expect(numFires).to.equal(1) + value:set(20) + expect(numFires).to.equal(2) + value:set(30) + expect(numFires).to.equal(3) + value:set(50) + expect(numFires).to.equal(4) + + doCleanup(scope) + end) end diff --git a/test/Spec/Graph/change.spec.luau b/test/Spec/Graph/change.spec.luau new file mode 100644 index 000000000..b8527f3d6 --- /dev/null +++ b/test/Spec/Graph/change.spec.luau @@ -0,0 +1,330 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Fusion = ReplicatedStorage.Fusion + +local Types = require(Fusion.Types) +local change = require(Fusion.Graph.change) + +local Graphs = require(script.Parent.Parent.Parent.Util.Graphs) + +return function() + local describe = getfenv().describe + + Graphs.propertyTest { + testing = "change", + it = "always ensures the target is valid", + filters = {Graphs.filters.wellFormed}, + perform = function( + graph: Graphs.Graph + ) + for _, target in graph.allObjects do + change(target) + end + return Graphs.check( + graph.allObjects, + Graphs.tests.validity("valid") + ) + end + } (describe) + + Graphs.propertyTest { + testing = "change", + it = "always changes the last changed time of the target", + filters = {Graphs.filters.wellFormed}, + perform = function( + graph: Graphs.Graph + ) + for _, object in graph.allObjects do + local before = object.lastChange + change(object) + if object.lastChange == before then + return `{Graphs.nameOf(object)} didn't change its last change time` + end + end + return false + end + } (describe) + + Graphs.propertyTest { + testing = "change", + it = "invalidates valid direct dependents", + filters = {Graphs.filters.wellFormed}, + perform = function( + graph: Graphs.Graph + ) + local targets = Graphs.selectors.noConnections(graph.allObjects, "dependency") + for _, target in targets do + change(target) + end + return Graphs.check( + Graphs.selectors.distance(graph.allObjects, targets, "dependent", 1), + Graphs.tests.validity("invalid") + ) + end + } (describe) + + Graphs.propertyTest { + testing = "change", + it = "stops at already-invalid direct dependents", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + local targets = Graphs.selectors.noConnections(graph.allObjects, "dependency") + for _, object in + Graphs.selectors.distance(graph.allObjects, targets, "dependent", 1) + do + object.validity = "invalid" + end + return {graph} + end + }, + perform = function( + graph: Graphs.Graph + ) + local targets = Graphs.selectors.noConnections(graph.allObjects, "dependency") + for _, target in targets do + change(target) + end + return Graphs.check( + Graphs.selectors.distance(graph.allObjects, targets, "dependent", 2), + Graphs.tests.validity("valid") + ) + end + } (describe) + + Graphs.propertyTest { + testing = "change", + it = "invalidates valid transitive dependents", + filters = {Graphs.filters.wellFormed}, + perform = function( + graph: Graphs.Graph + ) + local targets = Graphs.selectors.noConnections(graph.allObjects, "dependency") + for _, target in targets do + change(target) + end + return Graphs.check( + Graphs.selectors.distance(graph.allObjects, targets, "dependent", 2), + Graphs.tests.validity("invalid") + ) + end + } (describe) + + Graphs.propertyTest { + testing = "change", + it = "stops at already-invalid transitive dependents", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + local targets = Graphs.selectors.noConnections(graph.allObjects, "dependency") + for _, object in + Graphs.selectors.distance(graph.allObjects, targets, "dependent", 2) + do + object.validity = "invalid" + end + return {graph} + end + }, + perform = function( + graph: Graphs.Graph + ) + local targets = Graphs.selectors.noConnections(graph.allObjects, "dependency") + for _, target in targets do + change(target) + end + return Graphs.check( + Graphs.selectors.distance(graph.allObjects, targets, "dependent", 3), + Graphs.tests.validity("valid") + ) + end + } (describe) + + Graphs.propertyTest { + testing = "change", + it = "does *not* invalidate any dependencies, direct or transitive", + filters = {Graphs.filters.wellFormed}, + perform = function( + graph: Graphs.Graph + ) + local targets = Graphs.selectors.noConnections(graph.allObjects, "dependent") + for _, target in targets do + change(target) + end + return Graphs.check( + Graphs.selectors.distance(graph.allObjects, targets, "dependency", 1), + Graphs.tests.validity("valid") + ) or Graphs.check( + Graphs.selectors.distance(graph.allObjects, targets, "dependency", 2), + Graphs.tests.validity("valid") + ) + end + } (describe) + + Graphs.propertyTest { + testing = "change", + it = "processes malformed graphs in finite time", + filters = {Graphs.filters.malformed}, + perform = function( + graph: Graphs.Graph + ) + local ok, err = pcall(function() + for _, object in graph.objects do + change(object) + for _, object in graph.objects do + object.validity = "valid" + end + end + end) + if ok or string.find(err, "infiniteLoop", 1, true) ~= nil then + return false + else + return err + end + end + } (describe) + + Graphs.propertyTest { + testing = "change", + it = "throws when encountering a busy object", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + local objects = Graphs.selectors.noConnections(graph.allObjects, "dependent") + for _, object in objects do + object.validity = "busy" + end + end + }, + perform = function( + graph: Graphs.Graph + ) + local ok, err = pcall(function() + for _, object in graph.objects do + change(object) + end + end) + if ok then + return "Should not have completed without errors" + elseif string.find(err, "infiniteLoop", 1, true) ~= nil then + return false + else + return err + end + end + } (describe) + + Graphs.propertyTest { + testing = "change", + it = "revalidates eager objects after all invalidation is complete", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + local objects = Graphs.selectors.noConnections(graph.allObjects, "dependent") + local doNotInvalidate: {Types.GraphObject} + for _, object in objects do + object.timeliness = "eager" + object._evaluate = function(self) + if doNotInvalidate == nil then + doNotInvalidate = {} + for _, object in graph.objects do + if object.validity ~= "invalid" then + table.insert(doNotInvalidate, object) + end + end + else + for _, object in doNotInvalidate do + if object.validity == "invalid" then + error(`{Graphs.nameOf(object)} became invalid between revalidations`, 0) + end + end + end + return true + end + end + end + }, + perform = function( + graph: Graphs.Graph + ) + local targets = Graphs.selectors.noConnections(graph.allObjects, "dependency") + local eagers = Graphs.selectors.noConnections(graph.allObjects, "dependent") + for _, target in targets do + change(target) + end + return Graphs.check( + eagers, + Graphs.tests.validity("valid") + ) + end + } (describe) + + Graphs.propertyTest { + testing = "change", + it = "revalidates eager objects in order of creation", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + local graph: Graphs.Graph & { + properOrder: {Types.GraphObject}, + executionOrder: {Types.GraphObject} + } = graph :: any + graph.properOrder = {} + graph.executionOrder = {} + local objects = Graphs.selectors.noConnections(graph.allObjects, "dependent") + local doNotInvalidate: {Types.GraphObject} + local createAtTime = 0 + for _, object in objects do + object.timeliness = "eager" + createAtTime += 1 + object.createdAt = createAtTime + table.insert(graph.properOrder, object) + object._evaluate = function(self) + table.insert(graph.executionOrder, object) + return true + end + end + end + }, + perform = function( + graph: Graphs.Graph + ) + local graph: Graphs.Graph & { + properOrder: {Types.GraphObject}, + executionOrder: {Types.GraphObject} + } = graph :: any + + local targets = Graphs.selectors.noConnections(graph.allObjects, "dependency") + local eagers = Graphs.selectors.noConnections(graph.allObjects, "dependent") + for _, target in targets do + change(target) + end + + for index, properObject in graph.properOrder do + if properObject ~= graph.executionOrder[index] then + return `Object {index} was evaluated out of order.` + end + end + + return false + end + } (describe) +end \ No newline at end of file diff --git a/test/Spec/Graph/evaluate.spec.luau b/test/Spec/Graph/evaluate.spec.luau new file mode 100644 index 000000000..9d53aaf15 --- /dev/null +++ b/test/Spec/Graph/evaluate.spec.luau @@ -0,0 +1,586 @@ +--!strict +--!nolint LocalUnused +local task = nil -- Disable usage of Roblox's task scheduler + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Fusion = ReplicatedStorage.Fusion + +local evaluate = require(Fusion.Graph.evaluate) + +local Graphs = require(script.Parent.Parent.Parent.Util.Graphs) + +return function() + local describe = getfenv().describe + + Graphs.propertyTest { + testing = "evaluate", + it = "always ensures the target is valid", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + for index, object in graph.allObjects do + local dependencySet = table.clone(object.dependencySet) + object.validity = if index % 2 == 0 then "valid" else "invalid" + object._evaluate = function() + object.dependencySet = table.clone(dependencySet) + for dependency in object.dependencySet do + evaluate(dependency, false) + end + return index // 2 % 2 == 0 + end + end + end + }, + perform = function( + graph: Graphs.Graph + ) + for _, object in graph.allObjects do + evaluate(object, false) + if object.validity ~= "valid" then + return `{Graphs.nameOf(object)} was not valid` + end + end + return Graphs.check( + graph.allObjects, + Graphs.tests.validity("valid") + ) + end + } (describe) + + Graphs.propertyTest { + testing = "evaluate", + it = "always ensures the last change time is non-nil, even after meaningless changes", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + for index, object in graph.allObjects do + local dependencySet = table.clone(object.dependencySet) + object.validity = if index % 2 == 0 then "valid" else "invalid" + object._evaluate = function() + object.dependencySet = table.clone(dependencySet) + for dependency in object.dependencySet do + evaluate(dependency, false) + end + return index // 2 % 2 == 0 + end + end + end + }, + perform = function( + graph: Graphs.Graph + ) + for _, object in graph.allObjects do + local beforeTime = object.lastChange + evaluate(object, false) + if object.lastChange == nil then + return `{Graphs.nameOf(object)} had a nil change time` + end + end + return false + end + } (describe) + + Graphs.propertyTest { + testing = "evaluate", + it = "does *not* evaluate prior dependencies", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + for index, object in graph.allObjects do + local dependencySet = table.clone(object.dependencySet) + object._evaluate = function() + object.dependencySet = table.clone(dependencySet) + for dependency in object.dependencySet do + evaluate(dependency, false) + end + return true + end + end + local targets = Graphs.selectors.noConnections(graph.allObjects, "dependent") + for _, object in + Graphs.selectors.distance(graph.allObjects, targets, "dependency", 1) + do + object.validity = "invalid" + end + return {graph} + end + }, + perform = function( + graph: Graphs.Graph + ) + local targets = Graphs.selectors.noConnections(graph.allObjects, "dependent") + for _, target in targets do + evaluate(target, false) + end + return Graphs.check( + Graphs.selectors.distance(graph.allObjects, targets, "dependency", 1), + Graphs.tests.validity("valid") + ) or Graphs.check( + Graphs.selectors.distance(graph.allObjects, targets, "dependency", 2), + Graphs.tests.validity("valid") + ) + end + } (describe) + + Graphs.propertyTest { + testing = "evaluate", + it = "allows further evaluating/depending on objects during evaluation", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + for index, object in graph.allObjects do + local dependencySet = table.clone(object.dependencySet) + object._evaluate = function() + object.dependencySet = table.clone(dependencySet) + for dependency in object.dependencySet do + evaluate(dependency, false) + end + return true + end + object.validity = "invalid" + end + end + }, + perform = function( + graph: Graphs.Graph + ) + local targets = Graphs.selectors.noConnections(graph.allObjects, "dependent") + local diagnosis: string | false = false + for _, target in targets do + local dependencies = Graphs.selectors.distance(graph.allObjects, {target}, "dependency", 1) + evaluate(target, false) + diagnosis = diagnosis or Graphs.check( + dependencies, + Graphs.tests.validity("valid") + ) + end + return diagnosis + end + } (describe) + + Graphs.propertyTest { + testing = "evaluate", + it = "properly clears dependency connections for objects that compute", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + for _, object in graph.allObjects do + (object :: any).TEST_OLD_DEPS = table.clone(object.dependencySet) + object.validity = "invalid" + object._evaluate = function() + (object :: any).TEST_MARKER = true + return false + end + end + end + }, + perform = function( + graph: Graphs.Graph + ) + local foundTestMarker = false + for _, object in graph.allObjects do + evaluate(object, false) + if not (object :: any).TEST_MARKER then + continue + end + foundTestMarker = true + if next(object.dependencySet) ~= nil then + return `{Graphs.nameOf(object)} had dependencies after revalidation` + end + for dependency in (object :: any).TEST_OLD_DEPS do + if dependency.dependentSet[object] ~= nil then + return `{Graphs.nameOf(object)} wasn't cleared from the dependent set of an old dependency` + end + end + end + if not foundTestMarker then + return "no test marker was found - this is a spec bug" + end + return false + end + } (describe) + + Graphs.propertyTest { + testing = "evaluate", + it = "preserves dependent connections", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + for index, object in graph.allObjects do + local dependencySet = table.clone(object.dependencySet) + object._evaluate = function() + object.dependencySet = table.clone(dependencySet) + for dependency in object.dependencySet do + evaluate(dependency, false) + end + return true + end + object.validity = "invalid" + end + end + }, + perform = function( + graph: Graphs.Graph + ) + for _, object in graph.allObjects do + if next(object.dependentSet) == nil then + continue + end + evaluate(object, false) + if next(object.dependentSet) == nil then + return `{Graphs.nameOf(object)} did not have dependents after revalidation` + end + end + return false + end + } (describe) + + Graphs.propertyTest { + testing = "evaluate", + it = "does *not* evaluate dependents", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + for index, object in graph.allObjects do + local dependencySet = table.clone(object.dependencySet) + object._evaluate = function() + object.dependencySet = table.clone(dependencySet) + for dependency in object.dependencySet do + evaluate(dependency, false) + end + return true + end + object.validity = "invalid" + end + local targets = Graphs.selectors.noConnections(graph.allObjects, "dependency") + local dependents = Graphs.selectors.distance(graph.allObjects, targets, "dependent", 1) + for _, dependent in dependents do + dependent._evaluate = function() + error(`{Graphs.nameOf(dependent)} was evaluated`, 0) + end + end + end + }, + perform = function( + graph: Graphs.Graph + ) + local targets = Graphs.selectors.noConnections(graph.allObjects, "dependency") + for _, target in targets do + evaluate(target, false) + end + return false + end + } (describe) + + Graphs.propertyTest { + testing = "evaluate", + it = "does *not* run computations for already-valid objects with a last change", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + for index, object in graph.allObjects do + object._evaluate = function() + error(`{Graphs.nameOf(object)} was evaluated`, 0) + end + object.validity = "valid" + object.lastChange = math.huge + end + end + }, + perform = function( + graph: Graphs.Graph + ) + for _, target in graph.allObjects do + evaluate(target, false) + end + return false + end + } (describe) + + Graphs.propertyTest { + testing = "evaluate", + it = "forcibly runs computations if specified to", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + for index, object in graph.allObjects do + object._evaluate = function() + (object :: any)._TEST_MARKER = true + return false + end + object.validity = "valid" + object.lastChange = math.huge + end + end + }, + perform = function( + graph: Graphs.Graph + ) + for _, target in graph.allObjects do + evaluate(target, true) + end + for _, object in graph.allObjects do + if (object :: any)._TEST_MARKER ~= true then + return `{Graphs.nameOf(object)} was not evaluated` + end + end + return false + end + } (describe) + + Graphs.propertyTest { + testing = "evaluate", + it = "runs computations for invalid objects with no change time", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + for _, object in graph.allObjects do + object.validity = "invalid" + object._evaluate = function() + (object :: any).TEST_MARKER = true + return false + end + end + end + }, + perform = function( + graph: Graphs.Graph + ) + local didAnyTests = false + for _, object in graph.allObjects do + if object.validity ~= "invalid" or object.lastChange ~= nil then + continue + end + didAnyTests = true + evaluate(object, false) + if not (object :: any).TEST_MARKER then + return `{Graphs.nameOf(object)} did not compute despite being invalid` + end + end + if not didAnyTests then + return "no tests were done - this is a spec bug" + end + return false + end + } (describe) + + Graphs.propertyTest { + testing = "evaluate", + it = "runs computations for invalid objects with non-nil change time", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + for _, object in graph.allObjects do + object._evaluate = function() + (object :: any).TEST_MARKER = true + return false + end + end + local searchNow = Graphs.selectors.noConnections(graph.allObjects, "dependency") + for depth = 0, math.huge do + local searchNext = {} + local seen = {} + for _, searchTarget in searchNow do + searchTarget.lastChange = -depth + searchTarget.validity = if depth == 0 then "valid" else "invalid" + for dependent in searchTarget.dependentSet do + if seen[dependent] then + continue + end + seen[dependent] = true + table.insert(searchNext, dependent) + end + end + if #searchNext == 0 then + break + end + searchNow = searchNext + end + end + }, + perform = function( + graph: Graphs.Graph + ) + local didAnyTests = false + for _, object in graph.allObjects do + if object.validity ~= "invalid" or object.lastChange == nil then + continue + end + didAnyTests = true + evaluate(object, false) + if not (object :: any).TEST_MARKER then + return `{Graphs.nameOf(object)} did not compute despite being invalid` + end + end + if not didAnyTests and #graph.allObjects > 1 then + return "no tests were done - this is a spec bug" + end + return false + end + } (describe) + + Graphs.propertyTest { + testing = "evaluate", + it = "does not compute if meaningful changes turn into meaningless changes", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + for _, object in graph.allObjects do + local dependencySet = table.clone(object.dependencySet) + object.validity = "invalid" + object._evaluate = function() + object.dependencySet = table.clone(dependencySet) + for dependency in object.dependencySet do + evaluate(dependency, false) + end + return true + end + end + + local transitives = Graphs.selectors.noConnections(graph.allObjects, "dependency") + local directs = Graphs.selectors.distance(graph.allObjects, transitives, "dependent", 1) + local targets = Graphs.selectors.distance(graph.allObjects, directs, "dependent", 1) + + for _, direct in directs do + local dependencySet = table.clone(direct.dependencySet) + local doEvaluate = direct._evaluate + direct._evaluate = function() + doEvaluate(direct) + return false + end + end + + for _, target in targets do + target.lastChange = -math.huge + target._evaluate = function() + error(`{Graphs.nameOf(target)} was evaluated`, 0) + end + end + end + }, + perform = function( + graph: Graphs.Graph + ) + local transitives = Graphs.selectors.noConnections(graph.allObjects, "dependency") + local directs = Graphs.selectors.distance(graph.allObjects, transitives, "dependent", 1) + local targets = Graphs.selectors.distance(graph.allObjects, directs, "dependent", 1) + + for _, target in targets do + evaluate(target, false) + end + + return false + end + } (describe) + + Graphs.propertyTest { + testing = "evaluate", + it = "throws an error for malformed graphs in finite time", + filters = {Graphs.filters.malformed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + for _, object in graph.allObjects do + local dependencySet = table.clone(object.dependencySet) + object._evaluate = function() + object.dependencySet = table.clone(dependencySet) + for dependency in object.dependencySet do + evaluate(dependency, false) + end + return true + end + end + end + }, + perform = function( + graph: Graphs.Graph + ) + local ok, err = pcall(function() + for _, object in graph.objects do + for _, object in graph.objects do + object.validity = "invalid" + object.lastChange = nil + end + evaluate(object, false) + end + end) + if ok then + return "Should not have completed without errors" + elseif string.find(err, "infiniteLoop", 1, true) ~= nil then + return false + else + return err + end + end + } (describe) + + Graphs.propertyTest { + testing = "evaluate", + it = "throws when encountering a busy object", + filters = {Graphs.filters.wellFormed}, + preparation = { + count = 1, + prepare = function( + graph: Graphs.Graph + ) + for _, object in graph.allObjects do + object.validity = "busy" + end + end + }, + perform = function( + graph: Graphs.Graph + ) + local ok, err = pcall(function() + for _, object in graph.objects do + evaluate(object, false) + end + end) + if ok then + return "Should not have completed without errors" + elseif string.find(err, "infiniteLoop", 1, true) ~= nil then + return false + else + return err + end + end + } (describe) +end \ No newline at end of file diff --git a/test/Spec/Instances/Children.spec.luau b/test/Spec/Instances/Children.spec.luau index 44738989f..b1fae1330 100644 --- a/test/Spec/Instances/Children.spec.luau +++ b/test/Spec/Instances/Children.spec.luau @@ -85,15 +85,15 @@ return function() doCleanup(scope) end) - it("should bind State objects passed as children", function() + it("should bind state objects passed as children", function() local expect = getfenv().expect local scope = {} - local child1 = New(scope, "Folder") {} - local child2 = New(scope, "Folder") {} - local child3 = New(scope, "Folder") {} - local child4 = New(scope, "Folder") {} + local child1 = New(scope, "Folder") { Name = "Child1" } + local child2 = New(scope, "Folder") { Name = "Child2" } + local child3 = New(scope, "Folder") { Name = "Child3" } + local child4 = New(scope, "Folder") { Name = "Child4" } local children = Value(scope, {child1}) local parent = New(scope, "Folder") { @@ -103,15 +103,11 @@ return function() } expect(child1.Parent).to.equal(parent) - children:set({child2, child3}) - expect(child1.Parent).to.equal(nil) expect(child2.Parent).to.equal(parent) expect(child3.Parent).to.equal(parent) - children:set({child1, child2, child3, child4}) - expect(child1.Parent).to.equal(parent) expect(child2.Parent).to.equal(parent) expect(child3.Parent).to.equal(parent) @@ -119,7 +115,7 @@ return function() doCleanup(scope) end) - it("should defer updates to State children", function() + it("should defer updates to state children", function() local expect = getfenv().expect local scope = {} @@ -143,7 +139,7 @@ return function() doCleanup(scope) end) - it("should recursively bind State children", function() + it("should recursively bind state children", function() local expect = getfenv().expect local scope = {} @@ -174,7 +170,7 @@ return function() doCleanup(scope) end) - it("should allow for State children to be nil", function() + it("should allow for state children to be nil", function() local expect = getfenv().expect local scope = {} diff --git a/test/Spec/Instances/New.spec.luau b/test/Spec/Instances/New.spec.luau index b5754010f..ebf39c3fa 100644 --- a/test/Spec/Instances/New.spec.luau +++ b/test/Spec/Instances/New.spec.luau @@ -43,4 +43,18 @@ return function() doCleanup(scope) end end) + + it("doesn't incorrectly cache scope between invocations", function() + local expect = getfenv().expect + + local scope1 = {} + local scope2 = {} + New (scope1, "Frame") {} + New (scope2, "Frame") {} + expect(#scope1).to.equal(1) + expect(#scope2).to.equal(1) + + doCleanup(scope1) + doCleanup(scope2) + end) end diff --git a/test/Spec/Memory/doCleanup.spec.luau b/test/Spec/Memory/doCleanup.spec.luau index 7f40767e9..0df2ceb73 100644 --- a/test/Spec/Memory/doCleanup.spec.luau +++ b/test/Spec/Memory/doCleanup.spec.luau @@ -88,9 +88,9 @@ return function() doCleanup(arr) expect(numRuns).to.equal(3) - expect(arr[3]).to.equal(nil) - expect(arr[2]).to.equal(nil) - expect(arr[1]).to.equal(nil) + expect(rawget(arr, 3)).to.equal(nil) + expect(rawget(arr, 2)).to.equal(nil) + expect(rawget(arr, 1)).to.equal(nil) end) it("should clean up contents of nested arrays", function() diff --git a/test/Spec/State/Computed.spec.luau b/test/Spec/State/Computed.spec.luau index 3796c196a..e2c963e71 100644 --- a/test/Spec/State/Computed.spec.luau +++ b/test/Spec/State/Computed.spec.luau @@ -24,7 +24,7 @@ return function() expect(computed).to.be.a("table") expect(computed.type).to.equal("State") expect(computed.kind).to.equal("Computed") - expect(scope[1]).to.equal(computed.oldestTask) + expect(table.find(scope, computed.oldestTask :: any)).never.to.equal(nil) doCleanup(scope) end) @@ -88,11 +88,12 @@ return function() local scope = {} local destructed = false - local _ = Computed(scope, function(_, innerScope) + local computed = Computed(scope, function(_, innerScope) table.insert(innerScope :: any, function() destructed = true end) end :: any) + peek(computed) expect(destructed).to.equal(false) doCleanup(scope) @@ -104,18 +105,21 @@ return function() local scope = {} local destructed = {} local dependency = Value(scope, 1) - local _ = Computed(scope, function(use, innerScope) + local computed = Computed(scope, function(use, innerScope) local value = use(dependency) table.insert(innerScope, function() destructed[value] = true end) return use(dependency) end) + peek(computed) expect(destructed[1]).to.equal(nil) dependency:set(2) + peek(computed) expect(destructed[1]).to.equal(true) expect(destructed[2]).to.equal(nil) dependency:set(3) + peek(computed) expect(destructed[2]).to.equal(true) doCleanup(scope) @@ -127,7 +131,7 @@ return function() local scope = {} local numDestructions = {} local dependency = Value(scope, 1) - local _ = Computed(scope, function(use, innerScope) + local computed = Computed(scope, function(use, innerScope) local value = use(dependency) table.insert(innerScope, function() numDestructions[value] = (numDestructions[value] or 0) + 1 @@ -135,14 +139,18 @@ return function() assert(value ~= 2, "This is an intentional error from a unit test") return value end) + peek(computed) expect(numDestructions[1]).to.equal(nil) dependency:set(2) + peek(computed) expect(numDestructions[1]).to.equal(nil) expect(numDestructions[2]).to.equal(1) dependency:set(3) + peek(computed) expect(numDestructions[2]).to.equal(1) expect(numDestructions[3]).to.equal(nil) dependency:set(4) + peek(computed) expect(numDestructions[3]).to.equal(1) doCleanup(scope) @@ -153,11 +161,12 @@ return function() local scope = {} local destructed = false - local _ = Computed(scope, function(use, innerScope) + local computed = Computed(scope, function(use, innerScope) table.insert(innerScope, function() destructed = true end) end :: any) + peek(computed) doCleanup(scope) expect(destructed).to.equal(true) end) diff --git a/test/Spec/State/For.spec.luau b/test/Spec/State/For.spec.luau deleted file mode 100644 index 83166deec..000000000 --- a/test/Spec/State/For.spec.luau +++ /dev/null @@ -1,198 +0,0 @@ ---!strict ---!nolint LocalUnused -local task = nil -- Disable usage of Roblox's task scheduler - -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Fusion = ReplicatedStorage.Fusion - -local For = require(Fusion.State.For) -local Value = require(Fusion.State.Value) -local Computed = require(Fusion.State.Computed) -local peek = require(Fusion.State.peek) -local doCleanup = require(Fusion.Memory.doCleanup) - -return function() - local it = getfenv().it - - it("constructs in scopes", function() - local expect = getfenv().expect - - local scope = {} - local forObject = For(scope, {}, function() - -- intentionally blank - end :: any) - - expect(forObject).to.be.a("table") - expect(forObject.type).to.equal("State") - expect(forObject.kind).to.equal("For") - expect(scope[1]).to.equal(forObject.oldestTask) - - doCleanup(scope) - end) - - it("is destroyable", function() - local expect = getfenv().expect - - local scope = {} - local forObject = For(scope, {}, function() - -- intentionally blank - end :: any) - - expect(function() - doCleanup(forObject) - end).to.never.throw() - end) - - it("processes pairs for constant tables", function() - local expect = getfenv().expect - - local scope = {} - local data = {foo = 1, bar = 2} - local seen = {} - local numCalls = 0 - local forObject = For(scope, data, function(scope, inputPair) - numCalls += 1 - local k, v = peek(inputPair).key, peek(inputPair).value - seen[k] = v - return Computed(scope, function(use) - return {key = string.upper(use(inputPair).key), value = (use(inputPair) :: any).value * 10} - end) - end) - expect(numCalls).to.equal(2) - expect(seen.foo).to.equal(1) - expect(seen.bar).to.equal(2) - - expect(peek(forObject)).to.be.a("table") - expect(peek(forObject).FOO).to.equal(10) - expect(peek(forObject).BAR).to.equal(20) - doCleanup(scope) - end) - - it("processes pairs for state tables", function() - local expect = getfenv().expect - - local scope = {} - local data = Value(scope, {foo = 1, bar = 2} :: {[string]: number}) - local numCalls = 0 - local forObject = For(scope, data, function(scope, inputPair) - numCalls += 1 - return Computed(scope, function(use) - return {key = string.upper(use(inputPair).key), value = (use(inputPair) :: any).value * 10} - end) - end) - expect(numCalls).to.equal(2) - - expect(peek(forObject)).to.be.a("table") - expect(peek(forObject).FOO).to.equal(10) - expect(peek(forObject).BAR).to.equal(20) - - data:set({frob = 3, garb = 4}) - - expect(numCalls).to.equal(2) - expect(peek(forObject).FOO).to.equal(nil) - expect(peek(forObject).BAR).to.equal(nil) - expect(peek(forObject).FROB).to.equal(30) - expect(peek(forObject).GARB).to.equal(40) - - data:set({frob = 5, garb = 6, baz = 7}) - - expect(numCalls).to.equal(3) - expect(peek(forObject).FROB).to.equal(50) - expect(peek(forObject).GARB).to.equal(60) - expect(peek(forObject).BAZ).to.equal(70) - - data:set({garb = 6, baz = 7}) - - expect(numCalls).to.equal(3) - expect(peek(forObject).FROB).to.equal(nil) - expect(peek(forObject).GARB).to.equal(60) - expect(peek(forObject).BAZ).to.equal(70) - - data:set({}) - - expect(numCalls).to.equal(3) - expect(peek(forObject).GARB).to.equal(nil) - expect(peek(forObject).BAZ).to.equal(nil) - - doCleanup(scope) - end) - - it("omits pairs that error", function() - local expect = getfenv().expect - - local scope = {} - local data = {first = 1, second = 2, third = 3} - local forObject = For(scope, data, function(scope, inputPair) - assert(peek(inputPair).key ~= "second", "This is an intentional error from a unit test") - return inputPair - end) - expect(peek(forObject).first).to.equal(1) - expect(peek(forObject).second).to.equal(nil) - expect(peek(forObject).third).to.equal(3) - doCleanup(scope) - end) - - it("omits pairs when their value is nil", function() - local expect = getfenv().expect - - local scope = {} - local data = {first = 1, second = 2, third = 3} - local omitThird = Value(scope, false) - local forObject = For(scope, data, function(scope, inputPair) - return Computed(scope, function(use) - if use(inputPair).key == "second" then - return {key = use(inputPair).key, value = nil} - elseif use(inputPair).key == "third" and use(omitThird) then - return {key = use(inputPair).key, value = nil} - else - return use(inputPair) - end - end) - end :: any) - expect(peek(forObject).first).to.equal(1) - expect(peek(forObject).second).to.equal(nil) - expect(peek(forObject).third).to.equal(3) - omitThird:set(true) - expect(peek(forObject).first).to.equal(1) - expect(peek(forObject).second).to.equal(nil) - expect(peek(forObject).third).to.equal(nil) - omitThird:set(false) - expect(peek(forObject).first).to.equal(1) - expect(peek(forObject).second).to.equal(nil) - expect(peek(forObject).third).to.equal(3) - doCleanup(scope) - end) - - it("allows values to roam when their key is nil", function() - local expect = getfenv().expect - - local scope = {} - local data = Value(scope, {"first", "second", "third"}) - local numCalls = 0 - local forObject = For(scope, data, function(scope, inputPair) - numCalls += 1 - return Computed(scope, function(use) - return {key = nil, value = use(inputPair).value} - end) - end) - expect(table.find(peek(forObject), "first")).to.be.ok() - expect(table.find(peek(forObject), "second")).to.be.ok() - expect(table.find(peek(forObject), "third")).to.be.ok() - expect(numCalls).to.equal(3) - data:set({"third", "first", "second"}) - expect(table.find(peek(forObject), "first")).to.be.ok() - expect(table.find(peek(forObject), "second")).to.be.ok() - expect(table.find(peek(forObject), "third")).to.be.ok() - expect(numCalls).to.equal(3) - data:set({"second", "first"}) - expect(table.find(peek(forObject), "first")).to.be.ok() - expect(table.find(peek(forObject), "second")).to.be.ok() - expect(table.find(peek(forObject), "third")).to.never.be.ok() - expect(numCalls).to.equal(3) - data:set({"first"}) - expect(table.find(peek(forObject), "first")).to.be.ok() - expect(table.find(peek(forObject), "second")).to.never.be.ok() - expect(numCalls).to.equal(3) - doCleanup(scope) - end) -end diff --git a/test/Spec/State/ForKeys.spec.luau b/test/Spec/State/ForKeys.spec.luau index 3935e2d65..c9949550e 100644 --- a/test/Spec/State/ForKeys.spec.luau +++ b/test/Spec/State/ForKeys.spec.luau @@ -24,7 +24,7 @@ return function() expect(forObject).to.be.a("table") expect(forObject.type).to.equal("State") expect(forObject.kind).to.equal("For") - expect(scope[1]).to.equal(forObject.oldestTask) + expect(table.find(scope, forObject.oldestTask :: any)).never.to.equal(nil) doCleanup(scope) end) @@ -210,15 +210,17 @@ return function() local scope = {} local destructed = {} local data = Value(scope, {foo = 1, bar = 2} :: {[string]: number}) - local _ = ForKeys(scope, data, function(_, innerScope, key) + local forKeys = ForKeys(scope, data, function(_, innerScope, key) table.insert(innerScope, function() destructed[key] = true end) return key end) + peek(forKeys) expect(destructed.foo).to.equal(nil) expect(destructed.bar).to.equal(nil) data:set({baz = 3}) + peek(forKeys) expect(destructed.foo).to.equal(true) expect(destructed.bar).to.equal(true) expect(destructed.baz).to.equal(nil) @@ -231,12 +233,13 @@ return function() local scope = {} local destructed = {} local data = Value(scope, {foo = 1, bar = 2}) - local _ = ForKeys(scope, data, function(_, innerScope, key) + local forKeys = ForKeys(scope, data, function(_, innerScope, key) table.insert(innerScope, function() destructed[key] = true end) return key end) + peek(forKeys) expect(destructed.foo).to.equal(nil) expect(destructed.bar).to.equal(nil) doCleanup(scope) @@ -250,16 +253,20 @@ return function() local scope = {} local data = Value(scope, {foo = 1, bar = 2}) local computations = 0 - local _ = ForKeys(scope, data, function(_, _, key) + local forKeys = ForKeys(scope, data, function(_, _, key) computations += 1 return string.upper(key) end) + peek(forKeys) expect(computations).to.equal(2) data:set({foo = 3, bar = 4}) + peek(forKeys) expect(computations).to.equal(2) data:set({foo = 3, bar = 4, baz = 5}) + peek(forKeys) expect(computations).to.equal(3) data:set({foo = 4, bar = 5, baz = 6}) + peek(forKeys) expect(computations).to.equal(3) doCleanup(scope) end) diff --git a/test/Spec/State/ForPairs.spec.luau b/test/Spec/State/ForPairs.spec.luau index be0e586c3..fc3bc0b2a 100644 --- a/test/Spec/State/ForPairs.spec.luau +++ b/test/Spec/State/ForPairs.spec.luau @@ -24,7 +24,7 @@ return function() expect(forObject).to.be.a("table") expect(forObject.type).to.equal("State") expect(forObject.kind).to.equal("For") - expect(scope[1]).to.equal(forObject.oldestTask) + expect(table.find(scope, forObject.oldestTask :: any)).never.to.equal(nil) doCleanup(scope) end) @@ -190,15 +190,17 @@ return function() local scope = {} local destructed = {} local data = Value(scope, {foo = "oof", bar = "rab", baz = "zab"}) - local _ = ForPairs(scope, data, function(_, innerScope, key, value) + local forPairs = ForPairs(scope, data, function(_, innerScope, key, value) table.insert(innerScope, function() destructed[key] = true end) return value, key end) + peek(forPairs) expect(destructed.foo).to.equal(nil) expect(destructed.bar).to.equal(nil) data:set({foo = "oof", bar = "rab", baz = "zab"}) + peek(forPairs) expect(destructed.foo).to.equal(nil) expect(destructed.bar).to.equal(nil) expect(destructed.baz).to.equal(nil) @@ -211,15 +213,17 @@ return function() local scope = {} local destructed = {} local data = Value(scope, {foo = "oof", bar = "rab"} :: {[string]: string}) - local _ = ForPairs(scope, data, function(_, innerScope, key, value) + local forPairs = ForPairs(scope, data, function(_, innerScope, key, value) table.insert(innerScope, function() destructed[key] = true end) return value, key end) + peek(forPairs) expect(destructed.foo).to.equal(nil) expect(destructed.bar).to.equal(nil) data:set({baz = "zab"}) + peek(forPairs) expect(destructed.foo).to.equal(true) expect(destructed.bar).to.equal(true) expect(destructed.baz).to.equal(nil) @@ -232,12 +236,13 @@ return function() local scope = {} local destructed = {} local data = Value(scope, {foo = "oof", bar = "rab"}) - local _ = ForPairs(scope, data, function(_, innerScope, key, value) + local forPairs = ForPairs(scope, data, function(_, innerScope, key, value) table.insert(innerScope, function() destructed[key] = true end) return value, key end) + peek(forPairs) expect(destructed.foo).to.equal(nil) expect(destructed.bar).to.equal(nil) doCleanup(scope) diff --git a/test/Spec/State/ForValues.spec.luau b/test/Spec/State/ForValues.spec.luau index 386850896..b7599902f 100644 --- a/test/Spec/State/ForValues.spec.luau +++ b/test/Spec/State/ForValues.spec.luau @@ -24,7 +24,7 @@ return function() expect(forObject).to.be.a("table") expect(forObject.type).to.equal("State") expect(forObject.kind).to.equal("For") - expect(scope[1]).to.equal(forObject.oldestTask) + expect(table.find(scope, forObject.oldestTask :: any)).never.to.equal(nil) doCleanup(scope) end) @@ -187,15 +187,17 @@ return function() local scope = {} local destructed = {} local data = Value(scope, {"foo", "bar"}) - local _ = ForValues(scope, data, function(_, innerScope, value) + local forValues = ForValues(scope, data, function(_, innerScope, value) table.insert(innerScope, function() destructed[value] = true end) return value end) + peek(forValues) expect(destructed.foo).to.equal(nil) expect(destructed.bar).to.equal(nil) data:set({"foo", "bar", "baz"}) + peek(forValues) expect(destructed.foo).to.equal(nil) expect(destructed.bar).to.equal(nil) expect(destructed.baz).to.equal(nil) @@ -208,15 +210,17 @@ return function() local scope = {} local destructed = {} local data = Value(scope, {"foo", "bar"}) - local _ = ForValues(scope, data, function(_, innerScope, value) + local forValues = ForValues(scope, data, function(_, innerScope, value) table.insert(innerScope, function() destructed[value] = true end) return value end) + peek(forValues) expect(destructed.foo).to.equal(nil) expect(destructed.bar).to.equal(nil) data:set({"baz"}) + peek(forValues) expect(destructed.foo).to.equal(true) expect(destructed.bar).to.equal(true) expect(destructed.baz).to.equal(nil) @@ -229,12 +233,13 @@ return function() local scope = {} local destructed = {} local data = Value(scope, {"foo", "bar"}) - local _ = ForValues(scope, data, function(_, innerScope, value) + local forValues = ForValues(scope, data, function(_, innerScope, value) table.insert(innerScope, function() destructed[value] = true end) return value end) + peek(forValues) expect(destructed.foo).to.equal(nil) expect(destructed.bar).to.equal(nil) doCleanup(scope) @@ -248,18 +253,23 @@ return function() local scope = {} local data = Value(scope, {"foo", "bar"}) local computations = 0 - ForValues(scope, data, function(_, _, value) + local forValues = ForValues(scope, data, function(_, _, value) computations += 1 return string.upper(value) end) + peek(forValues) expect(computations).to.equal(2) data:set({"bar", "foo"}) + peek(forValues) expect(computations).to.equal(2) data:set({"baz", "bar", "foo"}) + peek(forValues) expect(computations).to.equal(3) data:set({"foo", "baz", "bar"}) + peek(forValues) expect(computations).to.equal(3) data:set({"garb"}) + peek(forValues) expect(computations).to.equal(4) doCleanup(scope) end) @@ -270,15 +280,64 @@ return function() local scope = {} local data = Value(scope, {"foo", "foo", "foo"}) local computations = 0 - ForValues(scope, data, function(_, _, value) + local forValues = ForValues(scope, data, function(_, _, value) computations += 1 return string.upper(value) end) + peek(forValues) expect(computations).to.equal(3) data:set({"foo", "foo", "foo", "foo"}) + peek(forValues) expect(computations).to.equal(4) data:set({"bar", "foo", "foo"}) + peek(forValues) expect(computations).to.equal(5) doCleanup(scope) end) + + it("preserves the relative order of items", function() + local expect = getfenv().expect + + local scope = {} + local data = Value(scope, {"10", "20", "30", "40", "50"}) + local omit = Value(scope, {}) + local forValues = ForValues(scope, data, function(use, _, value): string? + if table.find(use(omit), value) ~= nil then + return nil + else + return value + end + end) + expect(peek(forValues)[1]).to.equal("10") + expect(peek(forValues)[2]).to.equal("20") + expect(peek(forValues)[3]).to.equal("30") + expect(peek(forValues)[4]).to.equal("40") + expect(peek(forValues)[5]).to.equal("50") + data:set({"50", "40", "30", "20", "10"}) + expect(peek(forValues)[1]).to.equal("50") + expect(peek(forValues)[2]).to.equal("40") + expect(peek(forValues)[3]).to.equal("30") + expect(peek(forValues)[4]).to.equal("20") + expect(peek(forValues)[5]).to.equal("10") + data:set({"50", "30", "10", "40"}) + expect(peek(forValues)[1]).to.equal("50") + expect(peek(forValues)[2]).to.equal("30") + expect(peek(forValues)[3]).to.equal("10") + expect(peek(forValues)[4]).to.equal("40") + expect(peek(forValues)[5]).to.equal(nil) + omit:set({"30"}) + expect(peek(forValues)[1]).to.equal("50") + expect(peek(forValues)[2]).to.equal("10") + expect(peek(forValues)[3]).to.equal("40") + expect(peek(forValues)[4]).to.equal(nil) + expect(peek(forValues)[5]).to.equal(nil) + data:set({"10", "20", "30", "40", "50"}) + expect(peek(forValues)[1]).to.equal("10") + expect(peek(forValues)[2]).to.equal("20") + expect(peek(forValues)[3]).to.equal("40") + expect(peek(forValues)[4]).to.equal("50") + expect(peek(forValues)[5]).to.equal(nil) + + doCleanup(scope) + end) end diff --git a/test/Spec/State/Value.spec.luau b/test/Spec/State/Value.spec.luau index 0538b4bb6..c00f95362 100644 --- a/test/Spec/State/Value.spec.luau +++ b/test/Spec/State/Value.spec.luau @@ -21,7 +21,7 @@ return function() expect(value).to.be.a("table") expect(value.type).to.equal("State") expect(value.kind).to.equal("Value") - expect(scope[1]).to.equal(value.oldestTask) + expect(table.find(scope, value.oldestTask :: any)).never.to.equal(nil) doCleanup(scope) end) @@ -60,6 +60,45 @@ return function() doCleanup(scope) end) + it("updates its last changed time on set", function() + local expect = getfenv().expect + + local scope = {} + local value = Value(scope, 1) + + local before = value.lastChange + value:set(2) + expect(value.lastChange).never.to.equal(before) + + before = value.lastChange + value:set(3) + expect(value.lastChange).never.to.equal(before) + + before = value.lastChange + value:set(4) + expect(value.lastChange).never.to.equal(before) + + doCleanup(scope) + end) + + it("is always valid", function() + local expect = getfenv().expect + + local scope = {} + local value = Value(scope, 1) + expect(value.validity).to.equal("valid") + + value:set(2) + expect(value.validity).to.equal("valid") + + value:set(3) + expect(value.validity).to.equal("valid") + + value:set(4) + expect(value.validity).to.equal("valid") + + doCleanup(scope) + end) it("always returns the set value", function() local expect = getfenv().expect diff --git a/test/Spec/State/updateAll.spec.luau b/test/Spec/State/updateAll.spec.luau deleted file mode 100644 index fc48c22be..000000000 --- a/test/Spec/State/updateAll.spec.luau +++ /dev/null @@ -1,201 +0,0 @@ ---!strict ---!nolint LocalUnused -local task = nil -- Disable usage of Roblox's task scheduler - -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Fusion = ReplicatedStorage.Fusion - -local updateAll = require(Fusion.State.updateAll) - -local function edge(from, to) - return { from = from, to = to } -end - -local function buildReactiveGraph(ancestorsToDescendants, handler: (any) -> boolean): any - local objects = {} - - local function getObject(named) - if objects[named] then - return objects[named] - end - local object = { - dependencySet = {}, - dependentSet = {}, - update = handler, - updates = 0, - name = named, - scope = "not nil" - } - - objects[named] = object - - return object - end - - local function relate(ancestor, descendant) - ancestor.dependentSet[descendant] = true - descendant.dependencySet[ancestor] = true - end - - for _, edge in ancestorsToDescendants do - local ancestor = getObject(edge.from) - local descendant = getObject(edge.to) - relate(ancestor, descendant) - end - - return objects -end - -return function() - local it = getfenv().it - - it("should update transitive dependencies", function() - local expect = getfenv().expect - - local objects = buildReactiveGraph({ - edge("A", "B"), - edge("B", "C"), - edge("C", "D"), - }, function(self: any) - self.updates += 1 - return true - end) - - updateAll(objects.A) - - expect(objects.A.updates).to.equal(0) - expect(objects.B.updates).to.equal(1) - expect(objects.C.updates).to.equal(1) - expect(objects.D.updates).to.equal(1) - end) - - it("should only update objects once", function() - local expect = getfenv().expect - - local objects = buildReactiveGraph({ - edge("A", "B"), edge("A", "C"), - edge("B", "D"), edge("C", "D"), - }, function(self: any) - self.updates += 1 - return true - end) - - updateAll(objects.A) - - expect(objects.A.updates).to.equal(0) - expect(objects.B.updates).to.equal(1) - expect(objects.C.updates).to.equal(1) - expect(objects.D.updates).to.equal(1) - end) - - it("should not update destroyed objects", function() - local expect = getfenv().expect - - local objects = buildReactiveGraph({ - edge("A", "B"), - edge("B", "C"), - edge("C", "D"), - }, function(self) - self.updates += 1 - return true - end) - - objects.D.scope = nil - updateAll(objects.A) - expect(objects.A.updates).to.equal(0) - expect(objects.B.updates).to.equal(1) - expect(objects.C.updates).to.equal(1) - expect(objects.D.updates).to.equal(0) - - objects.B.scope = nil - updateAll(objects.A) - expect(objects.A.updates).to.equal(0) - expect(objects.B.updates).to.equal(1) - expect(objects.C.updates).to.equal(1) - expect(objects.D.updates).to.equal(0) - end) - - it("should not update unchanged subgraphs", function() - local expect = getfenv().expect - - local objects = buildReactiveGraph({ - edge("A", "B"), - edge("B", "C"), - edge("C", "D"), - }, function(self) - self.updates += 1 - return if self.name == "C" then false else true - end) - - updateAll(objects.A) - - expect(objects.A.updates).to.equal(0) - expect(objects.B.updates).to.equal(1) - expect(objects.C.updates).to.equal(1) - expect(objects.D.updates).to.equal(0) - end) - - it("should update state objects in subgraphs of unchanged state objects", function() - local expect = getfenv().expect - - local objects = buildReactiveGraph({ - edge("A", "B"), edge("A", "D"), - edge("B", "C"), - edge("C", "E"), - edge("D", "E"), - edge("E", "F"), - }, function(self) - self.updates += 1 - if self.name == "B" then - return false - else - return true - end - end) - - updateAll(objects.A) - - expect(objects.A.updates).to.equal(0) - expect(objects.B.updates).to.equal(1) - expect(objects.C.updates).to.equal(0) - expect(objects.D.updates).to.equal(1) - expect(objects.E.updates).to.equal(1) - expect(objects.F.updates).to.equal(1) - end) - - it("should update complicated graphs correctly", function() - local expect = getfenv().expect - - local objects = buildReactiveGraph({ - edge("A", "B"), edge("A", "F"), edge("A", "I"), - edge("B", "C"), edge("B", "D"), edge("B", "E"), - edge("C", "E"), edge("C", "G"), - edge("D", "F"), - edge("E", "H"), - edge("F", "I"), - edge("G", "J"), - edge("H", "J"), - edge("I", "J"), - }, function(self) - self.updates += 1 - if self.name == "C" or self.name == "D" or self.name == "E" then - return false - else - return true - end - end) - - updateAll(objects.A) - - expect(objects.A.updates).to.equal(0) - expect(objects.B.updates).to.equal(1) - expect(objects.C.updates).to.equal(1) - expect(objects.D.updates).to.equal(1) - expect(objects.E.updates).to.equal(1) - expect(objects.F.updates).to.equal(1) - expect(objects.G.updates).to.equal(0) - expect(objects.H.updates).to.equal(0) - expect(objects.I.updates).to.equal(1) - expect(objects.J.updates).to.equal(1) - end) -end \ No newline at end of file diff --git a/test/Spec/_Integration/DynamicGraphs.spec.lua b/test/Spec/_Integration/DynamicGraphs.spec.lua new file mode 100644 index 000000000..ac6b00e4e --- /dev/null +++ b/test/Spec/_Integration/DynamicGraphs.spec.lua @@ -0,0 +1,47 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Fusion = require(ReplicatedStorage.Fusion) +local scoped, peek = Fusion.scoped, Fusion.peek + +return function() + local describe = getfenv().describe + + describe("regression tests", function() + local it = getfenv().it + + it("re-entrant Observers do not block eager updates", function() + local expect = getfenv().expect + + local scope = scoped(Fusion) + + local count = 0 + local unrelatedValue = scope:Value(count) + local trigger = scope:Value(false) + + local o1 = scope:Observer(trigger) + o1:onChange(function() + count += 1 + unrelatedValue:set(count) + end) + + local numFires = 0 + local o2 = scope:Observer(trigger) + o2:onChange(function() + numFires += 1 + end) + + trigger:set(true) + expect(numFires).to.equal(1) + trigger:set(false) + expect(numFires).to.equal(2) + trigger:set(true) + expect(numFires).to.equal(3) + + scope:doCleanup() + end) + end) +end \ No newline at end of file diff --git a/test/Util/FiniteTime.luau b/test/Util/FiniteTime.luau new file mode 100644 index 000000000..775d3b5ac --- /dev/null +++ b/test/Util/FiniteTime.luau @@ -0,0 +1,22 @@ +--!strict + +local FiniteTime = {} + +local DEFAULT_TIME_LIMIT = 1 / 10 + +function FiniteTime.start( + timeLimit: number? +): () -> () + local endTime = os.clock() + (timeLimit or DEFAULT_TIME_LIMIT) + local errored = false + local function check() + if os.clock() > endTime and not errored then + errored = true + error("Finite time limit reached. (FUSION_TEST_FINITE_TIME)", 0) + end + end + + return check +end + +return FiniteTime \ No newline at end of file diff --git a/test/Util/Graphs.luau b/test/Util/Graphs.luau new file mode 100644 index 000000000..d017a7ab8 --- /dev/null +++ b/test/Util/Graphs.luau @@ -0,0 +1,671 @@ +--!strict + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Fusion = ReplicatedStorage.Fusion +local Types = require(Fusion.Types) + +local FiniteTime = require(script.Parent.FiniteTime) + +export type GraphShape = { + name: string, + repr: {string}, + facts: { + wellFormed: boolean + }, + objects: {string}, + edges: { + { + from: string, + to: string + } + } +} +local function GraphShape(x: GraphShape) + if x.facts.wellFormed then + local reverseIndex = {} + for index, object in x.objects do + assert(reverseIndex[object] == nil, "Duplicate object definition: " .. object) + reverseIndex[object] = index + end + for _, edge in x.edges do + if reverseIndex[edge.to] < reverseIndex[edge.from] then + error(`{edge.to} appears before {edge.from} even though it's a dependency.`) + end + end + end + return table.freeze(x) +end + +export type Graph = { + shape: GraphShape, + objects: {[string]: Types.GraphObject}, + allObjects: {Types.GraphObject} +} + +local Graphs = {} + +Graphs.STANDARD = { + GraphShape { + name = "Unit", + repr = { + "A"; + }, + facts = { + wellFormed = true + }, + objects = {"A"}, + edges = { + } + }, + + GraphShape { + name = "Pair", + repr = { + "A"; + "↓"; + "B"; + }, + facts = { + wellFormed = true + }, + objects = {"A", "B"}, + edges = { + {from = "A", to = "B"} + } + }, + + GraphShape { + name = "Chain", + repr = { + "A"; + "↓"; + "B"; + "↓"; + "C"; + "↓"; + "D"; + }, + facts = { + wellFormed = true + }, + objects = {"A", "B", "C", "D"}, + edges = { + {from = "A", to = "B"}, + {from = "B", to = "C"}, + {from = "C", to = "D"} + } + }, + + GraphShape { + name = "Many In", + repr = { + "A B C"; + " ↘↓↙ "; + " D "; + }, + facts = { + wellFormed = true + }, + objects = {"A", "B", "C", "D"}, + edges = { + {from = "A", to = "D"}, + {from = "B", to = "D"}, + {from = "C", to = "D"} + } + }, + + GraphShape { + name = "Many Out", + repr = { + " A "; + " ↙↓↘ "; + "B C D"; + }, + facts = { + wellFormed = true + }, + objects = {"A", "B", "C", "D"}, + edges = { + {from = "A", to = "B"}, + {from = "A", to = "C"}, + {from = "A", to = "D"} + } + }, + + GraphShape { + name = "N", + repr = { + " A "; + " ↙ ↘ "; + "B C"; + "↓ ↓"; + "D E"; + }, + facts = { + wellFormed = true + }, + objects = {"A", "B", "C", "D", "E"}, + edges = { + {from = "A", to = "B"}, + {from = "A", to = "C"}, + {from = "B", to = "D"}, + {from = "C", to = "E"} + } + }, + + GraphShape { + name = "M", + repr = { + "A B"; + "↓↘ ↙↓"; + "↓↙ ↘↓"; + "C D"; + "↓ ↓"; + "E F"; + }, + facts = { + wellFormed = true + }, + objects = {"A", "B", "C", "D", "E", "F"}, + edges = { + {from = "A", to = "C"}, + {from = "A", to = "D"}, + {from = "B", to = "C"}, + {from = "B", to = "D"}, + {from = "C", to = "E"}, + {from = "D", to = "F"} + } + }, + + GraphShape { + name = "Diamond", + repr = { + " A "; + " ↙ ↘ "; + "B C"; + " ↘ ↙ "; + " D "; + }, + facts = { + wellFormed = true + }, + objects = {"A", "B", "C", "D"}, + edges = { + {from = "A", to = "B"}, + {from = "A", to = "C"}, + {from = "B", to = "D"}, + {from = "C", to = "D"} + } + }, + + GraphShape { + name = "Pentagon", + repr = { + " A "; + " ↙ ↘ "; + "B ↓"; + "↓ C"; + "D ↓"; + " ↘ ↙ "; + " E "; + }, + facts = { + wellFormed = true + }, + objects = {"A", "B", "C", "D", "E"}, + edges = { + {from = "A", to = "B"}, + {from = "A", to = "C"}, + {from = "B", to = "D"}, + {from = "C", to = "E"}, + {from = "D", to = "E"} + } + }, + + GraphShape { + name = "Hexagon", + repr = { + " A "; + " ↙ ↘ "; + "B C"; + "↓ ↓"; + "D E"; + " ↘ ↙ "; + " F "; + }, + facts = { + wellFormed = true + }, + objects = {"A", "B", "C", "D", "E", "F"}, + edges = { + {from = "A", to = "B"}, + {from = "A", to = "C"}, + {from = "B", to = "D"}, + {from = "C", to = "E"}, + {from = "D", to = "F"}, + {from = "E", to = "F"} + } + }, + + GraphShape { + name = "Pair Cycle", + repr = { + " A "; + " ↙ ↖ "; + " ↘ ↗ "; + " B "; + }, + facts = { + wellFormed = false + }, + objects = {"A", "B"}, + edges = { + {from = "A", to = "B"}, + {from = "B", to = "A"} + } + }, + + GraphShape { + name = "Cycle In Chain", + repr = { + " A "; + " ↓ "; + " B "; + " ↙ ↖ "; + " ↘ ↗ "; + " C "; + " ↓ "; + " D "; + }, + facts = { + wellFormed = false + }, + objects = {"A", "B", "C", "D"}, + edges = { + {from = "A", to = "B"}, + {from = "B", to = "C"}, + {from = "C", to = "B"}, + {from = "C", to = "D"} + } + }, + + GraphShape { + name = "Circle", + repr = { + " A "; + " ↙ ↖ "; + "B D"; + " ↘ ↗ "; + " C "; + }, + facts = { + wellFormed = false + }, + objects = {"A", "B", "C", "D"}, + edges = { + {from = "A", to = "B"}, + {from = "B", to = "C"}, + {from = "C", to = "D"}, + {from = "D", to = "A"} + } + }, +} + +Graphs.filters = {} + +function Graphs.filters.wellFormed( + shape: GraphShape +): boolean + return shape.facts.wellFormed +end + +function Graphs.filters.malformed( + shape: GraphShape +): boolean + return not shape.facts.wellFormed +end + +Graphs.selectors = {} + +function Graphs.selectors.distance( + selectFrom: {Types.GraphObject}, + distanceFrom: {Types.GraphObject}, + kind: "dependency" | "dependent", + desiredDistance: number +) + local isFindTarget: {[Types.GraphObject]: true} = {} + for _, object in distanceFrom or {} :: any do + isFindTarget[object] = true + end + local selection = {} + for _, object in selectFrom do + local searchNow = {[object] = true} + local searchNext = {} + for distance = 0, desiredDistance do + local found = false + for searchTarget in searchNow do + if isFindTarget[searchTarget] then + found = true + continue + end + local searchSet: {[Types.GraphObject]: unknown} = + if kind == "dependency" then + searchTarget.dependencySet + else + searchTarget.dependentSet + for dependent in searchSet do + searchNext[dependent] = true + end + end + if found then + if distance == desiredDistance then + table.insert(selection, object) + else + break + end + end + searchNow, searchNext = searchNext, searchNow + table.clear(searchNext) + end + end + return selection +end + +function Graphs.selectors.noConnections( + selectFrom: {Types.GraphObject}, + kind: "dependency" | "dependent" +) + local selection = {} + for _, object in selectFrom do + local searchSet: {[Types.GraphObject]: unknown} = + if kind == "dependency" then + object.dependencySet + else + object.dependentSet + if next(searchSet) == nil then + table.insert(selection, object) + end + end + return selection +end + +Graphs.tests = {} + +function Graphs.tests.validity( + ...: "valid" | "invalid" | "busy" +) + local possibilities = {...} + return function( + object: Types.GraphObject + ): string | false + if not table.find(possibilities, object.validity) then + return `{Graphs.nameOf(object)} was {object.validity} instead of {table.concat(possibilities, " or ")}` + else + return false + end + end +end + +function Graphs.check( + objects: {Types.GraphObject}, + test: (Types.GraphObject) -> string | false +): string | false + for _, object in objects do + local diagnosis = test(object) + if diagnosis ~= false then + return diagnosis :: string + end + end + return false +end + +function Graphs.nameOf( + object: Types.GraphObject +): string + return (object :: any).name or "GraphObject" +end + +function Graphs.format( + output: {string}, + graph: Graph +): () + + local VALIDITY_SYMBOLS = { + valid = "✓", + invalid = "✕", + busy = "○" + } + + local TIMELINESS_SYMBOLS = { + lazy = "▽", + eager = "▼" + } + + local ARROWS = {"↑", "↗", "→", "↘", "↓", "↙", "←", "↖"} + + local line = 1 + local reprWidth = 0 + while true do + local edge = graph.shape.edges[line] + local reprLine = graph.shape.repr[line] + local objectName = graph.shape.objects[line] + + if edge == nil and reprLine == nil and objectName == nil then + break + end + + if reprLine ~= nil then + reprWidth = math.max(reprWidth, utf8.len(reprLine) or 0) + else + reprLine = string.rep(" ", reprWidth) + end + + local reprLineNoArrows = reprLine + + for _, arrow in ARROWS do + reprLineNoArrows = string.gsub(reprLineNoArrows, arrow, " ") + end + + local validityLine = string.gsub(reprLineNoArrows, "%w+", function(name: string) + local object = graph.objects[name] + if object == nil then + return name + else + local symbol = VALIDITY_SYMBOLS[object.validity] or "?" + return string.rep(symbol, #name) + end + end) + + local timelinessLine = string.gsub(reprLineNoArrows, "%w+", function(name: string) + local object = graph.objects[name] + if object == nil then + return name + else + local symbol = TIMELINESS_SYMBOLS[object.timeliness] or "?" + return string.rep(symbol, #name) + end + end) + + local edgeLine = "" + do + if graph.shape.edges[line] ~= nil then + edgeLine = `{edge.from} → {edge.to}` + end + end + + edgeLine = edgeLine .. string.rep(" ", 5 - (utf8.len(edgeLine) or 0)) + + local changeLine = "" + if objectName ~= nil then + local object = graph.objects[objectName] + if object ~= nil then + changeLine = `{objectName} ◷ {object.lastChange or "nil"}` + end + end + + table.insert(output, ` | {reprLine} | {validityLine} | {timelinessLine} | {edgeLine} | {changeLine}`) + + line += 1 + end +end + +export type ObjectTemplate = { + scope: Types.Scope?, + lastChange: number?, + timeliness: nil | "lazy" | "eager", + validity: nil | "valid" | "invalid" | "busy", + _evaluate: nil | ( + self: Types.GraphObject, + name: string + ) -> boolean +} + +function Graphs.make( + shape: GraphShape +): Graph + local objects = {} + local allObjects = {} + for _, name in shape.objects do + local object: Types.GraphObject = { + scope = nil, + name = name, + createdAt = 0, + dependencySet = {}, + dependentSet = {}, + lastChange = nil, + timeliness = "lazy" :: "lazy", + validity = "valid" :: "valid", + _evaluate = function() + return true + end, + destroy = function() end + } + objects[name] = object + table.insert(allObjects, object) + end + for _, edge in shape.edges do + local from = objects[edge.from] + local to = objects[edge.to] + from.dependentSet[to] = true + to.dependencySet[from] = true + end + return { + shape = shape, + objects = objects, + allObjects = allObjects + } +end + +type PropertySubTest = { + subName: string, + graph: Graph +} +function Graphs.propertyTest( + test: { + testing: string, + it: string, + filters: {(GraphShape) -> boolean}, + preparation: nil | { + count: number, + prepare: (Graph) -> () + }, + perform: "not implemented" | (Graph) -> string | false + } +) + return function(describe: any): () + if test.perform == "not implemented" then + warn(`{test.testing} - Property test not implemented: "{test.it}"`) + return + end + describe(test.it, function() + local it = getfenv().it + + for _, shape in Graphs.STANDARD do + local filtered = false + for _, filter in test.filters do + if not filter(shape) then + filtered = true + break + end + end + if filtered then + continue + end + + local subTests: {Graph} = {} + if test.preparation == nil then + subTests[1] = Graphs.make(shape) + else + for index = 1, test.preparation.count do + local graph = Graphs.make(shape) + test.preparation.prepare(graph) + subTests[index] = graph + end + end + + for index, graph in subTests do + local testTitle = if #subTests == 1 then shape.name else `{shape.name} [{index}]` + it(testTitle, function() + local preStateFormatted = {} + Graphs.format(preStateFormatted, graph) + -- Because an incorrect algorithm is prone to hang, + -- implement a time limit as a backup strategy to + -- avoid hanging the entire test suite. + local timeCheck = FiniteTime.start() + for _, object in graph.objects do + local metatable = getmetatable(object :: any) + if metatable == nil then + metatable = {} + setmetatable(object, metatable) + end + local realValidity = object.validity + object.validity = nil :: any + function metatable:__index(key) + if key == "validity" then + timeCheck() + return realValidity + else + return rawget(self, key) + end + end + function metatable:__newindex(key, value) + if key == "validity" then + timeCheck() + realValidity = value + else + return rawset(self, key, value) + end + end + end + local ok, diagnosis = pcall(test.perform, graph) + if not ok or typeof(diagnosis) == "string" then + if string.find(diagnosis, "FUSION_TEST_FINITE_TIME", 1, true) ~= nil then + diagnosis = "the test took an unreasonable amount of time." + end + local output = { + "", + "", + ` Tested {test.testing} ...`, + ` ... spec says it {test.it} ...`, + ` ... but {diagnosis}`, + "", + " Initial state of graph after preparation:", + "", + unpack(preStateFormatted) + } + table.insert(output, "") + table.insert(output, " Final state of graph after error:") + table.insert(output, "") + Graphs.format(output, graph) + table.insert(output, "") + + error(table.concat(output, "\n")) + end + end) + end + end + end) + end +end + +return Graphs \ No newline at end of file diff --git a/test/init.server.luau b/test/init.server.luau index 83ba418a9..27fc947eb 100644 --- a/test/init.server.luau +++ b/test/init.server.luau @@ -1,6 +1,6 @@ --!strict -local TestEZ = require(script.TestEZ) +local TestEZ = require(script.TestEZ) :: any local TestVars = require(script.TestVars) local ReplicatedStorage = game:GetService("ReplicatedStorage") @@ -12,7 +12,7 @@ local SpecExternal = require(script.SpecExternal) -- run unit tests if TestVars.runTests then print("Running unit tests...") - -- Suppress "unknown require path" error here. + External.safetyTimerMultiplier = 1 / 20 External.setExternalProvider(SpecExternal) local data