diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index d1ae594..519d4d6 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -26,6 +26,7 @@ export default defineConfig({
items: [
{ text: "Reactivity: Core", link: "/api/reactivity-core" },
{ text: "Reactivity: Utility", link: "/api/reactivity-utility" },
+ { text: "Reactivity: Control Flow", link: "/api/reactivity-flow" },
{ text: "Element Creation", link: "/api/creation" },
{ text: "Animation", link: "/api/animation" },
{ text: "Strict Mode", link: "/api/strict-mode" },
@@ -41,16 +42,26 @@ export default defineConfig({
{ text: "Element Creation", link: "/tut/crash-course/2-creation" },
{ text: "Components", link: "/tut/crash-course/3-components" },
{ text: "Source", link: "/tut/crash-course/4-source" },
- { text: "Derived Source", link: "/tut/crash-course/5-derived-source" },
- { text: "Table Source", link: "/tut/crash-course/6-table-source" },
- { text: "Nested Properties", link: "/tut/crash-course/7-nested-properties" },
- { text: "Actions", link: "/tut/crash-course/8-actions" },
+ { text: "Effect", link: "/tut/crash-course/5-effect" },
+ { text: "Derived Source", link: "/tut/crash-course/6-derived-source" },
+ { text: "Cleanup", link: "/tut/crash-course/7-cleanup" },
+ { text: "Control Flow", link: "/tut/crash-course/8-control-flow" },
+ { text: "Property Nesting", link: "/tut/crash-course/9-property-nesting" },
+ { text: "Actions", link: "/tut/crash-course/10-actions" },
]
},
{
- text: "Tutorials",
+ text: "Control Flow WIP",
items: [
- { text: "Crash Course", link: "/tut/crash-course/index" },
+ { text: "switch", link: "/tut/control-flow/switch.md" },
+ { text: "indexes", link: "/tut/control-flow/indexes.md" },
+ { text: "values", link: "/tut/control-flow/values.md" },
+ ]
+ },
+ {
+ text: "Advanced Reactivity WIP",
+ items: [
+ { text: "reactive-scopes", link: "/tut/reactive-scoping.md"}
]
}
],
diff --git a/docs/api/creation.md b/docs/api/creation.md
index 8726919..f5b4c1c 100644
--- a/docs/api/creation.md
+++ b/docs/api/creation.md
@@ -2,6 +2,38 @@
+## mount()
+
+Runs a function and applies its result to a target instance.
+
+- **Type**
+
+ ```lua
+ function mount(component: () -> T, target: Instance?): () -> ()
+ ```
+
+- **Details**
+
+ The result of the function is applies to the target in the same way
+ properties are using `create()`.
+
+ The function is ran in a new reactive scope, just like
+ [root()](reactivity-core.md#root).
+
+ Returns a function that when called will destroy the reactive scope.
+
+- **Example**
+
+ ```lua
+ local function App()
+ return create "ScreenGui" {
+ create "TextLabel" { Text = "Vide" }
+ }
+ end
+
+ mount(App, game.StarterGui)
+ ```
+
## create()
Creates a new UI element, applying any given properties.
@@ -28,19 +60,18 @@ Creates a new UI element, applying any given properties.
- **Property setting rules**
-
- If a table index is a string:
- - If its value is a table then it will attempt to perform aggregate
- initialization.
- If its value is a function then it will either bind that property to
the function or connect it if the property type is a `RBXScriptSignal`.
- If the value is not a function then the property will be set to that
value.
- If a table index is a number:
- - If its value is a table then that table will be recursively
- - processed just like the outer table.
- - If its value is a function then it will parent and bind any instances
- returned by that function as children.
+ - If its value is an action then that action will be queued to run after
+ properties are set.
+ - If its value is a table then that table will be recursively
+ processed just like the outer table.
+ - If its value is a function then it will bind the instances children to
+ that function.
- If its value is an instance then it will be parented to the instance.
- **Example**
@@ -54,7 +85,7 @@ Creates a new UI element, applying any given properties.
}
```
- A component using property nesting/grouping.
+ A component using property nesting.
```lua
type Layout = {
diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md
index 9585184..0e056f9 100644
--- a/docs/api/reactivity-core.md
+++ b/docs/api/reactivity-core.md
@@ -22,6 +22,10 @@ Creates and runs a function in a new reactive scope.
Also returns a function to destroy the root, which will run any cleanups
and allow derived sources created to garbage collect.
+ ::: warning
+ `fn()` cannot yield.
+ :::
+
## source()
Creates a new source with the given value.
@@ -52,42 +56,37 @@ Creates a new source with the given value.
## effect()
-Runs a callback on source update.
+Runs a side-effect on source update.
- **Type**
```lua
- function effect(source: () -> ()): Uneffect
-
- type Uneffect = () -> ()
+ function effect(callback: () -> ())
```
- **Details**
- The source callback is ran immediately to determine what states are
- referenced.
+ The callback is ran immediately.
Any time a source referenced in the callback is changed, the callback will
be reran.
- Also returns a function that when called, stops the effecter immediately.
-
::: warning
- `source()` cannot yield.
+ `callback()` cannot yield.
:::
- **Example**
```lua
- local state = source(1)
+ local num = source(1)
effect(function()
- print(state())
+ print(num())
end)
-- prints 1
- state(state() + 1)
+ num(num() + 1)
-- prints 2
```
@@ -130,150 +129,4 @@ Derives a new source from existing sources.
text() -- "count: 1"
```
-## indexes()
-
-Maps each index in a table source to an object.
-
-- **Type**
-
- ```lua
- function indexes(
- source: () -> Map,
- transform: (value: () -> VI, index: KI) -> VO
- ): Array
-
-- **Details**
-
- The transform function is called only ever *once* for each index in the
- source table. The first argument is a source containing the index's value
- and the second argument is just the index.
-
- Anytime a new index is added, the transform function will be called again
- for that new index.
-
- Anytime an existing index value changes, the transform function is not rerun,
- instead the source value for that index will update, causing anything
- depending on it to update too.
-
- Returns a state containing an array of all objects returned by the
- transform.
-
- ::: warning
- `transform()` cannot yield.
- :::
-
-- **Example**
-
- The intended purpose of this function is to map each index in a table to
- a UI element.
-
- ```lua
- type Item = {
- name: string,
- icon: number
- }
-
- local items = source {} :: () -> Array-
-
- local displays = indexes(items, function(item, i)
- return ItemDisplay {
- Name = function()
- return item().name
- end,
-
- Image = function()
- return "rbxassetid://" .. item().icon
- end,
-
- LayoutOrder = i
- }
- end)
- ```
-
-## values()
-
-Maps each value in a table source to an object.
-
-- **Type**
-
- ```lua
- function values(
- source: () -> Map,
- transform: (value: VI, index: () -> KI) -> VO
- ): Array
-
-- **Details**
-
- The transform function is called only ever *once* for each value in the
- source table. The first argument is the index's value and
- the second argument is a source containing the index.
-
- Anytime a new value is added, the transform function will be called again
- for that new value.
-
- Anytime an existing value's index changes, the transform function is not
- rerun, instead the source index for that value will update, causing anything
- depending on it to update too.
-
- Returns a state containing an array of all objects returned by the
- transform.
-
- ::: warning
- `transform()` cannot yield.
- :::
-
- ::: warning
- Having primitive values in the source table can cause unexpected behavior,
- as duplicate primitives can result in multiple index sources being bound
- to the same UI element.
- :::
-
-- **Example**
-
- The intended purpose of this function is to map each value in a table to
- a UI element.
-
- ```lua
- type Item = {
- name: string,
- icon: number
- }
-
- local items = source {} :: () -> Array
-
-
- local displays = values(items, function(item, i)
- return ItemDisplay {
- Name = item.Name
-
- Image = "rbxassetid://" .. item.icon,
-
- LayoutOrder = i
- }
- end)
- ```
-
-- **Extra**
-
- When should you use `indexes()` and `values()`?
-
- `values()` should be used when you have a fixed set of objects where the
- same objects can be re-arranged in the source table. It maps a value to a
- UI element.
-
- e.g.
- - List of all players.
- - Inventory of items.
- - Chat message history.
- - Toast notifications.
-
- `indexes()` should be used in other cases, especially when your source table
- has primitive value. It maps an index to a UI element.
-
- e.g.
- - List of character or weapon stats.
-
- In most cases, both functions will appear to have the same behavior.
- The main difference is performance, picking the right function to use can
- result in less property updates and less re-renders.
-
--------------------------------------------------------------------------------
diff --git a/docs/api/reactivity-flow.md b/docs/api/reactivity-flow.md
index e69de29..58e2200 100644
--- a/docs/api/reactivity-flow.md
+++ b/docs/api/reactivity-flow.md
@@ -0,0 +1,186 @@
+# Reactivity API: Control Flow
+
+
+
+## switch()
+
+Changes object based on a source and a mapping table.
+
+- **Type**
+
+ ```lua
+ function switch(source: () -> K): (map: Map V>) -> V?
+ ```
+
+- **Details**
+
+ The mapped function is ran in a new reactive scope that is destroyed when
+ the source changes and maps to a different function.
+
+ ::: warning
+ Mapped functions cannot yield.
+ :::
+
+- **Example**
+
+ ```lua
+ local logged = source(false)
+
+ local button = switch(logged) {
+ [true] = function()
+ return Button { Text = "Log out", Toggle = logged }
+ end,
+
+ [false] = function()
+ return Button { Text = "Log in", Toggle = logged }
+ end
+ }
+ ```
+
+## indexes()
+
+Maps each index in a table source to an object.
+
+- **Type**
+
+ ```lua
+ function indexes(
+ source: () -> Map,
+ transform: (value: () -> VI, index: KI) -> VO
+ ): Array
+
+- **Details**
+
+ The transform function is called only ever *once* for each index in the
+ source table. The first argument is a source containing the index's value
+ and the second argument is just the index.
+
+ Anytime a new index is added, the transform function will be called again
+ for that new index.
+
+ Anytime an existing index value changes, the transform function is not rerun,
+ instead the source value for that index will update, causing anything
+ depending on it to update too.
+
+ Returns a state containing an array of all objects returned by the
+ transform.
+
+ ::: warning
+ `transform()` cannot yield.
+ :::
+
+- **Example**
+
+ The intended purpose of this function is to map each index in a table to
+ a UI element.
+
+ ```lua
+ type Item = {
+ name: string,
+ icon: number
+ }
+
+ local items = source {} :: () -> Array-
+
+ local displays = indexes(items, function(item, i)
+ return ItemDisplay {
+ Name = function()
+ return item().name
+ end,
+
+ Image = function()
+ return "rbxassetid://" .. item().icon
+ end,
+
+ LayoutOrder = i
+ }
+ end)
+ ```
+
+## values()
+
+Maps each value in a table source to an object.
+
+- **Type**
+
+ ```lua
+ function values(
+ source: () -> Map,
+ transform: (value: VI, index: () -> KI) -> VO
+ ): Array
+
+- **Details**
+
+ The transform function is called only ever *once* for each value in the
+ source table. The first argument is the index's value and
+ the second argument is a source containing the index.
+
+ Anytime a new value is added, the transform function will be called again
+ for that new value.
+
+ Anytime an existing value's index changes, the transform function is not
+ rerun, instead the source index for that value will update, causing anything
+ depending on it to update too.
+
+ Returns a state containing an array of all objects returned by the
+ transform.
+
+ ::: warning
+ `transform()` cannot yield.
+ :::
+
+ ::: warning
+ Having primitive values in the source table can cause unexpected behavior,
+ as duplicate primitives can result in multiple index sources being bound
+ to the same UI element.
+ :::
+
+- **Example**
+
+ The intended purpose of this function is to map each value in a table to
+ a UI element.
+
+ ```lua
+ type Item = {
+ name: string,
+ icon: number
+ }
+
+ local items = source {} :: () -> Array
-
+
+ local displays = values(items, function(item, i)
+ return ItemDisplay {
+ Name = item.Name
+
+ Image = "rbxassetid://" .. item.icon,
+
+ LayoutOrder = i
+ }
+ end)
+ ```
+
+- **Extra**
+
+ When should you use `indexes()` and `values()`?
+
+ `values()` should be used when you have a fixed set of objects where the
+ same objects can be re-arranged in the source table. It maps a value to a
+ UI element.
+
+ e.g.
+ - List of all players.
+ - Inventory of items.
+ - Chat message history.
+ - Toast notifications.
+
+ `indexes()` should be used in other cases, especially when your source table
+ has primitive value. It maps an index to a UI element.
+
+ e.g.
+ - List of character or weapon stats.
+
+ In most cases, both functions will appear to have the same behavior.
+ The main difference is performance, picking the right function to use can
+ result in less property updates and less re-renders.
+
+--------------------------------------------------------------------------------
diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md
index 35559ee..22adba5 100644
--- a/docs/api/reactivity-utility.md
+++ b/docs/api/reactivity-utility.md
@@ -69,3 +69,5 @@ Runs a given function where any sources read will not track its reactive scope.
a(1)
print(sum()) -- 2
```
+
+--------------------------------------------------------------------------------
diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md
index 54c30fc..0472954 100644
--- a/docs/api/strict-mode.md
+++ b/docs/api/strict-mode.md
@@ -12,14 +12,14 @@ and identifying improper usage.
Currently, strict mode will:
1. Run derived sources twice a source updates.
-2. Run effecters twice when a source updates.
+2. Run effects twice when a source updates.
3. Throw an error if yields occur where they are not allowed.
4. Checks for `indexes()` and `values()` returning primitive values.
5. Checks for duplicate nested properties at same depth.
6. Better error reporting and stack traces.
7. Checks for multiple `cleanup()` calls in the same function scope.
-By rerunning sources and effecters, any side-effects are made more apparent.
+By rerunning sources and effects, any side-effects are made more apparent.
This also helps ensure that cleanups are being handled correctly.
Accidental yielding within reactive scopes can break Vide's reactive graph,
diff --git a/docs/tut/control-flow/indexes.md b/docs/tut/control-flow/indexes.md
new file mode 100644
index 0000000..70864e7
--- /dev/null
+++ b/docs/tut/control-flow/indexes.md
@@ -0,0 +1,66 @@
+# Control Flow
+
+Vide has specific functions for dealing with sources that store a table value.
+
+Often, you will have a table of values that will be displayed in a similar
+manner. Rather than manually looping over each value to generate a corresponding
+UI element, Vide provides functions `indexes()` and `values()` to do this for
+you.
+
+`indexes()` maps each *index* in a table to a UI element.
+
+```lua
+local names = source { "a", "b", "c" }
+
+local elements = indexes(names, function(name, i)
+ return create "TextLabel" {
+ Text = function()
+ return "Name: " .. name()
+ end,
+
+ LayoutOrder = i
+ }
+end)
+```
+
+What happens here is the given callback is only ever ran *once* for each index
+in the table. The callback receives two arguments, a *source* containing the
+index's value and then the index itself.
+
+Anytime the value at a corresponding index changes, the source for that index
+value is updated, causing the UI element depending on it to update too.
+
+`values()` behaves similarly, except it maps each *value* in a table to a UI
+element.
+
+```lua
+type Item = {
+ Name: string,
+ Icon: number
+}
+
+local items = source({} :: Array
- )
+
+local elements = values(items, function(item, i)
+ return create "ImageLabel" {
+ Image = "rbxassetid://" .. item.Icon,
+ LayoutOrder = i
+ }
+end)
+```
+
+The callback is again only ever ran *once* for each value in the table. The
+callback receives two arguments, a value in the table and then a *source*
+containing the value's corresponding index.
+
+Any time a value in a table changes index, the source for that value is updated,
+causing the UI element position to change.
+
+In certain cases `values()` can cause less recalculation and rerenders than
+`indexes()` like when items are re-arranged and shifted within a table.
+
+It is important that each value in a table is unique when using `values()`,
+and for this reason always using `indexes()` if a table contains primitive
+values.
+
+Both `indexes()` and `values()` return an array of all mapped UI elements.
diff --git a/docs/tut/control-flow/switch.md b/docs/tut/control-flow/switch.md
new file mode 100644
index 0000000..70864e7
--- /dev/null
+++ b/docs/tut/control-flow/switch.md
@@ -0,0 +1,66 @@
+# Control Flow
+
+Vide has specific functions for dealing with sources that store a table value.
+
+Often, you will have a table of values that will be displayed in a similar
+manner. Rather than manually looping over each value to generate a corresponding
+UI element, Vide provides functions `indexes()` and `values()` to do this for
+you.
+
+`indexes()` maps each *index* in a table to a UI element.
+
+```lua
+local names = source { "a", "b", "c" }
+
+local elements = indexes(names, function(name, i)
+ return create "TextLabel" {
+ Text = function()
+ return "Name: " .. name()
+ end,
+
+ LayoutOrder = i
+ }
+end)
+```
+
+What happens here is the given callback is only ever ran *once* for each index
+in the table. The callback receives two arguments, a *source* containing the
+index's value and then the index itself.
+
+Anytime the value at a corresponding index changes, the source for that index
+value is updated, causing the UI element depending on it to update too.
+
+`values()` behaves similarly, except it maps each *value* in a table to a UI
+element.
+
+```lua
+type Item = {
+ Name: string,
+ Icon: number
+}
+
+local items = source({} :: Array
- )
+
+local elements = values(items, function(item, i)
+ return create "ImageLabel" {
+ Image = "rbxassetid://" .. item.Icon,
+ LayoutOrder = i
+ }
+end)
+```
+
+The callback is again only ever ran *once* for each value in the table. The
+callback receives two arguments, a value in the table and then a *source*
+containing the value's corresponding index.
+
+Any time a value in a table changes index, the source for that value is updated,
+causing the UI element position to change.
+
+In certain cases `values()` can cause less recalculation and rerenders than
+`indexes()` like when items are re-arranged and shifted within a table.
+
+It is important that each value in a table is unique when using `values()`,
+and for this reason always using `indexes()` if a table contains primitive
+values.
+
+Both `indexes()` and `values()` return an array of all mapped UI elements.
diff --git a/docs/tut/control-flow/values.md b/docs/tut/control-flow/values.md
new file mode 100644
index 0000000..70864e7
--- /dev/null
+++ b/docs/tut/control-flow/values.md
@@ -0,0 +1,66 @@
+# Control Flow
+
+Vide has specific functions for dealing with sources that store a table value.
+
+Often, you will have a table of values that will be displayed in a similar
+manner. Rather than manually looping over each value to generate a corresponding
+UI element, Vide provides functions `indexes()` and `values()` to do this for
+you.
+
+`indexes()` maps each *index* in a table to a UI element.
+
+```lua
+local names = source { "a", "b", "c" }
+
+local elements = indexes(names, function(name, i)
+ return create "TextLabel" {
+ Text = function()
+ return "Name: " .. name()
+ end,
+
+ LayoutOrder = i
+ }
+end)
+```
+
+What happens here is the given callback is only ever ran *once* for each index
+in the table. The callback receives two arguments, a *source* containing the
+index's value and then the index itself.
+
+Anytime the value at a corresponding index changes, the source for that index
+value is updated, causing the UI element depending on it to update too.
+
+`values()` behaves similarly, except it maps each *value* in a table to a UI
+element.
+
+```lua
+type Item = {
+ Name: string,
+ Icon: number
+}
+
+local items = source({} :: Array
- )
+
+local elements = values(items, function(item, i)
+ return create "ImageLabel" {
+ Image = "rbxassetid://" .. item.Icon,
+ LayoutOrder = i
+ }
+end)
+```
+
+The callback is again only ever ran *once* for each value in the table. The
+callback receives two arguments, a value in the table and then a *source*
+containing the value's corresponding index.
+
+Any time a value in a table changes index, the source for that value is updated,
+causing the UI element position to change.
+
+In certain cases `values()` can cause less recalculation and rerenders than
+`indexes()` like when items are re-arranged and shifted within a table.
+
+It is important that each value in a table is unique when using `values()`,
+and for this reason always using `indexes()` if a table contains primitive
+values.
+
+Both `indexes()` and `values()` return an array of all mapped UI elements.
diff --git a/docs/tut/crash-course/1-introduction.md b/docs/tut/crash-course/1-introduction.md
index aa334f8..7b00dac 100644
--- a/docs/tut/crash-course/1-introduction.md
+++ b/docs/tut/crash-course/1-introduction.md
@@ -19,11 +19,11 @@ Some of the main focuses behind Vide's design choices:
- Reducing the amount of imports needed for usage by leveraging Luau's syntax
and semantics.
- Being completely typecheckable.
-- Flexibility, particularly with integrating other libraries and allowing users
- to use their own patterns.
+- Flexibility with integrating other libraries and allowing users to use their
+ own patterns.
- Independence from instance lifetimes.
-- A powerful reactive system that can surgically update properties as a result
- of state changes.
+- A powerful reactive system that can update specific properties as a result of
+ state changes, updates are immediate with no diffing needed.
## Structure Of A Vide App
diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md
index 0dc77bf..67c1f58 100644
--- a/docs/tut/crash-course/5-effect.md
+++ b/docs/tut/crash-course/5-effect.md
@@ -40,3 +40,6 @@ the updating of the Text property anytime the count is changed.
All observable changes to the user are considered to be side-effects of the
reactive system.
+
+You should not update other sources using an effect. Improper usage can lead to
+unecessary updates and infinite loops.
diff --git a/docs/tut/crash-course/8-control-flow.md b/docs/tut/crash-course/8-control-flow.md
index 70864e7..f590145 100644
--- a/docs/tut/crash-course/8-control-flow.md
+++ b/docs/tut/crash-course/8-control-flow.md
@@ -1,66 +1,95 @@
# Control Flow
-Vide has specific functions for dealing with sources that store a table value.
+Eventually you will need a way to dynamically create and destroy UI elements
+resulting from state changes. Vide provides functions to help you do this,
+known as *control flow* functions.
+
+These functions return a new source, which holds the instances to be displayed.
+These sources can be assigned as children, meaning the displayed children
+will update when the input source updates.
+
+One of these functions is `switch()`, used to conditionally show one of a set of
+components.
+
+```lua
+local vide = require(vide)
+local source = vide.source
+local switch = vide.switch
+
+local function ToggleButton(p: {
+ Text: string,
+ Toggle: (boolean) -> boolean
+})
+ return create "TextButton" {
+ Size = UDim2.fromOffset(300, 300),
+ Text = p.Text,
+ Activated = function()
+ p.Toggle(not p.Toggle())
+ end
+ }
+end
+
+local loggedIn = source(false)
+
+local function LoginMenu()
+ return Frame {
+ switch(loggedIn) {
+ [true] = function()
+ return ToggleButton { Text = "Log out", Toggle = loggedIn }
+ end,
+
+ [false] = function()
+ return ToggleButton { Text = "Log in", Toggle = loggedIn }
+ end
+ }
+ }
+end
+
+mount(function() return create "ScreenGui" { LoginMenu {} } end, game.StarterGui)
+```
+
+Above is an example of using a switch to create a login menu. Each time
+`loggedIn` toggles, the current button will be destroyed, and a new button
+created, which the text to represent the current action, to log in or log out.
+
+Another control flow function, `indexes()`, is used to create elements from an
+input table.
Often, you will have a table of values that will be displayed in a similar
manner. Rather than manually looping over each value to generate a corresponding
-UI element, Vide provides functions `indexes()` and `values()` to do this for
-you.
-
-`indexes()` maps each *index* in a table to a UI element.
+UI element, `indexes()` can autmatically run a transform function for each
+index and value, generating a UI element.
```lua
-local names = source { "a", "b", "c" }
+local todoList = {
+ "Finish the crash course",
+ "Star vide's GitHub"
+}
-local elements = indexes(names, function(name, i)
+local elements = indexes(todoList, function(todo, i)
return create "TextLabel" {
Text = function()
- return "Name: " .. name()
+ return i .. ": " .. todo()
end,
LayoutOrder = i
}
end)
-```
-What happens here is the given callback is only ever ran *once* for each index
-in the table. The callback receives two arguments, a *source* containing the
-index's value and then the index itself.
-
-Anytime the value at a corresponding index changes, the source for that index
-value is updated, causing the UI element depending on it to update too.
-
-`values()` behaves similarly, except it maps each *value* in a table to a UI
-element.
-
-```lua
-type Item = {
- Name: string,
- Icon: number
-}
-
-local items = source({} :: Array
- )
-
-local elements = values(items, function(item, i)
- return create "ImageLabel" {
- Image = "rbxassetid://" .. item.Icon,
- LayoutOrder = i
+mount(function()
+ return create "ScreenGui" {
+ create "UIListLayout" {}, elements
}
-end)
+end, game.StarterGui)
```
-The callback is again only ever ran *once* for each value in the table. The
-callback receives two arguments, a value in the table and then a *source*
-containing the value's corresponding index.
+For each unique index in the passed table, the transform function will be called
+with 1. a source containing the value of the index, 2. the index itself.
-Any time a value in a table changes index, the source for that value is updated,
-causing the UI element position to change.
+When the value at an index is changed, the function is not reran. Instead, the
+given source is updated instead.
-In certain cases `values()` can cause less recalculation and rerenders than
-`indexes()` like when items are re-arranged and shifted within a table.
+`indexes()` is said to map each *index* in a table to a UI element, each index
+has a single corresponding element.
-It is important that each value in a table is unique when using `values()`,
-and for this reason always using `indexes()` if a table contains primitive
-values.
-
-Both `indexes()` and `values()` return an array of all mapped UI elements.
+An element is only destroyed if the value of an index is set to `nil`.
diff --git a/docs/tut/reactive-scoping.md b/docs/tut/reactive-scoping.md
new file mode 100644
index 0000000..5b983ab
--- /dev/null
+++ b/docs/tut/reactive-scoping.md
@@ -0,0 +1,61 @@
+# Reactive Scoping
+
+This is a brief document designed to give the user more insight into how Vide's
+reactive graph works.
+
+Each time you create and derive sources, a new node representing that source is
+created and added to the reactive graph. Each node stores a value and a
+side-effect function. Each node also keeps track of its parents and children,
+as well as any cleanups registered.
+
+Any time a node is updated, Vide will traverse and update that node's children,
+its children's children, etc, until all nodes descending from that node has been
+updated. Traversal will stop at a node if that node's cached value does not
+change after an update.
+
+For every node that is updated, a scope is opened for that node. These scopes
+are referred to as "reactive scopes". Any source read from within a node's scope
+will that node as a child. This is similar to cleanups, anytime a cleanup is
+registered, it is added to the node of the currently active scope.
+
+The way Vide tracks reactive scopes, is by using a stack of nodes. The current
+active reactive scope is the node at the top of this stack.
+
+When destroying a node, its descendents are traversed and also destroyed.
+When being destroyed, a node's connections (parents and children) are cleared,
+and any pending cleanup functions are ran.
+
+The purpose of `root()` (which is called internally by `mount()`) is to setup
+the root node which will track any node created or derived inside its scope, or
+any cleanups registered. Without it, nodes could be garbage collected without a
+chance to run pending cleanups which can cause memory leakage.
+
+Control flow functions in Vide are special, as they can dynamically create and
+destroy new root scopes.
+
+It is the combination of the above which allows us to write components like so:
+
+```lua
+local function Counter()
+ local count = source(0)
+
+ local connection = stepped:Connect(function() count(count() + 1) end)
+
+ cleanup(function() connection:Disconnect() end)
+ effect(function() print(count()) end)
+
+ return create "TextLabel" { Text = count }
+end
+```
+
+Vide doesn't recognise this as a "component", that is a user abstraction. Vide
+just sees this as a function that creates nodes in the reactive graph.
+
+Whenever the reactive scope that calls this function is destroyed, like by a
+control flow function, the registered cleanup will be called, and the effect
+(which is just a node on the reactive graph) is destroyed. The returned instance
+and the bound `count` source is just considered to be a side-effect, and with
+the reactive scope from which the side-effects stem from destroyed, the instance
+can be garbage collected - everything is nicely cleaned up.
+
+> todo: add graphics
diff --git a/src/action.luau b/src/action.luau
index 3efcfe6..f40bf3a 100644
--- a/src/action.luau
+++ b/src/action.luau
@@ -3,7 +3,7 @@ type Action = {
callback: (Instance) -> ()
}
-local ActionMT = {}
+local ActionMT = table.freeze {}
local function is_action(v: any)
return getmetatable(v) == ActionMT
diff --git a/src/bind.luau b/src/bind.luau
index 99718a5..bcd0701 100644
--- a/src/bind.luau
+++ b/src/bind.luau
@@ -1,6 +1,7 @@
if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw)
+local trace = require(script.Parent.trace)
local flags = require(script.Parent.flags)
local graph = require(script.Parent.graph)
type Node = graph.Node
@@ -9,34 +10,11 @@ local get_scope = graph.get_scope
local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner
--- todo: replace with throw's method
-local root do
- local src = debug.info(1, "s")
- root = string.sub(src, 1, #src - 5)
-end
-
-local function traceback(skips: number) -- ensures trace begins outside of any vide library file
- local s = 1
-
- repeat
- s += 1
- local path = debug.info(s, "s")
-
- local found = not string.find(path, root)
-
- if found then
- skips -= 1
- end
- until found and skips < 0
-
- return debug.traceback(nil, s)
-end
-
function create_binding(updater: (T) -> T, binding: T)
if flags.strict then
-- track bind creation trace
local fn = updater
- local bind_trace = traceback(0)
+ local bind_trace = debug.traceback(nil, trace()-1)
updater = function(...)
local ok, result = xpcall(fn, function(err: string)
return err
diff --git a/src/cleanup.luau b/src/cleanup.luau
index 2d8fc73..05a516f 100644
--- a/src/cleanup.luau
+++ b/src/cleanup.luau
@@ -5,12 +5,13 @@ local graph = require(script.Parent.graph)
local get_scope = graph.get_scope
local add_cleanup = graph.add_cleanup
-local function cleanup(fn: () -> ())
+local function cleanup(callback: () -> ())
local scope = get_scope()
- if not scope then throw("cannot cleanup in a non-reactive scope") end
- assert(scope)
+ if not scope then
+ throw("cannot cleanup in a non-reactive scope")
+ end; assert(scope)
- add_cleanup(scope, fn)
+ add_cleanup(scope, callback)
end
return cleanup
diff --git a/src/create.luau b/src/create.luau
index 736ed26..0ce889e 100644
--- a/src/create.luau
+++ b/src/create.luau
@@ -7,11 +7,11 @@ local defaults = require(script.Parent.defaults)
local apply = require(script.Parent.apply)
local memoize = require(script.Parent.memoize)
-local function create_instance(class_name: string)
- local ok, instance: Instance = pcall(Instance.new, class_name :: any)
- if not ok then throw(`invalid class name, could not create instance of class { class_name }`) end
+local function create_instance(class: string)
+ local ok, instance: Instance = pcall(Instance.new, class :: any)
+ if not ok then throw(`invalid class name, could not create instance of class { class }`) end
- local default: { [string]: unknown }? = defaults[class_name]
+ local default: { [string]: unknown }? = defaults[class]
if default then
for i, v in next, default do
(instance :: any)[i] = v
diff --git a/src/defaults.luau b/src/defaults.luau
index 28ecf28..03badd6 100644
--- a/src/defaults.luau
+++ b/src/defaults.luau
@@ -1,10 +1,11 @@
local Enum = game and Enum or require "test/mock".Enum :: never
local Color3 = game and Color3 or require "test/mock".Color3 :: never
+local Vector3 = game and Vector3 or require "test/mock".Vector3 :: never
return {
Part = {
Material = Enum.Material.SmoothPlastic,
- --Size = Vector3.new(1, 1, 1),
+ Size = Vector3.new(1, 1, 1),
Anchored = true
},
diff --git a/src/derive.luau b/src/derive.luau
index 7c2a763..29b915a 100644
--- a/src/derive.luau
+++ b/src/derive.luau
@@ -8,13 +8,13 @@ local track = graph.track
local get_scope = graph.get_scope
local evaluate_node = graph.evaluate_node
-local function derive(fn: () -> T): () -> T
+local function derive(source: () -> T): () -> T
local owner = get_scope()
if not owner then
throw("cannot derive in non-reactive scope")
end; assert(owner)
- local node = create_node(false :: any, fn)
+ local node = create_node(false :: any, source)
set_owner(node, owner)
evaluate_node(node)
diff --git a/src/effect.luau b/src/effect.luau
index 8c54d23..bc53660 100644
--- a/src/effect.luau
+++ b/src/effect.luau
@@ -7,16 +7,16 @@ local get_scope = graph.get_scope
local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner
-local function effect(effect: (T) -> T, initial_value: T)
+local function effect(callback: (T) -> T, initial_value: T)
local owner = get_scope()
if not owner then
throw("cannot create effect in non-reactive scope")
end; assert(owner)
- local node = create_node(initial_value, effect)
+ local node = create_node(initial_value, callback)
set_owner(node, owner)
evaluate_node(node)
end
-return effect :: ((effect: (T) -> T, initial_value: T) -> ()) & ((effect: () -> ()) -> ())
+return effect :: ((callback: (T) -> T, initial_value: T) -> ()) & ((callback: () -> ()) -> ())
diff --git a/src/graph.luau b/src/graph.luau
index b355e6f..a70fe57 100644
--- a/src/graph.luau
+++ b/src/graph.luau
@@ -16,10 +16,11 @@ export type Node = {
[number]: Node
}
+-- reactive scope stack
local scopes = { n = 0 } :: { [number]: Node, n: number }
-- runs a given callback in a context that Luau does not allow yielding in
-local check_for_yield: (fn: (T...) -> (boolean, string?), T...) -> () do
+local check_for_yield: (fn: (T...) -> (), T...) -> (boolean, string?) do
local t = { __mode = "kv" }
setmetatable(t, t)
@@ -40,10 +41,6 @@ local check_for_yield: (fn: (T...) -> (boolean, string?), T...) -> () do
end
end
-local function get_stack_scope(offset: number): Node?
- return scopes[scopes.n - offset]
-end
-
local function get_scope(): Node?
return scopes[scopes.n]
end
@@ -120,8 +117,18 @@ end
local function evaluate_node(node: Node)
local cur_value = node.cache
- run_cleanups(node) -- todo: move in scope?
+ if flags.strict then
+ run_cleanups(node)
+ open_scope(node)
+ local ok, err = check_for_yield(node.effect :: (T) -> T, cur_value)
+
+ close_scope()
+
+ if not ok then throw(err :: string) end
+ end
+
+ run_cleanups(node) -- todo: move in scope?
open_scope(node)
local ok, new_value = pcall(node.effect :: (T) -> T, cur_value)
@@ -150,6 +157,7 @@ local function update(node: StartNode)
table.clear(update_queue)
end
+ -- unparent all children and queue for eval
do
local child = node[1]
while child do -- todo: case where child in owner context
@@ -162,6 +170,7 @@ local function update(node: StartNode)
end
end
+ -- evaluate all queued children
for i = n0 + 1, n do
local child = update_queue[i]
if not child.effect then continue end
@@ -178,36 +187,33 @@ end
local function track(node: StartNode)
local scope = get_scope()
- if scope and scope.effect then -- todo
+ if scope and scope.effect then -- do not track nodes with no effect
add_child(node, scope)
end
end
local function create_node(value: T, effect: false | (T) -> T): Node
- local node: Node = {
+ return {
cache = value,
effect = effect,
- cleanups = false :: false,
+ cleanups = false,
parents = {},
}
-
- return node
-end
-
-local function get_children(node: Node): { Node }
- return { unpack(node) } :: { Node }
end
local function create_start_node(value: T): StartNode
return { cache = value }
end
+local function get_children(node: Node): { Node }
+ return { unpack(node) } :: { Node }
+end
+
return table.freeze {
open_scope = open_scope,
close_scope = close_scope,
evaluate_node = evaluate_node,
get_scope = get_scope,
- get_stack_scope = get_stack_scope,
add_cleanup = add_cleanup,
set_owner = set_owner,
destroy = destroy,
@@ -217,5 +223,6 @@ return table.freeze {
add_child = add_child,
create_node = create_node,
create_start_node = create_start_node,
- get_children = get_children
+ get_children = get_children,
+ scopes = scopes
}
diff --git a/src/maps.luau b/src/maps.luau
index 3f2b821..da5c5d7 100644
--- a/src/maps.luau
+++ b/src/maps.luau
@@ -1,7 +1,5 @@
if not game then script = require "test/relative-string" end
--- todo: more testing needed regarding `cleanup()` usage
-
local throw = require(script.Parent.throw)
local flags = require(script.Parent.flags)
local graph = require(script.Parent.graph)
@@ -71,7 +69,7 @@ local function indexes(input: () -> Map, transform: (() -> VI,
local cv = input_cache[i]
if cv ~= v then
- if cv == nil then
+ if cv == nil then -- create new scope and run transform
local scope = create_node(false, false)
scopes[i] = scope :: Node
@@ -87,20 +85,22 @@ local function indexes(input: () -> Map, transform: (() -> VI,
close_scope()
- if not ok then error(result, 0) end
+ if not ok then
+ close_scope() -- subowner scope
+ error(result, 0)
+ end
input_nodes[i] = node
- input_cache[i] = v
output_cache[i] = result
- else
+ else -- update source
input_nodes[i].cache = v
update(input_nodes[i])
- input_cache[i] = v
end
+
+ input_cache[i] = v
end
end
- -- todo: handle early error
close_scope()
local output_array = table.create(#scopes)
@@ -118,7 +118,6 @@ local function indexes(input: () -> Map, transform: (() -> VI,
evaluate_node(node)
-
return function()
track(node)
return node.cache
@@ -161,7 +160,7 @@ local function values(input: () -> Map, transform: (VI, () ->
local cv = cur_input_cache[v]
- if cv == nil then
+ if cv == nil then -- create new scope and run transform
local scope = create_node(false, false)
scopes[v] = scope :: Node
@@ -177,20 +176,23 @@ local function values(input: () -> Map, transform: (VI, () ->
close_scope()
- if not ok then error(result, 0) end
+ if not ok then
+ close_scope() -- subowner scope
+ error(result, 0)
+ end
input_nodes[v] = node
output_cache[v] = result
- else
+ else -- update source
if cv ~= i then
input_nodes[v].cache = i
update(input_nodes[v])
end
+
cur_input_cache[v] = nil
end
end
- -- todo: handle early error
close_scope()
-- remove old values
diff --git a/src/root.luau b/src/root.luau
index 1ed5db8..766f1fb 100644
--- a/src/root.luau
+++ b/src/root.luau
@@ -31,4 +31,4 @@ local function root(fn: () -> T): (T, () -> ())
end
end
-return root
+return root :: ((fn: () -> T) -> (T, () -> ())) & ((fn: () -> ()) -> (nil, () -> ()))
diff --git a/src/source.luau b/src/source.luau
index 2e960a2..f1307fc 100644
--- a/src/source.luau
+++ b/src/source.luau
@@ -9,8 +9,6 @@ local update = graph.update
export type Source = (() -> T) & ((T) -> T)
local function source(initial_value: T): Source
- --assert(get_scope())
-
local node = create_start_node(initial_value)
return function(...): T
diff --git a/src/spring.luau b/src/spring.luau
index ee76e10..9503e15 100644
--- a/src/spring.luau
+++ b/src/spring.luau
@@ -28,8 +28,7 @@ type StartNode = graph.StartNode
local create_node = graph.create_node
local create_start_node = graph.create_start_node
local get_scope = graph.get_scope
-local open_scope = graph.open_scope
-local close_scope = graph.close_scope
+local evaluate_node = graph.evaluate_node
local update = graph.update
local set_owner = graph.set_owner
local track = graph.track
@@ -43,6 +42,8 @@ local function Vec3(x: number?, y: number?, z: number?)
return Vector3.new(x, y, z)
end
+local ZERO = Vec3(0, 0, 0)
+
type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3
type SpringData = {
@@ -67,21 +68,20 @@ type Vec6ToType = (Vec3, Vec3) -> T
local type_to_vec6 = {
number = function(v)
- return Vec3(v, 0, 0), Vec3()
+ return Vec3(v, 0, 0), ZERO
end :: TypeToVec6,
CFrame = function(v)
- -- todo: proper rotation tween
return v.Position, Vec3(v:ToEulerAnglesXYZ())
end :: TypeToVec6,
Color3 = function(v)
- -- todo: hsv
- return Vec3(v.R, v.G, v.B), Vec3()
+ -- todo: hsv, oklab?
+ return Vec3(v.R, v.G, v.B), ZERO
end :: TypeToVec6,
UDim = function(v)
- return Vec3(v.Scale, v.Offset, 0), Vec3()
+ return Vec3(v.Scale, v.Offset, 0), ZERO
end :: TypeToVec6,
UDim2 = function(v)
@@ -89,11 +89,11 @@ local type_to_vec6 = {
end :: TypeToVec6,
Vector2 = function(v)
- return Vec3(v.X, v.Y, 0), Vec3()
+ return Vec3(v.X, v.Y, 0), ZERO
end :: TypeToVec6,
Vector3 = function(v)
- return v, Vec3()
+ return v, ZERO
end :: TypeToVec6,
Rect = function(v)
@@ -151,25 +151,11 @@ setmetatable(springs, { __mode = "v" })
local function spring(source: () -> T, period: number?, damping_ratio: number?): () -> T
local owner = get_scope()
- if not owner then throw("cannot derive in non-reactive scope") end
- assert(owner)
-
- local updater = create_node(false)
- updater.effect = true :: any -- todo
-
- set_owner(updater, owner)
- open_scope(updater)
-
- local initial_value = source()
-
- close_scope()
-
- local vtype = typeof(initial_value)
-
- local x1_123, x1_456 = type_to_vec6[vtype](initial_value)
+ if not owner then
+ throw("cannot derive in non-reactive scope")
+ end; assert(owner)
-- https://en.wikipedia.org/wiki/Damping
- -- todo: calculate damped freq at 10tau instead of natural freq
local w_n = 2*math.pi / (period or 1)
local z = damping_ratio or 1
@@ -182,27 +168,38 @@ local function spring(source: () -> T, period: number?, damping_ratio: number
k = k,
c = c,
- x0_123 = x1_123,
- x1_123 = x1_123,
- v_123 = Vec3(),
+ x0_123 = ZERO,
+ x1_123 = ZERO,
+ v_123 = ZERO,
- x0_456 = x1_456,
- x1_456 = x1_456,
- v_456 = Vec3(),
+ x0_456 = ZERO,
+ x1_456 = ZERO,
+ v_456 = ZERO,
- source_value = initial_value,
+ source_value = false :: any,
}
- local output = create_start_node(initial_value)
+ local output = create_start_node(false :: any)
- updater.effect = function()
- local v = source()
- data.x1_123, data.x1_456 = type_to_vec6[typeof(v)](v)
- data.source_value = v
+ local function updater_effect()
+ local value = source()
+ data.x1_123, data.x1_456 = type_to_vec6[typeof(value)](value)
+ data.source_value = value
springs[data] = output -- todo: investigate why insertion is not O(1) at ~20k springs
- return false
+ return value
end
+ local updater = create_node(false :: any, updater_effect)
+
+ set_owner(updater, owner)
+ evaluate_node(updater)
+
+ -- set initial position to goal
+ data.x0_123, data.x0_456 = data.x1_123, data.x1_456
+
+ -- set output to goal
+ output.cache = data.source_value
+
return function()
track(output)
return output.cache
diff --git a/src/switch.luau b/src/switch.luau
index 97a5f72..31c2b4a 100644
--- a/src/switch.luau
+++ b/src/switch.luau
@@ -22,22 +22,27 @@ local function switch(source: () -> T): (map: Map U)?)>) -> ()
throw("cannot switch in non-reactive scope")
end; assert(owner)
- local scope: Node?
+ local last_scope: Node?
local last_component: (() -> U)?
- local function update(): U?
+ local function update(cached): U?
local component = map[source()]
- if component == last_component then return nil end
+ if component == last_component then return cached end
last_component = component
- if scope then
- destroy(scope :: Node)
+ if last_scope then
+ destroy(last_scope :: Node)
+ last_scope = nil
end
if component == nil then return nil end
+ if type(component) ~= "function" then
+ throw("map must map a value to a function")
+ end
+
local new_scope = create_node(false, false)
- scope = new_scope :: Node
+ last_scope = new_scope :: Node
set_owner(new_scope, owner)
open_scope(new_scope)
@@ -51,7 +56,7 @@ local function switch(source: () -> T): (map: Map U)?)>) -> ()
return result
end
- local node = create_node(nil, update :: () -> any)
+ local node = create_node(nil :: any, update)
set_owner(node, owner)
evaluate_node(node)
diff --git a/src/throw.luau b/src/throw.luau
index 712cdc2..d3ea687 100644
--- a/src/throw.luau
+++ b/src/throw.luau
@@ -1,32 +1,9 @@
--- returns path to file as an array with each directory
--- accounts for Roblox and Luau contexts
-local function get_path(s)
- if string.sub(s, #s - 4, #s) == ".luau" then
- s = string.sub(s, 1, #s - 5)
- end
+if not game then script = require "test/relative-string" end
- return string.split(s, string.match(s, "%w+/") and "/" or ".")
-end
+local trace = require(script.Parent.trace)
--- get directory of vide root
-local root do
- local path = get_path(debug.info(1, "s"))
- root = path[#path - 1]
-end
-
--- throws an error, ensuring stack trace begins at the first callsite outside
--- of all vide library files
-local function throw(msg: string)
- local stack = 1
-
- local path = get_path(debug.info(stack, "s"))
-
- while path[#path] == root or path[#path - 1] == root do
- stack += 1
- path = get_path(debug.info(stack, "s"))
- end
-
- error(msg, stack)
+local function throw(msg): any
+ error(msg, trace()-1)
end
return throw
diff --git a/src/trace.luau b/src/trace.luau
new file mode 100644
index 0000000..04672ff
--- /dev/null
+++ b/src/trace.luau
@@ -0,0 +1,29 @@
+-- returns path to file as an array with each directory
+-- accounts for Roblox and Luau contexts
+local function get_path(s)
+ if string.sub(s, #s - 4, #s) == ".luau" then
+ s = string.sub(s, 1, #s - 5)
+ end
+
+ return string.split(s, string.match(s, "%w+/") and "/" or ".")
+end
+
+-- get directory of vide root
+local root do
+ local path = get_path(debug.info(1, "s"))
+ root = path[#path - 1]
+end
+
+-- finds the first stack depth outside of any vide library function
+return function(): number
+ local stack = 1
+
+ local path = get_path(debug.info(stack, "s"))
+
+ while path[#path] == root or path[#path - 1] == root do
+ stack += 1
+ path = get_path(debug.info(stack, "s"))
+ end
+
+ return stack
+end
diff --git a/src/untrack.luau b/src/untrack.luau
index abdfb78..230674d 100644
--- a/src/untrack.luau
+++ b/src/untrack.luau
@@ -7,17 +7,21 @@ local get_scope = graph.get_scope
local function untrack(source: () -> T): T
local scope = get_scope()
- if not scope then throw("cannot untrack in non-reactive scope") end
- assert(scope)
+ if not scope then
+ throw("cannot untrack in non-reactive scope")
+ end; assert(scope)
+ -- sources are only tracked if the node in scope has an effect
local effect = scope.effect
scope.effect = false
- local v = source()
+ local ok, result = pcall(source)
scope.effect = effect :: () -> ()
- return v
+ if not ok then error(result, 0) end
+
+ return result
end
return untrack
diff --git a/test/benchmark.luau b/test/benchmark.luau
index 47f766e..bf7085a 100644
--- a/test/benchmark.luau
+++ b/test/benchmark.luau
@@ -1,14 +1,19 @@
local BENCH, START = require("test/testkit").benchmark()
local vide = require "src/init"
+local root = vide.root
+local source = vide.source
+local derive = vide.derive
+local indexes = vide.indexes
+local values = vide.values
+local cleanup = vide.cleanup
+local create = vide.create
local N = 2^18 -- 262144
-- todo: wide and deep graph benchmarks
BENCH("create source", function()
- local source = vide.source
-
local cache = table.create(N)
for i = 1, START(N) do
@@ -17,7 +22,7 @@ BENCH("create source", function()
end)
BENCH("get value", function()
- local src = vide.source(1)
+ local src = source(1)
for i = 1, START(N) do
src()
@@ -25,7 +30,7 @@ BENCH("get value", function()
end)
BENCH("set value", function()
- local src = vide.source(1)
+ local src = source(1)
for i = 1, START(N) do
src(i)
@@ -33,12 +38,10 @@ BENCH("set value", function()
end)
BENCH("derive 1 source", function()
- local derive = vide.derive
-
local cache = table.create(N)
- local src = vide.source(1)
+ local src = source(1)
- vide.root(function()
+ root(function()
for i = 1, START(N) do
cache[i] = derive(function()
return src()
@@ -49,8 +52,6 @@ BENCH("derive 1 source", function()
end)
BENCH("derive 4 sources", function()
- local derive = vide.derive
-
local cache = table.create(N)
local src = vide.source(1)
local src2 = vide.source(2)
@@ -68,11 +69,11 @@ BENCH("derive 4 sources", function()
end)
end)
-BENCH("set derived value", function()
- local src = vide.source(1)
+BENCH("update 1->1 graph", function()
+ local src = source(1)
- vide.root(function()
- local _derived = vide.derive(function() return src() end)
+ root(function()
+ local _derived = derive(function() return src() end)
for i = 1, START(N) do
src(i)
@@ -82,9 +83,104 @@ BENCH("set derived value", function()
end)
end)
+BENCH("update 1->1 graph with cleanup", function()
+ local src = source(1)
+
+ root(function()
+ derive(function()
+ cleanup(function() end)
+ return src()
+ end)
+
+ for i = 1, START(N) do
+ src(i)
+ end
+
+ return nil
+ end)
+end)
+
+BENCH("update 1->1000 graph", function()
+ local src = source(-1)
+
+ root(function()
+ for i = 1, 1000 do
+ derive(function() return src() end)
+ end
+
+ src(0)
+
+ for i = 1, START(10) do
+ src(i)
+ end
+ end)
+end)
+
+BENCH("update 1->1->1->1...1000 graph", function()
+ local src = source(-1)
+
+ root(function()
+ local last = src
+ for i = 1, 1000 do
+ local l = last
+ last = derive(function() return l() end)
+ end
+
+ src(0)
+
+ for i = 1, START(10) do
+ src(i)
+ end
+ end)
+end)
+
+-- todo: repeat with batching
+BENCH("update 1000->1 graph", function()
+ local srcs = {}
+ for i = 1, 1000 do
+ srcs[i] = source(0)
+ end
+
+ root(function()
+ derive(function()
+ for i = 1, 1000 do
+ srcs[i]()
+ end
+ return false
+ end)
+
+ for i = 1, START(1) do
+ for idx = 1, 1000 do
+ srcs[idx](i)
+ end
+ end
+ end)
+end)
+
+-- todo: optimize, repeat with batching
+BENCH("update 1000x 1->1 common extern. graph", function()
+ local ext = source(-1)
+
+ root(function()
+ local srcs = {}
+ for i = 1, 1000 do
+ srcs[i] = source(0)
+ derive(function() return srcs[i]() + ext() end)
+ end
+
+ ext(0)
+
+ for i = 1, START(10) do
+ for idx = 1, 1000 do
+ srcs[idx](i)
+ end
+ end
+ end)
+end)
+
BENCH("apply 0 properties", function()
local apply = require "src/apply"
- local instance = vide.create("Frame") {}
+ local instance = create("Frame") {}
for i = 1, START(N) do
apply(instance, {})
@@ -93,7 +189,7 @@ end)
BENCH("apply 8 properties", function()
local apply = require "src/apply"
- local instance = vide.create("Frame") {}
+ local instance = create("Frame") {}
for i = 1, START(N) do
apply(instance, {
@@ -112,10 +208,10 @@ end)
BENCH("bind property", function()
local apply = require "src/apply"
- local instance = vide.create("Frame") {}
- local src = vide.source(1)
+ local instance = create("Frame") {}
+ local src = source(1)
- vide.root(function()
+ root(function()
for i = 1, START(N) do
apply(instance, {
Text = src
@@ -129,10 +225,10 @@ end)
BENCH("update binding", function()
local apply = require "src/apply"
- local instance = vide.create("Frame") {}
- local src = vide.source(1)
+ local instance = create("Frame") {}
+ local src = source(1)
- vide.root(function()
+ root(function()
apply(instance, {
Text = src
})
@@ -154,12 +250,12 @@ BENCH("indexes() all new", function()
data[i] = i
end
- local src = vide.source(data)
+ local src = source(data)
- vide.root(function()
+ root(function()
START(N)
- local _list = vide.indexes(src, function(v, i)
+ local _list = indexes(src, function(v, i)
return {}
end)
@@ -174,10 +270,10 @@ BENCH("indexes() no change", function()
data[i] = i
end
- local src = vide.source(data)
+ local src = source(data)
- vide.root(function()
- local _list = vide.indexes(src, function(v, i)
+ root(function()
+ local _list = indexes(src, function(v, i)
return {}
end)
@@ -196,11 +292,11 @@ BENCH("indexes() all change", function()
data[i] = i
end
- local src = vide.source(data)
+ local src = source(data)
- vide.root(function()
- local _list = vide.indexes(src, function(v, i)
+ root(function()
+ local _list = indexes(src, function(v, i)
return {}
end)
@@ -225,10 +321,10 @@ BENCH("indexes() all remove", function()
data[i] = i
end
- local src = vide.source(data)
+ local src = source(data)
- vide.root(function()
- local _list = vide.indexes(src, function(v, i)
+ root(function()
+ local _list = indexes(src, function(v, i)
return {}
end)
@@ -249,12 +345,12 @@ BENCH("values() all new", function()
data[i] = {}
end
- local src = vide.source(data)
+ local src = source(data)
- vide.root(function()
+ root(function()
START(N)
- local _list = vide.values(src, function(v, i)
+ local _list = values(src, function(v, i)
return {}
end)
@@ -269,10 +365,10 @@ BENCH("values() no change", function()
data[i] = {}
end
- local src = vide.source(data)
+ local src = source(data)
- vide.root(function()
- local _list = vide.values(src, function(v, i)
+ root(function()
+ local _list = values(src, function(v, i)
return {}
end)
@@ -293,10 +389,10 @@ BENCH("values() all change", function()
data[i] = {}
end
- local src = vide.source(data)
+ local src = source(data)
- vide.root(function()
- local _list = vide.values(src, function(v, i)
+ root(function()
+ local _list = values(src, function(v, i)
return {}
end)
@@ -322,10 +418,10 @@ BENCH("values() all remove", function()
data[i] = {}
end
- local src = vide.source(data)
+ local src = source(data)
- vide.root(function()
- local _list = vide.values(src, function(v, i)
+ root(function()
+ local _list = values(src, function(v, i)
return {}
end)
@@ -342,9 +438,9 @@ end)
N *= 1024
BENCH("register new cleanup", function()
- local cleanup = vide.cleanup
+ local cleanup = cleanup
- vide.root(function()
+ root(function()
local cleaner = function() end
local callers = {}
@@ -368,7 +464,6 @@ do
-- the purpose of the two following benchmarks is to measure the overhead of
-- aggregate construction
BENCH("set explicit mock vector2", function()
- local create = vide.create
local apply = require "src/apply"
local Vector2 = require "test/mock".Vector2
@@ -384,7 +479,6 @@ do
end)
BENCH("set aggregate mock vector2", function()
- local create = vide.create
local apply = require "src/apply"
local Vector2 = require "test/mock".Vector2
@@ -400,4 +494,47 @@ do
end)
end
+-- innacurate due to no Vector3 in vanilla Luau
+-- mock vector is 200x slower than native vector
+
+-- BENCH("spring update", function()
+-- local root, source, spring = vide.root, vide.source, vide.spring
+
+-- local src = source(0)
+
+-- root(function()
+-- for i = 1, N do
+-- spring(src)
+-- end
+
+-- START(N)
+
+-- src(1)
+
+-- return nil
+-- end)
+-- end)
+
+-- N /= 1024
+
+-- BENCH("spring step", function()
+-- local root, source, spring = vide.root, vide.source, vide.spring
+
+-- local src = source(0)
+
+-- root(function()
+-- for i = 1, N do
+-- spring(src)
+-- end
+
+-- src(1)
+
+-- START(N)
+
+-- vide.step(1/60)
+
+-- return nil
+-- end)
+-- end)
+
return nil
diff --git a/test/relative-string.luau b/test/relative-string.luau
index 824036c..232215c 100644
--- a/test/relative-string.luau
+++ b/test/relative-string.luau
@@ -1,5 +1,6 @@
local function dir(directory: string)
- return setmetatable({} :: { [string]: any }, { __index = function(_, path) return directory .. path end })
+ return setmetatable({} :: { [string]: any },
+ { __index = function(_, path) return directory .. path end })
end
local script = dir "src/"
diff --git a/test/spring-test.luau b/test/spring-test.luau
index 21f2d18..c22d955 100644
--- a/test/spring-test.luau
+++ b/test/spring-test.luau
@@ -65,7 +65,7 @@ local function main()
until false
end
-main()
+vide.root(main)
diff --git a/test/tests.luau b/test/tests.luau
index b374479..030f846 100644
--- a/test/tests.luau
+++ b/test/tests.luau
@@ -33,7 +33,6 @@ local NIL = nil :: any
TEST("graph", function()
local create_node = graph.create_node
- local create_start_node = graph.create_start_node
local track = graph.track
local update = graph.update
local add_child = graph.add_child
@@ -45,13 +44,11 @@ TEST("graph", function()
local destroy = graph.destroy
local function node(v: T?)
- local n = create_node(v or false)
- n.effect = function() end
- return n
+ return create_node(v or false, function(x) return not x end)
end
local function scope()
- return create_node(false)
+ return create_node(false, false)
end
local function cleanup(fn: () -> ())
@@ -82,17 +79,18 @@ TEST("graph", function()
local count = 0
- local function effect()
+ local function effect(x)
track(a)
track(b)
count += 1
+ return not x
end
c.effect = effect
open_scope(c)
- effect()
+ effect(c.cache)
close_scope()
@@ -107,9 +105,9 @@ TEST("graph", function()
local a, b, c, d = node(), node(), node(), node()
local b_cnt, c_cnt, d_cnt = 0, 0, 0
- function b.effect() b_cnt += 1 end
- function c.effect() c_cnt += 1 end
- function d.effect() d_cnt += 1 end
+ function b.effect(x) b_cnt += 1; return not x end
+ function c.effect(x) c_cnt += 1; return not x end
+ function d.effect(x) d_cnt += 1; return not x end
open_scope(b); track(a); close_scope()
open_scope(c); track(a); close_scope()
@@ -125,9 +123,10 @@ TEST("graph", function()
do CASE "duplicate child on rerun"
local a, b, c = node(), node(), node()
- function c.effect()
+ function c.effect(x)
track(a)
track(b)
+ return not x
end
open_scope(c); assert(c.effect)(NIL); close_scope()
@@ -165,7 +164,6 @@ TEST("graph", function()
do open_scope(root)
clean "root"
items_updated = node()
- items_updated.effect = function() end
track(items_updated) -- should not
add_child(root, items_updated)
@@ -177,7 +175,6 @@ TEST("graph", function()
do open_scope(scope1)
clean "scope1"
bind1 = node()
- bind1.effect = function() end
add_child(scope1, bind1)
do open_scope(bind1)
@@ -190,7 +187,6 @@ TEST("graph", function()
do open_scope(scope2)
clean "scope2"
bind2 = node()
- bind2.effect = function() end
add_child(scope2, bind2)
do open_scope(bind2)
clean "bind2"
@@ -252,13 +248,47 @@ TEST("graph", function()
end
do CASE "nodes garbage collection"
- local wref = weak { create_node(1) }
+ local wref = weak { node(1) }
destroy(wref[1])
gc()
CHECK(not wref[1])
end
end)
+TEST("mount()", function()
+ local mount = vide.mount
+ local create = vide.create
+ local source = vide.source
+ local cleanup = vide.cleanup
+
+ local screen = create "ScreenGui" {}
+
+ local text = source "foo"
+ local count = 0
+
+ local unmount = mount(function()
+ cleanup(function()
+ count += 1
+ end)
+
+ return create "TextLabel" {
+ Name = "TextLabel",
+ Text = text
+ }
+ end, screen)
+
+ local label = screen:FindFirstChild "TextLabel" :: TextLabel
+ CHECK(label)
+ CHECK(label.Text == "foo")
+
+ text "bar"
+ CHECK(label.Text == "bar")
+ CHECK(count == 0)
+
+ unmount()
+ CHECK(count == 1)
+end)
+
TEST("source()", wrap_root(function()
local source = vide.source
local effect = vide.effect
@@ -422,7 +452,7 @@ TEST("derive()", wrap_root(function()
local _, destroy = root(function()
- local b = derive(function()
+ local _b = derive(function()
cleanup(function()
count += 1
end)
@@ -534,13 +564,13 @@ TEST("cleanup()", wrap_root(function()
end
do CASE "cleanup on rerun"
- local state = source(1)
+ local src = source(1)
local effected = 0
local cleaned = 0
effect(function()
- state()
+ src()
effected += 1
cleanup(function()
cleaned += 1
@@ -550,27 +580,27 @@ TEST("cleanup()", wrap_root(function()
CHECK(effected == 1)
CHECK(cleaned == 0)
- state(2)
+ src(2)
CHECK(effected == 2)
CHECK(cleaned == 1)
end
do CASE "multiple cleanup"
- local state = source(1)
+ local src = source(1)
local queue = {}
effect(function()
- state()
+ src()
cleanup(function() table.insert(queue, 1) end)
cleanup(function() table.insert(queue, 2) end)
end)
CHECK(testkit.seq(queue, {}))
- state(2)
+ src(2)
CHECK(testkit.seq(queue, { 1, 2 }))
- state(3)
+ src(3)
CHECK(testkit.seq(queue, { 1, 2, 1, 2 }))
end
end))
@@ -660,7 +690,6 @@ TEST("create()", wrap_root(function()
CHECK(frame:FindFirstChild "C")
CHECK(frame:FindFirstChild "D")
CHECK(frame:FindFirstChild "E")
-
CHECK(frame:FindFirstChild "F")
CHECK(frame:FindFirstChild "G")
end
@@ -706,7 +735,7 @@ TEST("create()", wrap_root(function()
CHECK(count == 1)
end
- do CASE "bind same state to multiple instance properties"
+ do CASE "bind same source to multiple instance properties"
local src = source "1"
local text = create "TextBox" {
@@ -807,11 +836,9 @@ TEST("create()", wrap_root(function()
end))
TEST("switch()", wrap_root(function()
- local create = vide.create
local source = vide.source
local switch = vide.switch
local effect = vide.effect
- local derive = vide.derive
local cleanup = vide.cleanup
do CASE "update on source change"
@@ -828,15 +855,42 @@ TEST("switch()", wrap_root(function()
CHECK(count == 1)
CHECK(output() == 1)
+
input(false)
CHECK(output() == 0)
CHECK(count == 2)
+
input(false)
+ CHECK(output() == 0)
CHECK(count == 2)
- input(nil)
+
+ input(NIL)
CHECK(output() == nil)
end
+ do CASE "same component different map"
+ local input = source(0)
+
+ local function component()
+ return {}
+ end
+
+ local output = switch(input) {
+ [1] = component,
+ [2] = component
+ }
+
+ CHECK(output() == nil)
+
+ input(1)
+ local instance = output()
+ CHECK(instance)
+
+ input(2)
+ CHECK(output() == instance)
+
+ end
+
do CASE "scoped switch"
local input = source(true)
@@ -865,11 +919,30 @@ TEST("switch()", wrap_root(function()
input(true)
CHECK(switch1_count == 1)
CHECK(switch0_count == 1)
- input(nil)
+ input(NIL)
CHECK(switch1_count == 2)
CHECK(switch0_count == 1)
CHECK(owner_count == 0)
end
+
+ do CASE "reactive stack resets after error"
+ local scopes = require "src/graph".scopes
+ local input = source(1)
+
+ local n0 = scopes.n
+
+ local ok = pcall(function()
+ switch(input) {
+ error :: any
+ }
+ end)
+
+ CHECK(not ok)
+
+ local n1 = scopes.n
+
+ CHECK(n0 == n1)
+ end
end))
TEST("indexes()", wrap_root(function()
@@ -893,10 +966,10 @@ TEST("indexes()", wrap_root(function()
do CASE "cache result"
local input = source { 1, 2, 3 }
- local runcount = table.create(3, 0)
+ local count = table.create(3, 0)
local output = indexes(input, function(v, i)
- runcount[i] += 1
+ count[i] += 1
return v
end)
@@ -906,9 +979,9 @@ TEST("indexes()", wrap_root(function()
CHECK(output()[2]() == 2)
CHECK(output()[3]() == 4)
- CHECK(runcount[1] == 1)
- CHECK(runcount[2] == 1)
- CHECK(runcount[3] == 1)
+ CHECK(count[1] == 1)
+ CHECK(count[2] == 1)
+ CHECK(count[3] == 1)
end
do CASE "removal reflected"
@@ -989,6 +1062,27 @@ TEST("indexes()", wrap_root(function()
CHECK(count[2] == 0)
CHECK(count[3] == 0)
end
+
+ do CASE "reactive stack resets after error"
+ local scopes = require "src/graph".scopes
+
+ local input = source { 1 }
+
+ local n0 = scopes.n
+
+ local ok = pcall(function()
+ indexes(input, function()
+ error("")
+ return NIL
+ end)
+ end)
+
+ CHECK(not ok)
+
+ local n1 = scopes.n
+
+ CHECK(n0 == n1)
+ end
end))
TEST("values()", wrap_root(function()
@@ -1012,10 +1106,10 @@ TEST("values()", wrap_root(function()
do CASE "cache result"
local input = source { 1, 2, 3 }
- local runcount = table.create(3, 0)
+ local count = table.create(3, 0)
local output = values(input, function(v, i)
- runcount[v] += 1
+ count[v] += 1
return i
end)
@@ -1025,9 +1119,9 @@ TEST("values()", wrap_root(function()
CHECK(output()[2]() == 3)
CHECK(output()[3]() == 2)
- CHECK(runcount[1] == 1)
- CHECK(runcount[2] == 1)
- CHECK(runcount[3] == 1)
+ CHECK(count[1] == 1)
+ CHECK(count[2] == 1)
+ CHECK(count[3] == 1)
end
do CASE "removal reflected"
@@ -1094,6 +1188,27 @@ TEST("values()", wrap_root(function()
CHECK(count[2] == 0)
CHECK(count[3] == 0)
end
+
+ do CASE "reactive stack resets after error"
+ local scopes = require "src/graph".scopes
+
+ local input = source { 1 }
+
+ local n0 = scopes.n
+
+ local ok = pcall(function()
+ values(input, function()
+ error("")
+ return NIL
+ end)
+ end)
+
+ CHECK(not ok)
+
+ local n1 = scopes.n
+
+ CHECK(n0 == n1)
+ end
end))
TEST("spring()", wrap_root(function()
@@ -1226,7 +1341,7 @@ TEST("untrack()", wrap_root(function()
CHECK(a() == untrack(a))
end
- do CASE "derived state"
+ do CASE "derived source"
local a = source(0)
local b = source(0)
local c = source(0)
@@ -1368,127 +1483,156 @@ TEST("actions", function()
end
end)
--- TEST("strict", function()
--- vide.strict = true
+TEST("changed()", wrap_root(function()
+ local root = vide.root
+ local create = vide.create
+ local source = vide.source
+ local changed = vide.changed
--- local create = vide.create
--- local source = vide.source
--- local derive = vide.derive
--- local effect = vide.effect
--- local indexes, values = vide.indexes, vide.values
--- local cleanup = vide.cleanup
+ do CASE "outputs"
+ local output = source(nil)
--- -- do CASE "error on derived callback yield"
--- -- local state = source(1)
+ local text = create "TextLabel" {
+ Text = "a",
+ changed("Text", output)
+ }
--- -- local ok = pcall(function()
--- -- local _derived = derive(function()
--- -- coroutine.yield()
--- -- return state()
--- -- end)
--- -- end)
+ --CHECK(output() == "a")
+ text.Text = "b"
+ CHECK(output() == "b")
+ end
--- -- CHECK(not ok)
--- -- end
+ do CASE "connection disconnected"
+ local text, destroy = root(function()
+ local output = source(nil)
--- -- do CASE "error on effecter callback yield"
--- -- local state = source(1)
+ return create "TextLabel" {
+ Text = "a",
+ changed("Text", output)
+ }
+ end)
--- -- local ok = pcall(function()
--- -- local _derived = effect(function()
--- -- coroutine.yield()
--- -- state()
--- -- end)
--- -- end)
+ destroy() -- changed() should of disconnect connection
--- -- CHECK(not ok)
--- -- end
+ -- check if instance can gc
+ local wref = weak { text }
+ text = NIL
+ gc()
+ CHECK(not wref[1])
+ end
+end))
--- do CASE "run derived callback twice"
--- local state = source(1)
--- local runcount = 0
+TEST("strict", wrap_root(function()
+ vide.strict = true
--- local _ = derive(function()
--- runcount += 1
--- return state()
--- end)
+ local create = vide.create
+ local source = vide.source
+ local derive = vide.derive
+ local effect = vide.effect
+ local indexes, values = vide.indexes, vide.values
--- CHECK(runcount == 2)
--- state(2)
--- CHECK(runcount == 4)
--- end
+ do CASE "error on derived callback yield"
+ local src = source(1)
--- do CASE "run effecter callback twice"
--- local state = source(1)
--- local runcount = 0
+ local ok = pcall(function()
+ local _derived = derive(function()
+ coroutine.yield()
+ return src()
+ end)
+ end)
--- effect(function()
--- runcount += 1
--- state()
--- end)
+ CHECK(not ok)
+ end
--- CHECK(runcount == 2)
--- state(2)
--- CHECK(runcount == 4)
--- end
+ do CASE "error on effecter callback yield"
+ local src = source(1)
--- do CASE "indexes() error if primitive"
--- local state = source { 1 }
+ local ok = pcall(function()
+ effect(function()
+ coroutine.yield()
+ src()
+ end)
+ end)
--- local ok = pcall(function()
--- indexes(state, function() return 1 end)
--- end)
+ CHECK(not ok)
+ end
--- CHECK(not ok)
--- end
+ do CASE "run derived callback twice"
+ local src = source(1)
+ local count = 0
--- do CASE "values() error if duplicate"
--- local state = source { 1, 2, 1 }
+ local _ = derive(function()
+ count += 1
+ return src()
+ end)
--- local ok = pcall(function()
--- values(state, function() return {} end)
--- end)
+ CHECK(count == 2)
+ src(2)
+ CHECK(count == 4)
+ end
--- CHECK(not ok)
--- end
+ do CASE "run effect callback twice"
+ local src = source(1)
+ local count = 0
--- do CASE "duplicate properties"
--- local ok = pcall(function()
--- create "TextLabel" {
--- {
--- Name = "foo"
--- },
--- {
--- Name = "bar"
--- }
--- }
--- end)
+ effect(function()
+ count += 1
+ src()
+ end)
--- CHECK(not ok)
+ CHECK(count == 2)
+ src(2)
+ CHECK(count == 4)
+ end
--- ok = pcall(function()
--- create "TextLabel" {
--- {
--- Name = "foo",
--- {
--- Name = "bar"
--- }
--- }
--- }
--- end)
+ do CASE "indexes() error if primitive"
+ local src = source { 1 }
--- CHECK(ok)
--- end
+ local ok = pcall(function()
+ indexes(src, function() return 1 end)
+ end)
--- do CASE "multiple cleanup per scope"
--- local ok = pcall(function()
--- cleanup(function() end)
--- cleanup(function() end)
--- end)
+ CHECK(not ok)
+ end
--- CHECK(not ok)
--- end
--- end)
+ do CASE "values() error if duplicate"
+ local src = source { 1, 2, 1 }
+
+ local ok = pcall(function()
+ values(src, function() return {} end)
+ end)
+
+ CHECK(not ok)
+ end
+
+ do CASE "duplicate properties"
+ local ok = pcall(function()
+ create "TextLabel" {
+ {
+ Name = "foo"
+ },
+ {
+ Name = "bar"
+ }
+ }
+ end)
+
+ CHECK(not ok)
+
+ ok = pcall(function()
+ create "TextLabel" {
+ {
+ Name = "foo",
+ {
+ Name = "bar"
+ }
+ }
+ }
+ end)
+
+ CHECK(ok)
+ end
+end))
local ok = FINISH()
if not ok then error("Tests failed", 0) end
diff --git a/todo.md b/todo.md
index c2ef0ec..0d59b74 100644
--- a/todo.md
+++ b/todo.md
@@ -1,19 +1,13 @@
# todo
-- better error reporting and stack traces in strict mode
- auto-enable of strict mode depending on compiler optimizaton level
-- investigate if weak table iteration can be invalidated
- property binding optimization
- would no longer allow `cleanup()` usage in binding scopes
- solution to nested reactivity, see: SolidJS stores
-- SolidJS control flow components
-- equality checking of derived sources
- investigate performance of wide graphs
- optimize child removal
- implement from solid:
- - Show
- - Switch
- - Dynamic
- Portal
- batch
- optimize `indexes()` double-diffing
+- define behavior of deriving a source within a derived source