diff --git a/.gitignore b/.gitignore index 5a28ccb03..eaccc5fd8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,6 @@ /site/ # Ignore selene auto-generated config -roblox.toml \ No newline at end of file +roblox.toml + +desktop.ini \ No newline at end of file diff --git a/benchmark/Colour/Oklab.bench.lua b/benchmark/Colour/Oklab.bench.lua deleted file mode 100644 index 3ed2cc0d0..000000000 --- a/benchmark/Colour/Oklab.bench.lua +++ /dev/null @@ -1,36 +0,0 @@ -local Package = game:GetService("ReplicatedStorage").Fusion -local Oklab = require(Package.Colour.Oklab) - -return { - - { - name = "Convert to Oklab", - calls = 50000, - - preRun = function() - return { - colour = Color3.new(0.25, 0.5, 0.75) - } - end, - - run = function(state) - Oklab.to(state.colour) - end - }, - - { - name = "Convert from Oklab", - calls = 50000, - - preRun = function() - return { - colour = Vector3.new(0.25, 0.5, 0.75) - } - end, - - run = function(state) - Oklab.from(state.colour) - end - } - -} \ No newline at end of file diff --git a/benchmark/Dependencies/updateAll.bench.lua b/benchmark/Dependencies/updateAll.bench.lua deleted file mode 100644 index 0105d1242..000000000 --- a/benchmark/Dependencies/updateAll.bench.lua +++ /dev/null @@ -1,104 +0,0 @@ -local Package = game:GetService("ReplicatedStorage").Fusion -local updateAll = require(Package.Dependencies.updateAll) - -local function update() - return true -end - -local function makeDependency(dependency, dependent) - dependent.dependencySet[dependency] = true - dependency.dependentSet[dependent] = true -end - -return { - - { - name = "Update empty tree", - calls = 50000, - - preRun = function() - local root = { dependentSet = {} } - return root - end, - - run = function(state) - updateAll(state) - end - }, - - { - name = "Update shallow tree", - calls = 50000, - - preRun = function() - local root = { dependentSet = {} } - local A = { dependentSet = {}, dependencySet = {}, update = update} - local B = { dependentSet = {}, dependencySet = {}, update = update} - local C = { dependentSet = {}, dependencySet = {}, update = update} - local D = { dependentSet = {}, dependencySet = {}, update = update} - - makeDependency(root, A) - makeDependency(root, B) - makeDependency(root, C) - makeDependency(root, D) - - return root - end, - - run = function(state) - updateAll(state) - end - }, - - { - name = "Update deep tree", - calls = 50000, - - preRun = function() - local root = { dependentSet = {} } - local A = { dependentSet = {}, dependencySet = {}, update = update} - local B = { dependentSet = {}, dependencySet = {}, update = update} - local C = { dependentSet = {}, dependencySet = {}, update = update} - local D = { dependentSet = {}, dependencySet = {}, update = update} - - makeDependency(root, A) - makeDependency(A, B) - makeDependency(B, C) - makeDependency(C, D) - - return root - end, - - run = function(state) - updateAll(state) - end - }, - - { - name = "Update tree with complex dependencies", - calls = 50000, - - preRun = function() - local root = { dependentSet = {} } - local A = { dependentSet = {}, dependencySet = {}, update = update} - local B = { dependentSet = {}, dependencySet = {}, update = update} - local C = { dependentSet = {}, dependencySet = {}, update = update} - local D = { dependentSet = {}, dependencySet = {}, update = update} - - makeDependency(root, A) - makeDependency(A, B) - makeDependency(B, C) - makeDependency(C, D) - makeDependency(A, C) - makeDependency(A, D) - makeDependency(B, D) - - return root - end, - - run = function(state) - updateAll(state) - end - } - -} \ No newline at end of file diff --git a/benchmark/Instances/New.bench.lua b/benchmark/Instances/New.bench.lua deleted file mode 100644 index 2261376fa..000000000 --- a/benchmark/Instances/New.bench.lua +++ /dev/null @@ -1,147 +0,0 @@ -local Package = game:GetService("ReplicatedStorage").Fusion -local New = require(Package.Instances.New) -local Children = require(Package.Instances.Children) -local OnEvent = require(Package.Instances.OnEvent) -local OnChange = require(Package.Instances.OnChange) -local Value = require(Package.State.Value) - -local function callback() - -end - -return { - - { - name = "New without properties", - calls = 50000, - - run = function() - New "Frame" {} - end - }, - - { - name = "New with properties - constant", - calls = 50000, - - run = function() - New "Frame" { - Name = "Foo" - } - end - }, - - { - name = "New with properties - state", - calls = 50000, - - run = function() - New "Frame" { - Name = Value("Foo") - } - end - }, - - { - name = "New with Parent - constant", - calls = 10000, - - preRun = function() - end, - - run = function() - New "Folder" { - Parent = Instance.new("Folder") - } - end - }, - - { - name = "New with Parent - state", - calls = 10000, - - preRun = function() - return Instance.new("Folder") - end, - - run = function(parent) - New "Folder" { - Parent = Value(parent) - } - end, - - postRun = function(parent) - parent:Destroy() - end - }, - - { - name = "New with Children - single", - calls = 50000, - - preRun = function() - return New "Folder" {} - end, - - run = function(child) - New "Frame" { - [Children] = child - } - end - }, - - { - name = "New with Children - array", - calls = 50000, - - preRun = function() - return { - New "Folder" {} - } - end, - - run = function(children) - New "Frame" { - [Children] = children - } - end - }, - - { - name = "New with Children - state", - calls = 50000, - - preRun = function() - return New "Folder" {} - end, - - run = function(child) - New "Frame" { - [Children] = Value(child) - } - end - }, - - { - name = "New with OnEvent", - calls = 50000, - - run = function() - New "Frame" { - [OnEvent "MouseEnter"] = callback - } - end - }, - - { - name = "New with OnChange", - calls = 50000, - - run = function() - New "Frame" { - [OnChange "Name"] = callback - } - end - } - -} \ No newline at end of file diff --git a/benchmark/State/ForKeys.bench.lua b/benchmark/State/ForKeys.bench.lua deleted file mode 100644 index 0d777d3d1..000000000 --- a/benchmark/State/ForKeys.bench.lua +++ /dev/null @@ -1,107 +0,0 @@ -local Package = game:GetService("ReplicatedStorage").Fusion -local Value = require(Package.State.Value) -local ForKeys = require(Package.State.ForValues) - -local function callback() - return true -end - -local function constantOutput(key) - return key -end - -local function dynamicOutput(key) - return {key} -end - -return { - { - name = "ForKeys with blank input table", - calls = 20000, - - run = function() - ForKeys({}, callback) - end, - }, - - { - name = "ForKeys with input table - constant output", - calls = 20000, - - run = function() - ForKeys({ ["Foo"] = "bar" }, constantOutput) - end, - }, - - { - name = "ForKeys with input table - dynamic output", - calls = 20000, - - run = function() - ForKeys({ ["Foo"] = "bar" }, dynamicOutput) - end, - }, - - - { - name = "ForKeys with input state - constant output", - calls = 20000, - - run = function() - ForKeys(Value({ ["Foo"] = "bar" }), constantOutput) - end, - }, - - { - name = "ForKeys with input state - dynamic output", - calls = 20000, - - run = function() - ForKeys(Value({ ["Foo"] = "bar" }), dynamicOutput) - end, - }, - - { - name = "ForKeys with changed input table - constant output", - calls = 20000, - - run = function() - local computed = ForKeys({ ["Foo"] = "bar" }, constantOutput) - computed.__inputTable = { ["Bar"] = "foo" } - computed:update() - end, - }, - - { - name = "ForKeys with changed input table - dynamic output", - calls = 20000, - - run = function() - local computed = ForKeys({ ["Foo"] = "bar" }, dynamicOutput) - computed.__inputTable = { ["Bar"] = "foo" } - computed:update() - end, - }, - - { - name = "ForKeys with changed input state - constant output", - calls = 20000, - - run = function() - local state = Value({ ["Foo"] = "bar" }) - ForKeys(state, constantOutput) - state:set({ ["Bar"] = "foo" }) - end, - }, - - { - name = "ForKeys with changed input state - dynamic output", - calls = 20000, - - run = function() - local state = Value({ ["Foo"] = "bar" }) - ForKeys(state, dynamicOutput) - state:set({ ["Bar"] = "foo" }) - end, - }, -} diff --git a/benchmark/State/ForPairs.bench.lua b/benchmark/State/ForPairs.bench.lua deleted file mode 100644 index 4dc03613f..000000000 --- a/benchmark/State/ForPairs.bench.lua +++ /dev/null @@ -1,105 +0,0 @@ -local Package = game:GetService("ReplicatedStorage").Fusion -local Value = require(Package.State.Value) -local ForPairs = require(Package.State.ForValues) - -local function callback() - return true -end - -local function constantOutput(key, value) - return key, value -end - -local function dynamicOutput(key, value) - return {key}, {value} -end - -return { - { - name = "ForPairs with blank input table", - calls = 20000, - - run = function() - ForPairs({}, callback) - end, - }, - - { - name = "ForPairs with input table - constant output", - calls = 20000, - run = function() - ForPairs({ ["Foo"] = "bar" }, constantOutput) - end, - }, - - { - name = "ForPairs with input table - dynamic output", - calls = 20000, - - run = function() - ForPairs({ ["Foo"] = "bar" }, dynamicOutput) - end, - }, - - { - name = "ForPairs with input state - constant output", - calls = 20000, - - run = function() - ForPairs(Value({ ["Foo"] = "bar" }), constantOutput) - end, - }, - - { - name = "ForPairs with input state - dynamic output", - calls = 20000, - - run = function() - ForPairs(Value({ ["Foo"] = "bar" }), dynamicOutput) - end, - }, - - { - name = "ForPairs with changed input table - constant output", - calls = 20000, - - run = function() - local computed = ForPairs({ ["Foo"] = "bar" }, constantOutput) - computed.__inputTable = { ["Bar"] = "foo" } - computed:update() - end, - }, - - { - name = "ForPairs with changed input table - dynamic output", - calls = 20000, - - run = function() - local computed = ForPairs({ ["Foo"] = "bar" }, dynamicOutput) - computed.__inputTable = { ["Bar"] = "foo" } - computed:update() - end, - }, - - { - name = "ForPairs with changed input state - constant output", - calls = 20000, - - run = function() - local state = Value({ ["Foo"] = "bar" }) - ForPairs(state, constantOutput) - state:set({ ["Bar"] = "foo" }) - end, - }, - - { - name = "ForPairs with changed input state - dynamic output", - calls = 20000, - - run = function() - local state = Value({ ["Foo"] = "bar" }) - ForPairs(state, dynamicOutput) - state:set({ ["Bar"] = "foo" }) - end, - }, -} diff --git a/benchmark/State/ForValues.bench.lua b/benchmark/State/ForValues.bench.lua deleted file mode 100644 index 82424cd4c..000000000 --- a/benchmark/State/ForValues.bench.lua +++ /dev/null @@ -1,107 +0,0 @@ -local Package = game:GetService("ReplicatedStorage").Fusion -local Value = require(Package.State.Value) -local ForValues = require(Package.State.ForValues) - -local function callback() - return true -end - -local function constantOutput(val) - return val -end - -local function dynamicOutput(val) - return {val} -end - -return { - { - name = "ForValues with blank input table", - calls = 20000, - - run = function() - ForValues({}, callback) - end, - }, - - { - name = "ForValues with input table - constant output", - calls = 20000, - - run = function() - ForValues({ 1 }, constantOutput) - end, - }, - - { - name = "ForValues with input table - dynamic output", - calls = 20000, - - run = function() - ForValues({ 1 }, dynamicOutput) - end, - }, - - - { - name = "ForValues with input state - constant output", - calls = 20000, - - run = function() - ForValues(Value({ 1 }), constantOutput) - end, - }, - - { - name = "ForValues with input state - dynamic output", - calls = 20000, - - run = function() - ForValues(Value({ 1 }), dynamicOutput) - end, - }, - - { - name = "ForValues with changed input table - constant output", - calls = 20000, - - run = function() - local computed = ForValues({ 1 }, constantOutput) - computed.__inputTable = { 2 } - computed:update() - end, - }, - - { - name = "ForValues with changed input table - dynamic output", - calls = 20000, - - run = function() - local computed = ForValues({ 1 }, dynamicOutput) - computed.__inputTable = { 2 } - computed:update() - end, - }, - - { - name = "ForValues with changed input state - constant output", - calls = 20000, - - run = function() - local state = Value({ 1 }) - ForValues(state, constantOutput) - state:set({ 2 }) - end, - }, - - { - name = "ForValues with changed input state - dynamic output", - calls = 20000, - - run = function() - local state = Value({ 1 }) - ForValues(state, dynamicOutput) - state:set({ 2 }) - end, - }, -} diff --git a/docs/api-reference/animation/animatable.md b/docs/api-reference/animation/animatable.md deleted file mode 100644 index 7dcb1b680..000000000 --- a/docs/api-reference/animation/animatable.md +++ /dev/null @@ -1,55 +0,0 @@ - - -

- :octicons-checklist-24: - Animatable - - type - since v0.1 - -

- -Represents types that can be animated component-wise. If a data type can -reasonably be represented as a fixed-length array of numbers, then it is -animatable. - -Any data type present in this type can be animated by Fusion. - -```Lua -number | CFrame | Color3 | ColorSequenceKeypoint | DateTime | NumberRange | -NumberSequenceKeypoint | PhysicalProperties | Ray | Rect | Region3 | -Region3int16 | UDim | UDim2 | Vector2 | Vector2int16 | Vector3 | Vector3int16 -``` - ------ - -## Example Usage - -```Lua -local DEFAULT_TWEEN = TweenInfo.new(0.25, Enum.EasingStyle.Quint) - -local function withDefaultTween(target: StateObject) - return Tween(target, DEFAULT_TWEEN) -end -``` - ------ - -## Animatability - -[Tween](./tween.md) and [Spring](./spring.md) work by animating the individual -components of whatever data they're working with. For example, if you tween a -Vector3, the X, Y and Z components will have the tween individually applied to -each. - -This is a very flexible definition of animatability, but it does not cover all -data types. For example, it still doesn't make sense to animate a string, a -boolean, or nil. - -By default, Tween and Spring will just snap to the goal value if you try to -smoothly animate something that is not animatable. However, if you want to try -and prevent the use of non-animatable types statically, you can use this type -definition in your own code. \ No newline at end of file diff --git a/docs/api-reference/animation/index.md b/docs/api-reference/animation/index.md deleted file mode 100644 index e93666a41..000000000 --- a/docs/api-reference/animation/index.md +++ /dev/null @@ -1,42 +0,0 @@ - - -

- :octicons-list-unordered-24: - Animation -

- -Utilities for adding transitions and animations to state objects. - ------ - -
- - - - - -
\ No newline at end of file diff --git a/docs/api-reference/animation/members/spring.md b/docs/api-reference/animation/members/spring.md new file mode 100644 index 000000000..f0b61d8b8 --- /dev/null +++ b/docs/api-reference/animation/members/spring.md @@ -0,0 +1,92 @@ + + +

+ :octicons-workflow-24: + Spring + + -> Spring<T> + +

+ +```Lua +function Fusion.Spring( + scope: Scope, + goalState: StateObject, + speed: CanBeState?, + damping: CanBeState? +) -> Spring +``` + +Constructs and returns a new [spring state object](../../types/spring). + +!!! success "Use scoped() method syntax" + This function is intended to be accessed as a method on a scope: + ```Lua + local spring = scope:Spring(goal, speed, damping) + ``` + +----- + +## Parameters + +

+ scope + + : Scope<S> + +

+ +The [scope](../../../memory/types/scope) which should be used to store +destruction tasks for this object. + +

+ goalState + + : StateObject<T> + +

+ +The goal state that this object should follow. For best results, `T` should be +[animatable](../../types/animatable). + +

+ speed + + : CanBeState<T>? + +

+ +Multiplies how fast the motion should occur; doubling the `speed` exactly halves +the time it takes for the motion to complete. + +

+ damping + + : CanBeState<T>? + +

+ +The amount of resistance the motion encounters. 0 represents no resistance, +1 is just enough resistance to prevent overshoot (critical damping), and larger +values damp out inertia effects and straighten the motion. + +----- + +

+ Returns + + -> Spring<T> + +

+ +A freshly constructed spring state object. + +----- + +## Learn More + +- [Springs tutorial](../../../../tutorials/animation/springs) \ No newline at end of file diff --git a/docs/api-reference/animation/members/tween.md b/docs/api-reference/animation/members/tween.md new file mode 100644 index 000000000..c169657dc --- /dev/null +++ b/docs/api-reference/animation/members/tween.md @@ -0,0 +1,79 @@ + + +

+ :octicons-workflow-24: + Tween + + -> Tween<T> + +

+ +```Lua +function Fusion.Tween( + scope: Scope, + goalState: StateObject, + tweenInfo: CanBeState? +) -> Tween +``` + +Constructs and returns a new [tween state object](../../types/tween). + +!!! success "Use scoped() method syntax" + This function is intended to be accessed as a method on a scope: + ```Lua + local tween = scope:Tween(goal, info) + ``` + +----- + +## Parameters + +

+ scope + + : Scope<S> + +

+ +The [scope](../../../memory/types/scope) which should be used to store +destruction tasks for this object. + +

+ goalState + + : StateObject<T> + +

+ +The goal state that this object should follow. For best results, `T` should be +[animatable](../../types/animatable). + +

+ info + + : CanBeState<TweenInfo>? + +

+ +Determines the easing curve that the motion will follow. + +----- + +

+ Returns + + -> Tween<T> + +

+ +A freshly constructed tween state object. + +----- + +## Learn More + +- [Tweens tutorial](../../../../tutorials/animation/tweens) \ No newline at end of file diff --git a/docs/api-reference/animation/spring.md b/docs/api-reference/animation/spring.md deleted file mode 100644 index ec4bca534..000000000 --- a/docs/api-reference/animation/spring.md +++ /dev/null @@ -1,125 +0,0 @@ - - -

- :octicons-package-24: - Spring - - state object - since v0.1 - -

- -Follows the value of another state object, as if linked by a damped spring. - -If the state object is not [animatable](./animatable.md), the spring will -just snap to the goal value. - -```Lua -( - goal: StateObject, - speed: CanBeState?, - damping: CanBeState? -) -> Spring -``` - ------ - -## Parameters - -- `goal` - The state object whose value should be followed. -- `speed` - Scales the time it takes for the spring to move (but does not -directly correlate to a duration). Defaults to `10`. -- `damping` - Affects the friction/damping which the spring experiences. `0` -represents no friction, and `1` is just enough friction to reach the goal -without overshooting or oscillating. Defaults to `1`. - ------ - -## Methods - -

- since v0.2 -

- -### :octicons-code-24: Spring:setPosition() - -Instantaneously moves the spring to a new position. This does not affect the -velocity of the spring. - -If the given value doesn't have the same type as the spring's current value, -the position will snap instantly to the new value. - -```Lua -(newPosition: T) -> () -``` - -#### Parameters - -- `newPosition` - The value the spring's position should jump to. - ------ - -

- since v0.2 -

- -### :octicons-code-24: Spring:setVelocity() - -Overwrites the velocity of this spring. This does not have an immediate effect -on the position of the spring. - -If the given value doesn't have the same type as the spring's current value, -the velocity will snap instantly to the new value. - -```Lua -(newVelocity: T) -> () -``` - -#### Parameters - -- `newVelocity` - The value the spring's velocity should jump to. - ------ - -

- since v0.2 -

- -### :octicons-code-24: Spring:addVelocity() - -Adds to the velocity of this spring. This does not have an immediate effect -on the position of the spring. - -If the given value doesn't have the same type as the spring's current value, -the velocity will snap instantly to the new value. - -```Lua -(deltaVelocity: T) -> () -``` - -#### Parameters - -- `deltaVelocity` - The velocity to add to the spring. - ------ - -## Example Usage - -```Lua -local position = Value(UDim2.fromOffset(25, 50)) -local smoothPosition = Spring(position, 25, 0.6) - -local ui = New "Frame" { - Parent = PlayerGui.ScreenGui, - Position = smoothPosition -} - -while true do - task.wait(5) - -- apply an impulse - smoothPosition:addVelocity(UDim2.fromOffset(-10, 10)) -end -``` \ No newline at end of file diff --git a/docs/api-reference/animation/tween.md b/docs/api-reference/animation/tween.md deleted file mode 100644 index 15a361e33..000000000 --- a/docs/api-reference/animation/tween.md +++ /dev/null @@ -1,52 +0,0 @@ - - -

- :octicons-package-24: - Tween - - state object - since v0.1 - -

- -Follows the value of another state object, by tweening towards it. - -If the state object is not [animatable](./animatable.md), the tween will -just snap to the goal value. - -```Lua -( - goal: StateObject, - tweenInfo: CanBeState? -) -> Tween -``` - ------ - -## Parameters - -- `goal` - The state object whose value should be followed. -- `tweenInfo` - The style of tween to use when moving to the goal. Defaults -to `TweenInfo.new()`. - ------ - -## Example Usage - -```Lua -local position = Value(UDim2.fromOffset(25, 50)) -local smoothPosition = Tween(position, TweenInfo.new(2)) - -local ui = New "Frame" { - Parent = PlayerGui.ScreenGui, - Position = smoothPosition -} - -while true do - task.wait(5) - position:set(peek(position) + UDim2.fromOffset(100, 100)) -end -``` \ No newline at end of file diff --git a/docs/api-reference/animation/types/animatable.md b/docs/api-reference/animation/types/animatable.md new file mode 100644 index 000000000..d756033ec --- /dev/null +++ b/docs/api-reference/animation/types/animatable.md @@ -0,0 +1,46 @@ + + +

+ :octicons-note-24: + Animatable +

+ +```Lua +export type Animatable = + number | + CFrame | + Color3 | + ColorSequenceKeypoint | + DateTime | + NumberRange | + NumberSequenceKeypoint | + PhysicalProperties | + Ray | + Rect | + Region3 | + Region3int16 | + UDim | + UDim2 | + Vector2 | + Vector2int16 | + Vector3 | + Vector3int16 +``` + +Any data type that Fusion can decompose into a tuple of animatable parameters. + +!!! note "Passing other types to animation objects" + Other types can be passed to `Tween` and `Spring` objects, however those + types will not animate. Instead, non-`Animatable` types will immediately + arrive at their goal value. + +----- + +## Learn More + +- [Tweens tutorial](../../../../tutorials/animation/tweens) +- [Springs tutorial](../../../../tutorials/animation/springs) \ No newline at end of file diff --git a/docs/api-reference/animation/types/spring.md b/docs/api-reference/animation/types/spring.md new file mode 100644 index 000000000..98655730a --- /dev/null +++ b/docs/api-reference/animation/types/spring.md @@ -0,0 +1,101 @@ + + +

+ :octicons-note-24: + Spring +

+ +```Lua +export type Spring = StateObject & Dependent & { + kind: "Spring", + setPosition: (self, newPosition: T) -> (), + setVelocity: (self, newVelocity: T) -> (), + addVelocity: (self, deltaVelocity: T) -> () +} +``` + +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. + +----- + +## Members + +

+ kind + + : "Spring" + +

+ +A more specific type string which can be used for runtime type checking. This +can be used to tell types of state object apart. + +----- + +## Methods + +

+ setPosition + + -> () + +

+ +```Lua +function Spring:setPosition( + newPosition: T +): () +``` + +Immediately snaps the spring to the given position. The position must have the +same `typeof()` as the goal state. + +

+ setVelocity + + -> () + +

+ +```Lua +function Spring:setVelocity( + newVelocity: T +): () +``` + +Overwrites the spring's velocity without changing its position. The velocity +must have the same `typeof()` as the goal state. + +

+ addVelocity + + -> () + +

+ +```Lua +function Spring:addVelocity( + deltaVelocity: T +): () +``` + +Appends to the spring's velocity without changing its position. The velocity +must have the same `typeof()` as the goal state. + +----- + +## Learn More + +- [Springs tutorial](../../../../tutorials/animation/springs) \ No newline at end of file diff --git a/docs/api-reference/animation/types/tween.md b/docs/api-reference/animation/types/tween.md new file mode 100644 index 000000000..dab6ac00b --- /dev/null +++ b/docs/api-reference/animation/types/tween.md @@ -0,0 +1,44 @@ + + +

+ :octicons-note-24: + Tween +

+ +```Lua +export type Tween = StateObject & Dependent & { + kind: "Tween" +} +``` + +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. + +----- + +## Members + +

+ kind + + : "Tween" + +

+ +A more specific type string which can be used for runtime type checking. This +can be used to tell types of state object apart. + +----- + +## Learn More + +- [Tweens tutorial](../../../../tutorials/animation/tweens) \ No newline at end of file diff --git a/docs/api-reference/errors/index.md b/docs/api-reference/errors/index.md deleted file mode 100644 index 89c95fad1..000000000 --- a/docs/api-reference/errors/index.md +++ /dev/null @@ -1,1107 +0,0 @@ - - -

- :octicons-x-circle-24: - Errors -

- -Whenever Fusion outputs any errors or messages to the console, it will have a -short error ID at the end. This is used to uniquely identify what kind of error -or message you're seeing. - -Use the search box below to paste in or type an error ID, and it will scroll to -the details for you. - - - - - - ------ - -
-

- since v0.3 -

- -## attributeNameNil - -``` -Attribute name cannot be nil. -``` - -This message occurs when you try to set an attribute with a 'nil' name. -This error shouldn't occur, however it is a failsafe incase something goes wrong. - -```Lua -local folder = New "Configuration" { - [Attribute(nil)] = "Foo" -} -``` - ------ - -
-

- since v0.1 -

- -## cannotAssignProperty - -``` -The class type 'Foo' has no assignable property 'Bar'. -``` - -This message means you tried to set a property on an instance, but the property -can't be assigned to. This could be because the property doesn't exist, or -because it's locked by Roblox to prevent edits. - -This usually occurs with the [New](../instances/new) or -[Hydrate](../instances/hydrate) functions: - -```Lua -local folder = New "Folder" { - DataCost = 12345, - ThisPropertyDoesntExist = "Example" -} -``` - -!!! tip - Different scripts may have different privileges - for example, plugins will - be allowed more privileges than in-game scripts. Make sure you have the - necessary privileges to assign to your properties! -
- ------ - -
-

- since v0.1 -

- -## cannotConnectChange - -``` -The Frame class doesn't have a property called 'Foo'. -``` - -This message means you tried to connect to a property change event, but the -property you specify doesn't exist on the instance. - -This usually occurs with the [New](../instances/new) or -[Hydrate](../instances/hydrate) functions: - -```Lua -local textBox = New "TextBox" { - [OnChange "ThisPropertyDoesntExist"] = function() - ... - end) -} -``` -
- ------ - -
-

- since v0.3 -

- -## cannotConnectAttributeChange - -``` -The Configuration class doesn't have an attribute called 'Foo'. -``` - -This message means you tried to connect to an attribute change event, however -the connection failed to register. - -```Lua --- How does an example for this work? --- An attribute change shouldn't fail, as GetAttributeChangedSignal --- doesn't error if the attribute doesn't exist. -``` - ------ - -
-

- since v0.1 -

- -## cannotConnectEvent - -``` -The Frame class doesn't have an event called 'Foo'. -``` - -This message means you tried to connect to an event on an instance, but the -event you specify doesn't exist on the instance. - -This usually occurs with the [New](../instances/new) or -[Hydrate](../instances/hydrate) functions: - -```Lua -local button = New "TextButton" { - [OnEvent "ThisEventDoesntExist"] = function() - ... - end) -} -``` -
- ------ - -
-

- since v0.1 -

- -## cannotCreateClass - -``` -Can't create a new instance of class 'Foo'. -``` - -This message means you tried to create a new instance type, but the type of -instance you specify doesn't exist in Roblox. - -This usually occurs with the [New](../instances/new) function: - -```Lua -local instance = New "ThisClassTypeIsInvalid" { - ... -} -``` -
- ------ - -
-

- since v0.1 -

- -## computedCallbackError - -``` -Computed callback error: attempt to index a nil value -``` - -This message means the callback of a [computed object](../state/computed) -encountered an error. - -```Lua -local example = Computed(function() - local badMath = 2 + "fish" -end) -``` -
- ------ - -
-

- since v0.2 -

- -## destructorNeededComputed - -``` -To return instances from Computeds, provide a destructor function. This will be an error soon - see discussion #183 on GitHub. -``` - -This message shows if you return destructible values from a -[computed object](../state/computed), without also specifying how to destroy -those values using a destructor. - -[Learn more by visiting this discussion on GitHub.](https://github.com/Elttob/Fusion/discussions/183) - -```Lua -local badComputed = Computed(function() - return New "Folder" { ... } -end, nil) -``` -
- ------ - -
-

- since v0.2 -

- -## destructorNeededForKeys - -``` -To return instances from ForKeys, provide a destructor function. This will be an error soon - see discussion #183 on GitHub. -``` - -This message shows if you return destructible values from a -[ForKeys object](../state/forkeys), without also specifying how to destroy -those values using a destructor. - -[Learn more by visiting this discussion on GitHub.](https://github.com/Elttob/Fusion/discussions/183) - -```Lua -local badForKeys = ForKeys(array, function(key) - return New "Folder" { ... } -end, nil) -``` - -!!! note - For some time during the development of v0.2, `ForKeys` would implicitly - insert a destructor for you. This behaviour still works, but it's going to - be removed in an upcoming version. -
- ------ - -
-

- since v0.2 -

- -## destructorNeededForPairs - -``` -To return instances from ForPairs, provide a destructor function. This will be an error soon - see discussion #183 on GitHub. -``` - -This message shows if you return destructible values from a -[ForPairs object](../state/forpairs), without also specifying how to destroy -those values using a destructor. - -[Learn more by visiting this discussion on GitHub.](https://github.com/Elttob/Fusion/discussions/183) - -```Lua -local badForPairs = ForPairs(array, function(key, value) - return key, New "Folder" { ... } -end, nil) -``` - -!!! note - For some time during the development of v0.2, `ForPairs` would implicitly - insert a destructor for you. This behaviour still works, but it's going to - be removed in an upcoming version. -
- ------ - -
-

- since v0.2 -

- -## destructorNeededForValues - -``` -To return instances from ForValues, provide a destructor function. This will be an error soon - see discussion #183 on GitHub. -``` - -This message shows if you return destructible values from a -[ForValues object](../state/forvalues), without also specifying how to destroy -those values using a destructor. - -[Learn more by visiting this discussion on GitHub.](https://github.com/Elttob/Fusion/discussions/183) - -```Lua -local badForValues = ForValues(array, function(value) - return New "Folder" { ... } -end, nil) -``` - -!!! note - For some time during the development of v0.2, `ForValues` would implicitly - insert a destructor for you. This behaviour still works, but it's going to - be removed in an upcoming version. -
- ------ - -
-

- since v0.2 -

- -## forKeysDestructorError - -``` -ForKeys destructor error: attempt to index a nil value -``` - -This message means the destructor passed to a [ForKeys object](../state/forkeys) -encountered an error. - -```Lua -local function destructor(x) - local badMath = 2 + "fish" -end - -local example = ForKeys(array, doSomething, destructor) -``` -
- ------ - -
-

- since v0.2 -

- -## forKeysKeyCollision - -``` -ForKeys should only write to output key 'Charlie' once when processing key changes, but it wrote to it twice. Previously input key: 'Alice'; New input key: 'Bob' -``` - -This message means you returned the same value twice for two different keys in -a [ForKeys object](../state/forkeys). - -```Lua -local data = { - Alice = true, - Bob = true -} -local example = ForKeys(data, function(key) - if key == "Alice" or key == "Bob" then - return "Charlie" - end -end) -``` -
- ------ - -
-

- since v0.2 -

- -## forKeysProcessorError - -``` -ForKeys callback error: attempt to index a nil value -``` - -This message means the callback of a [ForKeys object](../state/forkeys) -encountered an error. - -```Lua -local example = ForKeys(array, function(key) - local badMath = 2 + "fish" -end) -``` -
- ------ - -
-

- since v0.2 -

- -## forPairsDestructorError - -``` -ForPairs destructor error: attempt to index a nil value -``` - -This message means the destructor passed to a [ForPairs object](../state/forpairs) -encountered an error. - -```Lua -local function destructor(x, y) - local badMath = 2 + "fish" -end - -local example = ForPairs(array, doSomething, destructor) -``` -
- ------ - -
-

- since v0.2 -

- -## forPairsKeyCollision - -``` -ForPairs should only write to output key 'Charlie' once when processing key changes, but it wrote to it twice. Previously input key: 'Alice'; New input key: 'Bob' -``` - -This message means you returned the same value twice for two different keys in -a [ForPairs object](../state/forpairs). - -```Lua -local data = { - Alice = true, - Bob = true -} -local example = ForPairs(data, function(key, value) - if key == "Alice" or key == "Bob" then - return "Charlie", value - end -end) -``` -
- ------ - -
-

- since v0.2 -

- -## forPairsProcessorError - -``` -ForPairs callback error: attempt to index a nil value -``` - -This message means the callback of a [ForPairs object](../state/forpairs) -encountered an error. - -```Lua -local example = ForPairs(array, function(key, value) - local badMath = 2 + "fish" -end) -``` -
- ------ - -
-

- since v0.2 -

- -## forValuesDestructorError - -``` -ForValues destructor error: attempt to index a nil value -``` - -This message means the destructor passed to a [ForValues object](../state/forvalues) -encountered an error. - -```Lua -local function destructor(x) - local badMath = 2 + "fish" -end - -local example = ForValues(array, doSomething, destructor) -``` -
- ------ - -
-

- since v0.2 -

- -## forValuesProcessorError - -``` -ForValues callback error: attempt to index a nil value -``` - -This message means the callback of a [ForValues object](../state/forvalues) -encountered an error. - -```Lua -local example = ForValues(array, function(value) - local badMath = 2 + "fish" -end) -``` -
- ------ - -
-

- since v0.2 -

- -## invalidChangeHandler - -``` -The change handler for the 'Text' property must be a function. -``` - -This message means you tried to use [OnChange](../instances/onchange) on an -instance's property, but instead of passing a function callback, you passed -something else. - -```Lua -local input = New "TextBox" { - [OnChange "Text"] = "lemons" -} -``` -
- ------ - -
-

- since v0.3 -

- -## invalidAttributeChangeHandler - -``` -The change handler for the 'Ammo' attribute must be a function. -``` - -This message means you tried to use [AttributeChange](../instances/attributechange) on an -instance's attribute, but instead of passing a function callback, you passed -something else. - -```Lua -local config = New "Configuration" { - [AttributeChange "Ammo"] = "guns" -} -``` - ------ - -
-

- since v0.2 -

- -## invalidEventHandler - -``` -The handler for the 'Activated' event must be a function. -``` - -This message means you tried to use [OnEvent](../instances/onevent) on an -instance's event, but instead of passing a function callback, you passed -something else. - -```Lua -local button = New "TextButton" { - [OnEvent "Activated"] = "limes" -} -``` -
- ------ - -
-

- since v0.2 -

- -## invalidPropertyType - -``` -'Frame.Size' expected a 'UDim2' type, but got a 'Color3' type. -``` - -This message means you tried to set a property on an instance, but you gave it -the wrong type of value. - -This usually occurs with the [New](../instances/new) or -[Hydrate](../instances/hydrate) functions: - -```Lua -local ui = New "Frame" { - Size = Computed(function() - return Color3.new(1, 0, 0) - end) -} -``` -
- ------ - -
-

- since v0.2 -

- -## invalidRefType - -``` -Instance refs must be Value objects. -``` - -This message means you tried to use [Ref](../instances/ref), but you didn't also -give it a [value object](../state/value) to store the instance inside of. - -```Lua -local thing = New "Part" { - [Ref] = 2 -} -``` -
- ------ - -
-

- since v0.2 -

- -## invalidOutType - -``` -[Out] properties must be given Value objects. -``` - -This message means you tried to use [Out](../instances/out), but you didn't also -give it a [value object](../state/value) to store the property's value inside -of. - -```Lua -local thing = New "Part" { - [Out "Color"] = true -} -``` -
- ------ - -
-

- since v0.3 -

- -## invalidAttributeOutType - -``` -[AttributeOut] properties must be given Value objects. -``` - -This message means you tried to use [AttributeOut](../instances/attributeout), but you didn't also -give it a [value object](../state/value) to store the attributes's value inside -of. - -```Lua -local config = New "Configuration" { - [AttributeChange "Ammo"] = "guns" -} -``` - ------ - -
-

- since v0.2 -

- -## invalidOutProperty - -``` -The Part class doesn't have a property called 'Flobulator'. -``` - -This message means you tried to read a property of an instance using -[Out](../instances/out), but the property can't be read. This could be because -the property doesn't exist, or because it's locked by Roblox to prevent reading. - -```Lua -local value = Value() - -local thing = New "Part" { - [Out "Flobulator"] = value -} -``` -
- ------ - -
-

- since v0.3 -

- -## invalidAttributeOutName - -``` -The Configuration class doesn't have an attribute called 'Ammo' -``` - -This message means you tried to read an attribute of an instance using -[AttributeOut](../instances/attributeout), but the attribute can't be read. This error -shouldn't occur, however it is here for a failsafe. - -```Lua --- Once again, how does an example for this work? -``` - ------ - -
-

- since v0.1 -

- -## invalidSpringDamping - -``` -The damping ratio for a spring must be >= 0. (damping was -0.50) -``` - -This message means you gave a damping ratio to a [spring object](../animation/spring) -which is less than 0: - -```Lua -local speed = 10 -local damping = -12345 -local spring = Spring(state, speed, damping) -``` - -Damping ratio must always be between 0 and infinity for a spring to be -physically simulatable. -
- ------ - -
-

- since v0.1 -

- -## invalidSpringSpeed - -``` -The speed of a spring must be >= 0. (speed was -2.00) -``` - -This message means you gave a speed to a [spring object](../animation/spring) -which is less than 0: - -```Lua -local speed = -12345 -local spring = Spring(state, speed) -``` - -Since a speed of 0 is equivalent to a spring that doesn't move, any slower speed -is not simulatable or physically sensible. -
- ------ - -
-

- since v0.1 -

- -## mistypedSpringDamping - -``` -The damping ratio for a spring must be a number. (got a boolean) -``` - -This message means you gave a damping ratio to a [spring object](../animation/spring) -which isn't a number. - -```Lua -local speed = 10 -local damping = true -local spring = Spring(state, speed, damping) -``` -
- ------ - -
-

- since v0.1 -

- -## mistypedSpringSpeed - -``` -The speed of a spring must be a number. (got a boolean) -``` - -This message means you gave a speed to a [spring object](../animation/spring) -which isn't a number. - -```Lua -local speed = true -local spring = Spring(state, speed) -``` -
- ------ - -
-

- since v0.1 -

- -## mistypedTweenInfo - -``` -The tween info of a tween must be a TweenInfo. (got a boolean) -``` - -This message shows if you try to provide a tween info to a [tween](../animation/tween) -which isn't a TweenInfo: - -```Lua -local tweenInfo = true -local tween = Tween(state, tweenInfo) -``` -
- ------ - -
-

- since v0.2 -

- -## multiReturnComputed - -``` -Returning multiple values from Computeds is discouraged, as behaviour will change soon - see discussion #189 on GitHub. -``` - -This message means you returned more than one value from a [computed object](../state/computed). -There are two ways this could occur; either you're explicitly returning two -values (e.g. `return 1, 2`) or you're calling a function which returns two -values (e.g. `string.find`). - -A simple fix is to surround your return expression with parentheses `()`, or to -save it into a variable before returning it. - -[Learn more by visiting this discussion on GitHub.](https://github.com/Elttob/Fusion/discussions/189) - -```Lua -local badComputed = Computed(function() - return 1, 2, "foo", true -end, nil) -``` -
- ------ - -
-

- since v0.3 -

- -## noTaskScheduler - -``` -Fusion is not connected to an external task scheduler. -``` - -This message shows when Fusion attempts to schedule something for execution -without first setting a task scheduler for the library to use. - -For users of Fusion on Roblox, this generally shouldn't occur as Fusion should -automatically be bound to Roblox's task scheduler. However, when using Fusion -in other environments, the fix is to provide Fusion with all the task scheduler -callbacks necessary to schedule tasks for execution in the future. - ------ - -
-

- since v0.2 -

- -## springTypeMismatch - -``` -The type 'number' doesn't match the spring's type 'Color3'. -``` - -Some methods on [spring objects](../animation/spring) require incoming values to -match the types previously being used on the spring. - -This message means you passed a value to one of those methods, but it wasn't the -same type as the type of the spring. - -```Lua -local colour = State(Color3.new(1, 0, 0)) -local colourSpring = Spring(colour) - -colourSpring:addVelocity(Vector2.new(2, 3)) -``` -
- ------ - -
-

- since v0.3 -

- -## stateGetWasRemoved - -``` -`StateObject:get()` has been replaced by `use()` and `peek()` - see discussion #217 on GitHub. -``` - -This message means you attempted to call the now-removed `:get()` method on a -[state object](../state/stateobject.md). Starting with Fusion 0.3, this method -has been removed in favour of the [peek function](../state/peek.md) and -[use callbacks](../state/use.md). - -[Learn more by visiting this discussion on GitHub.](https://github.com/Elttob/Fusion/discussions/217) - -```Lua -local value = Value(5) -print(value:get()) -- should be print(peek(value)) -``` -
- ------ - -
-

- since v0.1 -

- -## strictReadError - -``` -'thisDoesNotExist' is not a valid member of 'Fusion'. -``` - -This message means you tried to access something that doesn't exist. This -specifically occurs with a few 'locked' tables in Fusion, such as the table -returned by the module directly. - -```Lua -local Foo = Fusion.thisDoesNotExist -``` -
- ------ - -
-

- since v0.1 -

- -## unknownMessage - -``` -Unknown error: attempt to index a nil value -``` - -If you see this message, it's almost certainly an internal bug, so make sure to -get in contact so the issue can be fixed. - -When Fusion code attempts to log a message, warning or error, it needs to -provide an ID. This ID is used to show the correct message, and serves as a -simple, memorable identifier if you need to look up the message later. -However, if that code provides an invalid ID, then the message will be replaced -with this one. -
- ------ - -
-

- since v0.1 -

- -## unrecognisedChildType - -``` -'number' type children aren't accepted as children in `New`. -``` - -This message means you tried to pass something to [Children](../instances/children.md) -which isn't a valid [child](../instances/child.md). This usually means that you -passed something that isn't an instance, array or [state object](../state/stateobject.md). - -```Lua -local instance = New "Folder" { - [Children] = { - 1, 2, 3, 4, 5, - - {true, false}, - - State(Enum.Material.Grass) - } -} -``` - -!!! note - Note that state objects are allowed to store `nil` to represent the absence - of an instance, as an exception to these rules. -
- ------ - -
-

- since v0.1 -

- -## unrecognisedPropertyKey - -``` -'number' keys aren't accepted in the property table of `New`. -``` - -This message means, while using [New](../instances/new) or [Hydrate](../instances/hydrate), -you specified something in the property table that's not a property name or -[special key](../instances/specialkey). - -Commonly, this means you accidentally specified children directly inside of -the property table, rather than using the dedicated [Children](../instances/children.md) -special key. - -```Lua -local folder = New "Folder" { - [Vector3.new()] = "Example", - - "This", "Shouldn't", "Be", "Here" -} -``` -
- ------ - -
-

- since v0.2 -

- -## unrecognisedPropertyStage - -``` -'discombobulate' isn't a valid stage for a special key to be applied at. -``` - -Fusion provides a standard interface for defining [special keys](../instances/specialkey.md) -which can be used to extend the functionality of [New](../instances/new.md) or -[Hydrate](../instances/hydrate.md). - -Within this interface, keys can select when they run using the `stage` field. -If an unexpected value is passed as the stage, then this error will be thrown -when attempting to use the key. - -```Lua -local Example = { - type = "SpecialKey", - kind = "Example", - stage = "discombobulate", - apply = function() ... end -} - -local folder = New "Folder" { - [Example] = "foo" -} -``` \ No newline at end of file diff --git a/docs/api-reference/general/errors.md b/docs/api-reference/general/errors.md new file mode 100644 index 000000000..d72ae735c --- /dev/null +++ b/docs/api-reference/general/errors.md @@ -0,0 +1,752 @@ + + +

+ :octicons-x-circle-24: + Errors +

+ +Whenever Fusion outputs any errors or messages to the console, it will have a +short error ID at the end. This is used to uniquely identify what kind of error +or message you're seeing. + +Use the search box below to paste in or type an error ID, and it will scroll to +the details for you. + + + + + +----- + +
+ +## callbackError + +``` +Error in callback: attempt to perform arithmetic (add) on number and string +``` + +**Thrown by:** +[`Computed`](../../state/members/computed), +[`ForKeys`](../../state/members/forkeys), +[`ForValues`](../../state/members/forvalues), +[`ForPairs`](../../state/members/forpairs), +[`Contextual`](../../memory/members/contextual) + +Fusion ran a function you specified, but the function threw an error that Fusion +couldn't handle. + +The error includes a more specific message which can be used to diagnose the +issue. +
+ +----- + +
+ +## cannotAssignProperty + +``` +The class type 'Foo' has no assignable property 'Bar'. +``` + +**Thrown by:** +[`New`](../../roblox/members/new), +[`Hydrate`](../../roblox/members/hydrate) + +You tried to set a property on an instance, but the property can't be assigned +to for some reason. This could be because the property doesn't exist, or because +it's locked by Roblox to prevent edits. + +!!! warning "Check your privileges" + Different scripts may have different privileges - for example, plugins will + be allowed more privileges than in-game scripts. Make sure you have the + necessary privileges to assign to your properties! +
+ +----- + +
+ +## cannotConnectChange + +``` +The Frame class doesn't have a property called 'Foo'. +``` + +**Thrown by:** +[`OnChange`](../../roblox/members/onchange) + +You tried to connect to a property change event, but the property you specify +doesn't exist on the instance. +
+ +----- + +
+ +## cannotConnectEvent + +``` +The Frame class doesn't have an event called 'Foo'. +``` + +**Thrown by:** +[`OnEvent`](../../roblox/members/onevent) + +You tried to connect to an event on an instance, but the event you specify +doesn't exist on the instance. +
+ +----- + +
+ +## cannotCreateClass + +``` +Can't create a new instance of class 'EditableImage'. +``` + +**Thrown by:** +[`New`](../../roblox/members/new) + +You attempted to create a type of instance that Fusion can't create. + +!!! warning "Beta features" + Some instances are only creatable when you have certain Studio betas + enabled. Check your Beta Features tab to ensure that beta features aren't + causing the issue. +
+ +----- + +
+ +## cleanupWasRenamed + +``` +`Fusion.cleanup` was renamed to `Fusion.doCleanup`. This will be an error in +future versions of Fusion. +``` + +**Thrown by:** +[`doCleanup`](../../memory/members/docleanup) + +You attempted to use `cleanup()` in Fusion 0.3, which replaces it with the +`doCleanup()` method. +
+ +----- + +
+ +## destroyedTwice + +``` +Attempted to destroy Computed twice; ensure you're not manually calling +`:destroy()` while using scopes. See discussion #292 on GitHub for advice. +``` + +**Thrown by:** +[`Value`](../../state/members/value), +[`Computed`](../../state/members/computed), +[`Observer`](../../state/members/observer), +[`ForKeys`](../../state/members/forkeys), +[`ForValues`](../../state/members/forvalues), +[`ForPairs`](../../state/members/forpairs), +[`Spring`](../../animation/members/spring), +[`Tween`](../../animation/members/tween) + +**Related discussions:** +[`#292`](https://github.com/dphfox/Fusion/discussions/292) + +The `:destroy()` method of the object in question was called more than once. + +This usually means you called `:destroy()` manually, which is almost never +required because Fusion's constructors always link objects to +[scopes](../../../tutorials/fundamentals/scopes). When that scope is passed to +[`doCleanup()`](../../memory/members/docleanup), the `:destroy()` method is +called on every object inside. +
+ +----- + +
+ +## destructorRedundant + +``` +Computed 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. +``` + +**Thrown by:** +[`Computed`](../../state/members/computed), +[`ForKeys`](../../state/members/forkeys), +[`ForValues`](../../state/members/forvalues), +[`ForPairs`](../../state/members/forpairs) + +**Related discussions:** +[`#292`](https://github.com/dphfox/Fusion/discussions/292) + +You passed an extra parameter to the constructor, which has historically been +interpreted as a function that runs when a value is cleaned up. + +This mechanism has been replaced by +[scopes](../../../tutorials/fundamentals/scopes). +
+ +----- + +
+ +## forKeyCollision + +``` +The key '6' was returned multiple times simultaneously, which is not allowed in +`For` objects. +``` + +**Thrown by:** +[`ForKeys`](../../state/members/forkeys), +[`ForPairs`](../../state/members/forpairs) + +When called with different items from the table, the same key was returned for +both of them. This is not allowed, because keys have to be unique in a table. +
+ +----- + +
+ +## invalidAttributeChangeHandler + +``` +The change handler for the 'Active' attribute must be a function. +``` + +**Thrown by:** +[`AttributeChange`](../../roblox/members/attributechange) + +`AttributeChange` expected you to provide a function for it to run when the +attribute changes, but you provided something other than a function. + +For example, you might have accidentally provided `nil`. +
+ +----- + +
+ +## invalidAttributeOutType + +``` +[AttributeOut] properties must be given Value objects. +``` + +**Thrown by:** +[`AttributeOut`](../../roblox/members/attributeout) + +`AttributeOut` expected you to give it a [value](../../state/members/value), but +you gave it something else. +
+ +----- + +
+ +## invalidChangeHandler + +``` +The change handler for the 'AbsoluteSize' property must be a function. +``` + +**Thrown by:** +[`OnChange`](../../roblox/members/onchange) + +`OnChange` expected you to provide a function for it to run when the property +changes, but you provided something other than a function. + +For example, you might have accidentally provided `nil`. +
+ +----- + +
+ +## invalidEventHandler + +``` +The handler for the 'MouseEnter' event must be a function. +``` + +**Thrown by:** +[`OnEvent`](../../roblox/members/onevent) + +`OnEvent` expected you to provide a function for it to run when the event is +fired, but you provided something other than a function. + +For example, you might have accidentally provided `nil`. +
+ +----- + +
+ +## invalidOutProperty + +``` +The Frame class doesn't have a property called 'MouseButton1Down'. +``` + +**Thrown by:** +[`Out`](../../roblox/members/out) + +The property that you tried to output doesn't exist on the instance that `Out` +was used with. +
+ +----- + +
+ +## invalidOutType + +``` +[Out] properties must be given Value objects. +``` + +**Thrown by:** +[`Out`](../../roblox/members/out) + +`Out` expected you to give it a [value](../../state/members/value), but you gave +it something else. +
+ +----- + +
+ +## invalidPropertyType + +``` +'Frame.BackgroundColor3' expected a 'Color3' type, but got a 'Vector3' type. +``` + +**Thrown by:** +[`New`](../../roblox/members/new), +[`Hydrate`](../../roblox/members/hydrate) + +You attempted to assign a value to a Roblox instance's property, but the +assignment threw an error because that property doesn't accept values of that +type. +
+ +----- + +
+ +## invalidRefType + +``` +Instance refs must be Value objects. +``` + +**Thrown by:** +[`Ref`](../../roblox/members/ref) + +`Ref` expected you to give it a [value](../../state/members/value), but you gave +it something else. +
+ +----- + +
+ +## invalidSpringDamping + +``` +The damping ratio for a spring must be >= 0. (damping was -1.00) +``` + +**Thrown by:** +[`Spring`](../../roblox/members/spring) + +You provided a damping ratio that the spring doesn't support, for example `NaN`, +or a negative damping implying negative friction. +
+ +----- + +
+ +## invalidSpringSpeed + +``` +The speed of a spring must be >= 0. (speed was NaN) +``` + +**Thrown by:** +[`Spring`](../../roblox/members/spring) + +You provided a speed multiplier that the spring doesn't support, for example +`NaN` or a negative speed implying the spring moves backwards through time. +
+ +----- + +
+ +## mergeConflict + +``` +Multiple definitions for 'Observer' found while merging. +``` + +**Thrown by:** +[`scoped`](../../memory/members/scoped) + +Fusion tried to merge together multiple tables, but a key was found in more than +one of the tables, and it's unclear which one you intended to have in the final +merged result. + +This can happen subtly with methods such as +[`scoped()`](../../memory/members/scoped) which automatically merge together all +of their arguments. +
+ +----- + +
+ +## mistypedSpringDamping + +``` +The damping ratio for a spring must be a number. (got a string) +``` + +**Thrown by:** +[`Spring`](../../roblox/members/spring) + +You provided a damping ratio that the spring couldn't understand. Damping ratio +has to be a number. +
+ +----- + +
+ +## mistypedSpringSpeed + +``` +The speed of a spring must be a number. (got a string) +``` + +**Thrown by:** +[`Spring`](../../roblox/members/spring) + +You provided a speed multiplier that the spring couldn't understand. Speed has +to be a number. +
+ +----- + +
+ +## mistypedTweenInfo + +``` +The tween info of a tween must be a TweenInfo. (got a table) +``` + +**Thrown by:** +[`Tween`](../../roblox/members/tween) + +You provided an easing curve that the tween couldn't understand. The easing +curve has to be specified using Roblox's `TweenInfo` data type. +
+ +----- + +
+ +## noTaskScheduler + +``` +Fusion is not connected to an external task scheduler. +``` + +Fusion depends on a task scheduler being present to perform certain time-related +tasks such as deferral, delays, or updating animations. You'll need to define a +set of standard task scheduler functions that Fusion can use for those purposes. + +Roblox users should never see this error, as Fusion automatically connects to +Roblox's task scheduling APIs. +
+ +----- + +
+ +## 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. +``` + +**Thrown by:** +[`Spring`](../../animation/members/spring), +[`Tween`](../../animation/members/tween), +[`New`](../../roblox/members/new), +[`Hydrate`](../../roblox/members/hydrate), +[`Attribute`](../../roblox/members/attribute), +[`AttributeOut`](../../roblox/members/attributeout), +[`Out`](../../roblox/members/out), +[`Ref`](../../roblox/members/ref), +[`Computed`](../../state/members/computed), +[`Observer`](../../state/members/observer) + +**Related discussions:** +[`#292`](https://github.com/dphfox/Fusion/discussions/292) + +If you use an object after it's been destroyed, then your code can break. This +mainly happens when one object 'outlives' another object that it's using. + +Because [scopes](../../../tutorials/fundamentals/scopes) clean up the newest +objects first, this can happen when an old object depends on something much +newer that itself. During cleanup, a situation could arise where the newer +object is destroyed, then the older object runs code of some kind that needed +the newer object to be there. + +Fusion can check for situations like this by analysing the scopes. This message +is shown when Fusion can prove one of these situations will occur. + +There are two typical solutions: + +- If the objects should always be created and destroyed at the exact same time, +then ensure they're created in the correct order. +- Otherwise, move the objects into separate scopes, and ensure that both scopes +can exist without the other scope. +
+ +----- + +
+ +## propertySetError + +``` +Error setting property: UIAspectRatioConstraint.AspectRatio set to a +non-positive value. Value must be a positive. +``` + +**Thrown by:** +[`New`](../../roblox/members/new), +[`Hydrate`](../../roblox/members/hydrate) + +You attempted to set a property, but Roblox threw an error in response. + +The error includes a more specific message which can be used to diagnose the +issue. +
+ +----- + +
+ +## scopeMissing + +``` +To create Observers, provide a scope. (e.g. `myScope:Observer(watching)`). See +discussion #292 on GitHub for advice. +``` + +**Thrown by:** +[`New`](../../roblox/members/new), +[`Hydrate`](../../roblox/members/hydrate), +[`Value`](../../state/members/value), +[`Computed`](../../state/members/computed), +[`Observer`](../../state/members/observer), +[`ForKeys`](../../state/members/forkeys), +[`ForValues`](../../state/members/forvalues), +[`ForPairs`](../../state/members/forpairs), +[`Spring`](../../animation/members/spring), +[`Tween`](../../animation/members/tween) + +**Related discussions:** +[`#292`](https://github.com/dphfox/Fusion/discussions/292) + +You attempted to create an object without providing a +[scope](../../../tutorials/fundamentals/scopes) as the first parameter. + +Scopes are mandatory for all Fusion constructors so that Fusion knows when the +object should be destroyed. +
+ +----- + +
+ +## springTypeMismatch + +``` +The type 'Vector3' doesn't match the spring's type 'Color3'. +``` + +**Thrown by:** +[`Spring`](../../animation/members/spring) + +The spring expected you to provide a type matching the data type that the spring +is currently outputting. However, you provided a different data type. +
+ +----- + +
+ +## stateGetWasRemoved + +``` +`StateObject:get()` has been replaced by `use()` and `peek()` - see discussion +#217 on GitHub. +``` + +**Thrown by:** +[`Value`](../../state/members/value), +[`Computed`](../../state/members/computed), +[`ForKeys`](../../state/members/forkeys), +[`ForValues`](../../state/members/forvalues), +[`ForPairs`](../../state/members/forpairs), +[`Spring`](../../animation/members/spring), +[`Tween`](../../animation/members/tween) + +**Related discussions:** +[`#217`](https://github.com/dphfox/Fusion/discussions/217) + +Older versions of Fusion let you call `:get()` directly on state objects to read +their current value and attempt to infer dependencies. + +This has been replaced by [use functions](../../state/types/use) in Fusion 0.3 +for more predictable behaviour and better support for constant values. +
+ +----- + +
+ +## unknownMessage + +``` +Unknown error: attempt to call a nil value +``` + +Fusion ran into a problem, but couldn't associate it with a valid type of error. +This is a fallback error type which shouldn't be seen by end users, because it +indicates that Fusion code isn't reporting errors correctly. +
+ +----- + +
+ +## unrecognisedChildType + +``` +'string' type children aren't accepted by `[Children]`. +``` + +**Thrown by:** +[`Children`](../../roblox/members/children) + +You provided a value inside of `[Children]` which didn't meet the definition of +a [child](../../roblox/types/child) value. Check that you're only passing +instances, arrays and state objects. +
+ +----- + +
+ +## unrecognisedPropertyKey + +``` +'number' keys aren't accepted in property tables. +``` + +**Thrown by:** +[`New`](../../roblox/members/new), +[`Hydrate`](../../roblox/members/hydrate) + +You provided something other than a property assignment (`Property = Value`) or +[special key](../../roblox/types/specialkey) in your property table. + +Most commonly, this means you tried to add child instances directly into the +property table, rather than passing them into the +[`[Children]`](../../roblox/members/children) special key. +
+ + +----- + +
+ +## unrecognisedPropertyStage + +``` +'children' isn't a valid stage for a special key to be applied at. +``` + +**Thrown by:** +[`New`](../../roblox/members/new), +[`Hydrate`](../../roblox/members/hydrate) + +You attempted to use a [special key](../../roblox/types/specialkey) which has a +misconfigured `stage`, so Fusion didn't know when to apply it during instance +construction. +
+ +----- + +
+ +## useAfterDestroy + +``` +The Value object is no longer valid - it was destroyed before the Computed that +is use()-ing. See discussion #292 on GitHub for advice. +``` + +**Thrown by:** +[`Spring`](../../animation/members/spring), +[`Tween`](../../animation/members/tween), +[`New`](../../roblox/members/new), +[`Hydrate`](../../roblox/members/hydrate), +[`Attribute`](../../roblox/members/attribute), +[`AttributeOut`](../../roblox/members/attributeout), +[`Out`](../../roblox/members/out), +[`Ref`](../../roblox/members/ref), +[`Computed`](../../state/members/computed), +[`Observer`](../../state/members/observer) + +**Related discussions:** +[`#292`](https://github.com/dphfox/Fusion/discussions/292) + +Your code attempted to access an object after that object was destroyed, either +because its `:destroy()` method was called manually, or because the object's +[scope](../../../tutorials/fundamentals/scope) was cleaned up. + +Make sure your objects are being added to the correct scopes according to when +you expect them to be destroyed. Additionally, make sure your code can detect +and deal with situations where other objects are no longer available. +
diff --git a/docs/api-reference/general/members/contextual.md b/docs/api-reference/general/members/contextual.md new file mode 100644 index 000000000..089f04819 --- /dev/null +++ b/docs/api-reference/general/members/contextual.md @@ -0,0 +1,52 @@ + + +

+ :octicons-workflow-24: + Contextual + + -> Contextual<T> + +

+ +```Lua +function Fusion.Contextual( + defaultValue: T +): Contextual +``` + +Constructs and returns a new [contextual](../../types/contextual). + +----- + +## Parameters + +

+ defaultValue + + : T + +

+ +The value which `Contextual:now()` should return if no value has been specified +by `Contextual:is():during()`. + +----- + +

+ Returns + + -> Contextual<T> + +

+ +A freshly constructed contextual. + +----- + +## Learn More + +- [Sharing Values tutorial](../../../../tutorials/best-practices/sharing-values) \ No newline at end of file diff --git a/docs/api-reference/general/members/version.md b/docs/api-reference/general/members/version.md new file mode 100644 index 000000000..aa8ba3d0d --- /dev/null +++ b/docs/api-reference/general/members/version.md @@ -0,0 +1,22 @@ + + +

+ :octicons-workflow-24: + version + + : Version + +

+ +```Lua +Fusion.version: Version +``` + +The version of the Fusion source code. + +`isRelease` is only `true` when using a version of Fusion downloaded from +[the Releases page](https://github.com/dphfox/Fusion/releases). \ No newline at end of file diff --git a/docs/api-reference/general/types/contextual.md b/docs/api-reference/general/types/contextual.md new file mode 100644 index 000000000..0053f9a50 --- /dev/null +++ b/docs/api-reference/general/types/contextual.md @@ -0,0 +1,87 @@ + + +

+ :octicons-note-24: + Contextual +

+ +```Lua +export type Contextual = { + type: "Contextual", + now: (self) -> T, + is: (self, newValue: T) -> { + during: (self, callback: (A...) -> R, A...) -> R + } +} +``` + +An object representing a widely-accessible value, which can take on different +values at different times in different coroutines. + +!!! note "Non-standard type syntax" + The above type definition uses `self` to denote methods. At time of writing, + Luau does not interpret `self` specially. + +----- + +## Fields + +

+ type + + : "Contextual" + +

+ +A type string which can be used for runtime type checking. + +----- + +## Methods + +

+ now + + -> T + +

+ +```Lua +function Contextual:now(): T +``` + +Returns the current value of this contextual. This varies based on when the +function is called, and in what coroutine it was called. + +

+ is/during + + -> R + +

+ +```Lua +function Contextual:is( + newValue: T +): { + during: ( + self, + callback: (A...) -> R, + A... + ) -> R +} +``` + +Runs the `callback` with the arguments `A...` and returns the value the callback +returns (`R`). The `Contextual` will appear to be `newValue` in the callback, +unless it's overridden by another `:is():during()` call. + +----- + +## Learn More + +- [Sharing Values tutorial](../../../../tutorials/best-practices/sharing-values) \ No newline at end of file diff --git a/docs/api-reference/general/types/version.md b/docs/api-reference/general/types/version.md new file mode 100644 index 000000000..5c4385359 --- /dev/null +++ b/docs/api-reference/general/types/version.md @@ -0,0 +1,55 @@ + + +

+ :octicons-note-24: + Version +

+ +```Lua +export type Version = { + major: number, + minor: number, + isRelease: boolean +} +``` + +Describes a version of Fusion's source code. + +----- + +## Members + +

+ major + + : number + +

+ +The major version number. If this is greater than `0`, then two versions sharing +the same major version number are not expected to be incompatible or have +breaking changes. + +

+ minor + + : number + +

+ +The minor version number. Describes version updates that are not enumerated by +the major version number, such as versions prior to 1.0, or versions which +are non-breaking. + +

+ isRelease + + : boolean + +

+ +Describes whether the version was sourced from an official release package. \ No newline at end of file diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index d70204fe9..c2c1756f5 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -7,113 +7,122 @@ title: API Reference Welcome to the API Reference! This is where you can find more technical documentation about what the Fusion library provides. -This isn't a beginner's guide. For a guided experience, [try the tutorials!](../tutorials/) +For a beginner-friendly experience, [try the tutorials.](../tutorials/) ------ +## Most Popular -## Navigation +
-Using the sidebar on the left, you can find API members grouped by category. -Alternatively, you can search for APIs using the search box at the top of the -page. - ------ - -## Commonly Used - -
- -
+ - - :octicons-list-unordered-24: - Instances + -
+ -
-### Instances - - :octicons-code-24: + -
+
### Animation - - :octicons-package-24: + + :octicons-note-24: + Animatable + :octicons-chevron-right-24: + + + :octicons-workflow-24: Spring :octicons-chevron-right-24: - - - :octicons-package-24: + + :octicons-workflow-24: Tween :octicons-chevron-right-24: diff --git a/docs/api-reference/instances/attribute.md b/docs/api-reference/instances/attribute.md deleted file mode 100644 index cc0dea384..000000000 --- a/docs/api-reference/instances/attribute.md +++ /dev/null @@ -1,44 +0,0 @@ - - -

- :octicons-key-24: - Attribute - - special key - since v0.3 - -

- -A [special key](./specialkey.md) for adding attributes to instances. - ------ - -## Parameters - -```lua -(attributeName: string) -> SpecialKey -``` - ------ - -## Example Usage - -```lua -local ammoValue = Value(10) -local label = New "TextLabel" { - [Attribute "Ammo"] = ammoValue -} - -print(label:GetAttribute("Ammo")) -- 10 -``` - ------ - -## Technical Details - -This special key runs at the `self` stage. - ------ diff --git a/docs/api-reference/instances/attributechange.md b/docs/api-reference/instances/attributechange.md deleted file mode 100644 index 7d4ba3e3c..000000000 --- a/docs/api-reference/instances/attributechange.md +++ /dev/null @@ -1,46 +0,0 @@ - - -

- :octicons-code-24: - AttributeChange - - function - since v0.3 - -

- -Given an attribute name, returns a [special key](./specialkey.md) which connects -to that attribute's change events. - -```Lua -(attributeName: string) -> SpecialKey -``` - ------ - -## Parameters - -- `attributeName` - The name of the attribute to listen for changes to. - ------ - -## Returns - -A special key which runs at the `observer` stage. When applied to an instance, -it connects to the attribute change signal on the instance for the given property. -The handler is run with the attribute's value after every change. - ------ - -## Example Usage - -```Lua -New "TextBox" { - [AttributeChange "State"] = function(newValue) - print("The state attribute changed to:", newValue) - end -} -``` diff --git a/docs/api-reference/instances/attributeout.md b/docs/api-reference/instances/attributeout.md deleted file mode 100644 index 5532c3012..000000000 --- a/docs/api-reference/instances/attributeout.md +++ /dev/null @@ -1,51 +0,0 @@ - - -

- :octicons-code-24: - AttributeOut - - function - since v0.3 - -

- -Given an attribute name, returns a [special key](./specialkey.md) which outputs the value of -attribute's with that name. It should be used with a [value](../state/value.md) object. - -```Lua -(attributeName: string) -> SpecialKey -``` - ------ - -## Parameters - -- `attributeName` - The name of the attribute to output the value of. - ------ - -## Returns - -A special key which runs at the `observer` stage. When applied to an instance, -it sets the value object equal to the attribute with the given name. It then -listens for further changes and updates the value object accordingly. - ------ - -## Example Usage - -```Lua -local ammo = Value() - -New "Configuration" { - [Attribute "Ammo"] = ammo, - [AttributeOut "Ammo"] = ammo -} - -Observer(ammo):onChange(function() - print("Current ammo:", peek(ammo)) -end) -``` diff --git a/docs/api-reference/instances/child.md b/docs/api-reference/instances/child.md deleted file mode 100644 index db1a358f7..000000000 --- a/docs/api-reference/instances/child.md +++ /dev/null @@ -1,46 +0,0 @@ - - -

- :octicons-checklist-24: - Child - - type - since v0.2 - -

- -Represents some UI which can be parented to an ancestor, usually via [Children](./children.md). -The most simple kind of child is a single instance, though arrays can be used -to parent multiple instances at once and state objects can be used to make the -children dynamic. - -```Lua -Instance | {[any]: Child} | StateObject -``` - ------ - -## Example Usage - -```Lua --- all of the following fit the definition of Child - -local child1: Child = New "Folder" {} -local child2: Child = { - New "Folder" {}, - New "Folder" {}, - New "Folder" {} -} -local child3: Child = Computed(function() - return New "Folder" {} -end) -local child4: Child = { - Computed(function() - return New "Folder" {} - end), - {New "Folder" {}, New "Folder" {}} -} -``` \ No newline at end of file diff --git a/docs/api-reference/instances/children.md b/docs/api-reference/instances/children.md deleted file mode 100644 index 6c64fe7d8..000000000 --- a/docs/api-reference/instances/children.md +++ /dev/null @@ -1,127 +0,0 @@ - - -

- :octicons-key-24: - Children - - special key - since v0.1 - -

- -Allows parenting children to an instance, both statically and dynamically. - ------ - -## Example Usage - -```Lua -local example = New "Folder" { - [Children] = New "StringValue" { - Value = "I'm parented to the Folder!" - } -} -``` - ------ - -## Processing Children - -A 'child' is defined (recursively) as: - -- an instance -- a [state object](../../state/stateobject) containing children -- an array of children - -Since this definition is recursive, arrays and state objects can be nested; that -is, the following code is valid: - -```Lua -local example = New "Folder" { - [Children] = { - { - { - New "StringValue" { - Value = "I'm parented to the Folder!" - } - } - } - } -} -``` - -This behaviour is especially useful when working with components - the following -component can return multiple instances to be parented without disrupting the -code next to it: - -```Lua -local function Component(props) - return { - New "TextLabel" { - LayoutOrder = 1, - Text = "Instance one" - }, - - New "TextLabel" { - LayoutOrder = 2, - Text = "Instance two" - } - } -end - -local parent = New "Frame" { - Children = { - New "UIListLayout" { - SortOrder = "LayoutOrder" - }, - - Component {} - } -} -``` - -When using a state object as a child, `Children` will interpret the stored value -as children and watch for changes. When the value of the state object changes, -it'll unparent the old children and parent the new children. - -!!! note - As with bound properties, updates are deferred to the next render step, and - so parenting won't occur right away. - -```Lua -local child1 = New "Folder" { - Name = "Child one" -} -local child2 = New "Folder" { - Name = "Child two" -} - -local childState = Value(child1) - -local parent = New "Folder" { - [Children] = childState -} - -print(parent:GetChildren()) -- { Child one } - -childState:set(child2) -wait(1) -- wait for deferred updates to run - -print(parent:GetChildren()) -- { Child two } -``` - -!!! warning - When using state objects, note that old children *won't* be destroyed, only - unparented - it's up to you to decide if/when children need to be destroyed. - ------ - -## Technical Details - -This special key runs at the `descendants` stage. - -On cleanup, all children are unparented, as if wrapped in a state object that -has changed to nil. \ No newline at end of file diff --git a/docs/api-reference/instances/cleanup.md b/docs/api-reference/instances/cleanup.md deleted file mode 100644 index bb9182d96..000000000 --- a/docs/api-reference/instances/cleanup.md +++ /dev/null @@ -1,50 +0,0 @@ - - -

- :octicons-key-24: - Cleanup - - special key - since v0.2 - -

- -Cleans up all items given to it when the instance is destroyed, equivalent to -passing the items to `Fusion.cleanup`. - ------ - -## Example Usage - -```Lua -local example1 = New "Folder" { - [Cleanup] = function() - print("I'm in danger!") - end -} - -local example2 = New "Folder" { - [Cleanup] = example1 -} - -local example3 = New "Folder" { - [Cleanup] = { - RunService.RenderStepped:Connect(print), - function() - print("I'm in danger also!") - end, - example2 - } -} - -example3:Destroy() -``` - ------ - -## Technical Details - -This special key runs at the `observer` stage. \ No newline at end of file diff --git a/docs/api-reference/instances/component.md b/docs/api-reference/instances/component.md deleted file mode 100644 index d657c640d..000000000 --- a/docs/api-reference/instances/component.md +++ /dev/null @@ -1,36 +0,0 @@ - - -

- :octicons-checklist-24: - Component - - type - since v0.2 - -

- -The standard type signature for UI components. They accept a property table and -return a [child type](./child.md). - -```Lua -(props: {[any]: any}) -> Child -``` - ------ - -## Example Usage - -```Lua --- create a Button component -local function Button(props) - return New "TextButton" { - Text = props.Text - } -end - --- the Button component is compatible with the Component type -local myComponent: Component = Button -``` \ No newline at end of file diff --git a/docs/api-reference/instances/hydrate.md b/docs/api-reference/instances/hydrate.md deleted file mode 100644 index 21847db32..000000000 --- a/docs/api-reference/instances/hydrate.md +++ /dev/null @@ -1,98 +0,0 @@ - - -

- :octicons-code-24: - Hydrate - - function - since v0.2 - -

- -Given an instance, returns a [component](./component.md) which modifies that -instance. The property table may specify properties to set on the instance, or -include [special keys](./specialkey.md) for more advanced operations. - -```Lua -(target: Instance) -> Component -``` - ------ - -## Parameters - -- `target` - the instance which the component should modify - ------ - -## Returns - -A component function. When called, it populates the target instance using the -property table, then returns the target instance. - ------ - -## Example Usage - -```Lua -local myButton: TextButton = Hydrate(PlayerGui.ScreenGui.TextButton) { - Position = UDim2.fromScale(.5, .5), - AnchorPoint = Vector2.new(.5, .5), - Size = UDim2.fromOffset(200, 50), - - Text = "Hello, world!", - - [OnEvent "Activated"] = function() - print("The button was clicked!") - end, - - [OnChange "Name"] = function(newName) - print("The button was renamed to:", newName) - end, - - [Children] = New "UICorner" { - CornerRadius = UDim.new(0, 8) - } -} -``` - ------ - -## Property Table Processing - -The `props` table uses a mix of string and special keys to specify attributes of -the instance which should be set. - -String keys are treated as property declarations - values passed in will be set -upon the instance: - -```Lua -local example = Hydrate(workspace.Part) { - -- sets the Position property - Position = Vector3.new(1, 2, 3) -} -``` - -Passing a state object to a string key will bind the property value; when the -value of the object changes, the property will update to match on the next -resumption step: - -```Lua -local myName = Value("Bob") - -local example = Hydrate(workspace.Part) { - -- initially, the Name will be set to Bob - Name = myName -} - --- change the state object to store "John" --- on the next resumption step, the part's Name will change to John -myName:set("John") -``` - -Special keys, such as [Children](./children.md) or [OnEvent](./onevent.md), may -also be used as keys in the property table. For more information about how -special keys work, [see the SpecialKey page.](./specialkey.md) \ No newline at end of file diff --git a/docs/api-reference/instances/index.md b/docs/api-reference/instances/index.md deleted file mode 100644 index 1546e67eb..000000000 --- a/docs/api-reference/instances/index.md +++ /dev/null @@ -1,110 +0,0 @@ - - -

- :octicons-list-unordered-24: - Instances -

- -Utilities for connecting state objects to instances via code. - ------ - - \ No newline at end of file diff --git a/docs/api-reference/instances/new.md b/docs/api-reference/instances/new.md deleted file mode 100644 index d4ae58aed..000000000 --- a/docs/api-reference/instances/new.md +++ /dev/null @@ -1,111 +0,0 @@ - - -

- :octicons-code-24: - New - - function - since v0.1 - -

- -Given a class name, returns a [component](./component.md) which creates -instances of that class. The property table may specify properties to set on the -instance, or include [special keys](./specialkey.md) for more advanced -operations. - -```Lua -(className: string) -> Component -``` - ------ - -## Parameters - -- `className` - the instance class that should be created - ------ - -## Returns - -A component function. When called, it creates a new instance of the given class, -populates it using the property table, and returns it. - ------ - -## Example Usage - -```Lua -local myButton: TextButton = New "TextButton" { - Parent = Players.LocalPlayer.PlayerGui, - - Position = UDim2.fromScale(.5, .5), - AnchorPoint = Vector2.new(.5, .5), - Size = UDim2.fromOffset(200, 50), - - Text = "Hello, world!", - - [OnEvent "Activated"] = function() - print("The button was clicked!") - end, - - [OnChange "Name"] = function(newName) - print("The button was renamed to:", newName) - end, - - [Children] = New "UICorner" { - CornerRadius = UDim.new(0, 8) - } -} -``` - ------ - -## Property Table Processing - -The `props` table uses a mix of string and special keys to specify attributes of -the instance which should be set. - -String keys are treated as property declarations - values passed in will be set -upon the instance: - -```Lua -local example = New "Part" { - -- sets the Position property - Position = Vector3.new(1, 2, 3) -} -``` - -Passing a state object to a string key will bind the property value; when the -value of the object changes, the property will update to match on the next -resumption step: - -```Lua -local myName = State("Bob") - -local example = New "Part" { - -- initially, the Name will be set to Bob - Name = myName -} - --- change the state object to store "John" --- on the next resumption step, the part's Name will change to John -myName:set("John") -``` - -Special keys, such as [Children](./children.md) or [OnEvent](./onevent.md), may -also be used as keys in the property table. For more information about how -special keys work, [see the SpecialKey page.](./specialkey.md) - ------ - -## Default Properties - -The `New` function provides its own set of 'sensible default' property values -for some class types, which will be used in place of Roblox defaults. This is -done to opt out of some legacy features and unhelpful defaults. - -[You can see the default properties Fusion uses here.](https://github.com/Elttob/Fusion/blob/main/src/Instances/defaultProps.lua) \ No newline at end of file diff --git a/docs/api-reference/instances/onchange.md b/docs/api-reference/instances/onchange.md deleted file mode 100644 index 6abc86a92..000000000 --- a/docs/api-reference/instances/onchange.md +++ /dev/null @@ -1,47 +0,0 @@ - - -

- :octicons-code-24: - OnChange - - function - since v0.1 - -

- -Given a property name, returns a [special key](./specialkey.md) which connects -to that property's change events. It should be used with a handler callback, -which may accept the new value of the property. - -```Lua -(propertyName: string) -> SpecialKey -``` - ------ - -## Parameters - -- `propertyName` - The name of the property to listen for changes to. - ------ - -## Returns - -A special key which runs at the `observer` stage. When applied to an instance, -it connects to the property change signal on the instance for the given property. -The handler is run with the property's value after every change. - ------ - -## Example Usage - -```Lua -New "TextBox" { - [OnChange "Text"] = function(newText) - print("You typed:", newText) - end -} -``` \ No newline at end of file diff --git a/docs/api-reference/instances/onevent.md b/docs/api-reference/instances/onevent.md deleted file mode 100644 index d7fb98d7c..000000000 --- a/docs/api-reference/instances/onevent.md +++ /dev/null @@ -1,47 +0,0 @@ - - -

- :octicons-code-24: - OnEvent - - function - since v0.1 - -

- -Given an event name, returns a [special key](./specialkey.md) which connects to -events of that name. It should be used with a handler callback, which may accept -arguments from the event. - -```Lua -(eventName: string) -> SpecialKey -``` - ------ - -## Parameters - -- `eventName` - the name of the event to connect to - ------ - -## Returns - -A special key which runs at the `observer` stage. When applied to an instance, -it connects to the event on the instance of the given name. The handler is run -with the event's arguments after every firing. - ------ - -## Example Usage - -```Lua -New "TextButton" { - [OnEvent "Activated"] = function(...) - print("The button was clicked! Arguments:", ...) - end -} -``` \ No newline at end of file diff --git a/docs/api-reference/instances/out.md b/docs/api-reference/instances/out.md deleted file mode 100644 index ae96c955c..000000000 --- a/docs/api-reference/instances/out.md +++ /dev/null @@ -1,50 +0,0 @@ - - -

- :octicons-code-24: - Out - - function - since v0.2 - -

- -Given a property name, returns a [special key](./specialkey.md) which outputs -the value of properties with that name. It should be used with a [value](../state/value.md). - -```Lua -(propertyName: string) -> SpecialKey -``` - ------ - -## Parameters - -- `propertyName` - The name of the property to output the value of. - ------ - -## Returns - -A special key which runs at the `observer` stage. When applied to an instance, -it sets the value object equal to the property with the given name. It then -listens for further changes and updates the value object accordingly. - ------ - -## Example Usage - -```Lua -local userText = Value() - -New "TextBox" { - [Out "Text"] = userText -} - -Observer(userText):onChange(function() - print("The user typed:", peek(userText)) -end) -``` \ No newline at end of file diff --git a/docs/api-reference/instances/ref.md b/docs/api-reference/instances/ref.md deleted file mode 100644 index c5d951a79..000000000 --- a/docs/api-reference/instances/ref.md +++ /dev/null @@ -1,39 +0,0 @@ - - -

- :octicons-key-24: - Ref - - special key - since v0.2 - -

- -When applied to an instance, outputs the instance to a state object. It should -be used with a [value](../state/value.md). - ------ - -## Example Usage - -```Lua -local myRef = Value() - -New "Part" { - [Ref] = myRef -} - -print(peek(ref)) --> Part -``` - ------ - -## Technical Details - -This special key runs at the `observer` stage. - -On cleanup, the state object is reset to nil, in order to avoid potential -memory leaks. diff --git a/docs/api-reference/instances/specialkey.md b/docs/api-reference/instances/specialkey.md deleted file mode 100644 index 452d58238..000000000 --- a/docs/api-reference/instances/specialkey.md +++ /dev/null @@ -1,110 +0,0 @@ - - -

- :octicons-checklist-24: - SpecialKey - - type - since v0.2 - -

- -The standard interface for special keys that can be used in property tables for -instance processing. Compatible with the [New](./new.md) and -[Hydrate](./hydrate.md) functions. - -```Lua -{ - type: "SpecialKey", - kind: string, - stage: "self" | "descendants" | "ancestor" | "observer", - apply: ( - self: SpecialKey, - value: any, - applyTo: Instance, - cleanupTasks: {Task} - ) -> () -} -``` - ------ - -## Fields - -- `type` - identifies this table as a special key -- `kind` - gives a developer-friendly name to the object for debugging -- `stage` - determines when -the special key should apply itself during the hydration process -- `apply` - the method that will be called to apply the special key to an -instance - ------ - -## Example Usage - -```Lua -local Example = {} -Example.type = "SpecialKey" -Example.kind = "Example" -Example.stage = "observer" - -function Example:apply(value, applyTo, cleanupTasks) - local conn = applyTo:GetAttributeChangedSignal("Foo"):Connect(function() - print("My value is", value) - end) - table.insert(cleanupTasks, conn) -end -``` - ------ - -## Stages - -When using [New](../instances/new.md) and [Hydrate](../instances/hydrate.md), -properties are applied in the following order: - -1. String keys, except Parent -2. Special keys with `stage = "self"` -3. Special keys with `stage = "descendants"` -4. Parent, if provided -5. Special keys with `stage = "ancestor"` -6. Special keys with `stage = "observer"` - -There are multiple motivations for splitting special keys into stages like these: - -- Before we parent descendants to the instance, we want to initialise all of -the instance's properties that don't depend on anything else -- Before we parent the instance to an ancestor, we want to parent and initialise -all of the instance's descendants as fully as possible -- Before we attach handlers to anything, we want to parent to and initialise -the instance's ancestor as fully as possible - -For these reasons, the roles of each stage are as follows: - -### self - -The `self` stage is used for special keys that run before descendants are -parented. This is typically used for special keys that operate on the instance -itself in a vacuum. - -### descendants - -The `descendants` stage is used for special keys that need to deal with -descendants, but which don't need to know about the ancestry. This is important -because parenting descendants after the instance is parented to an ancestor can -be more expensive in terms of performance. - -### ancestor - -The `ancestor` stage is used for special keys that deal with the ancestor of -the instance. This is the last stage that should be used for initialising the -instance, and occurs after the Parent has been set. - -### observer - -The `observer` stage is used for special keys that watch the instance for -changes or export references to the instance. This stage is where any event -handlers should be connected, as initialisation should be done by this point. \ No newline at end of file diff --git a/docs/api-reference/memory/members/derivescope.md b/docs/api-reference/memory/members/derivescope.md new file mode 100644 index 000000000..3fd25b52b --- /dev/null +++ b/docs/api-reference/memory/members/derivescope.md @@ -0,0 +1,60 @@ + + +

+ :octicons-workflow-24: + deriveScope + + -> Scope<T> + +

+ +```Lua +function Fusion.deriveScope( + existing: Scope +): Scope +``` + +Returns a blank [scope](../../types/scope) with the same methods as an existing +scope. + +!!! warning "Scopes are not unique" + Fusion can recycle old unused scopes. This helps make scopes more + lightweight, but it also means they don't uniquely belong to any part of + your program. + + As a result, you shouldn't hold on to scopes after they've been cleaned up, + and you shouldn't use them as unique identifiers anywhere. + +----- + +## Parameters + +

+ existing + + : Scope<T> + +

+ +An existing scope, whose methods should be re-used for the new scope. + +----- + +

+ Returns + + -> Scope<T> + +

+ +A blank scope with the same methods as the existing scope. + +----- + +## Learn More + +- [Scopes tutorial](../../../../tutorials/fundamentals/scopes) \ No newline at end of file diff --git a/docs/api-reference/memory/members/docleanup.md b/docs/api-reference/memory/members/docleanup.md new file mode 100644 index 000000000..25eb41c40 --- /dev/null +++ b/docs/api-reference/memory/members/docleanup.md @@ -0,0 +1,58 @@ + + +

+ :octicons-workflow-24: + doCleanup + + -> () + +

+ +```Lua +function Fusion.doCleanup( + ...: unknown +): () +``` + +Attempts to destroy all arguments based on their runtime type. + +!!! warning "This is a black hole!" + Any values you pass into `doCleanup` should be treated as completely gone. + Make sure you remove all references to those values, and ensure your code + never uses them again. + +----- + +## Parameters + +

+ ... + + : unknown + +

+ +A value which should be disposed of; the value's runtime type will be inspected +to determine what should happen. + +- if `function`, it is called +- ...else if `{destroy: (self) -> ()}`, `:destroy()` is called +- ...else if `{Destroy: (self) -> ()}`, `:Destroy()` is called +- ...else if `{any}`, `doCleanup` is called on all members + +When Fusion is running inside of Roblox: + +- if `Instance`, `:Destroy()` is called +- ...else if `RBXScriptConnection`, `:Disconnect()` is called + +If none of these conditions match, the value is ignored. + +----- + +## Learn More + +- [Scopes tutorial](../../../../tutorials/fundamentals/scopes) \ No newline at end of file diff --git a/docs/api-reference/memory/members/scoped.md b/docs/api-reference/memory/members/scoped.md new file mode 100644 index 000000000..9d8aaf987 --- /dev/null +++ b/docs/api-reference/memory/members/scoped.md @@ -0,0 +1,61 @@ + + +

+ :octicons-workflow-24: + scoped + + -> Scope<T> + +

+ +```Lua +function Fusion.scoped( + constructors: T +): Scope +``` + +Returns a blank [scope](../../types/scope), with the `__index` metatable +pointing at the given list of constructors for syntax convenience. + +!!! warning "Scopes are not unique" + Fusion can recycle old unused scopes. This helps make scopes more + lightweight, but it also means they don't uniquely belong to any part of + your program. + + As a result, you shouldn't hold on to scopes after they've been cleaned up, + and you shouldn't use them as unique identifiers anywhere. + +----- + +## Parameters + +

+ constructors + + : T + +

+ +A table, ideally including functions which take a scope as their first +parameter. Those functions will turn into methods. + +----- + +

+ Returns + + -> Scope<T> + +

+ +A blank scope with the specified methods. + +----- + +## Learn More + +- [Scopes tutorial](../../../../tutorials/fundamentals/scopes) \ No newline at end of file diff --git a/docs/api-reference/memory/types/scope.md b/docs/api-reference/memory/types/scope.md new file mode 100644 index 000000000..3cf13a4ae --- /dev/null +++ b/docs/api-reference/memory/types/scope.md @@ -0,0 +1,31 @@ + + +

+ :octicons-note-24: + Scope +

+ +```Lua +export type Scope = {unknown} & Constructors +``` + +A table collecting all objects created as part of an independent unit of code, +with optional `Constructors` as methods which can be called. + +!!! warning "Scopes are not unique" + Fusion can recycle old unused scopes. This helps make scopes more + lightweight, but it also means they don't uniquely belong to any part of + your program. + + As a result, you shouldn't hold on to scopes after they've been cleaned up, + and you shouldn't use them as unique identifiers anywhere. + +----- + +## Learn More + +- [Scopes tutorial](../../../../tutorials/fundamentals/scopes) \ No newline at end of file diff --git a/docs/api-reference/memory/types/scopedobject.md b/docs/api-reference/memory/types/scopedobject.md new file mode 100644 index 000000000..d2f995ebf --- /dev/null +++ b/docs/api-reference/memory/types/scopedobject.md @@ -0,0 +1,78 @@ + + +

+ :octicons-note-24: + ScopedObject +

+ +```Lua +export type ScopedObject = { + scope: Scope?, + destroy: () -> () +} +``` + +An object designed for use with [scopes](../../types/scope). + +Objects satisfying this interface can be probed for information about their +lifetime and how long they live relative to other objects satisfying this +interface. + +These objects are also recognised by [`doCleanup`](../../members/docleanup). + +----- + +## Members + +

+ scope + + : Scope<unknown>? + +

+ +The scope which this object was constructed with, or `nil` if the object has +been destroyed. + +!!! note "Unchanged until destruction" + The `scope` is expected to be set once upon construction. It should not be + assigned to again, except when the scope is destroyed - at which point it + should be set to `nil` to indicate that it no longer exists inside of a + scope. This is typically done inside the `:destroy()` method, if it exists. + +----- + +## Methods + +

+ destroy + + -> () + +

+ +```Lua +function ScopedObject:destroy(): () +``` + +Called by `doCleanup` to destroy this object. User code should generally not +call this; instead, destroy the scope as a whole. + +!!! tip "Double-destruction prevention" + Fusion's objects throw + [`destroyedTwice`](../../../general/errors/#destroyedtwice) if they detect + a `nil` scope during`:destroy()`. + + It's strongly recommended that you emulate this behaviour if you're + implementing your own objects, as this protects against double-destruction + and exposes potential scoping issues further ahead of time. + +----- + +## Learn More + +- [Scopes tutorial](../../../../tutorials/fundamentals/scopes) \ No newline at end of file diff --git a/docs/api-reference/memory/types/task.md b/docs/api-reference/memory/types/task.md new file mode 100644 index 000000000..3e34c1785 --- /dev/null +++ b/docs/api-reference/memory/types/task.md @@ -0,0 +1,33 @@ + + +

+ :octicons-note-24: + Task +

+ +```Lua +export type Task = + Instance + | RBXScriptConnection + | () -> () + | {destroy: (self) -> ()} + | {Destroy: (self) -> ()} + | {Task} +``` +Types which [`doCleanup`](../../members/docleanup) has defined behaviour for. + +!!! warning "Not enforced" + Fusion does not use static types to enforce that `doCleanup` is given a type + which it can process. + + This type is only exposed for your own use. + +----- + +## Learn More + +- [Scopes tutorial](../../../../tutorials/fundamentals/scopes) \ No newline at end of file diff --git a/docs/api-reference/roblox/members/attribute.md b/docs/api-reference/roblox/members/attribute.md new file mode 100644 index 000000000..f05d5eee7 --- /dev/null +++ b/docs/api-reference/roblox/members/attribute.md @@ -0,0 +1,49 @@ + + +

+ :octicons-workflow-24: + Attribute + + -> SpecialKey + +

+ +```Lua +function Fusion.Attribute( + attributeName: string +): SpecialKey +``` + +Given an attribute name, returns a [special key](../../types/specialkey) which +can modify attributes of that name. + +When paired with a value in a [property table](../../types/propertytable), the +special key sets the attribute to that value. + +----- + +## Parameters + +

+ attributeName + + : string + +

+ +The name of the attribute that the special key should target. + +----- + +

+ Returns + + -> SpecialKey + +

+ +A special key for modifying attributes of that name. \ No newline at end of file diff --git a/docs/api-reference/roblox/members/attributechange.md b/docs/api-reference/roblox/members/attributechange.md new file mode 100644 index 000000000..7b0994833 --- /dev/null +++ b/docs/api-reference/roblox/members/attributechange.md @@ -0,0 +1,49 @@ + + +

+ :octicons-workflow-24: + AttributeChange + + -> SpecialKey + +

+ +```Lua +function Fusion.AttributeChange( + attributeName: string +): SpecialKey +``` + +Given an attribute name, returns a [special key](../../types/specialkey) which +can listen to changes for attributes of that name. + +When paired with a callback in a [property table](../../types/propertytable), +the special key connects the callback to the attribute's change event. + +----- + +## Parameters + +

+ attributeName + + : string + +

+ +The name of the attribute that the special key should target. + +----- + +

+ Returns + + -> SpecialKey + +

+ +A special key for listening to changes for attributes of that name. \ No newline at end of file diff --git a/docs/api-reference/roblox/members/attributeout.md b/docs/api-reference/roblox/members/attributeout.md new file mode 100644 index 000000000..8ec480811 --- /dev/null +++ b/docs/api-reference/roblox/members/attributeout.md @@ -0,0 +1,50 @@ + + +

+ :octicons-workflow-24: + AttributeOut + + -> SpecialKey + +

+ +```Lua +function Fusion.AttributeOut( + attributeName: string +): SpecialKey +``` + +Given an attribute name, returns a [special key](../../types/specialkey) which +can output values from attributes of that name. + +When paired with a [value object](../../../state/types/value) in a +[property table](../../types/propertytable), the special key sets the value when +the attribute changes. + +----- + +## Parameters + +

+ attributeName + + : string + +

+ +The name of the attribute that the special key should target. + +----- + +

+ Returns + + -> SpecialKey + +

+ +A special key for outputting values from attributes of that name. \ No newline at end of file diff --git a/docs/api-reference/roblox/members/children.md b/docs/api-reference/roblox/members/children.md new file mode 100644 index 000000000..859e77286 --- /dev/null +++ b/docs/api-reference/roblox/members/children.md @@ -0,0 +1,34 @@ + + +

+ :octicons-workflow-24: + Children + + : SpecialKey + +

+ +```Lua +Fusion.Children: SpecialKey +``` + +A [special key](../../types/specialkey) which parents other instances into this +instance. + +When paired with a [`Child`](../../types/child) in a +[property table](../../types/propertytable), the special key explores the +`Child` to find every `Instance` nested inside. It then parents those instances +under the instance which the special key was applied to. + +In particular, this special key will recursively explore arrays and bind to any +[state objects](../../../state/types/stateobject). + +----- + +## Learn More + +- [Parenting tutorial](../../../../tutorials/roblox/parenting) \ No newline at end of file diff --git a/docs/api-reference/roblox/members/hydrate.md b/docs/api-reference/roblox/members/hydrate.md new file mode 100644 index 000000000..6a1ee75de --- /dev/null +++ b/docs/api-reference/roblox/members/hydrate.md @@ -0,0 +1,72 @@ + + +

+ :octicons-workflow-24: + Hydrate + + -> (PropertyTable) -> Instance + +

+ +```Lua +function Fusion.Hydrate( + target: Instance +): ( + props: PropertyTable +) -> Instance +``` + +Given an instance, returns a component for binding extra functionality to that +instance. + +In the property table, string keys are assigned as properties on the instance. +If the value is a [state object](../../../state/types/stateobject), it is +re-assigned every time the value of the state object changes. + +Any [special keys](../../types/specialkey) present in the property table are +applied to the instance after string keys are processed, in the order specified +by their `stage`. + +A special exception is made for assigning `Parent`, which is only assigned after +the `descendants` stage. + +!!! warning "Do not overwrite properties" + If the instance was previously created with [`New`](../new) or previously + hydrated, do not assign to any properties that were previously specified in + those prior calls. Duplicated assignments can interfere with each other in + unpredictable ways. + +----- + +## Parameters + +

+ target + + : Instance + +

+ +The instance which should be modified. + +----- + +

+ Returns + + -> (PropertyTable) -> Instance + +

+ +A component that hydrates that instance, accepting various properties to build +up bindings and operations applied to the instance. + +----- + +## Learn More + +- [Hydration tutorial](../../../../tutorials/roblox/hydration) \ No newline at end of file diff --git a/docs/api-reference/roblox/members/new.md b/docs/api-reference/roblox/members/new.md new file mode 100644 index 000000000..5b9bf85c8 --- /dev/null +++ b/docs/api-reference/roblox/members/new.md @@ -0,0 +1,66 @@ + + +

+ :octicons-workflow-24: + New + + -> (PropertyTable) -> Instance + +

+ +```Lua +function Fusion.New( + className: string +): ( + props: PropertyTable +) -> Instance +``` + +Given a class name, returns a component for constructing instances of that +class. + +In the property table, string keys are assigned as properties on the instance. +If the value is a [state object](../../../state/types/stateobject), it is +re-assigned every time the value of the state object changes. + +Any [special keys](../../types/specialkey) present in the property table are +applied to the instance after string keys are processed, in the order specified +by their `stage`. + +A special exception is made for assigning `Parent`, which is only assigned after +the `descendants` stage. + +----- + +## Parameters + +

+ className + + : string + +

+ +The kind of instance that should be constructed. + +----- + +

+ Returns + + -> (PropertyTable) -> Instance + +

+ +A component that constructs instances of that type, accepting various properties +to customise each instance uniquely. + +----- + +## Learn More + +- [New Instances tutorial](../../../../tutorials/roblox/new-instances) \ No newline at end of file diff --git a/docs/api-reference/roblox/members/onchange.md b/docs/api-reference/roblox/members/onchange.md new file mode 100644 index 000000000..341d4a854 --- /dev/null +++ b/docs/api-reference/roblox/members/onchange.md @@ -0,0 +1,55 @@ + + +

+ :octicons-workflow-24: + OnChange + + -> SpecialKey + +

+ +```Lua +function Fusion.OnChange( + propertyName: string +): SpecialKey +``` + +Given an property name, returns a [special key](../../types/specialkey) which +can listen to changes for properties of that name. + +When paired with a callback in a [property table](../../types/propertytable), +the special key connects the callback to the property's change event. + +----- + +## Parameters + +

+ propertyName + + : string + +

+ +The name of the property that the special key should target. + +----- + +

+ Returns + + -> SpecialKey + +

+ +A special key for listening to changes for properties of that name. + +----- + +## Learn More + +- [Change Events tutorial](../../../../tutorials/roblox/change-events) \ No newline at end of file diff --git a/docs/api-reference/roblox/members/onevent.md b/docs/api-reference/roblox/members/onevent.md new file mode 100644 index 000000000..1ed718768 --- /dev/null +++ b/docs/api-reference/roblox/members/onevent.md @@ -0,0 +1,55 @@ + + +

+ :octicons-workflow-24: + OnEvent + + -> SpecialKey + +

+ +```Lua +function Fusion.OnEvent( + eventName: string +): SpecialKey +``` + +Given an event name, returns a [special key](../../types/specialkey) which +can listen for events of that name. + +When paired with a callback in a [property table](../../types/propertytable), +the special key connects the callback to the event. + +----- + +## Parameters + +

+ eventName + + : string + +

+ +The name of the event that the special key should target. + +----- + +

+ Returns + + -> SpecialKey + +

+ +A special key for listening to events of that name. + +----- + +## Learn More + +- [Events tutorial](../../../../tutorials/roblox/events) \ No newline at end of file diff --git a/docs/api-reference/roblox/members/out.md b/docs/api-reference/roblox/members/out.md new file mode 100644 index 000000000..9dfa5c9e9 --- /dev/null +++ b/docs/api-reference/roblox/members/out.md @@ -0,0 +1,56 @@ + + +

+ :octicons-workflow-24: + Out + + -> SpecialKey + +

+ +```Lua +function Fusion.Out( + propertyName: string +): SpecialKey +``` + +Given an property name, returns a [special key](../../types/specialkey) which +can output values from properties of that name. + +When paired with a [value object](../../../state/types/value) in a +[property table](../../types/propertytable), the special key sets the value when +the property changes. + +----- + +## Parameters + +

+ propertyName + + : string + +

+ +The name of the property that the special key should target. + +----- + +

+ Returns + + -> SpecialKey + +

+ +A special key for outputting values from properties of that name. + +----- + +## Learn More + +- [Outputs tutorial](../../../../tutorials/roblox/outputs) \ No newline at end of file diff --git a/docs/api-reference/roblox/members/ref.md b/docs/api-reference/roblox/members/ref.md new file mode 100644 index 000000000..83f9e449e --- /dev/null +++ b/docs/api-reference/roblox/members/ref.md @@ -0,0 +1,30 @@ + + +

+ :octicons-workflow-24: + Ref + + : SpecialKey + +

+ +```Lua +Fusion.Ref: SpecialKey +``` + +A [special key](../../types/specialkey) which outputs the instance it's being +applied to. + +When paired with a [value object](../../../state/types/value) in a +[property table](../../types/propertytable), the special key sets the value to +the instance. + +----- + +## Learn More + +- [References tutorial](../../../../tutorials/roblox/references) \ No newline at end of file diff --git a/docs/api-reference/roblox/types/child.md b/docs/api-reference/roblox/types/child.md new file mode 100644 index 000000000..67d14d21b --- /dev/null +++ b/docs/api-reference/roblox/types/child.md @@ -0,0 +1,24 @@ + + +

+ :octicons-note-24: + Child +

+ +```Lua +export type Child = Instance | StateObject | {[unknown]: Child} +``` + +All of the types understood by the [`[Children]`](../../members/children) +special key. + +----- + +## Learn More + +- [Parenting tutorial](../../../../tutorials/roblox/parenting/) +- [Instance Handling tutorial](../../../../tutorials/best-practices/instance-handling/) \ No newline at end of file diff --git a/docs/api-reference/roblox/types/propertytable.md b/docs/api-reference/roblox/types/propertytable.md new file mode 100644 index 000000000..d3fc62bcc --- /dev/null +++ b/docs/api-reference/roblox/types/propertytable.md @@ -0,0 +1,25 @@ + + +

+ :octicons-note-24: + PropertyTable +

+ +```Lua +export type PropertyTable = {[string | SpecialKey]: unknown} +``` + +A table of named instance properties and [special keys](../specialkey), which +can be passed to [`New`](../../members/new) to create an instance. + +!!! warning "This type can be overly generic" + In most cases, you should know what properties your code is looking for. In + those cases, you should prefer to list out the properties explicitly, to + document what your code needs. + + You should only use this type if you don't know what properties your code + will accept. diff --git a/docs/api-reference/roblox/types/specialkey.md b/docs/api-reference/roblox/types/specialkey.md new file mode 100644 index 000000000..f6dd3cbe6 --- /dev/null +++ b/docs/api-reference/roblox/types/specialkey.md @@ -0,0 +1,99 @@ + + +

+ :octicons-note-24: + SpecialKey +

+ +```Lua +export type SpecialKey = { + type: "SpecialKey", + kind: string, + stage: "self" | "descendants" | "ancestor" | "observer", + apply: ( + self, + scope: Scope, + value: unknown, + applyTo: Instance + ) -> () +} +``` + +When used as the key in a [property table](../propertytable), defines a custom +operation to apply to the created Roblox instance. + +!!! 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 + +

+ type + + : "SpecialKey" + +

+ +A type string which can be used for runtime type checking. + + +

+ kind + + : string + +

+ +A more specific type string which can be used for runtime type checking. This +can be used to tell types of special key apart. + +

+ stage + + : "self" | "descendants" | "ancestor" | "observer" + +

+ +Describes the type of operation, which subsequently determines when it's applied +relative to other operations. + +- `self` runs before parenting any instances +- `descendants` runs once descendants are parented, but before this instance is +parented to its ancestor +- `ancestor` runs after all parenting operations are complete +- `observer` runs after all other operations, so the final state of the instance +can be observed + +----- + +## Methods + +

+ apply + + -> () + +

+ +```Lua +function SpecialKey:apply( + self, + scope: Scope, + value: unknown, + applyTo: Instance +): () +``` + +Called to apply this operation to an instance. `value` is the value from the +property table, and `applyTo` is the instance to apply the operation to. + +The given `scope` is cleaned up when the operation is being unapplied, including +when the instance is destroyed. Operations should use the scope to clean up any +connections or undo any changes they cause. \ No newline at end of file diff --git a/docs/api-reference/state/canbestate.md b/docs/api-reference/state/canbestate.md deleted file mode 100644 index 9b2b36372..000000000 --- a/docs/api-reference/state/canbestate.md +++ /dev/null @@ -1,44 +0,0 @@ - - -

- :octicons-checklist-24: - CanBeState - - type - since v0.2 - -

- -A value which may either be a [state object](../stateobject) or a constant. - -Provided as a convenient shorthand for indicating that constant-ness is not -important. - -```Lua -StateObject | T -``` - ------ - -## Example Usage - -```Lua -local function printItem(item: CanBeState) - if typeof(item) == "string" then - -- constant - print("Got constant: ", item) - else - -- state object - print("Got state object: ", peek(item)) - end -end - -local constant = "Hello" -local state = Value("World") - -printItem(constant) --> Got constant: Hello -printItem(state) --> Got state object: World -``` \ No newline at end of file diff --git a/docs/api-reference/state/cleanup.md b/docs/api-reference/state/cleanup.md deleted file mode 100644 index ccde7d676..000000000 --- a/docs/api-reference/state/cleanup.md +++ /dev/null @@ -1,53 +0,0 @@ - - -

- :octicons-code-24: - cleanup - - function - since v0.2 - -

- -Attempts to destroy all destructible objects passed to it. - -```Lua -(...any) -> () -``` - ------ - -## Parameters - -- `...` - Any objects that need to be destroyed. - ------ - -## Example Usage - -```Lua -Fusion.cleanup( - workspace.Part1, - RunService.RenderStepped:Connect(print), - function() - print("I will be run!") - end -) -``` - ------ - -## Destruction Behaviour - -Destruction behaviour varies by type: - -- if `Instance`, `:Destroy()` is called -- ...else if `RBXScriptConnection`, `:Disconnect()` is called -- ...else if `function`, it is called -- ...else if `{destroy: (self) -> ()}`, `:destroy()` is called -- ...else if `{Destroy: (self) -> ()}`, `:Destroy()` is called -- ...else if `{any}`, `Fusion.cleanup` is called on all members -- ...else nothing occurs. \ No newline at end of file diff --git a/docs/api-reference/state/computed.md b/docs/api-reference/state/computed.md deleted file mode 100644 index 57bda66dc..000000000 --- a/docs/api-reference/state/computed.md +++ /dev/null @@ -1,78 +0,0 @@ - - -

- :octicons-package-24: - Computed - - state object - since v0.1 - -

- -Calculates a single value based on the returned values from other state objects. - -```Lua -( - processor: (Use) -> (T, M), - destructor: ((T, M) -> ())? -) -> Computed -``` - ------ - -## Parameters - -- `processor: (Use) -> (T, M)` - computes and returns values to be returned from -the computed object, optionally returning extra values for the destructor alone - -- `destructor: ((T, M) -> ())?` - disposes of values generated by `processor` -when they are no longer in use - ------ - -## Example Usage - -```Lua -local numCoins = Value(50) - -local doubleCoins = Computed(function(use) - return use(numCoins) * 2 -end) - -print(peek(doubleCoins)) --> 100 - -numCoins:set(2) -print(peek(doubleCoins)) --> 4 -``` - ------ - -## Dependency Management - -By default, computed objects run their processor function once during -construction, then cache the result indefinitely. To specify the calculation -should re-run when a state object changes value, the objects can be passed -to the [use callback](./use.md) passed to the processor function. The use -callback will unwrap the value as normal, but any state objects will become -dependencies of the computed object. - ------ - -## Destructors - -The `destructor` callback, if provided, is called when the computed object swaps -out an old value for a newly-generated one. It is called with the old value as -the first parameter, and - if provided - an extra value returned from `processor` -as a customisable second parameter. - -Destructors are required when working with data types that require destruction, -such as instances. Otherwise, they are optional, so not all calculations have to -specify destruction behaviour. - -Fusion guarantees that values passed to destructors by default will never be -used again by the library, so it is safe to finalise them. This does not apply -to the customisable second parameter, which the user is responsible for handling -properly. \ No newline at end of file diff --git a/docs/api-reference/state/dependency.md b/docs/api-reference/state/dependency.md deleted file mode 100644 index f500c05b2..000000000 --- a/docs/api-reference/state/dependency.md +++ /dev/null @@ -1,40 +0,0 @@ - - -

- :octicons-checklist-24: - Dependency - - type - since v0.2 - -

- -A graph object which can send updates to [dependents](../dependent) on the -reactive graph. - -Most often used with [state objects](../stateobject), though the reactive graph -does not require objects to store state. - -```Lua -{ - dependentSet: Set -} -``` - ------ - -## Example Usage - -```Lua --- these are examples of objects which are dependencies -local value: Dependency = Value(2) -local computed: Dependency = Computed(function(use) - return use(value) * 2 -end) - --- dependencies can be used with some internal functions such as updateAll() -updateAll(value) -``` \ No newline at end of file diff --git a/docs/api-reference/state/dependent.md b/docs/api-reference/state/dependent.md deleted file mode 100644 index 507c8ecf3..000000000 --- a/docs/api-reference/state/dependent.md +++ /dev/null @@ -1,66 +0,0 @@ - - -

- :octicons-checklist-24: - Dependent - - type - since v0.2 - -

- -A graph object which can receive updates from [dependecies](../dependency) on -the reactive graph. - -Most often used with [state objects](../stateobject), though the reactive graph -does not require objects to store state. - -```Lua -{ - dependencySet: Set, - update: (self) -> boolean -} -``` - ------ - -## Fields - -- `dependencySet` - stores the graph objects which this object can receive -updates from - ------ - -## Methods - -

- since v0.2 -

- -### :octicons-code-24: Dependent:update() - -Called when this object receives an update from one or more dependencies. - -If this object is a dependency, and updates should be propagated to further -dependencies, this method should return true. Otherwise, to block further -updates from occuring (for example, because this object did not change value), -this method should return false. - -```Lua -() -> boolean -``` - ------ - -## Example Usage - -```Lua --- these are examples of objects which are dependents -local computed: Dependent = Computed(function(use) - return "foo" -end) -local observer: Dependent = Observer(computed) -``` \ No newline at end of file diff --git a/docs/api-reference/state/donothing.md b/docs/api-reference/state/donothing.md deleted file mode 100644 index 0c11a522f..000000000 --- a/docs/api-reference/state/donothing.md +++ /dev/null @@ -1,36 +0,0 @@ - - -

- :octicons-code-24: - doNothing - - function - since v0.2 - -

- -No-op function - does nothing at all, and returns nothing at all. Intended for -use as a destructor when no destruction is needed. - -```Lua -(...any) -> () -``` - ------ - -## Parameters - -- `...` - Any objects. - ------ - -## Example Usage - -```Lua -local foo = Computed(function(use) - return workspace.Part -end, Fusion.doNothing) -``` \ No newline at end of file diff --git a/docs/api-reference/state/forkeys.md b/docs/api-reference/state/forkeys.md deleted file mode 100644 index 69992c7ed..000000000 --- a/docs/api-reference/state/forkeys.md +++ /dev/null @@ -1,93 +0,0 @@ - - -

- :octicons-package-24: - ForKeys - - state object - since v0.2 - -

- -Processes a table from another state object by transforming its keys only. - -```Lua -( - input: CanBeState<{[KI]: V}>, - keyProcessor: (Use, KI) -> (KO, M), - keyDestructor: ((KO, M) -> ())? -) -> ForKeys -``` - ------ - -## Parameters - -- `input: CanBeState<{[KI]: V}>` - the table to be processed, either as a state -object or a constant value -- `keyProcessor: (Use, KI) -> (KO, M)` - transforms input keys into new -keys, optionally providing metadata for the destructor alone -- `keyDestructor: ((KO, M) -> ())?` - disposes of values generated by -`keyProcessor` when they are no longer in use - ------ - -## Example Usage - -```Lua -local data = Value({ - one = 1, - two = 2, - three = 3, - four = 4 -}) - -local transformed = ForKeys(data, function(use, key) - local newKey = string.upper(key) - return newKey -end) - -print(peek(transformed)) --> {ONE = 1, TWO = 2 ... } -``` - ------ - -## Dependency Management - -By default, ForKeys runs the processor function once per key in the input, then -caches the result indefinitely. To specify the calculation should re-run for the -key when a state object changes value, the objects can be passed to the -[use callback](./use.md) passed to the processor function for that key. The use -callback will unwrap the value as normal, but any state objects will become -dependencies of that key. - ------ - -## Destructors - -The `keyDestructor` callback, if provided, is called when this object swaps out -an old key for a newly-generated one. It is called with the old key as the first -parameter, and - if provided - an extra value returned from `keyProcessor` as a -customisable second parameter. - -Destructors are required when working with data types that require destruction, -such as instances. Otherwise, they are optional, so not all calculations have to -specify destruction behaviour. - -Fusion guarantees that values passed to destructors by default will never be -used again by the library, so it is safe to finalise them. This does not apply -to the customisable second parameter, which the user is responsible for handling -properly. - ------ - -## Optimisations - -ForKeys does not allow access to the values of the input table. This guarantees -that all generated keys are completely independent of any values. This means -keys only need to be calculated when they're added to the input table - all -other changes are simply forwarded to the output table. Since keys are also -unique, all calculations are unique, so caching and reuse are not required. \ No newline at end of file diff --git a/docs/api-reference/state/forpairs.md b/docs/api-reference/state/forpairs.md deleted file mode 100644 index 704ba3a9d..000000000 --- a/docs/api-reference/state/forpairs.md +++ /dev/null @@ -1,98 +0,0 @@ - - -

- :octicons-package-24: - ForPairs - - state object - since v0.2 - -

- -Processes a table from another state object by transforming its keys and values. - -```Lua -( - input: CanBeState<{[KI]: VI}>, - pairProcessor: (Use, KI, VI) -> (KO, VO, M), - pairDestructor: ((KO, VO, M) -> ())? -) -> ForPairs -``` - ------ - -## Parameters - -- `input: CanBeState<{[KI]: VI}>` - the table to be processed, either as a state -object or a constant value -- `pairProcessor: (Use, KI, VI) -> (KO, VO, M)` - transforms input key-value -pairs into new key-value pairs, optionally providing metadata for the destructor -alone -- `pairDestructor: ((KO, VO, M) -> ())?` - disposes of values generated by -`pairProcessor` when they are no longer in use - ------ - -## Example Usage - -```Lua -local data = Value({ - one = 1, - two = 2, - three = 3, - four = 4 -}) - -local transformed = ForPairs(data, function(use, key, value) - local newKey = value - local newValue = string.upper(key) - return newKey, newValue -end) - -print(peek(transformed)) --> {[1] = "ONE", [2] = "TWO" ... } -``` - ------ - -## Dependency Management - -By default, ForPairs runs the processor function once per key/value pair in the -input, then caches the result indefinitely. To specify the calculation should -re-run for the key/value pair when a state object changes value, the objects can -be passed to the [use callback](./use.md) passed to the processor function for -that key/value pair. The use callback will unwrap the value as normal, but any -state objects will become dependencies of that key/value pair. - ------ - -## Destructors - -The `pairDestructor` callback, if provided, is called when this object swaps -out an old key-value pair for a newly-generated one. It is called with the old -pair as the first and second parameters, and - if provided - an extra value -returned from `pairProcessor` as a customisable third parameter. - -Destructors are required when working with data types that require destruction, -such as instances. Otherwise, they are optional, so not all calculations have to -specify destruction behaviour. - -Fusion guarantees that values passed to destructors by default will never be -used again by the library, so it is safe to finalise them. This does not apply -to the customisable third parameter, which the user is responsible for handling -properly. - ------ - -## Optimisations - -ForPairs is the least restrictive of the For objects, allowing full access to -the key-value pairs being processed. This means that very little optimisation is -applied - values are always locked to the specific key they were generated for, -and any change in the input's key or value will prompt a recalculation. - -For other optimisations, consider using [ForValues](../forvalues) or -[ForKeys](../forkeys), which impose stricter restrictions to allow for less -frequent updates and greater reuse. \ No newline at end of file diff --git a/docs/api-reference/state/forvalues.md b/docs/api-reference/state/forvalues.md deleted file mode 100644 index b815a1bec..000000000 --- a/docs/api-reference/state/forvalues.md +++ /dev/null @@ -1,93 +0,0 @@ - - -

- :octicons-package-24: - ForValues - - state object - since v0.2 - -

- -Processes a table from another state object by transforming its values only. - -```Lua -( - input: CanBeState<{[K]: VI}>, - valueProcessor: (Use, VI) -> (VO, M), - valueDestructor: ((VO, M) -> ())? -) -> ForValues -``` - ------ - -## Parameters - -- `input: CanBeState<{[K]: VI}>` - the table to be processed, either as a state -object or a constant value -- `valueProcessor: (Use, VI) -> (VO, M)` - transforms input values into new values, -optionally providing metadata for the destructor alone -- `valueDestructor: ((VO, M) -> ())?` - disposes of values generated by -`valueProcessor` when they are no longer in use - ------ - -## Example Usage - -```Lua -local data = Value({ - one = 1, - two = 2, - three = 3, - four = 4 -}) - -local transformed = ForValues(data, function(use, value) - local newValue = value * 2 - return newValue -end) - -print(peek(transformed)) --> {ONE = 2, TWO = 4 ... } -``` - ------ - -## Dependency Management - -By default, ForValues runs the processor function once per value in the input, -then caches the result indefinitely. To specify the calculation should re-run -for the value when a state object changes value, the objects can be passed to -the [use callback](./use.md) passed to the processor function for that value. -The use callback will unwrap the value as normal, but any state objects will -become dependencies of that value. - ------ - -## Destructors - -The `valueDestructor` callback, if provided, is called when this object swaps -out an old value for a newly-generated one. It is called with the old value as -the first parameter, and - if provided - an extra value returned from -`valueProcessor` as a customisable second parameter. - -Destructors are required when working with data types that require destruction, -such as instances. Otherwise, they are optional, so not all calculations have to -specify destruction behaviour. - -Fusion guarantees that values passed to destructors by default will never be -used again by the library, so it is safe to finalise them. This does not apply -to the customisable second parameter, which the user is responsible for handling -properly. - ------ - -## Optimisations - -ForValues does not allow access to the keys of the input table. This guarantees -that all generated values are completely independent of the key they were -generated for. This means that values may be moved between keys instead of being -destroyed when their original key changes value. Values are only reused once - -values aren't copied when there are multiple occurences of the same input. \ No newline at end of file diff --git a/docs/api-reference/state/index.md b/docs/api-reference/state/index.md deleted file mode 100644 index 3c898f685..000000000 --- a/docs/api-reference/state/index.md +++ /dev/null @@ -1,112 +0,0 @@ - - -

- :octicons-list-unordered-24: - State -

- -Fundamental state objects and utilities for working with reactive graphs. - ------ - - \ No newline at end of file diff --git a/docs/api-reference/state/members/computed.md b/docs/api-reference/state/members/computed.md new file mode 100644 index 000000000..5b4846dd3 --- /dev/null +++ b/docs/api-reference/state/members/computed.md @@ -0,0 +1,73 @@ + + +

+ :octicons-workflow-24: + Computed + + -> Computed<T> + +

+ +```Lua +function Fusion.Computed( + scope: Scope, + processor: (Use, Scope) -> T +) -> Computed +``` + +Constructs and returns a new [computed state object](../../types/computed). + +!!! success "Use scoped() method syntax" + This function is intended to be accessed as a method on a scope: + ```Lua + local computed = scope:Computed(processor) + ``` + +----- + +## Parameters + +

+ scope + + : Scope<S> + +

+ +The [scope](../../../memory/types/scope) which should be used to store +destruction tasks for this object. + +

+ processor + + : (Use, + Scope<S>) -> T + +

+ +Computes the value that will be used by the computed. The processor is given a +[use function](../../../memory/types/use) for including other objects in the +computation, and a [scope](../../../memory/types/scope) for queueing destruction +tasks to run on re-computation. The given scope has the same methods as the +scope used to create the computed. + +----- + +

+ Returns + + -> Computed<T> + +

+ +A freshly constructed computed state object. + +----- + +## Learn More + +- [Computeds tutorial](../../../../tutorials/fundamentals/computeds) \ No newline at end of file diff --git a/docs/api-reference/state/members/forkeys.md b/docs/api-reference/state/members/forkeys.md new file mode 100644 index 000000000..f67ff5231 --- /dev/null +++ b/docs/api-reference/state/members/forkeys.md @@ -0,0 +1,89 @@ + + +

+ :octicons-workflow-24: + ForKeys + + -> For<KO, V> + +

+ +```Lua +function Fusion.ForKeys( + scope: Scope, + inputTable: CanBeState<{[KI]: V}>, + processor: (Use, Scope, key: KI) -> KO +) -> For +``` + +Constructs and returns a new [For state object](../../types/for) which processes +keys and preserves values. + +!!! success "Use scoped() method syntax" + This function is intended to be accessed as a method on a scope: + ```Lua + local forObj = scope:ForKeys(inputTable, processor) + ``` + +----- + +## Parameters + +

+ scope + + : Scope<S> + +

+ +The [scope](../../../memory/types/scope) which should be used to store +destruction tasks for this object. + +

+ inputTable + + : CanBeState<{[KI]: V}> + +

+ +The table which will provide the input keys and input values for this object. + +If it is a state object, this object will respond to changes in that state. + +

+ processor + + : (Use, + Scope<S>, + key: KI) -> KO + +

+ +Accepts a `KI` key from the input table, and returns the `KO` key that should +appear in the output table. + +The processor is given a [use function](../../../memory/types/use) for including +other objects in the computation, and a [scope](../../../memory/types/scope) for +queueing destruction tasks to run on re-computation. The given scope has the +same methods as the scope used to create the whole object. + +----- + +

+ Returns + + -> For<KO, V> + +

+ +A freshly constructed For state object. + +----- + +## Learn More + +- [ForKeys tutorial](../../../../tutorials/tables/forkeys) \ No newline at end of file diff --git a/docs/api-reference/state/members/forpairs.md b/docs/api-reference/state/members/forpairs.md new file mode 100644 index 000000000..4ea1f7034 --- /dev/null +++ b/docs/api-reference/state/members/forpairs.md @@ -0,0 +1,89 @@ + + +

+ :octicons-workflow-24: + ForPairs + + -> For<KO, VO> + +

+ +```Lua +function Fusion.ForPairs( + scope: Scope, + inputTable: CanBeState<{[KI]: VI}>, + processor: (Use, Scope, key: KI, value: VI) -> (KO, VO) +) -> For +``` + +Constructs and returns a new [For state object](../../types/for) which processes +keys and values in pairs. + +!!! success "Use scoped() method syntax" + This function is intended to be accessed as a method on a scope: + ```Lua + local forObj = scope:ForPairs(inputTable, processor) + ``` + +----- + +## Parameters + +

+ scope + + : Scope<S> + +

+ +The [scope](../../../memory/types/scope) which should be used to store +destruction tasks for this object. + +

+ inputTable + + : CanBeState<{[KI]: VI}> + +

+ +The table which will provide the input keys and input values for this object. + +If it is a state object, this object will respond to changes in that state. + +

+ processor + + : (Use, + Scope<S>, + key: KI, value: VI) -> (KO, VO) + +

+ +Accepts a `KI` key and `VI` value pair from the input table, and returns the +`KO` key and `VO` value pair that should appear in the output table. + +The processor is given a [use function](../../../memory/types/use) for including +other objects in the computation, and a [scope](../../../memory/types/scope) for +queueing destruction tasks to run on re-computation. The given scope has the +same methods as the scope used to create the whole object. + +----- + +

+ Returns + + -> For<KO, VO> + +

+ +A freshly constructed For state object. + +----- + +## Learn More + +- [ForPairs tutorial](../../../../tutorials/tables/forpairs) \ No newline at end of file diff --git a/docs/api-reference/state/members/forvalues.md b/docs/api-reference/state/members/forvalues.md new file mode 100644 index 000000000..746b405da --- /dev/null +++ b/docs/api-reference/state/members/forvalues.md @@ -0,0 +1,89 @@ + + +

+ :octicons-workflow-24: + ForValues + + -> For<K, VO> + +

+ +```Lua +function Fusion.ForValues( + scope: Scope, + inputTable: CanBeState<{[K]: VI}>, + processor: (Use, Scope, value: VI) -> VO +) -> For +``` + +Constructs and returns a new [For state object](../../types/for) which processes +values and preserves keys. + +!!! success "Use scoped() method syntax" + This function is intended to be accessed as a method on a scope: + ```Lua + local forObj = scope:ForValues(inputTable, processor) + ``` + +----- + +## Parameters + +

+ scope + + : Scope<S> + +

+ +The [scope](../../../memory/types/scope) which should be used to store +destruction tasks for this object. + +

+ inputTable + + : CanBeState<{[K]: VI}> + +

+ +The table which will provide the input keys and input values for this object. + +If it is a state object, this object will respond to changes in that state. + +

+ processor + + : (Use, + Scope<S>, + value: VI) -> VO + +

+ +Accepts a `VI` value from the input table, and returns the `VO` value that +should appear in the output table. + +The processor is given a [use function](../../../memory/types/use) for including +other objects in the computation, and a [scope](../../../memory/types/scope) for +queueing destruction tasks to run on re-computation. The given scope has the +same methods as the scope used to create the whole object. + +----- + +

+ Returns + + -> For<K, VO> + +

+ +A freshly constructed For state object. + +----- + +## Learn More + +- [ForValues tutorial](../../../../tutorials/tables/forvalues) \ No newline at end of file diff --git a/docs/api-reference/state/members/observer.md b/docs/api-reference/state/members/observer.md new file mode 100644 index 000000000..6323d1c71 --- /dev/null +++ b/docs/api-reference/state/members/observer.md @@ -0,0 +1,68 @@ + + +

+ :octicons-workflow-24: + Observer + + -> Observer + +

+ +```Lua +function Fusion.Observer( + scope: Scope, + watching: Dependency +) -> Observer +``` + +Constructs and returns a new [observer](../../types/observer). + +!!! success "Use scoped() method syntax" + This function is intended to be accessed as a method on a scope: + ```Lua + local observer = scope:Observer(watching) + ``` + +----- + +## Parameters + +

+ scope + + : Scope<unknown> + +

+ +The [scope](../../../memory/types/scope) which should be used to store +destruction tasks for this object. + +

+ watching + + : Dependency + +

+ +The object which the observer should receive updates from. + +----- + +

+ Returns + + -> Observer + +

+ +A freshly constructed observer. + +----- + +## Learn More + +- [Observers tutorial](../../../../tutorials/fundamentals/observers) \ No newline at end of file diff --git a/docs/api-reference/state/members/peek.md b/docs/api-reference/state/members/peek.md new file mode 100644 index 000000000..84a793632 --- /dev/null +++ b/docs/api-reference/state/members/peek.md @@ -0,0 +1,69 @@ + + +

+ :octicons-workflow-24: + peek + + : Use + +

+ +```Lua +function Fusion.peek( + target: CanBeState +): T +``` + +Extract 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 +the returned value. + +!!! warning "Specific implementations" + If you're given a specific implementation of `Use` by an API, it's highly + likely that you are expected to use that implementation instead of `peek()`. + + This applies to reusable code too. It's often best to ask for a `Use` + callback if your code needs to extract values, so an appropriate + implementation can be passed in. + + Alternatively for reusable code, you can avoid extracting values entirely, + and expect the user to do it prior to calling your code. This can work well + if you unconditionally use all inputs, but beware that you may end up + extracting more values than you need - this can have performance + implications. + +----- + +## Parameters + +

+ target + + : CanBeState<T> + +

+ +The abstract representation of `T` to extract a value from. + +----- + +

+ Returns + + -> T + +

+ +The current value of `T`, derived from `target`. + +----- + +## Learn More + +- [Values tutorial](../../../../tutorials/fundamentals/values/) \ No newline at end of file diff --git a/docs/api-reference/state/members/value.md b/docs/api-reference/state/members/value.md new file mode 100644 index 000000000..369b42cee --- /dev/null +++ b/docs/api-reference/state/members/value.md @@ -0,0 +1,68 @@ + + +

+ :octicons-workflow-24: + Value + + -> Value<T> + +

+ +```Lua +function Fusion.Value( + scope: Scope, + initialValue: T +) -> Value +``` + +Constructs and returns a new [value state object](../../types/value). + +!!! success "Use scoped() method syntax" + This function is intended to be accessed as a method on a scope: + ```Lua + local computed = scope:Computed(processor) + ``` + +----- + +## Parameters + +

+ scope + + : Scope<S> + +

+ +The [scope](../../../memory/types/scope) which should be used to store +destruction tasks for this object. + +

+ initialValue + + : T + +

+ +The initial value that will be stored until the next value is `:set()`. + +----- + +

+ Returns + + -> Value<T> + +

+ +A freshly constructed value state object. + +----- + +## Learn More + +- [Values tutorial](../../../../tutorials/fundamentals/values) \ No newline at end of file diff --git a/docs/api-reference/state/observer.md b/docs/api-reference/state/observer.md deleted file mode 100644 index ab53d4cfc..000000000 --- a/docs/api-reference/state/observer.md +++ /dev/null @@ -1,115 +0,0 @@ - - -

- :octicons-package-24: - Observer - - graph object - since v0.2 - -

- -Observes various updates and events on a given dependency. - -```Lua -( - observe: Dependency -) -> Observer -``` - ------ - -## Parameters - -- `observe: Dependency` - the dependency this observer should respond to - ------ - -## Object Methods - -

- since v0.2 -

- -### :octicons-code-24: Observer:onChange() - -Connects the given callback as a change handler, and returns a function which -will disconnect the callback. The callback will run whenever the observed -dependency is updated. - -```Lua -(callback: () -> ()) -> (() -> ()) -``` -#### Parameters - -- `callback` - The function to call when a change is observed - -!!! caution "Connection memory leaks" - Make sure to disconnect any change handlers made using this function once - you're done using them. - - As long as a change handler is connected, this observer and the dependency - it observes will be held in memory in case further changes occur. This means, - if you don't call the disconnect function, you may end up accidentally - holding your state objects in memory forever after you're done using them. - ------ - -

- since v0.3 -

- -### :octicons-code-24: Observer:onBind() - -Connects the given callback as a change handler, and returns a function which will disconnect the callback. The callback is run immediately, and re-run whenever the observed dependency is updated. - -```Lua -(callback: () -> ()) -> (() -> ()) -``` -#### Parameters - -- `callback` - The function to call when a change is observed **and** when the observer is created - -!!! caution "Connection memory leaks" - Make sure to disconnect any change handlers made using this function once - you're done using them. - - As long as a change handler is connected, this observer and the dependency - it observes will be held in memory in case further changes occur. This means, - if you don't call the disconnect function, you may end up accidentally - holding your state objects in memory forever after you're done using them. - ------ - -## Example Usage - -```Lua -local numCoins = Value(50) - -local coinObserver = Observer(numCoins) - -local disconnect = coinObserver:onChange(function() - print("coins is now:", peek(numCoins)) -end) - -numCoins:set(25) -- prints 'coins is now: 25' - --- always clean up your connections! -disconnect() -``` - -```Lua -local someValue = Value("") - -function update() - someObject.Text = peek(someValue) -end - -local disconnect = Observer(someValue):onBind(update) - --- always clean up your connections! -disconnect() -``` \ No newline at end of file diff --git a/docs/api-reference/state/peek.md b/docs/api-reference/state/peek.md deleted file mode 100644 index cfd14a47a..000000000 --- a/docs/api-reference/state/peek.md +++ /dev/null @@ -1,43 +0,0 @@ - - -

- :octicons-code-24: - peek - - function - since v0.3 - -

- -The most basic [use callback](./use.md), which returns the interior value of -state objects without adding any dependencies. - -```Lua -(target: CanBeState) -> T -``` - ------ - -## Parameters - -- `target: CanBeState` - The argument to attempt to unwrap. - ------ - -## Returns - -If the argument is a state object, returns the interior value of the state -object. Otherwise, returns the argument itself. - ------ - -## Example Usage - -```Lua -local thing = Value(5) - -print(peek(thing)) --> 5 -``` \ No newline at end of file diff --git a/docs/api-reference/state/stateobject.md b/docs/api-reference/state/stateobject.md deleted file mode 100644 index daa3fc1c7..000000000 --- a/docs/api-reference/state/stateobject.md +++ /dev/null @@ -1,46 +0,0 @@ - - -

- :octicons-checklist-24: - StateObject - - type - since v0.2 - -

- -A dependency that provides a single stateful value; the dependency updates when -the value changes state. - -Note that state objects do not expose a public interface for accessing their -interior value - the standard way of doing this is by using a -[use callback](./use.md) such as the [peek function](./peek.md). - -```Lua -Dependency & { - type: "State", - kind: string -} -``` - ------ - -## Fields - -- `type` - uniquely identifies state objects for runtime type checks -- `kind` - holds a more specific type name for different kinds of state object - ------ - -## Example Usage - -```Lua --- these are examples of objects which are state objects -local value: StateObject = Value(5) -local computed: StateObject = Computed(function(use) - return "foo" -end) -``` \ No newline at end of file diff --git a/docs/api-reference/state/types/canbestate.md b/docs/api-reference/state/types/canbestate.md new file mode 100644 index 000000000..826c74278 --- /dev/null +++ b/docs/api-reference/state/types/canbestate.md @@ -0,0 +1,35 @@ + + +

+ :octicons-note-24: + CanBeState +

+ +```Lua +export type CanBeState = T | StateObject +``` + +Something which describes a value of type `T`. When it is [used](../use) in a +calculation, it becomes that value. + +!!! success "Recommended" + Instead of using one of the more specific variants, your code should aim to + use this type as often as possible. It allows your logic to deal with many + representations of values at once, + +----- + +## Variants + +- `T` - represents unchanging constant values +- [`StateObject`](../stateobject) - represents dynamically updating values + +----- + +## Learn More + +- [Components tutorial](../../../../tutorials/best-practices/components/) \ No newline at end of file diff --git a/docs/api-reference/state/types/computed.md b/docs/api-reference/state/types/computed.md new file mode 100644 index 000000000..c6d81f297 --- /dev/null +++ b/docs/api-reference/state/types/computed.md @@ -0,0 +1,45 @@ + + +

+ :octicons-note-24: + Computed +

+ +```Lua +export type Computed = StateObject & Dependent & { + kind: "Computed" +} +``` + +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. + +----- + +## Members + +

+ kind + + : "Computed" + +

+ +A more specific type string which can be used for runtime type checking. This +can be used to tell types of state object apart. + +----- + +## Learn More + +- [Computeds tutorial](../../../../tutorials/fundamentals/computeds) \ No newline at end of file diff --git a/docs/api-reference/state/types/dependency.md b/docs/api-reference/state/types/dependency.md new file mode 100644 index 000000000..fd4d7969a --- /dev/null +++ b/docs/api-reference/state/types/dependency.md @@ -0,0 +1,37 @@ + + +

+ :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 new file mode 100644 index 000000000..613d1c6b7 --- /dev/null +++ b/docs/api-reference/state/types/dependent.md @@ -0,0 +1,65 @@ + + +

+ :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 new file mode 100644 index 000000000..ea85c8b44 --- /dev/null +++ b/docs/api-reference/state/types/for.md @@ -0,0 +1,47 @@ + + +

+ :octicons-note-24: + For +

+ +```Lua +export type For = StateObject<{[KO]: VO}> & Dependent & { + kind: "For" +} +``` + +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. + +----- + +## Members + +

+ kind + + : "For" + +

+ +A more specific type string which can be used for runtime type checking. This +can be used to tell types of state object apart. + +----- + +## Learn More + +- [ForValues tutorial](../../../../tutorials/tables/forvalues) +- [ForKeys tutorial](../../../../tutorials/tables/forkeys) +- [ForPairs tutorial](../../../../tutorials/tables/forpairs) \ No newline at end of file diff --git a/docs/api-reference/state/types/observer.md b/docs/api-reference/state/types/observer.md new file mode 100644 index 000000000..e133ed33d --- /dev/null +++ b/docs/api-reference/state/types/observer.md @@ -0,0 +1,83 @@ + + +

+ :octicons-note-24: + Observer +

+ +```Lua +export type Observer = Dependent & { + type: "Observer", + onChange: (self, callback: () -> ()) -> (() -> ()), + onBind: (self, callback: () -> ()) -> (() -> ()) +} +``` + +A user-constructed [dependent](../dependent) that runs user code when its +[dependency](../dependency) is updated. + +!!! 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 + +

+ type + + : "Observer" + +

+ +A type string which can be used for runtime type checking. + +----- + +## Methods + +

+ onChange + + -> (() -> ()) + +

+ +```Lua +function Observer:onChange( + callback: () -> () +): (() -> ()) +``` + +Registers the callback to run when an update is received. + +The returned function will unregister the callback. + +

+ onBind + + -> (() -> ()) + +

+ +```Lua +function Observer:onBind( + callback: () -> () +): (() -> ()) +``` + +Runs the callback immediately, and registers the callback to run when an update +is received. + +The returned function will unregister the callback. + +----- + +## Learn More + +- [Observers tutorial](../../../../tutorials/fundamentals/observers) \ No newline at end of file diff --git a/docs/api-reference/state/types/stateobject.md b/docs/api-reference/state/types/stateobject.md new file mode 100644 index 000000000..bfb90860c --- /dev/null +++ b/docs/api-reference/state/types/stateobject.md @@ -0,0 +1,46 @@ + + +

+ :octicons-note-24: + StateObject +

+ +```Lua +export type StateObject = Dependency & { + type: "State", + kind: string +} +``` + +Stores a value of `T` which can change over time. As a +[dependency](../dependency), it can broadcast updates when its value changes. + +This type isn't generally useful outside of Fusion itself; you should prefer to +work with [`CanBeState`](../canbestate) in your own code. + +----- + +## Members + +

+ type + + : "State" + +

+ +A type string which can be used for runtime type checking. + +

+ kind + + : string + +

+ +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 diff --git a/docs/api-reference/state/types/use.md b/docs/api-reference/state/types/use.md new file mode 100644 index 000000000..13885608d --- /dev/null +++ b/docs/api-reference/state/types/use.md @@ -0,0 +1,58 @@ + + +

+ :octicons-note-24: + Use +

+ +```Lua +export type Use = (target: CanBeState) -> T +``` + +A function which extracts a value of `T` from abstract representations of `T` +([`CanBeState`](../canbestate)). + +The most generic implementation of this is +[the `peek()` function](../../members/peek), which performs this extraction with +no additional steps. + +However, certain APIs may provide their own implementation, +so they can perform additional processing for certain representations. Most +notably, [computeds](../../members/computed) provide their own `use()` function +which adds inputs to a watchlist, which allows them to re-calculate as inputs +change. + +----- + +## Parameters + +

+ target + + : CanBeState<T> + +

+ +The abstract representation of `T` to extract a value from. + +----- + +

+ Returns + + -> T + +

+ +The current value of `T`, derived from `target`. + +----- + +## Learn More + +- [Values tutorial](../../../../tutorials/fundamentals/values/) +- [Computeds tutorial](../../../../tutorials/fundamentals/computeds/) \ No newline at end of file diff --git a/docs/api-reference/state/types/value.md b/docs/api-reference/state/types/value.md new file mode 100644 index 000000000..65cfa665f --- /dev/null +++ b/docs/api-reference/state/types/value.md @@ -0,0 +1,65 @@ + + +

+ :octicons-note-24: + Value +

+ +```Lua +export type Value = StateObject & { + kind: "State", + set: (self, newValue: T) -> () +} +``` + +A specialised [state object](../stateobject) which allows regular Luau code to +control its value. + +!!! 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 + +

+ kind + + : "Value" + +

+ +A more specific type string which can be used for runtime type checking. This +can be used to tell types of state object apart. + +----- + +## Methods + +

+ set + + -> () + +

+ +```Lua +function Value:set( + newValue: T +): () +``` + +Updates the value of this state object. + +Other objects using the value are notified immediately of the change. + +----- + +## Learn More + +- [Values tutorial](../../../../tutorials/fundamentals/values) \ No newline at end of file diff --git a/docs/api-reference/state/use.md b/docs/api-reference/state/use.md deleted file mode 100644 index baf155729..000000000 --- a/docs/api-reference/state/use.md +++ /dev/null @@ -1,35 +0,0 @@ - - -

- :octicons-checklist-24: - Use - - type - since v0.3 - -

- -The general function signature for unwrapping [state objects](./stateobject.md) -while transparently passing through other (constant) values. - -Functions of this shape are often referred to as 'use callbacks', and are often -provided by dependency capturers such as [computeds](./computed.md) for the -purposes of tracking used state objects in a processor function. - -```Lua -(target: CanBeState) -> T -``` - ------ - -## Example Usage - -```Lua -local foo: Value = Value(2) -local doubleFoo = Computed(function(use: Fusion.Use) - return use(foo) * 2 -end) -``` \ No newline at end of file diff --git a/docs/api-reference/state/value.md b/docs/api-reference/state/value.md deleted file mode 100644 index 1db40e04c..000000000 --- a/docs/api-reference/state/value.md +++ /dev/null @@ -1,84 +0,0 @@ - - -

- :octicons-package-24: - Value - - state object - since v0.2 - -

- -Stores a single value which can be updated at any time. - -```Lua -( - initialValue: T -) -> Value -``` - ------ - -## Parameters - -- `initialValue` - The value that should be initially stored after construction. - ------ - -## Methods - -

- since v0.2 -

- -### :octicons-code-24: Value:set() - -Replaces the currently stored value, updating any other state objects that -depend on this value object. The value is stored directly, and no cloning or -alteration is done. - -If the new value is the same as the old value, other state objects won't be -updated. - -```Lua -(newValue: T) -> () -``` - -#### Parameters - -- `newValue` - The new value to be stored. - -??? note "Table sameness" - Updates are always sent out when setting a table value, because it's much - more difficult to evaluate if two tables are the same. Therefore, this - method is conservative and labels all tables as different, even - compared to themselves. - -??? caution "Legacy parameter: force" - Originally, a second `force` parameter was available in Fusion 0.1 so that - updates could forcibly be sent out, even when the new value was the same as - the old value. This is because Fusion 0.1 used equality to evaluate sameness - for all data types, including tables. This was problematic as many users - attempted to get the table value, modify it, and `:set()` it back into the - object, which would not cause an update as the table reference did not - change. - - Fusion 0.2 uses a different sameness definition for tables to alleviate this - problem. As such, there is no longer a good reason to use this parameter, - and so it is not currently recommended for use. For backwards compatibility, - it will remain for the time being, but do not depend on it for new work. - ------ - -## Example Usage - -```Lua -local numCoins = Value(50) -- start off with 50 coins -print(peek(numCoins)) --> 50 - -numCoins:set(10) -print(peek(numCoins)) --> 10 -``` \ No newline at end of file diff --git a/docs/assets/404-dark.svg b/docs/assets/404-dark.svg new file mode 100644 index 000000000..4236207d4 --- /dev/null +++ b/docs/assets/404-dark.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/docs/assets/404-light.svg b/docs/assets/404-light.svg new file mode 100644 index 000000000..0a69ff624 --- /dev/null +++ b/docs/assets/404-light.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/docs/assets/404.svg b/docs/assets/404.svg deleted file mode 100644 index 16d14df04..000000000 --- a/docs/assets/404.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/assets/overrides/404.html b/docs/assets/overrides/404.html index 59ac717f1..390cf5bbf 100644 --- a/docs/assets/overrides/404.html +++ b/docs/assets/overrides/404.html @@ -2,31 +2,54 @@ {% block content %} -
+
+

- 404 - 404 + Oh noes!

-

+

We couldn't find that page - it might have been moved or deleted. -

-

- Here's some ideas on how to find it: -

    -
  • - Make sure there's no typos in the URL. -
  • -
  • - Use the search box above to try and find the page. -
  • -
  • - The page might not exist yet! -
  • -
- Alternatively, return to the main page instead. -

+

+
+

+ Here's some ideas on how to find it: +

    +
  • + Make sure there's no typos in the URL. +
  • +
  • + Use the search box above to try and find the page. +
  • +
  • + The page might not exist yet! +
  • +
+ Alternatively, return to the main page instead. +

+
{% endblock %} -{% block disqus %}{% endblock %} \ No newline at end of file +{% block disqus %}{% endblock %} + +{% block site_nav %} + + {% if nav %} + {% if page.meta and page.meta.hide %} + {% set hidden = "hidden" if "navigation" in page.meta.hide %} + {% endif %} + + {% endif %} +{% endblock %} \ No newline at end of file diff --git a/docs/api-reference/errors/error-paste-box.js b/docs/assets/scripts/error-paste-box.js similarity index 90% rename from docs/api-reference/errors/error-paste-box.js rename to docs/assets/scripts/error-paste-box.js index 76487a5fd..d060d48b2 100644 --- a/docs/api-reference/errors/error-paste-box.js +++ b/docs/assets/scripts/error-paste-box.js @@ -48,6 +48,4 @@ try { } catch(e) { alert("Couldn't instantiate the error paste box - " + e); -} - -const input = "[Fusion] Computed callback error: attempt to index nil with 'get' (ID: computedCallbackError) ---- Stack trace ---- Replica"; \ No newline at end of file +} \ No newline at end of file diff --git a/docs/assets/theme/404.css b/docs/assets/theme/404.css index 1e6a0d140..06b0931a4 100644 --- a/docs/assets/theme/404.css +++ b/docs/assets/theme/404.css @@ -1,13 +1,40 @@ -.fusion-404 h1 { - display: flex; - align-items: center; - justify-content: left; +#fusion-404 .graphic { + display: block; + width: 16rem; + max-width: 80vmin; + aspect-ratio: 1; + margin: 0 auto; + margin-bottom: 2rem; + background-size: contain; + background-position: center; + background-repeat: no-repeat; +} + +[data-md-color-scheme="fusiondoc-light"] #fusion-404 .graphic { + background-image: url("../404-light.svg"); +} + +[data-md-color-scheme="fusiondoc-dark"] #fusion-404 .graphic { + background-image: url("../404-dark.svg"); +} + +#fusion-404 h1 { + text-align: center; font-size: 2rem; + margin-top: 0; margin-bottom: 1rem; } -.fusion-404 h1 img { - width: 6rem; - height: 6rem; - margin-right: 2rem; +#fusion-404 h2 { + text-align: center; + font-size: 1rem; + font-weight: 400; + + margin-top: 0; + margin-bottom: 2rem; } + +#fusion-404 .advice { + max-width: max-content; + margin: 0 auto; +} \ No newline at end of file diff --git a/docs/assets/theme/api-reference.css b/docs/assets/theme/api-reference.css index a1667c190..df32bd7f2 100644 --- a/docs/assets/theme/api-reference.css +++ b/docs/assets/theme/api-reference.css @@ -1,88 +1,91 @@ .fusiondoc-api-header { display: flex; - align-items: center; - gap: 0.75rem; + align-items: baseline; margin: 0 !important; flex-wrap: wrap; } -.fusiondoc-api-header .headerlink { - display: none; +.fusiondoc-api-header .fusiondoc-api-icon { + margin-right: 0.5em; } .fusiondoc-api-header > * { flex-shrink: 0; } -.fusiondoc-api-header .fusiondoc-api-name { - margin-right: auto; +.fusiondoc-api-type { + vertical-align: middle; + font-size: calc((0.5em + 1rem) / 2); + flex-shrink: 0; + + margin-left: 0.5em; + + font-family: var(--md-code-font-family); + font-weight: 400; +} + +.fusiondoc-api-type:not(a){ + opacity: 0.8; } -.fusiondoc-api-pills { +.fusiondoc-api-breadcrumbs { display: flex; align-items: center; - justify-content: flex-end; - gap: 0.5em; - flex-wrap: wrap; - line-height: 1.5em; - - font-size: 0.75rem; - margin-left: 0.5em; - margin-bottom: -2em; + height: 2rem; +} - letter-spacing: -0.02em; - font-weight: 400; - vertical-align: middle; +.fusiondoc-api-breadcrumbs { + opacity: 0.8; } -.fusiondoc-api-pills + h1, -.fusiondoc-api-pills + h2, -.fusiondoc-api-pills + h3, -.fusiondoc-api-pills + h4 { - margin-top: 0; +.fusiondoc-api-breadcrumbs > *:not(:last-child)::after { + content: "›"; + margin: 0 0.25rem; } -.fusiondoc-api-header .fusiondoc-api-pills { - font-size: 0.5em; - margin-bottom: 0em; +.fusiondoc-error-api-section { + opacity: 1; + transition: opacity 0.4s ease, filter 0.4s ease; } -.fusiondoc-api-pills > * { - padding: 0.25em 0.75em; - border-radius: 0.25rem; - height: 2em; - flex-shrink: 0; +.fusiondoc-error-api-section.fusiondoc-error-api-section-defocus { + opacity: 0.4; + filter: blur(0.25rem); + pointer-events: none; } -.fusiondoc-api-pills .fusiondoc-api-pill-type { - background-color: var(--fusiondoc-orange-a20); - color: var(--fusiondoc-accent-text); +.fusiondoc-api-bento { + column-width: 12rem; + column-gap: 0.5rem; } -.fusiondoc-api-pills .fusiondoc-api-pill-since { - background-color: var(--fusiondoc-grey-a20); - color: var(--fusiondoc-fg-1); +.fusiondoc-api-bento > * { + break-inside: avoid; + background-color: var(--fusiondoc-bg-2); + box-shadow: var(--md-shadow-z1); + border-radius: 0.25rem; + padding: 0.75rem 1rem; + padding-bottom: 0.01rem; + margin: 0.5rem 0; + margin-top: 0; } -.fusiondoc-api-breadcrumbs { - display: flex; - align-items: center; - height: 2rem; +.fusiondoc-api-bento > * :first-child { + margin-top: 0 !important; } -.fusiondoc-api-breadcrumbs > a { +.fusiondoc-api-bento > * > h3 { + font-size: 0.7rem; color: var(--fusiondoc-fg-3); + font-weight: 700; } -.fusiondoc-api-breadcrumbs > a:hover { - color: var(--fusiondoc-accent-hover); +.fusiondoc-api-index-header { + color: var(--fusiondoc-fg-1) !important; } -.fusiondoc-api-breadcrumbs > a::after { - content: "/"; - margin: 0 0.25rem; - opacity: 0.5; - color: var(--fusiondoc-fg-3); +.fusiondoc-api-index-header:hover { + color: var(--fusiondoc-accent-hover) !important; } .fusiondoc-api-index-link { @@ -99,20 +102,11 @@ } .fusiondoc-api-index-link > .fusiondoc-api-index-arrow { + margin-bottom: -0.3em; transform: translateX(0rem); transition: transform 0.2s ease; } .fusiondoc-api-index-link:hover > .fusiondoc-api-index-arrow { transform: translateX(0.5rem); -} - -.fusiondoc-error-api-section { - opacity: 1; - transition: opacity 0.4s ease; -} - -.fusiondoc-error-api-section.fusiondoc-error-api-section-defocus { - opacity: 0.4; - transition: opacity 0.4s ease; } \ No newline at end of file diff --git a/docs/assets/theme/home.css b/docs/assets/theme/home.css index 6eb6dc615..8b38869f6 100644 --- a/docs/assets/theme/home.css +++ b/docs/assets/theme/home.css @@ -41,6 +41,17 @@ background-repeat: no-repeat; background-size: 177vh 100vh; background-position: center right; + animation: home-fade-in-bg 0.5s ease 0.5s; + animation-fill-mode: forwards; +} + +@keyframes home-fade-in-bg { + 0% { + transform: translateY(-0.8rem); + } + 100% { + transform: translateY(0rem); + } } [data-md-color-scheme="fusiondoc-dark"] #fusiondoc-home-main::before { diff --git a/docs/assets/theme/page.css b/docs/assets/theme/page.css index 0659e02f3..703bd121c 100644 --- a/docs/assets/theme/page.css +++ b/docs/assets/theme/page.css @@ -169,6 +169,10 @@ margin-right: 0rem; } +.md-header__title .md-header__topic:first-child { + height: 2.4rem; +} + .md-header__title .md-header__topic:first-child::before { content: ""; display: inline-block; @@ -238,7 +242,7 @@ } .md-copyright::after { - content: "∙ Theme by Elttob"; + content: "∙ Theme by Daniel P H Fox"; } @media screen and (max-width: 76.1875em) { diff --git a/docs/assets/theme/paragraph.css b/docs/assets/theme/paragraph.css index a853eab0e..a3937e819 100644 --- a/docs/assets/theme/paragraph.css +++ b/docs/assets/theme/paragraph.css @@ -86,13 +86,4 @@ h6 > .twemoji:first-child { font-style: normal; font-size: 0.8em; letter-spacing: 0.02em; -} - -.fusiondoc-index-multicol { - column-width: 15em; -} - -.fusiondoc-index-multicol .fusiondoc-index-multicol-section { - break-inside: avoid; - padding-top: 0.001em; /* prevent margins collapsing */ } \ No newline at end of file diff --git a/docs/examples/cookbook/animated-computed.md b/docs/examples/cookbook/animated-computed.md index a460dadf6..414a5403e 100644 --- a/docs/examples/cookbook/animated-computed.md +++ b/docs/examples/cookbook/animated-computed.md @@ -1,43 +1,114 @@ +This example shows you how to animate a single value with an animation curve of +your preference. + +For demonstration, the example uses Roblox API members. + +----- + +## Overview + ```Lua linenums="1" --- [Fusion imports omitted for clarity] +local Players = game:GetService("Players") --- Oftentimes we calculate values for a single purpose, such as the position of --- a single UI element. These values are often calculated inline, like this: +local Fusion = -- initialise Fusion here however you please! +local scoped = Fusion.scoped +local Children = Fusion.Children -local menuBar = New "Frame" { - AnchorPoint = Computed(function(use) - return if use(menuIsOpen) then Vector2.new(0.5, 0) else Vector2.new(0.5, -1) - end) -} +local TWEEN_INFO = TweenInfo.new( + 0.5, + Enum.EasingStyle.Sine, + Enum.EasingDirection.InOut +) --- If you want to animate these inline values, you can pass them through an --- object such as Spring and Tween- you don't have to do it separately. +-- Don't forget to pass this to `doCleanup` if you disable the script. +local scope = scoped(Fusion) -local menuBar = New "Frame" { - -- Use tweens for highly controllable animations: - AnchorPoint = Tween(Computed(function(use) - return if use(menuIsOpen) then Vector2.new(0.5, 0) else Vector2.new(0.5, -1) - end), TweenInfo.new(0.2, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut)), +-- You can set this at any time to indicate where The Thing should be. +local showTheThing = scope:Value(false) - -- Or use springs for more natural and responsive movement: - AnchorPoint = Spring(Computed(function(use) - return if use(menuIsOpen) then Vector2.new(0.5, 0) else Vector2.new(0.5, -1) - end), 20, 0.5) -} +local exampleUI = scope:New "ScreenGui" { + Parent = Players.LocalPlayer:FindFirstChildOfClass("PlayerGui"), + Name = "Example UI", --- The equivalent 'expanded' code looks like this: + [Children] = scope:New "Frame" { + Name = "The Thing", + Position = scope:Tween( + scope:Computed(function(use) + local CENTRE = UDim2.fromScale(0.5, 0.5) + local OFFSCREEN = UDim2.fromScale(-0.5, 0.5) + return if use(showTheThing) then CENTRE else OFFSCREEN + end), + TWEEN_INFO + ), + Size = UDim2.fromOffset(200, 200) + } +} -local anchorPoint = Computed(function(use) - return if use(menuIsOpen) then Vector2.new(0.5, 0) else Vector2.new(0.5, -1) +-- Without toggling the value, you won't see it animate. +task.defer(function() + while true do + task.wait(1) + showTheThing:set(not peek(showTheThing)) + end end) +``` -local smoothAnchorPoint = Spring(anchorPoint, 20, 0.5) -- or equivalent Tween +----- -local menuBar = New "Frame" { - AnchorPoint = smoothAnchorPoint -} +## Explanation + +There's three key components to the above code snippet. + +Firstly, there's `showTheThing`. When this is `true`, The Thing should be in +the centre of the screen. Otherwise, The Thing should be off-screen. + +```Lua linenums="13" +-- You can set this at any time to indicate where The Thing should be. +local showTheThing = scope:Value(false) +``` + +Next, there's the computed object on line 26. This takes that boolean value, and +turns it into a UDim2 position for The Thing to use. You can imagine this as the +'non-animated' version of what you want The Thing to do, if it were to instantly +teleport around. + +```Lua linenums="26" + scope:Computed(function(use) + local CENTRE = UDim2.fromScale(0.5, 0.5) + local OFFSCREEN = UDim2.fromScale(-0.5, 0.5) + return if use(showTheThing) then CENTRE else OFFSCREEN + end), +``` + +Finally, there's the tween object that the computed is being passed into. The +tween object will smoothly move towards the computed over time. If needed, you +could separate the computed into a dedicated variable to access it +independently. + +```Lua linenums="25" + Position = scope:Tween( + scope:Computed(function(use) + local CENTRE = UDim2.fromScale(0.5, 0.5) + local OFFSCREEN = UDim2.fromScale(-0.5, 0.5) + return if use(showTheThing) then CENTRE else OFFSCREEN + end), + TWEEN_INFO + ), +``` + +The 'shape' of the animation is saved in a `TWEEN_INFO` constant defined earlier +in the code. [The Tween tutorial](../../../tutorials/animation/tweens) explains +how each parameter shapes the motion. + +```Lua linenums="7" +local TWEEN_INFO = TweenInfo.new( + 0.5, + Enum.EasingStyle.Sine, + Enum.EasingDirection.InOut +) +``` --- Keep in mind that you probably shouldn't use inline animation for everything. --- Sometimes you need to use the expanded form, or the expanded form would be --- more efficient, and that's okay - choose what works best for your code :) -``` \ No newline at end of file +!!! tip "Fluid animations with springs" + For extra smooth animation shapes that preserve velocity, consider trying + [spring objects](../../../tutorials/animation/springs). They're very similar + in usage and can help improve the responsiveness of the motion. \ No newline at end of file diff --git a/docs/examples/cookbook/button-component.md b/docs/examples/cookbook/button-component.md index 848222743..942315266 100644 --- a/docs/examples/cookbook/button-component.md +++ b/docs/examples/cookbook/button-component.md @@ -1,116 +1,180 @@ +This example is a relatively complete button component implemented using +Fusion's Roblox API. It handles many common interactions such as hovering and +clicking. + +This should be a generally useful template for assembling components of your +own. For further ideas and best practices for building components, see +[the Components tutorial](../../../tutorials/components/components). + + +----- + +## Overview + ```Lua linenums="1" --- [Fusion imports omitted for clarity] - --- This is a relatively complete example of a button component. --- It handles many common interactions such as hovering and clicking. - --- This should be a generally useful template for assembling components of your --- own. Unless you're prototyping, it's probably wise to stick to some good --- guidelines; the Tutorials have some tips if you don't have any existing --- guidelines of your own. - --- Defining the names of properties the button accepts, and their types. This is --- useful for autocomplete and helps catch some typos, but is optional. -export type Props = { - -- some generic properties we'll allow other code to control directly - Name: CanBeState?, - LayoutOrder: CanBeState?, - Position: CanBeState?, - AnchorPoint: CanBeState?, - Size: CanBeState?, - AutomaticSize: CanBeState?, - ZIndex: CanBeState?, - - -- button-specific properties - Text: CanBeState?, - OnClick: (() -> ())?, - Disabled: CanBeState? -} - --- Returns `Child` to match Fusion's `Component` type. This should work for most --- use cases, and offers the greatest encapsulation as you're able to swap out --- your return type for an array or state object if you want to. -local function Button(props: Props): Child - -- We should generally be careful about storing state in widely reused - -- components, as the Tutorials explain, but for contained use cases such as - -- hover states, it should be perfectly fine. - local isHovering = Value(false) - local isHeldDown = Value(false) - - return New "TextButton" { +local Fusion = -- initialise Fusion here however you please! +local scoped = Fusion.scoped +local Children, OnEvent = Fusion.Children, Fusion.OnEvent + +local COLOUR_BLACK = Color3.new(0, 0, 0) +local COLOUR_WHITE = Color3.new(1, 1, 1) + +local COLOUR_TEXT = COLOUR_WHITE +local COLOUR_BG_REST = Color3.fromHex("0085FF") +local COLOUR_BG_HOVER = COLOUR_BG_REST:Lerp(COLOUR_WHITE, 0.25) +local COLOUR_BG_HELD = COLOUR_BG_REST:Lerp(COLOUR_BLACK, 0.25) +local COLOUR_BG_DISABLED = Color3.fromHex("CCCCCC") + +local BG_FADE_SPEED = 20 -- spring speed units + +local ROUNDED_CORNERS = UDim.new(0, 4) +local PADDING = UDim2.fromOffset(6, 4) + +local function Button( + scope: Fusion.Scope, + props: { + Name: Fusion.CanBeState?, + Layout: { + LayoutOrder: Fusion.CanBeState?, + Position: Fusion.CanBeState?, + AnchorPoint: Fusion.CanBeState?, + ZIndex: Fusion.CanBeState?, + Size: Fusion.CanBeState?, + AutomaticSize: Fusion.CanBeState? + }, + Text: Fusion.CanBeState?, + Disabled: Fusion.CanBeState?, + OnClick: (() -> ())? + } +): Fusion.Child + local isHovering = scope:Value(false) + local isHeldDown = scope:Value(false) + + return scope:New "TextButton" { Name = props.Name, - LayoutOrder = props.LayoutOrder, - Position = props.Position, - AnchorPoint = props.AnchorPoint, - Size = props.Size, - AutomaticSize = props.AutomaticSize, - ZIndex = props.ZIndex, + + LayoutOrder = props.Layout.LayoutOrder, + Position = props.Layout.Position, + AnchorPoint = props.Layout.AnchorPoint, + ZIndex = props.Layout.ZIndex, + Size = props.Layout.Size, + AutomaticSize = props.Layout.AutomaticSize, Text = props.Text, - TextColor3 = Color3.fromHex("FFFFFF"), - - BackgroundColor3 = Spring(Computed(function(use) - if use(props.Disabled) then - return Color3.fromHex("CCCCCC") - else - local baseColour = Color3.fromHex("0085FF") - -- darken/lighten when hovered or held down - if use(isHeldDown) then - baseColour = baseColour:Lerp(Color3.new(0, 0, 0), 0.25) - elseif use(isHovering) then - baseColour = baseColour:Lerp(Color3.new(1, 1, 1), 0.25) + TextColor3 = COLOUR_TEXT, + + BackgroundColor3 = scope:Spring( + scope:Computed(function(use) + -- The order of conditions matter here; it defines which states + -- visually override other states, with earlier states being + -- more important. + return + if use(props.Disabled) then COLOUR_BG_DISABLED + elseif use(isHeldDown) then COLOUR_BG_HELD + elseif use(isHovering) then COLOUR_BG_HOVER + else return COLOUR_BG_REST end - return baseColour - end - end), 20), + end), + BG_FADE_SPEED + ), [OnEvent "Activated"] = function() if props.OnClick ~= nil and not peek(props.Disabled) then - -- We're explicitly calling this function with no arguments to - -- match the types we specified above. If we just passed it - -- straight into the event, the function would receive arguments - -- from the Activated event, which might not be desirable. + -- Explicitly called with no arguments to match the typedef. + -- If passed straight to `OnEvent`, the function might receive + -- arguments from the event. If the function secretly *does* + -- take arguments (despite the type) this would cause problems. props.OnClick() end end, [OnEvent "MouseButton1Down"] = function() - isHeldDown:set(true) -- it's good UX to give immediate feedback + isHeldDown:set(true) end, - [OnEvent "MouseButton1Up"] = function() isHeldDown:set(false) end, [OnEvent "MouseEnter"] = function() -- Roblox calls this event even if the button is being covered by - -- other UI. For simplicity, we won't worry about that. + -- other UI. For simplicity, this does not account for that. isHovering:set(true) end, - [OnEvent "MouseLeave"] = function() - isHovering:set(false) -- If the button is being held down, but the cursor moves off the -- button, then we won't receive the mouse up event. To make sure -- the button doesn't get stuck held down, we'll release it if the -- cursor leaves the button. isHeldDown:set(false) + isHovering:set(false) end, [Children] = { New "UICorner" { - CornerRadius = UDim.new(0, 4) + CornerRadius = ROUNDED_CORNERS }, New "UIPadding" { - PaddingTop = UDim.new(0, 6), - PaddingBottom = UDim.new(0, 6), - PaddingLeft = UDim.new(0, 6), - PaddingRight = UDim.new(0, 6) + PaddingTop = PADDING.Y, + PaddingBottom = PADDING.Y, + PaddingLeft = PADDING.X, + PaddingRight = PADDING.X } } } end return Button -``` \ No newline at end of file +``` + +----- + +## Explanation + +The main part of note is the function signature. It's highly recommended that +you statically type the function signature for components, because it not only +improves autocomplete and error checking, but also acts as up-to-date, machine +readable documentation. + +```Lua +local function Button( + scope: Fusion.Scope, + props: { + Name: Fusion.CanBeState?, + Layout: { + LayoutOrder: Fusion.CanBeState?, + Position: Fusion.CanBeState?, + AnchorPoint: Fusion.CanBeState?, + ZIndex: Fusion.CanBeState?, + Size: Fusion.CanBeState?, + AutomaticSize: Fusion.CanBeState? + }, + Text: Fusion.CanBeState?, + Disabled: Fusion.CanBeState?, + OnClick: (() -> ())? + } +): Fusion.Child +``` + +The `scope` parameter specifies that the component depends on Fusion's methods. +If you're not sure how to write type definitions for scopes, +[the 'Scopes' section of the Components tutorial](../../../tutorials/components/components/#scopes) +goes into further detail. + +The property table is laid out with each property on a new line, so it's easy to +scan the list and see what properties are available. Most are `CanBeState`, +which allows the user to use state objects if they desire. They're also `?` +(optional), which can reduce boilerplate when using the component. Not all +properties have to be that way, but usually it's better to have the flexibility. + +!!! tip "Property grouping" + You can group properties together in nested tables, like the `Layout` table + above, to avoid long mixed lists of properties. In addition to being more + readable, this can sometimes help with passing around lots of properties at + once, because you can pass the whole nested table as one value if you'd like + to. + +The return type of the function is `Fusion.Child`, which tells the user that the +component is compatible with Fusion's `[Children]` API, without exposing what +children it's returning specifically. This helps ensure the user doesn't +accidentally depend on the internal structure of the component. \ No newline at end of file diff --git a/docs/examples/cookbook/drag-and-drop.md b/docs/examples/cookbook/drag-and-drop.md index 2f4ede297..e274566c9 100644 --- a/docs/examples/cookbook/drag-and-drop.md +++ b/docs/examples/cookbook/drag-and-drop.md @@ -1,223 +1,152 @@ +This example shows a full drag-and-drop implementation for mouse input only, +using Fusion's Roblox API. + +To ensure best accessibility, any interactions you implement shouldn't force you +to hold the mouse button down. Either allow drag-and-drop with single clicks, or +provide a non-dragging alternative. This ensures people with reduced motor +ability aren't locked out of UI functions. + ```Lua linenums="1" -local GuiService = game:GetService("GuiService") -local HttpService = game:GetService("HttpService") +local Players = game:GetService("Players") local UserInputService = game:GetService("UserInputService") --- [Fusion imports omitted for clarity] - --- This example shows a full drag-and-drop implementation for mouse input only. --- Extending this system to generically work with other input types, such as --- touch gestures or gamepads, is left as an exercise to the reader. However, it --- should robustly support dragging many types of UI around flexibly. - --- To ensure best accessibility, any interactions you implement shouldn't force --- the player to hold the mouse button down. Either allow drag-and-drop using --- single inputs, or provide a non-dragging alternative; this will ensure that --- players with reduced motor ability aren't locked out of UI functions. - --- We're going to need to account for the UI inset sometimes. We cache it here. -local TOP_LEFT_INSET = GuiService:GetGuiInset() - --- To reflect the current position of the cursor on-screen, we'll use a state --- object that's updated using UserInputService. -local mousePos = Value(UserInputService:GetMouseLocation() - TOP_LEFT_INSET) -local mousePosConn = UserInputService.InputChanged:Connect(function(inputObject) - if inputObject.UserInputType == Enum.UserInputType.MouseMovement then - mousePos:set(Vector2.new(inputObject.Position.X, inputObject.Position.Y)) - end -end) - --- We need to keep drag of which item is currently being dragged. Only one item --- can be dragged at a time. This type stores all the information needed: -export type CurrentlyDragging = { - -- Each draggable item will have a unique ID; the ID stored here represents - -- which item is being dragged right now. We'll use strings for this, but - -- you could use numbers if that's more convenient for you. - id: string, - -- When a drag is started, we store the mouse's offset relative to the item - -- being dragged. When the mouse moves, we can then apply the same offset to - -- make it look like the item is 'pinned' to the cursor. - offset: Vector2 -} --- This state object stores the above during a drag, or `nil` when not dragging. -local currentlyDragging: Value = Value(nil) - --- Now we need a component to encapsulate all of our dragging behaviour, such --- as moving our UI between different parents, placing it at the mouse cursor, --- managing sizing, and so on. - -export type DraggableProps = { - -- This should uniquely identify the draggable item apart from all other - -- draggable items. This is constant and so shouldn't be a state object. - ID: string, - -- It doesn't make sense for a draggable item to have a constant parent. You - -- wouldn't be able to drop it anywhere else, so we enforce that Parent is a - -- state object for our own convenience. - Parent: StateObject, - -- When an item is being dragged, it needs to appear above all other UI. We - -- will create an overlay frame that fills the screen to achieve this. - OverlayFrame: Instance, - -- To start a drag, we'll need to know where the top-left corner of the item - -- is, so we can calculate `currentlyDragging.offset`. We'll allow the - -- calling code to pass through a Value object to [Out "AbsolutePosition"]. - OutAbsolutePosition: Value?, - - Name: CanBeState?, - LayoutOrder: CanBeState?, - Position: CanBeState?, - AnchorPoint: CanBeState?, - Size: CanBeState?, - AutomaticSize: CanBeState?, - ZIndex: CanBeState?, - [Children]: Child -} -local function Draggable(props: DraggableProps): Child - -- If we need something to be cleaned up when our item is destroyed, we can - -- add it to this array. It'll be passed to `[Cleanup]` later. - local cleanupTasks = {} +local Fusion = -- initialise Fusion here however you please! +local scoped = Fusion.scoped +local Children, OnEvent = Fusion.Children, Fusion.OnEvent - -- This acts like `currentlyDragging`, but filters out anything without a - -- matching ID, so it'll only exist when this specific item is dragged. - local thisDragging = Computed(function(use) - local dragInfo = use(currentlyDragging) - return if dragInfo ~= nil and dragInfo.id == props.ID then dragInfo else nil - end) +type DragInfo = { + id: string, + mouseOffset: Vector2 -- relative to the dragged item +} - -- Our item controls its own parent - one of the few times you'll see this - -- done in Fusion. This means we don't have to destroy and re-build the item - -- when it moves to a new location. - local itemParent = Computed(function(use) - return if use(thisDragging) ~= nil then props.OverlayFrame else use(props.Parent) - end, Fusion.doNothing) - - -- If we move a scaled UI into the `overlayBox`, by default it will stretch - -- to the screen size. Ideally we want it to preserve its current size while - -- it's being dragged, so we need to track the parent's size and calculate - -- the item size ourselves. - - -- To start with, we'll store the parent's absolute size. This takes a bit - -- of legwork to get right, and we need to remember the UI might not have a - -- parent which we can measure the size of - we'll represent that as `nil`. - -- Feel free to extract this into a separate function if you want to. - local parentSize = Value(nil) +local function Draggable( + scope: Fusion.Scope, + props: { + ID: string, + Name: Fusion.CanBeState?, + Parent: Fusion.StateObject, -- StateObject so it's observable + Layout: { + LayoutOrder: Fusion.CanBeState?, + Position: Fusion.CanBeState?, + AnchorPoint: Fusion.CanBeState?, + ZIndex: Fusion.CanBeState?, + Size: Fusion.CanBeState?, + OutAbsolutePosition: Fusion.Value?, + }, + Dragging: { + MousePosition: Fusion.CanBeState, + SelfDragInfo: Fusion.CanBeState, + OverlayFrame: Fusion.CanBeState + } + [typeof(Children)]: Fusion.Child + } +): Fusion.Child + -- When `nil`, the parent can't be measured for some reason. + local parentSize = scope:Value(nil) do - -- We'll call this whenever the parent's AbsoluteSize changes, or when - -- the parent changes (because different parents might have different - -- absolute sizes, if any) - local function recalculateParentSize() + local function measureParentNow() local parent = peek(props.Parent) - local parentHasSize = parent ~= nil and parent:IsA("GuiObject") - parentSize:set(if parentHasSize then parent.AbsoluteSize else nil) + parentSize:set( + if parent ~= nil and parent:IsA("GuiObject") + then parent.AbsoluteSize + else nil + ) end - - -- We don't just need to connect to the AbsoluteSize changed event of - -- the parent we have *right now*! If the parent changes, we need to - -- disconnect the old event and re-connect on the new parent, which we - -- do here. - local parentSizeConn = nil - local function rebindToParentSize() - if parentSizeConn ~= nil then - parentSizeConn:Disconnect() - parentSizeConn = nil + local resizeConn = nil + local function stopMeasuring() + if resizeConn ~= nil then + resizeConn:Disconnect() + resizeConn = nil end - local parent = peek(props.Parent) - local parentHasSize = parent ~= nil and parent:IsA("GuiObject") - if parentHasSize then - parentSizeConn = parent:GetPropertyChangedSignal("AbsoluteSize"):Connect(recalculateParentSize) - end - recalculateParentSize() end - local disconnect = Observer(props.Parent):onBind(rebindToParentSize) - - -- When the item gets destroyed, we need to disconnect that observer and - -- our AbsoluteSize change event (if any is active right now) - table.insert(cleanupTasks, function() - disconnect() - if parentSizeConn ~= nil then - parentSizeConn:Disconnect() - parentSizeConn = nil + scope:Observer(props.Parent):onBind(function() + stopMeasuring() + measureParentNow() + if peek(parentSize) ~= nil then + resizeConn = parent:GetPropertyChangedSignal("AbsoluteSize") + :Connect(measureParentNow) end end) + table.insert(scope, stopMeasuring) end - -- Now that we have a reliable parent size, we can calculate the item's size - -- without worrying about all of those event connections. - local itemSize = Computed(function(use) - local udim2 = use(props.Size) or UDim2.fromOffset(0, 0) - local scaleSize = use(parentSize) or Vector2.zero -- might be nil! - return UDim2.fromOffset( - udim2.X.Scale * scaleSize.X + udim2.X.Offset, - udim2.Y.Scale * scaleSize.Y + udim2.Y.Offset - ) - end) - - -- Similarly, we'll need to override the item's position while it's being - -- dragged. Happily, this is simpler to do :) - local itemPosition = Computed(function(use) - local dragInfo = use(thisDragging) - if dragInfo == nil then - return use(props.Position) or UDim2.fromOffset(0, 0) - else - -- `dragInfo.offset` stores the distance from the top-left corner - -- of the item to the mouse position. Subtracting the offset from - -- the mouse position therefore gives us the item's position. - local position = use(mousePos) - dragInfo.offset - return UDim2.fromOffset(position.X, position.Y) - end - end) - return New "Frame" { Name = props.Name or "Draggable", - LayoutOrder = props.LayoutOrder, - AnchorPoint = props.AnchorPoint, - AutomaticSize = props.AutomaticSize, - ZIndex = props.ZIndex, - - Parent = itemParent, - Position = itemPosition, - Size = itemSize, + Parent = scope:Computed(function(use) + return + if use(props.Dragging.SelfDragInfo) ~= nil + then use(props.Dragging.OverlayFrame) + else use(props.Parent) + end), + + LayoutOrder = props.Layout.LayoutOrder, + AnchorPoint = props.Layout.AnchorPoint, + ZIndex = props.Layout.ZIndex, + AutomaticSize = props.Layout.AutomaticSize, BackgroundTransparency = 1, - [Out "AbsolutePosition"] = props.OutAbsolutePosition, + Position = scope:Computed(function(use) + local dragInfo = use(props.Dragging.SelfDragInfo) + if dragInfo == nil then + return use(props.Layout.Position) or UDim2.fromOffset(0, 0) + else + local mousePos = use(props.Dragging.MousePosition) + local topLeftCorner = mousePos - dragInfo.mouseOffset + return UDim2.fromOffset(topLeftCorner.X, topLeftCorner.Y) + end + end), + -- Calculated manually so the Scale can be set relative to + -- `props.Parent` at all times, rather than the `Parent` of this Frame. + Size = scope:Computed(function(use) + local udim2 = use(props.Layout.Size) or UDim2.fromOffset(0, 0) + local parentSize = use(parentSize) or Vector2.zero + return UDim2.fromOffset( + udim2.X.Scale * parentSize.X + udim2.X.Offset, + udim2.Y.Scale * parentSize.Y + udim2.Y.Offset + ) + end), + [Out "AbsolutePosition"] = props.OutAbsolutePosition, [Children] = props[Children] } end --- The hard part is over! Now we just need to create some draggable items and --- start/stop drags in response to mouse events. We'll use a very basic example. +local COLOUR_COMPLETED = Color3.new(0, 1, 0) +local COLOUR_NOT_COMPLETED = Color3.new(1, 1, 1) + +local TODO_ITEM_SIZE = UDim2.new(1, 0, 0, 50) --- Let's make some to-do items. They'll show up in two lists - one for --- incomplete tasks, and another for complete tasks. You'll be able to drag --- items between the lists to mark them as complete. The lists will be sorted --- alphabetically so we don't have to deal with calculating where the items --- should be placed when they're dropped. +local function newUniqueID() + -- You can replace this with a better method for generating unique IDs. + return game:GetService("HttpService"):GenerateGUID() +end -export type TodoItem = { +type TodoItem = { id: string, text: string, - completed: Value + completed: Fusion.Value } -local todoItems: Value = { +local todoItems: Fusion.Value = { { - -- You can use HttpService to easily generate unique IDs statelessly. - id = HttpService:GenerateGUID(), + id = newUniqueID(), text = "Wake up today", completed = Value(true) }, { - id = HttpService:GenerateGUID(), + id = newUniqueID(), text = "Read the Fusion docs", completed = Value(true) }, { - id = HttpService:GenerateGUID(), + id = newUniqueID(), text = "Take over the universe", completed = Value(false) } } -local function getTodoItemForID(id: string): TodoItem? +local function getTodoItemForID( + id: string +): TodoItem? for _, item in todoItems do if item.id == id then return item @@ -226,192 +155,550 @@ local function getTodoItemForID(id: string): TodoItem? return nil end --- These represent the individual draggable to-do item entries in the lists. --- This is where we'll use our `Draggable` component! -export type TodoEntryProps = { - Item: TodoItem, - Parent: StateObject, - OverlayFrame: Instance, -} -local function TodoEntry(props: TodoEntryProps): Child - local absolutePosition = Value(nil) - - -- Using our item's ID, we can figure out if we're being dragged to apply - -- some styling for dragged items only! - local isDragging = Computed(function(use) - local dragInfo = use(currentlyDragging) +local function TodoEntry( + outerScope: Fusion.Scope<{}>, + props: { + Item: TodoItem, + Parent: Fusion.StateObject, + Layout: { + LayoutOrder: Fusion.CanBeState?, + Position: Fusion.CanBeState?, + AnchorPoint: Fusion.CanBeState?, + ZIndex: Fusion.CanBeState?, + Size: Fusion.CanBeState?, + OutAbsolutePosition: Fusion.Value?, + }, + Dragging: { + MousePosition: Fusion.CanBeState, + SelfDragInfo: Fusion.CanBeState, + OverlayFrame: Fusion.CanBeState? + }, + OnMouseDown: () -> ()? + } +): Fusion.Child + local scope = scoped(Fusion, { + Draggable = Draggable + }) + table.insert(outerScope, scope) + + local itemPosition = scope:Value(nil) + local itemIsDragging = scope:Computed(function(use) + local dragInfo = use(props.CurrentlyDragging) return dragInfo ~= nil and dragInfo.id == props.Item.id end) - return Draggable { + return scope:Draggable { ID = props.Item.id, - Parent = props.Parent, - OverlayFrame = props.OverlayFrame, - OutAbsolutePosition = absolutePosition, - Name = props.Item.text, - Size = UDim2.new(1, 0, 0, 50), + Parent = props.Parent, + Layout = props.Layout, + Dragging = props.Dragging, - [Children] = New "TextButton" { + [Children] = scope:New "TextButton" { Name = "TodoEntry", Size = UDim2.fromScale(1, 1), - BackgroundColor3 = Computed(function(use) - if use(isDragging) then - return Color3.new(1, 1, 1) - elseif use(props.Item.completed) then - return Color3.new(0, 1, 0) - else - return Color3.new(1, 0, 0) + BackgroundColor3 = scope:Computed(function(use) + return + if use(props.Item.completed) + then COLOUR_COMPLETED + else COLOUR_NOT_COMPLETED end end), Text = props.Item.text, TextSize = 28, + + [OnEvent "MouseButton1Down"] = props.OnMouseDown - -- This is where we'll detect mouse down. When the mouse is pressed - -- over this item, we should pick it up. - [OnEvent "MouseButton1Down"] = function() - -- only start a drag if we're not already dragging - if peek(currentlyDragging) == nil then - local itemPos = peek(absolutePosition) or Vector2.zero - local offset = peek(mousePos) - itemPos - currentlyDragging:set({ - id = props.Item.id, - offset = offset - }) - end - end - - -- We're not going to detect mouse up here, because in some rare - -- cases the event could be dropped due to lag between the item's - -- position and the cursor position. We'll deal with this at a - -- global level instead. + -- Don't detect mouse up here, because in some rare cases, the event + -- could be missed due to lag between the item's position and the + -- cursor position. } } end --- Now we should construct our two task lists for housing our to-do entries. --- Notice that they don't manage the entries themselves! The entries don't --- belong to these lists after all, so that'd be nonsense :) +-- Don't forget to pass this to `doCleanup` if you disable the script. +local scope = scoped(Fusion) --- When we release our mouse, we need to know where to drop any dragged item we --- have. This will tell us if we're hovering over either list. -local dropAction = Value(nil) +local mousePos = scope:Value(UserInputService:GetMouseLocation()) +table.insert(scope, + UserInputService.InputChanged:Connect(function(inputObject) + if inputObject.UserInputType == Enum.UserInputType.MouseMovement then + -- If this code did not read coordinates from the same method, it + -- might inconsistently handle UI insets. So, keep it simple! + mousePos:set(UserInputService:GetMouseLocation()) + end + end) +) -local incompleteList = New "ScrollingFrame" { - Name = "IncompleteTasks", - Position = UDim2.fromScale(0.1, 0.1), - Size = UDim2.fromScale(0.35, 0.9), +local dropAction = scope:Value(nil) - BackgroundTransparency = 0.75, - BackgroundColor3 = Color3.new(1, 0, 0), +local taskLists = scope:ForPairs( + { + incomplete = "mark-as-incomplete", + completed = "mark-as-completed" + }, + function(use, scope, listName, listDropAction) + return + listName, + scope:New "ScrollingFrame" { + Name = `TaskList ({listName})`, + Position = UDim2.fromScale(0.1, 0.1), + Size = UDim2.fromScale(0.35, 0.9), + + BackgroundTransparency = 0.75, + BackgroundColor3 = Color3.new(1, 0, 0), + + [OnEvent "MouseEnter"] = function() + dropAction:set(listDropAction) + end, + + [OnEvent "MouseLeave"] = function() + -- A different item might have overwritten this already. + if peek(dropAction) == listDropAction then + dropAction:set(nil) + end + end, + + [Children] = { + New "UIListLayout" { + SortOrder = "Name", + Padding = UDim.new(0, 5) + } + } + } + end +) + +local overlayFrame = scope:New "Frame" { + Size = UDim2.fromScale(1, 1), + ZIndex = 10, + BackgroundTransparency = 1 +} - [OnEvent "MouseEnter"] = function() - dropAction:set("incomplete") - end, +local currentlyDragging: Fusion.Value = scope:Value(nil) + +local allEntries = scope:ForValues( + todoItems, + function(use, scope, item) + local itemPosition = scope:Value(nil) + return scope:TodoEntry { + Item = item, + Parent = scope:Computed(function(use) + return + if use(item.completed) + then use(taskLists).completed + else use(taskLists).incomplete + end), + Layout = { + Size = TODO_ITEM_SIZE, + OutAbsolutePosition = itemPosition + }, + Dragging = { + MousePosition = mousePos, + SelfDragInfo = scope:Computed(function(use) + local dragInfo = use(currentlyDragging) + return + if dragInfo == nil or dragInfo.id ~= item.id + then nil + else dragInfo + end) + OverlayFrame = overlayFrame + }, + OnMouseDown = function() + if peek(currentlyDragging) == nil then + local itemPos = peek(itemPosition) or Vector2.zero + local mouseOffset = peek(mousePos) - itemPos + currentlyDragging:set({ + id = item.id, + mouseOffset = mouseOffset + }) + end + end + } + end +) - [OnEvent "MouseLeave"] = function() - if peek(dropAction) == "incomplete" then - dropAction:set(nil) -- only clear this if it's not overwritten yet +table.insert(scope, + UserInputService.InputEnded:Connect(function(inputObject) + if inputObject.UserInputType ~= Enum.UserInputType.MouseButton1 then + return + end + local dragInfo = peek(currentlyDragging) + if dragInfo == nil then + return + end + local item = getTodoItemForID(dragInfo.id) + local action = peek(dropAction) + if item ~= nil then + if action == "mark-as-incomplete" then + item.completed:set(false) + elseif action == "mark-as-completed" then + item.completed:set(true) + end end - end, + currentlyDragging:set(nil) + end) +) + +local ui = scope:New "ScreenGui" { + Parent = Players.LocalPlayer:FindFirstChildOfClass("PlayerGui") [Children] = { - New "UIListLayout" { - SortOrder = "Name", - Padding = UDim.new(0, 5) - } + overlayFrame, + taskLists, + -- Don't pass `allEntries` in here - they manage their own parent! } } +``` -local completedList = New "ScrollingFrame" { - Name = "CompletedTasks", - Position = UDim2.fromScale(0.55, 0.1), - Size = UDim2.fromScale(0.35, 0.9), +------ - BackgroundTransparency = 0.75, - BackgroundColor3 = Color3.new(0, 1, 0), +## Explanation - [OnEvent "MouseEnter"] = function() - dropAction:set("completed") - end, +The basic idea is to create a container which stores the UI you want to drag. +This container then reparents itself as it gets dragged around between +different containers. - [OnEvent "MouseLeave"] = function() - if peek(dropAction) == "completed" then - dropAction:set(nil) -- only clear this if it's not overwritten yet - end - end, +The `Draggable` component implements everything necessary to make a seamlessly +re-parentable container. - [Children] = { - New "UIListLayout" { - SortOrder = "Name", - Padding = UDim.new(0, 5) +```Lua linenums="13" +type DragInfo = { + id: string, + mouseOffset: Vector2 -- relative to the dragged item +} + +local function Draggable( + scope: Fusion.Scope, + props: { + ID: string, + Name: Fusion.CanBeState?, + Parent: Fusion.StateObject, -- StateObject so it's observable + Layout: { + LayoutOrder: Fusion.CanBeState?, + Position: Fusion.CanBeState?, + AnchorPoint: Fusion.CanBeState?, + ZIndex: Fusion.CanBeState?, + Size: Fusion.CanBeState?, + OutAbsolutePosition: Fusion.Value?, + }, + Dragging: { + MousePosition: Fusion.CanBeState, + SelfDragInfo: Fusion.CanBeState, + OverlayFrame: Fusion.CanBeState } + [typeof(Children)]: Fusion.Child + } +): Fusion.Child +``` + +By default, `Draggable` behaves like a regular Frame, parenting itself to the +`Parent` property and applying its `Layout` properties. + +It only behaves specially when `Dragging.SelfDragInfo` is provided. Firstly, +it reparents itself to `Dragging.OverlayFrame`, so it can be seen in front of +other UI. + +```Lua linenums="66" + Parent = scope:Computed(function(use) + return + if use(props.Dragging.SelfDragInfo) ~= nil + then use(props.Dragging.OverlayFrame) + else use(props.Parent) + end), +``` + +Because of this reparenting, `Draggable` has to do some extra work to keep the +size consistent; it manually calculates the size based on the size of `Parent`, +so it doesn't change size when moved to `Dragging.OverlayFrame`. + +```Lua linenums="90" + -- Calculated manually so the Scale can be set relative to + -- `props.Parent` at all times, rather than the `Parent` of this Frame. + Size = scope:Computed(function(use) + local udim2 = use(props.Layout.Size) or UDim2.fromOffset(0, 0) + local parentSize = use(parentSize) or Vector2.zero + return UDim2.fromOffset( + udim2.X.Scale * parentSize.X + udim2.X.Offset, + udim2.Y.Scale * parentSize.Y + udim2.Y.Offset + ) + end), +``` + +The `Draggable` also needs to snap to the mouse cursor, so it can be moved by +the user. Ideally, the mouse would stay fixed in position relative to the +`Draggable`, so there are no abrupt changes in the position of any elements. + +As part of `Dragging.SelfDragInfo`, a `mouseOffset` is provided, which describes +how far the mouse should stay from the top-left corner. So, when setting the +position of the `Draggable`, that offset can be applied to keep the UI fixed +in position relative to the mouse. + +```Lua linenums="80" + Position = scope:Computed(function(use) + local dragInfo = use(props.Dragging.SelfDragInfo) + if dragInfo == nil then + return use(props.Layout.Position) or UDim2.fromOffset(0, 0) + else + local mousePos = use(props.Dragging.MousePosition) + local topLeftCorner = mousePos - dragInfo.mouseOffset + return UDim2.fromOffset(topLeftCorner.X, topLeftCorner.Y) + end + end), +``` + +This is all that's needed to make a generic container that can seamlessly move +between distinct parts of the UI. The rest of the example demonstrates how this +can be integrated into real world UI. + +The example creates a list of `TodoItem` objects, each with a unique ID, text +message, and completion status. Because we don't expect the ID or text to +change, they're just constant values. However, the completion status *is* +expected to change, so that's specified to be a `Value` object. + +```Lua linenums="116" +type TodoItem = { + id: string, + text: string, + completed: Fusion.Value +} +local todoItems: Fusion.Value = { + { + id = newUniqueID(), + text = "Wake up today", + completed = Value(true) + }, + { + id = newUniqueID(), + text = "Read the Fusion docs", + completed = Value(true) + }, + { + id = newUniqueID(), + text = "Take over the universe", + completed = Value(false) } } +``` + +The `TodoEntry` component is meant to represent one individual `TodoItem`. + +```Lua linenums="149" +local function TodoEntry( + outerScope: Fusion.Scope<{}>, + props: { + Item: TodoItem, + Parent: Fusion.StateObject, + Layout: { + LayoutOrder: Fusion.CanBeState?, + Position: Fusion.CanBeState?, + AnchorPoint: Fusion.CanBeState?, + ZIndex: Fusion.CanBeState?, + Size: Fusion.CanBeState?, + OutAbsolutePosition: Fusion.Value?, + }, + Dragging: { + MousePosition: Fusion.CanBeState, + SelfDragInfo: Fusion.CanBeState, + OverlayFrame: Fusion.CanBeState? + }, + OnMouseDown: () -> ()? + } +): Fusion.Child +``` --- Now we can write a mouse up handler to drop our items. +Notice that it shares many of the same property groups as `Draggable` - these +can be passed directly through. -local mouseUpConn = UserInputService.InputEnded:Connect(function(inputObject) - if inputObject.UserInputType ~= Enum.UserInputType.MouseButton1 then - return - end - local dragInfo = peek(currentlyDragging) - if dragInfo == nil then - return - end - local item = getTodoItemForID(dragInfo.id) - local action = peek(dropAction) - if item ~= nil then - if action == "incomplete" then - item.completed:set(false) - elseif action == "completed" then - item.completed:set(true) - end +```Lua linenums="181" + return scope:Draggable { + ID = props.Item.id, + Name = props.Item.text, + Parent = props.Parent, + Layout = props.Layout, + Dragging = props.Dragging, +``` + +It also provides an `OnMouseDown` callback, which can be used to pick up the +entry if the mouse is pressed down above the entry. Note the comment about why +it is *not* desirable to detect mouse-up here; the UI should unconditionally +respond to mouse-up, even if the mouse happens to briefly leave this element. + +```Lua linenums="202" + [OnEvent "MouseButton1Down"] = props.OnMouseDown + + -- Don't detect mouse up here, because in some rare cases, the event + -- could be missed due to lag between the item's position and the + -- cursor position. +``` + +Now, the destinations for these entries can be created. To help decide where to +drop items later, the `dropAction` tracks which destination the mouse is hovered +over. + +```Lua linenums="225" +local dropAction = scope:Value(nil) + +local taskLists = scope:ForPairs( + { + incomplete = "mark-as-incomplete", + completed = "mark-as-completed" + }, + function(use, scope, listName, listDropAction) + return + listName, + scope:New "ScrollingFrame" { + Name = `TaskList ({listName})`, + Position = UDim2.fromScale(0.1, 0.1), + Size = UDim2.fromScale(0.35, 0.9), + + BackgroundTransparency = 0.75, + BackgroundColor3 = Color3.new(1, 0, 0), + + [OnEvent "MouseEnter"] = function() + dropAction:set(listDropAction) + end, + + [OnEvent "MouseLeave"] = function() + -- A different item might have overwritten this already. + if peek(dropAction) == listDropAction then + dropAction:set(nil) + end + end, + + [Children] = { + New "UIListLayout" { + SortOrder = "Name", + Padding = UDim.new(0, 5) + } + } + } end - currentlyDragging:set(nil) -end) +) +``` --- We'll need to construct an overlay frame for our items to live in while they --- get dragged around. +This is also where the 'overlay frame' is created, which gives currently-dragged +UI a dedicated layer above all other UI to freely move around. -local overlayFrame = New "Frame" { +```Lua linenums="264" +local overlayFrame = scope:New "Frame" { Size = UDim2.fromScale(1, 1), ZIndex = 10, BackgroundTransparency = 1 } +``` + +Finally, each `TodoItem` is created as a `TodoEntry`. Some state is also created +to track which entry is being dragged at the moment. + +```Lua linenums="270" +local currentlyDragging: Fusion.Value = scope:Value(nil) + +local allEntries = scope:ForValues( + todoItems, + function(use, scope, item) + local itemPosition = scope:Value(nil) + return scope:TodoEntry { + Item = item, +``` + +Each entry dynamically picks one of the two destinations based on its +completion status. + +```Lua linenums="278" + Parent = scope:Computed(function(use) + return + if use(item.completed) + then use(taskLists).completed + else use(taskLists).incomplete + end), +``` + +It also provides the information needed by the `Draggable`. + +Note that the current drag information is filtered from the `currentlyDragging` +state so the `Draggable` won't see information about other entries being +dragged. + +```Lua linenums="288" + Dragging = { + MousePosition = mousePos, + SelfDragInfo = scope:Computed(function(use) + local dragInfo = use(currentlyDragging) + return + if dragInfo == nil or dragInfo.id ~= item.id + then nil + else dragInfo + end) + OverlayFrame = overlayFrame + }, +``` + +Now it's time to handle starting and stopping the drag. + +To begin the drag, this code makes use of the `OnMouseDown` callback. If nothing +else is being dragged right now, the position of the mouse relative to the item +is captured. Then, that `mouseOffset` and the `id` of the item are passed into +the `currentlyDragging` state to indicate this entry is being dragged. + +```Lua linenums="299" + OnMouseDown = function() + if peek(currentlyDragging) == nil then + local itemPos = peek(itemPosition) or Vector2.zero + local mouseOffset = peek(mousePos) - itemPos + currentlyDragging:set({ + id = item.id, + mouseOffset = mouseOffset + }) + end + end +``` --- Let's construct the items themselves! Because we're constructing them at the --- global level like this, they're only created and destroyed when they're added --- and removed from the list. - -local allEntries = ForValues(todoItems, function(use, item) - return TodoEntry { - Item = item, - Parent = Computed(function() - return if use(item.completed) then completedList else incompleteList - end, Fusion.doNothing), - OverlayFrame = overlayFrame - } -end, Fusion.cleanup) +To end the drag, a global `InputEnded` listener is created, which should +reliably fire no matter where or when the event occurs. --- Finally, construct the whole UI :) +If there's a `dropAction` to take, for example `mark-as-completed`, then that +action is executed here. -local ui = New "ScreenGui" { - Parent = game:GetService("Players").LocalPlayer.PlayerGui, +In all cases, `currentlyDragging` is cleared, so the entry is no longer dragged. - [Cleanup] = { - mousePosConn, - mouseUpConn - }, +```Lua linenums="313" +table.insert(scope, + UserInputService.InputEnded:Connect(function(inputObject) + if inputObject.UserInputType ~= Enum.UserInputType.MouseButton1 then + return + end + local dragInfo = peek(currentlyDragging) + if dragInfo == nil then + return + end + local item = getTodoItemForID(dragInfo.id) + local action = peek(dropAction) + if item ~= nil then + if action == "mark-as-incomplete" then + item.completed:set(false) + elseif action == "mark-as-completed" then + item.completed:set(true) + end + end + currentlyDragging:set(nil) + end) +) +``` + +All that remains is to parent the task lists and overlay frames to a UI, so they +can be seen. Because the `TodoEntry` component manages their own parent, this +code shouldn't pass in `allEntries` as a child here. + +```Lua linenums="335" +local ui = scope:New "ScreenGui" { + Parent = Players.LocalPlayer:FindFirstChildOfClass("PlayerGui") [Children] = { overlayFrame, - incompleteList, - completedList - - -- We don't have to pass `allEntries` in here - they manage their own - -- parenting thanks to `Draggable` :) + taskLists, + -- Don't pass `allEntries` in here - they manage their own parent! } } ``` \ No newline at end of file diff --git a/docs/examples/cookbook/fetch-data-from-server.md b/docs/examples/cookbook/fetch-data-from-server.md index 86d74e483..9506482a5 100644 --- a/docs/examples/cookbook/fetch-data-from-server.md +++ b/docs/examples/cookbook/fetch-data-from-server.md @@ -1,56 +1,149 @@ +This code shows how to deal with yielding/blocking code, such as fetching data +from a server. + +Because these tasks don't complete immediately, they can't be directly run +inside of a `Computed`, so this example provides a robust framework for handling +this in a way that doesn't corrupt your code. + +This example assumes the presence of a Roblox-like `task` scheduler. + +----- + +## Overview + ```Lua linenums="1" --- [Fusion imports omitted for clarity] +local Fusion = -- initialise Fusion here however you please! +local scoped = Fusion.scoped + +local function fetchUserBio( + userID: number +): string + -- pretend this calls out to a server somewhere, causing this code to yield + task.wait(1) + return "This is the bio for user " .. userID .. "!" +end --- This code assumes that there is a RemoteFunction at this location, which --- accepts a user ID and will return a string with that user's bio text. --- The server implementation is not shown here. -local FetchUserBio = game:GetService("ReplicatedStorage").FetchUserBio +-- Don't forget to pass this to `doCleanup` if you disable the script. +local scope = scoped(Fusion) --- Creating a Value object to store the user ID we're currently looking at -local currentUserID = Value(1670764) +-- This doesn't have to be a `Value` - any kind of state object works too. +local currentUserID = scope:Value(1670764) --- If we could instantly calculate the user's bio text, we could use a Computed --- here. However, fetching data from the server takes time, which means we can't --- use Computed without introducing serious consistency errors into our program. +-- While the bio is loading, this is `nil` instead of a string. +local currentUserBio: Fusion.Value = scope:Value(nil) --- Instead, we fall back to using an observer to manually manage our own value: -local currentUserBio = Value(nil) --- Using a scope to hide our management code from the rest of the script: do - local lastFetchTime = nil - local function fetch() - local fetchTime = os.clock() - lastFetchTime = fetchTime - currentUserBio:set(nil) -- set to a default value to indicate loading - task.spawn(function() - local bio = FetchUserBio:InvokeServer(peek(currentUserID)) - -- If these two are not equal, then that means another fetch was - -- started while we were waiting for the server to return a value. - -- In that case, the more recent call will be more up-to-date, so we - -- shouldn't overwrite it. This adds a nice layer of reassurance, - -- but if your value doesn't change often, this might be optional. - if lastFetchTime == fetchTime then - currentUserBio:set(bio) - end + local fetchInProgress = nil + local function performFetch() + local userID = peek(currentUserID) + currentUserBio:set(nil) + if fetchInProgress ~= nil then + task.cancel(fetchInProgress) + end + fetchInProgress = task.spawn(function() + currentUserBio:set(fetchUserBio()) + fetchInProgress = nil end) end + scope:Observer(currentUserID):onBind(performFetch) +end + +scope:Observer(currentUserBio):onBind(function() + local bio = peek(currentUserBio) + if bio == nil then + print("User bio is loading...") + else + print("Loaded user bio:", bio) + end +end) +``` + +----- - fetch() -- get the bio for the initial user ID - -- when the user ID changes, reload the bio - local disconnect = Observer(currentUserID):onChange(fetch) +## Explanation - -- Don't forget to call `disconnect` when you're done with `currentUserBio`. - -- That's not included in this code snippet, but it's important if you want - -- to avoid leaking memory. +If you yield or wait inside of a `Computed`, you can easily corrupt your entire +program. + +However, this example has a function, `fetchUserBio`, that yields. + +```Lua linenums="5" +local function fetchUserBio( + userID: number +): string + -- pretend this calls out to a server somewhere, causing this code to yield + task.wait(1) + return "This is the bio for user " .. userID .. "!" end +``` + +It also has some arbitrary state object, `currentUserID`, that it needs to +convert into a bio somehow. --- Now, you can use `currentUserBio` just like any other state object! Note that --- `nil` is used to represent a bio that hasn't loaded yet, so you'll want to --- handle that case before passing it into any code that expects a solid value. +```Lua linenums="15" +-- This doesn't have to be a `Value` - any kind of state object works too. +local currentUserID = scope:Value(1670764) +``` + +Because `Computed` can't yield, this code has to manually manage a +`currentUserBio` object, which will store the output of the code in a way that +can be used by other Fusion objects later. + +Notice that the 'loading' state is explicitly documented. It's a good idea to +be clear and honest when you have no data to show, because it allows other code +to respond to that case flexibly. + +```Lua linenums="18" +-- While the bio is loading, this is `nil` instead of a string. +local currentUserBio: Fusion.Value = scope:Value(nil) +``` + +To perform the actual fetch, a simple function can be written which calls +`fetchUserBio` in a separate task. Once it returns a bio, the `currentUserBio` +can be updated. + +To avoid two fetches overwriting each other, any existing fetch task is canceled +before the new task is created. + +```Lua linenums="22" + local fetchInProgress = nil + local function performFetch() + local userID = peek(currentUserID) + currentUserBio:set(nil) + if fetchInProgress ~= nil then + task.cancel(fetchInProgress) + end + fetchInProgress = task.spawn(function() + currentUserBio:set(fetchUserBio()) + fetchInProgress = nil + end) + end +``` + +Finally, to run this function when the `currentUserID` changes, `performFetch` +can be added to an `Observer`. + +The `onBind` method also runs `performFetch` once at the start of the program, +so the request is sent out automatically. + +```Lua linenums="34" +scope:Observer(currentUserID):onBind(performFetch) +``` + +That's all you need - now, any other Fusion code can read and depend upon +`currentUserBio` as if it were any other kind of state object. Just remember to +handle the 'loading' state as well as the successful state. + +```Lua linenums="37" +scope:Observer(currentUserBio):onBind(function() + local bio = peek(currentUserBio) + if bio == nil then + print("User bio is loading...") + else + print("Loaded user bio:", bio) + end +end) +``` -local bioLabel = New "TextLabel" { - Text = Computed(function(use) - return use(currentUserBio) or "Loading user bio..." - end) -} -``` \ No newline at end of file +You may wish to expand this code with error handling if `fetchUserBio()` can +throw errors. \ No newline at end of file diff --git a/docs/examples/cookbook/light-and-dark-theme.md b/docs/examples/cookbook/light-and-dark-theme.md index a9ffb1b27..3aec057e7 100644 --- a/docs/examples/cookbook/light-and-dark-theme.md +++ b/docs/examples/cookbook/light-and-dark-theme.md @@ -1,42 +1,88 @@ +This example demonstrates how to create dynamic theme colours using Fusion's +state objects. + +----- + +## Overview + ```Lua linenums="1" --- [Fusion imports omitted for clarity] +local Fusion = --initialise Fusion here however you please! +local scoped = Fusion.scoped --- Defining some theme colours. Something to note; I'm intentionally putting the --- actual colour names as the topmost keys here, and putting `light` and `dark` --- keys inside the colours. If you did it the other way around, then there's no --- single source of truth for what colour names are available, and it's hard to --- keep in sync. If a theme doesn't have a colour, it's better to explicitly not --- specify it under the colour name. +local Theme = {} -local THEME_COLOURS = { +Theme.colours = { background = { light = Color3.fromHex("FFFFFF"), - dark = Color3.fromHex("2D2D2D") + dark = Color3.fromHex("222222") }, text = { - light = Color3.fromHex("2D2D2D"), + light = Color3.fromHex("222222"), dark = Color3.fromHex("FFFFFF") - }, - -- [etc, for all the colours you'd want] + } } --- This will control which colours we're using at the moment. You could expose --- this to the rest of your code directly, or calculate it using a Computed. -local currentTheme = Value("light") +-- Don't forget to pass this to `doCleanup` if you disable the script. +local scope = scoped(Fusion) --- Now we'll create a Computed for every theme colour, which will pick a colour --- from `THEME_COLS` based on our `currentTheme`. -local currentColours = {} -for colourName, colourOptions in THEME_COLOURS do - currentColours[colourName] = Computed(function(use) - return colourOptions[use(currentTheme)] +Theme.current = scope:Value("light") +Theme.dynamic = {} +for colour, variants in Theme.colours do + Theme.dynamic[colour] = scope:Computed(function(use) + return variants[use(Theme.current)] end) end --- Now you can expose `colourOptions` to the rest of your code, preferably under --- a convenient name :) +Theme.current:set("light") +print(peek(Theme.dynamic.background)) --> 255, 255, 255 + +Theme.current:set("dark") +print(peek(Theme.dynamic.background)) --> 34, 34, 34 +``` + +----- -local text = New "TextLabel" { - TextColor3 = currentColours.text +## Explanation + +To begin, this example defines a set of colours with light and dark variants. + +```Lua linenums="6" +Theme.colours = { + background = { + light = Color3.fromHex("FFFFFF"), + dark = Color3.fromHex("222222") + }, + text = { + light = Color3.fromHex("222222"), + dark = Color3.fromHex("FFFFFF") + } } +``` + +A `Value` object stores which variant is in use right now. + +```Lua linenums="20" +Theme.current = scope:Value("light") +``` + +Finally, each colour is turned into a `Computed`, which dynamically pulls the +desired variant from the list. + +```Lua linenums="21" +Theme.dynamic = {} +for colour, variants in Theme.colours do + Theme.dynamic[colour] = scope:Computed(function(use) + return variants[use(Theme.current)] + end) +end +``` + +This allows other code to easily access theme colours from `Theme.dynamic`. + +```Lua linenums="28" +Theme.current:set("light") +print(peek(Theme.dynamic.background)) --> 255, 255, 255 + +Theme.current:set("dark") +print(peek(Theme.dynamic.background)) --> 34, 34, 34 ``` \ No newline at end of file diff --git a/docs/examples/cookbook/loading-spinner.md b/docs/examples/cookbook/loading-spinner.md index 70e71017b..c944684f2 100644 --- a/docs/examples/cookbook/loading-spinner.md +++ b/docs/examples/cookbook/loading-spinner.md @@ -1,39 +1,128 @@ +This example implements a procedural spinning animation using Fusion's Roblox +APIs. + +----- + +## Overview + ```Lua linenums="1" local RunService = game:GetService("RunService") --- [Fusion imports omitted for clarity] - --- Loading spinners generally don't use transition-based animations like tweens. --- Instead, they animate continuously and independently, so we'll need to set up --- our own animation clock that will drive the animation. --- We can set up one clock and use it everywhere. -local timer = Value(os.clock()) -local timerConn = RunService.RenderStepped:Connect(function() - -- Remember to disconnect this event when you're done using it! - timer:set(os.clock()) -end) - --- Our loading spinner will consist of an image which rotates around. You could --- do something more complex or intricate for spice, but in the interest of --- providing a simple starting point, let's keep it simple. -local spinner = New "ImageLabel" { - Position = UDim2.fromScale(0.5, 0.5), - AnchorPoint = Vector2.new(0.5, 0.5), - Size = UDim2.fromOffset(50, 50), - - BackgroundTransparency = 1, - Image = "rbxassetid://your-loading-spinner-image", -- replace this! - - -- As the timer runs, this will automatically update and rotate our image. - Rotation = Computed(function(use) - local time = use(timer) - local angle = time * 180 -- Spin at a rate of 180 degrees per second - angle %= 360 -- Don't need to go beyond 360 degrees; wrap instead - return angle - end), - - -- If your `timer` is only used by this one loading spinner, you can clean - -- up the `timerConn` here. If you're re-using one timer for all of your - -- spinners, you don't need to do this here. - [Cleanup] = timerConn + +local Fusion = -- initialise Fusion here however you please! +local scoped = Fusion.scoped +local Children = Fusion.Children + +local SPIN_DEGREES_PER_SECOND = 180 +local SPIN_SIZE = 50 + +local function Spinner( + scope: Fusion.Scope, + props: { + Layout: { + LayoutOrder: Fusion.CanBeState?, + Position: Fusion.CanBeState?, + AnchorPoint: Fusion.CanBeState?, + ZIndex: Fusion.CanBeState? + }, + CurrentTime: Fusion.CanBeState, + } +): Fusion.Child + return scope:New "ImageLabel" { + Name = "Spinner", + + LayoutOrder = props.Layout.LayoutOrder, + Position = props.Layout.Position, + AnchorPoint = props.Layout.AnchorPoint, + ZIndex = props.Layout.ZIndex, + + Size = UDim2.fromOffset(SPIN_SIZE, SPIN_SIZE), + + BackgroundTransparency = 1, + Image = "rbxassetid://your-loading-spinner-image", -- replace this! + + Rotation = scope:Computed(function(use) + return (use(props.CurrentTime) * SPIN_DEGREES_PER_SECOND) % 360 + end) + } +end + +-- Don't forget to pass this to `doCleanup` if you disable the script. +local scope = scoped(Fusion, { + Spinner = Spinner +}) + +local currentTime = scope:Value(os.clock()) +table.insert(scope, + RunService.RenderStepped:Connect(function() + currentTime:set(os.clock()) + end) +) + +local spinner = scope:Spinner { + Layout = { + Position = UDim2.fromScale(0.5, 0.5), + AnchorPoint = Vector2.new(0.5, 0.5), + Size = UDim2.fromOffset(50, 50) + }, + CurrentTime = currentTime +} +``` + +----- + +## Explanation + +The `Spinner` components implements the animation for the loading spinner. It's +largely a standard Fusion component definition. + +The main thing to note is that it asks for a `CurrentTime` property. + +```Lua linenums="10" hl_lines="10" +local function Spinner( + scope: Fusion.Scope, + props: { + Layout: { + LayoutOrder: Fusion.CanBeState?, + Position: Fusion.CanBeState?, + AnchorPoint: Fusion.CanBeState?, + ZIndex: Fusion.CanBeState? + }, + CurrentTime: Fusion.CanBeState, + } +): Fusion.Child +``` + +The `CurrentTime` is used to drive the rotation of the loading spinner. + +```Lua linenums="35" + Rotation = scope:Computed(function(use) + return (use(props.CurrentTime) * SPIN_DEGREES_PER_SECOND) % 360 + end) +``` + +That's all that's required for the `Spinner` component. + +Later on, the example creates a `Value` object that will store the current time, +and starts a process to keep it up to date. + +```Lua linenums="46" +local currentTime = scope:Value(os.clock()) +table.insert(scope, + RunService.RenderStepped:Connect(function() + currentTime:set(os.clock()) + end) +) +``` + +This can then be passed in as `CurrentTime` when the `Spinner` is created. + +```Lua linenums="53" hl_lines="7" +local spinner = scope:Spinner { + Layout = { + Position = UDim2.fromScale(0.5, 0.5), + AnchorPoint = Vector2.new(0.5, 0.5), + Size = UDim2.fromOffset(50, 50) + }, + CurrentTime = currentTime } ``` \ No newline at end of file diff --git a/docs/examples/cookbook/player-list.md b/docs/examples/cookbook/player-list.md index dd169286e..4d4a34102 100644 --- a/docs/examples/cookbook/player-list.md +++ b/docs/examples/cookbook/player-list.md @@ -1,47 +1,24 @@ -```Lua linenums="1" --- [Fusion imports omitted for clarity] - -type Set = {[T]: true} +This shows how to use Fusion's Roblox API to create a simple, dynamically +updating player list. --- Defining a component for each row of the player list. --- Each row represents a player currently logged into the server. --- We set the `Name` to the player's name so the rows can be sorted by name. +----- -type PlayerListRowProps = { - Player: Player -} +## Overview -local function PlayerListRow(props: PlayerListRowProps) - return New "TextLabel" { - Name = props.Player.DisplayName, - - Size = UDim2.new(1, 0, 0, 25), - BackgroundTransparency = 1, +```Lua linenums="1" +local Players = game:GetService("Players") - Text = props.Player.DisplayName, - TextColor3 = Color3.new(1, 1, 1), - Font = Enum.Font.GothamMedium, - FontSize = 16, - TextXAlignment = "Right", - TextTruncate = "AtEnd", +local Fusion = -- initialise Fusion here however you please! +local scoped = Fusion.scoped +local Children = Fusion.Children - [Children] = New "UIPadding" { - PaddingLeft = UDim.new(0, 10), - PaddingRight = UDim.new(0, 10) - } +local function PlayerList( + scope: Fusion.Scope, + props: { + Players: Fusion.CanBeState<{Player}> } -end - --- Defining a component for the entire player list. --- It should take in a set of all logged-in players, and it should be a state --- object so the set of players can change as players join and leave. - -type PlayerListProps = { - PlayerSet: Fusion.StateObject> -} - -local function PlayerList(props: PlayerListProps) - return New "Frame" { +): Fusion.Child + return scope:New "Frame" { Name = "PlayerList", Position = UDim2.fromScale(1, 0), @@ -53,53 +30,131 @@ local function PlayerList(props: PlayerListProps) BackgroundColor3 = Color3.new(0, 0, 0), [Children] = { - New "UICorner" {}, - New "UIListLayout" { + scope:New "UICorner" { + CornerRadius = UDim.new(0, 8) + }, + scope:New "UIListLayout" { SortOrder = "Name", FillDirection = "Vertical" }, - ForPairs(props.PlayerSet, function(use, player, _) - return player, PlayerListRow { - Player = player + scope:ForValues(props.Players, function(use, scope, player) + return scope:New "TextLabel" { + Name = "PlayerListRow: " .. player.DisplayName, + + Size = UDim2.new(1, 0, 0, 25), + BackgroundTransparency = 1, + + Text = player.DisplayName, + TextColor3 = Color3.new(1, 1, 1), + Font = Enum.Font.GothamMedium, + FontSize = 16, + TextXAlignment = "Right", + TextTruncate = "AtEnd", + + [Children] = scope:New "UIPadding" { + PaddingLeft = UDim.new(0, 10), + PaddingRight = UDim.new(0, 10) + } } - end, Fusion.cleanup) + end) } } end --- To create the PlayerList component, first we need a state object that stores --- the set of logged-in players, and updates as players join and leave. - -local Players = game:GetService("Players") +-- Don't forget to pass this to `doCleanup` if you disable the script. +local scope = scoped(Fusion, { + PlayerList = PlayerList +}) -local playerSet = Value() -local function updatePlayerSet() - local newPlayerSet = {} - for _, player in Players:GetPlayers() do - newPlayerSet[player] = true - end - playerSet:set(newPlayerSet) +local players = scope:Value(Players:GetPlayers()) +local function updatePlayers() + players:set(Players:GetPlayers()) end -local playerConnections = { - Players.PlayerAdded:Connect(updatePlayerSet), - Players.PlayerRemoving:Connect(updatePlayerSet) +table.insert(scope, { + Players.PlayerAdded:Connect(updatePlayers), + Players.PlayerRemoving:Connect(updatePlayers) +}) + +local gui = scope:New "ScreenGui" { + Name = "PlayerListGui", + Parent = Players.LocalPlayer:FindFirstChildOfClass("PlayerGui"), + + [Children] = scope:PlayerList { + Players = players + } } -updatePlayerSet() +``` --- Now, we can create the component and pass in `playerSet`. --- Don't forget to clean up your connections when your UI is destroyed; to do --- that, we're using the `[Cleanup]` key to clean up `playerConnections` later. +----- -local gui = New "ScreenGui" { +## Explanation + +The `PlayerList` component is designed to be simple and self-contained. The only +thing it needs is a `Players` list - it handles everything else, including its +position, size, appearance and behaviour. + +```Lua linenums="7" +local function PlayerList( + scope: Fusion.Scope, + props: { + Players: Fusion.CanBeState<{Player}> + } +): Fusion.Child +``` + +After creating a vertically expanding Frame with some style and layout added, +it turns the `Players` into a series of text labels using `ForValues`, which +will automatically create and remove them as the `Players` list changes. + +```Lua linenums="33" + scope:ForValues(props.Players, function(use, scope, player) + return scope:New "TextLabel" { + Name = "PlayerListRow: " .. player.DisplayName, + + Size = UDim2.new(1, 0, 0, 25), + BackgroundTransparency = 1, + + Text = player.DisplayName, + TextColor3 = Color3.new(1, 1, 1), + Font = Enum.Font.GothamMedium, + FontSize = 16, + TextXAlignment = "Right", + TextTruncate = "AtEnd", + + [Children] = scope:New "UIPadding" { + PaddingLeft = UDim.new(0, 10), + PaddingRight = UDim.new(0, 10) + } + } + end) +``` + +That's all that the `PlayerList` component has to do. + +Later on, the code creates a `Value` object to store a list of players, and +update it every time a player joins or leaves the game. + +```Lua linenums="62" +local players = scope:Value(Players:GetPlayers()) +local function updatePlayers() + players:set(Players:GetPlayers()) +end +table.insert(scope, { + Players.PlayerAdded:Connect(updatePlayers), + Players.PlayerRemoving:Connect(updatePlayers) +}) +``` + +That object can then be passed in as `Players` when creating the `PlayerList`. + +```Lua linenums="71" hl_lines="6" +local gui = scope:New "ScreenGui" { Name = "PlayerListGui", Parent = Players.LocalPlayer:FindFirstChildOfClass("PlayerGui"), - [Cleanup] = playerConnections, - - [Children] = PlayerList { - PlayerSet = playerSet + [Children] = scope:PlayerList { + Players = players } } - ``` \ No newline at end of file diff --git a/docs/examples/index.md b/docs/examples/index.md index 5eaa91e4a..be7c9b88a 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -24,7 +24,7 @@ animations and responding to different events. ## Open-Source Projects -### Fusion Wordle +### Fusion Wordle (for Fusion 0.2) ![A photo taken in Fusion Wordle, showing a completed game board](place-thumbnails/Fusion-Wordle.jpg) @@ -33,7 +33,7 @@ validation, spring animations and sounds. [Play and edit the game on Roblox.](https://www.roblox.com/games/12178127791/) -### Fusion Obby +### Fusion Obby (for Fusion 0.1) ![A photo taken in Fusion Obby, showing the counter and confetti](place-thumbnails/Fusion-Obby.jpg) diff --git a/docs/tutorials/animation/springs.md b/docs/tutorials/animation/springs.md index e6422008c..ed53aa3a2 100644 --- a/docs/tutorials/animation/springs.md +++ b/docs/tutorials/animation/springs.md @@ -9,24 +9,17 @@ movement naturally without abrupt changes in direction. ## Usage -To use `Spring` in your code, you first need to import it from the Fusion -module, so that you can refer to it by name: - -```Lua linenums="1" hl_lines="2" -local Fusion = require(ReplicatedStorage.Fusion) -local Spring = Fusion.Spring -``` - -To create a new spring object, call the `Spring` function and pass it a state -object to move towards: +To create a new spring object, call `scope:Spring()` and pass it a state object +to move towards: ```Lua -local goal = Value(0) -local animated = Spring(target) +local goal = scope:Value(0) +local animated = scope:Spring(goal) ``` -The spring will smoothly follow the 'goal' state object over time. As with other -state objects, you can `peek()` at its value at any time: +The spring will smoothly follow the 'goal' state object over time. + +As with other state objects, you can `peek()` at its value at any time: ```Lua print(peek(animated)) --> 0.26425... @@ -36,10 +29,17 @@ To configure how the spring moves, you can provide a speed and damping ratio to use. Both are optional, and both can be state objects if desired: ```Lua -local goal = Value(0) +local goal = scope:Value(0) local speed = 25 -local damping = Value(0.5) -local animated = Spring(target, speed, damping) +local damping = scope:Value(0.5) +local animated = scope:Spring(goal, speed, damping) +``` + +You can also set the position and velocity of the spring at any time. + +```Lua +animated:setPosition(5) -- teleport the spring to 5 +animated:setVelocity(2) -- from here, move 2 units/second ``` You can use many different kinds of values with springs, not just numbers. @@ -47,8 +47,8 @@ Vectors, CFrames, Color3s, UDim2s and other number-based types are supported; each number inside the type is animated individually. ```Lua -local goalPosition = Value(UDim2.new(0.5, 0, 0, 0)) -local animated = Spring(target, 25, 0.5) +local goalPosition = scope:Value(UDim2.new(0.5, 0, 0, 0)) +local animated = scope:Spring(goalPosition, 25, 0.5) ``` ----- diff --git a/docs/tutorials/animation/tweens.md b/docs/tutorials/animation/tweens.md index d7a430423..cc7723a3b 100644 --- a/docs/tutorials/animation/tweens.md +++ b/docs/tutorials/animation/tweens.md @@ -8,24 +8,17 @@ This can be used for basic, predictable animations. ## Usage -To use `Tween` in your code, you first need to import it from the Fusion -module, so that you can refer to it by name: - -```Lua linenums="1" hl_lines="2" -local Fusion = require(ReplicatedStorage.Fusion) -local Tween = Fusion.Tween -``` - -To create a new tween object, call the `Tween` function and pass it a state -object to move towards: +To create a new tween object, call `scope:Tween()` and pass it a state object to +move towards: ```Lua -local goal = Value(0) -local animated = Tween(target) +local goal = scope:Value(0) +local animated = scope:Tween(goal) ``` -The tween will smoothly follow the 'goal' state object over time. As with other -state objects, you can `peek()` at its value at any time: +The tween will smoothly follow the 'goal' state object over time. + +As with other state objects, you can `peek()` at its value at any time: ```Lua print(peek(animated)) --> 0.26425... @@ -36,9 +29,9 @@ shape of the animation curve. It's optional, and it can be a state object if desired: ```Lua -local goal = Value(0) +local goal = scope:Value(0) local style = TweenInfo.new(0.5, Enum.EasingStyle.Quad) -local animated = Tween(target, style) +local animated = scope:Tween(goal, style) ``` You can use many different kinds of values with tweens, not just numbers. @@ -46,8 +39,8 @@ Vectors, CFrames, Color3s, UDim2s and other number-based types are supported; each number inside the type is animated individually. ```Lua -local goalPosition = Value(UDim2.new(0.5, 0, 0, 0)) -local animated = Tween(target, TweenInfo.new(0.5, Enum.EasingStyle.Quad)) +local goalPosition = scope:Value(UDim2.new(0.5, 0, 0, 0)) +local animated = scope:Tween(goalPosition, TweenInfo.new(0.5, Enum.EasingStyle.Quad)) ``` ----- diff --git a/docs/tutorials/components/callbacks.md b/docs/tutorials/best-practices/callbacks.md similarity index 66% rename from docs/tutorials/components/callbacks.md rename to docs/tutorials/best-practices/callbacks.md index c04e88e15..7a792836e 100644 --- a/docs/tutorials/components/callbacks.md +++ b/docs/tutorials/best-practices/callbacks.md @@ -11,17 +11,9 @@ example to report when button clicks occur. ## In Luau -Callbacks are functions which you pass into other functions. They're part of -normal Luau code. - -```Lua -workspace.ChildAdded:Connect(function() - -- this function is a callback! -end) -``` - -They're useful because they allow the function to 'call back' into your code, -so your code can do something in response: +Callbacks are functions which you pass into other functions. They're useful +because they allow the function to 'call back' into your code, so your code can +do something in response: ```Lua local function printMessage() @@ -38,10 +30,12 @@ function. Luau will execute the function, then return to your code. In this example, the `fiveTimes` function calls a callback five times: -=== "Script code" +=== "Luau code" ```Lua - local function fiveTimes(callback) + local function fiveTimes( + callback: (number) -> () + ) for x=1, 5 do callback(x) end @@ -69,9 +63,16 @@ In this example, the `fiveTimes` function calls a callback five times: Components can use callbacks the same way. Consider this button component; when the button is clicked, the button needs to run some external code: -```Lua -local function Button(props) - return New "TextButton" { +```Lua hl_lines="17" +local function Button( + scope: Fusion.Scope + props: { + Position: Fusion.CanBeState?, + Size: Fusion.CanBeState?, + Text: Fusion.CanBeState? + } +) + return scope:New "TextButton" { BackgroundColor3 = Color3.new(0.25, 0.5, 1), Position = props.Position, Size = props.Size, @@ -84,10 +85,10 @@ local function Button(props) end ``` -It can ask the controlling code to provide a callback in `props`, called OnClick: +It can ask the controlling code to provide an `OnClick` callback in `props`. -```Lua hl_lines="3-5" -local button = Button { +```Lua +local button = scope:Button { Text = "Hello, world!", OnClick = function() print("The button was clicked") @@ -96,11 +97,20 @@ local button = Button { ``` Assuming that callback is passed in, the callback can be passed directly into -`[OnEvent]`, because `[OnEvent]` accepts functions: - -```Lua hl_lines="10" -local function Button(props) - return New "TextButton" { +`[OnEvent]`, because `[OnEvent]` accepts functions. It can even be optional - +Luau won't add the key to the table if the value is `nil`. + +```Lua hl_lines="7 18" +local function Button( + scope: Fusion.Scope, + props: { + Position: Fusion.CanBeState?, + Size: Fusion.CanBeState?, + Text: Fusion.CanBeState?, + OnClick: (() -> ())? + } +) + return scope:New "TextButton" { BackgroundColor3 = Color3.new(0.25, 0.5, 1), Position = props.Position, Size = props.Size, @@ -116,9 +126,18 @@ end Alternatively, we can call `props.OnClick` manually, which is useful if you want to do your own processing first: -```Lua hl_lines="10-15" -local function Button(props) - return New "TextButton" { +```Lua hl_lines="19-23" +local function Button( + scope: Fusion.Scope, + props: { + Position: Fusion.CanBeState?, + Size: Fusion.CanBeState?, + Text: Fusion.CanBeState?, + Disabled: Fusion.CanBeState?, + OnClick: (() -> ())? + } +) + return scope:New "TextButton" { BackgroundColor3 = Color3.new(0.25, 0.5, 1), Position = props.Position, Size = props.Size, @@ -127,8 +146,7 @@ local function Button(props) TextColor3 = Color3.new(1, 1, 1), [OnEvent "Activated"] = function() - -- don't send clicks if the button is disabled - if not peek(props.Disabled) then + if props.OnClick ~= nil and not peek(props.Disabled) then props.OnClick() end end diff --git a/docs/tutorials/components/callbacks/Top-Down-Control-Dark.svg b/docs/tutorials/best-practices/callbacks/Top-Down-Control-Dark.svg similarity index 100% rename from docs/tutorials/components/callbacks/Top-Down-Control-Dark.svg rename to docs/tutorials/best-practices/callbacks/Top-Down-Control-Dark.svg diff --git a/docs/tutorials/components/callbacks/Top-Down-Control-Light.svg b/docs/tutorials/best-practices/callbacks/Top-Down-Control-Light.svg similarity index 100% rename from docs/tutorials/components/callbacks/Top-Down-Control-Light.svg rename to docs/tutorials/best-practices/callbacks/Top-Down-Control-Light.svg diff --git a/docs/tutorials/best-practices/components.md b/docs/tutorials/best-practices/components.md new file mode 100644 index 000000000..f219c47c3 --- /dev/null +++ b/docs/tutorials/best-practices/components.md @@ -0,0 +1,340 @@ +You can use functions to create self-contained, reusable blocks of code. In the +world of UI, you may think of them as *components* - though they can be used for +much more than just UI. + +For example, consider this function, which generates a button based on some +`props` the user passes in: + +```Lua +local function Button( + scope: Fusion.Scope, + props: { + Position: Fusion.CanBeState?, + AnchorPoint: Fusion.CanBeState?, + Size: Fusion.CanBeState?, + LayoutOrder: Fusion.CanBeState?, + ButtonText: Fusion.CanBeState + } +) + return scope:New "TextButton" { + BackgroundColor3 = Color3.new(0, 0.25, 1), + Position = props.Position, + AnchorPoint = props.AnchorPoint, + Size = props.Size, + LayoutOrder = props.LayoutOrder, + + Text = props.ButtonText, + TextSize = 28, + TextColor3 = Color3.new(1, 1, 1), + + [Children] = UICorner { CornerRadius = UDim2.new(0, 8) } + } +end +``` + +You can call this function later to generate as many buttons as you need. + +```Lua +local helloBtn = Button(scope, { + ButtonText = "Hello", + Size = UDim2.fromOffset(200, 50) +}) + +helloBtn.Parent = Players.LocalPlayer.PlayerGui.ScreenGui +``` + +Since the `scope` is the first parameter, it can even be used with `scoped()` +syntax. + +```Lua +local scope = scoped(Fusion, { + Button = Button +}) + +local helloBtn = scope:Button { + ButtonText = "Hello", + Size = UDim2.fromOffset(200, 50) +} + +helloBtn.Parent = Players.LocalPlayer.PlayerGui.ScreenGui +``` + +This is the primary way of writing components in Fusion. You create functions +that accept `scope` and `props`, then return some content from them. + +----- + +## Properties + +If you don't say what `props` should contain, it might be hard to figure +out how to use it. + +You can specify your list of properties by adding a type to `props`, which gives +you useful autocomplete and type checking. + +```Lua +local function Cake( + -- ... some stuff here ... + props: { + Size: Vector3, + Colour: Color3, + IsTasty: boolean + } +) + -- ... some other stuff here ... +end +``` + +Note that the above code only accepts constant values, not state objects. If you +want to accept *either* a constant or a state object, you can use the +`CanBeState` type. + +```Lua +local function Cake( + -- ... some stuff here ... + props: { + Size: Fusion.CanBeState, + Colour: Fusion.CanBeState, + IsTasty: Fusion.CanBeState + } +) + -- ... some other stuff here ... +end +``` + +This is usually what you want, because it means the user can easily switch +a property to dynamically change over time, while still writing properties +normally when they don't change over time. You can mostly treat `CanBeState` +properties like they're state objects, because functions like `peek()` and +`use()` automatically choose the right behaviour for you. + +If something *absolutely must* be a state object, you can use the +`StateObject` type instead. You should only consider this when it doesn't +make sense for the property to stay the same forever. + +```Lua +local function Cake( + -- ... some stuff here ... + props: { + Size: Fusion.StateObject, + Colour: Fusion.StateObject, + IsTasty: Fusion.StateObject + } +) + -- ... some other stuff here ... +end +``` + +You can use the rest of Luau's type checking features to do more complex +things, like making certain properties optional, or restricting that values +are valid for a given property. Go wild! + +!!! warning "Be mindful of the angle brackets" + Remember that, when working with `StateObject` and `CanBeState`, you should + be mindful of whether you're putting things inside the angled brackets, or + outside of them. Putting some things inside of the angle brackets can change + their meaning, compared to putting them outside of the angle brackets. + + Consider these two type definitions carefully: + + ```Lua + -- either nil, or a state object which always stores Vector3 + Fusion.StateObject? + + -- always a state object, which stores either Vector3 or nil + Fusion.StateObject + ``` + + The first type is best for *optional properties*, where you provide a + default value if it isn't specified by the user. If the user *does* specify + it, they're forced to always give a valid value for it. + + The second type is best if the property understands `nil` as a valid value. + This means the user can set it to `nil` at any time. + +----- + +## Scopes + +In addition to `props`, you should also ask for a `scope`. The `scope` +parameter should come first, so that your users can use `scoped()` syntax to +create it. + +```Lua +-- barebones syntax +local thing = Component(scope, { + -- ... some properties here ... +}) + +-- scoped() syntax +local thing = scope:Component { + -- ... some properties here ... +} +``` + +It's a good idea to provide a type for `scope`. This lets you specify what +methods you need the scope to have. + +```Lua +scope: Fusion.Scope +``` + +If you don't know what methods to ask for, consider these two strategies. + +1. If you only use common methods (like Fusion's constructors) then it's a +safe assumption that the user will also have those methods. You can ask for a +scope with those methods pre-defined. + + ```Lua hl_lines="2" + local function Component( + scope: Fusion.Scope, + props: {} + ) + return scope:New "Thing" { + -- ... rest of code here ... + } + end + ``` + +2. If you need more specific or niche things that the user likely won't have +(for example, components you use internally), then you should not ask for those. +Instead, create a new inner scope with the methods you need. + + ```Lua hl_lines="2 5-9" + local function Component( + outerScope: Fusion.Scope<{}>, + props: {} + ) + local scope = scoped(Fusion, { + SpecialThing1 = require(script.SpecialThing1), + SpecialThing2 = require(script.SpecialThing2), + }) + table.insert(outerScope, scope) + + return scope:SpecialThing1 { + -- ... rest of code here ... + } + end + ``` + +If you're not sure which strategy to pick, the second is always a safe fallback, +because it assumes less about your users and helps hide implementation details. + + +----- + +## Modules + +It's common to save different components inside of different files. +There's a number of advantages to this: + +- it's easier to find the source code for a specific component +- it keep each file shorter and simpler +- it makes sure components are properly independent, and can't interfere +- it encourages reusing components everywhere, not just in one file + +Here's an example of how you could split up some components into modules: + +=== "Main file" + + ```Lua linenums="1" + local Fusion = require(game:GetService("ReplicatedStorage").Fusion) + local scoped, doCleanup = Fusion.scoped, Fusion.doCleanup + + local scope = scoped(Fusion, { + PopUp = require(script.Parent.PopUp) + }) + + local ui = scope:New "ScreenGui" { + -- ...some properties... + + [Children] = scope:PopUp { + Message = "Hello, world!", + DismissText = "Close" + } + } + ``` +=== "PopUp" + + ```Lua linenums="1" + local Fusion = require(game:GetService("ReplicatedStorage").Fusion) + + local function PopUp( + outerScope: Fusion.Scope<{}>, + props: { + Message: Fusion.CanBeState, + DismissText: Fusion.CanBeState + } + ) + local scope = scoped(Fusion, { + Message = require(script.Parent.Message), + Button = require(script.Parent.Button) + }) + table.insert(outerScope, scope) + + return scope:New "Frame" { + -- ...some properties... + + [Children] = { + scope:Message { + Scope = scope, + Text = props.Message + } + scope:Button { + Scope = scope, + Text = props.DismissText + } + } + } + end + + return PopUp + ``` + +=== "Message" + + ```Lua linenums="1" + local Fusion = require(game:GetService("ReplicatedStorage").Fusion) + + local function Message( + scope: Fusion.Scope, + props: { + Text: Fusion.CanBeState + } + ) + return scope:New "TextLabel" { + AutomaticSize = "XY", + BackgroundTransparency = 1, + + -- ...some properties... + + Text = props.Text + } + end + + return Message + ``` + +=== "Button" + + ```Lua linenums="1" + local Fusion = require(game:GetService("ReplicatedStorage").Fusion) + + local function Button( + scope: Fusion.Scope, + props: { + Text: Fusion.CanBeState + } + ) + return scope:New "TextButton" { + BackgroundColor3 = Color3.new(0.25, 0.5, 1), + AutoButtonColor = true, + + -- ...some properties... + + Text = props.Text + } + end + + return Button + ``` \ No newline at end of file diff --git a/docs/tutorials/best-practices/instance-handling.md b/docs/tutorials/best-practices/instance-handling.md new file mode 100644 index 000000000..b43722802 --- /dev/null +++ b/docs/tutorials/best-practices/instance-handling.md @@ -0,0 +1,187 @@ +Components are a good fit for Roblox instances. You can assemble complex groups +of instances by combining simpler, self-contained parts. + +To ensure maximum compatibility, there are a few best practices to consider. + +----- + +## Returns + +Anything you return from a component should be supported by +[`[Children]`](../roblox/parenting.md). + +```Lua +-- returns an instance +return scope:New "Frame" {} + +-- returns an array of instances +return { + scope:New "Frame" {}, + scope:New "Frame" {}, + scope:New "Frame" {} +} + +-- returns a state object containing instances +return scope:ForValues({1, 2, 3}, function(use, scope, number) + return scope:New "Frame" {} +end) + +-- mix of arrays, instances and state objects +return { + scope:New "Frame" {}, + { + scope:New "Frame" {}, + scope:ForValues( ... ) + } + scope:ForValues( ... ) +} +``` + +!!! fail "Returning multiple values is fragile" + Don't return multiple values directly from your function. When a function + returns multiple values directly, the extra returned values can easily get + lost. + + ```Lua + local function BadThing(scope, props) + -- returns *multiple* instances (not surrounded by curly braces!) + return + scope:New "Frame" {}, + scope:New "Frame" {}, + scope:New "Frame" {} + end + + local things = { + -- Luau doesn't let you add multiple returns to a list like this. + -- Only the first Frame will be added. + scope:BadThing {}, + scope:New "TextButton" {} + } + print(things) --> { Frame, TextButton } + ``` + + Instead, you should return them inside of an array. Because the array is a + single return value, it won't get lost. + +If your returns are compatible with `[Children]` like above, you can insert a +component anywhere you'd normally insert an instance. + +You can pass in one component on its own... + +```Lua hl_lines="2-4" +local ui = scope:New "ScreenGui" { + [Children] = scope:Button { + Text = "Hello, world!" + } +} +``` + +...you can include components as part of an array.. + +```Lua hl_lines="5-7 9-11" +local ui = scope:New "ScreenGui" { + [Children] = { + scope:New "UIListLayout" {}, + + scope:Button { + Text = "Hello, world!" + }, + + scope:Button { + Text = "Hello, again!" + } + } +} +``` + +...and you can return them from state objects, too. + +```Lua hl_lines="8-10" +local stuff = {"Hello", "world", "from", "Fusion"} + +local ui = scope:New "ScreenGui" { + [Children] = { + scope:New "UIListLayout" {}, + + scope:ForValues(stuff, function(use, scope, text) + return scope:Button { + Text = text + } + end) + } +} +``` + +----- + +## Containers + +Some components, for example pop-ups, might contain lots of different content: + +![Examples of pop-ups with different content.](Popups-Dark.svg#only-dark) +![Examples of pop-ups with different content.](Popups-Light.svg#only-light) + +Ideally, you would be able to reuse the pop-up 'container', while placing your +own content inside. + +![Separating the pop-up container from the pop-up contents.](Popup-Exploded-Dark.svg#only-dark) +![Separating the pop-up container from the pop-up contents.](Popup-Exploded-Light.svg#only-light) + +The simplest way to do this is to pass instances through to `[Children]`. For +example, if you accept a table of `props`, you can add a `[Children]` key: + +```Lua hl_lines="4 8" +local function PopUp( + scope: Fusion.Scope, + props: { + [typeof(Children)]: Fusion.Children + } +) + return scope:New "Frame" { + [Children] = props[Children] + } +end +``` + +!!! tip "Accepting multiple instances" + If you have multiple 'slots' where you want to pass through instances, you + can make other properties and give them the `Fusion.Children` type. + +Later on, when a pop-up is created, instances can now be parented into that +pop-up: + +```Lua +scope:PopUp { + [Children] = { + scope:Label { + Text = "New item collected" + }, + scope:ItemPreview { + Item = Items.BRICK + }, + scope:Button { + Text = "Add to inventory" + } + } +} +``` + +If you need to add other instances, you can still use arrays and state objects +as normal. You can include instances you're given, in exactly the same way you +would include any other instances. + +```Lua +scope:New "Frame" { + -- ... some other properties ... + + [Children] = { + -- the component provides some children here + scope:New "UICorner" { + CornerRadius = UDim.new(0, 8) + }, + + -- include children from outside the component here + props[Children] + } +} +``` \ No newline at end of file diff --git a/docs/tutorials/components/children/Popup-Exploded-Dark.svg b/docs/tutorials/best-practices/instance-handling/Popup-Exploded-Dark.svg similarity index 100% rename from docs/tutorials/components/children/Popup-Exploded-Dark.svg rename to docs/tutorials/best-practices/instance-handling/Popup-Exploded-Dark.svg diff --git a/docs/tutorials/components/children/Popup-Exploded-Light.svg b/docs/tutorials/best-practices/instance-handling/Popup-Exploded-Light.svg similarity index 100% rename from docs/tutorials/components/children/Popup-Exploded-Light.svg rename to docs/tutorials/best-practices/instance-handling/Popup-Exploded-Light.svg diff --git a/docs/tutorials/components/children/Popups-Dark.svg b/docs/tutorials/best-practices/instance-handling/Popups-Dark.svg similarity index 100% rename from docs/tutorials/components/children/Popups-Dark.svg rename to docs/tutorials/best-practices/instance-handling/Popups-Dark.svg diff --git a/docs/tutorials/components/children/Popups-Light.svg b/docs/tutorials/best-practices/instance-handling/Popups-Light.svg similarity index 100% rename from docs/tutorials/components/children/Popups-Light.svg rename to docs/tutorials/best-practices/instance-handling/Popups-Light.svg diff --git a/docs/tutorials/best-practices/sharing-values.md b/docs/tutorials/best-practices/sharing-values.md new file mode 100644 index 000000000..f77ec6143 --- /dev/null +++ b/docs/tutorials/best-practices/sharing-values.md @@ -0,0 +1,293 @@ +Sometimes values are used in far-away parts of the codebase. For example, many +UI elements might share theme colours for light and dark theme. + +----- + +## Globals + +Typically, values are shared by placing them in modules. These modules can be +required from anywhere in the codebase, and their values can be imported into +any code. + +Values shared in this way are known as *globals*. + +=== "Theme.luau" + + ```Lua linenums="1" + local Theme = {} + + Theme.colours = { + background = Color3.fromHex("FFFFFF"), + text = Color3.fromHex("222222"), + -- etc. + } + + return Theme + ``` + +=== "Somewhere else" + + ```Lua linenums="1" + local Theme = require("path/to/Theme.luau") + + local textColour = Theme.colours.text + print(textColour) --> 34, 34, 34 + ``` + +In particular, you can share state objects this way, and every part of the +codebase will be able to see and interact with those state objects. + +=== "Theme.luau" + + ```Lua linenums="1" + local Fusion = require("path/to/Fusion.luau") + + local Theme = {} + + Theme.colours = { + background = { + light = Color3.fromHex("FFFFFF"), + dark = Color3.fromHex("222222") + }, + text = { + light = Color3.fromHex("FFFFFF"), + dark = Color3.fromHex("222222") + }, + -- etc. + } + + function Theme.init( + scope: Fusion.Scope + ) + Theme.currentTheme = scope:Value("light") + end + + return Theme + ``` + +=== "Somewhere else" + + ```Lua linenums="1" + local Fusion = require("path/to/Fusion.luau") + local scoped, peek = Fusion.scoped, Fusion.peek + + local Theme = require("path/to/Theme.luau") + + local function printTheme() + local theme = Theme.currentTheme + print( + peek(theme), + if typeof(theme) == "string" then "constant" else "state object" + ) + end + + local scope = scoped(Fusion) + Theme.init(scope) + printTheme() --> light state object + + Theme.currentTheme:set("dark") + printTheme() --> dark state object + ``` + +Globals are very straightforward to implement and can be useful, but they can +quickly cause problems if not used carefully. + +### Hidden dependencies + +When you use a global inside a block of reusable code such as a component, you +are making your code dependent on another code file without declaring it to the +outside world. + +To some extent, this is entirely why using globals is desirable. While it's more +'correct' to accept the `Theme` via the parameters of your function, it often +means the `Theme` has to be passed down through multiple layers of functions. +This is known as [prop drilling](https://kentcdodds.com/blog/prop-drilling) and +is widely considered bad practice, because it clutters up unrelated code with +parameters that are only passed through functions. + +To avoid prop drilling, globals are often used, which 'hides' the dependency on +that external code file. You no longer have to pass it down through parameters. +However, relying too heavily on these hidden dependencies can cause your code to +behave in surprising, unintuitive ways, or it can obscure what functionality is +available to developers using your code. + +### Hard-to-locate writes + +If you write into globals from deep inside your code base, it becomes very hard +to figure out where the global is being changed from, which significantly hurts +debugging. + +Generally, it's best to treat globals as *read-only*. If you're writing to a +global, it should be coming from a single well-signposted, easy-to-find place. + +You should also keep the principles of [top-down control](../callbacks) in mind; +think of globals as 'flowing down' from the root of the program. Globals are +best managed from high up in the program, because they have widespread effects, +so consider using callbacks to pass control up the chain, rather than managing +globals directly from every part of the code base. + +### Memory management + +In addition, globals can complicate memory management. Because every part of +your code base can access them, you can't destroy globals until the very end of +your program. + +In the above example, this is solved with an `init()` method which passes the +main scope to `Theme`. Because `init()` is called before anything else that uses +`Theme`, the objects that `Theme` creates will be added to the scope first. + +When the main scope is cleaned up, `doCleanup()` will destroy things in reverse +order. This means the `Theme` objects will be cleaned up last, after everything +else in the program has been cleaned up. + +This only works if you know that the main script is the only entry point in your +program. If you have two scripts running concurrently which try to `init()` the +`Theme` module, they will overwrite each other. + +### Non-replaceable for testing + +When your code uses a global, you're hard-coding a connection between your code +and that specific global. + +This is problematic for testing; unless you're using an advanced testing +framework with code injection, it's pretty much impossible to separate your code +from that global code, which makes it impossible to replace global values for +testing purposes. + +For example, if you wanted to write automated tests that verify light theme and +dark theme are correctly applied throughout your UI, you can't replace any +values stored in `Theme`. + +You might be able to write to the `Theme` by going through the normal process, +but this fundamentally limits how you can test. For example, you couldn't run a +test for light theme and dark theme at the same time. + +----- + +## Contextuals + +The main drawback of globals is that they hold one value for all code. To solve +this, Fusion introduces *contextual values*, which can be temporarily changed +for the duration of a code block. + +To create a contextual, call the `Contextual` function from Fusion. It asks for +a default value. + +```Lua +local myContextual = Contextual("foo") +``` + +At any time, you can query its current value using the `:now()` method. + +```Lua +local myContextual = Contextual("foo") +print(myContextual:now()) --> foo +``` + +You can override the value for a limited span of time using `:is():during()`. +Pass the temporary value to `:is()`, and pass a callback to `:during()`. + +While the callback is running, the contextual will adopt the temporary value. + +```Lua +local myContextual = Contextual("foo") +print(myContextual:now()) --> foo + +myContextual:is("bar"):during(function() + print(myContextual:now()) --> bar +end) + +print(myContextual:now()) --> foo +``` + +By storing widely-used values inside contextuals, you can isolate different +code paths from each other, while retaining the easy, hidden referencing that +globals offer. This makes testing and memory management significantly easier, +and helps you locate which code is modifying any shared values. + +To demonstrate, the `Theme` example can be rewritten to use contextuals. + +=== "Theme.luau" + + ```Lua linenums="1" + local Fusion = require("path/to/Fusion.luau") + local Contextual = Fusion.Contextual + + local Theme = {} + + Theme.colours = { + background = { + light = Color3.fromHex("FFFFFF"), + dark = Color3.fromHex("222222") + }, + text = { + light = Color3.fromHex("FFFFFF"), + dark = Color3.fromHex("222222") + }, + -- etc. + } + + Theme.currentTheme = Contextual("light") + + return Theme + ``` + +=== "Somewhere else" + + ```Lua linenums="1" + local Fusion = require("path/to/Fusion.luau") + local scoped, peek = Fusion.scoped, Fusion.peek + + local Theme = require("path/to/Theme.luau") + + local function printTheme() + local theme = Theme.currentTheme:now() + print( + peek(theme), + if typeof(theme) == "string" then "constant" else "state object" + ) + end + + printTheme() --> light constant + + local scope = scoped(Fusion) + local override = scope:Value("light") + Theme.currentTheme:is(override):during(function() + printTheme() --> light state object + override:set("dark") + printTheme() --> dark state object + end) + + printTheme() --> light constant + ``` + +In this rewritten example, `Theme` no longer requires an `init()` function, +because - instead of defining a state object globally - `Theme` only defines +`"light"` as the default value. + +You're expected to replace the default value with a state object when you want +to make the theme dynamic. This has a number of benefits: + +- Because the override is time-limited to one span of your code, you can have +multiple scripts running at the same time with completely different overrides. + +- It also explicitly places your code in charge of memory management, because +you're creating the object yourself. + +- It's easy to locate where changes are coming from, because you can look for +the nearest `:is():during()` call. Optionally, you could share a limited, +read-only version of the value, while retaining private access to write to the +value wherever you're overriding the contextual from. + +- Testing becomes much simpler; you can override the contextual for different +parts of your testing, without ever having to inject code, and without altering +how you read and override the contextual in your production code. + +It's still possible to run into issues with contextuals, though. + +- You're still hiding a dependency of your code, which can still lead to +confusion and obscuring available features, just the same as globals. +- Unlike globals, contextuals are time-limited. If you connect to an event or +start a delayed task, you won't be able to access the value anymore. Instead, +capture the value at the start of the code block, so you can use it in delayed +tasks. \ No newline at end of file diff --git a/docs/tutorials/best-practices/state.md b/docs/tutorials/best-practices/state.md new file mode 100644 index 000000000..694dee4d3 --- /dev/null +++ b/docs/tutorials/best-practices/state.md @@ -0,0 +1,215 @@ +Components can hold their own data privately using state objects. This can be +useful, but you should be careful when adding state. + +----- + +## Creation + +You can create state objects inside components as you would anywhere else. + +```Lua hl_lines="10 13-15" +local HOVER_COLOUR = Color3.new(0.5, 0.75, 1) +local REST_COLOUR = Color3.new(0.25, 0.5, 1) + +local function Button( + scope: Fusion.Scope, + props: { + -- ... some properties ... + } +) + local isHovering = scope:Value(false) + + return scope:New "TextButton" { + BackgroundColor3 = scope:Computed(function(use) + return if use(isHovering) then HOVER_COLOUR else REST_COLOUR + end), + + -- ... ... some more code ... + } +end +``` + +Because these state objects are made with the same `scope` as the rest of the +component, they're destroyed alongside the rest of the component. + +----- + +## Top-Down Control + +Remember that Fusion mainly works with a top-down flow of control. It's a good +idea to keep that in mind when adding state to components. + +When you're making reusable components, it's more flexible if your component can +be controlled externally. Components that control themselves entirely are hard +to use and customise. + +Consider the example of a check box. Each check box often reflects a state +object under the hood: + +![Showing check boxes connected to Value objects.](Check-Boxes-Dark.svg#only-dark) +![Showing check boxes connected to Value objects.](Check-Boxes-Light.svg#only-light) + +It might *seem* logical to store the state object inside the check box, but +this causes a few problems: + +- because the state is hidden, it's awkward to read and write from outside +- often, the user already has a state object representing the same setting, so +now there's two state objects where one would have sufficed + +```Lua hl_lines="7" +local function CheckBox( + scope: Fusion.Scope, + props: { + -- ... some properties ... + } +) + local isChecked = scope:Value(false) -- problematic + + return scope:New "ImageButton" { + [OnEvent "Activated"] = function() + isChecked:set(not peek(isChecked)) + end, + + -- ... some more code ... + } +end +``` + +A *slightly better* solution is to pass the state object in. This ensures the +controlling code has easy access to the state if it needs it. However, this is +not a complete solution: + +- the user is forced to store the state in a `Value` object, but they might be +computing the value dynamically with other state objects instead +- the behaviour of clicking the check box is hardcoded; the user cannot +intercept the click or toggle a different state + +```Lua hl_lines="4" +local function CheckBox( + scope: Fusion.Scope, + props: { + IsChecked: Fusion.Value -- slightly better + } +) + return scope:New "ImageButton" { + [OnEvent "Activated"] = function() + props.IsChecked:set(not peek(props.IsChecked)) + end, + + -- ... some more code ... + } +end +``` + +That's why the *best* solution is to use `CanBeState` to create read-only +properties, and add callbacks for signalling actions and events. + +- because `CanBeState` is read-only, it lets the user plug in any data +source, including dynamic computations +- because the callback is provided by the user, the behaviour of clicking the +check box is completely customisable + +```Lua hl_lines="4-5 10" +local function CheckBox( + scope: Fusion.Scope, + props: { + IsChecked: Fusion.CanBeState, -- best + OnClick: () -> () + } +) + return scope:New "ImageButton" { + [OnEvent "Activated"] = function() + props.OnClick() + end, + + -- ... some more code ... + } +end +``` + +The control is always top-down here; the check box's appearance is fully +controlled by the creator. The creator of the check box *decides* to switch the +setting when the check box is clicked. + +### In Practice + +Setting up your components in this way makes extending their behaviour +incredibly straightforward. + +Consider a scenario where you wish to group multiple options under a 'main' +check box, so you can turn them all on/off at once. + +![Showing check boxes connected to Value objects.](Master-Check-Box-Dark.svg#only-dark) +![Showing check boxes connected to Value objects.](Master-Check-Box-Light.svg#only-light) + +The appearance of that check box would not be controlled by a single state, but +instead reflects the combination of multiple states. Because the code uses +`CanBeState`, you can represent this with a `Computed` object. + +```Lua hl_lines="7-18" +local playMusic = scope:Value(true) +local playSFX = scope:Value(false) +local playNarration = scope:Value(true) + +local checkBox = scope:CheckBox { + Text = "Play sounds", + IsChecked = scope:Computed(function(use) + local anyChecked = use(playMusic) or use(playSFX) or use(playNarration) + local allChecked = use(playMusic) and use(playSFX) and use(playNarration) + + if not anyChecked then + return "unchecked" + elseif not allChecked then + return "partially-checked" + else + return "checked" + end + end) +} +``` + +You can then implement the 'check all'/'uncheck all' behaviour inside `OnClick`: + +```Lua hl_lines="7-13" +local playMusic = scope:Value(true) +local playSFX = scope:Value(false) +local playNarration = scope:Value(true) + +local checkBox = scope:CheckBox { + -- ... same properties as before ... + OnClick = function() + local allChecked = peek(playMusic) and peek(playSFX) and peek(playNarration) + + playMusic:set(not allChecked) + playSFX:set(not allChecked) + playNarration:set(not allChecked) + end +} +``` + +Because the check box was written to be flexible, it can handle complex usage +easily. + +----- + +## Best Practices + +Those examples lead us to the golden rule of reusable components: + +!!! tip "Golden Rule" + Reusable components should *reflect* program state. They should + not *control* program state. + +At the bottom of the chain of control, components shouldn't be massively +responsible. At these levels, reflective components are easier to work with. + +As you go up the chain of control, components get broader in scope and less +reusable; those places are often suitable for controlling components. + +A well-balanced codebase places controlling components at key, strategic +locations. They allow higher-up components to operate without special knowledge +about what goes on below. + +At first, this might be difficult to do well, but with experience you'll have a +better intuition for it. Remember that you can always rewrite your code if it +becomes a problem! \ No newline at end of file diff --git a/docs/tutorials/components/state/Check-Boxes-Dark.svg b/docs/tutorials/best-practices/state/Check-Boxes-Dark.svg similarity index 100% rename from docs/tutorials/components/state/Check-Boxes-Dark.svg rename to docs/tutorials/best-practices/state/Check-Boxes-Dark.svg diff --git a/docs/tutorials/components/state/Check-Boxes-Light.svg b/docs/tutorials/best-practices/state/Check-Boxes-Light.svg similarity index 100% rename from docs/tutorials/components/state/Check-Boxes-Light.svg rename to docs/tutorials/best-practices/state/Check-Boxes-Light.svg diff --git a/docs/tutorials/components/state/Master-Check-Box-Dark.svg b/docs/tutorials/best-practices/state/Master-Check-Box-Dark.svg similarity index 100% rename from docs/tutorials/components/state/Master-Check-Box-Dark.svg rename to docs/tutorials/best-practices/state/Master-Check-Box-Dark.svg diff --git a/docs/tutorials/components/state/Master-Check-Box-Light.svg b/docs/tutorials/best-practices/state/Master-Check-Box-Light.svg similarity index 100% rename from docs/tutorials/components/state/Master-Check-Box-Light.svg rename to docs/tutorials/best-practices/state/Master-Check-Box-Light.svg diff --git a/docs/tutorials/components/children.md b/docs/tutorials/components/children.md deleted file mode 100644 index 82d0501c3..000000000 --- a/docs/tutorials/components/children.md +++ /dev/null @@ -1,194 +0,0 @@ -Using components, you can assemble more complex instance hierarchies by -combining simpler, self-contained parts. To do that, you should pay attention to -how instances are passed between components. - ------ - -## Returning Children - -Components return a child when you call them. That means anything you return -from a component should be supported by `[Children]`. - -That means you can return *one* (and only one): - -- instance -- array of children -- or state object containing a child - -This should be familiar from parenting instances using `[Children]`. To recap: - -!!! success "Allowed" - You can return one value per component. - ```Lua - -- returns *one* instance - local function Component() - return New "Frame" {} - end - ``` - ```Lua - -- returns *one* array - local function Component() - return { - New "Frame" {}, - New "Frame" {}, - New "Frame" {} - } - end - ``` - ```Lua - -- returns *one* state object - local function Component() - return ForValues({1, 2, 3}, function(use, number) - return New "Frame" {} - end) - end - ``` -!!! success "Allowed" - Inside arrays or state objects, you can mix and match different children. - ```Lua - -- mix of arrays, instances and state objects - local function Component() - return { - New "Frame" {}, - { - New "Frame" {}, - ForValues( ... ) - } - ForValues( ... ) - } - end - ``` -!!! fail "Not allowed" - Don't return multiple values straight from your function. Prefer to use an - array instead. - ```Lua - -- returns *multiple* instances (not surrounded by curly braces!) - local function Component() - return - New "Frame" {}, - New "Frame" {}, - New "Frame" {} - end - ``` - Luau does not support multiple return values consistently. They can get lost - easily if you're not careful. - ------ - -## Parenting Components - -Components return the same values which `[Children]` uses. That means they're -directly compatible, and you can insert a component anywhere you'd normally -insert an instance. - -You can pass in one component on its own... - -```Lua -local ui = New "ScreenGui" { - [Children] = Button { - Text = "Hello, world!" - } -} -``` - -...you can include components as part of an array.. - -```Lua -local ui = New "ScreenGui" { - [Children] = { - New "UIListLayout" {}, - Button { - Text = "Hello, world!" - }, - Button { - Text = "Hello, again!" - } - } -} -``` - -...and you can return them from state objects, too. - -```Lua -local ui = New "ScreenGui" { - [Children] = { - New "UIListLayout" {}, - - ForValues({"Hello", "world", "from", "Fusion"}, function(use, text) - return Button { - Text = text - } - end) - } -} -``` - ------ - -## Accepting Children - -Some components, for example pop-ups, might contain lots of different content: - -![Examples of pop-ups with different content.](Popups-Dark.svg#only-dark) -![Examples of pop-ups with different content.](Popups-Light.svg#only-light) - -Ideally, you would be able to reuse the pop-up 'container', while placing your -own content inside. - -![Separating the pop-up container from the pop-up contents.](Popup-Exploded-Dark.svg#only-dark) -![Separating the pop-up container from the pop-up contents.](Popup-Exploded-Light.svg#only-light) - -The simplest way to do this is to pass children through to `[Children]`. For -example, if you accept a table of `props`, you can add a `[Children]` key: - -```Lua hl_lines="7" -local function PopUp(props) - return New "Frame" { - -- ... some other properties ... - - -- Since `props` is a table, and `[Children]` is a key, you can use it - -- yourself as a key in `props`: - [Children] = props[Children] - } -end -``` - -Later on, when a pop-up is created, children can now be parented into that -instance: - -```Lua -local popUp = PopUp { - [Children] = { - Label { - Text = "New item collected" - }, - ItemPreview { - Item = Items.BRICK - }, - Button { - Text = "Add to inventory" - } - } -} -``` - -You're not limited to passing it straight into `[Children]`. If you need to add -other children, you can still use arrays and state objects as normal: - -```Lua hl_lines="5-13" -local function PopUp(props) - return New "Frame" { - -- ... some other properties ... - - [Children] = { - -- the component provides some children here - New "UICorner" { - CornerRadius = UDim.new(0, 8) - }, - - -- include children from outside the component here - props[Children] - } - } -end -``` \ No newline at end of file diff --git a/docs/tutorials/components/reusing-ui.md b/docs/tutorials/components/reusing-ui.md deleted file mode 100644 index e44d15810..000000000 --- a/docs/tutorials/components/reusing-ui.md +++ /dev/null @@ -1,151 +0,0 @@ -Up until this point, you have been creating and parenting instances directly -without much organisation or code reuse. However, those two factors will become -increasingly important as you start building more game-ready UIs. - -These next few pages won't introduce new features of Fusion, but instead will -focus on techniques for making your UI more modular, portable and easy to -maintain. - ------ - -## Components - -One of the greatest advantages of libraries like Fusion is that UI and code are -the same thing. Any tool you can use on one, you can use on the other. - -To reduce repetition in your codebases, you'll often use functions to run small -reusable blocks of code, sometimes with parameters you can change. You can use -functions to organise your UI code, too. - -For example, consider this function, which generates a button based on some -`props` the user passes in: - -```Lua -local function Button(props) - return New "TextButton" { - BackgroundColor3 = Color3.new(0, 0.25, 1), - Position = props.Position, - AnchorPoint = props.AnchorPoint, - Size = props.Size, - LayoutOrder = props.LayoutOrder, - - Text = props.ButtonText, - TextSize = 28, - TextColor3 = Color3.new(1, 1, 1), - - [Children] = UICorner { CornerRadius = UDim2.new(0, 8) } - } -end -``` - -You can call this function later to generate as many buttons as you need: - -```Lua --- this is just a regular Lua function call! -local helloBtn = Button { - ButtonText = "Hello", - Size = UDim2.fromOffset(200, 50) -} - -helloBtn.Parent = Players.LocalPlayer.PlayerGui.ScreenGui -``` - -This is the primary way UI is reused in Fusion. These kinds of functions are -common enough that they have a special name: **components**. Specifically, -components are functions which return a child. - -In the above example, `Button` is a component, because it's a function that -returns a TextButton. - ------ - -## Modules - -It's common to save different components inside of different ModuleScripts. -There's a number of advantages to this: - -- it's easier to find the source code for a specific component -- it keep each script shorter and simpler -- it makes sure components are properly independent, and can't interfere -- it encourages reusing components everywhere, not just in one script - -Here's an example of how you could split up some components into modules: - -=== "Main script" - - ```Lua linenums="1" - local PopUp = require(script.Parent.PopUp) - - local ui = New "ScreenGui" { - -- ...some properties... - - [Children] = PopUp { - Message = "Hello, world!", - DismissText = "Close" - } - } - ``` - -=== "PopUp.lua" - - ```Lua linenums="1" - local Message = require(script.Parent.Message) - local Button = require(script.Parent.Button) - - local function PopUp(props) - return New "Frame" { - -- ...some properties... - - [Children] = { - Message { - Text = props.Message - } - Button { - Text = props.DismissText - } - } - } - end - - return PopUp - ``` - -=== "Message.lua" - - ```Lua linenums="1" - local function Message(props) - return New "TextLabel" { - AutomaticSize = "XY", - BackgroundTransparency = 1, - - -- ...some properties... - - Text = props.Text - } - end - - return Message - ``` - -=== "Button.lua" - - ```Lua linenums="1" - local function Button(props) - return New "TextButton" { - BackgroundColor3 = Color3.new(0.25, 0.5, 1), - AutoButtonColor = true, - - -- ...some properties... - - Text = props.Text - } - end - - return Button - ``` - -You can further group your modules using folders if you need more organisation. - -It might be scary at first to see a large list of modules, but because you can -browse visually by names and folders, it's almost always better than having one -long script. \ No newline at end of file diff --git a/docs/tutorials/components/state.md b/docs/tutorials/components/state.md deleted file mode 100644 index 83518d451..000000000 --- a/docs/tutorials/components/state.md +++ /dev/null @@ -1,172 +0,0 @@ -Components can hold their own data privately using state objects. This can be -useful, but you should be careful when adding state. - ------ - -## Creating State Objects - -Inside a component, state objects can be created and used the same way as usual: - -```Lua hl_lines="5 8-10 13 17" -local HOVER_COLOUR = Color3.new(0.5, 0.75, 1) -local REST_COLOUR = Color3.new(0.25, 0.5, 1) - -local function Button(props) - local isHovering = Value(false) - - return New "TextButton" { - BackgroundColor3 = Computed(function(use) - return if use(isHovering) then HOVER_COLOUR else REST_COLOUR - end), - - [OnEvent "MouseEnter"] = function() - isHovering:set(true) - end, - - [OnEvent "MouseLeave"] = function() - isHovering:set(false) - end, - - -- ... some properties ... - } -end -``` - -Like regular Luau, state objects stay around as long as they're being used. Once -your component is destroyed and your code no longer uses the objects, they'll be -cleaned up. - ------ - -## Top-Down Control - -Remember that Fusion mainly works with a top-down flow of control. It's a good -idea to keep that in mind when adding state to components. - -When you're making reusable components, it's more flexible if your component can -be controlled externally. Components that control themselves entirely are hard -to use and customise. - -Consider the example of a check box. Each check box often reflects a state -object under the hood: - -![Showing check boxes connected to Value objects.](Check-Boxes-Dark.svg#only-dark) -![Showing check boxes connected to Value objects.](Check-Boxes-Light.svg#only-light) - -It might *seem* logical to store the state object inside the check box: - -```Lua hl_lines="2" -local function CheckBox(props) - local isChecked = Value(false) - - return New "ImageButton" { - -- ... some properties ... - } -end -``` - -However, hiding away important state in components causes a few problems: - -- to control the appearance of the check box, you're forced to change the -internal state -- clicking the check box has hard-coded behaviour, which is bad if you need to -intercept the click (e.g. to show a confirmation dialogue) -- if you already had a state object for that setting, now the check box has a -duplicate state object representing the same setting - -Therefore, it's better for the controlling code to hold the state object, and -use callbacks to switch the value when the check box is clicked: - -```Lua -local playMusic = Value(true) - -local checkBox = CheckBox { - Text = "Play music", - IsChecked = playMusic, - OnClick = function() - playMusic:set(not peek(playMusic)) - end -} -``` - -The control is always top-down here; the check box's appearance is fully -controlled by the creator. The creator of the check box *decides* to switch the -setting when the check box is clicked. - -The check box itself is an inert, visual element; it just shows a graphic and -reports clicks. - ------ - -Setting up the check box this way also allows for more complex behaviour later -on. Suppose we wanted to group together multiple options under a 'main' check -box, so you can turn them all on/off at once. - -![Showing check boxes connected to Value objects.](Master-Check-Box-Dark.svg#only-dark) -![Showing check boxes connected to Value objects.](Master-Check-Box-Light.svg#only-light) - -The appearance of that check box would not be controlled by a single state, but -instead reflects the combination of multiple states. We can use a `Computed` -for that: - -```Lua hl_lines="7-18" -local playMusic = Value(true) -local playSFX = Value(false) -local playNarration = Value(true) - -local checkBox = CheckBox { - Text = "Play sounds", - Appearance = Computed(function(use) - local anyChecked = use(playMusic) or use(playSFX) or use(playNarration) - local allChecked = use(playMusic) and use(playSFX) and use(playNarration) - - if not anyChecked then - return "unchecked" - elseif not allChecked then - return "partially-checked" - else - return "checked" - end - end) -} -``` - -We can then implement the 'check all'/'uncheck all' behaviour inside `OnClick`: - -```Lua hl_lines="7-13" -local playMusic = Value(true) -local playSFX = Value(false) -local playNarration = Value(true) - -local checkBox = CheckBox { - -- ... same properties as before ... - OnClick = function() - local allChecked = peek(playMusic) and peek(playSFX) and peek(playNarration) - - playMusic:set(not allChecked) - playSFX:set(not allChecked) - playNarration:set(not allChecked) - end -} -``` - -By keeping the check box 'stateless', we can make it behave much more flexibly. - ------ - -## Best Practices - -Those examples lead us into the golden rule when adding state to components. - -!!! tip "Golden Rule" - It's better for reusable components to *reflect* program state. They should - not usually *contain* program state. - -State objects are best suited to self-contained use cases, such as implementing -hover effects, animations or responsive design. As such, you should think about -whether you really need to add state to components, or whether it's better to -add it higher up. - -At first, this might be difficult to do well, but with experience you'll have a -better intuition for it. Remember that you can always rewrite your code if it -becomes a problem! \ No newline at end of file diff --git a/docs/tutorials/fundamentals/computeds.md b/docs/tutorials/fundamentals/computeds.md index e7bfc4da2..a415ce15f 100644 --- a/docs/tutorials/fundamentals/computeds.md +++ b/docs/tutorials/fundamentals/computeds.md @@ -1,12 +1,14 @@ -Computeds are state objects that can process values from other state objects. +Computeds are state objects that immediately process values from other state +objects. + You pass in a callback to define a calculation. Then, you can use `peek()` to read the result of the calculation at any time. ```Lua -local numCoins = Value(50) -local itemPrice = Value(10) +local numCoins = scope:Value(50) +local itemPrice = scope:Value(10) -local finalCoins = Computed(function(use) +local finalCoins = scope:Computed(function(use, scope) return use(numCoins) - use(itemPrice) end) @@ -21,19 +23,13 @@ print(peek(finalCoins)) --> 10 ## Usage -To use `Computed` in your code, you first need to import it from the Fusion -module, so that you can refer to it by name: - -```Lua linenums="1" hl_lines="2" -local Fusion = require(ReplicatedStorage.Fusion) -local Computed = Fusion.Computed -``` +To create a new computed object, call `scope:Computed()` and give it a function +that performs your calculation. It takes two parameters which will be explained +later; for the first part of this tutorial, they'll be left unnamed. -To create a new computed object, call the `Computed` function. You need to give -it a callback representing the calculation - for now, we'll add two numbers: - -```Lua -local hardMaths = Computed(function(use) +```Lua linenums="6" hl_lines="2-4" +local scope = scoped(Fusion) +local hardMaths = scope:Computed(function(_, _) return 1 + 1 end) ``` @@ -41,20 +37,33 @@ end) The value the callback returns will be stored as the computed's value. You can get the computed's current value using `peek()`: -```Lua +```Lua linenums="6" hl_lines="6" +local scope = scoped(Fusion) +local hardMaths = scope:Computed(function(_, _) + return 1 + 1 +end) + print(peek(hardMaths)) --> 2 ``` -The calculation is only run once by default. If you try and use `peek()` inside -the calculation, your code won't work: +The calculation should be *immediate* - that is, it should never delay. That +means you should not use computed objects when you need to wait for something to +occur (e.g. waiting for a server to respond to a request). -```Lua -local number = Value(2) -local double = Computed(function(use) +----- + +## Using State Objects + +The calculation is only run once by default. If you try to `peek()` at state +objects inside the calculation, your code breaks quickly: + +```Lua linenums="6" hl_lines="10-11" +local scope = scoped(Fusion) +local number = scope:Value(2) +local double = scope:Computed(function(_, _) return peek(number) * 2 end) --- The calculation runs once by default. print(peek(number), peek(double)) --> 2 4 -- The calculation won't re-run! Oh no! @@ -62,12 +71,14 @@ number:set(10) print(peek(number), peek(double)) --> 10 4 ``` -This is where the `use` parameter comes in (see line 2 above). If you want your -calculation to re-run when your objects change value, pass the object to `use()`: +Instead, the computed object provides a `use` function as the first argument. +As your logic runs, you can call this function with different state objects. If +any of them changes, then the computed throws everything away and recalculates. -```Lua -local number = Value(2) -local double = Computed(function(use) +```Lua linenums="6" hl_lines="4" +local scope = scoped(Fusion) +local number = scope:Value(2) +local double = scope:Computed(function(use, _) use(number) -- the calculation will re-run when `number` changes value return peek(number) * 2 end) @@ -80,156 +91,175 @@ print(peek(number), peek(double)) --> 10 20 ``` For convenience, `use()` will also read the value, just like `peek()`, so you -can easily replace `peek()` calls with `use()` calls: - -```Lua -local number = Value(2) -local double = Computed(function(use) - return use(number) * 2 -- works identically to before +can easily replace `peek()` calls with `use()` calls. This keeps your logic +concise, readable and easily copyable. + +```Lua linenums="6" hl_lines="4" +local scope = scoped(Fusion) +local number = scope:Value(2) +local double = scope:Computed(function(use, _) + return use(number) * 2 end) -``` ------ +print(peek(number), peek(double)) --> 2 4 -## When To Use This +number:set(10) +print(peek(number), peek(double)) --> 10 20 +``` -Computeds are more specialist than regular values and observers. They're -designed for a single purpose: they make it easier and more efficient to derive -new values from existing state objects. +It's recommended you always give the first parameter the name `use`, even if it +already exists. This helps prevent you from using the wrong parameter if you +have multiple computed objects at the same time. -Derived values show up in a lot of places throughout UIs. For example, you might -want to insert a death counter into a string. Therefore, the contents of the -string are derived from the death counter: +```Lua +scope:Computed(function(use, _) + -- ... + scope:Computed(function(use, _) + -- ... + scope:Computed(function(use, _) + return use(number) * 2 + end) + -- ... + end) + -- ... +end) +``` -![Diagram showing how the message depends on the death counter.](Derived-Value-Dark.svg#only-dark) -![Diagram showing how the message depends on the death counter.](Derived-Value-Light.svg#only-light) +??? warning "Help! Using the same name gives me a warning." -While you can do this with values and observers alone, your code could get messy. + Depending on your setup, Luau might be configured to warn when you use the + same variable name multiple times. -Consider the following code that doesn't use computeds - the intent is to create -a derived value, `finalCoins`, which equals `numCoins - itemPrice` at all times: + In many cases, using the same variable name can be a mistake, but in this + case we actually find it useful. So, to turn off the warning, try adding + `--!nolint LocalShadow` to the top of your file. -```Lua linenums="1" -local numCoins = Value(50) -local itemPrice = Value(10) +Keep in mind that Fusion sometimes applies optimisations; recalculations might +be postponed or cancelled if the value of the computed isn't being used. This is +why you should not use computed objects for things like playing sound effects. -local finalCoins = Value(peek(numCoins) - peek(itemPrice)) -local function updateFinalCoins() - finalCoins:set(peek(numCoins) - peek(itemPrice)) -end -Observer(numCoins):onChange(updateFinalCoins) -Observer(itemPrice):onChange(updateFinalCoins) -``` +----- -There are a few problems with this code currently: +## Inner Scopes + +Sometimes, you'll need to create things inside computed objects temporarily. In +these cases, you want the temporary things to be destroyed when you're done. + +You might try and reuse the scope you already have, to construct objects and +add cleanup tasks. + +=== "Luau code" + + ```Lua linenums="6" hl_lines="7" + local scope = scoped(Fusion) + local number = scope:Value(5) + local double = scope:Computed(function(use, _) + local current = use(number) + print("Creating", current) + -- suppose we want to run some cleanup code for stuff in here + table.insert(scope, function() + print("Destroying", current) + end) + return current * 2 + end) + + print("...setting to 25...") + number:set(25) + print("...setting to 2...") + number:set(2) + print("...cleaning up...") + doCleanup(scope) + ``` + +=== "Output" + + ``` + Creating 5 + ...setting to 25... + Creating 25 + ...setting to 2... + Creating 2 + ...cleaning up... + Destroying 2 + Destroying 25 + Destroying 5 + ``` + +However, this doesn't work the way you'd want it to. All of the tasks pile up at +the end of the program, instead of being thrown away with the rest of the +calculation. + +That's why the second argument is a different scope for you to use while inside +the computed object. This scope argument is automatically cleaned up for you +when the computed object recalculates. + +=== "Luau code" + + ```Lua linenums="6" hl_lines="3 6" + local scope = scoped(Fusion) + local number = scope:Value(5) + local double = scope:Computed(function(use, myBrandNewScope) + local current = use(number) + print("Creating", current) + table.insert(myBrandNewScope, function() + print("Destroying", current) + end) + return current * 2 + end) + + print("...setting to 25...") + number:set(25) + print("...setting to 2...") + number:set(2) + print("...cleaning up...") + doCleanup(scope) + ``` + +=== "Output" + + ``` + Creating 5 + ...setting to 25... + Creating 25 + Destroying 5 + ...setting to 2... + Creating 2 + Destroying 25 + ...cleaning up... + Destroying 2 + ``` + +When using this new 'inner' scope, the tasks no longer pile up at the end of the +program. Instead, they're cleaned up as soon as possible, when the computed +object throws away the old calculation. + +It can help to give this parameter the same name as the original scope. This +stops you from accidentally using the original scope inside the computed, and +makes your code more easily copyable and movable. -- It's not immediately clear what's happening at a glance; there's lots of -boilerplate code obscuring what the *intent* of the code is. -- The logic for calculating `finalCoins` is duplicated twice - on lines 4 and 6. -- You have to manage updating the value yourself using observers. This is an -easy place for desynchronisation bugs to slip in. -- Another part of the code base could call `finalCoins:set()` and mess with the -value. +```Lua +local scope = scoped(Fusion) +scope:Computed(function(use, scope) + -- ... + scope:Computed(function(use, scope) + -- ... + scope:Computed(function(use, scope) + local innerValue = scope:Value(5) + end) + -- ... + end) + -- ... +end) +``` -When written with computeds, the above problems are largely solved: +??? warning "Help! Using the same name gives me a warning." -```Lua linenums="1" -local numCoins = Value(50) -local itemPrice = Value(10) + Depending on your setup, Luau might be configured to warn when you use the + same variable name multiple times. -local finalCoins = Computed(function(use) - return use(numCoins) - use(itemPrice) -end) -``` + In many cases, using the same variable name can be a mistake, but in this + case we actually find it useful. So, to turn off the warning, try adding + `--!nolint LocalShadow` to the top of your file. -- The intent is immediately clear - this is a derived value. -- The logic is only specified once, in one callback. -- The computed updates itself when a state object you `use()` changes value. -- The callback is the only thing that can change the value - there is no `:set()` -method. - -??? warning "A warning about delays in computed callbacks" - - One small caveat of computeds is that you must return the value immediately. - If you need to send a request to the server or perform a long-running - calculation, you shouldn't use computeds. - - The reason for this is consistency between variables. When all computeds run - immediately (i.e. without yielding), all of your variables will behave - consistently: - - ```Lua - local numCoins = Value(50) - local isEnoughCoins = Computed(function(use) - return use(numCoins) > 25 - end) - - local message = Computed(function(use) - if use(isEnoughCoins) then - return use(numCoins) .. " is enough coins." - else - return use(numCoins) .. " is NOT enough coins." - end - end) - - print(peek(message)) --> 50 is enough coins. - numCoins:set(2) - print(peek(message)) --> 2 is NOT enough coins. - ``` - - If a delay is introduced, then inconsistencies and nonsense values could - quickly appear: - - ```Lua hl_lines="3 17" - local numCoins = Value(50) - local isEnoughCoins = Computed(function(use) - task.wait(5) -- Don't do this! This is just for the example - return use(numCoins) > 25 - end) - - local message = Computed(function(use) - if use(isEnoughCoins) then - return use(numCoins) .. " is enough coins." - else - return use(numCoins) .. " is NOT enough coins." - end - end) - - print(peek(message)) --> 50 is enough coins. - numCoins:set(2) - print(peek(message)) --> 2 is enough coins. - ``` - - For this reason, yielding in computed callbacks is disallowed. - - If you have to introduce a delay, for example when invoking a - RemoteFunction, consider using values and observers. - - ```Lua hl_lines="3-10 13-14 24-26" - local numCoins = Value(50) - - local isEnoughCoins = Value(nil) - local function updateIsEnoughCoins() - isEnoughCoins:set(nil) -- indicate that we're calculating the value - task.wait(5) -- this is now ok - isEnoughCoins:set(peek(numCoins) > 25) - end - task.spawn(updateIsEnoughCoins) - Observer(numCoins):onChange(updateIsEnoughCoins) - - local message = Computed(function() - if peek(isEnoughCoins) == nil then - return "Loading..." - elseif peek(isEnoughCoins) then - return peek(numCoins) .. " is enough coins." - else - return peek(numCoins) .. " is NOT enough coins." - end - end) - - print(peek(message)) --> 50 is enough coins. - numCoins:set(2) - print(peek(message)) --> Loading... - task.wait(5) - print(peek(message)) --> 2 is NOT enough coins. - ``` \ No newline at end of file +Once you understand computeds, as well as the previously discussed scopes, +values and observers, you're well positioned to explore the rest of Fusion. \ No newline at end of file diff --git a/docs/tutorials/fundamentals/computeds/Derived-Value-Dark.svg b/docs/tutorials/fundamentals/computeds/Derived-Value-Dark.svg deleted file mode 100644 index 747707506..000000000 --- a/docs/tutorials/fundamentals/computeds/Derived-Value-Dark.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/docs/tutorials/fundamentals/computeds/Derived-Value-Light.svg b/docs/tutorials/fundamentals/computeds/Derived-Value-Light.svg deleted file mode 100644 index 996f72587..000000000 --- a/docs/tutorials/fundamentals/computeds/Derived-Value-Light.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/docs/tutorials/fundamentals/destructors.md b/docs/tutorials/fundamentals/destructors.md deleted file mode 100644 index 68705e48f..000000000 --- a/docs/tutorials/fundamentals/destructors.md +++ /dev/null @@ -1,134 +0,0 @@ -Destructors are functions that clean up values passed to them. Computed objects -use them to clean up old values when they're no longer needed. - -```Lua -local function callDestroy(x) - x:Destroy() -end - -local brick = Computed(function(use) - return Instance.new("Part") -end, callDestroy) -``` - ------ - -## Memory Management - -In Luau, most values clean themselves up automatically, because they're managed -by the garbage collector: - -```Lua --- This will create a new table in memory: -local x = { - hello = "world" -} -task.wait(5) --- The table is destroyed automatically when you stop using it. -x = nil -``` - -However, not all values clean themselves up. Some common 'unmanaged' types are: - -1. Instances - need to be `:Destroy()`ed -2. Event connections - need to be `:Disconnect()`ed -3. Custom objects - might provide their own `:Destroy()` methods. - -The garbage collector doesn't manage these for you, so if you don't clean them -up, they could stick around forever: - -```Lua --- We're creating an event connection here. -local event = workspace.Changed:Connect(function() - print("Hello!") -end) - --- Even if we stop using the event connection in our code, it will continue to --- receive events. It will not be disconnected for you. -event = nil -``` - ------ - -## State Objects - -Those types of values are a problem for Computed objects. For example, if they -generate fresh instances, they need to destroy those instances too: - -```Lua -local className = Value("Frame") --- `instance` will generate a Frame at first -local instance = Computed(function(use) - return Instance.new(use(className)) -end) --- This will cause it to generate a TextLabel - but we didn't destroy the Frame! -className:set("TextLabel") -``` - -This is where destructors help out. You can provide a second function, which -Fusion will call to destroy the values we generate: - -```Lua -local function callDestroy(x) - x:Destroy() -end - -local instance = Computed(function(use) - return Instance.new(use(className)) -end, callDestroy) -``` - -Destructors aren't limited to typical cleanup behaviour! You can customise what -happens during cleanup, or do no cleanup at all: - -```Lua -local function moveToServerStorage(x) - x.Parent = game:GetService("ServerStorage") -end - -local function doNothing(x) - -- intentionally left blank -end -``` - ------ - -## Shorthand - -Most of the time, you'll want to either: - -1. destroy/disconnect/clean up the values you generate... -2. ...or leave them alone and do nothing. - -Fusion provides default destructors for both of these situations. - -### Cleanup - -`Fusion.cleanup` is a function which tries to cleans up whatever you pass to it: - -- given an instance, it is `:Destroy()`ed -- given an event connection, it is `:Disconnect()`ed -- given an object, any `:destroy()` or `:Destroy()` methods are run -- given a function, the function is run -- given an array, it cleans up everything inside - -You can use this when generating unmanaged values: - -```Lua -local instance = Computed(function(use) - return Instance.new(use(className)) -end, Fusion.cleanup) -``` - -### Do Nothing - -`Fusion.doNothing` is an empty function. It does nothing. - -You can use this when passing 'through' unmanaged values that you don't control. -It makes it clear that your code is supposed to leave the values alone: - -```Lua -local instance = Computed(function(use) - return workspace:FindFirstChild(use(className)) -end, Fusion.doNothing) -``` \ No newline at end of file diff --git a/docs/tutorials/fundamentals/observers.md b/docs/tutorials/fundamentals/observers.md index 41807e0c7..bf2b96868 100644 --- a/docs/tutorials/fundamentals/observers.md +++ b/docs/tutorials/fundamentals/observers.md @@ -1,14 +1,14 @@ -Observers allow you to detect when any state object changes value. You can -connect handlers using `:onChange()`, which returns a function you can call to -disconnect it later. +When you're working with state objects, it can be useful to detect various +changes that happen to them. -```Lua -local observer = Observer(health) +Observers allow you to detect those changes. Create one with a state object to +'watch', then connect code to run using `:onChange()` or `:onBind()`. +```Lua +local observer = scope:Observer(health) local disconnect = observer:onChange(function() print("The new value is: ", peek(health)) end) - task.wait(5) disconnect() ``` @@ -17,36 +17,50 @@ disconnect() ## Usage -To use `Observer` in your code, you first need to import it from the Fusion -module, so that you can refer to it by name: +To create a new observer object, call `scope:Observer()` and give it a state +object you want to detect changes on. -```Lua linenums="1" hl_lines="2" -local Fusion = require(ReplicatedStorage.Fusion) -local Observer = Fusion.Observer +```Lua linenums="6" hl_lines="3" +local scope = scoped(Fusion) +local health = scope:Value(5) +local observer = scope:Observer(health) ``` -To create a new observer, call the `Observer` function with an object to watch: +The observer will watch the state object for changes until it's destroyed. You +can take advantage of this by connecting your own code using the observer's +different methods. -```Lua -local health = Value(100) +The first method is `:onChange()`, which runs your code when the state object +changes value. --- This observer will watch `health` for changes: -local observer = Observer(health) -``` +=== "Luau code" -When the watched object changes value, the observer will run all of its handlers. -To add a handler, you can use `:onChange()`: + ```Lua linenums="8" hl_lines="4-6" + local observer = scope:Observer(health) -```Lua -local disconnect = observer:onChange(function() - print("The new value is: ", peek(health)) -end) -``` + print("...connecting...") + observer:onChange(function() + print("Observed a change to: ", peek(health)) + end) + + print("...setting health to 25...") + health:set(25) + ``` -When you're done with the handler, it's very important to disconnect it. The -`:onChange()` method returns a function you can call to disconnect your handler: +=== "Output" -```Lua + ``` + ...connecting... + ...setting health to 25... + Observed a change to: 25 + ``` + +By default, the `:onChange()` connection is disconnected when the observer +object is destroyed. However, if you want to disconnect it earlier, the +`:onChange()` method returns an optional disconnect function. Calling it will +disconnect that specific `:onChange()` handler early. + +```Lua linenums="8" hl_lines="1 7" local disconnect = observer:onChange(function() print("The new value is: ", peek(health)) end) @@ -56,81 +70,63 @@ task.wait(5) disconnect() ``` -??? question "Why is disconnecting so important?" - While an observer has at least one active handler, it will hold the watched - object in memory forcibly. This is done to make sure that changes aren't - missed. - - Disconnecting your handlers tells Fusion you don't need to track changes - any more, which allows it to clean up the observer and the watched object. - ------ - -## What Counts As A Change? +The second method is `:onBind()`. It works identically to `:onChange()`, but it +also runs your code right away, which can often be useful. -You might notice that not all calls to `Value:set()` will cause your observer to -run: +=== "Luau code" -=== "Script code" + ```Lua linenums="8" hl_lines="4" + local observer = scope:Observer(health) - ```Lua - local thing = Value("Hello") - - Observer(thing):onChange(function() - print("=> Thing changed to", peek(thing)) + print("...connecting...") + observer:onBind(function() + print("Observed a change to: ", peek(health)) end) - print("Setting thing once...") - thing:set("World") - print("Setting thing twice...") - thing:set("World") - print("Setting thing thrice...") - thing:set("World") + print("...setting health to 25...") + health:set(25) ``` === "Output" ``` - Setting thing once... - => Thing changed to World - Setting thing twice... - Setting thing thrice... + ...connecting... + Observed a change to: 5 + ...setting health to 25... + Observed a change to: 25 ``` -When you set the value, if it's the same as the existing value, an update won't -be sent out. This means observers won't re-run when you set the -value multiple times in a row. +----- -![A diagram showing how value objects only send updates if the new value and previous value aren't equal.](Value-Equality-Dark.svg#only-dark) -![A diagram showing how value objects only send updates if the new value and previous value aren't equal.](Value-Equality-Light.svg#only-light) +## What Counts As A Change? -In most cases, this leads to improved performance because your code runs less -often. However, if you need to override this behaviour, `Value:set()` accepts a -second argument - if you set it to `true`, an update will be forced: +If you set the `health` to the same value multiple times in a row, you might +notice your observer only runs the first time. -=== "Script code" +=== "Luau code" - ```Lua hl_lines="11-12" - local thing = Value("Hello") + ```Lua linenums="8" + local observer = scope:Observer(health) - Observer(thing):onChange(function() - print("=> Thing changed to", peek(thing)) + observer:onChange(function() + print("Observed a change to: ", peek(health)) end) - print("Setting thing once...") - thing:set("World") - print("Setting thing twice...") - thing:set("World") - print("Setting thing thrice (update forced)...") - thing:set("World", true) + print("...setting health to 25 three times...") + health:set(25) + health:set(25) + health:set(25) ``` === "Output" - ``` hl_lines="4-5" - Setting thing once... - => Thing changed to World - Setting thing twice... - Setting thing thrice (update forced)... - => Thing changed to World - ``` \ No newline at end of file + ``` + ...setting health to 25 three times... + Observed a change to: 25 + ``` + +This is because the `health` object sees that it isn't actually changing value, +so it doesn't broadcast any updates. Therefore, our observer doesn't run. + +This leads to improved performance because your code runs less often. Fusion +applies these kinds of optimisations generously throughout your program. \ No newline at end of file diff --git a/docs/tutorials/fundamentals/observers/Value-Equality-Dark.svg b/docs/tutorials/fundamentals/observers/Value-Equality-Dark.svg deleted file mode 100644 index 1d9c4c00f..000000000 --- a/docs/tutorials/fundamentals/observers/Value-Equality-Dark.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/docs/tutorials/fundamentals/observers/Value-Equality-Light.svg b/docs/tutorials/fundamentals/observers/Value-Equality-Light.svg deleted file mode 100644 index c17ff4a0a..000000000 --- a/docs/tutorials/fundamentals/observers/Value-Equality-Light.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/docs/tutorials/fundamentals/scopes.md b/docs/tutorials/fundamentals/scopes.md new file mode 100644 index 000000000..100f14f58 --- /dev/null +++ b/docs/tutorials/fundamentals/scopes.md @@ -0,0 +1,249 @@ +In Fusion, you create a lot of objects. These objects need to be destroyed when +you're done with them. + +Fusion has some coding conventions to make large quantities of objects easier to +manage. + +----- + +## Scopes + +When you create many objects at once, you often want to `:destroy()` them +together later. + +To make this easier, some people add their objects to an array. Arrays that +group together objects like this are given a special name: *scopes*. + +To create a new scope, create an empty array. + +```Lua linenums="2" hl_lines="3" +local Fusion = require(ReplicatedStorage.Fusion) + +local scope = {} +``` + +Later, when you create objects, they will ask for a scope as the first argument. + +```Lua linenums="2" hl_lines="4" +local Fusion = require(ReplicatedStorage.Fusion) + +local scope = {} +local thing = Fusion.Value(scope, "i am a thing") +``` + +That object will add itself to the scope. + +```Lua linenums="2" hl_lines="6" +local Fusion = require(ReplicatedStorage.Fusion) + +local scope = {} +local thing = Fusion.Value(scope, "i am a thing") + +print(scope[1] == thing) --> true +``` + +Repeat as many times as you like. Objects appear in order of creation. + +```Lua linenums="2" +local Fusion = require(ReplicatedStorage.Fusion) + +local scope = {} +local thing1 = Fusion.Value(scope, "i am thing 1") +local thing2 = Fusion.Value(scope, "i am thing 2") +local thing3 = Fusion.Value(scope, "i am thing 3") + +print(scope[1] == thing1) --> true +print(scope[2] == thing2) --> true +print(scope[3] == thing3) --> true +``` + +Later, destroy the scope by using the `doCleanup()` function. The contents are +destroyed in reverse order. + +```Lua linenums="2" hl_lines="2 9" +local Fusion = require(ReplicatedStorage.Fusion) +local doCleanup = Fusion.doCleanup + +local scope = {} +local thing1 = Fusion.Value(scope, "i am thing 1") +local thing2 = Fusion.Value(scope, "i am thing 2") +local thing3 = Fusion.Value(scope, "i am thing 3") + +doCleanup(scope) +-- Using `doCleanup` is the same as: +-- thing3:destroy() +-- thing2:destroy() +-- thing1:destroy() +``` + +Scopes passed to `doCleanup` can contain: + +- Objects with `:destroy()` or `:Destroy()` methods to be called +- Functions to be run +- Roblox instances to destroy +- Roblox event connections to disconnect +- Other nested scopes to be cleaned up + +You can add these manually using `table.insert` if you need custom behaviour, +or if you are working with objects that don't add themselves to scopes. + +That's all there is to scopes. They are arrays of objects which later get passed +to a cleanup function. + +----- + +## Improved Scopes + +!!! success "This syntax is recommended" + From now on, you'll see this syntax used throughout the tutorials. + +Fusion can help manage your scopes for you. This unlocks convenient syntax, and +allows Fusion to optimise your code. + +You can call `scoped()` to obtain a new scope. + +```Lua linenums="2" hl_lines="2 4" +local Fusion = require(ReplicatedStorage.Fusion) +local doCleanup, scoped = Fusion.doCleanup, Fusion.scoped + +local scope = scoped() +local thing1 = Fusion.Value(scope, "i am thing 1") +local thing2 = Fusion.Value(scope, "i am thing 2") +local thing3 = Fusion.Value(scope, "i am thing 3") + +doCleanup(scope) +``` + +Unlike `{}` (which always creates a new array), `scoped` can re-use old arrays. +This helps keep your program running smoothly. + +Beyond making your code more efficient, you can also use `scoped` for convenient +syntax. + +You can pass a table of constructor functions into `scoped`: + +```Lua linenums="2" hl_lines="4-6" +local Fusion = require(ReplicatedStorage.Fusion) +local doCleanup, scoped = Fusion.doCleanup, Fusion.scoped + +local scope = scoped({ + Value = Fusion.Value +}) +local thing1 = Fusion.Value(scope, "i am thing 1") +local thing2 = Fusion.Value(scope, "i am thing 2") +local thing3 = Fusion.Value(scope, "i am thing 3") + +doCleanup(scope) +``` + +You can now use those constructors as methods on `scope`. + +```Lua linenums="2" hl_lines="7-9" +local Fusion = require(ReplicatedStorage.Fusion) +local doCleanup, scoped = Fusion.doCleanup, Fusion.scoped + +local scope = scoped({ + Value = Fusion.Value +}) +local thing1 = scope:Value("i am thing 1") +local thing2 = scope:Value("i am thing 2") +local thing3 = scope:Value("i am thing 3") + +doCleanup(scope) +``` + +This makes it harder to mess up writing scopes. Your code reads more naturally, +too. + +### Adding Methods In Bulk + +Try passing `Fusion` to `scoped()` - it's a table with functions. + +```Lua linenums="2" hl_lines="4" +local Fusion = require(ReplicatedStorage.Fusion) +local doCleanup, scoped = Fusion.doCleanup, Fusion.scoped + +local scope = scoped(Fusion) +local thing1 = scope:Value("i am thing 1") +local thing2 = scope:Value("i am thing 2") +local thing3 = scope:Value("i am thing 3") + +doCleanup(scope) +``` + +This gives you access to all of Fusion's constructors without having to import +each one manually. + +If you need to mix in other things, you can pass in another table. + +```Lua +local scope = scoped(Fusion, { + Foo = ..., + Bar = ... +}) +``` + +You can do this for as many tables as you need. + +!!! fail "Conflicting names" + If you pass in two tables that contain things with the same name, `scoped()` + will error. + +### Reusing Methods From Other Scopes + +Sometimes, you'll want to make a new scope with the same methods as an existing +scope. + +```Lua +local foo = scoped({ + Foo = Foo, + Bar = Bar, + Baz = Baz +}) + +-- it'd be nice to define this once only... +local bar = scoped({ + Foo = Foo, + Bar = Bar, + Baz = Baz +}) +``` + +To do this, Fusion provides a `deriveScope` function. It behaves like `scoped` +but lets you skip defining the methods. Instead, you give it an example of what +the scope should look like. + +```Lua linenums="2" hl_lines="2 11" +local Fusion = require(ReplicatedStorage.Fusion) +local scoped, deriveScope = Fusion.scoped, Fusion.deriveScope +local doCleanup = Fusion.doCleanup + +local foo = scoped({ + Foo = Foo, + Bar = Bar, + Baz = Baz +}) + +local bar = deriveScope(foo) + +doCleanup(bar) +doCleanup(foo) +``` + +*Deriving* scopes like this is highly efficient because Fusion can re-use the +same information for both scopes. It also helps keep your definitions all in +one place. + +----- + +## When You'll Use This + +Scopes might sound like a lot of upfront work. However, you'll find in practice +that Fusion manages a lot of this for you. + +You'll need to create and destroy your own scopes manually sometimes. For +example, you'll need to create a scope in your main code file to start using +Fusion, and you might want to make a few more in other parts of your code. + +However, Fusion manages most of your scopes for you, so for large parts of your +codebase, you won't have to consider scopes and destruction at all. \ No newline at end of file diff --git a/docs/tutorials/fundamentals/values.md b/docs/tutorials/fundamentals/values.md index ea5fb5be7..1b3b1323a 100644 --- a/docs/tutorials/fundamentals/values.md +++ b/docs/tutorials/fundamentals/values.md @@ -1,8 +1,11 @@ +Now that you understand how Fusion works with objects, you can create Fusion's +simplest object. + Values are objects which store single values. You can write to them with their `:set()` method, and read from them with the `peek()` function. ```Lua -local health = Value(100) +local health = scope:Value(100) print(peek(health)) --> 100 health:set(25) @@ -13,132 +16,46 @@ print(peek(health)) --> 25 ## Usage -To use `Value` in your code, you first need to import it from the Fusion module, -so that you can refer to it by name. You should also import `peek` for later: +To create a new value object, call `scope:Value()` and give it a value you want +to store. -```Lua linenums="1" hl_lines="2-3" +```Lua linenums="2" hl_lines="5" local Fusion = require(ReplicatedStorage.Fusion) -local Value = Fusion.Value -local peek = Fusion.peek -``` +local doCleanup, scoped = Fusion.doCleanup, Fusion.scoped -To create a new value, call the `Value` function: - -```Lua -local health = Value() -- this will create and return a new Value object +local scope = scoped(Fusion) +local health = scope:Value(5) ``` -By default, new `Value` objects store `nil`. If you want the `Value` object to -start with a different value, you can provide one: +Fusion provides a global `peek()` function. It will read the value of whatever +you give it. You'll use `peek()` to read the value of lots of things; for now, +it's useful for printing `health` back out. -```Lua -local health = Value(100) -- the Value will initially store a value of 100 -``` - -Fusion provides a global `peek()` function which returns the value of whatever -you give it. For example, it will read the value of our `health` object: +```Lua linenums="2" hl_lines="3 7" +local Fusion = require(ReplicatedStorage.Fusion) +local doCleanup, scoped = Fusion.doCleanup, Fusion.scoped +local peek = Fusion.peek -```Lua -print(peek(health)) --> 100 +local scope = scoped(Fusion) +local health = scope:Value(5) +print(peek(health)) --> 5 ``` -We can change the value using the `:set()` method on the object itself: +You can change the value using the `:set()` method. Unlike `peek()`, this is +specific to value objects, so it's done on the object itself. + +```Lua linenums="6" hl_lines="5-6" +local scope = scoped(Fusion) +local health = scope:Value(5) +print(peek(health)) --> 5 -```Lua health:set(25) print(peek(health)) --> 25 ``` ------ - -## Why Objects? - -### The Problem - -Imagine some UI in your head. Think about what it looks like, and think about -the different variables it's showing to you. - -
-![An example of a game's UI, with some variables labelled and linked to parts of the UI.](Game-UI-Variables-Light.svg#only-light) -![An example of a game's UI, with some variables labelled and linked to parts of the UI.](Game-UI-Variables-Dark.svg#only-dark) -
Screenshot: GameUIDatabase (Halo Infinite)
-
- -Your UIs are usually driven by a few internal variables. When those variables -change, you want your UI to reflect those changes. - -Unfortunately, there's no way to listen for those changes in Lua. When you -change those variables, it's normally *your* responsibility to figure out what -needs to update, and to send out those updates. - -Over time, we've come up with many methods of dealing with this inconvenience. -Perhaps the simplest are 'setter functions', like these: - -```Lua -local ammo = 100 - -local function setAmmo(newAmmo) - ammo = newAmmo - -- you need to send out updates to every piece of code using `ammo` here - updateHUD() - updateGunModel() - sendAmmoToServer() -end -``` - -But this is clunky and unreliable; what if there's another piece of code using -`ammo` that we've forgotten to update here? How can you guarantee we've covered -everything? Moreover, why is the code setting the `ammo` even concerned with who -uses it? - -### Building Better Variables - -In an ideal world, anyone using `ammo` should be able to listen for changes, and -get notified when someone sets it to a new value. - -To make this work, we need to fundamentally extend what variables can do. In -particular, we need two additional features: - -- We need to save a list of *dependents* - other places currently using our -variable. This is so we know who to notify when the value changes. -- We need to run some code when the variable is set to a new value. If we can -do that, then we can go through the list and notify everyone. - -To solve this, Fusion introduces the idea of a 'state object'. These are objects -that represent a single value, which you can `peek()` at any time. They also -keep a list of dependents; when the object's value changes, it can notify -everyone so they can respond to the change. - -`Value` is one such state object. It's specifically designed to act like a -variable, so it has an extra `:set()` method. Using that method, you can change -the object's value manually. If you set it to a different value than before, -it'll notify anyone using the object. - -This means you can use `Value` objects like variables, with the added benefit of -being able to listen to changes like we wanted! - -### Sharing Variables - -There is another benefit to using objects too; you can easily share your objects -directly with other code. Every usage of that object will refer to the -same underlying value: - -```Lua --- someObject is a `Value` object -local function printValue(someObject) - print(peek(someObject)) -end - -local health = Value(100) -printValue(health) --> 100 - -local myDogsName = Value("Dan") -printValue(myDogsName) --> Dan -``` - -This is something that normal variables can't do by default, and is a benefit -exclusive to state objects. +Value objects are Fusion's simplest 'state object'. State objects contain a +single value - their *state*, you might say - and that single value can be read +out at any time using `peek()`. -In the above code, `printValue` can operate on *any* arbitrary variable without -knowing what it is, or where it comes from. This is very useful for writing -generic, reusable code, and you'll see it used a lot throughout Fusion. \ No newline at end of file +Later on, you'll discover more advanced state objects that can calculate their +value in more interesting ways. \ No newline at end of file diff --git a/docs/tutorials/fundamentals/values/Game-UI-Variables-Dark.svg b/docs/tutorials/fundamentals/values/Game-UI-Variables-Dark.svg deleted file mode 100644 index f166397de..000000000 --- a/docs/tutorials/fundamentals/values/Game-UI-Variables-Dark.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/tutorials/fundamentals/values/Game-UI-Variables-Light.svg b/docs/tutorials/fundamentals/values/Game-UI-Variables-Light.svg deleted file mode 100644 index c35f98bbd..000000000 --- a/docs/tutorials/fundamentals/values/Game-UI-Variables-Light.svg +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md index 49df9e6ff..19b078081 100644 --- a/docs/tutorials/index.md +++ b/docs/tutorials/index.md @@ -1,26 +1,37 @@ Welcome to the Fusion tutorial section! Here, you'll learn how to build great things with Fusion, even if you're a complete newcomer to the library. -!!! caution "But first, something important..." +You'll not only learn how Fusion's features work, but you'll also be presented +with wisdom from those who've worked with some of the largest Fusion codebases +today. + +!!! caution "But first, some advice from the maintainers..." ** - Do not use Fusion for real-world production work unless you're 100,000% - willing and able to withstand large breaking changes. + Fusion is pre-1.0 software. ** - Fusion is in beta right now! You *will* encounter: + We *(the maintainers and contributors)* work hard to keep releases bug-free + and relatively complete, so it should be safe to use in production. Many + people already do, and report fantastic results! + + However, we mark Fusion as pre-1.0 because we are working on the design of + the library itself. We strive for the best library design we can deliver, + which means breaking changes are common and sweeping. + + With Fusion, you should expect: + + - upgrades to be frictionful, requiring code to be rethought + - features to be superseded or removed across versions + - advice or best practices to change over time - - bugs in core features - - updates that completely remove existing features - - changes in behaviour between versions - - changing advice on coding conventions and how to structure your project + You should *also* expect: - This is not a bad thing! Moving fast with Fusion at this early stage means - we can quickly abandon counterproductive ideas and features, and discover - much more solid foundations to build upon. + - careful consideration around breakage, even though we reserve the right to + do it + - clear communication ahead of any major changes + - helpful advice to answer your questions and ease your porting process - Don't be discouraged from Fusion though; feel free to follow along with our - development and try using the library in your own time! More stable, - long-term Fusion versions will be available once Fusion exits beta testing. + We hope you enjoy using Fusion! ----- @@ -28,108 +39,98 @@ things with Fusion, even if you're a complete newcomer to the library. These tutorials assume: -- You're comfortable with Roblox and the Luau scripting language. - - These tutorials aren't an introduction to scripting! If you'd like to - learn, check out the [Roblox DevHub](https://developer.roblox.com/). -- You're familiar with how UI works on Roblox. - - You don't have to be a designer - knowing about UI instances, events - and data types like `UDim2` and `Color3` will be good enough. +- That you're comfortable with the Luau scripting language. + - These tutorials aren't an introduction to Luau! If you'd like to learn, + check out the [Roblox documentation](https://create.roblox.com/docs). +- That - if you're using Roblox features - you're familiar with how Roblox works. + - You don't have to be an expert! Knowing about basic instances, events + and data types will be good enough. -Of course, based on your existing knowledge, you may find some tutorials easier -or harder. Fusion's built to be easy to learn, but it may still take a bit of -time to absorb some concepts, so don't be discouraged :slightly_smiling_face: +Based on your existing knowledge, you may find some tutorials easier or harder. +Don't be discouraged - Fusion's built to be easy to learn, but it may still take +a bit of time to absorb some concepts. Learn at a pace which is right for you. ----- ## Installing Fusion -Fusion is distributed as a single module script. Before starting, you'll need -to add this module script to your game. Here's how: +There are two ways of installing Fusion, dependent on your use case. -### If you edit scripts in Roblox Studio... +If you are creating Luau experiences in Roblox Studio, then you can import a +Roblox model file containing Fusion. -Head over to [Fusion's 'Releases' page](https://github.com/Elttob/Fusion/releases). -Click the 'Assets' dropdown to view the downloadable files: +??? example "Steps (click to expand)" + + Head over to [Fusion's 'Releases' page](https://github.com/Elttob/Fusion/releases). + Click the 'Assets' dropdown to view the downloadable files: -![Picture of Fusion's GitHub Releases page, with the Assets dropdown highlighted.](index/Github-Releases-Guide-1-Light.png#only-light) -![Picture of Fusion's GitHub Releases page, with the Assets dropdown highlighted.](index/Github-Releases-Guide-1-Dark.png#only-dark) + ![Picture of Fusion's GitHub Releases page, with the Assets dropdown highlighted.](index/Github-Releases-Guide-1-Light.png#only-light) + ![Picture of Fusion's GitHub Releases page, with the Assets dropdown highlighted.](index/Github-Releases-Guide-1-Dark.png#only-dark) -Now, click on the `Fusion.rbxm` file to download it. This contains the module as -a Roblox model: + Now, click on the `Fusion.rbxm` file to download it. This model contains Fusion. -![The Assets dropdown opened to reveal downloads, with Fusion.rbxm highlighted.](index/Github-Releases-Guide-2-Light.png#only-light) -![The Assets dropdown opened to reveal downloads, with Fusion.rbxm highlighted.](index/Github-Releases-Guide-2-Dark.png#only-dark) + ![The Assets dropdown opened to reveal downloads, with Fusion.rbxm highlighted.](index/Github-Releases-Guide-2-Light.png#only-light) + ![The Assets dropdown opened to reveal downloads, with Fusion.rbxm highlighted.](index/Github-Releases-Guide-2-Dark.png#only-dark) -Head into Roblox Studio to import the model; if you're just following the -tutorials, an empty baseplate will do. + Head into Roblox Studio to import the model; if you're just following the + tutorials, an empty baseplate will do. -Right-click on `ReplicatedStorage`, and select 'Insert from File': + Right-click on `ReplicatedStorage`, and select 'Insert from File': -![ReplicatedStorage is right-clicked, showing a context menu of items. Insert from File is highlighted.](index/Github-Releases-Guide-3-Light.png#only-light) -![ReplicatedStorage is right-clicked, showing a context menu of items. Insert from File is highlighted.](index/Github-Releases-Guide-3-Dark.png#only-dark) + ![ReplicatedStorage is right-clicked, showing a context menu of items. Insert from File is highlighted.](index/Github-Releases-Guide-3-Light.png#only-light) + ![ReplicatedStorage is right-clicked, showing a context menu of items. Insert from File is highlighted.](index/Github-Releases-Guide-3-Dark.png#only-dark) -Select the `Fusion.rbxm` file you just downloaded. You should see the module -script appear in `ReplicatedStorage` - you're ready to go! + Select the `Fusion.rbxm` file you just downloaded. You should see a 'Fusion' + module script appear in `ReplicatedStorage`! -### If you edit scripts externally... (advanced) + Now, you can create a script for testing: + + 1. Create a `LocalScript` in `StarterGui` or `StarterPlayerScripts`. + 2. Remove the default code, and paste the following code in: + ```Lua linenums="1" + local ReplicatedStorage = game:GetService("ReplicatedStorage") + local Fusion = require(ReplicatedStorage.Fusion) + ``` + 3. Press 'Play' - if there are no errors, everything was set up correctly!. -If you use an external editor to write scripts, and synchronise them into Roblox -using a plugin, you can use these alternate steps instead: +If you're using pure Luau, or if you're synchronising external files into Roblox +Studio, then you can use Fusion's source code directly. ??? example "Steps (click to expand)" 1. Head over to [Fusion's 'Releases' page](https://github.com/Elttob/Fusion/releases). 2. Under 'Assets', download `Source code (zip)`. Inside is a copy of the Fusion GitHub repository. 3. Inside the zip, copy the `src` folder - it may be inside another folder. - 4. Paste `src` into your local project, preferably in your `shared` folder - if you have one. - 5. Rename the folder from `src` to `Fusion`. + 4. Paste the `src` folder into your local project, wherever you keep your + libraries (e.g. inside a `lib` or `shared` folder) + 5. Rename the pasted folder from `src` to `Fusion`. - Once everything is set up, you should see Fusion appear in Studio when you - next synchronise your project. + Once everything is set up, you should be able to `require()` Fusion in one + of the following ways: ------ - -## Setting Up A Test Script + ```Lua + -- Rojo + local Fusion = require(ReplicatedStorage.Fusion) -Now that you've installed Fusion, you can set up a local script for testing. -Here's how: + -- darklua + local Fusion = require("../shared/Fusion") -1. Create a `LocalScript` in `StarterGui` or `StarterPlayerScripts`. -2. Remove the default code, and paste the following code in: -```Lua linenums="1" -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Fusion = require(ReplicatedStorage.Fusion) -``` -3. Press 'Play' - if there are no errors, everything was set up correctly! - -??? fail "My script didn't work! (click to expand)" - ``` - Fusion is not a valid member of ReplicatedStorage "ReplicatedStorage" + -- vanilla Luau + local Fusion = require("../shared/Fusion/init.lua") ``` - If you're seeing this error, then your script can't find Fusion. - - This code assumes you've installed Fusion into `ReplicatedStorage`. If - you've installed Fusion elsewhere, you'll need to tweak the `require()` on - line 2 to point to the correct location. - - If line 2 looks like it points to the correct location, refer back to - [the previous section](#installing-fusion) and double-check you've set - everything up properly. Make sure you have a `ModuleScript` inside - `ReplicatedStorage` called "Fusion". - ----- -## Where To Get Help +## Getting Help -Fusion is built to be easy to use, and we want these tutorials to be as useful -and comprehensive as possible. However, maybe you're stuck on a cursed issue -and really need some help; or perhaps you're looking to get a better overall -understanding of Fusion! +Fusion is built to be easy to use, and this website strives to be as useful and +comprehensive as possible. However, you might need targeted help on a specific +issue, or you might want to grow your understanding of Fusion in other ways. -Whatever you're looking for, here are some resources for you to get help: +The best place to get help is [the #fusion channel](https://discord.com/channels/385151591524597761/895437663040077834) +over on [the Roblox OSS Discord server](https://discord.gg/h2NV8PqhAD). +Maintainers and contributors drop in frequently, alongside many eager Fusion +users. -- [The Roblox OSS Discord](https://discord.gg/h2NV8PqhAD) has a [#fusion](https://discord.com/channels/385151591524597761/895437663040077834) channel -- Check out [our Discussions page](https://github.com/Elttob/Fusion/discussions) on GitHub -- [Open an issue](https://github.com/Elttob/Fusion/issues) if you run into bugs or have feature requests +For bugs and feature requests, [open an issue](https://github.com/Elttob/Fusion/issues) +on GitHub. \ No newline at end of file diff --git a/docs/tutorials/instances/change-events.md b/docs/tutorials/instances/change-events.md deleted file mode 100644 index 6f4d9ede0..000000000 --- a/docs/tutorials/instances/change-events.md +++ /dev/null @@ -1,87 +0,0 @@ -`OnChange` is a function that returns keys to use when hydrating or creating an -instance. Those keys let you connect functions to property changed events on the -instance. - -```Lua -local input = New "TextBox" { - [OnChange "Text"] = function(newText) - print("You typed:", newText) - end -} -``` - ------ - -## Usage - -To use `OnChange` in your code, you first need to import it from the Fusion -module, so that you can refer to it by name: - -```Lua linenums="1" hl_lines="2" -local Fusion = require(ReplicatedStorage.Fusion) -local OnChange = Fusion.OnChange -``` - -When you call `OnChange` with a property name, it will return a special key: - -```Lua -local key = OnEvent("Activated") -``` - -When used in a property table, you can pass in a handler and it will be run when -that property changes. The new value of the property is passed in: - -```Lua -local input = New "TextBox" { - [OnChange("Text")] = function(newText) - print("You typed:", newText) - end -} -``` - -If you're using quotes `'' ""` for the event name, the extra parentheses `()` -are optional: - -```Lua -local input = New "TextBox" { - [OnChange "Text"] = function(newText) - print("You typed:", newText) - end -} -``` - -??? warning "A warning about using OnChange with state objects" - - When passing a state object as a property, changes will only affect the property - on the next frame: - - ```Lua - local text = Value("Hello") - - local message = New "Message" { - Text = text - } - - print(message.Text) --> Hello - - text:set("World") - print(message.Text) --> Hello - task.wait() -- wait for next frame - print(message.Text) --> World - ``` - - This means `OnChange` for that property will run your handlers *one frame after* - the state object is changed. This could introduce off-by-one-frame errors. - For this case, prefer to use an observer on the state object directly for - zero latency. - ------ - -## Differences from Roblox API - -Normally in the Roblox API, when using `:GetPropertyChangedSignal()` on an -instance, the handler callback will not receive any arguments. - -It's worth noting that `OnChange` is not identical in that respect. To make -working with change events easier, `OnChange` will pass the new value of the -property to the handler callback. \ No newline at end of file diff --git a/docs/tutorials/instances/cleanup.md b/docs/tutorials/instances/cleanup.md deleted file mode 100644 index a596752ea..000000000 --- a/docs/tutorials/instances/cleanup.md +++ /dev/null @@ -1,218 +0,0 @@ -The `[Cleanup]` key allows you to add cleanup code to an instance you're -hydrating or creating. You can also pass in instances or event connections to -destroy. - -```Lua -local connection = RunService.Heartbeat:Connect(function() - print("Blah blah...") -end) - -local part = New "Part" { - [Cleanup] = connection -} -``` - -```Lua -local box = New "SelectionBox" { - Parent = PlayerGui -} - -local part = New "Part" { - [Cleanup] = box -} -``` - -```Lua -local part = New "Part" { - [Cleanup] = { - function() - print("Oh no, I got destroyed. Ouch :(") - end, - connection, - box - } -} -``` - ------ - -## Usage - -To use `Cleanup` in your code, you first need to import it from the Fusion -module, so that you can refer to it by name: - -```Lua linenums="1" hl_lines="2" -local Fusion = require(ReplicatedStorage.Fusion) -local Cleanup = Fusion.Cleanup -``` - -When using `New` or `Hydrate`, you can use `[Cleanup]` as a key in the property -table. Any function you pass in will be run when the instance is destroyed: - -```Lua -local folder = New "Folder" { - [Cleanup] = function() - print("This folder was destroyed") - end -} -``` - -Arrays are supported - their contents will be cleaned up in order: - -```Lua -local folder = New "Folder" { - [Cleanup] = { - function() - print("This will run first") - end, - function() - print("This will run next") - end, - function() - print("This will run last") - end - } -} -``` - -You may also nest arrays up to any depth - sub-arrays are also run in order: - -```Lua -local folder = New "Folder" { - [Cleanup] = { - function() - print("This will run first") - end, - { - function() - print("This will run next") - end, - function() - print("This will run second-to-last") - end, - }, - function() - print("This will run last") - end - } -} -``` - -For convenience, `Cleanup` allows you to pass some types of value in directly. -Passing in an instance will destroy it: - -```Lua -local box = New "SelectionBox" { - Parent = PlayerGui -} - -local part = New "Part" { - -- `box` will be destroyed when the part is destroyed - [Cleanup] = box -} -``` - -Passing in an event connection will disconnect it: - -```Lua -local connection = RunService.Heartbeat:Connect(function() - print("Blah blah...") -end) - -local part = New "Part" { - -- `connection` will be disconnected when the part is destroyed - [Cleanup] = connection -} -``` - -Finally, passing in anything with a `:destroy()` or `:Destroy()` method will -have that method called: - -```Lua --- you might receive an object like this from a third party library -local object = {} -function object:destroy() - print("I was destroyed!") -end - -local part = New "Part" { - -- `object:destroy()` will be called when the part is destroyed - [Cleanup] = object -} -``` - -Any other kind of value will do nothing by default. - ------ - -## Don't Use Destroyed - -While Roblox does provide it's own `Destroyed` event, it should *not* be relied -upon for cleaning up correctly in all cases. It only fires when the `Destroy` -method is called, meaning other kinds of destruction are ignored. - -For example, notice only one of these parts runs their cleanup code: - -=== "Script code" - - ```Lua linenums="1" - local part1 = New "Part" { - [OnEvent "Destroyed"] = function() - print("=> Part 1 cleaned up") - end - } - - local part2 = New "Part" { - [OnEvent "Destroyed"] = function() - print("=> Part 2 cleaned up") - end - } - - print("Destroying part 1...") - part1:Destroy() - - print("Setting part 2 to nil...") - part2 = nil - ``` - -=== "Output" - - ``` - Destroying part 1... - => Part 1 cleaned up - Setting part 2 to nil... - ``` - -Meanwhile, Fusion's `[Cleanup]` will work regardless of how your instances were -destroyed, meaning you can avoid serious memory leaks: - -=== "Script code" - - ```Lua linenums="1" - local part1 = New "Part" { - [Cleanup] = function() - print("=> Part 1 cleaned up") - end - } - - local part2 = New "Part" { - [Cleanup] = function() - print("=> Part 2 cleaned up") - end - } - - print("Destroying part 1...") - part1:Destroy() - - print("Setting part 2 to nil...") - part2 = nil - ``` - -=== "Output" - - ``` - Destroying part 1... - => Part 1 cleaned up - Setting part 2 to nil... - => Part 2 cleaned up - ``` \ No newline at end of file diff --git a/docs/tutorials/instances/references.md b/docs/tutorials/instances/references.md deleted file mode 100644 index a400ea67b..000000000 --- a/docs/tutorials/instances/references.md +++ /dev/null @@ -1,141 +0,0 @@ -The `[Ref]` key allows you to save a reference to an instance you're hydrating -or creating. - -```Lua -local myRef = Value() - -local thing = New "Part" { - [Ref] = myRef -} - -print(peek(myRef)) --> Part -print(peek(myRef) == thing) --> true -``` - ------ - -## Usage - -To use `Ref` in your code, you first need to import it from the Fusion module, -so that you can refer to it by name: - -```Lua linenums="1" hl_lines="2" -local Fusion = require(ReplicatedStorage.Fusion) -local Ref = Fusion.Ref -``` - -When using `New` or `Hydrate`, you can use `[Ref]` as a key in the property -table. It expects a value object to be passed in, and it will save a reference -to the instance in that object: - -```Lua -local myRef = Value() - -New "Part" { - [Ref] = myRef -} - -print(peek(myRef)) --> Part -``` - -When the instance is cleaned up, the value object is set to nil to avoid memory -leaks: - -```Lua -local myPart = Value() - -New "Part" { - [Ref] = myPart -} - -print(peek(myRef)) --> Part - -peek(myRef):Destroy() - -print(peek(myRef)) --> nil -``` - ------ - -## When To Use This - -You may have noticed that `New` and `Hydrate` already return their instances. -You might wonder why there's two ways to get the same instance reference: - -```Lua -local fromRef = Value() -local returned = New "Part" { - [Ref] = fromRef -} - -print(returned) --> Part -print(peek(fromRef)) --> Part - -print(returned == peek(fromRef)) --> true -``` - -There are two main use cases. Firstly, when you're using `[Children]` to nest -instances inside each other, it's hard to access the instance reference: - -```Lua -local folders = New "Folder" { - [Children] = New "Folder" { - -- the instance reference gets passed straight into [Children] - -- so... how do you save this somewhere else? - [Children] = New "Part" {} - } -} -``` - -One solution is to extract the `New` call out to a separate variable. This is -the simplest solution, but because the part is separated from the folders, it's -harder to see they're related at a glance: - -```Lua --- build the part elsewhere, so it can be saved to a variable -local myPart = New "Part" {} - -local folders = New "Folder" { - [Children] = New "Folder" { - -- use the saved reference - [Children] = myPart - } -} -``` - -`Ref` allows you to save the reference without moving the `New` call: - -```Lua --- use a Value instead of a plain variable, so it can be passed to `Ref` -local myPart = Value() - -local folders = New "Folder" { - [Children] = New "Folder" { - [Children] = New "Part" { - -- save a reference into the value object - [Ref] = myPart - } - } -} -``` - -The second use case arises when one instance needs to refer to another. Since -`Ref` saves to a value object, you can pass the object directly into another -`New` or `Hydrate` call: - -```Lua -local myPart = Value() - -New "SelectionBox" { - -- the selection box should adorn to the part - Adornee = myPart -} - -New "Part" { - -- saving a reference to `myPart`, which will change the Adornee prop above - [Ref] = myPart -} -``` - -These aren't the only use cases for `Ref`, but they're the most common patterns -which are worth covering. \ No newline at end of file diff --git a/docs/tutorials/lists-and-tables/forkeys.md b/docs/tutorials/lists-and-tables/forkeys.md deleted file mode 100644 index 0184e2007..000000000 --- a/docs/tutorials/lists-and-tables/forkeys.md +++ /dev/null @@ -1,193 +0,0 @@ -`ForKeys` is a state object that creates a new table by processing keys from -another table. - -The input table can be a state object, and the output keys can use state objects. - -```Lua -local data = {Red = "foo", Blue = "bar"} -local prefix = Value("Key_") - -local renamed = ForKeys(data, function(use, key) - return use(prefix) .. key -end) - -print(peek(renamed)) --> {Key_Red = "foo", Key_Blue = "bar"} - -prefix:set("colour") -print(peek(renamed)) --> {colourRed = "foo", colourBlue = "bar"} -``` - ------ - -## Usage - -To use `ForKeys` in your code, you first need to import it from the Fusion -module, so that you can refer to it by name: - -```Lua linenums="1" hl_lines="2" -local Fusion = require(ReplicatedStorage.Fusion) -local ForKeys = Fusion.ForKeys -``` - -### Basic Usage - -To create a new `ForKeys` object, call the constructor with an input table and -a processor function: - -```Lua -local data = {red = "foo", blue = "bar"} -local renamed = ForKeys(data, function(use, key) - return string.upper(key) -end) -``` - -This will generate a new table, where each key is replaced using the processor -function. The first argument is `use`, similar to a computed, and the -second argument is one of the keys from the input table. - -You can read the processed table using `peek()`: - -```Lua hl_lines="6" -local data = {red = "foo", blue = "bar"} -local renamed = ForKeys(data, function(use, key) - return string.upper(key) -end) - -print(peek(renamed)) --> {RED = "foo", BLUE = "bar"} -``` - -### State Objects - -The input table can be provided as a state object instead, and the output table -will update as the input table is changed: - -```Lua -local playerSet = Value({}) -local userIdSet = ForKeys(playerSet, function(use, player) - return player.UserId -end) - -playerSet:set({ [Players.Elttob] = true }) -print(peek(userIdSet)) --> {[1670764] = true} - -playerSet:set({ [Players.boatbomber] = true, [Players.EgoMoose] = true }) -print(peek(userIdSet)) --> {[33655127] = true, [2155311] = true} -``` - -Additionally, you can `use()` state objects in your calculations, just like a -computed: - -```Lua -local playerSet = { [Players.boatbomber] = true, [Players.EgoMoose] = true } -local prefix = Value("User_") -local userIdSet = ForKeys(playerSet, function(use, player) - return use(prefix) .. player.UserId -end) - -print(peek(userIdSet)) --> {User_33655127 = true, User_2155311 = true} - -prefix:set("player") -print(peek(userIdSet)) --> {player33655127 = true, player2155311 = true} -``` - -### Cleanup Behaviour - -Similar to computeds, if you want to run your own code when values are removed, -you can pass in a second 'destructor' function: - -```Lua hl_lines="15-19" -local eventSet = Value({ - [RunService.RenderStepped] = true, - [RunService.Heartbeat] = true -}) - -local connectionSet = ForKeys(eventSet, - -- processor - function(use, event) - local eventName = tostring(event) - local connection = event:Connect(function(...) - print(eventName, "fired with arguments:", ...) - end) - return connection - end, - -- destructor - function(connection) - print("Disconnecting the event!") - connection:Disconnect() -- don't forget we're overriding the default cleanup - end -) - --- remove Heartbeat from the event set --- this will run the destructor with the Heartbeat connection -eventSet:set({ [RunService.RenderStepped] = true }) --> Disconnecting the event! -``` - -When using a custom destructor, you can send one extra return value to your -destructor without including it in the output table: - -```Lua hl_lines="13 16" -local eventSet = Value({ - [RunService.RenderStepped] = true, - [RunService.Heartbeat] = true -}) - -local connectionSet = ForKeys(eventSet, - -- processor - function(use, event) - local eventName = tostring(event) - local connection = event:Connect(function(...) - print(eventName, "fired with arguments:", ...) - end) - return connection, eventName - end, - -- destructor - function(connection, eventName) - print("Disconnecting " .. eventName .. "!") - connection:Disconnect() - end -) - -eventSet:set({ [RunService.RenderStepped] = true }) --> Disconnecting Signal Heartbeat! -``` - ------ - -## Optimisations - -!!! help "Optional" - You don't have to memorise these optimisations to use `ForKeys`, but it - can be helpful if you have a performance problem. - -Rather than creating a new output table from scratch every time the input table -is changed, `ForKeys` will try and reuse as much as possible to improve -performance. - -For example, let's say we're converting an array to a dictionary: - -```Lua -local array = Value({"Fusion", "Knit", "Matter"}) -local dict = ForKeys(array, function(use, index) - return "Value" .. index -end) - -print(peek(dict)) --> {Value1 = "Fusion", Value2 = "Knit", Value3 = "Matter"} -``` - -Because `ForKeys` only operates on the keys, changing the values in the array -doesn't affect the keys. Keys are only added or removed as needed: - -```Lua -local array = Value({"Fusion", "Knit", "Matter"}) -local dict = ForKeys(array, function(use, index) - return "Value" .. index -end) - -print(peek(dict)) --> {Value1 = "Fusion", Value2 = "Knit", Value3 = "Matter"} - -array:set({"Roact", "Rodux"}) -print(peek(dict)) --> {Value1 = "Roact", Value2 = "Rodux"} -``` - -`ForKeys` takes advantage of this - when a value changes, it's copied into the -output table without recalculating the key. Keys are only calculated when a -value is assigned to a new key. \ No newline at end of file diff --git a/docs/tutorials/lists-and-tables/forpairs.md b/docs/tutorials/lists-and-tables/forpairs.md deleted file mode 100644 index 6983eb158..000000000 --- a/docs/tutorials/lists-and-tables/forpairs.md +++ /dev/null @@ -1,135 +0,0 @@ -`ForPairs` combines the functions of `ForValues` and `ForKeys` into one object. -It can process pairs of keys and values at the same time. - -The input table can be a state object, and the output values can use state -objects. - -```Lua -local itemColours = { shoes = "red", socks = "blue" } -local owner = Value("Elttob") - -local manipulated = ForPairs(itemColours, function(use, thing, colour) - local newKey = colour - local newValue = use(owner) .. "'s " .. thing - return newKey, newValue -end) - -print(peek(manipulated)) --> {red = "Elttob's shoes", blue = "Elttob's socks"} - -owner:set("Quenty") -print(peek(manipulated)) --> {red = "Quenty's shoes", blue = "Quenty's socks"} -``` - ------ - -## Usage - -To use `ForPairs` in your code, you first need to import it from the Fusion -module, so that you can refer to it by name: - -```Lua linenums="1" hl_lines="2" -local Fusion = require(ReplicatedStorage.Fusion) -local ForPairs = Fusion.ForPairs -``` - -### Basic Usage - -To create a new `ForPairs` object, call the constructor with an input table and -a processor function: - -```Lua -local itemColours = { shoes = "red", socks = "blue" } -local swapped = ForPairs(data, function(use, key, value) - return value, key -end) -``` - -This will generate a new table, where each key-value pair is replaced using the -processor function. The first argument is `use`, similar to a computed, and the -second/third arguments are a key/value pair from the input table. - -You can read the processed table using `peek()`: - -```Lua hl_lines="6" -local itemColours = { shoes = "red", socks = "blue" } -local swapped = ForPairs(data, function(use, key, value) - return value, key -end) - -print(peek(swapped)) --> {red = "shoes", blue = "socks"} -``` - -### State Objects - -As with `ForKeys` and `ForValues`, the input table can be provided as a state -object, and the processor function can `use()` other state objects in its -calculations. [See the ForValues page for examples.](./forvalues.md#state-objects) - -### Cleanup Behaviour - -Similar to `ForValues` and `ForKeys`, you may pass in a 'destructor' function to -add cleanup behaviour, and send your own metadata to it: - -```Lua -local watchedInstances = Value({ - [workspace.Part1] = "One", - [workspace.Part2] = "Two", - [workspace.Part3] = "Three" -}) - -local connectionSet = ForPairs(eventSet, - -- processor - function(use, instance, displayName) - local metadata = { displayName = displayName, numChanges = 0 } - local connection = instance.Changed:Connect(function() - print("Instance", displayName, "was changed!") - metadata.numChanges += 1 - end) - return instance, connection, metadata - end, - -- destructor - function(instance, connection, metadata) - print("Removing", metadata.displayName, "after", metadata.numChanges, "changes") - connection:Disconnect() -- don't forget we're overriding the default cleanup - end -) - --- remove Part3 from the input table --- this will run the destructor with Part3, its Changed event, and its metadata -watchedInstances:set({ - [workspace.Part1] = "One", - [workspace.Part2] = "Two" -}) -``` - ------ - -## Optimisations - -!!! help "Optional" - You don't have to memorise these optimisations to use `ForPairs`, but it - can be helpful if you have a performance problem. - -Rather than creating a new output table from scratch every time the input table -is changed, `ForPairs` will try and reuse as much as possible to improve -performance. - -Since `ForPairs` has to depend on both keys and values, changing any value in -the input table will cause a recalculation for that key-value pair. - -![A diagram showing values of keys changing in a table.](Optimisation-KeyValueChange-Dark.svg#only-dark) -![A diagram showing values of keys changing in a table.](Optimisation-KeyValueChange-Light.svg#only-light) - -Inversely, `ForPairs` won't recalculate any key-value pairs that stay the same. -Instead, these will be preserved in the output table. - -![A diagram showing values of keys staying the same in a table.](Optimisation-KeyValuePreserve-Dark.svg#only-dark) -![A diagram showing values of keys staying the same in a table.](Optimisation-KeyValuePreserve-Light.svg#only-light) - -If you don't need the keys or the values, Fusion can offer better optimisations. -For example, if you're working with an array of values where position doesn't -matter, [ForValues can move values between keys.](./forvalues.md#optimisations) - -Alternatively, if you're working with a set of objects stored in keys, and don't -need the values in the table, -[ForKeys will ignore the values for optimal performance.](./forkeys.md#optimisations) \ No newline at end of file diff --git a/docs/tutorials/lists-and-tables/forvalues.md b/docs/tutorials/lists-and-tables/forvalues.md deleted file mode 100644 index 469adac24..000000000 --- a/docs/tutorials/lists-and-tables/forvalues.md +++ /dev/null @@ -1,184 +0,0 @@ -`ForValues` is a state object that creates a new table by processing values from -another table. - -The input table can be a state object, and the output values can use state -objects. - -```Lua -local numbers = {1, 2, 3, 4, 5} -local multiplier = Value(2) - -local multiplied = ForValues(numbers, function(use, num) - return num * use(multiplier) -end) - -print(multiplied:get()) --> {2, 4, 6, 8, 10} - -multiplier:set(10) -print(multiplied:get()) --> {10, 20, 30, 40, 50} -``` - ------ - -## Usage - -To use `ForValues` in your code, you first need to import it from the Fusion -module, so that you can refer to it by name: - -```Lua linenums="1" hl_lines="2" -local Fusion = require(ReplicatedStorage.Fusion) -local ForValues = Fusion.ForValues -``` - -### Basic Usage - -To create a new `ForValues` object, call the constructor with an input table and -a processor function: - -```Lua -local numbers = {1, 2, 3, 4, 5} -local doubled = ForValues(numbers, function(use, num) - return num * 2 -end) -``` - -This will generate a new table of values, where each value is passed through the -processor function. The first argument is `use`, similar to a computed, and the -second argument is one of the values from the input table. - -You can read the processed table using `peek()`: - -```Lua hl_lines="6" -local numbers = {1, 2, 3, 4, 5} -local doubled = ForValues(numbers, function(use, num) - return num * 2 -end) - -print(peek(doubled)) --> {2, 4, 6, 8, 10} -``` - -### State Objects - -The input table can be provided as a state object instead, and the output table -will update as the input table is changed: - -```Lua -local numbers = Value({}) -local doubled = ForValues(numbers, function(use, num) - return num * 2 -end) - -numbers:set({1, 2, 3, 4, 5}) -print(peek(doubled)) --> {2, 4, 6, 8, 10} - -numbers:set({5, 15, 25}) -print(peek(doubled)) --> {10, 30, 50} -``` - -Additionally, you can `use()` state objects in your calculations, just like a -computed: - -```Lua -local numbers = {1, 2, 3, 4, 5} -local factor = Value(2) -local multiplied = ForValues(numbers, function(use, num) - return num * use(factor) -end) - -print(peek(multiplied)) --> {2, 4, 6, 8, 10} - -factor:set(10) -print(peek(multiplied)) --> {10, 20, 30, 40, 50} -``` - -### Cleanup Behaviour - -Similar to computeds, if you want to run your own code when values are removed, -you can pass in a second 'destructor' function: - -```Lua hl_lines="9-13" -local names = Value({"Jodi", "Amber", "Umair"}) -local textLabels = ForValues(names, - -- processor - function(use, name) - return New "TextLabel" { - Text = name - } - end, - -- destructor - function(textLabel) - print("Destructor got text label:", textLabel.Text) - textLabel:Destroy() -- don't forget we're overriding the default cleanup - end -) - --- remove Jodi from the names list --- this will run the destructor with Jodi's TextLabel -names:set({"Amber", "Umair"}) --> Destructor got text label: Jodi -``` - -When using a custom destructor, you can send one extra return value to your -destructor without including it in the output table: - -```Lua hl_lines="11 14" -local names = Value({"Jodi", "Amber", "Umair"}) -local textLabels = ForValues(names, - -- processor - function(use, name) - local textLabel = New "TextLabel" { - Text = name - } - local uppercased = name:upper() - -- `textLabel` will be included in the output table - -- `uppercased` is not included, but still passed to the destructor - return textLabel, uppercased - end, - -- destructor - function(textLabel, uppercased) - print("Destructor got uppercased:", uppercased) - textLabel:Destroy() - end -) - -names:set({"Amber", "Umair"}) --> Destructor got uppercased: JODI -``` - ------ - -## Optimisations - -!!! help "Optional" - You don't have to memorise these optimisations to use `ForValues`, but it - can be helpful if you have a performance problem. - -Rather than creating a new output table from scratch every time the input table -is changed, `ForValues` will try and reuse as much as possible to improve -performance. - -For example, let's say we're measuring the lengths of an array of words: - -```Lua -local words = Value({"Orange", "Red", "Magenta"}) -local lengths = ForValues(words, function(use, word) - return #word -end) - -print(peek(lengths)) --> {6, 3, 7} -``` - -The word lengths don't depend on the position of the word in the array. This -means that rearranging the words in the input array will just rearrange the -lengths in the output array: - -![A diagram visualising how the values move around.](Optimisation-Reordering-Dark.svg#only-dark) -![A diagram visualising how the values move around.](Optimisation-Reordering-Light.svg#only-light) - -`ForValues` takes advantage of this - when input values move around, the output -values will move around too, instead of being recalculated. - -Note that values are only reused once. For example, if you added another -occurence of 'Orange', your calculation would have to run again for the second -'Orange': - -![A diagram visualising how values aren't reused when duplicates appear.](Optimisation-Duplicates-Dark.svg#only-dark) -![A diagram visualising how values aren't reused when duplicates appear.](Optimisation-Duplicates-Light.svg#only-light) \ No newline at end of file diff --git a/docs/tutorials/lists-and-tables/the-for-objects.md b/docs/tutorials/lists-and-tables/the-for-objects.md deleted file mode 100644 index 053d6f7b8..000000000 --- a/docs/tutorials/lists-and-tables/the-for-objects.md +++ /dev/null @@ -1,108 +0,0 @@ -Often when building UI, you need to deal with lists, arrays and tables. For -example: - -- creating an array of TextLabels for a player list -- generating a settings page from pairs of keys and values in a configuration -- filling a grid with inventory slots and items - -Most of these use cases involve processing one table into another: - -- converting an array of player names into an array of TextLabels -- converting a table of settings into a table of UI controls -- converting an array of inventory items into an array of slot UIs - -So, to assist with these use cases, Fusion has a few state objects which are -specially designed for working with arrays and tables. These are known as the -`For` objects. - ------ - -## The Problem - -To start, let's try making a player list using the basic state objects from -before. Let's define a changeable list of player names and some basic UI: - -```Lua linenums="1" -local playerNames = Value({"Elttob", "boatbomber", "thisfall", "AxisAngles"}) - -local textLabels = {} -- TODO: implement this - -local ui = New "ScreenGui" { - Parent = Players.LocalPlayer.PlayerGui, - - [Children] = New "Frame" { - Name = "PlayerList", - - Position = UDim2.fromScale(1, 1), - AnchorPoint = Vector2.new(1, 0), - Size = UDim2.fromOffset(200, 0), - AutomaticSize = "Y", - - [Children] = { - New "UIListLayout" { - SortOrder = "Name" - }, - textLabels - } - } -} -``` - -Now, let's make a `Computed` which generates that list of text labels for us: - -```Lua linenums="1" hl_lines="3-13" -local playerNames = Value({"Elttob", "boatbomber", "thisfall", "AxisAngles"}) - -local textLabels = Computed(function(use) - local out = {} - for index, playerName in use(playerNames) do - out[index] = New "TextLabel" { - Name = playerName, - Size = UDim2.new(1, 0, 0, 50), - Text = playerName - } - end - return out -end, Fusion.cleanup) - -local ui = New "ScreenGui" { - Parent = Players.LocalPlayer.PlayerGui, -``` - -This is alright, but there are a few problems: - -- Firstly, there's a fair amount of boilerplate - in order to generate the list -of text labels, you have to create a `Computed`, initialise a new table, write a -for-loop to populate the table, then return it. - - Boilerplate is generally annoying, and especially so for a task as common - as dealing with lists and tables. It's less clear to read and more tedious - to write. -- Secondly, whenever `playerNames` is changed, you reconstruct the entire list, -destroying all of your instances and any data associated with them. This is both -inefficient and also causes issues with data loss. - - Ideally, you should only modify the text labels for players that have - joined or left, leaving the rest of the text labels alone. - -To address this shortcoming, the `For` objects provide a cleaner way to do the -same thing, except with less boilerplate and leaving unchanged values alone: - -```Lua linenums="1" hl_lines="3-9" -local playerNames = Value({"Elttob", "boatbomber", "thisfall", "AxisAngles"}) - -local textLabels = ForValues(playerNames, function(use, playerName) - return New "TextLabel" { - Name = playerName, - Size = UDim2.new(1, 0, 0, 50), - Text = playerName - } -end, Fusion.cleanup) - -local ui = New "ScreenGui" { - Parent = Players.LocalPlayer.PlayerGui, -``` - -Over the next few pages, we'll take a look at three state objects: - -- `ForValues`, which lets you process just the values in a table. -- `ForKeys`, which lets you process just the keys in a table. -- `ForPairs`, which lets you do both at the same time. \ No newline at end of file diff --git a/docs/tutorials/roblox/change-events.md b/docs/tutorials/roblox/change-events.md new file mode 100644 index 000000000..b7403cda5 --- /dev/null +++ b/docs/tutorials/roblox/change-events.md @@ -0,0 +1,57 @@ +`OnChange` is a function that returns keys to use when hydrating or creating an +instance. Those keys let you connect functions to property changed events on the +instance. + +```Lua +local input = scope:New "TextBox" { + [OnChange "Text"] = function(newText) + print("You typed:", newText) + end +} +``` + +----- + +## Usage + +`OnChange` doesn't need a scope - import it into your code from Fusion directly. + +```Lua +local OnChange = Fusion.OnChange +``` + +When you call `OnChange` with a property name, it will return a special key: + +```Lua +local key = OnChange("Text") +``` + +When used in a property table, you can pass in a handler and it will be run when +that property changes. + +!!! info "Arguments are different to Roblox API" + Normally in the Roblox API, when using `:GetPropertyChangedSignal()` on an + instance, the callback will not receive any arguments. + + To make working with change events easier, `OnChange` will pass the new value of + the property to the callback. + +```Lua +local input = scope:New "TextBox" { + [OnChange("Text")] = function(newText) + print("You typed:", newText) + end +} +``` + +If you're using quotes `'' ""` for the event name, the extra parentheses `()` +are optional: + +```Lua +local input = scope:New "TextBox" { + [OnChange "Text"] = function(newText) + print("You typed:", newText) + end +} +``` + diff --git a/docs/tutorials/instances/events.md b/docs/tutorials/roblox/events.md similarity index 75% rename from docs/tutorials/instances/events.md rename to docs/tutorials/roblox/events.md index b97939549..51f05a82d 100644 --- a/docs/tutorials/instances/events.md +++ b/docs/tutorials/roblox/events.md @@ -2,7 +2,7 @@ instance. Those keys let you connect functions to events on the instance. ```Lua -local button = New "TextButton" { +local button = scope:New "TextButton" { [OnEvent "Activated"] = function(_, numClicks) print("The button was pressed", numClicks, "time(s)!") end @@ -13,11 +13,9 @@ local button = New "TextButton" { ## Usage -To use `OnEvent` in your code, you first need to import it from the Fusion -module, so that you can refer to it by name: +`OnEvent` doesn't need a scope - import it into your code from Fusion directly. -```Lua linenums="1" hl_lines="2" -local Fusion = require(ReplicatedStorage.Fusion) +```Lua local OnEvent = Fusion.OnEvent ``` @@ -31,7 +29,7 @@ When that key is used in a property table, you can pass in a handler and it will be connected to the event for you: ```Lua -local button = New "TextButton" { +local button = scope:New "TextButton" { [OnEvent("Activated")] = function(_, numClicks) print("The button was pressed", numClicks, "time(s)!") end @@ -42,7 +40,7 @@ If you're using quotes `'' ""` for the event name, the extra parentheses `()` are optional: ```Lua -local button = New "TextButton" { +local button = scope:New "TextButton" { [OnEvent "Activated"] = function(_, numClicks) print("The button was pressed", numClicks, "time(s)!") end diff --git a/docs/tutorials/instances/hydration.md b/docs/tutorials/roblox/hydration.md similarity index 78% rename from docs/tutorials/instances/hydration.md rename to docs/tutorials/roblox/hydration.md index 3c6798b1b..1ce4ce550 100644 --- a/docs/tutorials/instances/hydration.md +++ b/docs/tutorials/roblox/hydration.md @@ -1,3 +1,9 @@ +!!! warning "Intent to replace" + While the contents of this page still apply (and are useful for explaining + other features), `Hydrate` itself will be replaced by other primitives in + the near future. + [See this issue on GitHub for further details.](https://github.com/dphfox/Fusion/issues/206) + The process of connecting your scripts to a pre-made UI template is known as *hydration*. This is where logic in your scripts translate into UI effects, for example setting a message inside a TextLabel, moving menus around, or showing @@ -15,9 +21,9 @@ of properties. If you pass in Fusion objects, changes will be applied on the next frame: ```Lua -local showUI = Value(false) +local showUI = scope:Value(false) -local ui = Hydrate(StarterGui.Template:Clone()) { +local ui = scope:Hydrate(StarterGui.Template:Clone()) { Name = "MainGui", Enabled = showUI } @@ -34,21 +40,13 @@ print(ui.Enabled) --> true ## Usage -To use `Hydrate` in your code, you first need to import it from the Fusion -module, so that you can refer to it by name: - -```Lua linenums="1" hl_lines="2" -local Fusion = require(ReplicatedStorage.Fusion) -local Hydrate = Fusion.Hydrate -``` - The `Hydrate` function is called in two parts. First, call the function with the instance you want to hydrate, then pass in the property table: ```Lua local instance = workspace.Part -Hydrate(instance)({ +scope:Hydrate(instance)({ Color = Color3.new(1, 0, 0) }) ``` @@ -60,7 +58,7 @@ parentheses `()` are optional: local instance = workspace.Part -- This only works when you're using curly braces {}! -Hydrate(instance) { +scope:Hydrate(instance) { Color = Color3.new(1, 0, 0) } ``` @@ -68,7 +66,7 @@ Hydrate(instance) { `Hydrate` returns the instance you give it, so you can use it in declarations: ```Lua -local instance = Hydrate(workspace.Part) { +local instance = scope:Hydrate(workspace.Part) { Color = Color3.new(1, 0, 0) } ``` @@ -78,9 +76,9 @@ instance directly. However, if you pass in a Fusion object (like `Value`), then changes will be applied on the next frame: ```Lua -local message = Value("Loading...") +local message = scope:Value("Loading...") -Hydrate(PlayerGui.LoadingText) { +scope:Hydrate(PlayerGui.LoadingText) { Text = message } diff --git a/docs/tutorials/instances/hydration/Hydration-Basic-Dark.svg b/docs/tutorials/roblox/hydration/Hydration-Basic-Dark.svg similarity index 100% rename from docs/tutorials/instances/hydration/Hydration-Basic-Dark.svg rename to docs/tutorials/roblox/hydration/Hydration-Basic-Dark.svg diff --git a/docs/tutorials/instances/hydration/Hydration-Basic-Light.svg b/docs/tutorials/roblox/hydration/Hydration-Basic-Light.svg similarity index 100% rename from docs/tutorials/instances/hydration/Hydration-Basic-Light.svg rename to docs/tutorials/roblox/hydration/Hydration-Basic-Light.svg diff --git a/docs/tutorials/instances/new-instances.md b/docs/tutorials/roblox/new-instances.md similarity index 84% rename from docs/tutorials/instances/new-instances.md rename to docs/tutorials/roblox/new-instances.md index bb58cda85..109a5f1dc 100644 --- a/docs/tutorials/instances/new-instances.md +++ b/docs/tutorials/roblox/new-instances.md @@ -3,9 +3,9 @@ creates a new instance, applies some default properties, then hydrates it with a property table. ```Lua -local message = Value("Hello there!") +local message = scope:Value("Hello there!") -local ui = New "TextLabel" { +local ui = scope:New "TextLabel" { Name = "Greeting", Parent = PlayerGui.ScreenGui, @@ -24,19 +24,11 @@ print(ui.Text) --> Goodbye friend! ## Usage -To use `New` in your code, you first need to import it from the Fusion module, -so that you can refer to it by name: - -```Lua linenums="1" hl_lines="2" -local Fusion = require(ReplicatedStorage.Fusion) -local New = Fusion.New -``` - The `New` function is called in two parts. First, call the function with the type of instance, then pass in the property table: ```Lua -local instance = New("Part")({ +local instance = scope:New("Part")({ Parent = workspace, Color = Color3.new(1, 0, 0) }) @@ -47,7 +39,7 @@ your class type, the extra parentheses `()` are optional: ```Lua -- This only works when you're using curly braces {} and quotes '' ""! -local instance = New "Part" { +local instance = scope:New "Part" { Parent = workspace, Color = Color3.new(1, 0, 0) } diff --git a/docs/tutorials/instances/new-instances/Default-Props-Dark.svg b/docs/tutorials/roblox/new-instances/Default-Props-Dark.svg similarity index 100% rename from docs/tutorials/instances/new-instances/Default-Props-Dark.svg rename to docs/tutorials/roblox/new-instances/Default-Props-Dark.svg diff --git a/docs/tutorials/instances/new-instances/Default-Props-Light.svg b/docs/tutorials/roblox/new-instances/Default-Props-Light.svg similarity index 100% rename from docs/tutorials/instances/new-instances/Default-Props-Light.svg rename to docs/tutorials/roblox/new-instances/Default-Props-Light.svg diff --git a/docs/tutorials/instances/outputs.md b/docs/tutorials/roblox/outputs.md similarity index 81% rename from docs/tutorials/instances/outputs.md rename to docs/tutorials/roblox/outputs.md index 723eb70ca..7f3691d8c 100644 --- a/docs/tutorials/instances/outputs.md +++ b/docs/tutorials/roblox/outputs.md @@ -2,9 +2,9 @@ instance. Those keys let you output a property's value to a `Value` object. ```Lua -local name = Value() +local name = scope:Value() -local thing = New "Part" { +local thing = scope:New "Part" { [Out "Name"] = name } @@ -18,11 +18,9 @@ print(peek(name)) --> Jimmy ## Usage -To use `Out` in your code, you first need to import it from the Fusion module, -so that you can refer to it by name: +`Out` doesn't need a scope - import it into your code from Fusion directly. -```Lua linenums="1" hl_lines="2" -local Fusion = require(ReplicatedStorage.Fusion) +```Lua local Out = Fusion.Out ``` @@ -37,9 +35,9 @@ to the value of the property, and when the property changes, it will be set to the new value: ```Lua -local name = Value() +local name = scope:Value() -local thing = New "Part" { +local thing = scope:New "Part" { [Out("Name")] = name } @@ -53,7 +51,7 @@ If you're using quotes `'' ""` for the event name, the extra parentheses `()` are optional: ```Lua -local thing = New "Part" { +local thing = scope:New "Part" { [Out "Name"] = name } ``` @@ -66,9 +64,9 @@ By default, `Out` only *outputs* changes to the property. If you set the value to something else, the property remains the same: ```Lua -local name = Value() +local name = scope:Value() -local thing = New "Part" { +local thing = scope:New "Part" { [Out "Name"] = name -- When `thing.Name` changes, set `name` } @@ -82,9 +80,9 @@ If you want the value to both *change* and *be changed* by the property, you need to explicitly say so: ```Lua hl_lines="4 11" -local name = Value() +local name = scope:Value() -local thing = New "Part" { +local thing = scope:New "Part" { Name = name -- When `name` changes, set `thing.Name` [Out "Name"] = name -- When `thing.Name` changes, set `name` } diff --git a/docs/tutorials/instances/parenting.md b/docs/tutorials/roblox/parenting.md similarity index 71% rename from docs/tutorials/instances/parenting.md rename to docs/tutorials/roblox/parenting.md index 84890d40e..e848dfa60 100644 --- a/docs/tutorials/instances/parenting.md +++ b/docs/tutorials/roblox/parenting.md @@ -1,10 +1,11 @@ The `[Children]` key allows you to add children when hydrating or creating an instance. -It accepts instances, arrays of children and state objects containing children. +It accepts instances, arrays of children, and state objects containing children +or `nil`. ```Lua -local folder = New "Folder" { +local folder = scope:New "Folder" { [Children] = { New "Part" { Name = "Gregory", @@ -22,11 +23,10 @@ local folder = New "Folder" { ## Usage -To use `Children` in your code, you first need to import it from the Fusion -module, so that you can refer to it by name: +`Children` doesn't need a scope - import it into your code from Fusion +directly. -```Lua linenums="1" hl_lines="2" -local Fusion = require(ReplicatedStorage.Fusion) +```Lua local Children = Fusion.Children ``` @@ -34,7 +34,7 @@ When using `New` or `Hydrate`, you can use `[Children]` as a key in the property table. Any instance you pass in will be parented: ```Lua -local folder = New "Folder" { +local folder = scope:New "Folder" { -- The part will be moved inside of the folder [Children] = workspace.Part } @@ -44,8 +44,8 @@ Since `New` and `Hydrate` both return their instances, you can nest them: ```Lua -- Makes a Folder, containing a part called Gregory -local folder = New "Folder" { - [Children] = New "Part" { +local folder = scope:New "Folder" { + [Children] = scope:New "Part" { Name = "Gregory", Color = Color3.new(1, 0, 0) } @@ -56,13 +56,13 @@ If you need to parent multiple children, arrays of children are accepted: ```Lua -- Makes a Folder, containing parts called Gregory and Sammy -local folder = New "Folder" { +local folder = scope:New "Folder" { [Children] = { - New "Part" { + scope:New "Part" { Name = "Gregory", Color = Color3.new(1, 0, 0) }, - New "Part" { + scope:New "Part" { Name = "Sammy", Material = "Glass" } @@ -73,12 +73,12 @@ local folder = New "Folder" { Arrays can be nested to any depth; all children will still be parented: ```Lua -local folder = New "Folder" { +local folder = scope:New "Folder" { [Children] = { { { { - New "Part" { + scope:New "Part" { Name = "Gregory", Color = Color3.new(1, 0, 0) } @@ -89,17 +89,17 @@ local folder = New "Folder" { } ``` -Similarly, state objects containing children (or `nil`) are also allowed: +State objects containing children or `nil` are also allowed: ```Lua -local value = Value() +local value = scope:Value() -local folder = New "Folder" { +local folder = scope:New "Folder" { [Children] = value } value:set( - New "Part" { + scope:New "Part" { Name = "Clyde", Transparency = 0.5 } @@ -110,22 +110,22 @@ You may use any combination of these to parent whichever children you need: ```Lua local modelChildren = workspace.Model:GetChildren() -local includeModel = Value(true) +local includeModel = scope:Value(true) -local folder = New "Folder" { +local folder = scope:New "Folder" { -- array of children [Children] = { -- single instance - New "Part" { + scope:New "Part" { Name = "Gregory", Color = Color3.new(1, 0, 0) }, -- state object containing children (or nil) - Computed(function(use) + scope:Computed(function(use) return if use(includeModel) then modelChildren:GetChildren() -- array of children else nil - end, Fusion.doNothing) + end) } } ``` \ No newline at end of file diff --git a/docs/tutorials/roblox/references.md b/docs/tutorials/roblox/references.md new file mode 100644 index 000000000..047307264 --- /dev/null +++ b/docs/tutorials/roblox/references.md @@ -0,0 +1,89 @@ +The `[Ref]` key allows you to save a reference to an instance you're hydrating +or creating. + +```Lua +local myRef = scope:Value() + +local thing = scope:New "Part" { + [Ref] = myRef +} + +print(peek(myRef)) --> Part +print(peek(myRef) == thing) --> true +``` + +----- + +## Usage + +`Ref` doesn't need a scope - import it into your code from Fusion directly. + +```Lua linenums="1" hl_lines="2" +local Fusion = require(ReplicatedStorage.Fusion) +local Ref = Fusion.Ref +``` + +When creating an instance with `New`, `[Ref]` will save that instance to a value +object. + +```Lua +local myRef = scope:Value() + +scope:New "Part" { + [Ref] = myRef +} + +print(peek(myRef)) --> Part +``` + +Among other things, this allows you to refer to instances from other instances. + +```Lua +local myPart = scope:Value() + +New "SelectionBox" { + -- the selection box should adorn to the part + Adornee = myPart +} + +New "Part" { + -- sets `myPart` to this part, which sets the adornee to this part + [Ref] = myPart +} +``` + +You can also get references to instances from deep inside function calls. + +```Lua +-- this will refer to the part, once we create it +local myPart = scope:Value() + +scope:New "Folder" { + [Children] = scope:New "Folder" { + [Children] = scope:New "Part" { + -- save a reference into the value object + [Ref] = myPart + } + } +} +``` + +!!! warning "Nil hazard" + Before the part is created, the `myPart` value object will be `nil`. Be + careful not to use it before it's created. + + If you need to know about the instance ahead of time, you should create the + instance early, and parent it in later, when you create the rest of the + instances. + + ```Lua + -- build the part elsewhere, so it can be saved to a variable + local myPart = scope:New "Part" {} + + local folders = scope:New "Folder" { + [Children] = scope:New "Folder" { + -- parent the part into the folder here + [Children] = myPart + } + } + ``` \ No newline at end of file diff --git a/docs/tutorials/tables/forkeys.md b/docs/tutorials/tables/forkeys.md new file mode 100644 index 000000000..af73778af --- /dev/null +++ b/docs/tutorials/tables/forkeys.md @@ -0,0 +1,129 @@ +`ForKeys` is a state object that processes keys from another table. + +It supports both constants and state objects. + +```Lua +local data = {Red = "foo", Blue = "bar"} +local prefix = scope:Value("Key_") + +local renamed = scope:ForKeys(data, function(use, key) + return use(prefix) .. key +end) + +print(peek(renamed)) --> {Key_Red = "foo", Key_Blue = "bar"} + +prefix:set("colour") +print(peek(renamed)) --> {colourRed = "foo", colourBlue = "bar"} +``` + +----- + +## Usage + +To create a new `ForKeys` object, call the constructor with an input table and +a processor function. The first two arguments are `use` and `scope`, just like +[computed objects](../../fundamentals/computeds). The third argument is one of +the keys read from the input table. + +```Lua +local data = {red = "foo", blue = "bar"} +local renamed = scope:ForKeys(data, function(use, scope, key) + return string.upper(key) +end) +``` + +You can read the table of processed keys using `peek()`: + +```Lua hl_lines="6" +local data = {red = "foo", blue = "bar"} +local renamed = scope:ForKeys(data, function(use, scope, key) + return string.upper(key) +end) + +print(peek(renamed)) --> {RED = "foo", BLUE = "bar"} +``` + +The input table can be a state object. When the input table changes, the output +will update. + +```Lua +local foodSet = scope:Value({}) + +local prefixes = { pie = "tasty", chocolate = "yummy", broccoli = "gross" } +local renamedFoodSet = scope:ForKeys(foodSet, function(use, scope, food) + return prefixes[food] .. food +end) + +foodSet:set({ pie = true }) +print(peek(renamedFoodSet)) --> { tasty_pie = true } + +foodSet:set({ broccoli = true, chocolate = true }) +print(peek(renamedFoodSet)) --> { gross_broccoli = true, yummy_chocolate = true } +``` + +You can also `use()` state objects in your calculations, just like a computed. + +```Lua +local foodSet = scope:Value({ broccoli = true, chocolate = true }) + +local prefixes = { chocolate = "yummy", broccoli = scope:Value("gross") } +local renamedFoodSet = scope:ForKeys(foodSet, function(use, scope, food) + return use(prefixes[food]) .. food +end) + +print(peek(renamedFoodSet)) --> { gross_broccoli = true, yummy_chocolate = true } + +prefixes.broccoli:set("scrumptious") +print(peek(renamedFoodSet)) --> { scrumptious_broccoli = true, yummy_chocolate = true } +``` + +Anything added to the `scope` is cleaned up for you when the processed key is +removed. + +```Lua +local foodSet = scope:Value({ broccoli = true, chocolate = true }) + +local shoutingFoodSet = scope:ForKeys(names, function(use, scope, food) + table.insert(scope, function() + print("I ate the " .. food .. "!") + end) + return string.upper(food) +end) + +names:set({ chocolate = true }) --> I ate the broccoli! +``` + +??? tip "How ForKeys optimises your code" + Rather than creating a new output table from scratch every time the input table + is changed, `ForKeys` will try and reuse as much as possible to improve + performance. + + Say you're converting an array to a dictionary: + + ```Lua + local array = scope:Value({"Fusion", "Knit", "Matter"}) + local dict = scope:ForKeys(array, function(use, scope, index) + return "Value" .. index + end) + + print(peek(dict)) --> {Value1 = "Fusion", Value2 = "Knit", Value3 = "Matter"} + ``` + + Because `ForKeys` only operates on the keys, changing the values in the array + doesn't affect the keys. Keys are only added or removed as needed: + + ```Lua + local array = scope:Value({"Fusion", "Knit", "Matter"}) + local dict = scope:ForKeys(array, function(use, scope, index) + return "Value" .. index + end) + + print(peek(dict)) --> {Value1 = "Fusion", Value2 = "Knit", Value3 = "Matter"} + + array:set({"Roact", "Rodux", "Promise"}) + print(peek(dict)) --> {Value1 = "Roact", Value2 = "Rodux", Value3 = "Promise"} + ``` + + `ForKeys` takes advantage of this - when a value changes, it's copied into the + output table without recalculating the key. Keys are only calculated when a + value is assigned to a new key. \ No newline at end of file diff --git a/docs/tutorials/tables/forpairs.md b/docs/tutorials/tables/forpairs.md new file mode 100644 index 000000000..e9786ffc9 --- /dev/null +++ b/docs/tutorials/tables/forpairs.md @@ -0,0 +1,122 @@ +`ForPairs` is like `ForValues` and `ForKeys` in one object. It can process pairs +of keys and values at the same time. + +It supports both constants and state objects. + +```Lua +local itemColours = { shoes = "red", socks = "blue" } +local owner = scope:Value("Janet") + +local manipulated = scope:ForPairs(itemColours, function(use, scope, thing, colour) + local newKey = colour + local newValue = use(owner) .. "'s " .. thing + return newKey, newValue +end) + +print(peek(manipulated)) --> {red = "Janet's shoes", blue = "Janet's socks"} + +owner:set("April") +print(peek(manipulated)) --> {red = "April's shoes", blue = "April's socks"} +``` + +----- + +## Usage + +To create a new `ForPairs` object, call the constructor with an input table and +a processor function. The first two arguments are `use` and `scope`, just like +[computed objects](../../fundamentals/computeds). The third and fourth arguments +are one of the key-value pairs read from the input table. + +```Lua +local itemColours = { shoes = "red", socks = "blue" } +local swapped = scope:ForPairs(data, function(use, scope, item, colour) + return colour, item +end) +``` + +You can read the processed table using `peek()`: + +```Lua hl_lines="6" +local itemColours = { shoes = "red", socks = "blue" } +local swapped = scope:ForPairs(data, function(use, scope, item, colour) + return colour, item +end) + +print(peek(swapped)) --> { red = "shoes", blue = "socks" } +``` + +The input table can be a state object. When the input table changes, the output +will update. + +```Lua +local itemColours = scope:Value({ shoes = "red", socks = "blue" }) +local swapped = scope:ForPairs(data, function(use, scope, item, colour) + return colour, item +end) + +print(peek(swapped)) --> { red = "shoes", blue = "socks" } + +itemColours:set({ sandals = "red", socks = "green" }) +print(peek(swapped)) --> { red = "sandals", green = "socks" } +``` + +You can also `use()` state objects in your calculations, just like a computed. + +```Lua +local itemColours = { shoes = "red", socks = "blue" } + +local shouldSwap = scope:Value(false) +local swapped = scope:ForPairs(data, function(use, scope, item, colour) + if use(shouldSwap) then + return colour, item + else + return item, colour + end +end) + +print(peek(swapped)) --> { shoes = "red", socks = "blue" } + +shouldSwap:set(true) +print(peek(swapped)) --> { red = "shoes", blue = "socks" } +``` + +Anything added to the `scope` is cleaned up for you when either the processed +key or the processed value is removed. + +```Lua +local itemColours = scope:Value({ shoes = "red", socks = "blue" }) +local swapped = scope:ForPairs(data, function(use, scope, item, colour) + table.insert(scope, function() + print("No longer wearing " .. colour .. " " .. item) + end) + return colour, item +end) + +itemColours:set({ shoes = "red", socks = "green" }) --> No longer wearing blue socks +``` + +??? tip "How ForPairs optimises your code" + Rather than creating a new output table from scratch every time the input table + is changed, `ForPairs` will try and reuse as much as possible to improve + performance. + + Since `ForPairs` has to depend on both keys and values, changing any value in + the input table will cause a recalculation for that key-value pair. + + ![A diagram showing values of keys changing in a table.](Optimisation-KeyValueChange-Dark.svg#only-dark) + ![A diagram showing values of keys changing in a table.](Optimisation-KeyValueChange-Light.svg#only-light) + + Inversely, `ForPairs` won't recalculate any key-value pairs that stay the same. + Instead, these will be preserved in the output table. + + ![A diagram showing values of keys staying the same in a table.](Optimisation-KeyValuePreserve-Dark.svg#only-dark) + ![A diagram showing values of keys staying the same in a table.](Optimisation-KeyValuePreserve-Light.svg#only-light) + + If you don't need the keys or the values, Fusion can offer better optimisations. + For example, if you're working with an array of values where position doesn't + matter, [ForValues can move values between keys.](./forvalues.md) + + Alternatively, if you're working with a set of objects stored in keys, and don't + need the values in the table, + [ForKeys will ignore the values for optimal performance.](./forkeys.md) \ No newline at end of file diff --git a/docs/tutorials/lists-and-tables/forpairs/Optimisation-KeyValueChange-Dark.svg b/docs/tutorials/tables/forpairs/Optimisation-KeyValueChange-Dark.svg similarity index 100% rename from docs/tutorials/lists-and-tables/forpairs/Optimisation-KeyValueChange-Dark.svg rename to docs/tutorials/tables/forpairs/Optimisation-KeyValueChange-Dark.svg diff --git a/docs/tutorials/lists-and-tables/forpairs/Optimisation-KeyValueChange-Light.svg b/docs/tutorials/tables/forpairs/Optimisation-KeyValueChange-Light.svg similarity index 100% rename from docs/tutorials/lists-and-tables/forpairs/Optimisation-KeyValueChange-Light.svg rename to docs/tutorials/tables/forpairs/Optimisation-KeyValueChange-Light.svg diff --git a/docs/tutorials/lists-and-tables/forpairs/Optimisation-KeyValuePreserve-Dark.svg b/docs/tutorials/tables/forpairs/Optimisation-KeyValuePreserve-Dark.svg similarity index 100% rename from docs/tutorials/lists-and-tables/forpairs/Optimisation-KeyValuePreserve-Dark.svg rename to docs/tutorials/tables/forpairs/Optimisation-KeyValuePreserve-Dark.svg diff --git a/docs/tutorials/lists-and-tables/forpairs/Optimisation-KeyValuePreserve-Light.svg b/docs/tutorials/tables/forpairs/Optimisation-KeyValuePreserve-Light.svg similarity index 100% rename from docs/tutorials/lists-and-tables/forpairs/Optimisation-KeyValuePreserve-Light.svg rename to docs/tutorials/tables/forpairs/Optimisation-KeyValuePreserve-Light.svg diff --git a/docs/tutorials/tables/forvalues.md b/docs/tutorials/tables/forvalues.md new file mode 100644 index 000000000..e6fe9bcd9 --- /dev/null +++ b/docs/tutorials/tables/forvalues.md @@ -0,0 +1,123 @@ +`ForValues` is a state object that processes values from another table. + +It supports both constants and state objects. + +```Lua +local numbers = {1, 2, 3, 4, 5} +local multiplier = Value(2) + +local multiplied = ForValues(numbers, function(use, num) + return num * use(multiplier) +end) + +print(peek(multiplied)) --> {2, 4, 6, 8, 10} + +multiplier:set(10) +print(peek(multiplied)) --> {10, 20, 30, 40, 50} +``` + +----- + +## Usage + +To create a new `ForValues` object, call the constructor with an input table and +a processor function. The first two arguments are `use` and `scope`, just like +[computed objects](../../fundamentals/computeds). The third argument is one of the +values read from the input table. + +```Lua +local numbers = {1, 2, 3, 4, 5} +local doubled = scope:ForValues(numbers, function(use, scope, num) + return num * 2 +end) +``` + +You can read the table of processed values using `peek()`: + +```Lua hl_lines="6" +local numbers = {1, 2, 3, 4, 5} +local doubled = scope:ForValues(numbers, function(use, scope, num) + return num * 2 +end) + +print(peek(doubled)) --> {2, 4, 6, 8, 10} +``` + +The input table can be a state object. When the input table changes, the output +will update. + +```Lua +local numbers = scope:Value({}) +local doubled = scope:ForValues(numbers, function(use, scope, num) + return num * 2 +end) + +numbers:set({1, 2, 3, 4, 5}) +print(peek(doubled)) --> {2, 4, 6, 8, 10} + +numbers:set({5, 15, 25}) +print(peek(doubled)) --> {10, 30, 50} +``` + +You can also `use()` state objects in your calculations, just like a computed. + +```Lua +local numbers = {1, 2, 3, 4, 5} +local factor = scope:Value(2) +local multiplied = scope:ForValues(numbers, function(use, scope, num) + return num * use(factor) +end) + +print(peek(multiplied)) --> {2, 4, 6, 8, 10} + +factor:set(10) +print(peek(multiplied)) --> {10, 20, 30, 40, 50} +``` + +Anything added to the `scope` is cleaned up for you when the processed value is +removed. + +```Lua +local names = scope:Value({"Jodi", "Amber", "Umair"}) +local shoutingNames = scope:ForValues(names, function(use, scope, name) + table.insert(scope, function() + print("Goodbye, " .. name .. "!") + end) + return string.upper(name) +end) + +names:set({"Amber", "Umair"}) --> Goodbye, Jodi! +``` + +??? tip "How ForValues optimises your code" + Rather than creating a new output table from scratch every time the input table + is changed, `ForValues` will try and reuse as much as possible to improve + performance. + + Say you're measuring the lengths of an array of words: + + ```Lua + local words = scope:Value({"Orange", "Red", "Magenta"}) + local lengths = scope:ForValues(words, function(use, scope, word) + return #word + end) + + print(peek(lengths)) --> {6, 3, 7} + ``` + + The word lengths don't depend on the position of the word in the array. This + means that rearranging the words in the input array will just rearrange the + lengths in the output array: + + ![A diagram visualising how the values move around.](Optimisation-Reordering-Dark.svg#only-dark) + ![A diagram visualising how the values move around.](Optimisation-Reordering-Light.svg#only-light) + + `ForValues` takes advantage of this - when input values move around, the output + values will move around too, instead of being recalculated. + + Note that values are only reused once. For example, if you added another + occurence of 'Orange', your calculation would have to run again for the second + 'Orange': + + ![A diagram visualising how values aren't reused when duplicates appear.](Optimisation-Duplicates-Dark.svg#only-dark) + ![A diagram visualising how values aren't reused when duplicates appear.](Optimisation-Duplicates-Light.svg#only-light) \ No newline at end of file diff --git a/docs/tutorials/lists-and-tables/forvalues/Optimisation-Duplicates-Dark.svg b/docs/tutorials/tables/forvalues/Optimisation-Duplicates-Dark.svg similarity index 100% rename from docs/tutorials/lists-and-tables/forvalues/Optimisation-Duplicates-Dark.svg rename to docs/tutorials/tables/forvalues/Optimisation-Duplicates-Dark.svg diff --git a/docs/tutorials/lists-and-tables/forvalues/Optimisation-Duplicates-Light.svg b/docs/tutorials/tables/forvalues/Optimisation-Duplicates-Light.svg similarity index 100% rename from docs/tutorials/lists-and-tables/forvalues/Optimisation-Duplicates-Light.svg rename to docs/tutorials/tables/forvalues/Optimisation-Duplicates-Light.svg diff --git a/docs/tutorials/lists-and-tables/forvalues/Optimisation-Reordering-Dark.svg b/docs/tutorials/tables/forvalues/Optimisation-Reordering-Dark.svg similarity index 100% rename from docs/tutorials/lists-and-tables/forvalues/Optimisation-Reordering-Dark.svg rename to docs/tutorials/tables/forvalues/Optimisation-Reordering-Dark.svg diff --git a/docs/tutorials/lists-and-tables/forvalues/Optimisation-Reordering-Light.svg b/docs/tutorials/tables/forvalues/Optimisation-Reordering-Light.svg similarity index 100% rename from docs/tutorials/lists-and-tables/forvalues/Optimisation-Reordering-Light.svg rename to docs/tutorials/tables/forvalues/Optimisation-Reordering-Light.svg diff --git a/mkdocs.yml b/mkdocs.yml index 685d4b9f9..449fededb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -54,33 +54,33 @@ nav: - Home: index.md - Tutorials: - Get Started: tutorials/index.md - - State Objects: + - Fundamentals: + - Scopes: tutorials/fundamentals/scopes.md - Values: tutorials/fundamentals/values.md - Observers: tutorials/fundamentals/observers.md - Computeds: tutorials/fundamentals/computeds.md - - Destructors: tutorials/fundamentals/destructors.md - - Instances: - - Hydration: tutorials/instances/hydration.md - - New Instances: tutorials/instances/new-instances.md - - Parenting: tutorials/instances/parenting.md - - Events: tutorials/instances/events.md - - Change Events: tutorials/instances/change-events.md - - Outputs: tutorials/instances/outputs.md - - Cleanup: tutorials/instances/cleanup.md - - References: tutorials/instances/references.md - - Lists & Tables: - - The For Objects: tutorials/lists-and-tables/the-for-objects.md - - ForValues: tutorials/lists-and-tables/forvalues.md - - ForKeys: tutorials/lists-and-tables/forkeys.md - - ForPairs: tutorials/lists-and-tables/forpairs.md - - Components: - - Reusing UI: tutorials/components/reusing-ui.md - - Children: tutorials/components/children.md - - Callbacks: tutorials/components/callbacks.md - - State: tutorials/components/state.md + - Tables: + - ForValues: tutorials/tables/forvalues.md + - ForKeys: tutorials/tables/forkeys.md + - ForPairs: tutorials/tables/forpairs.md - Animation: - Tweens: tutorials/animation/tweens.md - Springs: tutorials/animation/springs.md + - Roblox: + - Hydration: tutorials/roblox/hydration.md + - New Instances: tutorials/roblox/new-instances.md + - Parenting: tutorials/roblox/parenting.md + - Events: tutorials/roblox/events.md + - Change Events: tutorials/roblox/change-events.md + - Outputs: tutorials/roblox/outputs.md + - References: tutorials/roblox/references.md + - Best Practices: + - Components: tutorials/best-practices/components.md + - Instance Handling: tutorials/best-practices/instance-handling.md + - Callbacks: tutorials/best-practices/callbacks.md + - State: tutorials/best-practices/state.md + - Sharing Values: tutorials/best-practices/sharing-values.md + - Examples: - Home: examples/index.md - Cookbook: @@ -93,46 +93,67 @@ nav: - Loading Spinner: examples/cookbook/loading-spinner.md - Drag & Drop: examples/cookbook/drag-and-drop.md - API Reference: - - Home: api-reference/index.md - - Errors: - - api-reference/errors/index.md + - api-reference/index.md + - General: + - Errors: api-reference/general/errors.md + - Types: + - Contextual: api-reference/general/types/contextual.md + - Version: api-reference/general/types/version.md + - Members: + - Contextual: api-reference/general/members/contextual.md + - version: api-reference/general/members/version.md + - Memory: + - Types: + - Scope: api-reference/memory/types/scope.md + - ScopedObject: api-reference/memory/types/scopedobject.md + - Task: api-reference/memory/types/task.md + - Members: + - deriveScope: api-reference/memory/members/derivescope.md + - doCleanup: api-reference/memory/members/docleanup.md + - scoped: api-reference/memory/members/scoped.md - State: - - api-reference/state/index.md - - CanBeState: api-reference/state/canbestate.md - - Computed: api-reference/state/computed.md - - cleanup: api-reference/state/cleanup.md - - Dependency: api-reference/state/dependency.md - - Dependent: api-reference/state/dependent.md - - doNothing: api-reference/state/donothing.md - - ForKeys: api-reference/state/forkeys.md - - ForPairs: api-reference/state/forpairs.md - - ForValues: api-reference/state/forvalues.md - - Observer: api-reference/state/observer.md - - peek: api-reference/state/peek.md - - StateObject: api-reference/state/stateobject.md - - Use: api-reference/state/use.md - - Value: api-reference/state/value.md - - Instances: - - api-reference/instances/index.md - - Attribute: api-reference/instances/attribute.md - - AttributeChange: api-reference/instances/attributechange.md - - AttributeOut: api-reference/instances/attributeout.md - - Child: api-reference/instances/child.md - - Children: api-reference/instances/children.md - - Cleanup: api-reference/instances/cleanup.md - - Component: api-reference/instances/component.md - - Hydrate: api-reference/instances/hydrate.md - - New: api-reference/instances/new.md - - OnChange: api-reference/instances/onchange.md - - OnEvent: api-reference/instances/onevent.md - - Out: api-reference/instances/out.md - - Ref: api-reference/instances/ref.md - - SpecialKey: api-reference/instances/specialkey.md + - Types: + - CanBeState: api-reference/state/types/canbestate.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 + - StateObject: api-reference/state/types/stateobject.md + - Use: api-reference/state/types/use.md + - Value: api-reference/state/types/value.md + - Members: + - Computed: api-reference/state/members/computed.md + - 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: + - Types: + - Child: api-reference/roblox/types/child.md + - PropertyTable: api-reference/roblox/types/propertytable.md + - SpecialKey: api-reference/roblox/types/specialkey.md + - Members: + - Attribute: api-reference/roblox/members/attribute.md + - AttributeChange: api-reference/roblox/members/attributechange.md + - AttributeOut: api-reference/roblox/members/attributeout.md + - Children: api-reference/roblox/members/children.md + - Hydrate: api-reference/roblox/members/hydrate.md + - New: api-reference/roblox/members/new.md + - OnChange: api-reference/roblox/members/onchange.md + - OnEvent: api-reference/roblox/members/onevent.md + - Out: api-reference/roblox/members/out.md + - Ref: api-reference/roblox/members/ref.md - Animation: - - api-reference/animation/index.md - - Animatable: api-reference/animation/animatable.md - - Tween: api-reference/animation/tween.md - - Spring: api-reference/animation/spring.md + - Types: + - Animatable: api-reference/animation/types/animatable.md + - Spring: api-reference/animation/types/spring.md + - Tween: api-reference/animation/types/tween.md + - Members: + - Tween: api-reference/animation/members/tween.md + - Spring: api-reference/animation/members/spring.md - Extras: - Home: extras/index.md - Backgrounds: extras/backgrounds.md diff --git a/src/Animation/Spring.lua b/src/Animation/Spring.lua index 295d5e392..40de55bb2 100644 --- a/src/Animation/Spring.lua +++ b/src/Animation/Spring.lua @@ -1,4 +1,5 @@ ---!nonstrict +--!strict +--!nolint LocalShadow --[[ Constructs a new computed state object, which follows the value of another @@ -6,20 +7,23 @@ ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) local Types = require(Package.Types) +local InternalTypes = require(Package.InternalTypes) local logError = require(Package.Logging.logError) local logErrorNonFatal = require(Package.Logging.logErrorNonFatal) local unpackType = require(Package.Animation.unpackType) local SpringScheduler = require(Package.Animation.SpringScheduler) local updateAll = require(Package.State.updateAll) -local xtypeof = require(Package.Utility.xtypeof) +local isState = require(Package.State.isState) local peek = require(Package.State.peek) +local whichLivesLonger = require(Package.Memory.whichLivesLonger) +local logWarn = require(Package.Logging.logWarn) local class = {} +class.type = "State" +class.kind = "Spring" local CLASS_METATABLE = {__index = class} -local WEAK_KEYS_METATABLE = {__mode = "k"} --[[ Sets the position of the internal springs, meaning the value of this @@ -28,7 +32,10 @@ local WEAK_KEYS_METATABLE = {__mode = "k"} If the type doesn't match the current type of the spring, an error will be thrown. ]] -function class:setPosition(newValue: PubTypes.Animatable) +function class:setPosition( + newValue: Types.Animatable +) + local self = self :: InternalTypes.Spring local newType = typeof(newValue) if newType ~= self._currentType then logError("springTypeMismatch", nil, newType, self._currentType) @@ -47,7 +54,10 @@ end If the type doesn't match the current type of the spring, an error will be thrown. ]] -function class:setVelocity(newValue: PubTypes.Animatable) +function class:setVelocity( + newValue: Types.Animatable +) + local self = self :: InternalTypes.Spring local newType = typeof(newValue) if newType ~= self._currentType then logError("springTypeMismatch", nil, newType, self._currentType) @@ -64,7 +74,10 @@ end If the type doesn't match the current type of the spring, an error will be thrown. ]] -function class:addVelocity(deltaValue: PubTypes.Animatable) +function class:addVelocity( + deltaValue: Types.Animatable +) + local self = self :: InternalTypes.Spring local deltaType = typeof(deltaValue) if deltaType ~= self._currentType then logError("springTypeMismatch", nil, deltaType, self._currentType) @@ -82,6 +95,7 @@ end changed. ]] function class:update(): boolean + local self = self :: InternalTypes.Spring local goalValue = peek(self._goalState) -- figure out if this was a goal change or a speed/damping change @@ -152,7 +166,8 @@ end --[[ Returns the interior value of this state object. ]] -function class:_peek(): any +function class:_peek(): unknown + local self = self :: InternalTypes.Spring return self._currentValue end @@ -160,11 +175,27 @@ function class:get() logError("stateGetWasRemoved") end +function class:destroy() + local self = self :: InternalTypes.Spring + if self.scope == nil then + logError("destroyedTwice", nil, "Spring") + end + SpringScheduler.remove(self) + self.scope = nil + for dependency in pairs(self.dependencySet) do + dependency.dependentSet[self] = nil + end +end + local function Spring( - goalState: PubTypes.Value, - speed: PubTypes.CanBeState?, - damping: PubTypes.CanBeState? + scope: Types.Scope, + goalState: Types.StateObject, + speed: Types.CanBeState?, + damping: Types.CanBeState? ): Types.Spring + if isState(scope) then + logError("scopeMissing", nil, "Springs", "myScope:Spring(goalState, speed, damping)") + end -- apply defaults for speed and damping if speed == nil then speed = 10 @@ -173,21 +204,20 @@ local function Spring( damping = 1 end - local dependencySet = {[goalState] = true} - if xtypeof(speed) == "State" then + local dependencySet: {[Types.Dependency]: unknown} = {[goalState] = true} + if isState(speed) then + local speed = speed :: Types.StateObject dependencySet[speed] = true end - if xtypeof(damping) == "State" then + if isState(damping) then + local damping = damping :: Types.StateObject dependencySet[damping] = true end local self = setmetatable({ - type = "State", - kind = "Spring", + scope = scope, dependencySet = dependencySet, - -- if we held strong references to the dependents, then they wouldn't be - -- able to get garbage collected when they fall out of scope - dependentSet = setmetatable({}, WEAK_KEYS_METATABLE), + dependentSet = {}, _speed = speed, _damping = damping, @@ -201,8 +231,20 @@ local function Spring( _springPositions = nil, _springGoals = nil, - _springVelocities = nil + _springVelocities = nil, + + _lastSchedule = -math.huge, + _startDisplacements = {}, + _startVelocities = {} }, CLASS_METATABLE) + local self = (self :: any) :: InternalTypes.Spring + + table.insert(scope, self) + if goalState.scope == nil then + logError("useAfterDestroy", nil, `The {goalState.kind} object`, `the Spring that is following it`) + elseif whichLivesLonger(scope, self, goalState.scope, goalState) == "definitely-a" then + logWarn("possiblyOutlives", `The {goalState.kind} object`, `the Spring that is following it`) + end -- add this object to the goal state's dependent set goalState.dependentSet[self] = true diff --git a/src/Animation/SpringScheduler.lua b/src/Animation/SpringScheduler.lua index 093ddd9d7..25a142a40 100644 --- a/src/Animation/SpringScheduler.lua +++ b/src/Animation/SpringScheduler.lua @@ -1,32 +1,34 @@ --!strict +--!nolint LocalShadow --[[ Manages batch updating of spring objects. ]] local Package = script.Parent.Parent -local Types = require(Package.Types) +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]: any} -type Spring = Types.Spring +type Set = {[T]: unknown} local SpringScheduler = {} local EPSILON = 0.0001 -local activeSprings: Set = {} +local activeSprings: Set> = {} local lastUpdateTime = External.lastUpdateStep() -function SpringScheduler.add(spring: Spring) +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 - spring._startDisplacements = {} - spring._startVelocities = {} + 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] @@ -35,18 +37,24 @@ function SpringScheduler.add(spring: Spring) activeSprings[spring] = true end -function SpringScheduler.remove(spring: Spring) +function SpringScheduler.remove( + spring: InternalTypes.Spring +) activeSprings[spring] = nil end local function updateAllSprings( now: number ) - local springsToSleep: Set = {} + 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 posPos, posVel, velPos, velVel = springCoefficients( + lastUpdateTime - spring._lastSchedule, + spring._currentDamping, + spring._currentSpeed + ) local positions = spring._springPositions local velocities = spring._springVelocities diff --git a/src/Animation/Tween.lua b/src/Animation/Tween.lua index 3cee343e1..6e301f371 100644 --- a/src/Animation/Tween.lua +++ b/src/Animation/Tween.lua @@ -1,4 +1,5 @@ ---!nonstrict +--!strict +--!nolint LocalShadow --[[ Constructs a new computed state object, which follows the value of another @@ -6,25 +7,29 @@ ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) -local External = require(Package.External) local Types = require(Package.Types) +local InternalTypes = require(Package.InternalTypes) +local External = require(Package.External) local TweenScheduler = require(Package.Animation.TweenScheduler) local logError = require(Package.Logging.logError) local logErrorNonFatal = require(Package.Logging.logErrorNonFatal) -local xtypeof = require(Package.Utility.xtypeof) +local isState = require(Package.State.isState) local peek = require(Package.State.peek) +local whichLivesLonger = require(Package.Memory.whichLivesLonger) +local logWarn = require(Package.Logging.logWarn) local class = {} +class.type = "State" +class.kind = "Tween" local CLASS_METATABLE = {__index = class} -local WEAK_KEYS_METATABLE = {__mode = "k"} --[[ 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._goalState) -- if the goal hasn't changed, then this is a TweenInfo change. @@ -63,7 +68,8 @@ end --[[ Returns the interior value of this state object. ]] -function class:_peek(): any +function class:_peek(): unknown + local self = self :: InternalTypes.Tween return self._currentValue end @@ -71,10 +77,26 @@ function class:get() logError("stateGetWasRemoved") end +function class:destroy() + local self = self :: InternalTypes.Tween + if self.scope == nil then + logError("destroyedTwice", nil, "Tween") + end + TweenScheduler.remove(self) + self.scope = nil + for dependency in pairs(self.dependencySet) do + dependency.dependentSet[self] = nil + end +end + local function Tween( - goalState: PubTypes.StateObject, - tweenInfo: PubTypes.CanBeState? + scope: Types.Scope, + goalState: Types.StateObject, + tweenInfo: Types.CanBeState? ): Types.Tween + if isState(scope) then + logError("scopeMissing", nil, "Tweens", "myScope:Tween(goalState, tweenInfo)") + end local currentValue = peek(goalState) -- apply defaults for tween info @@ -82,9 +104,10 @@ local function Tween( tweenInfo = TweenInfo.new() end - local dependencySet = {[goalState] = true} - local tweenInfoIsState = xtypeof(tweenInfo) == "State" + local dependencySet: {[Types.Dependency]: unknown} = {[goalState] = true} + local tweenInfoIsState = isState(tweenInfo) if tweenInfoIsState then + local tweenInfo = tweenInfo :: Types.StateObject dependencySet[tweenInfo] = true end @@ -95,12 +118,9 @@ local function Tween( end local self = setmetatable({ - type = "State", - kind = "Tween", + scope = scope, dependencySet = dependencySet, - -- if we held strong references to the dependents, then they wouldn't be - -- able to get garbage collected when they fall out of scope - dependentSet = setmetatable({}, WEAK_KEYS_METATABLE), + dependentSet = {}, _goalState = goalState, _tweenInfo = tweenInfo, _tweenInfoIsState = tweenInfoIsState, @@ -116,6 +136,14 @@ local function Tween( _currentTweenStartTime = 0, _currentlyAnimating = false }, CLASS_METATABLE) + local self = (self :: any) :: InternalTypes.Tween + + table.insert(scope, self) + if goalState.scope == nil then + logError("useAfterDestroy", nil, `The {goalState.kind} object`, `the Tween that is following it`) + elseif whichLivesLonger(scope, self, goalState.scope, goalState) == "definitely-a" then + logWarn("possiblyOutlives", `The {goalState.kind} object`, `the Tween that is following it`) + end -- add this object to the goal state's dependent set goalState.dependentSet[self] = true diff --git a/src/Animation/TweenScheduler.lua b/src/Animation/TweenScheduler.lua index 548c900a9..5eebea3c3 100644 --- a/src/Animation/TweenScheduler.lua +++ b/src/Animation/TweenScheduler.lua @@ -1,11 +1,12 @@ --!strict +--!nolint LocalShadow --[[ Manages batch updating of tween objects. ]] local Package = script.Parent.Parent -local Types = require(Package.Types) +local InternalTypes = require(Package.InternalTypes) local External = require(Package.External) local lerpType = require(Package.Animation.lerpType) local getTweenRatio = require(Package.Animation.getTweenRatio) @@ -13,26 +14,26 @@ local updateAll = require(Package.State.updateAll) local TweenScheduler = {} -type Set = {[T]: any} -type Tween = Types.Tween - -local WEAK_KEYS_METATABLE = {__mode = "k"} +type Set = {[T]: unknown} -- all the tweens currently being updated -local allTweens: Set = {} -setmetatable(allTweens, WEAK_KEYS_METATABLE) +local allTweens: Set> = {} --[[ Adds a Tween to be updated every render step. ]] -function TweenScheduler.add(tween: Tween) +function TweenScheduler.add( + tween: InternalTypes.Tween +) allTweens[tween] = true end --[[ Removes a Tween from the scheduler. ]] -function TweenScheduler.remove(tween: Tween) +function TweenScheduler.remove( + tween: InternalTypes.Tween +) allTweens[tween] = nil end @@ -42,8 +43,7 @@ end local function updateAllTweens( now: number ) - -- FIXME: Typed Luau doesn't understand this loop yet - for tween: Tween in pairs(allTweens :: any) do + for tween in allTweens do local currentTime = now - tween._currentTweenStartTime if currentTime > tween._currentTweenDuration and tween._currentTweenInfo.RepeatCount > -1 then diff --git a/src/Animation/getTweenRatio.lua b/src/Animation/getTweenRatio.lua index 1803606d5..587701cc3 100644 --- a/src/Animation/getTweenRatio.lua +++ b/src/Animation/getTweenRatio.lua @@ -1,4 +1,5 @@ --!strict +--!nolint LocalShadow --[[ Given a `tweenInfo` and `currentTime`, returns a ratio which can be used to @@ -7,7 +8,10 @@ local TweenService = game:GetService("TweenService") -local function getTweenRatio(tweenInfo: TweenInfo, currentTime: number): number +local function getTweenRatio( + tweenInfo: TweenInfo, + currentTime: number +): number local delay = tweenInfo.DelayTime local duration = tweenInfo.Time local reverses = tweenInfo.Reverses diff --git a/src/Animation/lerpType.lua b/src/Animation/lerpType.lua index 3530dbec6..b9f2b3f3a 100644 --- a/src/Animation/lerpType.lua +++ b/src/Animation/lerpType.lua @@ -1,19 +1,20 @@ --!strict +--!nolint LocalShadow --[[ Linearly interpolates the given animatable types by a ratio. If the types are different or not animatable, then the first value will be returned for ratios below 0.5, and the second value for 0.5 and above. - - FIXME: This function uses a lot of redefinitions to suppress false positives - from the Luau typechecker - ideally these wouldn't be required ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) local Oklab = require(Package.Colour.Oklab) -local function lerpType(from: any, to: any, ratio: number): any +local function lerpType( + from: unknown, + to: unknown, + ratio: number +): unknown local typeString = typeof(from) if typeof(to) == typeString then diff --git a/src/Animation/packType.lua b/src/Animation/packType.lua index 480024826..dcc5a6b6d 100644 --- a/src/Animation/packType.lua +++ b/src/Animation/packType.lua @@ -1,19 +1,19 @@ --!strict +--!nolint LocalShadow --[[ Packs an array of numbers into a given animatable data type. If the type is not animatable, nil will be returned. - - FUTURE: When Luau supports singleton types, those could be used in - conjunction with intersection types to make this function fully statically - type checkable. ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) +local Types = require(Package.Types) local Oklab = require(Package.Colour.Oklab) -local function packType(numbers: {number}, typeString: string): PubTypes.Animatable? +local function packType( + numbers: {number}, + typeString: string +): Types.Animatable? if typeString == "number" then return numbers[1] diff --git a/src/Animation/springCoefficients.lua b/src/Animation/springCoefficients.lua index 9f87a7823..940399746 100644 --- a/src/Animation/springCoefficients.lua +++ b/src/Animation/springCoefficients.lua @@ -1,4 +1,5 @@ --!strict +--!nolint LocalShadow --[[ Returns a 2x2 matrix of coefficients for a given time, damping and speed. @@ -11,7 +12,11 @@ Special thanks to AxisAngle for helping to improve numerical precision. ]] -local function springCoefficients(time: number, damping: number, speed: number): (number, number, number, number) +local function springCoefficients( + time: number, + damping: number, + speed: number +): (number, number, number, number) -- if time or speed is 0, then the spring won't move if time == 0 or speed == 0 then return 1, 0, 0, 1 diff --git a/src/Animation/unpackType.lua b/src/Animation/unpackType.lua index 7eacfbe24..877615353 100644 --- a/src/Animation/unpackType.lua +++ b/src/Animation/unpackType.lua @@ -1,59 +1,65 @@ --!strict +--!nolint LocalShadow --[[ Unpacks an animatable type into an array of numbers. If the type is not animatable, an empty array will be returned. - - FIXME: This function uses a lot of redefinitions to suppress false positives - from the Luau typechecker - ideally these wouldn't be required - - FUTURE: When Luau supports singleton types, those could be used in - conjunction with intersection types to make this function fully statically - type checkable. ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) local Oklab = require(Package.Colour.Oklab) -local function unpackType(value: any, typeString: string): {number} +local function unpackType( + value: unknown, + typeString: string +): {number} if typeString == "number" then local value = value :: number return {value} elseif typeString == "CFrame" then + local value = value :: CFrame -- FUTURE: is there a better way of doing this? doing distance -- calculations on `angle` may be incorrect local axis, angle = value:ToAxisAngle() return {value.X, value.Y, value.Z, axis.X, axis.Y, axis.Z, angle} elseif typeString == "Color3" then + local value = value :: Color3 local lab = Oklab.to(value) return {lab.X, lab.Y, lab.Z} elseif typeString == "ColorSequenceKeypoint" then + local value = value :: ColorSequenceKeypoint local lab = Oklab.to(value.Value) return {lab.X, lab.Y, lab.Z, value.Time} elseif typeString == "DateTime" then + local value = value :: DateTime return {value.UnixTimestampMillis} elseif typeString == "NumberRange" then + local value = value :: NumberRange return {value.Min, value.Max} elseif typeString == "NumberSequenceKeypoint" then + local value = value :: NumberSequenceKeypoint return {value.Value, value.Time, value.Envelope} elseif typeString == "PhysicalProperties" then + local value = value :: PhysicalProperties return {value.Density, value.Friction, value.Elasticity, value.FrictionWeight, value.ElasticityWeight} elseif typeString == "Ray" then + local value = value :: Ray return {value.Origin.X, value.Origin.Y, value.Origin.Z, value.Direction.X, value.Direction.Y, value.Direction.Z} elseif typeString == "Rect" then + local value = value :: Rect return {value.Min.X, value.Min.Y, value.Max.X, value.Max.Y} elseif typeString == "Region3" then + local value = value :: Region3 -- FUTURE: support rotated Region3s if/when they become constructable return { value.CFrame.X, value.CFrame.Y, value.CFrame.Z, @@ -61,24 +67,31 @@ local function unpackType(value: any, typeString: string): {number} } elseif typeString == "Region3int16" then + local value = value :: Region3int16 return {value.Min.X, value.Min.Y, value.Min.Z, value.Max.X, value.Max.Y, value.Max.Z} elseif typeString == "UDim" then + local value = value :: UDim return {value.Scale, value.Offset} elseif typeString == "UDim2" then + local value = value :: UDim2 return {value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset} elseif typeString == "Vector2" then + local value = value :: Vector2 return {value.X, value.Y} elseif typeString == "Vector2int16" then + local value = value :: Vector2int16 return {value.X, value.Y} elseif typeString == "Vector3" then + local value = value :: Vector3 return {value.X, value.Y, value.Z} elseif typeString == "Vector3int16" then + local value = value :: Vector3int16 return {value.X, value.Y, value.Z} else return {} diff --git a/src/Colour/Oklab.lua b/src/Colour/Oklab.lua index ea090f91d..db3be8fc5 100644 --- a/src/Colour/Oklab.lua +++ b/src/Colour/Oklab.lua @@ -1,4 +1,5 @@ --!strict +--!nolint LocalShadow --[[ Provides functions for converting Color3s into Oklab space, for more diff --git a/src/External.lua b/src/External.lua index 0b274b1d8..62e1a1238 100644 --- a/src/External.lua +++ b/src/External.lua @@ -1,7 +1,9 @@ --!strict +--!nolint LocalShadow + --[[ - Abstraction layer between Fusion internals and external environments, - allowing for flexible integration with schedulers and test mocks. + Abstraction layer between Fusion internals and external environments, + allowing for flexible integration with schedulers and test mocks. ]] local Package = script.Parent @@ -9,15 +11,19 @@ local logError = require(Package.Logging.logError) local External = {} +-- Silences non-fatal error logging, used in unit tests for cleaning up output +-- and removing false negatives when checking how systems respond to errors. +External.unitTestSilenceNonFatal = false + export type Scheduler = { - doTaskImmediate: ( - resume: () -> () - ) -> (), - doTaskDeferred: ( - resume: () -> () - ) -> (), - startScheduler: () -> (), - stopScheduler: () -> () + doTaskImmediate: ( + resume: () -> () + ) -> (), + doTaskDeferred: ( + resume: () -> () + ) -> (), + startScheduler: () -> (), + stopScheduler: () -> () } local updateStepCallbacks = {} @@ -25,90 +31,90 @@ local currentScheduler: Scheduler? = nil local lastUpdateStep = 0 --[[ - Sets the external scheduler that Fusion will use for queuing async tasks. - Returns the previous scheduler so it can be reset later. + Sets the external scheduler that Fusion will use for queuing async tasks. + Returns the previous scheduler so it can be reset later. ]] function External.setExternalScheduler( - newScheduler: Scheduler? + newScheduler: Scheduler? ): Scheduler? - local oldScheduler = currentScheduler - if oldScheduler ~= nil then - oldScheduler.stopScheduler() - end - currentScheduler = newScheduler - if newScheduler ~= nil then - newScheduler.startScheduler() - end - return oldScheduler + local oldScheduler = currentScheduler + if oldScheduler ~= nil then + oldScheduler.stopScheduler() + end + currentScheduler = newScheduler + if newScheduler ~= nil then + newScheduler.startScheduler() + end + return oldScheduler end --[[ Sends an immediate task to the external scheduler. Throws if none is set. ]] function External.doTaskImmediate( - resume: () -> () + resume: () -> () ) - if currentScheduler == nil then - logError("noTaskScheduler") - else - currentScheduler.doTaskImmediate(resume) - end + if currentScheduler == nil then + logError("noTaskScheduler") + else + currentScheduler.doTaskImmediate(resume) + end end --[[ - Sends a deferred task to the external scheduler. Throws if none is set. + Sends a deferred task to the external scheduler. Throws if none is set. ]] function External.doTaskDeferred( - resume: () -> () + resume: () -> () ) - if currentScheduler == nil then - logError("noTaskScheduler") - else - currentScheduler.doTaskDeferred(resume) - end + if currentScheduler == nil then + logError("noTaskScheduler") + else + currentScheduler.doTaskDeferred(resume) + end end --[[ - Registers a callback to the update step of the external scheduler. - Returns a function that can be used to disconnect later. + Registers a callback to the update step of the external scheduler. + Returns a function that can be used to disconnect later. - Callbacks are given the current number of seconds since an arbitrary epoch. - - TODO: This epoch may change between schedulers. We could investigate ways - of allowing schedulers to co-operate to keep the epoch the same, so that - monotonicity can be better preserved. + Callbacks are given the current number of seconds since an arbitrary epoch. + + TODO: This epoch may change between schedulers. We could investigate ways + of allowing schedulers to co-operate to keep the epoch the same, so that + monotonicity can be better preserved. ]] function External.bindToUpdateStep( - callback: ( - now: number - ) -> () + callback: ( + now: number + ) -> () ): () -> () - local uniqueIdentifier = {} - updateStepCallbacks[uniqueIdentifier] = callback - return function() - updateStepCallbacks[uniqueIdentifier] = nil - end + local uniqueIdentifier = {} + updateStepCallbacks[uniqueIdentifier] = callback + return function() + updateStepCallbacks[uniqueIdentifier] = nil + end end --[[ - Steps time-dependent systems with the current number of seconds since an - arbitrary epoch. This should be called as early as possible in the external - scheduler's update cycle. + Steps time-dependent systems with the current number of seconds since an + arbitrary epoch. This should be called as early as possible in the external + scheduler's update cycle. ]] function External.performUpdateStep( - now: number + now: number ) - lastUpdateStep = now - for _, callback in updateStepCallbacks do - callback(now) - end + lastUpdateStep = now + for _, callback in updateStepCallbacks do + callback(now) + end end --[[ - Returns the timestamp of the last update step. + Returns the timestamp of the last update step. ]] function External.lastUpdateStep() - return lastUpdateStep + return lastUpdateStep end return External \ No newline at end of file diff --git a/src/Instances/Attribute.lua b/src/Instances/Attribute.lua index d9a968b0f..059ec4d6a 100644 --- a/src/Instances/Attribute.lua +++ b/src/Instances/Attribute.lua @@ -1,55 +1,58 @@ --!strict +--!nolint LocalShadow --[[ A special key for property tables, which allows users to apply custom - attributes to instances + attributes to instances ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) +local Types = require(Package.Types) local External = require(Package.External) local logError = require(Package.Logging.logError) -local xtypeof = require(Package.Utility.xtypeof) +local logWarn = require(Package.Logging.logWarn) +local isState = require(Package.State.isState) local Observer = require(Package.State.Observer) local peek = require(Package.State.peek) - -local function setAttribute(instance: Instance, attribute: string, value: any) - instance:SetAttribute(attribute, value) -end - -local function bindAttribute(instance: Instance, attribute: string, value: any, cleanupTasks: {PubTypes.Task}) - if xtypeof(value) == "State" then - local didDefer = false - local function update() - if not didDefer then - didDefer = true - External.doTaskDeferred(function() - didDefer = false - setAttribute(instance, attribute, peek(value)) - end) - end - end - setAttribute(instance, attribute, peek(value)) - table.insert(cleanupTasks, Observer(value :: any):onChange(update)) - else - setAttribute(instance, attribute, value) - end -end - -local function Attribute(attributeName: string): PubTypes.SpecialKey - local AttributeKey = {} - AttributeKey.type = "SpecialKey" - AttributeKey.kind = "Attribute" - AttributeKey.stage = "self" - - if attributeName == nil then - logError("attributeNameNil") - end - - function AttributeKey:apply(attributeValue: any, applyTo: Instance, cleanupTasks: {PubTypes.Task}) - bindAttribute(applyTo, attributeName, attributeValue, cleanupTasks) - end - return AttributeKey +local whichLivesLonger = require(Package.Memory.whichLivesLonger) + +local function Attribute( + attributeName: string +): Types.SpecialKey + return { + type = "SpecialKey", + kind = "Attribute", + stage = "self", + apply = function( + self: Types.SpecialKey, + scope: Types.Scope, + value: unknown, + applyTo: Instance + ) + if isState(value) then + local value = value :: Types.StateObject + if value.scope == nil then + logError("useAfterDestroy", nil, `The {value.kind} object, bound to [Attribute "{attributeName}"],`, `the {applyTo.ClassName} instance`) + elseif whichLivesLonger(scope, applyTo, value.scope, value) == "definitely-a" then + logWarn("possiblyOutlives", `The {value.kind} object, bound to [Attribute "{attributeName}"],`, `the {applyTo.ClassName} instance`) + end + local didDefer = false + local function update() + if not didDefer then + didDefer = true + External.doTaskDeferred(function() + didDefer = false + applyTo:SetAttribute(attributeName, peek(value)) + end) + end + end + applyTo:SetAttribute(attributeName, peek(value)) + table.insert(scope, Observer(scope, value :: any):onChange(update)) + else + applyTo:SetAttribute(attributeName, value) + end + end + } end return Attribute \ No newline at end of file diff --git a/src/Instances/AttributeChange.lua b/src/Instances/AttributeChange.lua index 29c1e2332..556448b82 100644 --- a/src/Instances/AttributeChange.lua +++ b/src/Instances/AttributeChange.lua @@ -1,40 +1,39 @@ --!strict +--!nolint LocalShadow --[[ A special key for property tables, which allows users to connect to - an attribute change on an instance. + an attribute change on an instance. ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) +local Types = require(Package.Types) local logError = require(Package.Logging.logError) -local xtypeof = require(Package.Utility.xtypeof) -local function AttributeChange(attributeName: string): PubTypes.SpecialKey - local attributeKey = {} - attributeKey.type = "SpecialKey" - attributeKey.kind = "AttributeChange" - attributeKey.stage = "observer" - - if attributeName == nil then - logError("attributeNameNil") - end - - function attributeKey:apply(callback: any, applyTo: Instance, cleanupTasks: {PubTypes.Task}) - if typeof(callback) ~= "function" then - logError("invalidAttributeChangeHandler", nil, attributeName) - end - local ok, event = pcall(applyTo.GetAttributeChangedSignal, applyTo, attributeName) - if not ok then - logError("cannotConnectAttributeChange", nil, applyTo.ClassName, attributeName) - else - callback((applyTo :: any):GetAttribute(attributeName)) - table.insert(cleanupTasks, event:Connect(function() - callback((applyTo :: any):GetAttribute(attributeName)) +local function AttributeChange( + attributeName: string +): Types.SpecialKey + return { + type = "SpecialKey", + kind = "AttributeChange", + stage = "observer", + apply = function( + self: Types.SpecialKey, + scope: Types.Scope, + value: unknown, + applyTo: Instance + ) + if typeof(value) ~= "function" then + logError("invalidAttributeChangeHandler", nil, attributeName) + end + local value = value :: (...unknown) -> (...unknown) + local event = applyTo:GetAttributeChangedSignal(attributeName) + value((applyTo :: any):GetAttribute(attributeName)) + table.insert(scope, event:Connect(function() + value((applyTo :: any):GetAttribute(attributeName)) end)) end - end - return attributeKey + } end return AttributeChange \ No newline at end of file diff --git a/src/Instances/AttributeOut.lua b/src/Instances/AttributeOut.lua index 257b1c66d..39c43ad4c 100644 --- a/src/Instances/AttributeOut.lua +++ b/src/Instances/AttributeOut.lua @@ -1,43 +1,53 @@ --!strict +--!nolint LocalShadow --[[ A special key for property tables, which allows users to save instance attributes - into state objects + into state objects ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) +local Types = require(Package.Types) local logError = require(Package.Logging.logError) -local xtypeof = require(Package.Utility.xtypeof) +local logWarn = require(Package.Logging.logWarn) +local isState = require(Package.State.isState) +local whichLivesLonger = require(Package.Memory.whichLivesLonger) -local function AttributeOut(attributeName: string): PubTypes.SpecialKey - local attributeOutKey = {} - attributeOutKey.type = "SpecialKey" - attributeOutKey.kind = "AttributeOut" - attributeOutKey.stage = "observer" +local function AttributeOut( + attributeName: string +): Types.SpecialKey + return { + type = "SpecialKey", + kind = "AttributeOut", + stage = "observer", + apply = function( + self: Types.SpecialKey, + scope: Types.Scope, + value: unknown, + applyTo: Instance + ) + local event = applyTo:GetAttributeChangedSignal(attributeName) - function attributeOutKey:apply(stateObject: PubTypes.StateObject, applyTo: Instance, cleanupTasks: { PubTypes.Task }) - if xtypeof(stateObject) ~= "State" or stateObject.kind ~= "Value" then - logError("invalidAttributeOutType") - end - if attributeName == nil then - logError("attributeNameNil") - end - local ok, event = pcall(applyTo.GetAttributeChangedSignal, applyTo, attributeName) - if not ok then - logError("invalidOutAttributeName", applyTo.ClassName, attributeName) - else - stateObject:set((applyTo :: any):GetAttribute(attributeName)) - table.insert(cleanupTasks, event:Connect(function() - stateObject:set((applyTo :: any):GetAttribute(attributeName)) + if not isState(value) then + logError("invalidAttributeOutType") + end + local value = value :: Types.StateObject + if value.kind ~= "Value" then + logError("invalidAttributeOutType") + end + local value = value :: Types.Value + + if value.scope == nil then + logError("useAfterDestroy", nil, `The Value object, which [AttributeOut "{attributeName}"] outputs to,`, `the {applyTo.ClassName} instance`) + elseif whichLivesLonger(scope, applyTo, value.scope, value) == "definitely-a" then + logWarn("possiblyOutlives", `The Value object, which [AttributeOut "{attributeName}"] outputs to,`, `the {applyTo.ClassName} instance`) + end + value:set((applyTo :: any):GetAttribute(attributeName)) + table.insert(scope, event:Connect(function() + value:set((applyTo :: any):GetAttribute(attributeName)) end)) - table.insert(cleanupTasks, function() - stateObject:set(nil) - end) end - end - - return attributeOutKey + } end return AttributeOut diff --git a/src/Instances/Children.lua b/src/Instances/Children.lua index 2e756a8c9..c1d05a1fa 100644 --- a/src/Instances/Children.lua +++ b/src/Instances/Children.lua @@ -1,4 +1,5 @@ --!strict +--!nolint LocalShadow --[[ A special key for property tables, which parents any given descendants into @@ -6,145 +7,160 @@ ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) +local Types = require(Package.Types) local External = require(Package.External) local logWarn = require(Package.Logging.logWarn) local Observer = require(Package.State.Observer) local peek = require(Package.State.peek) local isState = require(Package.State.isState) +local doCleanup = require(Package.Memory.doCleanup) +local scopePool = require(Package.Memory.scopePool) -type Set = {[T]: boolean} +type Set = {[T]: unknown} -- Experimental flag: name children based on the key used in the [Children] table local EXPERIMENTAL_AUTO_NAMING = false -local Children = {} -Children.type = "SpecialKey" -Children.kind = "Children" -Children.stage = "descendants" - -function Children:apply(propValue: any, applyTo: Instance, cleanupTasks: {PubTypes.Task}) - local newParented: Set = {} - local oldParented: Set = {} - - -- save disconnection functions for state object observers - local newDisconnects: {[PubTypes.StateObject]: () -> ()} = {} - local oldDisconnects: {[PubTypes.StateObject]: () -> ()} = {} - - local updateQueued = false - local queueUpdate: () -> () - - -- Rescans this key's value to find new instances to parent and state objects - -- to observe for changes; then unparents instances no longer found and - -- disconnects observers for state objects no longer present. - local function updateChildren() - if not updateQueued then - return -- this update may have been canceled by destruction, etc. - end - updateQueued = false - - oldParented, newParented = newParented, oldParented - oldDisconnects, newDisconnects = newDisconnects, oldDisconnects - table.clear(newParented) - table.clear(newDisconnects) - - local function processChild(child: any, autoName: string?) - local childType = typeof(child) - - if childType == "Instance" then - -- case 1; single instance - - newParented[child] = true - if oldParented[child] == nil then - -- wasn't previously present - - -- TODO: check for ancestry conflicts here - child.Parent = applyTo - else - -- previously here; we want to reuse, so remove from old - -- set so we don't encounter it during unparenting - oldParented[child] = nil - end - - if EXPERIMENTAL_AUTO_NAMING and autoName ~= nil then - child.Name = autoName - end - - elseif isState(child) then - -- case 2; state object - - local value = peek(child) - -- allow nil to represent the absence of a child - if value ~= nil then - processChild(value, autoName) - end - - local disconnect = oldDisconnects[child] - if disconnect == nil then - -- wasn't previously present - disconnect = Observer(child):onChange(queueUpdate) - else - -- previously here; we want to reuse, so remove from old - -- set so we don't encounter it during unparenting - oldDisconnects[child] = nil - end - - newDisconnects[child] = disconnect - - elseif childType == "table" then - -- case 3; table of objects - - for key, subChild in pairs(child) do - local keyType = typeof(key) - local subAutoName: string? = nil - - if keyType == "string" then - subAutoName = key - elseif keyType == "number" and autoName ~= nil then - subAutoName = autoName .. "_" .. key +return { + type = "SpecialKey", + kind = "Children", + stage = "descendants", + apply = function( + self: Types.SpecialKey, + scope: Types.Scope, + value: unknown, + applyTo: Instance + ) + local newParented: Set = {} + local oldParented: Set = {} + + -- save scopes for state object observers + local newScopes: {[Types.StateObject]: Types.Scope} = {} + local oldScopes: {[Types.StateObject]: Types.Scope} = {} + + local updateQueued = false + local queueUpdate: () -> () + + -- Rescans this key's value to find new instances to parent and state objects + -- to observe for changes; then unparents instances no longer found and + -- disconnects observers for state objects no longer present. + local function updateChildren() + if not updateQueued then + return -- this update may have been canceled by destruction, etc. + end + updateQueued = false + + oldParented, newParented = newParented, oldParented + oldScopes, newScopes = newScopes, oldScopes + table.clear(newParented) + table.clear(newScopes) + + local function processChild( + child: unknown, + autoName: string? + ) + local childType = typeof(child) + + if childType == "Instance" then + -- case 1; single instance + local child = child :: Instance + + newParented[child] = true + if oldParented[child] == nil then + -- wasn't previously present + + -- TODO: check for ancestry conflicts here + child.Parent = applyTo + else + -- previously here; we want to reuse, so remove from old + -- set so we don't encounter it during unparenting + oldParented[child] = nil end - - processChild(subChild, subAutoName) + + if EXPERIMENTAL_AUTO_NAMING and autoName ~= nil then + child.Name = autoName + end + + elseif isState(child) then + -- case 2; state object + local child = child :: Types.StateObject + + local value = peek(child) + -- allow nil to represent the absence of a child + if value ~= nil then + processChild(value, autoName) + end + + local childScope = oldScopes[child] + if childScope == nil then + -- wasn't previously present + childScope = {} + Observer(childScope, child):onChange(queueUpdate) + else + -- previously here; we want to reuse, so remove from old + -- set so we don't encounter it during unparenting + oldScopes[child] = nil + end + + newScopes[child] = childScope + + elseif childType == "table" then + -- case 3; table of objects + local child = child :: {[unknown]: unknown} + + for key, subChild in pairs(child) do + local keyType = typeof(key) + local subAutoName: string? = nil + + if keyType == "string" then + local key = key :: string + subAutoName = key + elseif keyType == "number" and autoName ~= nil then + local key = key :: number + subAutoName = autoName .. "_" .. key + end + + processChild(subChild, subAutoName) + end + + else + logWarn("unrecognisedChildType", childType) end - - else - logWarn("unrecognisedChildType", childType) + end + + if value ~= nil then + -- `propValue` is set to nil on cleanup, so we don't process children + -- in that case + processChild(value) + end + + -- unparent any children that are no longer present + for oldInstance in pairs(oldParented) do + oldInstance.Parent = nil + end + + -- disconnect observers which weren't reused + for oldState, childScope in pairs(oldScopes) do + doCleanup(childScope) + scopePool.clearAndGive(childScope) end end - - if propValue ~= nil then - -- `propValue` is set to nil on cleanup, so we don't process children - -- in that case - processChild(propValue) - end - - -- unparent any children that are no longer present - for oldInstance in pairs(oldParented) do - oldInstance.Parent = nil - end - - -- disconnect observers which weren't reused - for oldState, disconnect in pairs(oldDisconnects) do - disconnect() + + queueUpdate = function() + if not updateQueued then + updateQueued = true + External.doTaskDeferred(updateChildren) + end end - end - - queueUpdate = function() - if not updateQueued then + + table.insert(scope, function() + value = nil updateQueued = true - External.doTaskDeferred(updateChildren) - end - end - - table.insert(cleanupTasks, function() - propValue = nil + updateChildren() + end) + + -- perform initial child parenting updateQueued = true updateChildren() - end) - - -- perform initial child parenting - updateQueued = true - updateChildren() -end - -return Children :: PubTypes.SpecialKey \ No newline at end of file + end +} :: Types.SpecialKey \ No newline at end of file diff --git a/src/Instances/Cleanup.lua b/src/Instances/Cleanup.lua deleted file mode 100644 index 5fd71002b..000000000 --- a/src/Instances/Cleanup.lua +++ /dev/null @@ -1,20 +0,0 @@ ---!strict - ---[[ - A special key for property tables, which adds user-specified tasks to be run - when the instance is destroyed. -]] - -local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) - -local Cleanup = {} -Cleanup.type = "SpecialKey" -Cleanup.kind = "Cleanup" -Cleanup.stage = "observer" - -function Cleanup:apply(userTask: any, applyTo: Instance, cleanupTasks: {PubTypes.Task}) - table.insert(cleanupTasks, userTask) -end - -return Cleanup \ No newline at end of file diff --git a/src/Instances/Hydrate.lua b/src/Instances/Hydrate.lua index b913d27d5..22d4b70e9 100644 --- a/src/Instances/Hydrate.lua +++ b/src/Instances/Hydrate.lua @@ -1,4 +1,5 @@ --!strict +--!nolint LocalShadow --[[ Processes and returns an existing instance, with options for setting @@ -6,12 +7,23 @@ ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) +local Types = require(Package.Types) local applyInstanceProps = require(Package.Instances.applyInstanceProps) +local logError = require(Package.Logging.logError) -local function Hydrate(target: Instance) - return function(props: PubTypes.PropertyTable): Instance - applyInstanceProps(props, target) +local function Hydrate( + scope: Types.Scope, + target: Instance +) + if target :: any == nil then + logError("scopeMissing", nil, "instances using Hydrate", "myScope:Hydrate (instance) { ... }") + end + return function( + props: Types.PropertyTable + ): Instance + + table.insert(scope, target) + applyInstanceProps(scope, props, target) return target end end diff --git a/src/Instances/New.lua b/src/Instances/New.lua index 6fc46f9cd..c28203179 100644 --- a/src/Instances/New.lua +++ b/src/Instances/New.lua @@ -1,4 +1,5 @@ --!strict +--!nolint LocalShadow --[[ Constructs and returns a new instance, with options for setting properties, @@ -6,15 +7,23 @@ ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) +local Types = require(Package.Types) local defaultProps = require(Package.Instances.defaultProps) local applyInstanceProps = require(Package.Instances.applyInstanceProps) local logError= require(Package.Logging.logError) -local function New(className: string) - return function(props: PubTypes.PropertyTable): Instance +local function New( + scope: Types.Scope, + className: string +) + if (className :: any) == nil then + local scope = (scope :: any) :: string + logError("scopeMissing", nil, "instances using New", "myScope:New \"" .. scope .. "\" { ... }") + end + return function( + props: Types.PropertyTable + ): Instance local ok, instance = pcall(Instance.new, className) - if not ok then logError("cannotCreateClass", nil, className) end @@ -22,11 +31,12 @@ local function New(className: string) local classDefaults = defaultProps[className] if classDefaults ~= nil then for defaultProp, defaultValue in pairs(classDefaults) do - instance[defaultProp] = defaultValue + (instance :: any)[defaultProp] = defaultValue end end - applyInstanceProps(props, instance) + table.insert(scope, instance) + applyInstanceProps(scope, props, instance) return instance end diff --git a/src/Instances/OnChange.lua b/src/Instances/OnChange.lua index 2561eeeb4..15d7e5986 100644 --- a/src/Instances/OnChange.lua +++ b/src/Instances/OnChange.lua @@ -1,4 +1,5 @@ --!strict +--!nolint LocalShadow --[[ Constructs special keys for property tables which connect property change @@ -6,29 +7,35 @@ ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) +local Types = require(Package.Types) local logError = require(Package.Logging.logError) -local function OnChange(propertyName: string): PubTypes.SpecialKey - local changeKey = {} - changeKey.type = "SpecialKey" - changeKey.kind = "OnChange" - changeKey.stage = "observer" - - function changeKey:apply(callback: any, applyTo: Instance, cleanupTasks: {PubTypes.Task}) - local ok, event = pcall(applyTo.GetPropertyChangedSignal, applyTo, propertyName) - if not ok then - logError("cannotConnectChange", nil, applyTo.ClassName, propertyName) - elseif typeof(callback) ~= "function" then - logError("invalidChangeHandler", nil, propertyName) - else - table.insert(cleanupTasks, event:Connect(function() - callback((applyTo :: any)[propertyName]) - end)) +local function OnChange( + propertyName: string +): Types.SpecialKey + return { + type = "SpecialKey", + kind = "OnChange", + stage = "observer", + apply = function( + self: Types.SpecialKey, + scope: Types.Scope, + callback: unknown, + applyTo: Instance + ) + local ok, event = pcall(applyTo.GetPropertyChangedSignal, applyTo, propertyName) + if not ok then + logError("cannotConnectChange", nil, applyTo.ClassName, propertyName) + elseif typeof(callback) ~= "function" then + logError("invalidChangeHandler", nil, propertyName) + else + local callback = callback :: (...unknown) -> (...unknown) + table.insert(scope, event:Connect(function() + callback((applyTo :: any)[propertyName]) + end)) + end end - end - - return changeKey + } end return OnChange \ No newline at end of file diff --git a/src/Instances/OnEvent.lua b/src/Instances/OnEvent.lua index 92a5880c4..418ef8356 100644 --- a/src/Instances/OnEvent.lua +++ b/src/Instances/OnEvent.lua @@ -1,4 +1,5 @@ --!strict +--!nolint LocalShadow --[[ Constructs special keys for property tables which connect event listeners to @@ -6,31 +7,39 @@ ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) +local Types = require(Package.Types) local logError = require(Package.Logging.logError) -local function getProperty_unsafe(instance: Instance, property: string) +local function getProperty_unsafe( + instance: Instance, + property: string +) return (instance :: any)[property] end -local function OnEvent(eventName: string): PubTypes.SpecialKey - local eventKey = {} - eventKey.type = "SpecialKey" - eventKey.kind = "OnEvent" - eventKey.stage = "observer" - - function eventKey:apply(callback: any, applyTo: Instance, cleanupTasks: {PubTypes.Task}) - local ok, event = pcall(getProperty_unsafe, applyTo, eventName) - if not ok or typeof(event) ~= "RBXScriptSignal" then - logError("cannotConnectEvent", nil, applyTo.ClassName, eventName) - elseif typeof(callback) ~= "function" then - logError("invalidEventHandler", nil, eventName) - else - table.insert(cleanupTasks, event:Connect(callback)) +local function OnEvent( + eventName: string +): Types.SpecialKey + return { + type = "SpecialKey", + kind = "OnEvent", + stage = "observer", + apply = function( + self: Types.SpecialKey, + scope: Types.Scope, + callback: unknown, + applyTo: Instance + ) + local ok, event = pcall(getProperty_unsafe, applyTo, eventName) + if not ok or typeof(event) ~= "RBXScriptSignal" then + logError("cannotConnectEvent", nil, applyTo.ClassName, eventName) + elseif typeof(callback) ~= "function" then + logError("invalidEventHandler", nil, eventName) + else + table.insert(scope, event:Connect(callback)) + end end - end - - return eventKey + } end return OnEvent \ No newline at end of file diff --git a/src/Instances/Out.lua b/src/Instances/Out.lua index c205f1d27..47d4c9ab0 100644 --- a/src/Instances/Out.lua +++ b/src/Instances/Out.lua @@ -1,4 +1,5 @@ --!strict +--!nolint LocalShadow --[[ A special key for property tables, which allows users to extract values from @@ -6,37 +7,53 @@ ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) +local Types = require(Package.Types) local logError = require(Package.Logging.logError) -local xtypeof = require(Package.Utility.xtypeof) +local logWarn = require(Package.Logging.logWarn) +local isState = require(Package.State.isState) +local whichLivesLonger = require(Package.Memory.whichLivesLonger) -local function Out(propertyName: string): PubTypes.SpecialKey - local outKey = {} - outKey.type = "SpecialKey" - outKey.kind = "Out" - outKey.stage = "observer" +local function Out( + propertyName: string +): Types.SpecialKey + return { + type = "SpecialKey", + kind = "Out", + stage = "observer", + apply = function( + self: Types.SpecialKey, + scope: Types.Scope, + value: unknown, + applyTo: Instance + ) + local ok, event = pcall(applyTo.GetPropertyChangedSignal, applyTo, propertyName) + if not ok then + logError("invalidOutProperty", nil, applyTo.ClassName, propertyName) + end - function outKey:apply(outState: any, applyTo: Instance, cleanupTasks: { PubTypes.Task }) - local ok, event = pcall(applyTo.GetPropertyChangedSignal, applyTo, propertyName) - if not ok then - logError("invalidOutProperty", nil, applyTo.ClassName, propertyName) - elseif xtypeof(outState) ~= "State" or outState.kind ~= "Value" then - logError("invalidOutType") - else - outState:set((applyTo :: any)[propertyName]) + if not isState(value) then + logError("invalidOutType") + end + local value = value :: Types.StateObject + if value.kind ~= "Value" then + logError("invalidOutType") + end + local value = value :: Types.Value + + if value.scope == nil then + logError("useAfterDestroy", nil, `The Value, which [Out "{propertyName}"] outputs to,`, `the {applyTo.ClassName} instance`) + elseif whichLivesLonger(scope, applyTo, value.scope, value) == "definitely-a" then + logWarn("possiblyOutlives", `The Value, which [Out "{propertyName}"] outputs to,`, `the {applyTo.ClassName} instance`) + end + value:set((applyTo :: any)[propertyName]) table.insert( - cleanupTasks, + scope, event:Connect(function() - outState:set((applyTo :: any)[propertyName]) + value:set((applyTo :: any)[propertyName]) end) ) - table.insert(cleanupTasks, function() - outState:set(nil) - end) end - end - - return outKey + } end return Out diff --git a/src/Instances/Ref.lua b/src/Instances/Ref.lua index 4bcab0bcd..031eb61c2 100644 --- a/src/Instances/Ref.lua +++ b/src/Instances/Ref.lua @@ -1,4 +1,5 @@ --!strict +--!nolint LocalShadow --[[ A special key for property tables, which stores a reference to the instance @@ -6,24 +7,36 @@ ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) +local Types = require(Package.Types) +local logWarn = require(Package.Logging.logWarn) local logError = require(Package.Logging.logError) -local xtypeof = require(Package.Utility.xtypeof) +local isState = require(Package.State.isState) +local whichLivesLonger = require(Package.Memory.whichLivesLonger) -local Ref = {} -Ref.type = "SpecialKey" -Ref.kind = "Ref" -Ref.stage = "observer" +return { + type = "SpecialKey", + kind = "Ref", + stage = "observer", + apply = function( + self: Types.SpecialKey, + scope: Types.Scope, + value: unknown, + applyTo: Instance + ) + if not isState(value) then + logError("invalidRefType") + end + local value = value :: Types.StateObject + if value.kind ~= "Value" then + logError("invalidRefType") + end + local value = value :: Types.Value -function Ref:apply(refState: any, applyTo: Instance, cleanupTasks: {PubTypes.Task}) - if xtypeof(refState) ~= "State" or refState.kind ~= "Value" then - logError("invalidRefType") - else - refState:set(applyTo) - table.insert(cleanupTasks, function() - refState:set(nil) - end) + if value.scope == nil then + logError("useAfterDestroy", nil, "The Value object, which [Ref] outputs to,", `the {applyTo} instance`) + elseif whichLivesLonger(scope, applyTo, value.scope, value) == "definitely-a" then + logWarn("possiblyOutlives", "The Value object, which [Ref] outputs to,", `the {applyTo} instance`) + end + value:set(applyTo) end -end - -return Ref \ No newline at end of file +} :: Types.SpecialKey \ No newline at end of file diff --git a/src/Instances/applyInstanceProps.lua b/src/Instances/applyInstanceProps.lua index ed45a07dc..902baf188 100644 --- a/src/Instances/applyInstanceProps.lua +++ b/src/Instances/applyInstanceProps.lua @@ -1,4 +1,5 @@ --!strict +--!nolint LocalShadow --[[ Applies a table of properties to an instance, including binding to any @@ -14,35 +15,42 @@ ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) +local Types = require(Package.Types) local External = require(Package.External) -local cleanup = require(Package.Utility.cleanup) -local xtypeof = require(Package.Utility.xtypeof) +local isState = require(Package.State.isState) local logError = require(Package.Logging.logError) +local logWarn = require(Package.Logging.logWarn) local parseError = require(Package.Logging.parseError) local Observer = require(Package.State.Observer) local peek = require(Package.State.peek) +local xtypeof = require(Package.Utility.xtypeof) +local whichLivesLonger = require(Package.Memory.whichLivesLonger) -local function setProperty_unsafe(instance: Instance, property: string, value: any) +local function setProperty_unsafe( + instance: Instance, + property: string, + value: unknown +) (instance :: any)[property] = value end -local function testPropertyAssignable(instance: Instance, property: string) +local function testPropertyAssignable( + instance: Instance, + property: string +) (instance :: any)[property] = (instance :: any)[property] end -local function setProperty(instance: Instance, property: string, value: any) - local success, err = xpcall(setProperty_unsafe, parseError, instance, property, value) +local function setProperty( + instance: Instance, + property: string, + value: unknown +) + local success, err = xpcall(setProperty_unsafe :: any, parseError, instance, property, value) if not success then if not pcall(testPropertyAssignable, instance, property) then - if instance == nil then - -- reference has been lost - logError("setPropertyNilRef", nil, property, tostring(value)) - else - -- property is not assignable - logError("cannotAssignProperty", nil, instance.ClassName, property) - end + logError("cannotAssignProperty", nil, instance.ClassName, property) else -- property is assignable, but this specific assignment failed -- this typically implies the wrong type was received @@ -58,8 +66,20 @@ local function setProperty(instance: Instance, property: string, value: any) end end -local function bindProperty(instance: Instance, property: string, value: PubTypes.CanBeState, cleanupTasks: {PubTypes.Task}) - if xtypeof(value) == "State" then +local function bindProperty( + scope: Types.Scope, + instance: Instance, + property: string, + value: Types.CanBeState +) + if isState(value) then + local value = value :: Types.StateObject + if value.scope == nil then + logError("useAfterDestroy", nil, `The {value.kind} object, bound to {property},`, `the {instance.ClassName} instance`) + elseif whichLivesLonger(scope, instance, value.scope, value) == "definitely-a" then + logWarn("possiblyOutlives", `The {value.kind} object, bound to {property},`, `the {instance.ClassName} instance`) + end + -- value is a state object - assign and observe for changes local willUpdate = false local function updateLater() @@ -73,31 +93,34 @@ local function bindProperty(instance: Instance, property: string, value: PubType end setProperty(instance, property, peek(value)) - table.insert(cleanupTasks, Observer(value :: any):onChange(updateLater)) + table.insert(scope, Observer(scope, value :: any):onChange(updateLater)) else -- value is a constant - assign once only setProperty(instance, property, value) end end -local function applyInstanceProps(props: PubTypes.PropertyTable, applyTo: Instance) +local function applyInstanceProps( + scope: Types.Scope, + props: Types.PropertyTable, + applyTo: Instance +) local specialKeys = { - self = {} :: {[PubTypes.SpecialKey]: any}, - descendants = {} :: {[PubTypes.SpecialKey]: any}, - ancestor = {} :: {[PubTypes.SpecialKey]: any}, - observer = {} :: {[PubTypes.SpecialKey]: any} + self = {} :: {[Types.SpecialKey]: unknown}, + descendants = {} :: {[Types.SpecialKey]: unknown}, + ancestor = {} :: {[Types.SpecialKey]: unknown}, + observer = {} :: {[Types.SpecialKey]: unknown} } - local cleanupTasks = {} for key, value in pairs(props) do local keyType = xtypeof(key) if keyType == "string" then if key ~= "Parent" then - bindProperty(applyTo, key :: string, value, cleanupTasks) + bindProperty(scope, applyTo, key :: string, value) end elseif keyType == "SpecialKey" then - local stage = (key :: PubTypes.SpecialKey).stage + local stage = (key :: Types.SpecialKey).stage local keys = specialKeys[stage] if keys == nil then logError("unrecognisedPropertyStage", nil, stage) @@ -106,31 +129,27 @@ local function applyInstanceProps(props: PubTypes.PropertyTable, applyTo: Instan end else -- we don't recognise what this key is supposed to be - logError("unrecognisedPropertyKey", nil, xtypeof(key)) + logError("unrecognisedPropertyKey", nil, keyType) end end for key, value in pairs(specialKeys.self) do - key:apply(value, applyTo, cleanupTasks) + key:apply(scope, value, applyTo) end for key, value in pairs(specialKeys.descendants) do - key:apply(value, applyTo, cleanupTasks) + key:apply(scope, value, applyTo) end if props.Parent ~= nil then - bindProperty(applyTo, "Parent", props.Parent, cleanupTasks) + bindProperty(scope, applyTo, "Parent", props.Parent) end for key, value in pairs(specialKeys.ancestor) do - key:apply(value, applyTo, cleanupTasks) + key:apply(scope, value, applyTo) end for key, value in pairs(specialKeys.observer) do - key:apply(value, applyTo, cleanupTasks) + key:apply(scope, value, applyTo) end - - applyTo.Destroying:Connect(function() - cleanup(cleanupTasks) - end) end return applyInstanceProps \ No newline at end of file diff --git a/src/Instances/defaultProps.lua b/src/Instances/defaultProps.lua index afe0cf47a..792c7b841 100644 --- a/src/Instances/defaultProps.lua +++ b/src/Instances/defaultProps.lua @@ -1,4 +1,5 @@ --!strict +--!nolint LocalShadow --[[ Stores 'sensible default' properties to be applied to instances created by diff --git a/src/InternalTypes.lua b/src/InternalTypes.lua new file mode 100644 index 000000000..b23b50d80 --- /dev/null +++ b/src/InternalTypes.lua @@ -0,0 +1,120 @@ +--!strict +--!nolint LocalShadow + +--[[ + 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 +]] + +-- Stores useful information about Luau errors. +export type Error = { + type: string, -- replace with "Error" when Luau supports singleton types + raw: string, + message: string, + trace: string +} + +-- 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: T +} + +-- 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.CanBeState<{[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 & { + _goalState: Value, + _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.CanBeState, + _damping: Types.CanBeState, + _goalState: Value, + _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/logError.lua b/src/Logging/logError.lua index c680da2d6..589b3f479 100644 --- a/src/Logging/logError.lua +++ b/src/Logging/logError.lua @@ -1,14 +1,19 @@ --!strict +--!nolint LocalShadow --[[ Utility function to log a Fusion-specific error. ]] local Package = script.Parent.Parent -local Types = require(Package.Types) +local InternalTypes = require(Package.InternalTypes) local messages = require(Package.Logging.messages) -local function logError(messageID: string, errObj: Types.Error?, ...) +local function logError( + messageID: string, + errObj: InternalTypes.Error?, + ...: unknown +) local formatString: string if messages[messageID] ~= nil then diff --git a/src/Logging/logErrorNonFatal.lua b/src/Logging/logErrorNonFatal.lua index e5b740d86..5f853dc51 100644 --- a/src/Logging/logErrorNonFatal.lua +++ b/src/Logging/logErrorNonFatal.lua @@ -1,14 +1,24 @@ --!strict +--!nolint LocalShadow --[[ Utility function to log a Fusion-specific error, without halting execution. ]] local Package = script.Parent.Parent -local Types = require(Package.Types) +local InternalTypes = require(Package.InternalTypes) +local External = require(Package.External) local messages = require(Package.Logging.messages) -local function logErrorNonFatal(messageID: string, errObj: Types.Error?, ...) +local function logErrorNonFatal( + messageID: string, + errObj: InternalTypes.Error?, + ...: unknown +) + if External.unitTestSilenceNonFatal then + return + end + local formatString: string if messages[messageID] ~= nil then diff --git a/src/Logging/logWarn.lua b/src/Logging/logWarn.lua index ce7867b4e..2216a0f30 100644 --- a/src/Logging/logWarn.lua +++ b/src/Logging/logWarn.lua @@ -1,4 +1,5 @@ --!strict +--!nolint LocalShadow --[[ Utility function to log a Fusion-specific warning. @@ -7,7 +8,10 @@ local Package = script.Parent.Parent local messages = require(Package.Logging.messages) -local function logWarn(messageID, ...) +local function logWarn( + messageID: string, + ...: unknown +) local formatString: string if messages[messageID] ~= nil then @@ -17,7 +21,9 @@ local function logWarn(messageID, ...) formatString = messages[messageID] end - warn(string.format("[Fusion] " .. formatString .. "\n(ID: " .. messageID .. ")", ...)) + local warnMessage = string.format("[Fusion] " .. formatString .. "\n(ID: " .. messageID .. ")", ...) + warnMessage ..= "\n---- Stack trace ----\n" .. debug.traceback(nil, 3) + warn(warnMessage) end return logWarn \ No newline at end of file diff --git a/src/Logging/messages.lua b/src/Logging/messages.lua index c283a7af4..214fe6c19 100644 --- a/src/Logging/messages.lua +++ b/src/Logging/messages.lua @@ -1,53 +1,43 @@ --!strict +--!nolint LocalShadow --[[ Stores templates for different kinds of logging messages. ]] return { - attributeNameNil = "Attribute name cannot be nil", + callbackError = "Error in callback: ERROR_MESSAGE", cannotAssignProperty = "The class type '%s' has no assignable property '%s'.", cannotConnectChange = "The %s class doesn't have a property called '%s'.", - cannotConnectAttributeChange = "The %s class doesn't have an attribute called '%s'.", cannotConnectEvent = "The %s class doesn't have an event called '%s'.", cannotCreateClass = "Can't create a new instance of class '%s'.", - computedCallbackError = "Computed callback error: ERROR_MESSAGE", - contextualCallbackError = "Contextual callback error: ERROR_MESSAGE", - destructorNeededValue = "To save instances into Values, provide a destructor function. This will be an error soon - see discussion #183 on GitHub.", - destructorNeededComputed = "To return instances from Computeds, provide a destructor function. This will be an error soon - see discussion #183 on GitHub.", - multiReturnComputed = "Returning multiple values from Computeds is discouraged, as behaviour will change soon - see discussion #189 on GitHub.", - destructorNeededForKeys = "To return instances from ForKeys, provide a destructor function. This will be an error soon - see discussion #183 on GitHub.", - destructorNeededForValues = "To return instances from ForValues, provide a destructor function. This will be an error soon - see discussion #183 on GitHub.", - destructorNeededForPairs = "To return instances from ForPairs, provide a destructor function. This will be an error soon - see discussion #183 on GitHub.", - forKeysProcessorError = "ForKeys callback error: ERROR_MESSAGE", - forKeysKeyCollision = "ForKeys should only write to output key '%s' once when processing key changes, but it wrote to it twice. Previously input key: '%s'; New input key: '%s'", - forKeysDestructorError = "ForKeys destructor error: ERROR_MESSAGE", - forPairsDestructorError = "ForPairs destructor error: ERROR_MESSAGE", - forPairsKeyCollision = "ForPairs should only write to output key '%s' once when processing key changes, but it wrote to it twice. Previous input pair: '[%s] = %s'; New input pair: '[%s] = %s'", - forPairsProcessorError = "ForPairs callback error: ERROR_MESSAGE", - forValuesProcessorError = "ForValues callback error: ERROR_MESSAGE", - forValuesDestructorError = "ForValues destructor error: ERROR_MESSAGE", - invalidChangeHandler = "The change handler for the '%s' property must be a function.", + cleanupWasRenamed = "`Fusion.cleanup` was renamed to `Fusion.doCleanup`. This will be an error in future versions of Fusion.", + destroyedTwice = "Attempted to destroy %s twice; ensure you're not manually calling `:destroy()` while using scopes. See discussion #292 on GitHub for advice.", + 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.", 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.", invalidEventHandler = "The handler for the '%s' event must be a function.", + invalidOutProperty = "The %s class doesn't have a property called '%s'.", + invalidOutType = "[Out] properties must be given Value objects.", invalidPropertyType = "'%s.%s' expected a '%s' type, but got a '%s' type.", invalidRefType = "Instance refs must be Value objects.", - invalidOutType = "[Out] properties must be given Value objects.", - invalidAttributeOutType = "[AttributeOut] properties must be given Value objects.", - invalidOutProperty = "The %s class doesn't have a property called '%s'.", - invalidOutAttributeName = "The %s class doesn't have an attribute called '%s'.", invalidSpringDamping = "The damping ratio for a spring must be >= 0. (damping was %.2f)", invalidSpringSpeed = "The speed of a spring must be >= 0. (speed was %.2f)", + mergeConflict = "Multiple definitions for '%s' found while merging.", mistypedSpringDamping = "The damping ratio for a spring must be a number. (got a %s)", mistypedSpringSpeed = "The speed of a spring must be a number. (got a %s)", mistypedTweenInfo = "The tween info of a tween must be a TweenInfo. (got a %s)", noTaskScheduler = "Fusion is not connected to an external task scheduler.", + 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", + scopeMissing = "To create %s, provide a scope. (e.g. `%s`). See discussion #292 on GitHub for advice.", springTypeMismatch = "The type '%s' doesn't match the spring's type '%s'.", stateGetWasRemoved = "`StateObject:get()` has been replaced by `use()` and `peek()` - see discussion #217 on GitHub.", - strictReadError = "'%s' is not a valid member of '%s'.", unknownMessage = "Unknown error: ERROR_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." + unrecognisedPropertyStage = "'%s' isn't a valid stage for a special key to be applied at.", + useAfterDestroy = "%s is no longer valid - it was destroyed before %s. See discussion #292 on GitHub for advice.", } \ No newline at end of file diff --git a/src/Logging/parseError.lua b/src/Logging/parseError.lua index 9703253b7..d5999ea4b 100644 --- a/src/Logging/parseError.lua +++ b/src/Logging/parseError.lua @@ -1,4 +1,5 @@ --!strict +--!nolint LocalShadow --[[ An xpcall() error handler to collect and parse useful information about @@ -6,9 +7,11 @@ ]] local Package = script.Parent.Parent -local Types = require(Package.Types) +local InternalTypes = require(Package.InternalTypes) -local function parseError(err: string): Types.Error +local function parseError( + err: string +): InternalTypes.Error return { type = "Error", raw = err, diff --git a/src/Memory/deriveScope.lua b/src/Memory/deriveScope.lua new file mode 100644 index 000000000..97ca4b38f --- /dev/null +++ b/src/Memory/deriveScope.lua @@ -0,0 +1,23 @@ +--!strict +--!nolint LocalShadow + +--[[ + Creates an empty scope with the same metatables as the original scope. Used + for preserving access to constructors when creating inner scopes. +]] +local Package = script.Parent.Parent +local Types = require(Package.Types) +local scopePool = require(Package.Memory.scopePool) + +-- This return type is technically a lie, but it's required for useful type +-- checking behaviour. +local function deriveScope( + existing: Types.Scope +): Types.Scope + return setmetatable( + scopePool.reuseAny() or {}, + getmetatable(existing) + ) :: any +end + +return deriveScope \ No newline at end of file diff --git a/src/Utility/cleanup.lua b/src/Memory/doCleanup.lua similarity index 56% rename from src/Utility/cleanup.lua rename to src/Memory/doCleanup.lua index 6a20519a5..cd1b78c16 100644 --- a/src/Utility/cleanup.lua +++ b/src/Memory/doCleanup.lua @@ -1,4 +1,5 @@ --!strict +--!nolint LocalShadow --[[ Cleans up the tasks passed in as the arguments. @@ -11,43 +12,58 @@ - an array - `cleanup` will be called on each item ]] -local function cleanupOne(task: any) +local function doCleanupOne( + task: unknown +) local taskType = typeof(task) -- case 1: Instance if taskType == "Instance" then + local task = task :: Instance task:Destroy() -- case 2: RBXScriptConnection elseif taskType == "RBXScriptConnection" then + local task = task :: RBXScriptConnection task:Disconnect() -- case 3: callback elseif taskType == "function" then + local task = task :: (...unknown) -> (...unknown) task() elseif taskType == "table" then + local task = task :: {destroy: unknown?, Destroy: unknown?} + -- case 4: destroy() function if typeof(task.destroy) == "function" then + local task = (task :: any) :: {destroy: (...unknown) -> (...unknown)} task:destroy() -- case 5: Destroy() function elseif typeof(task.Destroy) == "function" then + local task = (task :: any) :: {Destroy: (...unknown) -> (...unknown)} task:Destroy() -- case 6: array of tasks elseif task[1] ~= nil then - for _, subtask in ipairs(task) do - cleanupOne(subtask) + local task = task :: {unknown} + -- It is important to iterate backwards through the table, since + -- objects are added in order of construction. + for index = #task, 1, -1 do + doCleanupOne(task[index]) + task[index] = nil end end end end -local function cleanup(...: any) +local function doCleanup( + ...: unknown +) for index = 1, select("#", ...) do - cleanupOne(select(index, ...)) + doCleanupOne(select(index, ...)) end end -return cleanup \ No newline at end of file +return doCleanup \ No newline at end of file diff --git a/src/Memory/legacyCleanup.lua b/src/Memory/legacyCleanup.lua new file mode 100644 index 000000000..c84247bef --- /dev/null +++ b/src/Memory/legacyCleanup.lua @@ -0,0 +1,15 @@ +--!strict +--!nolint LocalShadow + +local Package = script.Parent.Parent +local logWarn = require(Package.Logging.logWarn) +local doCleanup = require(Package.Memory.doCleanup) + +local function legacyCleanup( + ...: unknown +) + logWarn("cleanupWasRenamed") + return doCleanup(...) +end + +return legacyCleanup \ No newline at end of file diff --git a/src/Memory/needsDestruction.lua b/src/Memory/needsDestruction.lua new file mode 100644 index 000000000..1d2d43be3 --- /dev/null +++ b/src/Memory/needsDestruction.lua @@ -0,0 +1,15 @@ +--!strict +--!nolint LocalShadow + +--[[ + Returns true if the given value is not automatically memory managed, and + requires manual cleanup. +]] + +local function needsDestruction( + x: unknown +): boolean + return typeof(x) == "Instance" +end + +return needsDestruction \ No newline at end of file diff --git a/src/Memory/scopePool.lua b/src/Memory/scopePool.lua new file mode 100644 index 000000000..817de559f --- /dev/null +++ b/src/Memory/scopePool.lua @@ -0,0 +1,44 @@ +--!strict +--!nolint LocalShadow + +local Package = script.Parent.Parent +local Types = require(Package.Types) + +local MAX_POOL_SIZE = 16 -- TODO: need to test what an ideal number for this is + +local pool = {} +local poolSize = 0 + +return { + giveIfEmpty = function( + scope: Types.Scope + ): Types.Scope? + if next(scope) == nil then + if poolSize < MAX_POOL_SIZE then + poolSize += 1 + pool[poolSize] = scope + end + return nil + else + return scope + end + end, + clearAndGive = function( + scope: Types.Scope + ) + if poolSize < MAX_POOL_SIZE then + table.clear(scope) + poolSize += 1 + pool[poolSize] = scope + end + end, + reuseAny = function(): Types.Scope + if poolSize == 0 then + return nil + else + local scope = pool[poolSize] + poolSize -= 1 + return scope + end + end +} \ No newline at end of file diff --git a/src/Memory/scoped.lua b/src/Memory/scoped.lua new file mode 100644 index 000000000..8270f832b --- /dev/null +++ b/src/Memory/scoped.lua @@ -0,0 +1,41 @@ +--!strict +--!nolint LocalShadow + +--[[ + Creates cleanup tables with access to constructors as methods. +]] + +local Package = script.Parent.Parent +local Types = require(Package.Types) +local logError = require(Package.Logging.logError) +local scopePool = require(Package.Memory.scopePool) + +local function merge( + into: {[unknown]: unknown}, + from: {[unknown]: unknown}?, + ...: {[unknown]: unknown} +): {[unknown]: unknown} + if from == nil then + return into + else + for key, value in from do + if into[key] == nil then + into[key] = value + else + logError("mergeConflict", nil, tostring(key)) + end + end + return merge(into, ...) + end +end + +local function scoped( + ...: {[unknown]: unknown} +): {[unknown]: unknown} + return setmetatable( + scopePool.reuseAny() or {}, + {__index = merge({}, ...)} + ) :: any +end + +return (scoped :: any) :: Types.ScopedConstructor \ No newline at end of file diff --git a/src/Memory/whichLivesLonger.lua b/src/Memory/whichLivesLonger.lua new file mode 100644 index 000000000..90296770c --- /dev/null +++ b/src/Memory/whichLivesLonger.lua @@ -0,0 +1,69 @@ +--!strict +--!nolint LocalShadow + +--[[ + Calculates how the lifetimes of the two values relate. Specifically, it + calculates which value will be destroyed earlier or later, if it is possible + to infer this from their scopes. +]] +local Package = script.Parent.Parent +local Types = require(Package.Types) + +local function whichScopeLivesLonger( + scopeA: Types.Scope, + scopeB: Types.Scope +): "definitely-a" | "definitely-b" | "unsure" + -- If we can prove one scope is inside of the other scope, then the outer + -- 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 openSetSize, nextOpenSetSize = 2, 0 + local closedSet = {} + while openSetSize > 0 do + for _, scope in openSet do + closedSet[scope] = true + for _, inScope in ipairs(scope) do + if inScope == scopeA then + return "definitely-b" + elseif inScope == scopeB then + return "definitely-a" + elseif typeof(inScope) == "table" then + local inScope = inScope :: {unknown} + if inScope[1] ~= nil and closedSet[scope] == nil then + nextOpenSetSize += 1 + nextOpenSet[nextOpenSetSize] = inScope + end + end + end + end + table.clear(openSet) + openSet, nextOpenSet = nextOpenSet, openSet + openSetSize, nextOpenSetSize = nextOpenSetSize, 0 + end + return "unsure" +end + +local function whichLivesLonger( + scopeA: Types.Scope, + a: unknown, + scopeB: Types.Scope, + b: unknown +): "definitely-a" | "definitely-b" | "unsure" + if scopeA == scopeB then + local scopeA: {unknown} = scopeA + for index = #scopeA, 1, -1 do + local value = scopeA[index] + if value == a then + return "definitely-b" + elseif value == b then + return "definitely-a" + end + end + return "unsure" + else + return whichScopeLivesLonger(scopeA, scopeB) + end +end + +return whichLivesLonger \ No newline at end of file diff --git a/src/PubTypes.lua b/src/PubTypes.lua deleted file mode 100644 index df389675f..000000000 --- a/src/PubTypes.lua +++ /dev/null @@ -1,160 +0,0 @@ ---!strict - ---[[ - Stores common public-facing type information for Fusion APIs. -]] - -type Set = {[T]: any} - ---[[ - General use types -]] - --- A unique symbolic value. -export type Symbol = { - type: "Symbol", - name: string -} - --- Types that can be expressed as vectors of numbers, and so can be animated. -export type Animatable = - number | - CFrame | - Color3 | - ColorSequenceKeypoint | - DateTime | - NumberRange | - NumberSequenceKeypoint | - PhysicalProperties | - Ray | - Rect | - Region3 | - Region3int16 | - UDim | - UDim2 | - Vector2 | - Vector2int16 | - Vector3 | - Vector3int16 - --- A task which can be accepted for cleanup. -export type Task = - Instance | - RBXScriptConnection | - () -> () | - {destroy: (any) -> ()} | - {Destroy: (any) -> ()} | - {Task} - --- Script-readable version information. -export type Version = { - major: number, - minor: number, - isRelease: boolean -} - --- An object which stores a value scoped in time. -export type Contextual = { - type: "Contextual", - now: (Contextual) -> T, - is: (Contextual, T) -> ContextualIsMethods -} - -type ContextualIsMethods = { - during: (ContextualIsMethods, (A...) -> T, A...) -> T -} - ---[[ - Generic reactive graph types -]] - --- A graph object which can have dependents. -export type Dependency = { - dependentSet: Set -} - --- A graph object which can have dependencies. -export type Dependent = { - update: (Dependent) -> boolean, - dependencySet: Set -} - --- An object which stores a piece of reactive state. -export type StateObject = Dependency & { - type: "State", - kind: string, - _typeIdentifier: T -} - --- Either a constant value of type T, or a state object containing type T. -export type CanBeState = StateObject | T - --- Function signature for use callbacks. -export type Use = (target: CanBeState) -> T - ---[[ - Specific reactive graph types -]] - --- A state object whose value can be set at any time by the user. -export type Value = StateObject & { - kind: "State", - set: (Value, newValue: any, force: boolean?) -> () -} - --- A state object whose value is derived from other objects using a callback. -export type Computed = StateObject & Dependent & { - kind: "Computed" -} - --- A state object whose value is derived from other objects using a callback. -export type ForPairs = StateObject<{ [KO]: VO }> & Dependent & { - kind: "ForPairs" -} --- A state object whose value is derived from other objects using a callback. -export type ForKeys = StateObject<{ [KO]: V }> & Dependent & { - kind: "ForKeys" -} --- A state object whose value is derived from other objects using a callback. -export type ForValues = StateObject<{ [K]: VO }> & Dependent & { - kind: "ForKeys" -} - --- A state object which follows another state object using tweens. -export type Tween = StateObject & Dependent & { - kind: "Tween" -} - --- A state object which follows another state object using spring simulation. -export type Spring = StateObject & Dependent & { - kind: "Spring", - setPosition: (Spring, newPosition: Animatable) -> (), - setVelocity: (Spring, newVelocity: Animatable) -> (), - addVelocity: (Spring, deltaVelocity: Animatable) -> () -} - --- An object which can listen for updates on another state object. -export type Observer = Dependent & { - kind: "Observer", - onChange: (Observer, callback: () -> ()) -> (() -> ()) -} - ---[[ - Instance related types -]] - --- Denotes children instances in an instance or component's property table. -export type SpecialKey = { - type: "SpecialKey", - kind: string, - stage: "self" | "descendants" | "ancestor" | "observer", - apply: (SpecialKey, value: any, applyTo: Instance, cleanupTasks: {Task}) -> () -} - --- A collection of instances that may be parented to another instance. -export type Children = Instance | StateObject | {[any]: Children} - --- A table that defines an instance's properties, handlers and children. -export type PropertyTable = {[string | SpecialKey]: any} - -return nil diff --git a/src/RobloxExternal.lua b/src/RobloxExternal.lua index d2370b2f4..f5e874aba 100644 --- a/src/RobloxExternal.lua +++ b/src/RobloxExternal.lua @@ -1,6 +1,8 @@ --!strict +--!nolint LocalShadow + --[[ - Roblox implementation for Fusion's abstract scheduler layer. + Roblox implementation for Fusion's abstract scheduler layer. ]] local RunService = game:GetService("RunService") @@ -15,63 +17,63 @@ local RobloxExternal = {} Sends an immediate task to the external scheduler. Throws if none is set. ]] function RobloxExternal.doTaskImmediate( - resume: () -> () + resume: () -> () ) task.spawn(resume) end --[[ - Sends a deferred task to the external scheduler. Throws if none is set. + Sends a deferred task to the external scheduler. Throws if none is set. ]] function RobloxExternal.doTaskDeferred( - resume: () -> () + resume: () -> () ) - task.defer(resume) + task.defer(resume) end --[[ - Sends an update step to Fusion using the Roblox clock time. + Sends an update step to Fusion using the Roblox clock time. ]] local function performUpdateStep() - External.performUpdateStep(os.clock()) + External.performUpdateStep(os.clock()) end --[[ - Binds Fusion's update step to RunService step events. + Binds Fusion's update step to RunService step events. ]] -local stopSchedulerFunc = nil +local stopSchedulerFunc = nil :: (() -> ())? function RobloxExternal.startScheduler() - if stopSchedulerFunc ~= nil then - return - end - if RunService:IsClient() then - -- In cases where multiple Fusion modules are running simultaneously, - -- this prevents collisions. - local id = "FusionUpdateStep_" .. HttpService:GenerateGUID() - RunService:BindToRenderStep( - id, - Enum.RenderPriority.First.Value, - performUpdateStep - ) - stopSchedulerFunc = function() - RunService:UnbindFromRenderStep(id) - end - else - local connection = RunService.Heartbeat:Connect(performUpdateStep) - stopSchedulerFunc = function() - connection:Disconnect() - end - end + if stopSchedulerFunc ~= nil then + return + end + if RunService:IsClient() then + -- In cases where multiple Fusion modules are running simultaneously, + -- this prevents collisions. + local id = "FusionUpdateStep_" .. HttpService:GenerateGUID() + RunService:BindToRenderStep( + id, + Enum.RenderPriority.First.Value, + performUpdateStep + ) + stopSchedulerFunc = function() + RunService:UnbindFromRenderStep(id) + end + else + local connection = RunService.Heartbeat:Connect(performUpdateStep) + stopSchedulerFunc = function() + connection:Disconnect() + end + end end --[[ - Unbinds Fusion's update step from RunService step events. + Unbinds Fusion's update step from RunService step events. ]] function RobloxExternal.stopScheduler() - if stopSchedulerFunc ~= nil then - stopSchedulerFunc() - stopSchedulerFunc = nil - end + if stopSchedulerFunc ~= nil then + stopSchedulerFunc() + stopSchedulerFunc = nil + end end return RobloxExternal \ No newline at end of file diff --git a/src/State/Computed.lua b/src/State/Computed.lua index 7a02892ff..e37331d6f 100644 --- a/src/State/Computed.lua +++ b/src/State/Computed.lua @@ -1,4 +1,5 @@ ---!nonstrict +--!strict +--!nolint LocalShadow --[[ Constructs and returns objects which can be used to model derived reactive @@ -7,6 +8,7 @@ local Package = script.Parent.Parent local Types = require(Package.Types) +local InternalTypes = require(Package.InternalTypes) -- Logging local logError = require(Package.Logging.logError) local logErrorNonFatal = require(Package.Logging.logErrorNonFatal) @@ -14,20 +16,32 @@ local logWarn = require(Package.Logging.logWarn) local parseError = require(Package.Logging.parseError) -- Utility local isSimilar = require(Package.Utility.isSimilar) -local needsDestruction = require(Package.Utility.needsDestruction) -- State -local makeUseCallback = require(Package.State.makeUseCallback) +local isState = require(Package.State.isState) +-- Memory +local doCleanup = require(Package.Memory.doCleanup) +local deriveScope = require(Package.Memory.deriveScope) +local whichLivesLonger = require(Package.Memory.whichLivesLonger) +local scopePool = require(Package.Memory.scopePool) local class = {} +class.type = "State" +class.kind = "Computed" local CLASS_METATABLE = {__index = class} -local WEAK_KEYS_METATABLE = {__mode = "k"} --[[ + 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 + 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 @@ -40,24 +54,33 @@ function class:update(): boolean self._oldDependencySet, self.dependencySet = self.dependencySet, self._oldDependencySet table.clear(self.dependencySet) - local use = makeUseCallback(self.dependencySet) - local ok, newValue, newMetaValue = xpcall(self._processor, parseError, use) - - if ok then - if self._destructor == nil and needsDestruction(newValue) then - logWarn("destructorNeededComputed") - end - - if newMetaValue ~= nil then - logWarn("multiReturnComputed") + local innerScope = deriveScope(outerScope) + local function use(target: Types.CanBeState): T + if isState(target) then + local target = target :: Types.StateObject + if target.scope == nil then + logError("useAfterDestroy", nil, `The {target.kind} object`, "the Computed that is use()-ing it") + elseif whichLivesLonger(outerScope, self, target.scope, target) == "definitely-a" then + 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 end + 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) - if self._destructor ~= nil then - self._destructor(oldValue) + 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 @@ -66,9 +89,15 @@ function class:update(): boolean return not similar else + local errorObj = (newValue :: any) :: InternalTypes.Error -- this needs to be non-fatal, because otherwise it'd disrupt the -- update process - logErrorNonFatal("computedCallbackError", newValue) + logErrorNonFatal("callbackError", errorObj) + + if innerScope ~= nil then + doCleanup(innerScope) + scopePool.clearAndGive(self._innerScope) + end -- restore old dependencies, because the new dependencies may be corrupt self._oldDependencySet, self.dependencySet = self.dependencySet, self._oldDependencySet @@ -85,7 +114,8 @@ end --[[ Returns the interior value of this state object. ]] -function class:_peek(): any +function class:_peek(): unknown + local self = self :: InternalTypes.Computed return self._value end @@ -93,23 +123,45 @@ function class:get() logError("stateGetWasRemoved") end -local function Computed(processor: () -> T, destructor: ((T) -> ())?): Types.Computed - local dependencySet = {} +function class:destroy() + local self = self :: InternalTypes.Computed + if self.scope == nil then + logError("destroyedTwice", nil, "Computed") + end + 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 + +local function Computed( + scope: Types.Scope, + processor: (Types.Use, Types.Scope) -> T, + destructor: unknown? +): Types.Computed + if typeof(scope) == "function" then + logError("scopeMissing", nil, "Computeds", "myScope:Computed(function(use, scope) ... end)") + elseif destructor ~= nil then + logWarn("destructorRedundant", "Computed") + end local self = setmetatable({ - type = "State", - kind = "Computed", - dependencySet = dependencySet, - -- if we held strong references to the dependents, then they wouldn't be - -- able to get garbage collected when they fall out of scope - dependentSet = setmetatable({}, WEAK_KEYS_METATABLE), + scope = scope, + dependencySet = {}, + dependentSet = {}, _oldDependencySet = {}, _processor = processor, - _destructor = destructor, - _value = nil + _value = nil, + _innerScope = nil }, CLASS_METATABLE) + local self = (self :: any) :: InternalTypes.Computed + table.insert(scope, self) self:update() - + return self end diff --git a/src/State/For.lua b/src/State/For.lua new file mode 100644 index 000000000..a2186b299 --- /dev/null +++ b/src/State/For.lua @@ -0,0 +1,253 @@ +--!strict +--!nolint LocalShadow + +--[[ + The private generic implementation for all public `For` objects. +]] + +local Package = script.Parent.Parent +local Types = require(Package.Types) +local InternalTypes = require(Package.InternalTypes) +-- Logging +local logError = require(Package.Logging.logError) +local logErrorNonFatal = require(Package.Logging.logErrorNonFatal) +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) + 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) :: InternalTypes.Error + 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 + 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() + logError("stateGetWasRemoved") +end + +function class:destroy() + if self.scope == nil then + logError("destroyedTwice", nil, "For") + end + 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 + +local function For( + scope: Types.Scope, + inputTable: Types.CanBeState<{ [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 + + table.insert(scope, self) + self:update() + + return self +end + +return For \ No newline at end of file diff --git a/src/State/ForKeys.lua b/src/State/ForKeys.lua index 8db00c6a7..0c79bc24b 100644 --- a/src/State/ForKeys.lua +++ b/src/State/ForKeys.lua @@ -1,247 +1,69 @@ ---!nonstrict +--!strict +--!nolint LocalShadow --[[ - Constructs a new ForKeys state object which maps keys of an array using - a `processor` function. + Constructs a new For object which maps keys of a table using a `processor` + function. - Optionally, a `destructor` function can be specified for cleaning up - calculated keys. If omitted, the default cleanup function will be used instead. + Optionally, a `destructor` function can be specified for cleaning up output. - Optionally, a `meta` value can be returned in the processor function as the - second value to pass data from the processor to the destructor. + 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. ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) local Types = require(Package.Types) +local InternalTypes = require(Package.InternalTypes) +-- State +local For = require(Package.State.For) +local Computed = require(Package.State.Computed) -- Logging local parseError = require(Package.Logging.parseError) local logErrorNonFatal = require(Package.Logging.logErrorNonFatal) local logError = require(Package.Logging.logError) local logWarn = require(Package.Logging.logWarn) --- Utility -local cleanup = require(Package.Utility.cleanup) -local needsDestruction = require(Package.Utility.needsDestruction) --- State -local peek = require(Package.State.peek) -local makeUseCallback = require(Package.State.makeUseCallback) -local isState = require(Package.State.isState) - -local class = {} - -local CLASS_METATABLE = { __index = class } -local WEAK_KEYS_METATABLE = { __mode = "k" } - - ---[[ - Called when the original table is changed. - - This will firstly find any keys meeting any of the following criteria: - - - they were not previously present - - a dependency used during generation of this value has changed - - It will recalculate those key pairs, storing information about any - dependencies used in the processor callback during output key generation, - and save the new key to the output array with the same value. If it is - overwriting an older value, that older value will be passed to the - destructor for cleanup. - - Finally, this function will find keys that are no longer present, and remove - their output keys from the output table and pass them to the destructor. -]] - -function class:update(): boolean - local inputIsState = self._inputIsState - local newInputTable = peek(self._inputTable) - local oldInputTable = self._oldInputTable - local outputTable = self._outputTable - - local keyOIMap = self._keyOIMap - local keyIOMap = self._keyIOMap - local meta = self._meta - - local didChange = false - - - -- clean out main dependency set - for dependency in pairs(self.dependencySet) do - dependency.dependentSet[self] = nil +-- Memory +local doCleanup = require(Package.Memory.doCleanup) + +local function ForKeys( + scope: Types.Scope, + inputTable: Types.CanBeState<{[KI]: V}>, + processor: (Types.Use, Types.Scope, KI) -> KO, + destructor: unknown? +): Types.For + if typeof(inputTable) == "function" then + logError("scopeMissing", nil, "ForKeys", "myScope:ForKeys(inputTable, function(scope, use, key) ... end)") + elseif destructor ~= nil then + logWarn("destructorRedundant", "ForKeys") end - - self._oldDependencySet, self.dependencySet = self.dependencySet, self._oldDependencySet - table.clear(self.dependencySet) - - -- if the input table is a state object, add it as a dependency - if inputIsState then - self._inputTable.dependentSet[self] = true - self.dependencySet[self._inputTable] = true - end - - - -- STEP 1: find keys that changed or were not previously present - for newInKey, value in pairs(newInputTable) do - -- get or create key data - local keyData = self._keyData[newInKey] - - if keyData == nil then - keyData = { - dependencySet = setmetatable({}, WEAK_KEYS_METATABLE), - oldDependencySet = setmetatable({}, WEAK_KEYS_METATABLE), - dependencyValues = setmetatable({}, WEAK_KEYS_METATABLE), - } - self._keyData[newInKey] = keyData - end - - -- check if the key is new - local shouldRecalculate = oldInputTable[newInKey] == nil - - -- check if the key's dependencies have changed - if shouldRecalculate == false then - for dependency, oldValue in pairs(keyData.dependencyValues) do - if oldValue ~= peek(dependency) then - shouldRecalculate = true - break + 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) :: InternalTypes.Error + logErrorNonFatal("callbackError", errorObj) + doCleanup(scope) + table.clear(scope) + return nil end - end + end) + return Computed(scope, function(use, scope) + return {key = use(outputKey), value = use(inputPair).value} + end) end - - - -- recalculate the output key if necessary - if shouldRecalculate then - keyData.oldDependencySet, keyData.dependencySet = keyData.dependencySet, keyData.oldDependencySet - table.clear(keyData.dependencySet) - - local use = makeUseCallback(keyData.dependencySet) - local processOK, newOutKey, newMetaValue = xpcall(self._processor, parseError, use, newInKey) - - if processOK then - if self._destructor == nil and (needsDestruction(newOutKey) or needsDestruction(newMetaValue)) then - logWarn("destructorNeededForKeys") - end - - local oldInKey = keyOIMap[newOutKey] - local oldOutKey = keyIOMap[newInKey] - - -- check for key collision - if oldInKey ~= newInKey and newInputTable[oldInKey] ~= nil then - logError("forKeysKeyCollision", nil, tostring(newOutKey), tostring(oldInKey), tostring(newOutKey)) - end - - -- check for a changed output key - if oldOutKey ~= newOutKey and keyOIMap[oldOutKey] == newInKey then - -- clean up the old calculated value - local oldMetaValue = meta[oldOutKey] - - local destructOK, err = xpcall(self._destructor or cleanup, parseError, oldOutKey, oldMetaValue) - if not destructOK then - logErrorNonFatal("forKeysDestructorError", err) - end - - keyOIMap[oldOutKey] = nil - outputTable[oldOutKey] = nil - meta[oldOutKey] = nil - end - - -- update the stored data for this key - oldInputTable[newInKey] = value - meta[newOutKey] = newMetaValue - keyOIMap[newOutKey] = newInKey - keyIOMap[newInKey] = newOutKey - outputTable[newOutKey] = value - - -- if we had to recalculate the output, then we did change - didChange = true - else - -- restore old dependencies, because the new dependencies may be corrupt - keyData.oldDependencySet, keyData.dependencySet = keyData.dependencySet, keyData.oldDependencySet - - logErrorNonFatal("forKeysProcessorError", newOutKey) - end - end - - - -- save dependency values and add to main dependency set - for dependency in pairs(keyData.dependencySet) do - keyData.dependencyValues[dependency] = peek(dependency) - - self.dependencySet[dependency] = true - dependency.dependentSet[self] = true - end - end - - - -- STEP 2: find keys that were removed - for outputKey, inputKey in pairs(keyOIMap) do - if newInputTable[inputKey] == nil then - -- clean up the old calculated value - local oldMetaValue = meta[outputKey] - - local destructOK, err = xpcall(self._destructor or cleanup, parseError, outputKey, oldMetaValue) - if not destructOK then - logErrorNonFatal("forKeysDestructorError", err) - end - - -- remove data - oldInputTable[inputKey] = nil - meta[outputKey] = nil - keyOIMap[outputKey] = nil - keyIOMap[inputKey] = nil - outputTable[outputKey] = nil - self._keyData[inputKey] = nil - - -- if we removed a key, then the table/state changed - didChange = true - end - end - - return didChange -end - ---[[ - Returns the interior value of this state object. -]] -function class:_peek(): any - return self._outputTable -end - -function class:get() - logError("stateGetWasRemoved") -end - -local function ForKeys( - inputTable: PubTypes.CanBeState<{ [KI]: any }>, - processor: (KI) -> (KO, M?), - destructor: (KO, M?) -> ()? -): Types.ForKeys - - local inputIsState = isState(inputTable) - - local self = setmetatable({ - type = "State", - kind = "ForKeys", - dependencySet = {}, - -- if we held strong references to the dependents, then they wouldn't be - -- able to get garbage collected when they fall out of scope - dependentSet = setmetatable({}, WEAK_KEYS_METATABLE), - _oldDependencySet = {}, - - _processor = processor, - _destructor = destructor, - _inputIsState = inputIsState, - - _inputTable = inputTable, - _oldInputTable = {}, - _outputTable = {}, - _keyOIMap = {}, - _keyIOMap = {}, - _keyData = {}, - _meta = {}, - }, CLASS_METATABLE) - - self:update() - - return self + ) end return ForKeys \ No newline at end of file diff --git a/src/State/ForPairs.lua b/src/State/ForPairs.lua index a2236ba0d..c1c0949de 100644 --- a/src/State/ForPairs.lua +++ b/src/State/ForPairs.lua @@ -1,309 +1,63 @@ ---!nonstrict +--!strict +--!nolint LocalShadow --[[ - Constructs a new ForPairs object which maps pairs of a table using - a `processor` function. + Constructs a new For object which maps pairs of a table using a `processor` + function. - Optionally, a `destructor` function can be specified for cleaning up values. - If omitted, the default cleanup function will be used instead. + Optionally, a `destructor` function can be specified for cleaning up output. - 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. + 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. ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) local Types = require(Package.Types) +local InternalTypes = require(Package.InternalTypes) +-- State +local For = require(Package.State.For) +local Computed = require(Package.State.Computed) -- Logging local parseError = require(Package.Logging.parseError) local logErrorNonFatal = require(Package.Logging.logErrorNonFatal) local logError = require(Package.Logging.logError) local logWarn = require(Package.Logging.logWarn) --- Utility -local cleanup = require(Package.Utility.cleanup) -local needsDestruction = require(Package.Utility.needsDestruction) --- State -local peek = require(Package.State.peek) -local makeUseCallback = require(Package.State.makeUseCallback) -local isState = require(Package.State.isState) - -local class = {} - -local CLASS_METATABLE = { __index = class } -local WEAK_KEYS_METATABLE = { __mode = "k" } - ---[[ - Called when the original table is changed. - - This will firstly find any keys meeting any of the following criteria: - - - they were not previously present - - their associated value has changed - - a dependency used during generation of this value has changed - - It will recalculate those key/value pairs, storing information about any - dependencies used in the processor callback during value generation, and - save the new key/value pair to the output array. If it is overwriting an - older key/value pair, that older pair will be passed to the destructor - for cleanup. - - Finally, this function will find keys that are no longer present, and remove - their key/value pairs from the output table and pass them to the destructor. -]] -function class:update(): boolean - local inputIsState = self._inputIsState - local newInputTable = peek(self._inputTable) - local oldInputTable = self._oldInputTable - - local keyIOMap = self._keyIOMap - local meta = self._meta - - local didChange = false - - - -- clean out main dependency set - for dependency in pairs(self.dependencySet) do - dependency.dependentSet[self] = nil - end - - self._oldDependencySet, self.dependencySet = self.dependencySet, self._oldDependencySet - table.clear(self.dependencySet) - - -- if the input table is a state object, add it as a dependency - if inputIsState then - self._inputTable.dependentSet[self] = true - self.dependencySet[self._inputTable] = true - end - - -- clean out output table - self._oldOutputTable, self._outputTable = self._outputTable, self._oldOutputTable - - local oldOutputTable = self._oldOutputTable - local newOutputTable = self._outputTable - table.clear(newOutputTable) - - -- Step 1: find key/value pairs that changed or were not previously present - - for newInKey, newInValue in pairs(newInputTable) do - -- get or create key data - local keyData = self._keyData[newInKey] - - if keyData == nil then - keyData = { - dependencySet = setmetatable({}, WEAK_KEYS_METATABLE), - oldDependencySet = setmetatable({}, WEAK_KEYS_METATABLE), - dependencyValues = setmetatable({}, WEAK_KEYS_METATABLE), - } - self._keyData[newInKey] = keyData - end - - - -- check if the pair is new or changed - local shouldRecalculate = oldInputTable[newInKey] ~= newInValue - - -- check if the pair's dependencies have changed - if shouldRecalculate == false then - for dependency, oldValue in pairs(keyData.dependencyValues) do - if oldValue ~= peek(dependency) then - shouldRecalculate = true - break - end - end - end - - - -- recalculate the output pair if necessary - if shouldRecalculate then - keyData.oldDependencySet, keyData.dependencySet = keyData.dependencySet, keyData.oldDependencySet - table.clear(keyData.dependencySet) - - local use = makeUseCallback(keyData.dependencySet) - local processOK, newOutKey, newOutValue, newMetaValue = xpcall( - self._processor, parseError, use, newInKey, newInValue - ) - - if processOK then - if self._destructor == nil and (needsDestruction(newOutKey) or needsDestruction(newOutValue) or needsDestruction(newMetaValue)) then - logWarn("destructorNeededForPairs") - end - - -- if this key was already written to on this run-through, throw a fatal error. - if newOutputTable[newOutKey] ~= nil then - -- figure out which key/value pair previously wrote to this key - local previousNewKey, previousNewValue - for inKey, outKey in pairs(keyIOMap) do - if outKey == newOutKey then - previousNewValue = newInputTable[inKey] - if previousNewValue ~= nil then - previousNewKey = inKey - break - end - end - end - - if previousNewKey ~= nil then - logError( - "forPairsKeyCollision", - nil, - tostring(newOutKey), - tostring(previousNewKey), - tostring(previousNewValue), - tostring(newInKey), - tostring(newInValue) - ) - end - end - - local oldOutValue = oldOutputTable[newOutKey] - - if oldOutValue ~= newOutValue then - local oldMetaValue = meta[newOutKey] - if oldOutValue ~= nil then - local destructOK, err = xpcall(self._destructor or cleanup, parseError, newOutKey, oldOutValue, oldMetaValue) - if not destructOK then - logErrorNonFatal("forPairsDestructorError", err) - end - end - - oldOutputTable[newOutKey] = nil - end - - -- update the stored data for this key/value pair - oldInputTable[newInKey] = newInValue - keyIOMap[newInKey] = newOutKey - meta[newOutKey] = newMetaValue - newOutputTable[newOutKey] = newOutValue - - -- if we had to recalculate the output, then we did change - didChange = true - else - -- restore old dependencies, because the new dependencies may be corrupt - keyData.oldDependencySet, keyData.dependencySet = keyData.dependencySet, keyData.oldDependencySet - - logErrorNonFatal("forPairsProcessorError", newOutKey) - end - else - local storedOutKey = keyIOMap[newInKey] - - -- check for key collision - if newOutputTable[storedOutKey] ~= nil then - -- figure out which key/value pair previously wrote to this key - local previousNewKey, previousNewValue - for inKey, outKey in pairs(keyIOMap) do - if storedOutKey == outKey then - previousNewValue = newInputTable[inKey] - - if previousNewValue ~= nil then - previousNewKey = inKey - break - end - end - end - - if previousNewKey ~= nil then - logError( - "forPairsKeyCollision", - nil, - tostring(storedOutKey), - tostring(previousNewKey), - tostring(previousNewValue), - tostring(newInKey), - tostring(newInValue) - ) - end - end - - -- copy the stored key/value pair into the new output table - newOutputTable[storedOutKey] = oldOutputTable[storedOutKey] - end - - - -- save dependency values and add to main dependency set - for dependency in pairs(keyData.dependencySet) do - keyData.dependencyValues[dependency] = peek(dependency) - - self.dependencySet[dependency] = true - dependency.dependentSet[self] = true - end +-- Memory +local doCleanup = require(Package.Memory.doCleanup) + +local function ForPairs( + scope: Types.Scope, + inputTable: Types.CanBeState<{[KI]: VI}>, + processor: (Types.Use, Types.Scope, KI, VI) -> (KO, VO), + destructor: unknown? +): Types.For + if typeof(inputTable) == "function" then + logError("scopeMissing", nil, "ForPairs", "myScope:ForPairs(inputTable, function(scope, use, key, value) ... end)") + elseif destructor ~= nil then + logWarn("destructorRedundant", "ForPairs") end - - -- STEP 2: find keys that were removed - for oldOutKey, oldOutValue in pairs(oldOutputTable) do - -- check if this key/value pair is in the new output table - if newOutputTable[oldOutKey] ~= oldOutValue then - -- clean up the old output pair - local oldMetaValue = meta[oldOutKey] - if oldOutValue ~= nil then - local destructOK, err = xpcall(self._destructor or cleanup, parseError, oldOutKey, oldOutValue, oldMetaValue) - if not destructOK then - logErrorNonFatal("forPairsDestructorError", err) + 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) :: InternalTypes.Error + logErrorNonFatal("callbackError", errorObj) + doCleanup(scope) + table.clear(scope) + return {key = nil, value = nil} end - end - - -- check if the key was completely removed from the output table - if newOutputTable[oldOutKey] == nil then - meta[oldOutKey] = nil - self._keyData[oldOutKey] = nil - end - - didChange = true + end) end - end - - for key in pairs(oldInputTable) do - if newInputTable[key] == nil then - oldInputTable[key] = nil - keyIOMap[key] = nil - end - end - - return didChange -end - ---[[ - Returns the interior value of this state object. -]] -function class:_peek(): any - return self._outputTable -end - -function class:get() - logError("stateGetWasRemoved") -end - -local function ForPairs( - inputTable: PubTypes.CanBeState<{ [KI]: VI }>, - processor: (KI, VI) -> (KO, VO, M?), - destructor: (KO, VO, M?) -> ()? -): Types.ForPairs - - local inputIsState = isState(inputTable) - - local self = setmetatable({ - type = "State", - kind = "ForPairs", - dependencySet = {}, - -- if we held strong references to the dependents, then they wouldn't be - -- able to get garbage collected when they fall out of scope - dependentSet = setmetatable({}, WEAK_KEYS_METATABLE), - _oldDependencySet = {}, - - _processor = processor, - _destructor = destructor, - _inputIsState = inputIsState, - - _inputTable = inputTable, - _oldInputTable = {}, - _outputTable = {}, - _oldOutputTable = {}, - _keyIOMap = {}, - _keyData = {}, - _meta = {}, - }, CLASS_METATABLE) - - self:update() - - return self + ) end return ForPairs \ No newline at end of file diff --git a/src/State/ForValues.lua b/src/State/ForValues.lua index 774b52a25..bb8548e66 100644 --- a/src/State/ForValues.lua +++ b/src/State/ForValues.lua @@ -1,245 +1,66 @@ ---!nonstrict +--!strict +--!nolint LocalShadow --[[ - Constructs a new ForValues object which maps values of a table using - a `processor` function. + Constructs a new For object which maps values of a table using a `processor` + function. - Optionally, a `destructor` function can be specified for cleaning up values. - If omitted, the default cleanup function will be used instead. + Optionally, a `destructor` function can be specified for cleaning up output. - 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. + 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. ]] + local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) local Types = require(Package.Types) +local InternalTypes = require(Package.InternalTypes) +-- State +local For = require(Package.State.For) +local Computed = require(Package.State.Computed) -- Logging local parseError = require(Package.Logging.parseError) -local logError = require(Package.Logging.logError) local logErrorNonFatal = require(Package.Logging.logErrorNonFatal) +local logError = require(Package.Logging.logError) local logWarn = require(Package.Logging.logWarn) --- Utility -local cleanup = require(Package.Utility.cleanup) -local needsDestruction = require(Package.Utility.needsDestruction) --- State -local peek = require(Package.State.peek) -local makeUseCallback = require(Package.State.makeUseCallback) -local isState = require(Package.State.isState) - -local class = {} - -local CLASS_METATABLE = { __index = class } -local WEAK_KEYS_METATABLE = { __mode = "k" } - ---[[ - Called when the original table is changed. - - This will firstly find any values meeting any of the following criteria: - - - they were not previously present - - a dependency used during generation of this value has changed - - It will recalculate those values, storing information about any dependencies - used in the processor callback during value generation, and save the new value - to the output array with the same key. If it is overwriting an older value, - that older value will be passed to the destructor for cleanup. - - Finally, this function will find values that are no longer present, and remove - their values from the output table and pass them to the destructor. You can re-use - the same value multiple times and this will function will update them as little as - possible; reusing the same values where possible. -]] -function class:update(): boolean - local inputIsState = self._inputIsState - local inputTable = peek(self._inputTable) - local outputValues = {} - - local didChange = false - - -- clean out value cache - self._oldValueCache, self._valueCache = self._valueCache, self._oldValueCache - local newValueCache = self._valueCache - local oldValueCache = self._oldValueCache - table.clear(newValueCache) - - -- clean out main dependency set - for dependency in pairs(self.dependencySet) do - dependency.dependentSet[self] = nil +-- Memory +local doCleanup = require(Package.Memory.doCleanup) + +local function ForValues( + scope: Types.Scope, + inputTable: Types.CanBeState<{[K]: VI}>, + processor: (Types.Use, Types.Scope, VI) -> VO, + destructor: unknown? +): Types.For + if typeof(inputTable) == "function" then + logError("scopeMissing", nil, "ForValues", "myScope:ForValues(inputTable, function(scope, use, value) ... end)") + elseif destructor ~= nil then + logWarn("destructorRedundant", "ForValues") end - self._oldDependencySet, self.dependencySet = self.dependencySet, self._oldDependencySet - table.clear(self.dependencySet) - - -- if the input table is a state object, add it as a dependency - if inputIsState then - self._inputTable.dependentSet[self] = true - self.dependencySet[self._inputTable] = true - end - - - -- STEP 1: find values that changed or were not previously present - for inKey, inValue in pairs(inputTable) do - -- check if the value is new or changed - local oldCachedValues = oldValueCache[inValue] - local shouldRecalculate = oldCachedValues == nil - - -- get a cached value and its dependency/meta data if available - local value, valueData, meta - - if type(oldCachedValues) == "table" and #oldCachedValues > 0 then - local valueInfo = table.remove(oldCachedValues, #oldCachedValues) - value = valueInfo.value - valueData = valueInfo.valueData - meta = valueInfo.meta - - if #oldCachedValues <= 0 then - oldValueCache[inValue] = nil - end - elseif oldCachedValues ~= nil then - oldValueCache[inValue] = nil - shouldRecalculate = true - end - - if valueData == nil then - valueData = { - dependencySet = setmetatable({}, WEAK_KEYS_METATABLE), - oldDependencySet = setmetatable({}, WEAK_KEYS_METATABLE), - dependencyValues = setmetatable({}, WEAK_KEYS_METATABLE), - } - end - - -- check if the value's dependencies have changed - if shouldRecalculate == false then - for dependency, oldValue in pairs(valueData.dependencyValues) do - if oldValue ~= peek(dependency) then - shouldRecalculate = true - break + 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) :: InternalTypes.Error + logErrorNonFatal("callbackError", errorObj) + doCleanup(scope) + table.clear(scope) + return {key = nil, value = nil} end - end + end) end - - -- recalculate the output value if necessary - if shouldRecalculate then - valueData.oldDependencySet, valueData.dependencySet = valueData.dependencySet, valueData.oldDependencySet - table.clear(valueData.dependencySet) - - local use = makeUseCallback(valueData.dependencySet) - local processOK, newOutValue, newMetaValue = xpcall(self._processor, parseError, use, inValue) - - if processOK then - if self._destructor == nil and (needsDestruction(newOutValue) or needsDestruction(newMetaValue)) then - logWarn("destructorNeededForValues") - end - - -- pass the old value to the destructor if it exists - if value ~= nil then - local destructOK, err = xpcall(self._destructor or cleanup, parseError, value, meta) - if not destructOK then - logErrorNonFatal("forValuesDestructorError", err) - end - end - - -- store the new value and meta data - value = newOutValue - meta = newMetaValue - didChange = true - else - -- restore old dependencies, because the new dependencies may be corrupt - valueData.oldDependencySet, valueData.dependencySet = valueData.dependencySet, valueData.oldDependencySet - logErrorNonFatal("forValuesProcessorError", newOutValue) - end - end - - - -- store the value and its dependency/meta data - local newCachedValues = newValueCache[inValue] - if newCachedValues == nil then - newCachedValues = {} - newValueCache[inValue] = newCachedValues - end - - table.insert(newCachedValues, { - value = value, - valueData = valueData, - meta = meta, - }) - - outputValues[inKey] = value - - - -- save dependency values and add to main dependency set - for dependency in pairs(valueData.dependencySet) do - valueData.dependencyValues[dependency] = peek(dependency) - - self.dependencySet[dependency] = true - dependency.dependentSet[self] = true - end - end - - - -- STEP 2: find values that were removed - -- for tables of data, we just need to check if it's still in the cache - for _oldInValue, oldCachedValueInfo in pairs(oldValueCache) do - for _, valueInfo in ipairs(oldCachedValueInfo) do - local oldValue = valueInfo.value - local oldMetaValue = valueInfo.meta - - local destructOK, err = xpcall(self._destructor or cleanup, parseError, oldValue, oldMetaValue) - if not destructOK then - logErrorNonFatal("forValuesDestructorError", err) - end - - didChange = true - end - - table.clear(oldCachedValueInfo) - end - - self._outputTable = outputValues - - return didChange -end - ---[[ - Returns the interior value of this state object. -]] -function class:_peek(): any - return self._outputTable -end - -function class:get() - logError("stateGetWasRemoved") -end - -local function ForValues( - inputTable: PubTypes.CanBeState<{ [any]: VI }>, - processor: (VI) -> (VO, M?), - destructor: (VO, M?) -> ()? -): Types.ForValues - - local inputIsState = isState(inputTable) - - local self = setmetatable({ - type = "State", - kind = "ForValues", - dependencySet = {}, - -- if we held strong references to the dependents, then they wouldn't be - -- able to get garbage collected when they fall out of scope - dependentSet = setmetatable({}, WEAK_KEYS_METATABLE), - _oldDependencySet = {}, - - _processor = processor, - _destructor = destructor, - _inputIsState = inputIsState, - - _inputTable = inputTable, - _outputTable = {}, - _valueCache = {}, - _oldValueCache = {}, - }, CLASS_METATABLE) - - self:update() - - return self + ) end return ForValues \ No newline at end of file diff --git a/src/State/Observer.lua b/src/State/Observer.lua index 5a89f2a8e..18d562be0 100644 --- a/src/State/Observer.lua +++ b/src/State/Observer.lua @@ -1,29 +1,29 @@ ---!nonstrict +--!strict +--!nolint LocalShadow --[[ Constructs a new state object which can listen for updates on another state object. - - FIXME: enabling strict types here causes free types to leak ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) local Types = require(Package.Types) +local InternalTypes = require(Package.InternalTypes) local External = require(Package.External) - -type Set = {[T]: any} +local whichLivesLonger = require(Package.Memory.whichLivesLonger) +local logWarn = require(Package.Logging.logWarn) +local logError = require(Package.Logging.logError) local class = {} -local CLASS_METATABLE = {__index = class} +class.type = "Observer" --- Table used to hold Observer objects in memory. -local strongRefs: Set = {} +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 @@ -38,28 +38,14 @@ end 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: () -> ()): () -> () +function class:onChange( + callback: () -> () +): () -> () + local self = self :: InternalTypes.Observer local uniqueIdentifier = {} - - self._numChangeListeners += 1 self._changeListeners[uniqueIdentifier] = callback - - -- disallow gc (this is important to make sure changes are received) - strongRefs[self] = true - - local disconnected = false return function() - if disconnected then - return - end - disconnected = true self._changeListeners[uniqueIdentifier] = nil - self._numChangeListeners -= 1 - - if self._numChangeListeners == 0 then - -- allow gc if all listeners are disconnected - strongRefs[self] = nil - end end end @@ -67,23 +53,60 @@ end Similar to `class:onChange()`, however it runs the provided callback immediately. ]] -function class:onBind(callback: () -> ()): () -> () +function class:onBind( + callback: () -> () +): () -> () + local self = self :: InternalTypes.Observer External.doTaskImmediate(callback) return self:onChange(callback) end -local function Observer(watchedState: PubTypes.Value): Types.Observer +function class:destroy() + local self = self :: InternalTypes.Observer + if self.scope == nil then + logError("destroyedTwice", nil, "Observer") + end + self.scope = nil + for dependency in pairs(self.dependencySet) do + dependency.dependentSet[self] = nil + end +end + +local function Observer( + scope: Types.Scope, + watching: Types.Dependency +): Types.Observer + if watching == nil then + logError("scopeMissing", nil, "Observers", "myScope:Observer(watching)") + end + local self = setmetatable({ - type = "State", - kind = "Observer", - dependencySet = {[watchedState] = true}, + scope = scope, + dependencySet = {[watching] = true}, dependentSet = {}, - _changeListeners = {}, - _numChangeListeners = 0, + _changeListeners = {} }, CLASS_METATABLE) + local self = (self :: any) :: InternalTypes.Observer + + table.insert(scope, self) + + if watching.scope == nil then + logError( + "useAfterDestroy", + nil, + `The {watching.kind or watching.type or "watched"} object`, + `the Observer that is watching it` + ) + elseif whichLivesLonger(scope, self, watching.scope, watching) == "definitely-a" then + logWarn( + "possiblyOutlives", + `The {watching.kind or watching.type or "watched"} object`, + `the Observer that is watching it` + ) + end - -- add this object to the watched state's dependent set - watchedState.dependentSet[self] = true + -- add this object to the watched object's dependent set + watching.dependentSet[self] = true return self end diff --git a/src/State/Value.lua b/src/State/Value.lua index 13b5f63fc..ac68d81bc 100644 --- a/src/State/Value.lua +++ b/src/State/Value.lua @@ -1,4 +1,5 @@ ---!nonstrict +--!strict +--!nolint LocalShadow --[[ Constructs and returns objects which can be used to model independent @@ -7,6 +8,7 @@ local Package = script.Parent.Parent local Types = require(Package.Types) +local InternalTypes = require(Package.InternalTypes) -- Logging local logError = require(Package.Logging.logError) -- State @@ -15,9 +17,10 @@ local updateAll = require(Package.State.updateAll) local isSimilar = require(Package.Utility.isSimilar) local class = {} +class.type = "State" +class.kind = "Value" local CLASS_METATABLE = {__index = class} -local WEAK_KEYS_METATABLE = {__mode = "k"} --[[ Updates the value stored in this State object. @@ -26,7 +29,11 @@ local WEAK_KEYS_METATABLE = {__mode = "k"} state object and any dependents - use this with care as this can lead to unnecessary updates. ]] -function class:set(newValue: any, force: boolean?) +function class:set( + newValue: unknown, + force: boolean? +) + local self = self :: InternalTypes.Value local oldValue = self._value if force or not isSimilar(oldValue, newValue) then self._value = newValue @@ -37,7 +44,8 @@ end --[[ Returns the interior value of this state object. ]] -function class:_peek(): any +function class:_peek(): unknown + local self = self :: InternalTypes.Value return self._value end @@ -45,15 +53,30 @@ function class:get() logError("stateGetWasRemoved") end -local function Value(initialValue: T): Types.State +function class:destroy() + local self = self :: InternalTypes.Value + if self.scope == nil then + logError("destroyedTwice", nil, "Value") + end + self.scope = nil +end + +local function Value( + scope: Types.Scope, + initialValue: T +): Types.Value + if initialValue == nil and (typeof(scope) ~= "table" or (scope[1] == nil and next(scope) ~= nil)) then + logError("scopeMissing", nil, "Value", "myScope:Value(initialValue)") + end + local self = setmetatable({ - type = "State", - kind = "Value", - -- if we held strong references to the dependents, then they wouldn't be - -- able to get garbage collected when they fall out of scope - dependentSet = setmetatable({}, WEAK_KEYS_METATABLE), + scope = scope, + dependentSet = {}, _value = initialValue }, CLASS_METATABLE) + local self = (self :: any) :: InternalTypes.Value + + table.insert(scope, self) return self end diff --git a/src/State/isState.lua b/src/State/isState.lua index ac9a6e6bb..f8880687b 100644 --- a/src/State/isState.lua +++ b/src/State/isState.lua @@ -1,11 +1,18 @@ --!strict +--!nolint LocalShadow --[[ Returns true if the given value can be assumed to be a valid state object. ]] -local function isState(target: any): boolean - return typeof(target) == "table" and typeof(target._peek) == "function" +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/makeUseCallback.lua b/src/State/makeUseCallback.lua deleted file mode 100644 index d1caca700..000000000 --- a/src/State/makeUseCallback.lua +++ /dev/null @@ -1,27 +0,0 @@ ---!strict - ---[[ - Constructs a 'use callback' for the purposes of collecting dependencies. -]] - -local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) -local Types = require(Package.Types) --- State -local isState = require(Package.State.isState) - -type Set = {[T]: any} - -local function makeUseCallback(dependencySet: Set) - local function use(target: PubTypes.CanBeState): T - if isState(target) then - dependencySet[target] = true - return (target :: Types.StateObject):_peek() - else - return target - end - end - return use -end - -return makeUseCallback \ No newline at end of file diff --git a/src/State/peek.lua b/src/State/peek.lua index fd6970461..3bf0bb6d6 100644 --- a/src/State/peek.lua +++ b/src/State/peek.lua @@ -1,20 +1,23 @@ --!strict +--!nolint LocalShadow --[[ A common interface for accessing the values of state objects or constants. ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) local Types = require(Package.Types) +local InternalTypes = require(Package.InternalTypes) -- State local isState = require(Package.State.isState) -local function peek(target: PubTypes.CanBeState): T +local function peek( + target: Types.CanBeState +): T if isState(target) then - return (target :: Types.StateObject):_peek() + return (target :: InternalTypes.StateObject):_peek() else - return target + return target :: T end end diff --git a/src/State/updateAll.lua b/src/State/updateAll.lua index d6500da93..ab3e4e099 100644 --- a/src/State/updateAll.lua +++ b/src/State/updateAll.lua @@ -1,4 +1,5 @@ --!strict +--!nolint LocalShadow --[[ Given a reactive object, updates all dependent reactive objects. @@ -8,13 +9,14 @@ ]] local Package = script.Parent.Parent -local PubTypes = require(Package.PubTypes) +local Types = require(Package.Types) -type Set = {[T]: any} -type Descendant = (PubTypes.Dependent & PubTypes.Dependency) | PubTypes.Dependent +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: PubTypes.Dependency) +local function updateAll( + root: Types.Dependency +) local counters: {[Descendant]: number} = {} local flags: {[Descendant]: boolean} = {} local queue: {Descendant} = {} @@ -33,7 +35,8 @@ local function updateAll(root: PubTypes.Dependency) local counter = counters[next] counters[next] = if counter == nil then 1 else counter + 1 if (next :: any).dependentSet ~= nil then - for object in (next :: any).dependentSet do + local next = next :: (Types.Dependent & Types.Dependency) + for object in next.dependentSet do queueSize += 1 queue[queueSize] = object end @@ -47,8 +50,15 @@ local function updateAll(root: PubTypes.Dependency) local next = queue[queuePos] local counter = counters[next] - 1 counters[next] = counter - if counter == 0 and flags[next] and next:update() and (next :: any).dependentSet ~= nil then - for object in (next :: any).dependentSet do + 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 diff --git a/src/Types.lua b/src/Types.lua index 8239a9293..913249261 100644 --- a/src/Types.lua +++ b/src/Types.lua @@ -1,160 +1,260 @@ --!strict +--!nolint LocalShadow --[[ - 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. + Stores common public-facing type information for Fusion APIs. ]] -local Package = script.Parent -local PubTypes = require(Package.PubTypes) - -type Set = {[T]: any} - --[[ General use types ]] --- A symbol that represents the absence of a value. -export type None = PubTypes.Symbol & { - -- name: "None" (add this when Luau supports singleton types) +-- Types that can be expressed as vectors of numbers, and so can be animated. +export type Animatable = + number | + CFrame | + Color3 | + ColorSequenceKeypoint | + DateTime | + NumberRange | + NumberSequenceKeypoint | + PhysicalProperties | + Ray | + Rect | + Region3 | + Region3int16 | + UDim | + UDim2 | + Vector2 | + Vector2int16 | + Vector3 | + Vector3int16 + +-- A task which can be accepted for cleanup. +export type Task = + Instance | + RBXScriptConnection | + () -> () | + {destroy: (unknown) -> ()} | + {Destroy: (unknown) -> ()} | + {Task} + +-- A scope of tasks to clean up. +export type Scope = {unknown} & Constructors + +-- An object which uses a scope to dictate how long it lives. +export type ScopedObject = { + scope: Scope?, + destroy: () -> () } --- Stores useful information about Luau errors. -export type Error = { - type: string, -- replace with "Error" when Luau supports singleton types - raw: string, - message: string, - trace: string +-- Script-readable version information. +export type Version = { + major: number, + minor: number, + isRelease: boolean } -- An object which stores a value scoped in time. -export type Contextual = PubTypes.Contextual & { - _valuesNow: {[thread]: {value: T}}, - _defaultValue: T +export type Contextual = { + type: "Contextual", + now: (Contextual) -> T, + is: (Contextual, T) -> ContextualIsMethods +} + +type ContextualIsMethods = { + during: (ContextualIsMethods, (A...) -> R, A...) -> R } --[[ Generic reactive graph types ]] -export type StateObject = PubTypes.StateObject & { - _peek: (StateObject) -> T +-- 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} } +-- An object which stores a piece of reactive state. +export type StateObject = Dependency & { + type: "State", + kind: string, + [{}]: T -- unindexable phantom data so StateObject actually contains a T +} + +-- Either a constant value of type T, or a state object containing type T. +export type CanBeState = StateObject | T + +-- Function signature for use callbacks. +export type Use = (target: CanBeState) -> T + --[[ Specific reactive graph types ]] -- A state object whose value can be set at any time by the user. -export type State = PubTypes.Value & { - _value: T +export type Value = StateObject & { + kind: "State", + set: (Value, newValue: T, force: boolean?) -> () } +export type ValueConstructor = ( + scope: Scope, + initialValue: T +) -> Value -- A state object whose value is derived from other objects using a callback. -export type Computed = PubTypes.Computed & { - _oldDependencySet: Set, - _callback: (PubTypes.Use) -> T, - _value: T +export type Computed = StateObject & Dependent & { + kind: "Computed" } +export type ComputedConstructor = ( + scope: Scope, + callback: (Use, Scope) -> T +) -> Computed --- A state object whose value is derived from other objects using a callback. -export type ForPairs = PubTypes.ForPairs & { - _oldDependencySet: Set, - _processor: (PubTypes.Use, KI, VI) -> (KO, VO), - _destructor: (VO, M?) -> (), - _inputIsState: boolean, - _inputTable: PubTypes.CanBeState<{ [KI]: VI }>, - _oldInputTable: { [KI]: VI }, - _outputTable: { [KO]: VO }, - _oldOutputTable: { [KO]: VO }, - _keyIOMap: { [KI]: KO }, - _meta: { [KO]: M? }, - _keyData: { - [KI]: { - dependencySet: Set, - oldDependencySet: Set, - dependencyValues: { [PubTypes.Dependency]: any }, - }, - }, +-- A state object which maps over keys and/or values in another table. +export type For = StateObject<{[KO]: VO}> & Dependent & { + kind: "For" } +export type ForPairsConstructor = ( + scope: Scope, + inputTable: CanBeState<{[KI]: VI}>, + processor: (Use, Scope, key: KI, value: VI) -> (KO, VO) +) -> For +export type ForKeysConstructor = ( + scope: Scope, + inputTable: CanBeState<{[KI]: V}>, + processor: (Use, Scope, key: KI) -> KO +) -> For +export type ForValuesConstructor = ( + scope: Scope, + inputTable: CanBeState<{[K]: VI}>, + processor: (Use, Scope, value: VI) -> VO +) -> For --- A state object whose value is derived from other objects using a callback. -export type ForKeys = PubTypes.ForKeys & { - _oldDependencySet: Set, - _processor: (PubTypes.Use, KI) -> (KO), - _destructor: (KO, M?) -> (), - _inputIsState: boolean, - _inputTable: PubTypes.CanBeState<{ [KI]: KO }>, - _oldInputTable: { [KI]: KO }, - _outputTable: { [KO]: any }, - _keyOIMap: { [KO]: KI }, - _meta: { [KO]: M? }, - _keyData: { - [KI]: { - dependencySet: Set, - oldDependencySet: Set, - dependencyValues: { [PubTypes.Dependency]: any }, - }, - }, -} - --- A state object whose value is derived from other objects using a callback. -export type ForValues = PubTypes.ForValues & { - _oldDependencySet: Set, - _processor: (PubTypes.Use, VI) -> (VO), - _destructor: (VO, M?) -> (), - _inputIsState: boolean, - _inputTable: PubTypes.CanBeState<{ [VI]: VO }>, - _outputTable: { [any]: VI }, - _valueCache: { [VO]: any }, - _oldValueCache: { [VO]: any }, - _meta: { [VO]: M? }, - _valueData: { - [VI]: { - dependencySet: Set, - oldDependencySet: Set, - dependencyValues: { [PubTypes.Dependency]: any }, - }, - }, +-- An object which can listen for updates on another state object. +export type Observer = Dependent & { + type: "Observer", + onChange: (Observer, callback: () -> ()) -> (() -> ()), + onBind: (Observer, callback: () -> ()) -> (() -> ()) } +export type ObserverConstructor = ( + scope: Scope, + watching: Dependency +) -> Observer -- A state object which follows another state object using tweens. -export type Tween = PubTypes.Tween & { - _goalState: State, - _tweenInfo: TweenInfo, - _prevValue: T, - _nextValue: T, - _currentValue: T, - _currentTweenInfo: TweenInfo, - _currentTweenDuration: number, - _currentTweenStartTime: number, - _currentlyAnimating: boolean +export type Tween = StateObject & Dependent & { + kind: "Tween" } +export type TweenConstructor = ( + scope: Scope, + goalState: StateObject, + tweenInfo: CanBeState? +) -> Tween -- A state object which follows another state object using spring simulation. -export type Spring = PubTypes.Spring & { - _speed: PubTypes.CanBeState, - _speedIsState: boolean, - _lastSpeed: number, - _damping: PubTypes.CanBeState, - _dampingIsState: boolean, - _lastDamping: number, - _goalState: State, - _goalValue: T, - _currentType: string, - _currentValue: T, - _springPositions: {number}, - _springGoals: {number}, - _springVelocities: {number} +export type Spring = StateObject & Dependent & { + kind: "Spring", + setPosition: (Spring, newPosition: T) -> (), + setVelocity: (Spring, newVelocity: T) -> (), + addVelocity: (Spring, deltaVelocity: T) -> () } +export type SpringConstructor = ( + scope: Scope, + goalState: StateObject, + speed: CanBeState?, + damping: CanBeState? +) -> Spring --- An object which can listen for updates on another state object. -export type Observer = PubTypes.Observer & { - _changeListeners: Set<() -> ()>, - _numChangeListeners: number +--[[ + Instance related types +]] + +-- Denotes children instances in an instance or component's property table. +export type SpecialKey = { + type: "SpecialKey", + kind: string, + stage: "self" | "descendants" | "ancestor" | "observer", + apply: ( + self: SpecialKey, + scope: Scope, + value: unknown, + applyTo: Instance + ) -> () +} + +-- A collection of instances that may be parented to another instance. +export type Child = Instance | StateObject | {[unknown]: Child} + +-- A table that defines an instance's properties, handlers and children. +export type PropertyTable = {[string | SpecialKey]: unknown} + +export type NewConstructor = ( + scope: Scope, + className: string +) -> (propertyTable: PropertyTable) -> Instance + +export type HydrateConstructor = ( + scope: Scope, + target: Instance +) -> (propertyTable: PropertyTable) -> Instance + +-- Is there a sane way to write out this type? +-- ... I sure hope so. + +export type ScopedConstructor = (() -> Scope<{}>) + & ((A & {}) -> Scope) + & ((A & {}, B & {}) -> Scope) + & ((A & {}, B & {}, C & {}) -> Scope) + & ((A & {}, B & {}, C & {}, D & {}) -> Scope) + & ((A & {}, B & {}, C & {}, D & {}, E & {}) -> Scope) + & ((A & {}, B & {}, C & {}, D & {}, E & {}, F & {}) -> Scope) + & ((A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}) -> Scope) + & ((A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}, H & {}) -> Scope) + & ((A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}, H & {}, I & {}) -> Scope) + & ((A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}, H & {}, I & {}, J & {}) -> Scope) + & ((A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}, H & {}, I & {}, J & {}, K & {}) -> Scope) + & ((A & {}, B & {}, C & {}, D & {}, E & {}, F & {}, G & {}, H & {}, I & {}, J & {}, K & {}, L & {}) -> Scope) + +export type ContextualConstructor = (defaultValue: T) -> Contextual + +export type Fusion = { + version: Version, + Contextual: ContextualConstructor, + + doCleanup: (...unknown) -> (), + scoped: ScopedConstructor, + deriveScope: (existing: Scope) -> Scope, + + peek: Use, + Value: ValueConstructor, + Computed: ComputedConstructor, + ForPairs: ForPairsConstructor, + ForKeys: ForKeysConstructor, + ForValues: ForValuesConstructor, + Observer: ObserverConstructor, + + Tween: TweenConstructor, + Spring: SpringConstructor, + + New: NewConstructor, + Hydrate: HydrateConstructor, + + Ref: SpecialKey, + Children: SpecialKey, + Out: (propertyName: string) -> SpecialKey, + OnEvent: (eventName: string) -> SpecialKey, + OnChange: (propertyName: string) -> SpecialKey, + Attribute: (attributeName: string) -> SpecialKey, + AttributeChange: (attributeName: string) -> SpecialKey, + AttributeOut: (attributeName: string) -> SpecialKey, + } -return nil \ No newline at end of file +return nil diff --git a/src/Utility/Contextual.lua b/src/Utility/Contextual.lua index 90d7f523b..750bd7088 100644 --- a/src/Utility/Contextual.lua +++ b/src/Utility/Contextual.lua @@ -8,11 +8,13 @@ local Package = script.Parent.Parent local Types = require(Package.Types) +local InternalTypes = require(Package.InternalTypes) -- Logging local logError = require(Package.Logging.logError) local parseError = require(Package.Logging.parseError) local class = {} +class.type = "Contextual" local CLASS_METATABLE = {__index = class} local WEAK_KEYS_METATABLE = {__mode = "k"} @@ -21,12 +23,12 @@ local WEAK_KEYS_METATABLE = {__mode = "k"} Returns the current value of this contextual. ]] function class:now(): unknown + local self = self :: InternalTypes.Contextual local thread = coroutine.running() local value = self._valuesNow[thread] if typeof(value) ~= "table" then return self._defaultValue else - local value: {value: unknown} = value :: any return value.value end end @@ -37,26 +39,26 @@ end function class:is( newValue: unknown ) - local methods = {} -- Methods use colon `:` syntax for consistency and autocomplete but we -- actually want them to operate on the `self` from this outer lexical scope - local contextual = self + local outerSelf = self :: InternalTypes.Contextual + local methods = {} function methods:during( callback: (A...) -> T, ...: A... ): T local thread = coroutine.running() - local prevValue = contextual._valuesNow[thread] + local prevValue = outerSelf._valuesNow[thread] -- Storing the value in this format allows us to distinguish storing -- `nil` from not calling `:during()` at all. - contextual._valuesNow[thread] = { value = newValue } + outerSelf._valuesNow[thread] = { value = newValue } local ok, value = xpcall(callback, parseError, ...) - contextual._valuesNow[thread] = prevValue + outerSelf._valuesNow[thread] = prevValue if ok then return value else - logError("contextualCallbackError", value) + logError("callbackError", value) end end @@ -67,7 +69,6 @@ local function Contextual( defaultValue: T ): Types.Contextual local self = setmetatable({ - type = "Contextual", -- if we held strong references to threads here, then if a thread was -- killed before this contextual had a chance to finish executing its -- callback, it would be held strongly in this table forever diff --git a/src/Utility/None.lua b/src/Utility/None.lua deleted file mode 100644 index 6089aa326..000000000 --- a/src/Utility/None.lua +++ /dev/null @@ -1,13 +0,0 @@ ---!strict - ---[[ - A symbol for representing nil values in contexts where nil is not usable. -]] - -local Package = script.Parent.Parent -local Types = require(Package.Types) - -return { - type = "Symbol", - name = "None" -} :: Types.None \ No newline at end of file diff --git a/src/Utility/doNothing.lua b/src/Utility/doNothing.lua deleted file mode 100644 index 7bdade2c4..000000000 --- a/src/Utility/doNothing.lua +++ /dev/null @@ -1,10 +0,0 @@ ---!strict - ---[[ - An empty function. Often used as a destructor to indicate no destruction. -]] - -local function doNothing(...: any) -end - -return doNothing \ No newline at end of file diff --git a/src/Utility/isSimilar.lua b/src/Utility/isSimilar.lua index 56d1f28a7..22927ed6c 100644 --- a/src/Utility/isSimilar.lua +++ b/src/Utility/isSimilar.lua @@ -1,18 +1,23 @@ --!strict +--!nolint LocalShadow + --[[ - Returns true if A and B are 'similar' - i.e. any user of A would not need - to recompute if it changed to B. + Returns true if A and B are 'similar' - i.e. any user of A would not need + to recompute if it changed to B. ]] -local function isSimilar(a: any, b: any): boolean - -- HACK: because tables are mutable data structures, don't make assumptions - -- about similarity from equality for now (see issue #44) - if typeof(a) == "table" then - return false - else - -- NaN does not equal itself but is the same - return a == b or a ~= a and b ~= b - end +local function isSimilar( + a: unknown, + b: unknown +): boolean + -- HACK: because tables are mutable data structures, don't make assumptions + -- about similarity from equality for now (see issue #44) + if typeof(a) == "table" then + return false + else + -- NaN does not equal itself but is the same + return a == b or a ~= a and b ~= b + end end return isSimilar diff --git a/src/Utility/needsDestruction.lua b/src/Utility/needsDestruction.lua deleted file mode 100644 index 7b375e2c1..000000000 --- a/src/Utility/needsDestruction.lua +++ /dev/null @@ -1,12 +0,0 @@ ---!strict - ---[[ - Returns true if the given value is not automatically memory managed, and - requires manual cleanup. -]] - -local function needsDestruction(x: any): boolean - return typeof(x) == "Instance" -end - -return needsDestruction \ No newline at end of file diff --git a/src/Utility/restrictRead.lua b/src/Utility/restrictRead.lua deleted file mode 100644 index c86b87758..000000000 --- a/src/Utility/restrictRead.lua +++ /dev/null @@ -1,28 +0,0 @@ ---!strict - ---[[ - Restricts the reading of missing members for a table. -]] - -local Package = script.Parent.Parent -local logError = require(Package.Logging.logError) - -type table = {[any]: any} - -local function restrictRead(tableName: string, strictTable: table): table - -- FIXME: Typed Luau doesn't recognise this correctly yet - local metatable = getmetatable(strictTable :: any) - - if metatable == nil then - metatable = {} - setmetatable(strictTable, metatable) - end - - function metatable:__index(memberName) - logError("strictReadError", nil, tostring(memberName), tableName) - end - - return strictTable -end - -return restrictRead \ No newline at end of file diff --git a/src/Utility/xtypeof.lua b/src/Utility/xtypeof.lua index 4eea6e992..9157b56ab 100644 --- a/src/Utility/xtypeof.lua +++ b/src/Utility/xtypeof.lua @@ -1,4 +1,5 @@ --!strict +--!nolint LocalShadow --[[ Extended typeof, designed for identifying custom objects. @@ -6,14 +7,19 @@ Otherwise, returns `typeof()` the argument. ]] -local function xtypeof(x: any) +local function xtypeof( + x: unknown +): string local typeString = typeof(x) - if typeString == "table" and typeof(x.type) == "string" then - return x.type - else - return typeString + if typeString == "table" then + local x = x :: {type: unknown?} + if typeof(x.type) == "string" then + return x.type + end end + + return typeString end return xtypeof \ No newline at end of file diff --git a/src/init.lua b/src/init.lua index 2558f3195..317afca71 100644 --- a/src/init.lua +++ b/src/init.lua @@ -1,12 +1,33 @@ --!strict +--!nolint LocalShadow --[[ The entry point for the Fusion library. ]] -local PubTypes = require(script.PubTypes) +local Types = require(script.Types) local External = require(script.External) -local restrictRead = require(script.Utility.restrictRead) + +export type Animatable = Types.Animatable +export type CanBeState = Types.CanBeState +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 For = Types.For +export type Observer = Types.Observer +export type PropertyTable = Types.PropertyTable +export type Scope = Types.Scope +export type ScopedObject = Types.ScopedObject +export type SpecialKey = Types.SpecialKey +export type Spring = Types.Spring +export type StateObject = Types.StateObject +export type Task = Types.Task +export type Tween = Types.Tween +export type Use = Types.Use +export type Value = Types.Value +export type Version = Types.Version -- Down the line, this will be conditional based on whether Fusion is being -- compiled for Roblox. @@ -15,80 +36,41 @@ do External.setExternalScheduler(RobloxExternal) end -local Fusion = restrictRead("Fusion", { +local Fusion: Types.Fusion = { + -- General version = {major = 0, minor = 3, isRelease = false}, + Contextual = require(script.Utility.Contextual), - New = require(script.Instances.New), - Hydrate = require(script.Instances.Hydrate), - Ref = require(script.Instances.Ref), - Out = require(script.Instances.Out), - Cleanup = require(script.Instances.Cleanup), - Children = require(script.Instances.Children), - OnEvent = require(script.Instances.OnEvent), - OnChange = require(script.Instances.OnChange), + -- Memory + cleanup = require(script.Memory.legacyCleanup), + deriveScope = require(script.Memory.deriveScope), + doCleanup = require(script.Memory.doCleanup), + scoped = require(script.Memory.scoped), + + -- 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), + + -- Roblox API Attribute = require(script.Instances.Attribute), AttributeChange = require(script.Instances.AttributeChange), AttributeOut = require(script.Instances.AttributeOut), + Children = require(script.Instances.Children), + Hydrate = require(script.Instances.Hydrate), + New = require(script.Instances.New), + OnChange = require(script.Instances.OnChange), + OnEvent = require(script.Instances.OnEvent), + Out = require(script.Instances.Out), + Ref = require(script.Instances.Ref), - Value = require(script.State.Value), - Computed = require(script.State.Computed), - ForPairs = require(script.State.ForPairs), - ForKeys = require(script.State.ForKeys), - ForValues = require(script.State.ForValues), - Observer = require(script.State.Observer), - + -- Animation Tween = require(script.Animation.Tween), Spring = require(script.Animation.Spring), - - Contextual = require(script.Utility.Contextual), - cleanup = require(script.Utility.cleanup), - doNothing = require(script.Utility.doNothing), - peek = require(script.State.peek) -}) :: Fusion - -export type StateObject = PubTypes.StateObject -export type CanBeState = PubTypes.CanBeState -export type Symbol = PubTypes.Symbol -export type Value = PubTypes.Value -export type Computed = PubTypes.Computed -export type ForPairs = PubTypes.ForPairs -export type ForKeys = PubTypes.ForKeys -export type ForValues = PubTypes.ForKeys -export type Observer = PubTypes.Observer -export type Tween = PubTypes.Tween -export type Spring = PubTypes.Spring -export type Use = PubTypes.Use -export type Contextual = PubTypes.Contextual - -type Fusion = { - version: PubTypes.Version, - - New: (className: string) -> ((propertyTable: PubTypes.PropertyTable) -> Instance), - Hydrate: (target: Instance) -> ((propertyTable: PubTypes.PropertyTable) -> Instance), - Ref: PubTypes.SpecialKey, - Cleanup: PubTypes.SpecialKey, - Children: PubTypes.SpecialKey, - Out: (propertyName: string) -> PubTypes.SpecialKey, - OnEvent: (eventName: string) -> PubTypes.SpecialKey, - OnChange: (propertyName: string) -> PubTypes.SpecialKey, - Attribute: (attributeName: string) -> PubTypes.SpecialKey, - AttributeChange: (attributeName: string) -> PubTypes.SpecialKey, - AttributeOut: (attributeName: string) -> PubTypes.SpecialKey, - - Value: (initialValue: T) -> Value, - Computed: (callback: (Use) -> T, destructor: (T) -> ()?) -> Computed, - ForPairs: (inputTable: CanBeState<{[KI]: VI}>, processor: (Use, KI, VI) -> (KO, VO, M?), destructor: (KO, VO, M?) -> ()?) -> ForPairs, - ForKeys: (inputTable: CanBeState<{[KI]: any}>, processor: (Use, KI) -> (KO, M?), destructor: (KO, M?) -> ()?) -> ForKeys, - ForValues: (inputTable: CanBeState<{[any]: VI}>, processor: (Use, VI) -> (VO, M?), destructor: (VO, M?) -> ()?) -> ForValues, - Observer: (watchedState: StateObject) -> Observer, - - Tween: (goalState: StateObject, tweenInfo: TweenInfo?) -> Tween, - Spring: (goalState: StateObject, speed: CanBeState?, damping: CanBeState?) -> Spring, - - Contextual: (defaultValue: T) -> Contextual, - cleanup: (...any) -> (), - doNothing: (...any) -> (), - peek: Use } return Fusion diff --git a/test-runner.project.json b/test-runner.project.json index 7786390c0..7c51d64ad 100644 --- a/test-runner.project.json +++ b/test-runner.project.json @@ -8,9 +8,6 @@ "Fusion": { "$path": "src" }, - "FusionBench": { - "$path": "benchmark" - }, "FusionTest": { "$path": "test" } diff --git a/test-runner/Run.client.lua b/test-runner/Run.client.lua index f9b292dc4..a46b43601 100644 --- a/test-runner/Run.client.lua +++ b/test-runner/Run.client.lua @@ -4,101 +4,18 @@ local StarterPlayerScripts = game:GetService("StarterPlayer").StarterPlayerScrip local TestEZ = require(StarterPlayerScripts.TestEZ) local RUN_TESTS = true -local RUN_BENCHMARKS = false -- run unit tests if RUN_TESTS then print("Running unit tests...") + local External = require(ReplicatedStorage.Fusion.External) + External.unitTestSilenceNonFatal = true local data = TestEZ.TestBootstrap:run({ ReplicatedStorage.FusionTest }) + External.unitTestSilenceNonFatal = false if data.failureCount > 0 then return end -end - --- run benchmarks -if RUN_BENCHMARKS then - print("Running benchmarks...") - - -- wait for a bit to allow initial load to pass - this means the lag from a ton - -- of things starting up shouldn't impact the benchmarks (as much) - wait(5) - - local results = {} - local maxNameLength = 0 - - for _, instance in pairs(ReplicatedStorage.FusionBench:GetDescendants()) do - if instance:IsA("ModuleScript") and instance.Name:match("%.bench$") then - -- yield between benchmarks so we don't freeze Studio - wait() - local benchmarks = require(instance) - - local fileName = instance.Name:gsub("%.bench$", "") - local fileResults = {} - - for index, benchmarkInfo in ipairs(benchmarks) do - local name = benchmarkInfo.name - local calls = benchmarkInfo.calls - - local preRun = benchmarkInfo.preRun - local run = benchmarkInfo.run - local postRun = benchmarkInfo.postRun - - maxNameLength = math.max(maxNameLength, #name) - - local state - - if preRun ~= nil then - state = preRun() - end - - local start = os.clock() - for n=1, calls do - run(state) - end - local fin = os.clock() - - if postRun ~= nil then - postRun(state) - end - - local timeMicros = (fin - start) * 1000000 / calls - - fileResults[index] = {name = name, time = timeMicros} - end - - table.sort(fileResults, function(a, b) - return a.name < b.name - end) - - table.insert(results, {fileName = fileName, results = fileResults}) - end - end - - table.sort(results, function(a, b) - return a.fileName < b.fileName - end) - - local resultsString = "Benchmark results:" - - for _, fileInfo in ipairs(results) do - resultsString ..= "\n[+] " .. fileInfo.fileName - - for _, testInfo in ipairs(fileInfo.results) do - resultsString ..= "\n [+] " - resultsString ..= testInfo.name .. " " - resultsString ..= ("."):rep(maxNameLength - #testInfo.name + 4) .. " " - resultsString ..= ("%.2f μs"):format(testInfo.time) - end - end - - print(resultsString) -end - --- run test main script -if StarterPlayerScripts:FindFirstChild("TestMain") then - print("Running test main script...") - require(StarterPlayerScripts.TestMain) end \ No newline at end of file diff --git a/test/Animation/Tween.spec.lua b/test/Animation/Tween.spec.lua index 2c69ed88c..a511f4baa 100644 --- a/test/Animation/Tween.spec.lua +++ b/test/Animation/Tween.spec.lua @@ -43,7 +43,7 @@ return function() -- local followerState = Value(UDim2.fromScale(1, 1)) -- local tween = Tween(followerState, TweenInfo.new(0.1)) - -- local testInstance = New "Frame" { Size = tween } + -- local testInstance = New(scope, "Frame") { Size = tween } -- followerState:set(UDim2.fromScale(0.5, 0.5)) diff --git a/test/Instances/Attribute.spec.lua b/test/Instances/Attribute.spec.lua index cdcdd8ece..c9f3c4bfe 100644 --- a/test/Instances/Attribute.spec.lua +++ b/test/Instances/Attribute.spec.lua @@ -3,59 +3,49 @@ local Package = game:GetService("ReplicatedStorage").Fusion local New = require(Package.Instances.New) local Attribute = require(Package.Instances.Attribute) local Value = require(Package.State.Value) +local doCleanup = require(Package.Memory.doCleanup) return function() - it("should create attributes (constant)", function() - local child = New "Folder" { - [Attribute "Foo"] = "Bar" - } - expect(child:GetAttribute("Foo")).to.equal("Bar") - end) + it("creates attributes (constant)", function() + local scope = {} + local child = New(scope, "Folder") { + [Attribute "Foo"] = "Bar" + } + expect(child:GetAttribute("Foo")).to.equal("Bar") + doCleanup(scope) + end) - it("should create attributes (state)", function() - local attributeValue = Value("Bar") - local child = New "Folder" { - [Attribute "Foo"] = attributeValue - } - expect(child:GetAttribute("Foo")).to.equal("Bar") - end) - - it("should update attributes when state objects are updated", function() - local attributeValue = Value("Bar") - local child = New "Folder" { - [Attribute "Foo"] = attributeValue - } - expect(child:GetAttribute("Foo")).to.equal("Bar") - attributeValue:set("Baz") - task.wait() - expect(child:GetAttribute("Foo")).to.equal("Baz") - end) + it("creates attributes (state)", function() + local scope = {} + local attributeValue = Value(scope, "Bar") + local child = New(scope, "Folder") { + [Attribute "Foo"] = attributeValue + } + expect(child:GetAttribute("Foo")).to.equal("Bar") + end) + + it("updates attributes when state objects are updated", function() + local scope = {} + local attributeValue = Value(scope, "Bar") + local child = New(scope, "Folder") { + [Attribute "Foo"] = attributeValue + } + expect(child:GetAttribute("Foo")).to.equal("Bar") + attributeValue:set("Baz") + task.wait() + expect(child:GetAttribute("Foo")).to.equal("Baz") + doCleanup(scope) + end) - it("should error when given nil names (constant)", function() - expect(function() - local child = New "Folder" { - [Attribute(nil)] = "foo" - } - end).to.throw("attributeNameNil") - end) - - it("should error when given nil names (state)", function() - expect(function() - local attributeValue = Value("foo") - local child = New "Folder" { - [Attribute(nil)] = attributeValue - } - end).to.throw("attributeNameNil") - end) - - it("should defer attribute changes", function() - local value = Value("Bar") - local child = New "Folder" { - [Attribute "Foo"] = value - } - value:set("Baz") - expect(child:GetAttribute("Foo")).to.equal("Bar") - task.wait() - expect(child:GetAttribute("Foo")).to.equal("Baz") - end) + it("defers attribute changes", function() + local scope = {} + local value = Value(scope, "Bar") + local child = New(scope, "Folder") { + [Attribute "Foo"] = value + } + value:set("Baz") + expect(child:GetAttribute("Foo")).to.equal("Bar") + task.wait() + expect(child:GetAttribute("Foo")).to.equal("Baz") + end) end diff --git a/test/Instances/AttributeChange.spec.lua b/test/Instances/AttributeChange.spec.lua index 90aba84c0..06a82e49e 100644 --- a/test/Instances/AttributeChange.spec.lua +++ b/test/Instances/AttributeChange.spec.lua @@ -2,12 +2,13 @@ local Package = game:GetService("ReplicatedStorage").Fusion local New = require(Package.Instances.New) local Attribute = require(Package.Instances.Attribute) local AttributeChange = require(Package.Instances.AttributeChange) -local Value = require(Package.State.Value) +local doCleanup = require(Package.Memory.doCleanup) return function() it("should connect attribute change handlers", function() + local scope = {} local changeCount = 0 - local child = New "Folder" { + local child = New(scope, "Folder") { [Attribute "Foo"] = "Bar", [AttributeChange "Foo"] = function() changeCount += 1 @@ -17,11 +18,13 @@ return function() child:SetAttribute("Foo", "Baz") task.wait() expect(changeCount).never.to.equal(0) + doCleanup(scope) end) it("should pass the updated value as an argument", function() + local scope = {} local updatedValue = "" - local child = New "Folder" { + local child = New(scope, "Folder") { [AttributeChange "Foo"] = function(newValue) updatedValue = newValue end @@ -30,13 +33,16 @@ return function() child:SetAttribute("Foo", "Baz") task.wait() expect(updatedValue).to.equal("Baz") + doCleanup(scope) end) it("should error when given an invalid handler", function() expect(function() - local child = New "Folder" { + local scope = {} + local child = New(scope, "Folder") { [AttributeChange "Foo"] = 0 } + doCleanup(scope) end).to.throw("invalidAttributeChangeHandler") end) end diff --git a/test/Instances/AttributeOut.spec.lua b/test/Instances/AttributeOut.spec.lua index 0cbf872a6..73cc1ea7a 100644 --- a/test/Instances/AttributeOut.spec.lua +++ b/test/Instances/AttributeOut.spec.lua @@ -4,11 +4,13 @@ local Attribute = require(Package.Instances.Attribute) local AttributeOut = require(Package.Instances.AttributeOut) local Value = require(Package.State.Value) local peek = require(Package.State.peek) +local doCleanup = require(Package.Memory.doCleanup) return function() it("should update when attributes are changed externally", function() - local attributeValue = Value() - local child = New "Folder" { + local scope = {} + local attributeValue = Value(scope, nil) + local child = New(scope, "Folder") { [AttributeOut "Foo"] = attributeValue } @@ -16,12 +18,14 @@ return function() child:SetAttribute("Foo", "Bar") task.wait() expect(peek(attributeValue)).to.equal("Bar") + doCleanup(scope) end) it("should update when state objects linked update", function() - local attributeValue = Value("Foo") - local attributeOutValue = Value() - local child = New "Folder" { + local scope = {} + local attributeValue = Value(scope, "Foo") + local attributeOutValue = Value(scope, nil) + local child = New(scope, "Folder") { [Attribute "Foo"] = attributeValue, [AttributeOut "Foo"] = attributeOutValue } @@ -29,11 +33,13 @@ return function() attributeValue:set("Bar") task.wait() expect(peek(attributeOutValue)).to.equal("Bar") + doCleanup(scope) end) it("should work with two-way connections", function() - local attributeValue = Value("Bar") - local child = New "Folder" { + local scope = {} + local attributeValue = Value(scope, "Bar") + local child = New(scope, "Folder") { [Attribute "Foo"] = attributeValue, [AttributeOut "Foo"] = attributeValue } @@ -45,5 +51,6 @@ return function() child:SetAttribute("Foo", "Biff") task.wait() expect(peek(attributeValue)).to.equal("Biff") + doCleanup(scope) end) end \ No newline at end of file diff --git a/test/Instances/Children.spec.lua b/test/Instances/Children.spec.lua index b77f58c4c..2396b6b0d 100644 --- a/test/Instances/Children.spec.lua +++ b/test/Instances/Children.spec.lua @@ -2,32 +2,36 @@ local Package = game:GetService("ReplicatedStorage").Fusion local New = require(Package.Instances.New) local Children = require(Package.Instances.Children) local Value = require(Package.State.Value) +local doCleanup = require(Package.Memory.doCleanup) return function() it("should assign single children to instances", function() - local ins = New "Folder" { + local scope = {} + local ins = New(scope, "Folder") { Name = "Bob", - [Children] = New "Folder" { + [Children] = New(scope, "Folder") { Name = "Fred" } } expect(ins:FindFirstChild("Fred")).to.be.ok() + doCleanup(scope) end) it("should assign multiple children to instances", function() - local ins = New "Folder" { + local scope = {} + local ins = New(scope, "Folder") { Name = "Bob", [Children] = { - New "Folder" { + New(scope, "Folder") { Name = "Fred" }, - New "Folder" { + New(scope, "Folder") { Name = "George" }, - New "Folder" { + New(scope, "Folder") { Name = "Harry" } } @@ -36,22 +40,24 @@ return function() expect(ins:FindFirstChild("Fred")).to.be.ok() expect(ins:FindFirstChild("George")).to.be.ok() expect(ins:FindFirstChild("Harry")).to.be.ok() + doCleanup(scope) end) it("should flatten children to be assigned", function() - local ins = New "Folder" { + local scope = {} + local ins = New(scope, "Folder") { Name = "Bob", [Children] = { - New "Folder" { + New(scope, "Folder") { Name = "Fred" }, { - New "Folder" { + New(scope, "Folder") { Name = "George" }, { - New "Folder" { + New(scope, "Folder") { Name = "Harry" } } @@ -62,17 +68,19 @@ return function() expect(ins:FindFirstChild("Fred")).to.be.ok() expect(ins:FindFirstChild("George")).to.be.ok() expect(ins:FindFirstChild("Harry")).to.be.ok() + doCleanup(scope) end) it("should bind State objects passed as children", function() - local child1 = New "Folder" {} - local child2 = New "Folder" {} - local child3 = New "Folder" {} - local child4 = New "Folder" {} + local scope = {} + local child1 = New(scope, "Folder") {} + local child2 = New(scope, "Folder") {} + local child3 = New(scope, "Folder") {} + local child4 = New(scope, "Folder") {} - local children = Value({child1}) + local children = Value(scope, {child1}) - local parent = New "Folder" { + local parent = New(scope, "Folder") { [Children] = { children } @@ -94,15 +102,17 @@ return function() expect(child2.Parent).to.equal(parent) expect(child3.Parent).to.equal(parent) expect(child4.Parent).to.equal(parent) + doCleanup(scope) end) it("should defer updates to State children", function() - local child1 = New "Folder" {} - local child2 = New "Folder" {} + local scope = {} + local child1 = New(scope, "Folder") {} + local child2 = New(scope, "Folder") {} - local children = Value(child1) + local children = Value(scope, child1) - local parent = New "Folder" { + local parent = New(scope, "Folder") { [Children] = { children } @@ -119,24 +129,26 @@ return function() expect(child1.Parent).to.equal(nil) expect(child2.Parent).to.equal(parent) + doCleanup(scope) end) it("should recursively bind State children", function() - local child1 = New "Folder" {} - local child2 = New "Folder" {} - local child3 = New "Folder" {} - local child4 = New "Folder" {} + local scope = {} + local child1 = New(scope, "Folder") {} + local child2 = New(scope, "Folder") {} + local child3 = New(scope, "Folder") {} + local child4 = New(scope, "Folder") {} - local children = Value({ + local children = Value(scope, { child1, - Value(child2), - Value({ + Value(scope, child2), + Value(scope, { child3, - Value(Value(child4)) + Value(scope, Value(scope, child4)) }) }) - local parent = New "Folder" { + local parent = New(scope, "Folder") { [Children] = { children } @@ -146,14 +158,16 @@ return function() expect(child2.Parent).to.equal(parent) expect(child3.Parent).to.equal(parent) expect(child4.Parent).to.equal(parent) + doCleanup(scope) end) it("should allow for State children to be nil", function() - local child = New "Folder" {} + local scope = {} + local child = New(scope, "Folder") {} - local children = Value(nil) + local children = Value(scope, nil) - local parent = New "Folder" { + local parent = New(scope, "Folder") { [Children] = { children } @@ -170,5 +184,6 @@ return function() task.wait() expect(child.Parent).to.equal(nil) + doCleanup(scope) end) end diff --git a/test/Instances/Cleanup.spec.lua b/test/Instances/Cleanup.spec.lua deleted file mode 100644 index 216720474..000000000 --- a/test/Instances/Cleanup.spec.lua +++ /dev/null @@ -1,110 +0,0 @@ -local Package = game:GetService("ReplicatedStorage").Fusion -local New = require(Package.Instances.New) -local Cleanup = require(Package.Instances.Cleanup) - -return function() - it("should destroy instances", function() - local instance = New "Folder" {} - -- one of the only reliable ways to test for proper destruction - local conn = instance.AncestryChanged:Connect(function() end) - - local child = New "Folder" { - [Cleanup] = instance - } - child:Destroy() - task.wait() - - expect(conn.Connected).to.equal(false) - end) - - it("should disconnect connections", function() - local instance = New "Folder" {} - local conn = instance.AncestryChanged:Connect(function() end) - - local child = New "Folder" { - [Cleanup] = conn - } - child:Destroy() - task.wait() - - expect(conn.Connected).to.equal(false) - end) - - it("should invoke callbacks", function() - local didRun = false - - local child = New "Folder" { - [Cleanup] = function() - didRun = true - end - } - child:Destroy() - task.wait() - - expect(didRun).to.equal(true) - end) - - it("should invoke :destroy() methods", function() - local didRun = false - - local child = New "Folder" { - [Cleanup] = { - destroy = function() - didRun = true - end - } - } - child:Destroy() - task.wait() - - expect(didRun).to.equal(true) - end) - - it("should invoke :Destroy() methods", function() - local didRun = false - - local child = New "Folder" { - [Cleanup] = { - Destroy = function() - didRun = true - end - } - } - child:Destroy() - task.wait() - - expect(didRun).to.equal(true) - end) - - it("should clean up contents of arrays", function() - local numRuns = 0 - - local function doRun() - numRuns += 1 - end - - local child = New "Folder" { - [Cleanup] = {doRun, doRun, doRun} - } - child:Destroy() - task.wait() - - expect(numRuns).to.equal(3) - end) - - it("should clean up contents of nested arrays", function() - local numRuns = 0 - - local function doRun() - numRuns += 1 - end - - local child = New "Folder" { - [Cleanup] = {{doRun, {doRun, {doRun}}}} - } - child:Destroy() - task.wait() - - expect(numRuns).to.equal(3) - end) -end diff --git a/test/Instances/Hydrate.spec.lua b/test/Instances/Hydrate.spec.lua index c39019e53..66a5adfe0 100644 --- a/test/Instances/Hydrate.spec.lua +++ b/test/Instances/Hydrate.spec.lua @@ -1,20 +1,22 @@ local Package = game:GetService("ReplicatedStorage").Fusion local Hydrate = require(Package.Instances.Hydrate) +local doCleanup = require(Package.Memory.doCleanup) return function() it("should return the instance it was passed", function() + local scope = {} local ins = Instance.new("Folder") - - expect(Hydrate(ins) {}).to.equal(ins) + expect(Hydrate(scope, ins) {}).to.equal(ins) + doCleanup(scope) end) it("should apply properties to the instance", function() + local scope = {} local ins = Instance.new("Folder") - - Hydrate(ins) { + Hydrate(scope, ins) { Name = "Jeremy" } - expect(ins.Name).to.equal("Jeremy") + doCleanup(scope) end) end diff --git a/test/Instances/New.spec.lua b/test/Instances/New.spec.lua index 166b8e4f1..43ef6098d 100644 --- a/test/Instances/New.spec.lua +++ b/test/Instances/New.spec.lua @@ -1,30 +1,32 @@ local Package = game:GetService("ReplicatedStorage").Fusion local New = require(Package.Instances.New) local defaultProps = require(Package.Instances.defaultProps) - -local waitForGC = require(script.Parent.Parent.Utility.waitForGC) +local doCleanup = require(Package.Memory.doCleanup) return function() it("should create a new instance", function() - local ins = New "Frame" {} - + local scope = {} + local ins = New (scope, "Frame") {} expect(typeof(ins) == "Instance").to.be.ok() expect(ins:IsA("Frame")).to.be.ok() end) it("should throw for non-existent class types", function() expect(function() - New "This is not a valid class type" {} + local scope = {} + New (scope, "This is not a valid class type") {} + doCleanup(scope) end).to.throw("cannotCreateClass") end) it("should apply 'sensible default' properties", function() for className, defaults in pairs(defaultProps) do - local ins = New (className) {} - + local scope = {} + local ins = New (scope, className) {} for propName, propValue in pairs(defaults) do expect(ins[propName]).to.equal(propValue) end + doCleanup(scope) end end) end diff --git a/test/Instances/OnChange.spec.lua b/test/Instances/OnChange.spec.lua index add8fdf5e..b905951f7 100644 --- a/test/Instances/OnChange.spec.lua +++ b/test/Instances/OnChange.spec.lua @@ -1,11 +1,13 @@ local Package = game:GetService("ReplicatedStorage").Fusion local New = require(Package.Instances.New) local OnChange = require(Package.Instances.OnChange) +local doCleanup = require(Package.Memory.doCleanup) return function() it("should connect property change handlers", function() + local scope = {} local fires = 0 - local ins = New "Folder" { + local ins = New(scope, "Folder") { Name = "Foo", [OnChange "Name"] = function(newName) @@ -16,11 +18,13 @@ return function() ins.Name = "Bar" task.wait() expect(fires).never.to.equal(0) + doCleanup(scope) end) it("should pass the new value to the handler", function() + local scope = {} local arg = nil - local ins = New "Folder" { + local ins = New(scope, "Folder") { Name = "Foo", [OnChange "Name"] = function(newName) @@ -31,21 +35,25 @@ return function() ins.Name = "Bar" task.wait() expect(arg).to.equal("Bar") + doCleanup(scope) end) it("should throw when connecting to non-existent property changes", function() + local scope = {} expect(function() - New "Folder" { + New(scope, "Folder") { Name = "Foo", [OnChange "Frobulate"] = function() end } + doCleanup(scope) end).to.throw("cannotConnectChange") end) it("shouldn't fire property changes during initialisation", function() + local scope = {} local fires = 0 - local ins = New "Folder" { + local ins = New(scope, "Folder") { Parent = game, Name = "Foo", @@ -62,5 +70,6 @@ return function() ins:Destroy() task.wait() expect(totalFires).to.equal(0) + doCleanup(scope) end) end diff --git a/test/Instances/OnEvent.spec.lua b/test/Instances/OnEvent.spec.lua index c8e93179c..43409057e 100644 --- a/test/Instances/OnEvent.spec.lua +++ b/test/Instances/OnEvent.spec.lua @@ -2,11 +2,13 @@ local Package = game:GetService("ReplicatedStorage").Fusion local New = require(Package.Instances.New) local Children = require(Package.Instances.Children) local OnEvent = require(Package.Instances.OnEvent) +local doCleanup = require(Package.Memory.doCleanup) return function() it("should connect event handlers", function() + local scope = {} local fires = 0 - local ins = New "Folder" { + local ins = New(scope, "Folder") { Name = "Foo", [OnEvent "AncestryChanged"] = function() @@ -20,31 +22,37 @@ return function() task.wait() expect(fires).never.to.equal(0) + doCleanup(scope) end) it("should throw for non-existent events", function() expect(function() - New "Folder" { + local scope = {} + New(scope, "Folder") { Name = "Foo", [OnEvent "Frobulate"] = function() end } + doCleanup(scope) end).to.throw("cannotConnectEvent") end) it("should throw for non-event event handlers", function() expect(function() - New "Folder" { + local scope = {} + New(scope, "Folder") { Name = "Foo", [OnEvent "Name"] = function() end } + doCleanup(scope) end).to.throw("cannotConnectEvent") end) it("shouldn't fire events during initialisation", function() + local scope = {} local fires = 0 - local ins = New "Folder" { + local ins = New(scope, "Folder") { Parent = game, Name = "Foo", @@ -60,7 +68,7 @@ return function() fires += 1 end, - [Children] = New "Folder" { + [Children] = New(scope, "Folder") { Name = "Bar" } } @@ -71,5 +79,6 @@ return function() task.wait() expect(totalFires).to.equal(0) + doCleanup(scope) end) end diff --git a/test/Instances/Out.spec.lua b/test/Instances/Out.spec.lua index 062fff59f..8b311ff1d 100644 --- a/test/Instances/Out.spec.lua +++ b/test/Instances/Out.spec.lua @@ -3,12 +3,14 @@ local New = require(Package.Instances.New) local Out = require(Package.Instances.Out) local Value = require(Package.State.Value) local peek = require(Package.State.peek) +local doCleanup = require(Package.Memory.doCleanup) return function() it("should reflect external property changes", function() - local outValue = Value() + local scope = {} + local outValue = Value(scope, nil) - local child = New "Folder" { + local child = New(scope, "Folder") { [Out "Name"] = outValue } expect(peek(outValue)).to.equal("Folder") @@ -16,13 +18,15 @@ return function() child.Name = "Mary" task.wait() expect(peek(outValue)).to.equal("Mary") + doCleanup(scope) end) it("should reflect property changes from bound state", function() - local outValue = Value() - local inValue = Value("Gabriel") + local scope = {} + local outValue = Value(scope, nil) + local inValue = Value(scope, "Gabriel") - local child = New "Folder" { + local child = New(scope, "Folder") { Name = inValue, [Out "Name"] = outValue } @@ -31,12 +35,14 @@ return function() inValue:set("Joseph") task.wait() expect(peek(outValue)).to.equal("Joseph") + doCleanup(scope) end) it("should support two-way data binding", function() - local twoWayValue = Value("Gabriel") + local scope = {} + local twoWayValue = Value(scope, "Gabriel") - local child = New "Folder" { + local child = New(scope, "Folder") { Name = twoWayValue, [Out "Name"] = twoWayValue } @@ -49,5 +55,6 @@ return function() child.Name = "Elias" task.wait() expect(peek(twoWayValue)).to.equal("Elias") + doCleanup(scope) end) end diff --git a/test/Instances/Ref.spec.lua b/test/Instances/Ref.spec.lua index cfa395b24..5208255b0 100644 --- a/test/Instances/Ref.spec.lua +++ b/test/Instances/Ref.spec.lua @@ -3,15 +3,18 @@ local New = require(Package.Instances.New) local Ref = require(Package.Instances.Ref) local Value = require(Package.State.Value) local peek = require(Package.State.peek) +local doCleanup = require(Package.Memory.doCleanup) return function() it("should set State objects passed as [Ref]", function() - local refValue = Value() + local scope = {} + local refValue = Value(scope, nil) - local child = New "Folder" { + local child = New(scope, "Folder") { [Ref] = refValue } expect(peek(refValue)).to.equal(child) + doCleanup(scope) end) end diff --git a/test/Instances/applyInstanceProps.spec.lua b/test/Instances/applyInstanceProps.spec.lua index a43278fa2..c977b4d5c 100644 --- a/test/Instances/applyInstanceProps.spec.lua +++ b/test/Instances/applyInstanceProps.spec.lua @@ -1,143 +1,217 @@ local Package = game:GetService("ReplicatedStorage").Fusion local applyInstanceProps = require(Package.Instances.applyInstanceProps) local Value = require(Package.State.Value) +local doCleanup = require(Package.Memory.doCleanup) return function() it("should assign properties (constant)", function() + local scope = {} local instance = Instance.new("Folder") - applyInstanceProps({ - Name = "Bob", - }, instance) + table.insert(scope, instance) + applyInstanceProps( + scope, + { Name = "Bob" }, + instance + ) expect(instance.Name).to.equal("Bob") + doCleanup(scope) end) it("should assign properties (state)", function() - local value = Value("Bob") + local scope = {} + local value = Value(scope, "Bob") local instance = Instance.new("Folder") - applyInstanceProps({ - Name = value, - }, instance) + table.insert(scope, instance) + applyInstanceProps( + scope, + { Name = value }, + instance + ) expect(instance.Name).to.equal("Bob") value:set("Maya") task.wait() -- property changes are deferred expect(instance.Name).to.equal("Maya") + doCleanup(scope) end) it("should assign Parent (constant)", function() + local scope = {} local parent = Instance.new("Folder") + table.insert(scope, parent) local instance = Instance.new("Folder") - applyInstanceProps({ - Parent = parent, - }, instance) + table.insert(scope, instance) + applyInstanceProps( + scope, + { Parent = parent }, + instance + ) expect(instance.Parent).to.equal(parent) + doCleanup(scope) end) it("should assign Parent (state)", function() + local scope = {} local parent1 = Instance.new("Folder") + table.insert(scope, parent1) local parent2 = Instance.new("Folder") - local value = Value(parent1) + table.insert(scope, parent2) + local value = Value(scope, parent1) local instance = Instance.new("Folder") - applyInstanceProps({ - Parent = value, - }, instance) + table.insert(scope, instance) + applyInstanceProps( + scope, + { Parent = value }, + instance + ) expect(instance.Parent).to.equal(parent1) value:set(parent2) task.wait() -- property changes are deferred expect(instance.Parent).to.equal(parent2) + doCleanup(scope) end) it("should throw for non-existent properties (constant)", function() expect(function() + local scope = {} local instance = Instance.new("Folder") - applyInstanceProps({ - NotARealProperty = true, - }, instance) + table.insert(scope, instance) + applyInstanceProps( + scope, + { NotARealProperty = true }, + instance + ) + doCleanup(scope) end).to.throw("cannotAssignProperty") end) it("should throw for non-existent properties (state)", function() expect(function() + local scope = {} + local value = Value(scope, true) local instance = Instance.new("Folder") - applyInstanceProps({ - NotARealProperty = Value(true), - }, instance) + table.insert(scope, instance) + applyInstanceProps( + scope, + { NotARealProperty = value }, + instance + ) + doCleanup(scope) end).to.throw("cannotAssignProperty") end) it("should throw for invalid property types (constant)", function() expect(function() + local scope = {} local instance = Instance.new("Folder") - applyInstanceProps({ - Name = Vector3.new(), - }, instance) + table.insert(scope, instance) + applyInstanceProps( + scope, + { Name = Vector3.new() }, + instance + ) + doCleanup(scope) end).to.throw("invalidPropertyType") end) it("should throw for invalid property types (state)", function() expect(function() + local scope = {} + local value = Value(scope, Vector3.new()) local instance = Instance.new("Folder") - applyInstanceProps({ - Name = Value(Vector3.new()), - }, instance) + table.insert(scope, instance) + applyInstanceProps( + scope, + { Name = value }, + instance + ) + doCleanup(scope) end).to.throw("invalidPropertyType") end) it("should throw for invalid Parent types (constant)", function() expect(function() + local scope = {} local instance = Instance.new("Folder") - applyInstanceProps({ - Parent = Vector3.new(), - }, instance) + table.insert(scope, instance) + applyInstanceProps( + scope, + { Parent = Vector3.new() }, + instance + ) + doCleanup(scope) end).to.throw("invalidPropertyType") end) it("should throw for invalid Parent types (state)", function() expect(function() + local scope = {} + local value = Value(scope, Vector3.new()) local instance = Instance.new("Folder") - applyInstanceProps({ - Parent = Value(Vector3.new()), - }, instance) + table.insert(scope, instance) + applyInstanceProps( + scope, + { Parent = value }, + instance + ) + doCleanup(scope) end).to.throw("invalidPropertyType") end) it("should throw for unrecognised keys in the property table", function() expect(function() + local scope = {} local instance = Instance.new("Folder") - applyInstanceProps({ - [2] = true, - }, instance) + table.insert(scope, instance) + applyInstanceProps( + scope, + { [2 :: any] = true }, + instance + ) + doCleanup(scope) end).to.throw("unrecognisedPropertyKey") end) it("should defer property changes", function() - local value = Value("Bob") + local scope = {} + local value = Value(scope, "Bob") local instance = Instance.new("Folder") - applyInstanceProps({ - Name = value, - }, instance) + table.insert(scope, instance) + applyInstanceProps( + scope, + { Name = value }, + instance + ) value:set("Maya") expect(instance.Name).to.equal("Bob") task.wait() expect(instance.Name).to.equal("Maya") + doCleanup(scope) end) it("should defer Parent changes", function() + local scope = {} local parent1 = Instance.new("Folder") + table.insert(scope, parent1) local parent2 = Instance.new("Folder") - local value = Value(parent1) + table.insert(scope, parent2) + local value = Value(scope, parent1) local instance = Instance.new("Folder") - applyInstanceProps({ - Parent = value, - }, instance) + table.insert(scope, instance) + applyInstanceProps( + scope, + { Parent = value }, + instance + ) value:set(parent2) expect(instance.Parent).to.equal(parent1) task.wait() expect(instance.Parent).to.equal(parent2) + doCleanup(scope) end) end diff --git a/test/Utility/cleanup.spec.lua b/test/Memory/doCleanup.spec.lua similarity index 74% rename from test/Utility/cleanup.spec.lua rename to test/Memory/doCleanup.spec.lua index b7ea6249b..7f40dcba4 100644 --- a/test/Utility/cleanup.spec.lua +++ b/test/Memory/doCleanup.spec.lua @@ -1,23 +1,23 @@ local Package = game:GetService("ReplicatedStorage").Fusion local New = require(Package.Instances.New) -local cleanup = require(Package.Utility.cleanup) +local doCleanup = require(Package.Memory.doCleanup) return function() it("should destroy instances", function() - local instance = New "Folder" {} + local instance = New({}, "Folder") {} -- one of the only reliable ways to test for proper destruction local conn = instance.AncestryChanged:Connect(function() end) - cleanup(instance) + doCleanup(instance) expect(conn.Connected).to.equal(false) end) it("should disconnect connections", function() - local instance = New "Folder" {} + local instance = New({}, "Folder") {} local conn = instance.AncestryChanged:Connect(function() end) - cleanup(conn) + doCleanup(conn) expect(conn.Connected).to.equal(false) end) @@ -25,7 +25,7 @@ return function() it("should invoke callbacks", function() local didRun = false - cleanup(function() + doCleanup(function() didRun = true end) @@ -35,7 +35,7 @@ return function() it("should invoke :destroy() methods", function() local didRun = false - cleanup({ + doCleanup({ destroy = function() didRun = true end @@ -47,7 +47,7 @@ return function() it("should invoke :Destroy() methods", function() local didRun = false - cleanup({ + doCleanup({ Destroy = function() didRun = true end @@ -63,9 +63,14 @@ return function() numRuns += 1 end - cleanup({doRun, doRun, doRun}) + local arr = {doRun, doRun, doRun} + + 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) end) it("should clean up contents of nested arrays", function() @@ -75,12 +80,12 @@ return function() numRuns += 1 end - cleanup({{doRun, {doRun, {doRun}}}}) + doCleanup({{doRun, {doRun, {doRun}}}}) expect(numRuns).to.equal(3) end) - it("should clean up contents of arrays in order", function() + it("should clean up contents of arrays in reverse order", function() local runs = {} local tasks = {} @@ -97,11 +102,11 @@ return function() table.insert(runs, 2) end - cleanup(tasks) + doCleanup(tasks) - expect(runs[1]).to.equal(1) + expect(runs[1]).to.equal(3) expect(runs[2]).to.equal(2) - expect(runs[3]).to.equal(3) + expect(runs[3]).to.equal(1) end) it("should clean up variadic arguments", function() @@ -111,7 +116,7 @@ return function() numRuns += 1 end - cleanup(doRun, doRun, doRun) + doCleanup(doRun, doRun, doRun) expect(numRuns).to.equal(3) end) diff --git a/test/Utility/doNothing.spec.lua b/test/Memory/doNothing.spec.lua similarity index 91% rename from test/Utility/doNothing.spec.lua rename to test/Memory/doNothing.spec.lua index 393ae9b66..e322e6c59 100644 --- a/test/Utility/doNothing.spec.lua +++ b/test/Memory/doNothing.spec.lua @@ -1,10 +1,10 @@ local Package = game:GetService("ReplicatedStorage").Fusion local New = require(Package.Instances.New) -local doNothing = require(Package.Utility.doNothing) +local doNothing = require(Package.Memory.doNothing) return function() it("should not destroy instances", function() - local instance = New "Folder" {} + local instance = New({}, "Folder") {} -- one of the only reliable ways to test for proper destruction local conn = instance.AncestryChanged:Connect(function() end) @@ -14,7 +14,7 @@ return function() end) it("should not disconnect connections", function() - local instance = New "Folder" {} + local instance = New({}, "Folder") {} local conn = instance.AncestryChanged:Connect(function() end) doNothing(conn) diff --git a/test/Memory/scoped.spec.lua b/test/Memory/scoped.spec.lua new file mode 100644 index 000000000..8b7a5472f --- /dev/null +++ b/test/Memory/scoped.spec.lua @@ -0,0 +1,72 @@ +local Package = game:GetService("ReplicatedStorage").Fusion +local scoped = require(Package.Memory.scoped) + +return function() + -- it("should accept zero arguments", function() + -- local merged = merge() + + -- expect(merged).to.be.a("table") + -- expect(#merged).to.equal(0) + -- end) + + -- it("should clone single arguments", function() + -- local original = {foo = "FOO", bar = "BAR", baz = "BAZ"} + -- local merged = merge(original) + + -- expect(merged).to.be.a("table") + -- expect(merged).to.never.equal(original) + -- for key, value in original do + -- expect(merged[key]).to.equal(value) + -- end + -- end) + + -- it("should merge two arguments", function() + -- local originalA = {foo = "FOO", bar = "BAR", baz = "BAZ"} + -- local originalB = {frob = "FROB", garb = "GARB", grok = "GROK"} + -- local merged = merge(originalA, originalB) + + -- expect(merged).to.be.a("table") + -- for _, original in {originalA, originalB} do + -- expect(merged).to.never.equal(original) + -- for key, value in original do + -- expect(merged[key]).to.equal(value) + -- end + -- for key, value in original do + -- expect(merged[key]).to.equal(value) + -- end + -- for key, value in original do + -- expect(merged[key]).to.equal(value) + -- end + -- end + -- end) + + -- it("should merge three arguments", function() + -- local originalA = {foo = "FOO", bar = "BAR", baz = "BAZ"} + -- local originalB = {frob = "FROB", garb = "GARB", grok = "GROK"} + -- local originalC = {grep = "GREP", bork = "BORK", grum = "GRUM"} + -- local merged = merge(originalA, originalB, originalC) + + -- expect(merged).to.be.a("table") + -- for _, original in {originalA, originalB, originalC} do + -- expect(merged).to.never.equal(original) + -- for key, value in original do + -- expect(merged[key]).to.equal(value) + -- end + -- for key, value in original do + -- expect(merged[key]).to.equal(value) + -- end + -- for key, value in original do + -- expect(merged[key]).to.equal(value) + -- end + -- end + -- end) + + -- it("should error on collision", function() + -- expect(function() + -- local originalA = {foo = "FOO", bar = "BAR", baz = "BAZ"} + -- local originalB = {frob = "FROB", garb = "GARB", grok = "GROK"} + -- local originalC = {grep = "GREP", grok = "GROK", grum = "GRUM"} + -- merge(originalA, originalB, originalC) + -- end).to.throw("mergeConflict") + -- end) +end \ No newline at end of file diff --git a/test/State/Computed.spec.lua b/test/State/Computed.spec.lua index 9c0213086..783f525b8 100644 --- a/test/State/Computed.spec.lua +++ b/test/State/Computed.spec.lua @@ -1,138 +1,138 @@ -local RunService = game:GetService("RunService") - local Package = game:GetService("ReplicatedStorage").Fusion local Computed = require(Package.State.Computed) local Value = require(Package.State.Value) local peek = require(Package.State.peek) - -local waitForGC = require(script.Parent.Parent.Utility.waitForGC) +local doCleanup = require(Package.Memory.doCleanup) return function() - it("should construct a Computed object", function() - local computed = Computed(function(use) end) + it("constructs in scopes", function() + local scope = {} + local computed = Computed(scope, function() + -- intentionally blank + end) expect(computed).to.be.a("table") expect(computed.type).to.equal("State") expect(computed.kind).to.equal("Computed") + expect(scope[1]).to.equal(computed) + + doCleanup(scope) end) - it("should calculate and retrieve its value", function() - local computed = Computed(function(use) - return "foo" + it("is destroyable", function() + local scope = {} + local computed = Computed(scope, function() + -- intentionally blank end) - - expect(peek(computed)).to.equal("foo") + expect(function() + computed:destroy() + end).to.never.throw() end) - it("should recalculate its value in response to State objects", function() - local currentNumber = Value(2) - local doubled = Computed(function(use) - return use(currentNumber) * 2 + it("computes with constants", function() + local scope = {} + local computed = Computed(scope, function(use) + return use(5) end) - - expect(peek(doubled)).to.equal(4) - - currentNumber:set(4) - expect(peek(doubled)).to.equal(8) + expect(peek(computed)).to.equal(5) + doCleanup(scope) end) - it("should recalculate its value in response to Computed objects", function() - local currentNumber = Value(2) - local doubled = Computed(function(use) - return use(currentNumber) * 2 + it("computes with state objects", function() + local scope = {} + local dependency = Value(scope, 5) + local computed = Computed(scope, function(use) + return use(dependency) end) - local tripled = Computed(function(use) - return use(doubled) * 1.5 - end) - - expect(peek(tripled)).to.equal(6) - - currentNumber:set(4) - expect(peek(tripled)).to.equal(12) + expect(peek(computed)).to.equal(5) + dependency:set("foo") + expect(peek(computed)).to.equal("foo") + doCleanup(scope) end) - it("should not corrupt dependencies after an error", function() - local state = Value(1) - local simulateError = false - local computed = Computed(function(use) - if simulateError then - -- in a naive implementation, this would corrupt dependencies as - -- use(state) hasn't been captured yet, preventing future - -- reactive updates from taking place - -- to avoid this, dependencies captured when a callback errors - -- have to be discarded - error("This is an intentional error from a unit test") - end - - return use(state) + it("preserves value on error", function() + local scope = {} + local dependency = Value(scope, 5) + local computed = Computed(scope, function(use) + assert(use(dependency) ~= 13, "This is an intentional error from a unit test") + return use(dependency) end) - - expect(peek(computed)).to.equal(1) - - simulateError = true - state:set(5) -- update the computed to invoke the error - - simulateError = false - state:set(10) -- if dependencies are corrupt, the computed won't update - - expect(peek(computed)).to.equal(10) + expect(peek(computed)).to.equal(5) + dependency:set(13) -- this will invoke the error + expect(peek(computed)).to.equal(5) + dependency:set(2) + expect(peek(computed)).to.equal(2) + doCleanup(scope) end) - it("should garbage-collect unused objects", function() - local state = Value(2) - - local counter = 0 - - do - local computed = Computed(function(use) - counter += 1 - return use(state) + it("doesn't destroy inner scope on creation", function() + local scope = {} + local destructed = false + local _ = Computed(scope, function(innerScope) + table.insert(innerScope, function() + destructed = true end) - end - - waitForGC() - state:set(5) + end) + expect(destructed).to.equal(false) - expect(counter).to.equal(1) + doCleanup(scope) end) - it("should not garbage-collect objects in use", function() - local state = Value(2) - local computed2 - - local counter = 0 - - do - local computed = Computed(function(use) - counter += 1 - return use(state) + it("destroys inner scope on update", function() + local scope = {} + local destructed = {} + local dependency = Value(scope, 1) + local _ = Computed(scope, function(use, innerScope) + local value = use(dependency) + table.insert(innerScope, function() + destructed[value] = true end) + return use(dependency) + end) + expect(destructed[1]).to.equal(nil) + dependency:set(2) + expect(destructed[1]).to.equal(true) + expect(destructed[2]).to.equal(nil) + dependency:set(3) + expect(destructed[2]).to.equal(true) + + doCleanup(scope) + end) - computed2 = Computed(function(use) - return use(computed) + it("destroys errored values and preserves the last non-error value", function() + local scope = {} + local numDestructions = {} + local dependency = Value(scope, 1) + local _ = Computed(scope, function(use, innerScope) + local value = use(dependency) + table.insert(innerScope, function() + numDestructions[value] = (numDestructions[value] or 0) + 1 end) - end - - waitForGC() - state:set(5) - - expect(counter).to.equal(2) + assert(value ~= 2, "This is an intentional error from a unit test") + return use(dependency) + end) + expect(numDestructions[1]).to.equal(nil) + dependency:set(2) + expect(numDestructions[1]).to.equal(nil) + expect(numDestructions[2]).to.equal(1) + dependency:set(3) + expect(numDestructions[2]).to.equal(1) + expect(numDestructions[3]).to.equal(nil) + dependency:set(4) + expect(numDestructions[3]).to.equal(1) + + doCleanup(scope) end) - it("should call destructors when old values are replaced", function() - local didRun = false - local function destructor(x) - if x == "old" then - didRun = true - end - end - - local value = Value("old") - local computed = Computed(function(use) - return use(value) - end, destructor) - value:set("new") - - expect(didRun).to.equal(true) + it("destroys inner scope on destroy", function() + local scope = {} + local destructed = false + local _ = Computed(scope, function(use, innerScope) + table.insert(innerScope, function() + destructed = true + end) + end) + doCleanup(scope) + expect(destructed).to.equal(true) end) end diff --git a/test/State/For.spec.lua b/test/State/For.spec.lua new file mode 100644 index 000000000..9ac4821c8 --- /dev/null +++ b/test/State/For.spec.lua @@ -0,0 +1,176 @@ +local Package = game:GetService("ReplicatedStorage").Fusion +local For = require(Package.State.For) +local Value = require(Package.State.Value) +local Computed = require(Package.State.Computed) +local peek = require(Package.State.peek) +local doCleanup = require(Package.Memory.doCleanup) + +return function() + it("constructs in scopes", function() + local scope = {} + local forObject = For(scope, {}, function() + -- intentionally blank + end) + + expect(forObject).to.be.a("table") + expect(forObject.type).to.equal("State") + expect(forObject.kind).to.equal("For") + expect(scope[1]).to.equal(forObject) + + doCleanup(scope) + end) + + it("is destroyable", function() + local scope = {} + local forObject = For(scope, {}, function() + -- intentionally blank + end) + + expect(function() + forObject:destroy() + end).to.never.throw() + end) + + it("processes pairs for constant tables", function() + 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).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 scope = {} + local data = Value(scope, {foo = 1, bar = 2}) + 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).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 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 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) + 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 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/State/ForKeys.spec.lua b/test/State/ForKeys.spec.lua index 0e449160b..d597bd0ca 100644 --- a/test/State/ForKeys.spec.lua +++ b/test/State/ForKeys.spec.lua @@ -1,281 +1,234 @@ -local RunService = game:GetService("RunService") - local Package = game:GetService("ReplicatedStorage").Fusion local ForKeys = require(Package.State.ForKeys) local Value = require(Package.State.Value) local peek = require(Package.State.peek) - -local waitForGC = require(script.Parent.Parent.Utility.waitForGC) +local doCleanup = require(Package.Memory.doCleanup) return function() - it("should construct a ForKeys object", function() - local forKeys = ForKeys({}, function(use) end) - - expect(forKeys).to.be.a("table") - expect(forKeys.type).to.equal("State") - expect(forKeys.kind).to.equal("ForKeys") - end) - - it("should calculate and retrieve its value", function() - local computedPair = ForKeys({ ["foo"] = true }, function(use, key) - return key .. "baz" + it("constructs in scopes", function() + local scope = {} + local forObject = ForKeys(scope, {}, function() + -- intentionally blank end) - local state = peek(computedPair) + expect(forObject).to.be.a("table") + expect(forObject.type).to.equal("State") + expect(forObject.kind).to.equal("For") + expect(scope[1]).to.equal(forObject) - expect(state["foobaz"]).to.be.ok() - expect(state["foobaz"]).to.equal(true) + doCleanup(scope) end) - it("should not recalculate its KO in response to an unchanged KI", function() - local state = Value({ - ["foo"] = "bar", - }) - - local calculations = 0 - - local computedPair = ForKeys(state, function(use, key) - calculations += 1 - return key + it("is destroyable", function() + local scope = {} + local forObject = ForKeys(scope, {}, function() + -- intentionally blank end) - - expect(calculations).to.equal(1) - - state:set({ - ["foo"] = "biz", - ["baz"] = "bar", - }) - - expect(calculations).to.equal(2) + expect(function() + forObject:destroy() + end).to.never.throw() end) - it("should call the destructor when a key gets removed", function() - local state = Value({ - ["foo"] = "bar", - ["baz"] = "bar", - }) - - local destructions = 0 - - local computedPair = ForKeys(state, function(use, key) - return key .. "biz" - end, function(key) - destructions += 1 + it("iterates on constants", function() + local scope = {} + local data = {foo = 1, bar = 2} + local forObject = ForKeys(scope, data, function(_, _, key) + return key:upper() end) - - state:set({ - ["foo"] = "bar", - }) - - expect(destructions).to.equal(1) - - state:set({ - ["baz"] = "bar", - }) - - expect(destructions).to.equal(2) - - state:set({ - ["foo"] = "bar", - ["baz"] = "bar", - }) - - expect(destructions).to.equal(2) - - state:set({}) - - expect(destructions).to.equal(4) + expect(peek(forObject)).to.be.a("table") + expect(peek(forObject).FOO).to.equal(1) + expect(peek(forObject).BAR).to.equal(2) + doCleanup(scope) end) - it("should throw if there is a key collision", function() - expect(function() - local state = Value({ - ["foo"] = "bar", - ["baz"] = "bar", - }) - - local computed = ForKeys(state, function(use) - return "foo" - end) - end).to.throw("forKeysKeyCollision") - - local state = Value({ - ["foo"] = "bar", - }) - - local computed = ForKeys(state, function(use) - return "foo" + it("iterates on state objects", function() + local scope = {} + local data = Value(scope, {foo = 1, bar = 2}) + local forObject = ForKeys(scope, data, function(_, _, key) + return key:upper() end) - - expect(function() - state:set({ - ["foo"] = "bar", - ["baz"] = "bar", - }) - end).to.throw("forKeysKeyCollision") + expect(peek(forObject)).to.be.a("table") + expect(peek(forObject).FOO).to.equal(1) + expect(peek(forObject).BAR).to.equal(2) + data:set({baz = 3, garb = 4}) + expect(peek(forObject).FOO).to.equal(nil) + expect(peek(forObject).BAR).to.equal(nil) + expect(peek(forObject).BAZ).to.equal(3) + expect(peek(forObject).GARB).to.equal(4) + doCleanup(scope) end) - it("should call the destructor with meta data", function() - local state = Value({ - ["foo"] = "bar", - }) - - local destructions = 0 - - local computedKey = ForKeys(state, function(use, key) - local newKey = key .. "biz" - return newKey, newKey - end, function(key, meta) - expect(meta).to.equal(key) - destructions += 1 + it("computes with constants", function() + local scope = {} + local data = {foo = 1, bar = 2} + local forObject = ForKeys(scope, data, function(use, _, key) + return key .. use("baz") end) - - state:set({ - ["baz"] = "bar", - }) - - -- this verifies that the meta expectation passed - expect(destructions).to.equal(1) - - state:set({}) - - -- this verifies that the meta expectation passed - expect(destructions).to.equal(2) + expect(peek(forObject).foobaz).to.equal(1) + expect(peek(forObject).barbaz).to.equal(2) + doCleanup(scope) end) - it("should only destruct an output key when it has no associated input key", function() - local map = { - ["foo"] = "fiz", - ["bar"] = "fiz", - ["baz"] = "fuzz", - } - - local state = Value({ - ["foo"] = true, - }) - - local destructions = 0 - - local computedKey = ForKeys(state, function(use, key) - return map[key] - end, function() - destructions += 1 + it("computes with state objects", function() + local scope = {} + local data = {foo = 1, bar = 2} + local suffix = Value(scope, "first") + local forObject = ForKeys(scope, data, function(use, _, key) + return key .. use(suffix) end) - - state:set({ - ["bar"] = true, - }) - - expect(destructions).to.equal(0) - - state:set({ - ["foo"] = true, - ["bar"] = true, - ["baz"] = true, - }) - - expect(destructions).to.equal(0) - - state:set({ - ["baz"] = true, - }) - - expect(destructions).to.equal(1) + expect(peek(forObject).foofirst).to.equal(1) + expect(peek(forObject).barfirst).to.equal(2) + suffix:set("second") + expect(peek(forObject).foofirst).to.equal(nil) + expect(peek(forObject).barfirst).to.equal(nil) + expect(peek(forObject).foosecond).to.equal(1) + expect(peek(forObject).barsecond).to.equal(2) + doCleanup(scope) end) - it("should recalculate its value in response to State objects", function() - local baseMap = Value({ - ["foo"] = "baz", - }) - local barMap = ForKeys(baseMap, function(use, key) - return key .. "bar" + it("destroys and omits keys that error during processing", function() + local scope = {} + local data = {foo = 1, bar = 2, baz = 3} + local suffix = Value(scope, "first") + local destroyed = {} + local forObject = ForKeys(scope, data, function(use, innerScope, key) + local generated = key .. use(suffix) + table.insert(innerScope, function() + destroyed[generated] = true + end) + if key == "bar" and use(suffix) == "second" then + error("This is an intentional error from a unit test") + end + return generated end) - - expect(peek(barMap)["foobar"]).to.be.ok() - - baseMap:set({ - ["baz"] = "foo", - }) - - expect(peek(barMap)["bazbar"]).to.be.ok() + expect(peek(forObject).foofirst).to.equal(1) + expect(peek(forObject).barfirst).to.equal(2) + expect(peek(forObject).bazfirst).to.equal(3) + suffix:set("second") + expect(peek(forObject).foofirst).to.equal(nil) + expect(peek(forObject).barfirst).to.equal(nil) + expect(peek(forObject).bazfirst).to.equal(nil) + expect(peek(forObject).foosecond).to.equal(1) + expect(peek(forObject).barsecond).to.equal(nil) + expect(peek(forObject).bazsecond).to.equal(3) + expect(destroyed.foofirst).to.equal(true) + expect(destroyed.barfirst).to.equal(true) + expect(destroyed.bazfirst).to.equal(true) + expect(destroyed.foosecond).to.equal(nil) + expect(destroyed.barsecond).to.equal(true) + expect(destroyed.bazsecond).to.equal(nil) + suffix:set("third") + expect(peek(forObject).foofirst).to.equal(nil) + expect(peek(forObject).barfirst).to.equal(nil) + expect(peek(forObject).bazfirst).to.equal(nil) + expect(peek(forObject).foosecond).to.equal(nil) + expect(peek(forObject).barsecond).to.equal(nil) + expect(peek(forObject).bazsecond).to.equal(nil) + expect(peek(forObject).foothird).to.equal(1) + expect(peek(forObject).barthird).to.equal(2) + expect(peek(forObject).bazthird).to.equal(3) + doCleanup(scope) end) - it("should recalculate its value in response to ForKeys objects", function() - local baseMap = Value({ - ["foo"] = "baz", - }) - local barMap = ForKeys(baseMap, function(use, key) - return key .. "bar" - end) - local bizMap = ForKeys(barMap, function(use, key) - return key .. "biz" + it("omits keys that return nil", function() + local scope = {} + local data = {foo = 1, bar = 2, baz = 3} + local omitThird = Value(scope, false) + local forObject = ForKeys(scope, data, function(use, _, key) + if key == "bar" then + return nil + end + if use(omitThird) then + if key == "baz" then + return nil + end + end + return key end) - - expect(peek(barMap)["foobar"]).to.be.ok() - expect(peek(bizMap)["foobarbiz"]).to.be.ok() - - baseMap:set({ - ["fiz"] = "foo", - ["baz"] = "foo", - }) - - expect(peek(barMap)["fizbar"]).to.be.ok() - expect(peek(bizMap)["fizbarbiz"]).to.be.ok() - expect(peek(barMap)["bazbar"]).to.be.ok() - expect(peek(bizMap)["bazbarbiz"]).to.be.ok() - end) - - itSKIP("should not corrupt dependencies after an error", function() - -- needs rewrite + expect(peek(forObject).foo).to.equal(1) + expect(peek(forObject).bar).to.equal(nil) + expect(peek(forObject).baz).to.equal(3) + omitThird:set(true) + expect(peek(forObject).foo).to.equal(1) + expect(peek(forObject).bar).to.equal(nil) + expect(peek(forObject).baz).to.equal(nil) + omitThird:set(false) + expect(peek(forObject).foo).to.equal(1) + expect(peek(forObject).bar).to.equal(nil) + expect(peek(forObject).baz).to.equal(3) + doCleanup(scope) end) - it("should garbage-collect unused objects", function() - local state = Value({ - ["foo"] = "bar", - }) - - local counter = 0 - - do - local computedKeys = ForKeys(state, function(use, key) - counter += 1 - return key + it("doesn't destroy inner scope on creation", function() + local scope = {} + local destructed = {} + local data = Value(scope, {foo = 1, bar = 2}) + local _ = ForKeys(scope, data, function(_, innerScope, key) + table.insert(innerScope, function() + destructed[key] = true end) - end - - waitForGC() - - state:set({ - ["bar"] = "baz", - }) - - expect(counter).to.equal(1) + return key + end) + expect(destructed.foo).to.equal(nil) + expect(destructed.bar).to.equal(nil) + data:set({foo = 1, bar = 2, baz = 3}) + expect(destructed.foo).to.equal(nil) + expect(destructed.bar).to.equal(nil) + expect(destructed.baz).to.equal(nil) + doCleanup(scope) end) - it("should not garbage-collect objects in use", function() - local state = Value({ - ["foo"] = "bar", - }) - local computed2 - - local counter = 0 - - do - local computed = ForKeys(state, function(use, key) - counter += 1 - return key + it("destroys inner scope on update", function() + local scope = {} + local destructed = {} + local data = Value(scope, {foo = 1, bar = 2}) + local _ = ForKeys(scope, data, function(_, innerScope, key) + table.insert(innerScope, function() + destructed[key] = true end) + return key + end) + expect(destructed.foo).to.equal(nil) + expect(destructed.bar).to.equal(nil) + data:set({baz = 3}) + expect(destructed.foo).to.equal(true) + expect(destructed.bar).to.equal(true) + expect(destructed.baz).to.equal(nil) + doCleanup(scope) + end) - computed2 = ForKeys(computed, function(use, key) - return key + it("destroys inner scope on destroy", function() + local scope = {} + local destructed = {} + local data = Value(scope, {foo = 1, bar = 2}) + local _ = ForKeys(scope, data, function(_, innerScope, key) + table.insert(innerScope, function() + destructed[key] = true end) - end - - waitForGC() - state:set({ - ["bar"] = "baz", - }) + return key + end) + expect(destructed.foo).to.equal(nil) + expect(destructed.bar).to.equal(nil) + doCleanup(scope) + expect(destructed.foo).to.equal(true) + expect(destructed.bar).to.equal(true) + end) - expect(counter).to.equal(2) + it("doesn't recompute when values change", function() + local scope = {} + local data = Value(scope, {foo = 1, bar = 2}) + local computations = 0 + local _ = ForKeys(scope, data, function(_, _, key) + computations += 1 + return string.upper(key) + end) + expect(computations).to.equal(2) + data:set({foo = 3, bar = 4}) + expect(computations).to.equal(2) + data:set({foo = 3, bar = 4, baz = 5}) + expect(computations).to.equal(3) + data:set({foo = 4, bar = 5, baz = 6}) + expect(computations).to.equal(3) + doCleanup(scope) end) end diff --git a/test/State/ForPairs.spec.lua b/test/State/ForPairs.spec.lua index b4fc5886a..68e38c29f 100644 --- a/test/State/ForPairs.spec.lua +++ b/test/State/ForPairs.spec.lua @@ -4,268 +4,216 @@ local Package = game:GetService("ReplicatedStorage").Fusion local ForPairs = require(Package.State.ForPairs) local Value = require(Package.State.Value) local peek = require(Package.State.peek) - -local waitForGC = require(script.Parent.Parent.Utility.waitForGC) +local doCleanup = require(Package.Memory.doCleanup) return function() - it("should construct a ForPairs object", function() - local forPairs = ForPairs({}, function(use) end) - - expect(forPairs).to.be.a("table") - expect(forPairs.type).to.equal("State") - expect(forPairs.kind).to.equal("ForPairs") - end) - - it("should calculate and retrieve its value", function() - local computedPair = ForPairs({ ["foo"] = "bar" }, function(use, key, value) - return key .. "baz", value .. "biz" + it("constructs in scopes", function() + local scope = {} + local forObject = ForPairs(scope, {}, function() + -- intentionally blank end) - local state = peek(computedPair) + expect(forObject).to.be.a("table") + expect(forObject.type).to.equal("State") + expect(forObject.kind).to.equal("For") + expect(scope[1]).to.equal(forObject) - expect(state["foobaz"]).to.be.ok() - expect(state["foobaz"]).to.equal("barbiz") + doCleanup(scope) end) - it("should not recalculate its KO/VO in response to an unchanged KI/VI", function() - local state = Value({ - ["foo"] = "bar", - }) - - local computedPair = ForPairs(state, function(use, key, value) - return key .. "biz", { value } + it("is destroyable", function() + local scope = {} + local forObject = ForPairs(scope, {}, function() + -- intentionally blank end) - - local foobiz = peek(computedPair)["foobiz"] - - state:set({ - ["foo"] = "bar", - ["baz"] = "bar", - }) - - expect(peek(computedPair)["foobiz"]).to.equal(foobiz) + expect(function() + forObject:destroy() + end).to.never.throw() end) - it("should call the destructor when a key/value pair gets changed", function() - local state = Value({ - ["foo"] = "bar", - ["baz"] = "bar", - }) - - local destructions = 0 - - local computedPair = ForPairs(state, function(use, key, value) - return key .. "biz", value .. "biz" - end, function(key, value) - destructions += 1 + it("iterates on constants", function() + local scope = {} + local data = {foo = "oof", bar = "rab"} + local forObject = ForPairs(scope, data, function(_, _, key, value) + return value, key end) - - state:set({ - ["foo"] = "bar", - }) - - expect(destructions).to.equal(1) - - state:set({ - ["baz"] = "bar", - }) - - expect(destructions).to.equal(2) - - state:set({ - ["foo"] = "bar", - ["baz"] = "bar", - }) - - expect(destructions).to.equal(2) - - state:set({}) - - expect(destructions).to.equal(4) + expect(peek(forObject)).to.be.a("table") + expect(peek(forObject).oof).to.equal("foo") + expect(peek(forObject).rab).to.equal("bar") + doCleanup(scope) end) - it( - "should not call the destructor when an output key/value pair still remains with a new input key/value pair", - function() - local state = Value({ - ["foo"] = "bar", - }) - - local destructions = 0 - - local computedPair = ForPairs(state, function(use, key, value) - return value, value - end, function(key, value) - destructions += 1 - end) - - state:set({ - ["baz"] = "bar", - }) - - expect(destructions).to.equal(0) - - state:set({ - ["biz"] = "baz", - }) - - expect(destructions).to.equal(1) - - state:set({ - ["foo"] = "bar", - ["biz"] = "baz", - }) - - expect(destructions).to.equal(1) - - state:set({ - ["foo"] = "baz", - ["biz"] = "bar", - }) - - expect(destructions).to.equal(1) - - state:set({ - ["biz"] = "bar", - }) - - expect(destructions).to.equal(2) - - state:set({}) - - expect(destructions).to.equal(3) - end - ) - - it("should throw if there is a key collision", function() - expect(function() - local state = Value({ - ["foo"] = "bar", - ["baz"] = "bar", - }) - - local computed = ForPairs(state, function(use, key, value) - return value, key - end) - end).to.throw("forPairsKeyCollision") - - local state = Value({ - ["foo"] = "bar", - }) - - local computed = ForPairs(state, function(use, key, value) + it("iterates on state objects", function() + local scope = {} + local data = Value(scope, {foo = "oof", bar = "rab"}) + local forObject = ForPairs(scope, data, function(_, _, key, value) return value, key end) - - expect(function() - state:set({ - ["foo"] = "bar", - ["baz"] = "bar", - }) - end).to.throw("forPairsKeyCollision") + expect(peek(forObject)).to.be.a("table") + expect(peek(forObject).oof).to.equal("foo") + expect(peek(forObject).rab).to.equal("bar") + data:set({baz = "zab", garb = "brag"}) + expect(peek(forObject).oof).to.equal(nil) + expect(peek(forObject).rab).to.equal(nil) + expect(peek(forObject).zab).to.equal("baz") + expect(peek(forObject).brag).to.equal("garb") + doCleanup(scope) end) - it("should call the destructor with meta data", function() - local state = Value({ - ["foo"] = "bar", - }) - - local destructions = 0 - - local computedPair = ForPairs(state, function(use, key, value) - local newKey = key .. "biz" - local newValue = value .. "biz" - - return newKey, newValue, newKey .. newValue - end, function(key, value, meta) - expect(meta).to.equal(key .. value) - destructions += 1 + it("computes with constants", function() + local scope = {} + local data = {foo = "oof", bar = "rab"} + local forObject = ForPairs(scope, data, function(use, _, key, value) + return value .. use("baz"), key .. use("baz") end) - - state:set({ - ["foo"] = "baz", - }) - - -- this verifies that the meta expectation passed - expect(destructions).to.equal(1) - - state:set({}) - - -- this verifies that the meta expectation passed - expect(destructions).to.equal(2) + expect(peek(forObject).oofbaz).to.equal("foobaz") + expect(peek(forObject).rabbaz).to.equal("barbaz") + doCleanup(scope) end) - it("should recalculate its value in response to State objects", function() - local currentNumber = Value({ ["foo"] = 2 }) - local doubled = ForPairs(currentNumber, function(use, key, value) - return key .. "bar", value * 2 + it("computes with state objects", function() + local scope = {} + local data = {foo = "oof", bar = "rab"} + local suffix = Value(scope, "first") + local forObject = ForPairs(scope, data, function(use, _, key, value) + return value .. use(suffix), key .. use(suffix) end) - - expect(peek(doubled)["foobar"]).to.equal(4) - - currentNumber:set({ ["foo"] = 4 }) - expect(peek(doubled)["foobar"]).to.equal(8) + expect(peek(forObject).ooffirst).to.equal("foofirst") + expect(peek(forObject).rabfirst).to.equal("barfirst") + suffix:set("second") + expect(peek(forObject).ooffirst).to.equal(nil) + expect(peek(forObject).rabfirst).to.equal(nil) + expect(peek(forObject).oofsecond).to.equal("foosecond") + expect(peek(forObject).rabsecond).to.equal("barsecond") + doCleanup(scope) end) - it("should recalculate its value in response to ForPairs objects", function() - local currentNumbers = Value({ 1, 2 }) - local doubled = ForPairs(currentNumbers, function(use, key, value) - return key * 2, value * 2 - end) - local tripled = ForPairs(doubled, function(use, key, value) - return key * 2, value * 2 + it("destroys and omits pair that error during processing", function() + local scope = {} + local data = {foo = "oof", bar = "rab", baz = "zab"} + local suffix = Value(scope, "first") + local destroyed = {} + local forObject = ForPairs(scope, data, function(use, innerScope, key, value) + local generatedKey = value .. use(suffix) + local generatedValue = key .. use(suffix) + table.insert(innerScope, function() + destroyed[generatedKey] = true + end) + if key == "bar" and use(suffix) == "second" then + error("This is an intentional error from a unit test") + end + return generatedKey, generatedValue end) - - expect(peek(tripled)[4]).to.equal(4) - expect(peek(tripled)[8]).to.equal(8) - - currentNumbers:set({ 2, 4 }) - expect(peek(tripled)[4]).to.equal(8) - expect(peek(tripled)[8]).to.equal(16) + expect(peek(forObject).ooffirst).to.equal("foofirst") + expect(peek(forObject).rabfirst).to.equal("barfirst") + expect(peek(forObject).zabfirst).to.equal("bazfirst") + suffix:set("second") + expect(peek(forObject).ooffirst).to.never.equal("foofirst") + expect(peek(forObject).rabfirst).to.never.equal("barfirst") + expect(peek(forObject).zabfirst).to.never.equal("bazfirst") + expect(peek(forObject).oofsecond).to.equal("foosecond") + expect(peek(forObject).rabsecond).to.never.equal("barsecond") + expect(peek(forObject).zabsecond).to.equal("bazsecond") + expect(destroyed.ooffirst).to.equal(true) + expect(destroyed.rabfirst).to.equal(true) + expect(destroyed.zabfirst).to.equal(true) + expect(destroyed.oofsecond).to.equal(nil) + expect(destroyed.rabsecond).to.equal(true) + expect(destroyed.zabsecond).to.equal(nil) + suffix:set("third") + expect(peek(forObject).ooffirst).to.never.equal("foofirst") + expect(peek(forObject).rabfirst).to.never.equal("barfirst") + expect(peek(forObject).zabfirst).to.never.equal("bazfirst") + expect(peek(forObject).oofsecond).to.never.equal("foosecond") + expect(peek(forObject).rabsecond).to.never.equal("barsecond") + expect(peek(forObject).zabsecond).to.never.equal("bazsecond") + expect(peek(forObject).oofthird).to.equal("foothird") + expect(peek(forObject).rabthird).to.equal("barthird") + expect(peek(forObject).zabthird).to.equal("bazthird") + doCleanup(scope) end) - itSKIP("should not corrupt dependencies after an error", function() - -- needs rewrite + it("omits values that return nil", function() + local scope = {} + local data = {foo = "oof", bar = "rab", baz = "zab"} + local omitThird = Value(scope, false) + local forObject = ForPairs(scope, data, function(use, _, key, value) + if key == "bar" then + return nil + end + if use(omitThird) then + if key == "baz" then + return value, nil + end + end + return value, key + end) + expect(peek(forObject).oof).to.equal("foo") + expect(peek(forObject).rab).to.never.equal("bar") + expect(peek(forObject).zab).to.equal("baz") + omitThird:set(true) + expect(peek(forObject).oof).to.equal("foo") + expect(peek(forObject).rab).to.never.equal("bar") + expect(peek(forObject).zab).to.never.equal("baz") + omitThird:set(false) + expect(peek(forObject).oof).to.equal("foo") + expect(peek(forObject).rab).to.never.equal("bar") + expect(peek(forObject).zab).to.equal("baz") + doCleanup(scope) end) - it("should garbage-collect unused objects", function() - local state = Value({ 2 }) - - local counter = 0 - - do - local computedPairs = ForPairs(state, function(use, key, value) - counter += 1 - return key, value + it("doesn't destroy inner scope on creation", function() + local scope = {} + local destructed = {} + local data = Value(scope, {foo = "oof", bar = "rab", baz = "zab"}) + local _ = ForPairs(scope, data, function(_, innerScope, key, value) + table.insert(innerScope, function() + destructed[key] = true end) - end - - waitForGC() - state:set({ 5 }) - - expect(counter).to.equal(1) + return value, key + end) + expect(destructed.foo).to.equal(nil) + expect(destructed.bar).to.equal(nil) + data:set({foo = "oof", bar = "rab", baz = "zab"}) + expect(destructed.foo).to.equal(nil) + expect(destructed.bar).to.equal(nil) + expect(destructed.baz).to.equal(nil) + doCleanup(scope) end) - it("should not garbage-collect objects in use", function() - local state = Value({ 2 }) - local computed2 - - local counter = 0 - - do - local computed = ForPairs(state, function(use, key, value) - counter += 1 - return key, value + it("destroys inner scope on update", function() + local scope = {} + local destructed = {} + local data = Value(scope, {foo = "oof", bar = "rab"}) + local _ = ForPairs(scope, data, function(_, innerScope, key, value) + table.insert(innerScope, function() + destructed[key] = true end) + return value, key + end) + expect(destructed.foo).to.equal(nil) + expect(destructed.bar).to.equal(nil) + data:set({baz = "zab"}) + expect(destructed.foo).to.equal(true) + expect(destructed.bar).to.equal(true) + expect(destructed.baz).to.equal(nil) + doCleanup(scope) + end) - computed2 = ForPairs(computed, function(use, key, value) - return key, value + it("destroys inner scope on destroy", function() + local scope = {} + local destructed = {} + local data = Value(scope, {foo = "oof", bar = "rab"}) + local _ = ForPairs(scope, data, function(_, innerScope, key, value) + table.insert(innerScope, function() + destructed[key] = true end) - end - - waitForGC() - state:set({ 5 }) - - expect(counter).to.equal(2) + return value, key + end) + expect(destructed.foo).to.equal(nil) + expect(destructed.bar).to.equal(nil) + doCleanup(scope) + expect(destructed.foo).to.equal(true) + expect(destructed.bar).to.equal(true) end) end diff --git a/test/State/ForValues.spec.lua b/test/State/ForValues.spec.lua index 27ce26638..f7ffd43f5 100644 --- a/test/State/ForValues.spec.lua +++ b/test/State/ForValues.spec.lua @@ -4,355 +4,249 @@ local Package = game:GetService("ReplicatedStorage").Fusion local ForValues = require(Package.State.ForValues) local Value = require(Package.State.Value) local peek = require(Package.State.peek) - -local waitForGC = require(script.Parent.Parent.Utility.waitForGC) +local doCleanup = require(Package.Memory.doCleanup) return function() - it("should construct a ForValues object", function() - local forKeys = ForValues({}, function(use) end) - - expect(forKeys).to.be.a("table") - expect(forKeys.type).to.equal("State") - expect(forKeys.kind).to.equal("ForValues") - end) - - it("should calculate and retrieve its value", function() - local computed = ForValues({ 1 }, function(use, value) - return value + it("constructs in scopes", function() + local scope = {} + local forObject = ForValues(scope, {}, function() + -- intentionally blank end) - local state = peek(computed) + expect(forObject).to.be.a("table") + expect(forObject.type).to.equal("State") + expect(forObject.kind).to.equal("For") + expect(scope[1]).to.equal(forObject) - expect(state[1]).to.be.ok() - expect(state[1]).to.equal(1) + doCleanup(scope) end) - it("should not recalculate its VO in response to a changed VI", function() - local state = Value({ - [1] = "foo", - }) - - local calculations = 0 - - local computed = ForValues(state, function(use, value) - calculations += 1 - return value + it("is destroyable", function() + local scope = {} + local forObject = ForValues(scope, {}, function() + -- intentionally blank end) - - expect(calculations).to.equal(1) - - state:set({ - [1] = "bar", - [2] = "foo", - }) - - expect(calculations).to.equal(2) + expect(function() + forObject:destroy() + end).to.never.throw() end) - it("should only call the processor the first time a constant output value is added", function() - local state = Value({ - [1] = "foo", - }) - - local processorCalls = 0 - - local computed = ForValues(state, function(use, value) - processorCalls += 1 - - return value .. "biz" + it("iterates on constants", function() + local scope = {} + local data = {"foo", "bar"} + local forObject = ForValues(scope, data, function(_, _, value) + return value:upper() end) - - expect(processorCalls).to.equal(1) - - state:set({ - [1] = "bar", - }) - - expect(processorCalls).to.equal(2) - - state:set({ - [2] = "bar", - [3] = "bar", - }) - - expect(processorCalls).to.equal(2) - - state:set({ - [1] = "bar", - [2] = "bar", - }) - - expect(processorCalls).to.equal(2) - - state:set({}) - - expect(processorCalls).to.equal(2) - - state:set({ - [1] = "bar", - [2] = "foo", - }) - - expect(processorCalls).to.equal(4) + expect(peek(forObject)).to.be.a("table") + expect(table.find(peek(forObject), "FOO")).to.be.ok() + expect(table.find(peek(forObject), "BAR")).to.be.ok() + doCleanup(scope) end) - it("should only call the destructor when a constant value gets removed from all indices", function() - local state = Value({ - [1] = "foo", - }) - - local destructions = 0 - - local computed = ForValues(state, function(use, value) - return value .. "biz" - end, function(key) - destructions += 1 + it("iterates on state objects", function() + local scope = {} + local data = Value(scope, {"foo", "bar"}) + local forObject = ForValues(scope, data, function(_, _, value) + return value:upper() end) - - state:set({ - [1] = "bar", - }) - - expect(destructions).to.equal(1) - - state:set({ - [2] = "bar", - [3] = "bar", - }) - - expect(destructions).to.equal(1) - - state:set({ - [1] = "bar", - [2] = "bar", - }) - - expect(destructions).to.equal(1) - - state:set({}) - - expect(destructions).to.equal(2) + expect(peek(forObject)).to.be.a("table") + expect(table.find(peek(forObject), "FOO")).to.be.ok() + expect(table.find(peek(forObject), "BAR")).to.be.ok() + data:set({"baz", "garb"}) + expect(table.find(peek(forObject), "FOO")).to.never.be.ok() + expect(table.find(peek(forObject), "BAR")).to.never.be.ok() + expect(table.find(peek(forObject), "BAZ")).to.be.ok() + expect(table.find(peek(forObject), "GARB")).to.be.ok() + doCleanup(scope) end) - it("should only call the destructor when a non-constant value gets removed from all indices", function() - local mem1 = Instance.new("Folder") - local mem2 = Instance.new("Folder") - local mem3 = Instance.new("Folder") - - local state = Value({ - [1] = mem1, - }) - - local destructions = 0 - - local computed = ForValues(state, function(use, value) - local obj = Instance.new("Folder") - obj.Parent = value - - return obj - end, function(value) - destructions += 1 - value:Destroy() + it("computes with constants", function() + local scope = {} + local data = {"foo", "bar"} + local forObject = ForValues(scope, data, function(use, _, value) + return value .. use("baz") end) - - state:set({ - [1] = mem2, - }) - - expect(destructions).to.equal(1) - - state:set({ - [2] = mem3, - [3] = mem2, - }) - - expect(destructions).to.equal(1) - - state:set({ - [1] = mem2, - [2] = mem3, - }) - - expect(destructions).to.equal(1) - - state:set({}) - - expect(destructions).to.equal(3) + expect(table.find(peek(forObject), "foobaz")).to.be.ok() + expect(table.find(peek(forObject), "barbaz")).to.be.ok() + doCleanup(scope) end) - it("should call the destructor with meta data", function() - local state = Value({ - [1] = "foo", - }) - - local destructions = 0 - - local computed = ForValues(state, function(use, value) - local newValue = value .. "biz" - return newValue, newValue - end, function(value, meta) - expect(meta).to.equal(value) - destructions += 1 + it("computes with state objects", function() + local scope = {} + local data = {"foo", "bar"} + local suffix = Value(scope, "first") + local forObject = ForValues(scope, data, function(use, _, value) + return value .. use(suffix) end) - - state:set({ - ["baz"] = "bar", - }) - - -- this verifies that the meta expectation passed - expect(destructions).to.equal(1) - - state:set({}) - - -- this verifies that the meta expectation passed - expect(destructions).to.equal(2) + expect(table.find(peek(forObject), "foofirst")).to.be.ok() + expect(table.find(peek(forObject), "barfirst")).to.be.ok() + suffix:set("second") + expect(table.find(peek(forObject), "foosecond")).to.be.ok() + expect(table.find(peek(forObject), "barsecond")).to.be.ok() + doCleanup(scope) end) - it("should not make any value changes or processor/destructor calls when only the input key changes", function() - local state = Value({ - ["foo"] = "bar", - ["bar"] = "baz", - ["biz"] = "buzz", - }) - - local processorCalls = 0 - local destructorCalls = 0 - - local computed = ForValues(state, function(use, value) - processorCalls += 1 - return value - end, function(value) - destructorCalls += 1 + it("destroys and omits values that error during processing", function() + local scope = {} + local data = {"foo", "bar", "baz"} + local suffix = Value(scope, "first") + local destroyed = {} + local forObject = ForValues(scope, data, function(use, innerScope, value) + local generated = value .. use(suffix) + table.insert(innerScope, function() + destroyed[generated] = true + end) + if value == "bar" and use(suffix) == "second" then + error("This is an intentional error from a unit test") + end + return generated end) - - expect(processorCalls).to.equal(3) - expect(destructorCalls).to.equal(0) - - state:set({ - [1] = "buzz", - ["bar"] = "bar", - ["fiz"] = "baz", - }) - - expect(processorCalls).to.equal(3) - expect(destructorCalls).to.equal(0) - - state:set({ - [1] = "bar", - ["bar"] = "bar", - ["fiz"] = "bar", - }) - - expect(processorCalls).to.equal(3) - expect(destructorCalls).to.equal(2) - - state:set({ - [2] = "bar", - [3] = "baz", - }) - - expect(processorCalls).to.equal(4) - expect(destructorCalls).to.equal(2) - - state:set({}) - - expect(processorCalls).to.equal(4) - expect(destructorCalls).to.equal(4) + expect(table.find(peek(forObject), "foofirst")).to.be.ok() + expect(table.find(peek(forObject), "barfirst")).to.be.ok() + expect(table.find(peek(forObject), "bazfirst")).to.be.ok() + suffix:set("second") + expect(table.find(peek(forObject), "foofirst")).to.never.be.ok() + expect(table.find(peek(forObject), "barfirst")).to.never.be.ok() + expect(table.find(peek(forObject), "bazfirst")).to.never.be.ok() + expect(table.find(peek(forObject), "foosecond")).to.be.ok() + expect(table.find(peek(forObject), "barsecond")).to.never.be.ok() + expect(table.find(peek(forObject), "bazsecond")).to.be.ok() + expect(destroyed.foofirst).to.equal(true) + expect(destroyed.barfirst).to.equal(true) + expect(destroyed.bazfirst).to.equal(true) + expect(destroyed.foosecond).to.equal(nil) + expect(destroyed.barsecond).to.equal(true) + expect(destroyed.bazsecond).to.equal(nil) + suffix:set("third") + expect(table.find(peek(forObject), "foofirst")).to.never.be.ok() + expect(table.find(peek(forObject), "barfirst")).to.never.be.ok() + expect(table.find(peek(forObject), "bazfirst")).to.never.be.ok() + expect(table.find(peek(forObject), "foosecond")).to.never.be.ok() + expect(table.find(peek(forObject), "barsecond")).to.never.be.ok() + expect(table.find(peek(forObject), "bazsecond")).to.never.be.ok() + expect(table.find(peek(forObject), "foothird")).to.be.ok() + expect(table.find(peek(forObject), "barthird")).to.be.ok() + expect(table.find(peek(forObject), "bazthird")).to.be.ok() + doCleanup(scope) end) - it("should recalculate its value in response to State objects", function() - local state = Value({ - [1] = "baz", - }) - local barMap = ForValues(state, function(use, value) - return value .. "bar" + it("omits values that return nil", function() + local scope = {} + local data = {"foo", "bar", "baz"} + local omitThird = Value(scope, false) + local forObject = ForValues(scope, data, function(use, _, value) + if value == "bar" then + return nil + end + if use(omitThird) then + if value == "baz" then + return nil + end + end + return value end) - - expect(peek(barMap)[1]).to.equal("bazbar") - - state:set({ - [1] = "bar", - }) - - expect(peek(barMap)[1]).to.equal("barbar") + expect(table.find(peek(forObject), "foo")).to.be.ok() + expect(table.find(peek(forObject), "bar")).to.never.be.ok() + expect(table.find(peek(forObject), "baz")).to.be.ok() + omitThird:set(true) + expect(table.find(peek(forObject), "foo")).to.be.ok() + expect(table.find(peek(forObject), "bar")).to.never.be.ok() + expect(table.find(peek(forObject), "baz")).to.never.be.ok() + omitThird:set(false) + expect(table.find(peek(forObject), "foo")).to.be.ok() + expect(table.find(peek(forObject), "bar")).to.never.be.ok() + expect(table.find(peek(forObject), "baz")).to.be.ok() + doCleanup(scope) end) - it("should recalculate its value in response to ForValues objects", function() - local state = Value({ - [1] = 1, - }) - local doubled = ForValues(state, function(use, value) - return value * 2 - end) - local tripled = ForValues(doubled, function(use, value) - return value * 2 + it("doesn't destroy inner scope on creation", function() + local scope = {} + local destructed = {} + local data = Value(scope, {"foo", "bar"}) + local _ = ForValues(scope, data, function(_, innerScope, value) + table.insert(innerScope, function() + destructed[value] = true + end) + return value end) - - expect(peek(doubled)[1]).to.equal(2) - expect(peek(tripled)[1]).to.equal(4) - - state:set({ - [1] = 2, - [2] = 3, - }) - - expect(peek(doubled)[1]).to.equal(4) - expect(peek(tripled)[1]).to.equal(8) - expect(peek(doubled)[2]).to.equal(6) - expect(peek(tripled)[2]).to.equal(12) - end) - - itSKIP("should not corrupt dependencies after an error", function() - -- needs rewrite + expect(destructed.foo).to.equal(nil) + expect(destructed.bar).to.equal(nil) + data:set({"foo", "bar", "baz"}) + expect(destructed.foo).to.equal(nil) + expect(destructed.bar).to.equal(nil) + expect(destructed.baz).to.equal(nil) + doCleanup(scope) end) - it("should garbage-collect unused objects", function() - local state = Value({ - [1] = "bar", - }) - - local counter = 0 - - do - local computedKeys = ForValues(state, function(use, value) - counter += 1 - return value + it("destroys inner scope on update", function() + local scope = {} + local destructed = {} + local data = Value(scope, {"foo", "bar"}) + local _ = ForValues(scope, data, function(_, innerScope, value) + table.insert(innerScope, function() + destructed[value] = true end) - end - - waitForGC() - - state:set({ - [1] = "biz", - }) - - expect(counter).to.equal(1) + return value + end) + expect(destructed.foo).to.equal(nil) + expect(destructed.bar).to.equal(nil) + data:set({"baz"}) + expect(destructed.foo).to.equal(true) + expect(destructed.bar).to.equal(true) + expect(destructed.baz).to.equal(nil) + doCleanup(scope) end) - it("should not garbage-collect objects in use", function() - local state = Value({ - [1] = 1, - }) - local computed2 - - local counter = 0 - - do - local computed = ForValues(state, function(use, value) - counter += 1 - return value + it("destroys inner scope on destroy", function() + local scope = {} + local destructed = {} + local data = Value(scope, {"foo", "bar"}) + local _ = ForValues(scope, data, function(_, innerScope, value) + table.insert(innerScope, function() + destructed[value] = true end) + return value + end) + expect(destructed.foo).to.equal(nil) + expect(destructed.bar).to.equal(nil) + doCleanup(scope) + expect(destructed.foo).to.equal(true) + expect(destructed.bar).to.equal(true) + end) - computed2 = ForValues(computed, function(use, value) - return value - end) - end - - waitForGC() - state:set({ - [1] = 2, - }) + it("doesn't recompute when values roam between keys", function() + local scope = {} + local data = Value(scope, {"foo", "bar"}) + local computations = 0 + local forObject = ForValues(scope, data, function(_, _, value) + computations += 1 + return string.upper(value) + end) + expect(computations).to.equal(2) + data:set({"bar", "foo"}) + expect(computations).to.equal(2) + data:set({"baz", "bar", "foo"}) + expect(computations).to.equal(3) + data:set({"foo", "baz", "bar"}) + expect(computations).to.equal(3) + data:set({"garb"}) + expect(computations).to.equal(4) + doCleanup(scope) + end) - expect(counter).to.equal(2) + it("does not reuse values for duplicated items", function() + local scope = {} + local data = Value(scope, {"foo", "foo", "foo"}) + local computations = 0 + local forObject = ForValues(scope, data, function(_, _, value) + computations += 1 + return string.upper(value) + end) + expect(computations).to.equal(3) + data:set({"foo", "foo", "foo", "foo"}) + expect(computations).to.equal(4) + data:set({"bar", "foo", "foo"}) + expect(computations).to.equal(5) + doCleanup(scope) end) end diff --git a/test/State/Observer.spec.lua b/test/State/Observer.spec.lua index f3d74838e..f1eaa498d 100644 --- a/test/State/Observer.spec.lua +++ b/test/State/Observer.spec.lua @@ -1,66 +1,115 @@ -local RunService = game:GetService("RunService") - local Package = game:GetService("ReplicatedStorage").Fusion local Observer = require(Package.State.Observer) local Value = require(Package.State.Value) local peek = require(Package.State.peek) +local doCleanup = require(Package.Memory.doCleanup) return function() - it("should fire connections on change", function() - local state = Value(false) - local observer = Observer(state) - - local changed = false - observer:onChange(function() - changed = true - end) + it("constructs in scopes", function() + local scope = {} + local dependency = Value(scope, 5) + local observer = Observer(scope, dependency) - state:set(true) + expect(observer).to.be.a("table") + expect(observer.type).to.equal("Observer") + expect(scope[2]).to.equal(observer) - -- Wait twice in case it gets deferred - RunService.RenderStepped:Wait() - RunService.RenderStepped:Wait() - - expect(changed).to.equal(true) + doCleanup(scope) end) - it("should fire connections only after the value changes", function() - local state = Value(false) - local observer = Observer(state) + it("is destroyable", function() + local scope = {} + local dependency = Value(scope, 5) + local observer = Observer(scope, dependency) + expect(function() + observer:destroy() + end).to.never.throw() + end) - local changedValue - local completed = false - observer:onChange(function(value) - changedValue = peek(state) - completed = true + it("fires once after change", function() + local scope = {} + local dependency = Value(scope, 5) + local observer = Observer(scope, dependency) + local numFires = 0 + local disconnect = observer:onChange(function() + numFires += 1 end) + dependency:set(15) + disconnect() - state:set(true) + expect(numFires).to.equal(1) + + doCleanup(scope) + end) + + it("fires asynchronously", function() + local scope = {} + local dependency = Value(scope, 5) + local observer = Observer(scope, dependency) + local numFires = 0 + local disconnect = observer:onChange(function() + task.wait(1) + numFires += 1 + end) + dependency:set(15) + disconnect() - -- Wait twice in case it gets deferred - RunService.RenderStepped:Wait() - RunService.RenderStepped:Wait() + expect(numFires).to.equal(0) - expect(changedValue).to.equal(true) + doCleanup(scope) end) - it("should fire connections only once after the value changes", function() - local state = Value(false) - local observer = Observer(state) + it("fires onBind at bind time", function() + local scope = {} + local dependency = Value(scope, 5) + local observer = Observer(scope, dependency) + local numFires = 0 + local disconnect = observer:onBind(function() + numFires += 1 + end) + disconnect() + + expect(numFires).to.equal(1) - local timesFired = 0 - local completed = false - observer:onChange(function(value) - timesFired += 1 - completed = true + doCleanup(scope) + end) + + it("disconnects manually", function() + local scope = {} + local dependency = Value(scope, 5) + local observer = Observer(scope, dependency) + local numFires = 0 + local disconnect = observer:onChange(function() + numFires += 1 end) + dependency:set(15) + disconnect() + dependency:set(2) - state:set(true) + expect(numFires).to.equal(1) - -- Wait twice in case it gets deferred - RunService.RenderStepped:Wait() - RunService.RenderStepped:Wait() + doCleanup(scope) + end) - expect(timesFired).to.equal(1) + it("disconnects on destroy", function() + local scope = {} + local dependency = Value(scope, 5) + + local subScope = {} + table.insert(scope, subScope) + local observer = Observer(subScope, dependency) + + local numFires = 0 + local _ = observer:onChange(function() + numFires += 1 + end) + dependency:set(15) + doCleanup(subScope) + dependency:set(2) + + expect(numFires).to.equal(1) + + doCleanup(scope) end) + end diff --git a/test/State/Value.spec.lua b/test/State/Value.spec.lua index 5fcd6a25e..872a19371 100644 --- a/test/State/Value.spec.lua +++ b/test/State/Value.spec.lua @@ -1,48 +1,47 @@ local Package = game:GetService("ReplicatedStorage").Fusion local Value = require(Package.State.Value) -local ForValues = require(Package.State.ForValues) local peek = require(Package.State.peek) - -local waitForGC = require(script.Parent.Parent.Utility.waitForGC) +local doCleanup = require(Package.Memory.doCleanup) return function() - it("should construct a Value object", function() - local value = Value() + it("constructs in scopes", function() + local scope = {} + local value = Value(scope, nil) expect(value).to.be.a("table") expect(value.type).to.equal("State") expect(value.kind).to.equal("Value") - end) - - it("should be able to store arbitrary values", function() - local value = Value(0) - expect(peek(value)).to.equal(0) - - value:set(10) - expect(peek(value)).to.equal(10) + expect(scope[1]).to.equal(value) - value:set(Value) - expect(peek(value)).to.equal(Value) + doCleanup(scope) end) - it("should garbage-collect unused objects", function() - local value = setmetatable({ { 2 } }, { __mode = "kv" }) - Value(value[1]) - - waitForGC() + it("is destroyable", function() + local value = Value({}, nil) + expect(value.destroy).to.be.a("function") + expect(function() + value:destroy() + end).to.never.throw() + end) - expect(value[1]).to.equal(nil) + it("accepts a default value", function() + local scope = {} + local value = Value(scope, 5) + expect(peek(value)).to.equal(5) + doCleanup(scope) end) - it("should not garbage-collect objects in use", function() - local value = setmetatable({ { 2 } }, { __mode = "kv" }) - local transformed = ForValues(Value(value[1]), function(innerValue) - return innerValue[1] + 1 - end) + it("is settable", function() + local scope = {} + local value = Value(scope, 0) + expect(peek(value)).to.equal(0) + + value:set(10) + expect(peek(value)).to.equal(10) - waitForGC() + value:set("foo") + expect(peek(value)).to.equal("foo") - expect(value[1]).never.to.equal(nil) - expect(peek(transformed)[1]).to.equal(3) + doCleanup(scope) end) end diff --git a/test/State/updateAll.spec.lua b/test/State/updateAll.spec.lua index 9762667e2..f01f7ba81 100644 --- a/test/State/updateAll.spec.lua +++ b/test/State/updateAll.spec.lua @@ -17,7 +17,8 @@ local function buildReactiveGraph(ancestorsToDescendants, handler) dependentSet = {}, update = handler, updates = 0, - name = named + name = named, + scope = "not nil" } objects[named] = object @@ -75,6 +76,31 @@ return function() expect(objects.D.updates).to.equal(1) end) + it("should not update destroyed objects", function() + 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 objects = buildReactiveGraph({ edge("A", "B"), diff --git a/test/Utility/restrictRead.spec.lua b/test/Utility/restrictRead.spec.lua deleted file mode 100644 index b89f1a35a..000000000 --- a/test/Utility/restrictRead.spec.lua +++ /dev/null @@ -1,32 +0,0 @@ -local Package = game:GetService("ReplicatedStorage").Fusion -local restrictRead = require(Package.Utility.restrictRead) - -return function() - it("should error for missing members", function() - local strictTable = restrictRead("", {}) - - expect(function() - local x = strictTable.thisDoesNotExist - end).to.throw("strictReadError") - end) - - it("should not error for present members", function() - local strictTable = restrictRead("", { - foo = 2, - bar = "blue" - }) - - expect(function() - local x = strictTable.foo - local y = strictTable.bar - end).never.to.throw() - end) - - it("should preserve metatables", function() - local metatable = {} - local strictTable = setmetatable({}, metatable) - restrictRead("", strictTable) - - expect(getmetatable(strictTable)).to.equal(metatable) - end) -end \ No newline at end of file diff --git a/test/Utility/waitForGC.lua b/test/Utility/waitForGC.lua deleted file mode 100644 index 25d0d29e6..000000000 --- a/test/Utility/waitForGC.lua +++ /dev/null @@ -1,17 +0,0 @@ -return function() - local ref = setmetatable({ {} }, { __mode = "kv" }) - - local lastLen = 1 - repeat - table.insert(ref, {}) - lastLen = #ref - print("waitForGC", lastLen) - - if #ref > 60 then - error("Timed out waiting for garbage collection cycle") - end - - task.wait(1) - until #ref < lastLen - print("waitForGC Done") -end diff --git a/test/init.spec.lua b/test/init.spec.lua index beb029841..dbd9485e4 100644 --- a/test/init.spec.lua +++ b/test/init.spec.lua @@ -7,33 +7,36 @@ return function() local api = { version = "table", + Contextual = "function", - New = "function", - Hydrate = "function", - Ref = "table", - Out = "function", - Cleanup = "table", - Children = "table", - OnEvent = "function", - OnChange = "function", - Attribute = "function", - AttributeChange = "function", - AttributeOut = "function", + cleanup = "function", + doCleanup = "function", + doNothing = "function", + scoped = "function", + deriveScope = "function", + peek = "function", Value = "function", Computed = "function", ForPairs = "function", ForKeys = "function", ForValues = "function", Observer = "function", - + Tween = "function", Spring = "function", - Contextual = "function", - cleanup = "function", - doNothing = "function", - peek = "function" + New = "function", + Hydrate = "function", + + Ref = "table", + Out = "function", + Children = "table", + OnEvent = "function", + OnChange = "function", + Attribute = "function", + AttributeChange = "function", + AttributeOut = "function" } for apiName, apiType in pairs(api) do @@ -55,9 +58,9 @@ return function() end end) - it("should error when accessing non-existent APIs", function() + it("should not error when accessing non-existent APIs", function() expect(function() - local foo = Fusion.thisIsNotARealAPI - end).to.throw("strictReadError") + local foo = Fusion["thisIsNotARealAPI" :: any] + end).never.to.throw() end) end \ No newline at end of file